diff --git a/kolibri/core/logger/test/test_upgrades.py b/kolibri/core/logger/test/test_upgrades.py new file mode 100644 index 00000000000..07bf6c769fd --- /dev/null +++ b/kolibri/core/logger/test/test_upgrades.py @@ -0,0 +1,109 @@ +from uuid import uuid4 + +from django.test import TestCase +from django.utils import timezone + +from kolibri.core.auth.models import Facility +from kolibri.core.auth.models import FacilityUser +from kolibri.core.logger.models import AttemptLog +from kolibri.core.logger.models import ContentSessionLog +from kolibri.core.logger.models import ContentSummaryLog +from kolibri.core.logger.models import MasteryLog +from kolibri.core.logger.upgrade import fix_masterylog_end_timestamps + + +class MasteryLogEndTimestampUpgradeTest(TestCase): + def setUp(self): + self.facility = Facility.objects.create() + self.user = FacilityUser.objects.create( + username="learner", facility=self.facility + ) + now = timezone.now() + + # Create base content summary log + self.summary_log = ContentSummaryLog.objects.create( + user=self.user, + content_id=uuid4().hex, + channel_id=uuid4().hex, + kind="exercise", + start_timestamp=now, + end_timestamp=now + timezone.timedelta(minutes=10), + ) + + # Case 1: MasteryLog with attempts + self.attempt_session = ContentSessionLog.objects.create( + user=self.user, + content_id=self.summary_log.content_id, + channel_id=self.summary_log.channel_id, + kind="exercise", + start_timestamp=now, + end_timestamp=now + timezone.timedelta(minutes=3), + ) + + self.attempt_mastery = MasteryLog.objects.create( + user=self.user, + summarylog=self.summary_log, + mastery_level=2, + start_timestamp=now, + end_timestamp=now, + ) + + AttemptLog.objects.create( + masterylog=self.attempt_mastery, + sessionlog=self.attempt_session, + start_timestamp=now, + end_timestamp=now - timezone.timedelta(minutes=3), + complete=True, + correct=1, + ) + + AttemptLog.objects.create( + masterylog=self.attempt_mastery, + sessionlog=self.attempt_session, + start_timestamp=now, + end_timestamp=now - timezone.timedelta(minutes=2), + complete=True, + correct=1, + ) + + self.last_attempt = AttemptLog.objects.create( + masterylog=self.attempt_mastery, + sessionlog=self.attempt_session, + start_timestamp=now, + end_timestamp=now + timezone.timedelta(minutes=3), + complete=True, + correct=1, + ) + + # Case 2: MasteryLog with only summary log + self.summary_session = ContentSessionLog.objects.create( + user=self.user, + content_id=self.summary_log.content_id, + channel_id=self.summary_log.channel_id, + kind="exercise", + start_timestamp=now, + end_timestamp=now, + ) + self.summary_only_mastery = MasteryLog.objects.create( + user=self.user, + summarylog=self.summary_log, + mastery_level=3, + start_timestamp=now, + end_timestamp=now, + ) + + fix_masterylog_end_timestamps() + + def test_attempt_logs_case(self): + """Test MasteryLog with attempt logs gets end_timestamp from last attempt""" + self.attempt_mastery.refresh_from_db() + self.assertEqual( + self.attempt_mastery.end_timestamp, self.last_attempt.end_timestamp + ) + + def test_summary_log_case(self): + """Test MasteryLog with only summary log gets end_timestamp from summary""" + self.summary_only_mastery.refresh_from_db() + self.assertEqual( + self.summary_only_mastery.end_timestamp, self.summary_log.end_timestamp + ) diff --git a/kolibri/core/logger/upgrade.py b/kolibri/core/logger/upgrade.py index 7698129c584..f049546bc9e 100644 --- a/kolibri/core/logger/upgrade.py +++ b/kolibri/core/logger/upgrade.py @@ -1,9 +1,15 @@ """ A file to contain specific logic to handle version upgrades in Kolibri. """ +from django.db.models import F +from django.db.models import Max +from django.db.models import OuterRef +from django.db.models import Subquery + from kolibri.core.logger.models import AttemptLog from kolibri.core.logger.models import ContentSummaryLog from kolibri.core.logger.models import ExamLog +from kolibri.core.logger.models import MasteryLog from kolibri.core.logger.utils.attempt_log_consolidation import ( consolidate_quiz_attempt_logs, ) @@ -57,3 +63,32 @@ def fix_duplicated_attempt_logs(): item and non-null masterylog_id. """ consolidate_quiz_attempt_logs(AttemptLog.objects.all()) + + +@version_upgrade(old_version=">0.15.0,<0.18.0") +def fix_masterylog_end_timestamps(): + """ + Fix any MasteryLogs that have an end_timestamp that was not updated after creation due to a bug in the + integrated logging API endpoint. + """ + # Fix the MasteryLogs that that have attempts - infer from the end_timestamp of the last attempt. + attempt_subquery = ( + AttemptLog.objects.filter(masterylog=OuterRef("pk")) + .values("masterylog") + .annotate(max_end=Max("end_timestamp")) + .values("max_end") + ) + + MasteryLog.objects.filter( + end_timestamp=F("start_timestamp"), attemptlogs__isnull=False + ).update(end_timestamp=Subquery(attempt_subquery)) + # Fix the MasteryLogs that don't have any attempts - just set the end_timestamp to the end_timestamp of the summary log. + summary_subquery = ContentSummaryLog.objects.filter( + masterylogs=OuterRef("pk") + ).values("end_timestamp") + + MasteryLog.objects.filter( + end_timestamp=F("start_timestamp"), + completion_timestamp__isnull=True, + attemptlogs__isnull=True, + ).update(end_timestamp=Subquery(summary_subquery))