Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions forum/migration_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,15 @@ def create_or_update_thread(thread_data: dict[str, Any]) -> None:
anonymous_to_peers=thread_data.get("anonymous_to_peers", False),
closed=thread_data.get("closed", False),
pinned=thread_data.get("pinned"),
created_at=make_aware(thread_data["created_at"]),
updated_at=make_aware(thread_data["updated_at"]),
last_activity_at=make_aware(thread_data["last_activity_at"]),
commentable_id=thread_data.get("commentable_id"),
)
# Use QuerySet.update() to preserve original timestamps from MongoDB.
# This bypasses Django's auto_now_add and auto_now which ignore explicit values.
CommentThread.objects.filter(pk=thread.pk).update(
created_at=make_aware(thread_data["created_at"]),
updated_at=make_aware(thread_data["updated_at"]),
)
mongo_content.content_object_id = thread.pk
mongo_content.content_type = thread.content_type
mongo_content.save()
Expand Down Expand Up @@ -209,16 +213,19 @@ def create_or_update_comment(comment_data: dict[str, Any]) -> None:
anonymous_to_peers=comment_data.get("anonymous_to_peers", False),
endorsed=comment_data.get("endorsed", False),
child_count=comment_data.get("child_count", 0),
depth=1 if parent else 0,
)
sort_key = f"{parent.pk}-{comment.pk}" if parent else f"{comment.pk}"
# Use QuerySet.update() to preserve original timestamps from MongoDB
# and set sort_key. This bypasses Django's auto_now_add and auto_now.
Comment.objects.filter(pk=comment.pk).update(
created_at=make_aware(comment_data["created_at"]),
updated_at=make_aware(comment_data["updated_at"]),
depth=1 if parent else 0,
sort_key=sort_key,
)
mongo_comment.content_object_id = comment.pk
mongo_comment.content_type = comment.content_type
mongo_comment.save()
sort_key = f"{parent.pk}-{comment.pk}" if parent else f"{comment.pk}"
comment.sort_key = sort_key
comment.save()
else:
comment = Comment.objects.get(pk=mongo_comment.content_object_id)

Expand Down Expand Up @@ -347,15 +354,31 @@ def migrate_subscriptions(db: Database[dict[str, Any]], content_id: str) -> None
)
continue
if content:
Subscription.objects.update_or_create(
# Check if subscription already exists
existing_sub = Subscription.objects.filter(
subscriber=user,
source_content_type=content.content_type,
source_object_id=content.pk,
defaults={
"created_at": sub.get("created_at", timezone.now()),
"updated_at": sub.get("updated_at", timezone.now()),
},
)
).first()
if not existing_sub:
# Create new subscription
subscription = Subscription.objects.create(
subscriber=user,
source_content_type=content.content_type,
source_object_id=content.pk,
)
# Use QuerySet.update() to preserve original timestamps from MongoDB.
# This bypasses Django's auto_now_add and auto_now.
Subscription.objects.filter(pk=subscription.pk).update(
created_at=sub.get("created_at", timezone.now()),
updated_at=sub.get("updated_at", timezone.now()),
)
else:
# Update existing subscription timestamps using QuerySet.update()
Subscription.objects.filter(pk=existing_sub.pk).update(
created_at=sub.get("created_at", timezone.now()),
updated_at=sub.get("updated_at", timezone.now()),
)


