From 08ca4c16f97050c93522c8f9b88e0a3c3eed10d1 Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Mon, 9 Feb 2026 14:16:02 +0200 Subject: [PATCH 1/5] feat: implement get_user_dates() method to get all user dates for a course --- edx_when/api.py | 59 +++++- edx_when/tests/__init__.py | 0 edx_when/tests/test_api.py | 378 +++++++++++++++++++++++++++++++++++++ 3 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 edx_when/tests/__init__.py create mode 100644 edx_when/tests/test_api.py diff --git a/edx_when/api.py b/edx_when/api.py index e3e9b87d..4197ae1c 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.db import transaction -from django.db.models import DateTimeField, ExpressionWrapper, F, ObjectDoesNotExist, Q +from django.db.models import DateTimeField, ExpressionWrapper, F, ObjectDoesNotExist, Prefetch, Q from edx_django_utils.cache.utils import TieredCache from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey @@ -548,6 +548,63 @@ def get_schedules_with_due_date(course_id, assignment_date): return schedules +def get_user_dates(course_id, user_id, block_types=None, block_keys=None, date_types=None): + """ + Get all user dates for a course with optional filters. + + Arguments: + course_id: either a CourseKey or string representation of same + user_id: User ID + block_types: optional list of block types to filter by (e.g., ['sequential', 'vertical']) + block_keys: optional list of UsageKey objects or strings to filter by + date_types: optional list of date field types to filter by (e.g., ['due', 'start', 'end']) + + Returns: + dict with keys as (location, field) tuples and values as date objects + User overrides take priority over content defaults + """ + course_id = _ensure_key(CourseKey, course_id) + + content_dates_query = models.ContentDate.objects.filter( + course_id=course_id, + active=True, + ).select_related('policy') + + # Apply filters + if block_types: + content_dates_query = content_dates_query.filter(block_type__in=block_types) + + if block_keys: + normalized_keys = [_ensure_key(UsageKey, key) for key in block_keys] + content_dates_query = content_dates_query.filter(location__in=normalized_keys) + + if date_types: + content_dates_query = content_dates_query.filter(field__in=date_types) + + content_dates_query = content_dates_query.prefetch_related( + Prefetch( + 'userdate_set', + queryset=models.UserDate.objects.filter(user_id=user_id).order_by('-modified'), + to_attr='user_overrides' + ) + ) + + dates = {} + + for content_date in content_dates_query: + key = (content_date.location, content_date.field) + + if content_date.user_overrides: + dates[key] = content_date.user_overrides[0].actual_date + else: + try: + dates[key] = content_date.policy.actual_date() + except (AttributeError, models.MissingScheduleError): + continue + + return dates + + class BaseWhenException(Exception): pass diff --git a/edx_when/tests/__init__.py b/edx_when/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edx_when/tests/test_api.py b/edx_when/tests/test_api.py new file mode 100644 index 00000000..1bfb39a9 --- /dev/null +++ b/edx_when/tests/test_api.py @@ -0,0 +1,378 @@ +""" +Test cases for the api module of edx-when. +""" +from datetime import datetime, timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey + +from edx_when import api, models + +User = get_user_model() + + +class TestGetUserDates(TestCase): + """ + Test cases for the get_user_dates API function. + """ + + def test_get_user_dates_basic(self): + """ + Test basic functionality of get_user_dates. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id) + + expected_key = (block_key, 'due') + self.assertIn(expected_key, result) + self.assertEqual(result[expected_key], datetime(2023, 1, 15, 10, 0, 0)) + + def test_get_user_dates_with_user_overrides(self): + """ + Test get_user_dates with user overrides taking priority. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + content_date = models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + user = User.objects.create(username='testuser', id=user_id) + models.UserDate.objects.create( + user=user, + content_date=content_date, + abs_date=datetime(2023, 1, 20, 10, 0, 0) + ) + + result = api.get_user_dates(course_id, user_id) + + expected_key = (block_key, 'due') + self.assertIn(expected_key, result) + self.assertEqual(result[expected_key], datetime(2023, 1, 20, 10, 0, 0)) + + def test_get_user_dates_with_block_type_filter(self): + """ + Test get_user_dates with block type filtering. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + seq_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') + seq_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=seq_key, + field='due', + active=True, + policy=seq_policy, + block_type='sequential' + ) + + seq_2_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq2') + seq_2_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=seq_2_key, + field='due', + active=True, + policy=seq_2_policy, + block_type='vertical' + ) + + result = api.get_user_dates(course_id, user_id, block_types=['sequential']) + + self.assertEqual(len(result), 1) + self.assertIn((seq_key, 'due'), result) + self.assertNotIn((seq_2_key, 'due'), result) + + def test_get_user_dates_with_block_keys_filter(self): + """ + Test get_user_dates with specific block keys filtering. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block1_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') + block1_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block1_key, + field='due', + active=True, + policy=block1_policy, + block_type='sequential' + ) + + block2_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq2') + block2_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block2_key, + field='due', + active=True, + policy=block2_policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id, block_keys=[block1_key]) + + self.assertEqual(len(result), 1) + self.assertIn((block1_key, 'due'), result) + self.assertNotIn((block2_key, 'due'), result) + + def test_get_user_dates_with_date_types_filter(self): + """ + Test get_user_dates with date type filtering. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=due_policy, + block_type='sequential' + ) + + start_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 10, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='start', + active=True, + policy=start_policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id, date_types=['due']) + + self.assertEqual(len(result), 1) + self.assertIn((block_key, 'due'), result) + self.assertNotIn((block_key, 'start'), result) + + def test_get_user_dates_multiple_filters(self): + """ + Test get_user_dates with multiple filters combined. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + seq_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') + vert_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@vertical+block@vert1') + + seq_due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=seq_key, + field='due', + active=True, + policy=seq_due_policy, + block_type='sequential' + ) + + seq_start_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 10, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=seq_key, + field='start', + active=True, + policy=seq_start_policy, + block_type='sequential' + ) + + vert_due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=vert_key, + field='due', + active=True, + policy=vert_due_policy, + block_type='vertical' + ) + + result = api.get_user_dates( + course_id, user_id, + block_types=['sequential'], + date_types=['due'] + ) + + self.assertEqual(len(result), 1) + self.assertIn((seq_key, 'due'), result) + self.assertNotIn((seq_key, 'start'), result) + self.assertNotIn((vert_key, 'due'), result) + + def test_get_user_dates_inactive_dates_excluded(self): + """ + Test that inactive content dates are excluded. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=False, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id) + self.assertEqual(len(result), 0) + + def test_get_user_dates_missing_schedule_error_handled(self): + """ + Test that MissingScheduleError is handled gracefully. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id) + self.assertEqual(len(result), 0) + + def test_get_user_dates_string_course_key(self): + """ + Test get_user_dates with string course key. + """ + course_id_str = 'course-v1:TestX+Test+2023' + course_id = CourseKey.from_string(course_id_str) + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id_str, user_id) + + expected_key = (block_key, 'due') + self.assertIn(expected_key, result) + + def test_get_user_dates_string_block_keys(self): + """ + Test get_user_dates with string block keys in filter. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key_str = 'block-v1:TestX+Test+2023+type@sequential+block@test' + block_key = UsageKey.from_string(block_key_str) + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id, block_keys=[block_key_str]) + + expected_key = (block_key, 'due') + self.assertIn(expected_key, result) + + def test_get_user_dates_empty_result(self): + """ + Test get_user_dates with no matching dates. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + result = api.get_user_dates(course_id, user_id) + + self.assertEqual(result, {}) + + def test_get_user_dates_latest_user_override(self): + """ + Test that the latest user override is used when multiple exist. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + content_date = models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + user = User.objects.create(username='testuser', id=user_id) + + older_override = models.UserDate.objects.create( + user=user, + content_date=content_date, + abs_date=datetime(2023, 1, 20, 10, 0, 0) + ) + older_override.modified = datetime(2023, 1, 1, 10, 0, 0) + older_override.save() + + newer_override = models.UserDate.objects.create( + user=user, + content_date=content_date, + abs_date=datetime(2023, 1, 25, 10, 0, 0) + ) + newer_override.modified = datetime(2023, 1, 2, 10, 0, 0) + newer_override.save() + + result = api.get_user_dates(course_id, user_id) + + expected_key = (block_key, 'due') + self.assertEqual(result[expected_key], datetime(2023, 1, 25, 10, 0, 0)) From 4a63da50360ec46d9c318f28926b7098061a9713 Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Thu, 12 Mar 2026 02:06:50 +0200 Subject: [PATCH 2/5] refactor: move tests into another folder, add overrides check in tests --- edx_when/tests/__init__.py | 0 edx_when/tests/test_api.py | 378 ------------------------------------- tests/test_api.py | 371 ++++++++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+), 378 deletions(-) delete mode 100644 edx_when/tests/__init__.py delete mode 100644 edx_when/tests/test_api.py diff --git a/edx_when/tests/__init__.py b/edx_when/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/edx_when/tests/test_api.py b/edx_when/tests/test_api.py deleted file mode 100644 index 1bfb39a9..00000000 --- a/edx_when/tests/test_api.py +++ /dev/null @@ -1,378 +0,0 @@ -""" -Test cases for the api module of edx-when. -""" -from datetime import datetime, timedelta - -from django.contrib.auth import get_user_model -from django.test import TestCase -from opaque_keys.edx.keys import CourseKey, UsageKey - -from edx_when import api, models - -User = get_user_model() - - -class TestGetUserDates(TestCase): - """ - Test cases for the get_user_dates API function. - """ - - def test_get_user_dates_basic(self): - """ - Test basic functionality of get_user_dates. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') - - policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='due', - active=True, - policy=policy, - block_type='sequential' - ) - - result = api.get_user_dates(course_id, user_id) - - expected_key = (block_key, 'due') - self.assertIn(expected_key, result) - self.assertEqual(result[expected_key], datetime(2023, 1, 15, 10, 0, 0)) - - def test_get_user_dates_with_user_overrides(self): - """ - Test get_user_dates with user overrides taking priority. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') - - policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - content_date = models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='due', - active=True, - policy=policy, - block_type='sequential' - ) - - user = User.objects.create(username='testuser', id=user_id) - models.UserDate.objects.create( - user=user, - content_date=content_date, - abs_date=datetime(2023, 1, 20, 10, 0, 0) - ) - - result = api.get_user_dates(course_id, user_id) - - expected_key = (block_key, 'due') - self.assertIn(expected_key, result) - self.assertEqual(result[expected_key], datetime(2023, 1, 20, 10, 0, 0)) - - def test_get_user_dates_with_block_type_filter(self): - """ - Test get_user_dates with block type filtering. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - seq_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') - seq_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=seq_key, - field='due', - active=True, - policy=seq_policy, - block_type='sequential' - ) - - seq_2_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq2') - seq_2_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=seq_2_key, - field='due', - active=True, - policy=seq_2_policy, - block_type='vertical' - ) - - result = api.get_user_dates(course_id, user_id, block_types=['sequential']) - - self.assertEqual(len(result), 1) - self.assertIn((seq_key, 'due'), result) - self.assertNotIn((seq_2_key, 'due'), result) - - def test_get_user_dates_with_block_keys_filter(self): - """ - Test get_user_dates with specific block keys filtering. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - block1_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') - block1_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=block1_key, - field='due', - active=True, - policy=block1_policy, - block_type='sequential' - ) - - block2_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq2') - block2_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=block2_key, - field='due', - active=True, - policy=block2_policy, - block_type='sequential' - ) - - result = api.get_user_dates(course_id, user_id, block_keys=[block1_key]) - - self.assertEqual(len(result), 1) - self.assertIn((block1_key, 'due'), result) - self.assertNotIn((block2_key, 'due'), result) - - def test_get_user_dates_with_date_types_filter(self): - """ - Test get_user_dates with date type filtering. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') - - due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='due', - active=True, - policy=due_policy, - block_type='sequential' - ) - - start_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 10, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='start', - active=True, - policy=start_policy, - block_type='sequential' - ) - - result = api.get_user_dates(course_id, user_id, date_types=['due']) - - self.assertEqual(len(result), 1) - self.assertIn((block_key, 'due'), result) - self.assertNotIn((block_key, 'start'), result) - - def test_get_user_dates_multiple_filters(self): - """ - Test get_user_dates with multiple filters combined. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - seq_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') - vert_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@vertical+block@vert1') - - seq_due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=seq_key, - field='due', - active=True, - policy=seq_due_policy, - block_type='sequential' - ) - - seq_start_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 10, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=seq_key, - field='start', - active=True, - policy=seq_start_policy, - block_type='sequential' - ) - - vert_due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=vert_key, - field='due', - active=True, - policy=vert_due_policy, - block_type='vertical' - ) - - result = api.get_user_dates( - course_id, user_id, - block_types=['sequential'], - date_types=['due'] - ) - - self.assertEqual(len(result), 1) - self.assertIn((seq_key, 'due'), result) - self.assertNotIn((seq_key, 'start'), result) - self.assertNotIn((vert_key, 'due'), result) - - def test_get_user_dates_inactive_dates_excluded(self): - """ - Test that inactive content dates are excluded. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') - - policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='due', - active=False, - policy=policy, - block_type='sequential' - ) - - result = api.get_user_dates(course_id, user_id) - self.assertEqual(len(result), 0) - - def test_get_user_dates_missing_schedule_error_handled(self): - """ - Test that MissingScheduleError is handled gracefully. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') - - policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) - models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='due', - active=True, - policy=policy, - block_type='sequential' - ) - - result = api.get_user_dates(course_id, user_id) - self.assertEqual(len(result), 0) - - def test_get_user_dates_string_course_key(self): - """ - Test get_user_dates with string course key. - """ - course_id_str = 'course-v1:TestX+Test+2023' - course_id = CourseKey.from_string(course_id_str) - user_id = 123 - - block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') - - policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='due', - active=True, - policy=policy, - block_type='sequential' - ) - - result = api.get_user_dates(course_id_str, user_id) - - expected_key = (block_key, 'due') - self.assertIn(expected_key, result) - - def test_get_user_dates_string_block_keys(self): - """ - Test get_user_dates with string block keys in filter. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - block_key_str = 'block-v1:TestX+Test+2023+type@sequential+block@test' - block_key = UsageKey.from_string(block_key_str) - - policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='due', - active=True, - policy=policy, - block_type='sequential' - ) - - result = api.get_user_dates(course_id, user_id, block_keys=[block_key_str]) - - expected_key = (block_key, 'due') - self.assertIn(expected_key, result) - - def test_get_user_dates_empty_result(self): - """ - Test get_user_dates with no matching dates. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - result = api.get_user_dates(course_id, user_id) - - self.assertEqual(result, {}) - - def test_get_user_dates_latest_user_override(self): - """ - Test that the latest user override is used when multiple exist. - """ - course_id = CourseKey.from_string('course-v1:TestX+Test+2023') - user_id = 123 - - block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') - - policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) - content_date = models.ContentDate.objects.create( - course_id=course_id, - location=block_key, - field='due', - active=True, - policy=policy, - block_type='sequential' - ) - - user = User.objects.create(username='testuser', id=user_id) - - older_override = models.UserDate.objects.create( - user=user, - content_date=content_date, - abs_date=datetime(2023, 1, 20, 10, 0, 0) - ) - older_override.modified = datetime(2023, 1, 1, 10, 0, 0) - older_override.save() - - newer_override = models.UserDate.objects.create( - user=user, - content_date=content_date, - abs_date=datetime(2023, 1, 25, 10, 0, 0) - ) - newer_override.modified = datetime(2023, 1, 2, 10, 0, 0) - newer_override.save() - - result = api.get_user_dates(course_id, user_id) - - expected_key = (block_key, 'due') - self.assertEqual(result[expected_key], datetime(2023, 1, 25, 10, 0, 0)) diff --git a/tests/test_api.py b/tests/test_api.py index a4591ab2..68d6f43f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,6 +11,7 @@ from django.test import TestCase from django.urls import reverse from edx_django_utils.cache.utils import RequestCache, TieredCache +from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import CourseLocator from edx_when import api, models @@ -721,6 +722,376 @@ def test_api_view(self): self.assertEqual(response.status_code, 403) +class TestGetUserDates(TestCase): + """ + Test cases for the get_user_dates API function. + """ + + def test_get_user_dates_basic(self): + """ + Test basic functionality of get_user_dates. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id) + + expected_key = (block_key, 'due') + self.assertIn(expected_key, result) + self.assertEqual(result[expected_key], datetime(2023, 1, 15, 10, 0, 0)) + + def test_get_user_dates_with_user_overrides(self): + """ + Test get_user_dates with user overrides taking priority. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + content_date = models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + user = User.objects.create(username='testuser', id=user_id) + models.UserDate.objects.create( + user=user, + content_date=content_date, + abs_date=datetime(2023, 1, 20, 10, 0, 0) + ) + + result = api.get_user_dates(course_id, user_id) + + expected_key = (block_key, 'due') + self.assertIn(expected_key, result) + self.assertEqual(result[expected_key], datetime(2023, 1, 20, 10, 0, 0)) + + def test_get_user_dates_with_block_type_filter(self): + """ + Test get_user_dates with block type filtering. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + seq_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') + seq_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=seq_key, + field='due', + active=True, + policy=seq_policy, + block_type='sequential' + ) + + seq_2_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq2') + seq_2_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=seq_2_key, + field='due', + active=True, + policy=seq_2_policy, + block_type='vertical' + ) + + result = api.get_user_dates(course_id, user_id, block_types=['sequential']) + + self.assertEqual(len(result), 1) + self.assertIn((seq_key, 'due'), result) + self.assertNotIn((seq_2_key, 'due'), result) + + def test_get_user_dates_with_block_keys_filter(self): + """ + Test get_user_dates with specific block keys filtering. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block1_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') + block1_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block1_key, + field='due', + active=True, + policy=block1_policy, + block_type='sequential' + ) + + block2_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq2') + block2_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block2_key, + field='due', + active=True, + policy=block2_policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id, block_keys=[block1_key]) + + self.assertEqual(len(result), 1) + self.assertIn((block1_key, 'due'), result) + self.assertNotIn((block2_key, 'due'), result) + + def test_get_user_dates_with_date_types_filter(self): + """ + Test get_user_dates with date type filtering. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=due_policy, + block_type='sequential' + ) + + start_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 10, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='start', + active=True, + policy=start_policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id, date_types=['due']) + + self.assertEqual(len(result), 1) + self.assertIn((block_key, 'due'), result) + self.assertNotIn((block_key, 'start'), result) + + def test_get_user_dates_multiple_filters(self): + """ + Test get_user_dates with multiple filters combined. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + seq_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@seq1') + vert_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@vertical+block@vert1') + + seq_due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=seq_key, + field='due', + active=True, + policy=seq_due_policy, + block_type='sequential' + ) + + seq_start_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 10, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=seq_key, + field='start', + active=True, + policy=seq_start_policy, + block_type='sequential' + ) + + vert_due_policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=vert_key, + field='due', + active=True, + policy=vert_due_policy, + block_type='vertical' + ) + + result = api.get_user_dates( + course_id, user_id, + block_types=['sequential'], + date_types=['due'] + ) + + self.assertEqual(len(result), 1) + self.assertIn((seq_key, 'due'), result) + self.assertNotIn((seq_key, 'start'), result) + self.assertNotIn((vert_key, 'due'), result) + + def test_get_user_dates_inactive_dates_excluded(self): + """ + Test that inactive content dates are excluded. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=False, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id) + self.assertEqual(len(result), 0) + + def test_get_user_dates_missing_schedule_error_handled(self): + """ + Test that MissingScheduleError is handled gracefully. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id) + self.assertEqual(len(result), 0) + + def test_get_user_dates_string_course_key(self): + """ + Test get_user_dates with string course key. + """ + course_id_str = 'course-v1:TestX+Test+2023' + course_id = CourseKey.from_string(course_id_str) + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id_str, user_id) + + expected_key = (block_key, 'due') + self.assertIn(expected_key, result) + + def test_get_user_dates_string_block_keys(self): + """ + Test get_user_dates with string block keys in filter. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key_str = 'block-v1:TestX+Test+2023+type@sequential+block@test' + block_key = UsageKey.from_string(block_key_str) + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + result = api.get_user_dates(course_id, user_id, block_keys=[block_key_str]) + + expected_key = (block_key, 'due') + self.assertIn(expected_key, result) + + def test_get_user_dates_empty_result(self): + """ + Test get_user_dates with no matching dates. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + result = api.get_user_dates(course_id, user_id) + + self.assertEqual(result, {}) + + def test_get_user_dates_latest_user_override(self): + """ + Test that the latest user override is used when multiple exist. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + content_date = models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + user = User.objects.create(username='testuser', id=user_id) + + older_override = models.UserDate.objects.create( + user=user, + content_date=content_date, + abs_date=datetime(2023, 1, 20, 10, 0, 0) + ) + older_override.modified = datetime(2023, 1, 1, 10, 0, 0) + older_override.save() + + newer_override = models.UserDate.objects.create( + user=user, + content_date=content_date, + abs_date=datetime(2023, 1, 25, 10, 0, 0) + ) + newer_override.modified = datetime(2023, 1, 2, 10, 0, 0) + newer_override.save() + + older_override.refresh_from_db() + newer_override.refresh_from_db() + self.assertLess(older_override.modified, newer_override.modified) + + result = api.get_user_dates(course_id, user_id) + + expected_key = (block_key, 'due') + self.assertEqual(result[expected_key], datetime(2023, 1, 25, 10, 0, 0)) + + class ApiWaffleTests(TestCase): """ Tests for edx_when.api waffle usage. From b86734d718b27bdce8287f5703a90a4485f35c6b Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Mon, 23 Mar 2026 23:46:55 +0200 Subject: [PATCH 3/5] refactor: implement _resolve_policy_dates function --- edx_when/api.py | 80 ++++++++++++++---- tests/test_api.py | 208 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 15 deletions(-) diff --git a/edx_when/api.py b/edx_when/api.py index 4197ae1c..0037b856 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -137,6 +137,28 @@ def _get_end_dates_from_content_dates(qset): return end_datetime, cutoff_datetime +def _resolve_policy_dates(content_dates, schedule=None, end_datetime=None, cutoff_datetime=None): + """ + Resolve ContentDate objects to their policy-derived datetimes. + + Passes schedule/end/cutoff so relative dates (self-paced courses) are included. + Silently skips entries that require a schedule when none is available. + + Returns: + dict where keys are (location, field) tuples and values are datetime objects, + representing policy-resolved dates. + """ + dates = {} + for cdate in content_dates: + try: + dates[(cdate.location, cdate.field)] = cdate.policy.actual_date( + schedule, end_datetime, cutoff_datetime + ) + except models.MissingScheduleError: + pass + return dates + + def _processed_results_cache_key( course_id, user_id, schedule, allow_relative_dates, subsection_and_higher_only, published_version @@ -581,30 +603,58 @@ def get_user_dates(course_id, user_id, block_types=None, block_keys=None, date_t if date_types: content_dates_query = content_dates_query.filter(field__in=date_types) - content_dates_query = content_dates_query.prefetch_related( - Prefetch( - 'userdate_set', - queryset=models.UserDate.objects.filter(user_id=user_id).order_by('-modified'), - to_attr='user_overrides' + content_dates = list( + content_dates_query.prefetch_related( + Prefetch( + 'userdate_set', + queryset=models.UserDate.objects.filter(user_id=user_id).order_by('-modified'), + to_attr='user_overrides' + ) ) ) - dates = {} - - for content_date in content_dates_query: - key = (content_date.location, content_date.field) + schedule = get_schedule_for_user(user_id, course_id) + end_datetime, cutoff_datetime = _get_end_dates_from_content_dates(content_dates) + dates = _resolve_policy_dates(content_dates, schedule, end_datetime, cutoff_datetime) + for content_date in content_dates: if content_date.user_overrides: - dates[key] = content_date.user_overrides[0].actual_date - else: - try: - dates[key] = content_date.policy.actual_date() - except (AttributeError, models.MissingScheduleError): - continue + dates[(content_date.location, content_date.field)] = content_date.user_overrides[0].actual_date return dates +def get_existing_due_locations(course_key): + """ + Return the set of block locations that already have an active 'due' ContentDate for the given course. + + Arguments: + course_key: either a CourseKey or string representation of same + + Returns: + set of UsageKey objects representing blocks with existing due dates + """ + course_key = _ensure_key(CourseKey, course_key) + return set( + models.ContentDate.objects.filter(course_id=course_key, field='due', active=True) + .values_list('location', flat=True) + ) + + +def update_or_create_assignments_due_dates(course_key, assignments): + """ + Create or update ContentDate entries for a list of assignment objects. + + Arguments: + course_key: either a CourseKey or string representation of same + assignments: iterable of objects with attributes ``block_key`` (UsageKey) and ``date`` (datetime) + """ + course_key = _ensure_key(CourseKey, course_key) + for assignment in assignments: + if assignment.date is not None: + set_date_for_block(course_key, assignment.block_key, 'due', assignment.date) + + class BaseWhenException(Exception): pass diff --git a/tests/test_api.py b/tests/test_api.py index 68d6f43f..852c342a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1118,3 +1118,211 @@ def test_relative_dates_disabled(self): @patch.dict(sys.modules, {'openedx.features.course_experience': None}) def test_relative_dates_import_error(self): assert not api._are_relative_dates_enabled() # pylint: disable=protected-access + + +class TestResolvePolicyDates(TestCase): + """ + Tests for the _resolve_policy_dates helper. + """ + + def setUp(self): + super().setUp() + self.course_key = CourseLocator('testX', 'tt101', '2019') + + def test_absolute_date(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15)) + content_date = models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='due', + active=True, policy=policy, block_type='sequential' + ) + + result = api._resolve_policy_dates([content_date]) # pylint: disable=protected-access + + assert result == {(block_key, 'due'): datetime(2023, 1, 15)} + + def test_relative_date_with_schedule(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) + content_date = models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='due', + active=True, policy=policy, block_type='sequential' + ) + schedule = Mock(start_date=datetime(2023, 1, 1), created=datetime(2023, 1, 1)) + + result = api._resolve_policy_dates([content_date], schedule=schedule) # pylint: disable=protected-access + + assert result == {(block_key, 'due'): datetime(2023, 1, 8)} + + def test_relative_date_without_schedule_skipped(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) + content_date = models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='due', + active=True, policy=policy, block_type='sequential' + ) + + result = api._resolve_policy_dates([content_date]) # pylint: disable=protected-access + + assert not result + + def test_empty_content_dates(self): + result = api._resolve_policy_dates([]) # pylint: disable=protected-access + assert not result + + +class TestGetUserDatesSelfPaced(TestCase): + """ + Tests for get_user_dates, focusing on schedule-driven (self-paced) behaviour. + """ + + def setUp(self): + super().setUp() + self.course_key = CourseLocator('testX', 'tt101', '2019') + self.user = User(username='tester', email='tester@test.com') + self.user.save() + + dummy_schedule_patcher = patch('edx_when.utils.Schedule', DummySchedule) + dummy_schedule_patcher.start() + self.addCleanup(dummy_schedule_patcher.stop) + self.addCleanup(RequestCache.clear_all_namespaces) + + def _make_enrollment_with_schedule(self, start_date, created=None): + """Create a DummyCourse/Enrollment/Schedule chain for self.user.""" + course = DummyCourse(id=self.course_key) + course.save() + enrollment = DummyEnrollment(user=self.user, course=course) + enrollment.save() + schedule = DummySchedule( + enrollment=enrollment, + start_date=start_date, + created=created or start_date, + ) + schedule.save() + return schedule + + def test_relative_date_resolved_when_schedule_exists(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) + models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='due', + active=True, policy=policy, block_type='sequential' + ) + self._make_enrollment_with_schedule(start_date=datetime(2023, 1, 1)) + + result = api.get_user_dates(self.course_key, self.user.id) + + assert result == {(block_key, 'due'): datetime(2023, 1, 8)} + + def test_relative_date_skipped_without_schedule(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) + models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='due', + active=True, policy=policy, block_type='sequential' + ) + + result = api.get_user_dates(self.course_key, self.user.id) + + assert not result + + def test_user_override_takes_priority_over_relative_date(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) + content_date = models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='due', + active=True, policy=policy, block_type='sequential' + ) + self._make_enrollment_with_schedule(start_date=datetime(2023, 1, 1)) + + override_date = datetime(2023, 1, 30) + models.UserDate.objects.create(user=self.user, content_date=content_date, abs_date=override_date) + + result = api.get_user_dates(self.course_key, self.user.id) + + assert result[(block_key, 'due')] == override_date + + +class TestGetExistingDueLocations(TestCase): + """ + Tests for get_existing_due_locations. + """ + + def setUp(self): + super().setUp() + self.course_key = CourseLocator('testX', 'tt101', '2019') + + def test_returns_locations_with_active_due_dates(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15)) + models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='due', + active=True, policy=policy, block_type='sequential' + ) + + result = api.get_existing_due_locations(self.course_key) + + assert block_key in result + + def test_excludes_inactive_due_dates(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15)) + models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='due', + active=False, policy=policy, block_type='sequential' + ) + + result = api.get_existing_due_locations(self.course_key) + + assert block_key not in result + + def test_excludes_non_due_fields(self): + block_key = make_block_id(self.course_key) + policy = models.DatePolicy.objects.create(abs_date=datetime(2023, 1, 15)) + models.ContentDate.objects.create( + course_id=self.course_key, location=block_key, field='start', + active=True, policy=policy, block_type='sequential' + ) + + result = api.get_existing_due_locations(self.course_key) + + assert block_key not in result + + def test_accepts_string_course_key(self): + result = api.get_existing_due_locations(str(self.course_key)) + assert isinstance(result, set) + + +class TestUpdateOrCreateAssignmentsDueDates(TestCase): + """ + Tests for update_or_create_assignments_due_dates. + """ + + def setUp(self): + super().setUp() + self.course_key = CourseLocator('testX', 'tt101', '2019') + + def test_creates_date_for_assignment(self): + block_key = make_block_id(self.course_key) + due = datetime(2023, 6, 1) + assignment = Mock(block_key=block_key, date=due) + + api.update_or_create_assignments_due_dates(self.course_key, [assignment]) + + assert models.ContentDate.objects.filter( + course_id=self.course_key, location=block_key, field='due' + ).exists() + + def test_skips_assignment_with_null_date(self): + block_key = make_block_id(self.course_key) + assignment = Mock(block_key=block_key, date=None) + + api.update_or_create_assignments_due_dates(self.course_key, [assignment]) + + assert not models.ContentDate.objects.filter( + course_id=self.course_key, location=block_key + ).exists() + + def test_empty_list_creates_nothing(self): + api.update_or_create_assignments_due_dates(self.course_key, []) + assert models.ContentDate.objects.count() == 0 From 5285469f66b126f38f8b389ae12fc5ad5b3c3c7a Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Mon, 1 Jun 2026 22:54:50 +0300 Subject: [PATCH 4/5] feat: add guard for MissingScheduleError --- edx_when/api.py | 6 +++++- tests/test_api.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/edx_when/api.py b/edx_when/api.py index 0037b856..f2950fe0 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -619,7 +619,11 @@ def get_user_dates(course_id, user_id, block_types=None, block_keys=None, date_t for content_date in content_dates: if content_date.user_overrides: - dates[(content_date.location, content_date.field)] = content_date.user_overrides[0].actual_date + try: + actual_date = content_date.user_overrides[0].actual_date + except models.MissingScheduleError: + continue + dates[(content_date.location, content_date.field)] = actual_date return dates diff --git a/tests/test_api.py b/tests/test_api.py index 852c342a..1642bf6a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -984,6 +984,35 @@ def test_get_user_dates_missing_schedule_error_handled(self): result = api.get_user_dates(course_id, user_id) self.assertEqual(len(result), 0) + def test_get_user_dates_override_missing_schedule_error_handled(self): + """ + A relative user override with no schedule must be skipped, not raise. + """ + course_id = CourseKey.from_string('course-v1:TestX+Test+2023') + user_id = 123 + + block_key = UsageKey.from_string('block-v1:TestX+Test+2023+type@sequential+block@test') + + policy = models.DatePolicy.objects.create(rel_date=timedelta(days=7)) + content_date = models.ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=policy, + block_type='sequential' + ) + + user = User.objects.create(username='testuser', id=user_id) + models.UserDate.objects.create( + user=user, + content_date=content_date, + rel_date=timedelta(days=3) + ) + + result = api.get_user_dates(course_id, user_id) + self.assertEqual(len(result), 0) + def test_get_user_dates_string_course_key(self): """ Test get_user_dates with string course key. From 5bb4daeeaaeb6d78b57caf516ff45f29748eed2b Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Fri, 3 Jul 2026 02:24:07 +0300 Subject: [PATCH 5/5] fix: [AXM-2268] address review on get_user_dates service layer --- edx_when/api.py | 70 ++++++++++++------------- tests/test_api.py | 130 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 133 insertions(+), 67 deletions(-) diff --git a/edx_when/api.py b/edx_when/api.py index f2950fe0..998806f3 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -137,26 +137,39 @@ def _get_end_dates_from_content_dates(qset): return end_datetime, cutoff_datetime -def _resolve_policy_dates(content_dates, schedule=None, end_datetime=None, cutoff_datetime=None): +def _resolve_policy_dates( + content_dates, schedule=None, end_datetime=None, cutoff_datetime=None, course_id=None +): # pylint: disable=too-many-positional-arguments """ Resolve ContentDate objects to their policy-derived datetimes. Passes schedule/end/cutoff so relative dates (self-paced courses) are included. Silently skips entries that require a schedule when none is available. + Arguments: + content_dates: iterable of ContentDate objects to resolve. + schedule: Schedule obj (optional), used for relative date calculations. + end_datetime: course end datetime (optional), caps relative dates. + cutoff_datetime: cutoff datetime (optional) for self-paced late starters. + course_id: CourseKey (optional). When provided, each location is normalized + with ``location.map_into_course(course_id)``. + Returns: - dict where keys are (location, field) tuples and values are datetime objects, - representing policy-resolved dates. + Tuple ``(dates, policies)`` where ``dates`` maps ``(location, field)`` tuples to + resolved datetimes and ``policies`` maps ``content_date.id`` to the same key + (used to apply user overrides). """ dates = {} + policies = {} for cdate in content_dates: + location = cdate.location.map_into_course(course_id) if course_id else cdate.location + key = (location, cdate.field) try: - dates[(cdate.location, cdate.field)] = cdate.policy.actual_date( - schedule, end_datetime, cutoff_datetime - ) + dates[key] = cdate.policy.actual_date(schedule, end_datetime, cutoff_datetime) except models.MissingScheduleError: pass - return dates + policies[cdate.id] = key + return dates, policies def _processed_results_cache_key( @@ -263,19 +276,11 @@ def get_dates_for_course( ) TieredCache.set_all_tiers(raw_results_cache_key, qset) - dates = {} - policies = {} end_datetime, cutoff_datetime = _get_end_dates_from_content_dates(qset) - for cdate in qset: - key = (cdate.location.map_into_course(course_id), cdate.field) - try: - dates[key] = cdate.policy.actual_date(schedule, end_datetime, cutoff_datetime) - except models.MissingScheduleError: - # We had a relative date but no schedule. This is permissible in some cases (staff users viewing a course - # they are not enrolled in, for example). Just let it go by. - pass - policies[cdate.id] = key + dates, policies = _resolve_policy_dates( + qset, schedule, end_datetime, cutoff_datetime, course_id=course_id + ) if user_id: for userdate in models.UserDate.objects.filter( @@ -586,13 +591,16 @@ def get_user_dates(course_id, user_id, block_types=None, block_keys=None, date_t User overrides take priority over content defaults """ course_id = _ensure_key(CourseKey, course_id) + allow_relative_dates = _are_relative_dates_enabled(course_id) content_dates_query = models.ContentDate.objects.filter( course_id=course_id, active=True, ).select_related('policy') - # Apply filters + if not allow_relative_dates: + content_dates_query = content_dates_query.filter(policy__rel_date__isnull=True) + if block_types: content_dates_query = content_dates_query.filter(block_type__in=block_types) @@ -614,8 +622,12 @@ def get_user_dates(course_id, user_id, block_types=None, block_keys=None, date_t ) schedule = get_schedule_for_user(user_id, course_id) - end_datetime, cutoff_datetime = _get_end_dates_from_content_dates(content_dates) - dates = _resolve_policy_dates(content_dates, schedule, end_datetime, cutoff_datetime) + + course_dates_for_bounds = list( + models.ContentDate.objects.filter(course_id=course_id, active=True).select_related('policy') + ) + end_datetime, cutoff_datetime = _get_end_dates_from_content_dates(course_dates_for_bounds) + dates, _ = _resolve_policy_dates(content_dates, schedule, end_datetime, cutoff_datetime) for content_date in content_dates: if content_date.user_overrides: @@ -628,7 +640,7 @@ def get_user_dates(course_id, user_id, block_types=None, block_keys=None, date_t return dates -def get_existing_due_locations(course_key): +def get_locations_with_due_dates(course_key): """ Return the set of block locations that already have an active 'due' ContentDate for the given course. @@ -645,20 +657,6 @@ def get_existing_due_locations(course_key): ) -def update_or_create_assignments_due_dates(course_key, assignments): - """ - Create or update ContentDate entries for a list of assignment objects. - - Arguments: - course_key: either a CourseKey or string representation of same - assignments: iterable of objects with attributes ``block_key`` (UsageKey) and ``date`` (datetime) - """ - course_key = _ensure_key(CourseKey, course_key) - for assignment in assignments: - if assignment.date is not None: - set_date_for_block(course_key, assignment.block_key, 'due', assignment.date) - - class BaseWhenException(Exception): pass diff --git a/tests/test_api.py b/tests/test_api.py index 1642bf6a..e0bfa7db 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1166,9 +1166,10 @@ def test_absolute_date(self): active=True, policy=policy, block_type='sequential' ) - result = api._resolve_policy_dates([content_date]) # pylint: disable=protected-access + result, policies = api._resolve_policy_dates([content_date]) # pylint: disable=protected-access assert result == {(block_key, 'due'): datetime(2023, 1, 15)} + assert policies == {content_date.id: (block_key, 'due')} def test_relative_date_with_schedule(self): block_key = make_block_id(self.course_key) @@ -1179,7 +1180,7 @@ def test_relative_date_with_schedule(self): ) schedule = Mock(start_date=datetime(2023, 1, 1), created=datetime(2023, 1, 1)) - result = api._resolve_policy_dates([content_date], schedule=schedule) # pylint: disable=protected-access + result, _ = api._resolve_policy_dates([content_date], schedule=schedule) # pylint: disable=protected-access assert result == {(block_key, 'due'): datetime(2023, 1, 8)} @@ -1191,13 +1192,14 @@ def test_relative_date_without_schedule_skipped(self): active=True, policy=policy, block_type='sequential' ) - result = api._resolve_policy_dates([content_date]) # pylint: disable=protected-access + result, _ = api._resolve_policy_dates([content_date]) # pylint: disable=protected-access assert not result def test_empty_content_dates(self): - result = api._resolve_policy_dates([]) # pylint: disable=protected-access + result, policies = api._resolve_policy_dates([]) # pylint: disable=protected-access assert not result + assert not policies class TestGetUserDatesSelfPaced(TestCase): @@ -1214,6 +1216,10 @@ def setUp(self): dummy_schedule_patcher = patch('edx_when.utils.Schedule', DummySchedule) dummy_schedule_patcher.start() self.addCleanup(dummy_schedule_patcher.stop) + + relative_dates_patcher = patch('edx_when.api._are_relative_dates_enabled', return_value=True) + relative_dates_patcher.start() + self.addCleanup(relative_dates_patcher.stop) self.addCleanup(RequestCache.clear_all_namespaces) def _make_enrollment_with_schedule(self, start_date, created=None): @@ -1272,9 +1278,9 @@ def test_user_override_takes_priority_over_relative_date(self): assert result[(block_key, 'due')] == override_date -class TestGetExistingDueLocations(TestCase): +class TestGetLocationsWithDueDates(TestCase): """ - Tests for get_existing_due_locations. + Tests for get_locations_with_due_dates. """ def setUp(self): @@ -1289,7 +1295,7 @@ def test_returns_locations_with_active_due_dates(self): active=True, policy=policy, block_type='sequential' ) - result = api.get_existing_due_locations(self.course_key) + result = api.get_locations_with_due_dates(self.course_key) assert block_key in result @@ -1301,7 +1307,7 @@ def test_excludes_inactive_due_dates(self): active=False, policy=policy, block_type='sequential' ) - result = api.get_existing_due_locations(self.course_key) + result = api.get_locations_with_due_dates(self.course_key) assert block_key not in result @@ -1313,45 +1319,107 @@ def test_excludes_non_due_fields(self): active=True, policy=policy, block_type='sequential' ) - result = api.get_existing_due_locations(self.course_key) + result = api.get_locations_with_due_dates(self.course_key) assert block_key not in result def test_accepts_string_course_key(self): - result = api.get_existing_due_locations(str(self.course_key)) + result = api.get_locations_with_due_dates(str(self.course_key)) assert isinstance(result, set) -class TestUpdateOrCreateAssignmentsDueDates(TestCase): +class TestGetUserDatesRelativeDates(TestCase): """ - Tests for update_or_create_assignments_due_dates. + Tests for get_user_dates relative-date handling: course end cap, self-paced cutoff, + and the _are_relative_dates_enabled toggle. These exercise the case where caller + filters (date_types/block_types) would otherwise hide the course end row needed to + compute the cap/cutoff. """ def setUp(self): super().setUp() - self.course_key = CourseLocator('testX', 'tt101', '2019') + self.course_key = CourseKey.from_string('course-v1:TestX+Test+2023') + self.course = DummyCourse(id=self.course_key) + self.course.save() + self.user = User.objects.create(username='rel-tester') + self.enrollment = DummyEnrollment(user=self.user, course=self.course) + self.enrollment.save() - def test_creates_date_for_assignment(self): - block_key = make_block_id(self.course_key) - due = datetime(2023, 6, 1) - assignment = Mock(block_key=block_key, date=due) + schedule_patcher = patch('edx_when.utils.Schedule', DummySchedule) + schedule_patcher.start() + self.addCleanup(schedule_patcher.stop) - api.update_or_create_assignments_due_dates(self.course_key, [assignment]) + relative_dates_patcher = patch('edx_when.api._are_relative_dates_enabled', return_value=True) + relative_dates_patcher.start() + self.addCleanup(relative_dates_patcher.stop) + self.addCleanup(RequestCache.clear_all_namespaces) - assert models.ContentDate.objects.filter( - course_id=self.course_key, location=block_key, field='due' - ).exists() + self.due_block = make_block_id(self.course_key, block_type='sequential') + self.end_block = make_block_id(self.course_key, block_type='course') - def test_skips_assignment_with_null_date(self): - block_key = make_block_id(self.course_key) - assignment = Mock(block_key=block_key, date=None) + def _make_schedule(self, start_date, created): + DummySchedule(enrollment=self.enrollment, start_date=start_date, created=created).save() + + def _make_relative_due(self, rel_date): + models.ContentDate.objects.create( + course_id=self.course_key, location=self.due_block, field='due', active=True, + block_type='sequential', policy=models.DatePolicy.objects.create(rel_date=rel_date), + ) + + def _make_course_end(self, end_date): + models.ContentDate.objects.create( + course_id=self.course_key, location=self.end_block, field='end', active=True, + block_type='course', policy=models.DatePolicy.objects.create(abs_date=end_date), + ) + + def test_due_filter_still_applies_course_end_cap(self): + """ + A relative due date is capped at the course end even when date_types=['due'] + excludes the course end row from the returned/queried set. + """ + self._make_relative_due(timedelta(days=100)) + self._make_course_end(datetime(2023, 1, 20)) + # created long ago (passes cutoff), schedule started 2023-01-01. + self._make_schedule(start_date=datetime(2023, 1, 1), created=datetime(2022, 1, 1)) + + result = api.get_user_dates(self.course_key, self.user.id, date_types=['due']) + + # start (2023-01-01) + 100d = 2023-04-11, capped to the course end 2023-01-20. + assert result == {(self.due_block, 'due'): datetime(2023, 1, 20)} + + def test_due_filter_still_applies_course_cutoff(self): + """ + A learner who enrolled after the cutoff gets no relative due date, even when + date_types=['due'] excludes the course end row. + """ + self._make_relative_due(timedelta(days=10)) + self._make_course_end(datetime(2023, 1, 20)) + # cutoff = end (2023-01-20) - 10d = 2023-01-10; created 2023-01-15 is past it. + self._make_schedule(start_date=datetime(2023, 1, 1), created=datetime(2023, 1, 15)) + + result = api.get_user_dates(self.course_key, self.user.id, date_types=['due']) - api.update_or_create_assignments_due_dates(self.course_key, [assignment]) + assert result == {(self.due_block, 'due'): None} - assert not models.ContentDate.objects.filter( - course_id=self.course_key, location=block_key - ).exists() + def test_relative_due_returned_when_relative_dates_enabled(self): + """ + With relative dates enabled and no course end, the resolved relative due date is returned. + """ + self._make_relative_due(timedelta(days=7)) + self._make_schedule(start_date=datetime(2023, 1, 1), created=datetime(2023, 1, 1)) + + result = api.get_user_dates(self.course_key, self.user.id, date_types=['due']) + + assert result == {(self.due_block, 'due'): datetime(2023, 1, 8)} + + def test_relative_due_skipped_when_relative_dates_disabled(self): + """ + With relative dates disabled for the course, relative ContentDate rows are ignored. + """ + self._make_relative_due(timedelta(days=7)) + self._make_schedule(start_date=datetime(2023, 1, 1), created=datetime(2023, 1, 1)) + + with patch('edx_when.api._are_relative_dates_enabled', return_value=False): + result = api.get_user_dates(self.course_key, self.user.id, date_types=['due']) - def test_empty_list_creates_nothing(self): - api.update_or_create_assignments_due_dates(self.course_key, []) - assert models.ContentDate.objects.count() == 0 + assert result == {}