diff --git a/edx_when/api.py b/edx_when/api.py index e3e9b87..998806f 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 @@ -137,6 +137,41 @@ 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, 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: + 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[key] = cdate.policy.actual_date(schedule, end_datetime, cutoff_datetime) + except models.MissingScheduleError: + pass + policies[cdate.id] = key + return dates, policies + + def _processed_results_cache_key( course_id, user_id, schedule, allow_relative_dates, subsection_and_higher_only, published_version @@ -241,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( @@ -548,6 +575,88 @@ 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) + 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') + + 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) + + 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 = 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' + ) + ) + ) + + schedule = get_schedule_for_user(user_id, course_id) + + 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: + 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 + + +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. + + 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) + ) + + class BaseWhenException(Exception): pass diff --git a/tests/test_api.py b/tests/test_api.py index a4591ab..e0bfa7d 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,405 @@ 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_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. + """ + 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. @@ -747,3 +1147,279 @@ 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, 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) + 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, policies = api._resolve_policy_dates([]) # pylint: disable=protected-access + assert not result + assert not policies + + +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) + + 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): + """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 TestGetLocationsWithDueDates(TestCase): + """ + Tests for get_locations_with_due_dates. + """ + + 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_locations_with_due_dates(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_locations_with_due_dates(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_locations_with_due_dates(self.course_key) + + assert block_key not in result + + def test_accepts_string_course_key(self): + result = api.get_locations_with_due_dates(str(self.course_key)) + assert isinstance(result, set) + + +class TestGetUserDatesRelativeDates(TestCase): + """ + 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 = 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() + + schedule_patcher = patch('edx_when.utils.Schedule', DummySchedule) + schedule_patcher.start() + self.addCleanup(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) + + 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 _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']) + + assert result == {(self.due_block, 'due'): None} + + 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']) + + assert result == {}