def migrate_read_states(db: Database[dict[str, Any]], course_id: str) -> None:
Expand Down
139 changes: 139 additions & 0 deletions tests/test_management/test_commands/test_migration_commands.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test forum mongodb migration commands."""

from io import StringIO
from datetime import timedelta
from typing import Any

import pytest
Expand Down Expand Up @@ -946,3 +947,141 @@ def test_migrate_comment_fallback_to_current_username(
mongo_comment = MongoContent.objects.get(mongo_id=comment_id)
comment = Comment.objects.get(pk=mongo_comment.content_object_id)
assert comment.author_username == "current_username"


def test_migrate_preserves_timestamps(patched_mongodb: Database[Any]) -> None:
"""Test that timestamps are preserved during migration (regression test for issue #261).

This test verifies that when migrating content from MongoDB to MySQL,
the original timestamps (created_at, updated_at, last_activity_at) are
preserved and not overwritten with the migration time.
"""

comment_thread_id = ObjectId()
comment_id = ObjectId()

# Create timestamps that are significantly in the past (30 days ago)
# This ensures we can clearly distinguish between preserved timestamps
# and timestamps that would be set to "now" during migration
now = timezone.now()
original_created_at = now - timedelta(days=30)
original_updated_at = now - timedelta(days=15)
original_last_activity_at = now - timedelta(days=10)
original_subscription_created_at = now - timedelta(days=25)
original_subscription_updated_at = now - timedelta(days=5)

patched_mongodb.users.insert_one(
{
"_id": "1",
"username": "testuser",
"default_sort_key": "date",
"course_stats": [{"course_id": "test_course"}],
}
)
patched_mongodb.contents.insert_many(
[
{
"_id": comment_thread_id,
"_type": "CommentThread",
"author_id": "1",
"course_id": "test_course",
"title": "Test Thread",
"body": "Test body",
"created_at": original_created_at,
"updated_at": original_updated_at,
"last_activity_at": original_last_activity_at,
"votes": {"up": [], "down": []},
"abuse_flaggers": [],
"historical_abuse_flaggers": [],
},
{
"_id": comment_id,
"_type": "Comment",
"author_id": "1",
"course_id": "test_course",
"body": "Test comment",
"created_at": original_created_at,
"updated_at": original_updated_at,
"comment_thread_id": comment_thread_id,
"votes": {"up": [], "down": []},
"abuse_flaggers": [],
"historical_abuse_flaggers": [],
"depth": 0,
"sk": f"{comment_id}",
},
]
)
patched_mongodb.subscriptions.insert_one(
{
"subscriber_id": "1",
"source_id": str(comment_thread_id),
"source_type": "CommentThread",
"source": {"course_id": "test_course"},
"created_at": original_subscription_created_at,
"updated_at": original_subscription_updated_at,
}
)

User.objects.create(id=1, username="testuser")
call_command("forum_migrate_course_from_mongodb_to_mysql", "test_course")

# The key assertion is that timestamps are NOT set to "now" during migration.
# We verify this by checking that the timestamps are within the expected
# date range (accounting for potential timezone/precision differences).
# If timestamps were being set to migration time, they would be ~30 days newer.

# Verify thread timestamps are preserved (within 1 second tolerance for precision)
mongo_thread = MongoContent.objects.get(mongo_id=comment_thread_id)
thread = CommentThread.objects.get(pk=mongo_thread.content_object_id)

# The timestamp should be approximately 30 days old, not "now"
thread_created_age = now - thread.created_at
assert thread_created_age > timedelta(days=29), (
f"Thread created_at should be ~30 days ago, but is only {thread_created_age} ago. "
"Timestamps are not being preserved during migration!"
)

thread_updated_age = now - thread.updated_at
assert thread_updated_age > timedelta(days=14), (
f"Thread updated_at should be ~15 days ago, but is only {thread_updated_age} ago. "
"Timestamps are not being preserved during migration!"
)

thread_last_activity_age = now - thread.last_activity_at
assert thread_last_activity_age > timedelta(days=9), (
f"Thread last_activity_at should be ~10 days ago, but is only {thread_last_activity_age} ago. "
"Timestamps are not being preserved during migration!"
)

# Verify comment timestamps are preserved
mongo_comment = MongoContent.objects.get(mongo_id=comment_id)
comment = Comment.objects.get(pk=mongo_comment.content_object_id)

comment_created_age = now - comment.created_at
assert comment_created_age > timedelta(days=29), (
f"Comment created_at should be ~30 days ago, but is only {comment_created_age} ago. "
"Timestamps are not being preserved during migration!"
)

comment_updated_age = now - comment.updated_at
assert comment_updated_age > timedelta(days=14), (
f"Comment updated_at should be ~15 days ago, but is only {comment_updated_age} ago. "
"Timestamps are not being preserved during migration!"
)

# Verify subscription timestamps are preserved
subscription = Subscription.objects.get(
subscriber_id=1, source_object_id=mongo_thread.content_object_id
)

subscription_created_age = now - subscription.created_at
assert subscription_created_age > timedelta(days=24), (
f"Subscription created_at should be ~25 days ago, but is only {subscription_created_age} ago. "
"Timestamps are not being preserved during migration!"
)

subscription_updated_age = now - subscription.updated_at
assert subscription_updated_age > timedelta(days=4), (
f"Subscription updated_at should be ~5 days ago, but is only {subscription_updated_age} ago. "
"Timestamps are not being preserved during migration!"
)