Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix masterylog end timestamp issues #12870

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions kolibri/core/logger/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,9 @@ def _update_and_return_mastery_log_id(
else:
self._check_quiz_log_permissions(masterylog)
if update_fields:
if end_timestamp:
masterylog.end_timestamp = end_timestamp
update_fields += ("end_timestamp",)
masterylog.save(
update_fields=update_fields + ("_morango_dirty_bit",)
)
Expand Down
4 changes: 4 additions & 0 deletions kolibri/core/logger/test/test_integrated_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2145,6 +2145,7 @@ def setUp(self):
mastery_criterion=mastery_model,
summarylog=self.summary_log,
start_timestamp=self.summary_log.start_timestamp,
end_timestamp=self.summary_log.start_timestamp,
user=self.user,
mastery_level=1,
)
Expand Down Expand Up @@ -2175,6 +2176,9 @@ def test_update_assessment_session_update_time_delta_succeeds(self):
self.assertEqual(response.status_code, 200)
self.mastery_log.refresh_from_db()
self.assertEqual(self.mastery_log.time_spent, 5)
self.assertNotEqual(
self.mastery_log.end_timestamp, self.mastery_log.start_timestamp
)

def test_update_assessment_session_create_attempt_in_lesson_succeeds(self):
lesson = create_assigned_lesson_for_user(self.user)
Expand Down
109 changes: 109 additions & 0 deletions kolibri/core/logger/test/test_upgrades.py
Original file line number Diff line number Diff line change
@@ -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
)
35 changes: 35 additions & 0 deletions kolibri/core/logger/upgrade.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This just makes it so that the function below is only run when the current version is between these versions? Like if you are going from >0.15.0,<0.18.0 to anything else, this will run?

If so then what if you're upgrading from 0.15.2 to 0.17.3, then you upgrade from 0.17.3 to >=0.18.x would this end up being run again?

Not that it'd matter since it'd basically do nothing in that case since it'd have been fixed already, I'm just asking out of curiosity.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, except this code only exists in 0.18.0, so I would be mystified if it managed to run in 0.17.3 :)

def fix_masterylog_end_timestamps():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what would be now a KDP scenario?
There we have logs that have not run consolidate_quiz_attempt_logs mixed with logs that have it. And KDP identifies as 0.17.3, so it'd run fix_masterylog_end_timestamps

So, if I understand this correctly, in KDP we should either apply both upgrades, in order, or skip both. Correct?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should skip both, yes. Let the Kolibris sort out their data before syncing to KDP.

"""
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))
51 changes: 51 additions & 0 deletions kolibri/core/test/test_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ def test_filter_old_version():
unfiltered_mock.assert_called_once()


def test_filter_compound_old_version():
filtered_mock = Mock()
unfiltered_mock = Mock()

filtered = VersionUpgrade(old_version=">1.0.1,<1.1.1", upgrade=filtered_mock)
not_filtered = VersionUpgrade(upgrade=unfiltered_mock)
with patch(
"kolibri.core.upgrade.get_upgrades", return_value=[not_filtered, filtered]
):
run_upgrades("1.0.0", "1.1.2")
filtered_mock.assert_not_called()
unfiltered_mock.assert_called_once()


def test_not_filter_alpha_version():
unfiltered_mock = Mock()

Expand Down Expand Up @@ -59,6 +73,17 @@ def test_order_old_version():
function.assert_has_calls([call(0), call(1)])


def test_order_compound_old_version():
function = Mock()

first = VersionUpgrade(old_version=">0.9.1,<0.10.1", upgrade=lambda: function(0))
second = VersionUpgrade(upgrade=lambda: function(1))

with patch("kolibri.core.upgrade.get_upgrades", return_value=[second, first]):
run_upgrades("0.10.0", "1.1.2")
function.assert_has_calls([call(0), call(1)])


def test_order_new_version():
function = Mock()

Expand All @@ -70,6 +95,17 @@ def test_order_new_version():
function.assert_has_calls([call(0), call(1)])


def test_order_compound_new_version():
function = Mock()

first = VersionUpgrade(new_version=">0.10.1,<1.1.3", upgrade=lambda: function(0))
second = VersionUpgrade(upgrade=lambda: function(1))

with patch("kolibri.core.upgrade.get_upgrades", return_value=[second, first]):
run_upgrades("0.10.0", "1.1.2")
function.assert_has_calls([call(0), call(1)])


def test_order_old_and_new_version():
function = Mock()

Expand Down Expand Up @@ -100,6 +136,21 @@ def test_filter_new_version():
unfiltered_mock.assert_called_once()


def test_filter_compound_new_version():
filtered_mock = Mock()
unfiltered_mock = Mock()

filtered = VersionUpgrade(new_version=">1.1.1,<1.1.3", upgrade=filtered_mock)
not_filtered = VersionUpgrade(upgrade=unfiltered_mock)

with patch(
"kolibri.core.upgrade.get_upgrades", return_value=[not_filtered, filtered]
):
run_upgrades("1.0.1", "1.1.0")
filtered_mock.assert_not_called()
unfiltered_mock.assert_called_once()


def test_blank_old_version():
function = Mock()

Expand Down
7 changes: 2 additions & 5 deletions kolibri/core/upgrade.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from importlib import import_module

from django.apps import apps
from semver import match
from semver import VersionInfo

import kolibri
from kolibri.utils.version import get_version_and_operator_from_range
from kolibri.utils.version import normalize_version_to_semver
from kolibri.utils.version import version_matches_range


CURRENT_VERSION = VersionInfo.parse(normalize_version_to_semver(kolibri.__version__))
Expand Down Expand Up @@ -110,10 +110,7 @@ def wrapper(upgrade):
def matches_version(version, version_range):
if version_range is None or not version:
return True
# For the purposes of upgrade comparison, treat dev versions as alphas
version = normalize_version_to_semver(version).replace("dev", "a")
version_range = "".join(get_version_and_operator_from_range(version_range))
return match(version, version_range)
return version_matches_range(version, version_range)


def get_upgrades(app_configs=None):
Expand Down
Loading