From 1c7febc666e0915697dff90e8a8841a6e488441a Mon Sep 17 00:00:00 2001 From: Andrii Date: Mon, 23 Jun 2025 16:54:43 +0300 Subject: [PATCH 01/22] feat: [AXM-2272] add new field for Content-/UserDate models to support assignment dates --- ..._title_contentdate_course_name_and_more.py | 55 +++++++++++++++++++ edx_when/models.py | 31 +++++++++++ 2 files changed, 86 insertions(+) create mode 100644 edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py diff --git a/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py b/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py new file mode 100644 index 00000000..66be68be --- /dev/null +++ b/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.22 on 2025-06-23 13:56 + +from django.db import migrations, models +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('edx_when', '0008_courseversion_block_type'), + ] + + operations = [ + migrations.AddField( + model_name='contentdate', + name='assignment_title', + field=models.CharField(blank=True, db_index=True, default='', max_length=255), + ), + migrations.AddField( + model_name='contentdate', + name='course_name', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='contentdate', + name='subsection_name', + field=models.CharField(blank=True, db_index=True, default='', max_length=255), + ), + migrations.AddField( + model_name='userdate', + name='first_component_block_id', + field=opaque_keys.edx.django.models.UsageKeyField(blank=True, db_index=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='userdate', + name='user_date_value', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AddIndex( + model_name='contentdate', + index=models.Index(fields=['assignment_title', 'course_id'], name='edx_when_assignment_course_idx'), + ), + migrations.AddIndex( + model_name='contentdate', + index=models.Index(fields=['subsection_name', 'course_id'], name='edx_when_subsection_course_idx'), + ), + migrations.AddIndex( + model_name='userdate', + index=models.Index(fields=['user', 'first_component_block_id'], name='edx_when_user_first_block_idx'), + ), + migrations.AddIndex( + model_name='userdate', + index=models.Index(fields=['user_date_value', 'user'], name='edx_when_user_date_value_idx'), + ), + ] diff --git a/edx_when/models.py b/edx_when/models.py index e540bdfa..d9d7af57 100644 --- a/edx_when/models.py +++ b/edx_when/models.py @@ -93,6 +93,9 @@ class ContentDate(models.Model): field = models.CharField(max_length=255, default='') active = models.BooleanField(default=True) block_type = models.CharField(max_length=255, null=True) + assignment_title = models.CharField(max_length=255, blank=True, default='', db_index=True) + course_name = models.CharField(max_length=255, blank=True, default='') + subsection_name = models.CharField(max_length=255, blank=True, default='', db_index=True) class Meta: """Django Metadata.""" @@ -100,6 +103,8 @@ class Meta: unique_together = ('policy', 'location', 'field') indexes = [ models.Index(fields=('course_id', 'block_type'), name='edx_when_course_block_type_idx'), + models.Index(fields=('assignment_title', 'course_id'), name='edx_when_assignment_course_idx'), + models.Index(fields=('subsection_name', 'course_id'), name='edx_when_subsection_course_idx'), ] def __str__(self): @@ -109,6 +114,14 @@ def __str__(self): # Location already holds course id return f'ContentDate({self.policy}, {self.location}, {self.field}, {self.block_type})' + def __repr__(self): + """ + Get a detailed representation of this model instance. + """ + return (f'ContentDate(id={self.id}, assignment_title="{self.assignment_title}", ' + f'course_name="{self.course_name}", subsection_name="{self.subsection_name}", ' + f'policy={self.policy}, location={self.location})') + class UserDate(TimeStampedModel): """ @@ -125,6 +138,16 @@ class UserDate(TimeStampedModel): actor = models.ForeignKey( get_user_model(), null=True, default=None, blank=True, related_name="actor", on_delete=models.CASCADE ) + first_component_block_id = UsageKeyField(null=True, blank=True, max_length=255, db_index=True) + user_date_value = models.DateTimeField(null=True, blank=True, db_index=True) + + class Meta: + """Django Metadata.""" + + indexes = [ + models.Index(fields=('user', 'first_component_block_id'), name='edx_when_user_first_block_idx'), + models.Index(fields=('user_date_value', 'user'), name='edx_when_user_date_value_idx'), + ] @property def actual_date(self): @@ -169,3 +192,11 @@ def __str__(self): # Location already holds course id # pylint: disable=no-member return f'{self.user.username}, {self.content_date.location}, {self.content_date.field}' + + def __repr__(self): + """ + Get a detailed representation of this model instance. + """ + return (f'UserDate(id={self.id}, user="{self.user.username}", ' + f'first_component_block_id={self.first_component_block_id}, ' + f'user_date_value={self.user_date_value}, content_date={self.content_date.id})') From 6ed30b38d2b715024f6f30204e339553bd261926 Mon Sep 17 00:00:00 2001 From: Andrii Date: Wed, 25 Jun 2025 14:44:40 +0300 Subject: [PATCH 02/22] feat: [AXM-2300] add signal handler to save assignment dates to ContentDate model --- edx_when/apps.py | 8 ++ edx_when/signals/__init__.py | 0 edx_when/signals/handlers.py | 16 +++ edx_when/tasks.py | 60 ++++++++++ edx_when/tests/__init__.py | 0 edx_when/tests/test_tasks.py | 226 +++++++++++++++++++++++++++++++++++ 6 files changed, 310 insertions(+) create mode 100644 edx_when/signals/__init__.py create mode 100644 edx_when/signals/handlers.py create mode 100644 edx_when/tasks.py create mode 100644 edx_when/tests/__init__.py create mode 100644 edx_when/tests/test_tasks.py diff --git a/edx_when/apps.py b/edx_when/apps.py index 9c37de48..572bc3a2 100644 --- a/edx_when/apps.py +++ b/edx_when/apps.py @@ -21,3 +21,11 @@ class EdxWhenConfig(AppConfig): }, } } + + def ready(self): + """ + Perform any necessary initialization when the application is ready. + This method is called when Django starts up and the application is loaded. + """ + # Import signal handlers or other initialization code here if needed + from .signals import handlers diff --git a/edx_when/signals/__init__.py b/edx_when/signals/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edx_when/signals/handlers.py b/edx_when/signals/handlers.py new file mode 100644 index 00000000..8ca7b663 --- /dev/null +++ b/edx_when/signals/handlers.py @@ -0,0 +1,16 @@ +from django.db import transaction +from django.dispatch import receiver + +from xmodule.modulestore.django import SignalHandler + + +@receiver(SignalHandler.course_published) +def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument + """ + Receive the course_published signal and update assignment dates for the course. + """ + # import here, because signal is registered at startup, but items in tasks are not available yet + from edx_when.tasks import update_assignment_dates_for_course + + course_key_str = str(course_key) + transaction.on_commit(lambda: update_assignment_dates_for_course.delay(course_key_str)) diff --git a/edx_when/tasks.py b/edx_when/tasks.py new file mode 100644 index 00000000..4b59963f --- /dev/null +++ b/edx_when/tasks.py @@ -0,0 +1,60 @@ +from celery import shared_task +from celery.utils.log import get_task_logger +from django.contrib.auth import get_user_model +from edx_django_utils.monitoring import set_code_owner_attribute +from opaque_keys.edx.keys import CourseKey + +from lms.djangoapps.courseware.courses import get_course_assignments +from edx_when.models import ContentDate, DatePolicy + + +User = get_user_model() + + +LOGGER = get_task_logger(__name__) + + +@shared_task +@set_code_owner_attribute +def update_assignment_dates_for_course(course_key_str): + """ + Celery task to update assignment dates for a course. + """ + try: + LOGGER.info("Starting to update assignment dates for course %s", course_key_str) + course_key = CourseKey.from_string(course_key_str) + staff_user = User.objects.filter(is_staff=True).first() + if not staff_user: + LOGGER.error("No staff user found to update assignment dates for course %s", course_key_str) + return + assignments = get_course_assignments(course_key, staff_user) + for assignment in assignments: + LOGGER.info( + "Updating assignment '%s' with due date '%s' for course %s", + assignment.title, + assignment.date, + course_key_str + ) + if not assignment.date: + LOGGER.info( + "Skipping assignment '%s' for course %s because it has no date", + assignment.title, + course_key_str + ) + continue + ContentDate.objects.update_or_create( + course_id=course_key, + location=assignment.block_key, + field='due', + block_type=assignment.assignment_type, + defaults={ + 'policy': DatePolicy.objects.get_or_create(abs_date=assignment.date)[0], + 'assignment_title': assignment.title, + 'course_name': course_key.course, + 'subsection_name': assignment.title + } + ) + LOGGER.info("Successfully updated assignment dates for course %s", course_key_str) + except Exception: # pylint: disable=broad-except + LOGGER.exception("Could not update assignment dates for course %s", course_key_str) + raise 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_tasks.py b/edx_when/tests/test_tasks.py new file mode 100644 index 00000000..bace743d --- /dev/null +++ b/edx_when/tests/test_tasks.py @@ -0,0 +1,226 @@ +from unittest.mock import Mock, patch +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from edx_when.tasks import update_assignment_dates_for_course +from edx_when.models import ContentDate, DatePolicy +from django.contrib.auth import get_user_model +from datetime import datetime, timezone + +User = get_user_model() + + +class TestUpdateAssignmentDatesForCourse(TestCase): + + def setUp(self): + self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + self.course_key_str = str(self.course_key) + self.staff_user = User.objects.create_user( + username='staff_user', + email='staff@example.com', + is_staff=True + ) + self.block_key = UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@sequential@test1' + ) + self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + + @patch('edx_when.tasks.get_course_assignments') + def test_update_assignment_dates_new_records(self, mock_get_assignments): + """ + Test inserting new records when missing. + """ + assignment = Mock() + assignment.title = 'Test Assignment' + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.assignment_type = 'Homework' + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertEqual(content_date.assignment_title, 'Test Assignment') + self.assertEqual(content_date.block_type, 'problem') + self.assertEqual(content_date.policy.abs_date, self.due_date) + + @patch('edx_when.tasks.get_course_assignments') + def test_update_assignment_dates_existing_records(self, mock_get_assignments): + """ + Test updating existing records when values differ. + """ + existing_policy = DatePolicy.objects.create( + abs_date=datetime(2024, 6, 1, tzinfo=timezone.utc) + ) + ContentDate.objects.create( + course_id=self.course_key, + location=self.block_key, + field='date', + block_type='Homework', + policy=existing_policy, + assignment_title='Old Title', + course_name=self.course_key.course, + subsection_name='Old Title' + ) + + assignment = Mock() + assignment.title = 'Updated Assignment' + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.assignment_type = 'Homework' + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertEqual(content_date.assignment_title, 'Updated Assignment') + self.assertEqual(content_date.policy.abs_date, self.due_date) + + @patch('edx_when.tasks.get_course_assignments') + def test_missing_staff_user(self, mock_get_assignments): + """ + Test graceful handling when no staff user exists. + """ + User.objects.filter(is_staff=True).delete() + + update_assignment_dates_for_course(self.course_key_str) + + mock_get_assignments.assert_not_called() + + @patch('edx_when.tasks.get_course_assignments') + def test_assignment_with_null_date(self, mock_get_assignments): + """ + Test handling assignments with null dates. + """ + assignment = Mock() + assignment.title = 'No Due Date Assignment' + assignment.date = None + assignment.block_key = self.block_key + assignment.assignment_type = 'Homework' + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = ContentDate.objects.filter( + course_id=self.course_key, + location=self.block_key + ).first() + self.assertIsNone(content_date) + + @patch('edx_when.tasks.get_course_assignments') + def test_assignment_with_missing_metadata(self, mock_get_assignments): + """ + Test handling assignments with missing metadata. + """ + assignment = Mock() + assignment.title = None + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.assignment_type = None + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertIsNone(content_date.assignment_title) + self.assertIsNone(content_date.block_type) + + @patch('edx_when.tasks.get_course_assignments') + def test_unsupported_block_types(self, mock_get_assignments): + """ + Test handling unsupported block types. + """ + assignment = Mock() + assignment.title = 'Unsupported Block' + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.block_type = 'unsupported_type' + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertEqual(content_date.block_type, 'unsupported_type') + self.assertEqual(content_date.assignment_title, 'Unsupported Block') + + @patch('edx_when.tasks.get_course_assignments') + def test_multiple_assignments(self, mock_get_assignments): + """ + Test processing multiple assignments. + """ + assignment1 = Mock() + assignment1.title = 'Assignment 1' + assignment1.date = self.due_date + assignment1.block_key = self.block_key + assignment1.assignment_type = 'Gradeable' + + assignment2 = Mock() + assignment2.title = 'Assignment 2' + assignment2.date = datetime(2025, 1, 15, tzinfo=timezone.utc) + assignment2.block_key = UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@sequential@test2' + ) + assignment2.assignment_type = 'Homework' + + mock_get_assignments.return_value = [assignment1, assignment2] + + update_assignment_dates_for_course(self.course_key_str) + + self.assertEqual(ContentDate.objects.count(), 2) + + @patch('edx_when.tasks.get_course_assignments') + def test_invalid_course_key(self, mock_get_assignments): + """ + Test handling invalid course key. + """ + with self.assertRaises(Exception): + update_assignment_dates_for_course('invalid-course-key') + + @patch('edx_when.tasks.get_course_assignments') + def test_get_course_assignments_exception(self, mock_get_assignments): + """ + Test handling exception from get_course_assignments. + """ + mock_get_assignments.side_effect = Exception('API Error') + + with self.assertRaises(Exception): + update_assignment_dates_for_course(self.course_key_str) + + @patch('edx_when.tasks.get_course_assignments') + def test_empty_assignments_list(self, mock_get_assignments): + """ + Test handling empty assignments list. + """ + mock_get_assignments.return_value = [] + + update_assignment_dates_for_course(self.course_key_str) + + self.assertEqual(ContentDate.objects.count(), 0) + + @patch('edx_when.tasks.get_course_assignments') + @patch('edx_when.models.DatePolicy.objects.get_or_create') + def test_date_policy_creation_exception(self, mock_policy_create, mock_get_assignments): + """ + Test handling exception during DatePolicy creation. + """ + assignment = Mock() + assignment.title = 'Test Assignment' + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.assignment_type = 'problem' + mock_get_assignments.return_value = [assignment] + mock_policy_create.side_effect = Exception('Database Error') + + with self.assertRaises(Exception): + update_assignment_dates_for_course(self.course_key_str) From e84dac95e5ecc03e9b6ab6a3a21f5e5a3da8093c Mon Sep 17 00:00:00 2001 From: Andrii Date: Thu, 26 Jun 2025 17:41:59 +0300 Subject: [PATCH 03/22] refactor: create api method to get assignments and process them --- edx_when/api.py | 53 +++++++- edx_when/apps.py | 8 -- edx_when/signals/__init__.py | 0 edx_when/signals/handlers.py | 16 --- edx_when/tasks.py | 60 ---------- edx_when/tests/test_api.py | 166 +++++++++++++++++++++++++ edx_when/tests/test_tasks.py | 226 ----------------------------------- 7 files changed, 218 insertions(+), 311 deletions(-) delete mode 100644 edx_when/signals/__init__.py delete mode 100644 edx_when/signals/handlers.py delete mode 100644 edx_when/tasks.py create mode 100644 edx_when/tests/test_api.py delete mode 100644 edx_when/tests/test_tasks.py diff --git a/edx_when/api.py b/edx_when/api.py index f56b4299..21867778 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -2,8 +2,9 @@ API for retrieving and setting dates. """ +from dataclasses import dataclass +from datetime import datetime, timedelta import logging -from datetime import timedelta from django.core.exceptions import ValidationError from django.db import transaction @@ -506,6 +507,56 @@ def get_schedules_with_due_date(course_id, assignment_date): return schedules +@dataclass +class _Assignment: + """ + Represents an assignment with a title, date, block key, and assignment type. + """ + title: str + date: datetime + block_key: UsageKey + assignment_type: str + + def __post_init__(self): + if not isinstance(self.date, datetime): + raise TypeError("date must be a datetime object") + if not isinstance(self.block_key, UsageKey): + raise TypeError("block_key must be a UsageKey object") + + +def update_or_create_assignments_due_dates(course_key, assignments: list[_Assignment]): + """ + Update or create assignment due dates for a course. + """ + course_key_str = str(course_key) + for assignment in assignments: + log.info( + "Updating assignment '%s' with due date '%s' for course %s", + assignment.title, + assignment.date, + course_key_str + ) + if not all((assignment.date, assignment.title)): + log.warning( + "Skipping assignment '%s' for course %s because it has no date or title", + assignment, + course_key_str + ) + continue + models.ContentDate.objects.update_or_create( + course_id=course_key, + location=assignment.block_key, + field='due', + block_type=assignment.assignment_type, + defaults={ + 'policy': models.DatePolicy.objects.get_or_create(abs_date=assignment.date)[0], + 'assignment_title': assignment.title, + 'course_name': course_key.course, + 'subsection_name': assignment.title + } + ) + + class BaseWhenException(Exception): pass diff --git a/edx_when/apps.py b/edx_when/apps.py index 572bc3a2..9c37de48 100644 --- a/edx_when/apps.py +++ b/edx_when/apps.py @@ -21,11 +21,3 @@ class EdxWhenConfig(AppConfig): }, } } - - def ready(self): - """ - Perform any necessary initialization when the application is ready. - This method is called when Django starts up and the application is loaded. - """ - # Import signal handlers or other initialization code here if needed - from .signals import handlers diff --git a/edx_when/signals/__init__.py b/edx_when/signals/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/edx_when/signals/handlers.py b/edx_when/signals/handlers.py deleted file mode 100644 index 8ca7b663..00000000 --- a/edx_when/signals/handlers.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import transaction -from django.dispatch import receiver - -from xmodule.modulestore.django import SignalHandler - - -@receiver(SignalHandler.course_published) -def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument - """ - Receive the course_published signal and update assignment dates for the course. - """ - # import here, because signal is registered at startup, but items in tasks are not available yet - from edx_when.tasks import update_assignment_dates_for_course - - course_key_str = str(course_key) - transaction.on_commit(lambda: update_assignment_dates_for_course.delay(course_key_str)) diff --git a/edx_when/tasks.py b/edx_when/tasks.py deleted file mode 100644 index 4b59963f..00000000 --- a/edx_when/tasks.py +++ /dev/null @@ -1,60 +0,0 @@ -from celery import shared_task -from celery.utils.log import get_task_logger -from django.contrib.auth import get_user_model -from edx_django_utils.monitoring import set_code_owner_attribute -from opaque_keys.edx.keys import CourseKey - -from lms.djangoapps.courseware.courses import get_course_assignments -from edx_when.models import ContentDate, DatePolicy - - -User = get_user_model() - - -LOGGER = get_task_logger(__name__) - - -@shared_task -@set_code_owner_attribute -def update_assignment_dates_for_course(course_key_str): - """ - Celery task to update assignment dates for a course. - """ - try: - LOGGER.info("Starting to update assignment dates for course %s", course_key_str) - course_key = CourseKey.from_string(course_key_str) - staff_user = User.objects.filter(is_staff=True).first() - if not staff_user: - LOGGER.error("No staff user found to update assignment dates for course %s", course_key_str) - return - assignments = get_course_assignments(course_key, staff_user) - for assignment in assignments: - LOGGER.info( - "Updating assignment '%s' with due date '%s' for course %s", - assignment.title, - assignment.date, - course_key_str - ) - if not assignment.date: - LOGGER.info( - "Skipping assignment '%s' for course %s because it has no date", - assignment.title, - course_key_str - ) - continue - ContentDate.objects.update_or_create( - course_id=course_key, - location=assignment.block_key, - field='due', - block_type=assignment.assignment_type, - defaults={ - 'policy': DatePolicy.objects.get_or_create(abs_date=assignment.date)[0], - 'assignment_title': assignment.title, - 'course_name': course_key.course, - 'subsection_name': assignment.title - } - ) - LOGGER.info("Successfully updated assignment dates for course %s", course_key_str) - except Exception: # pylint: disable=broad-except - LOGGER.exception("Could not update assignment dates for course %s", course_key_str) - raise diff --git a/edx_when/tests/test_api.py b/edx_when/tests/test_api.py new file mode 100644 index 00000000..960546e6 --- /dev/null +++ b/edx_when/tests/test_api.py @@ -0,0 +1,166 @@ +""" +Tests for course date signals tasks. +""" +from unittest.mock import Mock, patch +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from django.contrib.auth import get_user_model +from datetime import datetime, timezone + +from edx_when.api import update_or_create_assignments_due_dates +from edx_when.models import ContentDate, DatePolicy + +User = get_user_model() + + +class TestUpdateAssignmentDatesForCourse(TestCase): + """ + Tests for the update_assignment_dates_for_course task. + """ + + def setUp(self): + self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + self.course_key_str = str(self.course_key) + self.staff_user = User.objects.create_user( + username='staff_user', + email='staff@example.com', + is_staff=True + ) + self.block_key = UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@test1' + ) + self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + self.assignments = [ + Mock( + title='Test Assignment', + date=self.due_date, + block_key=self.block_key, + assignment_type='Homework' + ) + ] + + def test_update_assignment_dates_new_records(self): + """ + Test inserting new records when missing. + """ + update_or_create_assignments_due_dates(self.course_key, self.assignments) + + content_date = ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertEqual(content_date.assignment_title, 'Test Assignment') + self.assertEqual(content_date.block_type, 'Homework') + self.assertEqual(content_date.policy.abs_date, self.due_date) + + def test_update_assignment_dates_existing_records(self): + """ + Test updating existing records when values differ. + """ + existing_policy = DatePolicy.objects.create( + abs_date=datetime(2024, 6, 1, tzinfo=timezone.utc) + ) + ContentDate.objects.create( + course_id=self.course_key, + location=self.block_key, + field='due', + block_type='Homework', + policy=existing_policy, + assignment_title='Old Title', + course_name=self.course_key.course, + subsection_name='Old Title' + ) + new_assignment = Mock( + title='Updated Assignment', + date=self.due_date, + block_key=self.block_key, + assignment_type='Homework' + ) + + update_or_create_assignments_due_dates(self.course_key, [new_assignment]) + + content_date = ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertEqual(content_date.assignment_title, 'Updated Assignment') + self.assertEqual(content_date.policy.abs_date, self.due_date) + + def test_assignment_with_null_date(self): + """ + Test handling assignments with null dates. + """ + null_date_assignment = Mock( + title='Null Date Assignment', + date=None, + block_key=self.block_key, + assignment_type='Homework' + ) + update_or_create_assignments_due_dates(self.course_key, [null_date_assignment]) + + content_date_exists = ContentDate.objects.filter( + course_id=self.course_key, + location=self.block_key + ).exists() + self.assertFalse(content_date_exists) + + def test_assignment_with_missing_metadata(self): + """ + Test handling assignments with missing metadata. + """ + assignment = Mock( + date=self.due_date, + block_key=self.block_key, + ) + update_or_create_assignments_due_dates(self.course_key, [assignment]) + + content_date_exists = ContentDate.objects.filter( + course_id=self.course_key, + location=self.block_key + ).exists() + self.assertFalse(content_date_exists) + + def test_multiple_assignments(self, mock_get_assignments): + """ + Test processing multiple assignments. + """ + assignment1 = Mock( + title='Assignment 1', + date=self.due_date, + block_key=self.block_key, + assignment_type='Gradeable' + ) + + assignment2 = Mock( + title='Assignment 2', + date=datetime(2025, 1, 15, tzinfo=timezone.utc), + block_key=UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@test2' + ), + assignment_type='Homework' + ) + update_or_create_assignments_due_dates(self.course_key, [assignment1, assignment2]) + self.assertEqual(ContentDate.objects.count(), 2) + + def test_empty_assignments_list(self, mock_get_assignments): + """ + Test handling empty assignments list. + """ + update_or_create_assignments_due_dates(self.course_key, []) + self.assertEqual(ContentDate.objects.count(), 0) + + @patch('edx_when.models.DatePolicy.objects.get_or_create') + def test_date_policy_creation_exception(self, mock_policy_create, mock_get_assignments): + """ + Test handling exception during DatePolicy creation. + """ + assignment = Mock( + title='Test Assignment', + date=self.due_date, + block_key=self.block_key, + assignment_type='problem' + ) + mock_policy_create.side_effect = Exception('Database Error') + + with self.assertRaises(Exception): + update_or_create_assignments_due_dates(self.course_key, [assignment]) diff --git a/edx_when/tests/test_tasks.py b/edx_when/tests/test_tasks.py deleted file mode 100644 index bace743d..00000000 --- a/edx_when/tests/test_tasks.py +++ /dev/null @@ -1,226 +0,0 @@ -from unittest.mock import Mock, patch -from django.test import TestCase -from opaque_keys.edx.keys import CourseKey, UsageKey -from edx_when.tasks import update_assignment_dates_for_course -from edx_when.models import ContentDate, DatePolicy -from django.contrib.auth import get_user_model -from datetime import datetime, timezone - -User = get_user_model() - - -class TestUpdateAssignmentDatesForCourse(TestCase): - - def setUp(self): - self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') - self.course_key_str = str(self.course_key) - self.staff_user = User.objects.create_user( - username='staff_user', - email='staff@example.com', - is_staff=True - ) - self.block_key = UsageKey.from_string( - 'block-v1:edX+DemoX+Demo_Course+type@sequential@test1' - ) - self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc) - - @patch('edx_when.tasks.get_course_assignments') - def test_update_assignment_dates_new_records(self, mock_get_assignments): - """ - Test inserting new records when missing. - """ - assignment = Mock() - assignment.title = 'Test Assignment' - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.assignment_type = 'Homework' - mock_get_assignments.return_value = [assignment] - - update_assignment_dates_for_course(self.course_key_str) - - content_date = ContentDate.objects.get( - course_id=self.course_key, - location=self.block_key - ) - self.assertEqual(content_date.assignment_title, 'Test Assignment') - self.assertEqual(content_date.block_type, 'problem') - self.assertEqual(content_date.policy.abs_date, self.due_date) - - @patch('edx_when.tasks.get_course_assignments') - def test_update_assignment_dates_existing_records(self, mock_get_assignments): - """ - Test updating existing records when values differ. - """ - existing_policy = DatePolicy.objects.create( - abs_date=datetime(2024, 6, 1, tzinfo=timezone.utc) - ) - ContentDate.objects.create( - course_id=self.course_key, - location=self.block_key, - field='date', - block_type='Homework', - policy=existing_policy, - assignment_title='Old Title', - course_name=self.course_key.course, - subsection_name='Old Title' - ) - - assignment = Mock() - assignment.title = 'Updated Assignment' - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.assignment_type = 'Homework' - mock_get_assignments.return_value = [assignment] - - update_assignment_dates_for_course(self.course_key_str) - - content_date = ContentDate.objects.get( - course_id=self.course_key, - location=self.block_key - ) - self.assertEqual(content_date.assignment_title, 'Updated Assignment') - self.assertEqual(content_date.policy.abs_date, self.due_date) - - @patch('edx_when.tasks.get_course_assignments') - def test_missing_staff_user(self, mock_get_assignments): - """ - Test graceful handling when no staff user exists. - """ - User.objects.filter(is_staff=True).delete() - - update_assignment_dates_for_course(self.course_key_str) - - mock_get_assignments.assert_not_called() - - @patch('edx_when.tasks.get_course_assignments') - def test_assignment_with_null_date(self, mock_get_assignments): - """ - Test handling assignments with null dates. - """ - assignment = Mock() - assignment.title = 'No Due Date Assignment' - assignment.date = None - assignment.block_key = self.block_key - assignment.assignment_type = 'Homework' - mock_get_assignments.return_value = [assignment] - - update_assignment_dates_for_course(self.course_key_str) - - content_date = ContentDate.objects.filter( - course_id=self.course_key, - location=self.block_key - ).first() - self.assertIsNone(content_date) - - @patch('edx_when.tasks.get_course_assignments') - def test_assignment_with_missing_metadata(self, mock_get_assignments): - """ - Test handling assignments with missing metadata. - """ - assignment = Mock() - assignment.title = None - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.assignment_type = None - mock_get_assignments.return_value = [assignment] - - update_assignment_dates_for_course(self.course_key_str) - - content_date = ContentDate.objects.get( - course_id=self.course_key, - location=self.block_key - ) - self.assertIsNone(content_date.assignment_title) - self.assertIsNone(content_date.block_type) - - @patch('edx_when.tasks.get_course_assignments') - def test_unsupported_block_types(self, mock_get_assignments): - """ - Test handling unsupported block types. - """ - assignment = Mock() - assignment.title = 'Unsupported Block' - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.block_type = 'unsupported_type' - mock_get_assignments.return_value = [assignment] - - update_assignment_dates_for_course(self.course_key_str) - - content_date = ContentDate.objects.get( - course_id=self.course_key, - location=self.block_key - ) - self.assertEqual(content_date.block_type, 'unsupported_type') - self.assertEqual(content_date.assignment_title, 'Unsupported Block') - - @patch('edx_when.tasks.get_course_assignments') - def test_multiple_assignments(self, mock_get_assignments): - """ - Test processing multiple assignments. - """ - assignment1 = Mock() - assignment1.title = 'Assignment 1' - assignment1.date = self.due_date - assignment1.block_key = self.block_key - assignment1.assignment_type = 'Gradeable' - - assignment2 = Mock() - assignment2.title = 'Assignment 2' - assignment2.date = datetime(2025, 1, 15, tzinfo=timezone.utc) - assignment2.block_key = UsageKey.from_string( - 'block-v1:edX+DemoX+Demo_Course+type@sequential@test2' - ) - assignment2.assignment_type = 'Homework' - - mock_get_assignments.return_value = [assignment1, assignment2] - - update_assignment_dates_for_course(self.course_key_str) - - self.assertEqual(ContentDate.objects.count(), 2) - - @patch('edx_when.tasks.get_course_assignments') - def test_invalid_course_key(self, mock_get_assignments): - """ - Test handling invalid course key. - """ - with self.assertRaises(Exception): - update_assignment_dates_for_course('invalid-course-key') - - @patch('edx_when.tasks.get_course_assignments') - def test_get_course_assignments_exception(self, mock_get_assignments): - """ - Test handling exception from get_course_assignments. - """ - mock_get_assignments.side_effect = Exception('API Error') - - with self.assertRaises(Exception): - update_assignment_dates_for_course(self.course_key_str) - - @patch('edx_when.tasks.get_course_assignments') - def test_empty_assignments_list(self, mock_get_assignments): - """ - Test handling empty assignments list. - """ - mock_get_assignments.return_value = [] - - update_assignment_dates_for_course(self.course_key_str) - - self.assertEqual(ContentDate.objects.count(), 0) - - @patch('edx_when.tasks.get_course_assignments') - @patch('edx_when.models.DatePolicy.objects.get_or_create') - def test_date_policy_creation_exception(self, mock_policy_create, mock_get_assignments): - """ - Test handling exception during DatePolicy creation. - """ - assignment = Mock() - assignment.title = 'Test Assignment' - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.assignment_type = 'problem' - mock_get_assignments.return_value = [assignment] - mock_policy_create.side_effect = Exception('Database Error') - - with self.assertRaises(Exception): - update_assignment_dates_for_course(self.course_key_str) From c819f24249bbd550914a94a7ef394cbda6e5f25d Mon Sep 17 00:00:00 2001 From: Andrii Date: Fri, 27 Jun 2025 14:41:08 +0300 Subject: [PATCH 04/22] 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 | 374 +++++++++++++++++++++++++++++++++++++ 3 files changed, 432 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 f56b4299..3bde772c 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 @@ -506,6 +506,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..6b99ecda --- /dev/null +++ b/edx_when/tests/test_api.py @@ -0,0 +1,374 @@ +from datetime import datetime, timedelta, timezone +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, tzinfo=timezone.utc)) + + 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, tzinfo=timezone.utc)) + + 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, tzinfo=timezone.utc)) From b2c49bcb4882f386681c16ab98e657656dcf4f06 Mon Sep 17 00:00:00 2001 From: Andrii Date: Tue, 1 Jul 2025 17:31:31 +0300 Subject: [PATCH 05/22] feat: add REST API for user dates retrieval --- edx_when/rest_api/__init__.py | 0 edx_when/rest_api/v1/__init__.py | 0 edx_when/rest_api/v1/tests/__init__.py | 0 edx_when/rest_api/v1/tests/test_views.py | 114 +++++++++++++++++++++++ edx_when/rest_api/v1/urls.py | 16 ++++ edx_when/rest_api/v1/views.py | 64 +++++++++++++ edx_when/urls.py | 10 +- edx_when/views.py | 17 ---- 8 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 edx_when/rest_api/__init__.py create mode 100644 edx_when/rest_api/v1/__init__.py create mode 100644 edx_when/rest_api/v1/tests/__init__.py create mode 100644 edx_when/rest_api/v1/tests/test_views.py create mode 100644 edx_when/rest_api/v1/urls.py create mode 100644 edx_when/rest_api/v1/views.py delete mode 100644 edx_when/views.py diff --git a/edx_when/rest_api/__init__.py b/edx_when/rest_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edx_when/rest_api/v1/__init__.py b/edx_when/rest_api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edx_when/rest_api/v1/tests/__init__.py b/edx_when/rest_api/v1/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edx_when/rest_api/v1/tests/test_views.py b/edx_when/rest_api/v1/tests/test_views.py new file mode 100644 index 00000000..416b4da5 --- /dev/null +++ b/edx_when/rest_api/v1/tests/test_views.py @@ -0,0 +1,114 @@ +""" +Tests for the UserDatesView in the edx_when REST API. +""" + +from datetime import datetime +from unittest.mock import patch + +from django.urls import reverse +from django.contrib.auth.models import User +from rest_framework.test import APITestCase + + +class TestUserDatesView(APITestCase): + """ + Tests for UserDatesView. + """ + + def setUp(self): + self.user = User.objects.create_user(username='testuser', password='testpass') + self.course_id = 'course-v1:TestOrg+TestCourse+TestRun' + self.url = reverse('edx_when:v1:user_dates', kwargs={'course_id': self.course_id}) + + @patch('edx_when.rest_api.v1.views.get_user_dates') + def test_get_user_dates_success(self, mock_get_user_dates): + """ + Test successful retrieval of user dates. + """ + mock_user_dates = { + ('assignment_1', 'due'): datetime(2023, 12, 15, 23, 59, 59), + ('quiz_1', 'due'): datetime(2023, 12, 20, 23, 59, 59), + } + mock_get_user_dates.return_value = mock_user_dates + + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, { + 'assignment_1': datetime(2023, 12, 15, 23, 59, 59), + 'quiz_1': datetime(2023, 12, 20, 23, 59, 59), + }) + mock_get_user_dates.assert_called_once_with( + self.course_id, + self.user.id, + block_types=None, + block_keys=None, + date_types=None + ) + + @patch('edx_when.rest_api.v1.views.get_user_dates') + def test_get_user_dates_with_filters(self, mock_get_user_dates): + """ + Test retrieval of user dates with query parameter filters. + """ + mock_get_user_dates.return_value = {} + + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url, { + 'block_types': 'assignment,quiz', + 'block_keys': 'block1,block2', + 'date_types': 'due,start' + }) + + self.assertEqual(response.status_code, 200) + mock_get_user_dates.assert_called_once_with( + self.course_id, + self.user.id, + block_types=['assignment', 'quiz'], + block_keys=['block1', 'block2'], + date_types=['due', 'start'] + ) + + @patch('edx_when.rest_api.v1.views.get_user_dates') + def test_get_user_dates_empty_filters(self, mock_get_user_dates): + """ + Test that empty filter parameters are converted to None. + """ + mock_get_user_dates.return_value = {} + + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url, { + 'block_types': '', + 'block_keys': '', + 'date_types': '' + }) + + self.assertEqual(response.status_code, 200) + mock_get_user_dates.assert_called_once_with( + self.course_id, + self.user.id, + block_types=None, + block_keys=None, + date_types=None + ) + + def test_get_user_dates_unauthenticated(self): + """ + Test that unauthenticated requests return 401. + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, 401) + + @patch('edx_when.rest_api.v1.views.get_user_dates') + def test_get_user_dates_empty_response(self, mock_get_user_dates): + """ + Test successful retrieval with empty user dates. + """ + mock_get_user_dates.return_value = {} + + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {}) diff --git a/edx_when/rest_api/v1/urls.py b/edx_when/rest_api/v1/urls.py new file mode 100644 index 00000000..18fa42d5 --- /dev/null +++ b/edx_when/rest_api/v1/urls.py @@ -0,0 +1,16 @@ +""" +URLs for edx_when REST API v1. +""" + +from django.conf import settings +from django.urls import re_path + +from . import views + +urlpatterns = [ + re_path( + r'user-dates/{}'.format(settings.COURSE_ID_PATTERN), + views.UserDatesView.as_view(), + name='user_dates', + ), +] diff --git a/edx_when/rest_api/v1/views.py b/edx_when/rest_api/v1/views.py new file mode 100644 index 00000000..ebfbb944 --- /dev/null +++ b/edx_when/rest_api/v1/views.py @@ -0,0 +1,64 @@ +""" +Views for the edx-when REST API v1. +""" + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView +from rest_framework.response import Response + +from edx_when.api import get_user_dates + + +class UserDatesView(APIView): + """ + View to handle user dates. + """ + + authentication_classes = (SessionAuthentication, JwtAuthentication) + permission_classes = (IsAuthenticated,) + + def get(self, request, *args, **kwargs): + """ + **Use Cases** + + Request user dates for a specific course. + + **Example Requests** + + GET /api/edx_when/v1/user-dates/{course_id} + + **Parameters:** + + block_types: (optional, str) Comma-separated list of block types to filter the dates. + block_keys: (optional, str) Comma-separated list of block keys to filter the dates. + date_types: (optional, str) Comma-separated list of date types to filter the dates. + + **Response Values** + + Body consists of the following fields: + + * user_dates: (dict) A dictionary containing user-specific dates for the course. + * The keys are date identifiers and the values are the corresponding date values. + + **Returns** + + * 200 on success with user dates. + * 401 if the user is not authenticated. + * 403 if the user does not have permission to access the course. + """ + + course_id = kwargs.get('course_id') + block_types = request.query_params.get('block_types', '').split(',') + block_keys = request.query_params.get('block_keys', '').split(',') + date_types = request.query_params.get('date_types', '').split(',') + + user_dates = get_user_dates( + course_id, + request.user.id, + block_types=block_types if block_types != [''] else None, + block_keys=block_keys if block_keys != [''] else None, + date_types=date_types if date_types != [''] else None + ) + return Response({str(key[0]): value for key, value in user_dates.items()}) diff --git a/edx_when/urls.py b/edx_when/urls.py index 5917cbe1..6757a59a 100644 --- a/edx_when/urls.py +++ b/edx_when/urls.py @@ -2,17 +2,11 @@ URLs for edx_when. """ -from django.conf import settings -from django.urls import re_path +from django.urls import include, path -from . import views app_name = 'edx_when' urlpatterns = [ - re_path( - r'edx_when/course/{}'.format(settings.COURSE_ID_PATTERN), - views.CourseDates.as_view(), - name='course_dates' - ) + path('edx_when/v1/', include('edx_when.rest_api.v1.urls'), name='v1'), ] diff --git a/edx_when/views.py b/edx_when/views.py deleted file mode 100644 index f79b08ef..00000000 --- a/edx_when/views.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Views for date-related REST APIs. -""" - -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from rest_framework.authentication import SessionAuthentication -from rest_framework.permissions import IsAuthenticated -from rest_framework.views import APIView - - -class CourseDates(APIView): - """ - Returns dates for a course. - """ - - authentication_classes = (SessionAuthentication, JwtAuthentication) - permission_classes = (IsAuthenticated,) From ba2b68ff34cc2f5346bac007911e55bb04118a7f Mon Sep 17 00:00:00 2001 From: Andrii Date: Wed, 2 Jul 2025 18:54:13 +0300 Subject: [PATCH 06/22] docs: add README.md with url description --- edx_when/rest_api/v1/README.md | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 edx_when/rest_api/v1/README.md diff --git a/edx_when/rest_api/v1/README.md b/edx_when/rest_api/v1/README.md new file mode 100644 index 00000000..9794d09e --- /dev/null +++ b/edx_when/rest_api/v1/README.md @@ -0,0 +1,77 @@ +## ๐Ÿ“˜ `GET /api/edx_when/v1/user-dates/{course_id}` + +### Description +Retrieves user-specific dates for a course in Open edX. Dates may include due dates, release dates, etc. Supports optional filtering. + +--- + +### ๐Ÿ” Authentication +- Required: โœ… Yes +- Methods: + - `SessionAuthentication` + - `JwtAuthentication` + +User must be authenticated and have access to the course. + +--- + +### ๐Ÿ“ฅ Path Parameters + +| Name | Type | Required | Description | +|------------|--------|----------|---------------------------------| +| course_id | string | โœ… Yes | Course ID in URL-encoded format | + +--- + +### ๐Ÿงพ Query Parameters (optional) + +| Name | Type | Description | +|-------------|--------|-------------------------------------------------------------------------| +| block_types | string | Comma-separated list of block types (e.g., `problem,html`) | +| block_keys | string | Comma-separated list of block keys (usage IDs or block identifiers) | +| date_types | string | Comma-separated list of date types (e.g., `start,due`) | + +--- + +### โœ… Response (200 OK) + +```json +{ + "block-v1:edX+DemoX+2023+type@problem+block@123abc": "2025-07-01T12:00:00Z", + "block-v1:edX+DemoX+2023+type@video+block@456def": "2025-07-03T09:30:00Z" +} +``` + +- A dictionary where keys are block identifiers and values are ISO 8601 date strings. + +--- + +### ๐Ÿ”’ Response Codes + +| Code | Meaning | +|------|----------------------------------| +| 200 | Success | +| 401 | Unauthorized (not logged in) | +| 403 | Forbidden (no access to course) | + +--- + +### ๐Ÿ’ก Usage Example + +#### Request +```http +GET /api/edx_when/v1/user-dates/course-v1:edX+DemoX+2023 +``` + +#### With Filters +```http +GET /api/edx_when/v1/user-dates/course-v1:edX+DemoX+2023?block_types=problem,video&date_types=due +``` + +#### Curl Example +```bash +curl -X GET "https://your-domain.org/api/edx_when/v1/user-dates/course-v1:edX+DemoX+2023?block_types=problem&date_types=due" \ + -H "Authorization: Bearer " +``` + +--- From 9d0de4d437ea93912c736f3ca9d3be5207714d0c Mon Sep 17 00:00:00 2001 From: Andrii Date: Mon, 7 Jul 2025 12:47:57 +0300 Subject: [PATCH 07/22] refactor: add processing for all enrolled courses if no course_id provided --- edx_when/rest_api/v1/README.md | 22 ++++++++++--- edx_when/rest_api/v1/tests/test_views.py | 41 ++++++++++++++++++++++++ edx_when/rest_api/v1/urls.py | 2 +- edx_when/rest_api/v1/views.py | 38 ++++++++++++++++------ 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/edx_when/rest_api/v1/README.md b/edx_when/rest_api/v1/README.md index 9794d09e..b7b6a76e 100644 --- a/edx_when/rest_api/v1/README.md +++ b/edx_when/rest_api/v1/README.md @@ -1,7 +1,8 @@ -## ๐Ÿ“˜ `GET /api/edx_when/v1/user-dates/{course_id}` +### ๐Ÿ“˜ `GET /api/edx_when/v1/user-dates/` +### ๐Ÿ“˜ `GET /api/edx_when/v1/user-dates/{course_id}` ### Description -Retrieves user-specific dates for a course in Open edX. Dates may include due dates, release dates, etc. Supports optional filtering. +Retrieves user-specific dates for a specific course or all enrolled courses. in Open edX. Dates may include due dates, release dates, etc. Supports optional filtering. --- @@ -19,7 +20,7 @@ User must be authenticated and have access to the course. | Name | Type | Required | Description | |------------|--------|----------|---------------------------------| -| course_id | string | โœ… Yes | Course ID in URL-encoded format | +| course_id | string | โŒ No | Course ID in URL-encoded format | --- @@ -58,17 +59,30 @@ User must be authenticated and have access to the course. ### ๐Ÿ’ก Usage Example -#### Request +#### Requests +```http +GET /api/edx_when/v1/user-dates/ +``` + ```http GET /api/edx_when/v1/user-dates/course-v1:edX+DemoX+2023 ``` #### With Filters +```http +GET /api/edx_when/v1/user-dates/?block_types=problem,video&date_types=due +``` + ```http GET /api/edx_when/v1/user-dates/course-v1:edX+DemoX+2023?block_types=problem,video&date_types=due ``` #### Curl Example +```bash +curl -X GET "https://your-domain.org/api/edx_when/v1/user-dates/?block_types=problem&date_types=due" \ + -H "Authorization: Bearer " +``` + ```bash curl -X GET "https://your-domain.org/api/edx_when/v1/user-dates/course-v1:edX+DemoX+2023?block_types=problem&date_types=due" \ -H "Authorization: Bearer " diff --git a/edx_when/rest_api/v1/tests/test_views.py b/edx_when/rest_api/v1/tests/test_views.py index 416b4da5..4e04914a 100644 --- a/edx_when/rest_api/v1/tests/test_views.py +++ b/edx_when/rest_api/v1/tests/test_views.py @@ -112,3 +112,44 @@ def test_get_user_dates_empty_response(self, mock_get_user_dates): self.assertEqual(response.status_code, 200) self.assertEqual(response.data, {}) + + @patch('edx_when.rest_api.v1.views.get_user_dates') + def test_get_user_dates_multiple_courses(self, mock_get_user_dates): + """ + Test retrieval of user dates for multiple enrolled courses. + """ + with patch.object(self.user, 'courseenrollment_set') as mock_enrollment_set: + mock_enrollment_set.filter.return_value.values_list.return_value = [ + 'course-v1:TestOrg+Course1+Run1', + 'course-v1:TestOrg+Course2+Run2' + ] + + mock_get_user_dates.side_effect = [ + {('assignment_1', 'due'): datetime(2023, 12, 15, 23, 59, 59)}, + {('quiz_1', 'due'): datetime(2023, 12, 20, 23, 59, 59)} + ] + + self.client.force_authenticate(user=self.user) + url = reverse('edx_when:v1:user_dates_no_course') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data.keys()), 2) + self.assertEqual(response.data['assignment_1'], datetime(2023, 12, 15, 23, 59, 59)) + self.assertEqual(response.data['quiz_1'], datetime(2023, 12, 20, 23, 59, 59)) + + self.assertEqual(mock_get_user_dates.call_count, 2) + mock_get_user_dates.assert_any_call( + 'course-v1:TestOrg+Course1+Run1', + self.user.id, + block_types=None, + block_keys=None, + date_types=None + ) + mock_get_user_dates.assert_any_call( + 'course-v1:TestOrg+Course2+Run2', + self.user.id, + block_types=None, + block_keys=None, + date_types=None + ) diff --git a/edx_when/rest_api/v1/urls.py b/edx_when/rest_api/v1/urls.py index 18fa42d5..f0c9bccc 100644 --- a/edx_when/rest_api/v1/urls.py +++ b/edx_when/rest_api/v1/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ re_path( - r'user-dates/{}'.format(settings.COURSE_ID_PATTERN), + r'user-dates/(?:{})?'.format(settings.COURSE_ID_PATTERN), views.UserDatesView.as_view(), name='user_dates', ), diff --git a/edx_when/rest_api/v1/views.py b/edx_when/rest_api/v1/views.py index ebfbb944..dc6dcd76 100644 --- a/edx_when/rest_api/v1/views.py +++ b/edx_when/rest_api/v1/views.py @@ -23,14 +23,16 @@ def get(self, request, *args, **kwargs): """ **Use Cases** - Request user dates for a specific course. + Request user dates for a specific course or all enrolled courses. **Example Requests** + GET /api/edx_when/v1/user-dates/ GET /api/edx_when/v1/user-dates/{course_id} **Parameters:** + course_id: (optional, str) Course ID to get dates for. If not provided, returns dates for all enrolled courses. block_types: (optional, str) Comma-separated list of block types to filter the dates. block_keys: (optional, str) Comma-separated list of block keys to filter the dates. date_types: (optional, str) Comma-separated list of date types to filter the dates. @@ -39,9 +41,16 @@ def get(self, request, *args, **kwargs): Body consists of the following fields: - * user_dates: (dict) A dictionary containing user-specific dates for the course. + * user_dates: (dict) A dictionary containing user-specific dates for the course(s). * The keys are date identifiers and the values are the corresponding date values. + **Example Response** + + { + "block1_due": "2023-10-01T12:00:00Z", + "block2_start": "2023-10-05T08:00:00 + } + **Returns** * 200 on success with user dates. @@ -54,11 +63,22 @@ def get(self, request, *args, **kwargs): block_keys = request.query_params.get('block_keys', '').split(',') date_types = request.query_params.get('date_types', '').split(',') - user_dates = get_user_dates( - course_id, - request.user.id, - block_types=block_types if block_types != [''] else None, - block_keys=block_keys if block_keys != [''] else None, - date_types=date_types if date_types != [''] else None + enrolled_courses = ( + [course_id] + if course_id + else request.user.courseenrollment_set.filter(is_active=True).values_list( + "course_id", flat=True + ) ) - return Response({str(key[0]): value for key, value in user_dates.items()}) + all_user_dates = {} + + for enrolled_course_id in enrolled_courses: + user_dates = get_user_dates( + enrolled_course_id, + request.user.id, + block_types=block_types if block_types != [''] else None, + block_keys=block_keys if block_keys != [''] else None, + date_types=date_types if date_types != [''] else None + ) + all_user_dates.update({str(key[0]): value for key, value in user_dates.items()}) + return Response(all_user_dates) From b93b0097f2c87fe65afd249006745304f9b1f79a Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Tue, 26 Aug 2025 10:31:19 +0300 Subject: [PATCH 08/22] feat: update UserDate model and migrations --- ...t_title_contentdate_course_name_and_more.py | 11 +---------- .../0010_userdate_is_content_gated.py | 18 ++++++++++++++++++ edx_when/models.py | 7 +++---- 3 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 edx_when/migrations/0010_userdate_is_content_gated.py diff --git a/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py b/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py index 66be68be..f664107a 100644 --- a/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py +++ b/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.22 on 2025-06-23 13:56 +# Generated by Django 4.2.22 on 2025-08-20 09:11 from django.db import migrations, models import opaque_keys.edx.django.models @@ -31,11 +31,6 @@ class Migration(migrations.Migration): name='first_component_block_id', field=opaque_keys.edx.django.models.UsageKeyField(blank=True, db_index=True, max_length=255, null=True), ), - migrations.AddField( - model_name='userdate', - name='user_date_value', - field=models.DateTimeField(blank=True, db_index=True, null=True), - ), migrations.AddIndex( model_name='contentdate', index=models.Index(fields=['assignment_title', 'course_id'], name='edx_when_assignment_course_idx'), @@ -48,8 +43,4 @@ class Migration(migrations.Migration): model_name='userdate', index=models.Index(fields=['user', 'first_component_block_id'], name='edx_when_user_first_block_idx'), ), - migrations.AddIndex( - model_name='userdate', - index=models.Index(fields=['user_date_value', 'user'], name='edx_when_user_date_value_idx'), - ), ] diff --git a/edx_when/migrations/0010_userdate_is_content_gated.py b/edx_when/migrations/0010_userdate_is_content_gated.py new file mode 100644 index 00000000..02fff731 --- /dev/null +++ b/edx_when/migrations/0010_userdate_is_content_gated.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.22 on 2025-08-20 15:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('edx_when', '0009_contentdate_assignment_title_contentdate_course_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='userdate', + name='is_content_gated', + field=models.BooleanField(default=False), + ), + ] diff --git a/edx_when/models.py b/edx_when/models.py index d9d7af57..677688d6 100644 --- a/edx_when/models.py +++ b/edx_when/models.py @@ -139,14 +139,13 @@ class UserDate(TimeStampedModel): get_user_model(), null=True, default=None, blank=True, related_name="actor", on_delete=models.CASCADE ) first_component_block_id = UsageKeyField(null=True, blank=True, max_length=255, db_index=True) - user_date_value = models.DateTimeField(null=True, blank=True, db_index=True) + is_content_gated = models.BooleanField(default=False) class Meta: """Django Metadata.""" - + indexes = [ models.Index(fields=('user', 'first_component_block_id'), name='edx_when_user_first_block_idx'), - models.Index(fields=('user_date_value', 'user'), name='edx_when_user_date_value_idx'), ] @property @@ -199,4 +198,4 @@ def __repr__(self): """ return (f'UserDate(id={self.id}, user="{self.user.username}", ' f'first_component_block_id={self.first_component_block_id}, ' - f'user_date_value={self.user_date_value}, content_date={self.content_date.id})') + f'content_date={self.content_date.id})') From 0762b4f6e91619b65bd4e680f2ddcd3bd8078d96 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Tue, 26 Aug 2025 10:32:01 +0300 Subject: [PATCH 09/22] feat: add new fields to _Assignment dataclass --- edx_when/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/edx_when/api.py b/edx_when/api.py index 93202f07..b1e2b87d 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -516,6 +516,8 @@ class _Assignment: date: datetime block_key: UsageKey assignment_type: str + contains_gated_content: bool + first_component_block_id: str def __post_init__(self): if not isinstance(self.date, datetime): From 12f89d55a42cf8f1cf979b60d006ca733a22586e Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Tue, 26 Aug 2025 10:38:34 +0300 Subject: [PATCH 10/22] feat: add UserDateHandler class --- edx_when/api.py | 226 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/edx_when/api.py b/edx_when/api.py index b1e2b87d..54ef8f08 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -14,6 +14,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey from . import models +from .models import UserDate, ContentDate from .utils import get_schedule_for_user try: @@ -626,3 +627,228 @@ class MissingDateError(BaseWhenException): class InvalidDateError(BaseWhenException): pass + + +@dataclass +class UserDateHandler: + """ + A handler for managing UserDate records in a specific course. + + The handler provides the public API for: + - Creating UserDate rows for a given user + - Deleting UserDate rows for a given user + - Synchronizing UserDate rows for a given user + + Synchronization involves determining the desired configuration of user dates (based on the provided course-level + and assignment-level data), comparing it to the existing state, and reconciling the differences by: + - Creating new rows + - Updating existing rows + - Deleting stale rows + + Args: + course_key (str): The course identifier to scope operations. + """ + + course_key: str + + def create_for_user(self, user_id: int, assignments: list, course_data: dict) -> None: + """ + Create UserDate entries for a user in a course. + + This method inserts new UserDates for: + - Course-level dates (start, end). + - Assignment-level due dates. + + Args: + user_id (int): The ID of the user for whom UserDates should be created. + assignments (list): A list of _Assignment named tuples (from get_course_assignments). + course_data (dict): Course-level data, including: + - start (datetime or None) + - end (datetime or None) + - location (str): course block key + """ + active_content_dates = self._map_active_content_dates() + course_dates = self._build_course_dates(user_id, course_data, active_content_dates) + assignment_dates = self._build_assignment_dates(user_id, assignments, active_content_dates) + + to_create = course_dates + assignment_dates + UserDate.objects.bulk_create(to_create) + + def delete_for_user(self, user_id: int): + """ + Delete all UserDate entries for a user in a course. + + Args: + user_id (int): The ID of the user whose UserDates should be removed. + """ + UserDate.objects.filter(user_id=user_id, content_date__course_id=self.course_key).delete() + + def sync_for_user(self, user_id: int, assignments: list, course_data: dict | None = None): + """ + Synchronize UserDates for a user with the current course configuration. + This ensures that UserDates match the desired state: + - Missing UserDates are created + - Outdated UserDates are updated + - Stale UserDates (no longer valid) are deleted + + All of these operations on the UserDate table are performed in bulk, inside a transaction. + + Args: + user_id (int): The ID of the user being synchronized. + assignments (list): A list of assignment objects. + course_data (dict, optional): Course-level data including start/end dates and location. + """ + target_user_date_map = {} + active_content_date_map = self._map_active_content_dates() + + if course_data: + course_dates = self._map_target_course_dates(user_id, course_data, active_content_date_map) + target_user_date_map |= course_dates + + assignment_dates = self._map_target_assignment_dates(user_id, assignments, active_content_date_map) + target_user_date_map |= assignment_dates + + existing_user_date_map = self._map_existing_dates(user_id) + + to_create, to_update = self._diff_creates_and_updates(user_id, target_user_date_map, existing_user_date_map) + to_delete = [ud.id for key, ud in existing_user_date_map.items() if key not in target_user_date_map] + + try: + with transaction.atomic(): + self._bulk_commit(to_create, to_update, to_delete) + + log.info( + f"UserDates synced for user_id={user_id} in {self.course_key}: " + f"len({to_create}) created, len({to_update}) updated, len({to_delete}) deleted." + ) + + except Exception: # pylint: disable=broad-except + log.exception(f"UserDate sync failed for user_id={user_id} in {self.course_key}") + raise + + + @staticmethod + def _validate(obj: UserDate): + try: + obj.full_clean() + except ValidationError as error: + raise InvalidDateError(obj.actual_date) from error + + def _map_active_content_dates(self) -> dict: + return { + (str(cd.location), cd.field): cd.id + for cd in ContentDate.objects.filter(course_id=self.course_key, active=True) + } + + def _build_course_dates(self, user_id: int, course_data: dict, active_content_dates: dict) -> list[UserDate]: + course_dates = [] + course_location = course_data["location"] + + for field in "start", "end": + content_date_key = course_location, field + content_date_id = active_content_dates.get(content_date_key) + if not content_date_id: + continue + + user_date = UserDate( + user_id=user_id, + content_date_id=content_date_id, + first_component_block_id=course_location, + ) + self._validate(user_date) + course_dates.append(user_date) + + return course_dates + + def _build_assignment_dates(self, user_id: int, assignments: list, active_content_dates: dict) -> list[UserDate]: + assignment_dates = [] + + for assignment in assignments: + content_date_key = str(assignment.block_key), "due" + content_date_id = active_content_dates.get(content_date_key) + if not content_date_id: + continue + + user_date = UserDate( + user_id=user_id, + content_date_id=content_date_id, + first_component_block_id=assignment.first_component_block_id, + ) + self._validate(user_date) + assignment_dates.append(user_date) + + return assignment_dates + + @staticmethod + def _map_target_course_dates(user_id: int, course_data: dict, active_content_dates: dict) -> dict: + target_map = {} + course_location = course_data["location"] + + for field in "start", "end": + content_date_key = course_location, field + content_date_id = active_content_dates.get(content_date_key) + if not content_date_id: + continue + + user_date_key = user_id, content_date_id + target_map[user_date_key] = { + "content_date_id": content_date_id, + "first_component_block_id": course_location, + } + + return target_map + + @staticmethod + def _map_target_assignment_dates(user_id: int, assignments: list, active_content_dates: dict) -> dict: + target_map = {} + + for assignment in assignments: + content_date_key = str(assignment.block_key), "due" + content_date_id = active_content_dates.get(content_date_key) + if not content_date_id: + continue + + user_date_key = user_id, content_date_id + target_map[user_date_key] = { + "content_date_id": content_date_id, + "first_component_block_id": assignment.first_component_block_id, + "is_content_gated": assignment.contains_gated_content, + } + + return target_map + + def _map_existing_dates(self, user_id: int) -> dict: + existing_user_dates = UserDate.objects.filter(user_id=user_id, content_date__course_id=self.course_key) + return {(ud.user_id, ud.content_date_id): ud for ud in existing_user_dates} + + def _diff_creates_and_updates(self, user_id: int, target_dates: dict, existing_dates: dict) -> tuple: + to_create = [] + to_update = [] + + for key, target_data in target_dates.items(): + if key not in existing_dates: + new_ud = UserDate(user_id=user_id, **target_data) + self._validate(new_ud) + to_create.append(new_ud) + else: + existing_ud = existing_dates[key] + existing_ud.first_component_block_id = target_data.get("first_component_block_id") + existing_ud.is_content_gated = target_data.get("is_content_gated", False) + to_update.append(existing_ud) + + return to_create, to_update + + @staticmethod + def _bulk_commit(to_create: list, to_update: list, to_delete: list) -> None: + if to_create: + UserDate.objects.bulk_create(to_create, batch_size=500) + + if to_update: + UserDate.objects.bulk_update( + to_update, + fields=["abs_date", "rel_date", "first_component_block_id", "is_content_gated"], + batch_size=500 + ) + + if to_delete: + UserDate.objects.filter(id__in=to_delete).delete() From 0638537d03033bf7461b171baf44835d92deddfd Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Tue, 26 Aug 2025 10:39:15 +0300 Subject: [PATCH 11/22] test: add test for UserDateHandler --- edx_when/tests/test_api.py | 199 +++++++++++++++++++++++++++++++++++-- 1 file changed, 191 insertions(+), 8 deletions(-) diff --git a/edx_when/tests/test_api.py b/edx_when/tests/test_api.py index 6d793355..f899fe57 100644 --- a/edx_when/tests/test_api.py +++ b/edx_when/tests/test_api.py @@ -7,7 +7,7 @@ from django.contrib.auth import get_user_model from datetime import datetime, timedelta, timezone -from edx_when.api import update_or_create_assignments_due_dates +from edx_when.api import update_or_create_assignments_due_dates, UserDateHandler, _Assignment from edx_when.models import ContentDate, DatePolicy, UserDate User = get_user_model() @@ -170,7 +170,7 @@ 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. @@ -258,7 +258,7 @@ def test_get_user_dates_with_block_type_filter(self): ) 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) @@ -374,11 +374,11 @@ def test_get_user_dates_multiple_filters(self): ) result = api.get_user_dates( - course_id, user_id, - block_types=['sequential'], + 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) @@ -424,7 +424,7 @@ def test_get_user_dates_missing_schedule_error_handled(self): policy=policy, block_type='sequential' ) - + result = api.get_user_dates(course_id, user_id) self.assertEqual(len(result), 0) @@ -507,7 +507,7 @@ def test_get_user_dates_latest_user_override(self): policy=policy, block_type='sequential' ) - + user = User.objects.create(username='testuser', id=user_id) older_override = UserDate.objects.create( @@ -530,3 +530,186 @@ def test_get_user_dates_latest_user_override(self): expected_key = (block_key, 'due') self.assertEqual(result[expected_key], datetime(2023, 1, 25, 10, 0, 0, tzinfo=timezone.utc)) + + +class TestUserDateHandler(TestCase): + """ + Tests for the UserDateHandler class, which manages creation, deletion, and synchronization of UserDate records + for users in a given course. + The tests verify that UserDateHandler correctly interacts with the database when handling course-level and + assignment-level content dates. They focus on the public API methods of the handler: + create_for_user, delete_for_user, sync_for_user. + """ + + def setUp(self): + self.user = User.objects.create(username="test_user") + self.course_key = CourseKey.from_string('course-v1:TestX+Test+2025') + self.block_key = UsageKey.from_string('block-v1:TestX+Test+2025+type@sequential+block@test') + self.course_block_key = UsageKey.from_string('block-v1:TestX+Test+2025+type@course+block@course') + self.policy = DatePolicy.objects.create(abs_date=datetime(2025, 1, 15, 10, 0, 0)) + self.content_date = ContentDate.objects.create( + course_id=self.course_key, + location=self.block_key, + field='due', + active=True, + policy=self.policy, + block_type='sequential' + ) + self.content_date_course_start = ContentDate.objects.create( + course_id=self.course_key, + location=self.course_block_key, + field='start', + active=True, + policy=DatePolicy.objects.create(abs_date=datetime(2025, 1, 2)), + block_type='course' + ) + self.content_date_course_end = ContentDate.objects.create( + course_id=self.course_key, + location=self.course_block_key, + field='end', + active=True, + policy=DatePolicy.objects.create(abs_date=datetime(2025, 1, 3)), + block_type='course' + ) + self.assignments = [ + _Assignment( + title='Test Assignment 1', + date=datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc), + block_key=self.block_key, + assignment_type='Homework', + first_component_block_id=self.block_key, + contains_gated_content=False, + + ) + ] + self.course_data = { + "start": datetime(2025, 1, 1), + "end": datetime(2025, 2, 1), + "location": str(self.course_block_key), + } + self.handler = UserDateHandler(str(self.course_key)) + + def test_user_dates_are_created(self): + """ + Ensure that `create_for_user` correctly creates UserDate records for both course-level (start/end) + and assignment-level dates when provided with course data and assignment input. + + This test verifies: + - The expected number of UserDate objects is created. + - The created UserDates are linked to the correct ContentDate rows. + """ + self.handler.create_for_user(self.user.id, self.assignments, self.course_data) + + uds = UserDate.objects.filter(user_id=self.user.id) + self.assertEqual(uds.count(), 3) + self.assertSetEqual( + {ud.content_date_id for ud in uds}, + {self.content_date.id, self.content_date_course_start.id, self.content_date_course_end.id}) + + def test_user_dates_are_deleted(self): + """ + Ensure that `delete_for_user` removes all UserDate records associated with a specific user and course. + + This test verifies: + - UserDate rows associated with the target course are deleted. + - UserDate rows associated with a different course are kept untouched. + """ + ud_to_delete = UserDate.objects.create(user=self.user, content_date=self.content_date) + + course_key_2 = CourseKey.from_string('course-v1:Other+Course+2026') + block_key_2 = UsageKey.from_string('block-v1:Other+Course+2026+type@sequential+block@test') + policy_2 = DatePolicy.objects.create(abs_date=datetime(2026, 1, 15, 10, 0, 0)) + content_date_2 = ContentDate.objects.create( + course_id=course_key_2, + location=block_key_2, + field='due', + active=True, + policy=policy_2, + block_type='sequential' + ) + ud_to_keep = UserDate.objects.create(user=self.user, content_date=content_date_2) + + self.handler.delete_for_user(self.user.id) + + self.assertFalse(UserDate.objects.filter(id=ud_to_delete.id).exists()) + self.assertTrue(UserDate.objects.filter(id=ud_to_keep.id).exists()) + + def test_user_dates_are_synced(self): + """ + Ensure that `sync_for_user` synchronizes the UserDate rows for a user by creating, updating, and deleting rows + to reflect the current state. This test verifies that: + - New UserDates are created when missing. + - Existing UserDates are updated when values differ. + - UserDates that no longer correspond to active content dates are deleted. + """ + ud_to_update = UserDate.objects.create( + user_id=self.user.id, + content_date=self.content_date, + first_component_block_id=self.block_key, + is_content_gated=False, + ) + content_date_2 = ContentDate.objects.create( + course_id=self.course_key, + location=UsageKey.from_string('block-v1:TestX+Test+2025+type@sequential+block@test3'), + field='due', + active=True, + policy=DatePolicy.objects.create(abs_date=datetime(2026, 1, 1)), + block_type='sequential' + ) + ud_to_delete = UserDate.objects.create(user=self.user, content_date=content_date_2) + + block_key_1 = UsageKey.from_string('block-v1:TestX+Test+2025+type@sequential+block@test1') + block_key_2 = UsageKey.from_string('block-v1:TestX+Test+2025+type@sequential+block@test2') + + content_date_2 = ContentDate.objects.create( + course_id=self.course_key, + location=block_key_2, + field='due', + active=True, + policy=DatePolicy.objects.create(abs_date=datetime(2026, 1, 1)), + block_type='sequential' + ) + + assignments = [ + _Assignment( + title='Test Assignment 1', + date=datetime(2025, 10, 10), + block_key=self.block_key, + assignment_type='Homework', + # fields with new values that should be populated in the existing UserDate + first_component_block_id=block_key_1, + contains_gated_content=True, + ), + _Assignment( + title='Test Assignment 2', + date=datetime(2025, 11, 11), + block_key=block_key_2, + assignment_type='Lab', + first_component_block_id=block_key_2, + contains_gated_content=True, + ) + ] + course_data = { + "start": datetime(2025, 1, 1), + "end": datetime(2025, 2, 1), + "location": str(self.course_key), + } + + self.handler.sync_for_user(self.user.id, assignments, course_data) + + # Test create + created_uds = UserDate.objects.filter(user=self.user, content_date=content_date_2) + self.assertTrue(created_uds.exists()) + created_ud = created_uds[0] + self.assertEqual(created_ud.first_component_block_id, block_key_2) + self.assertTrue(created_ud.is_content_gated, True) + + # Test update + updated_uds = UserDate.objects.filter(id=ud_to_update.id) + self.assertTrue(updated_uds.exists()) + updated_ud = updated_uds[0] + self.assertEqual(updated_ud.first_component_block_id, block_key_1) + self.assertTrue(updated_ud.is_content_gated, True) + + # Test delete + self.assertFalse(UserDate.objects.filter(id=ud_to_delete.id).exists()) From 8916aa0e113f3558091f03fbfd94a3e9b333aed4 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Thu, 4 Sep 2025 19:43:03 +0300 Subject: [PATCH 12/22] style: enclose tuples in parentheses --- edx_when/api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/edx_when/api.py b/edx_when/api.py index 54ef8f08..f1beb1ef 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -744,8 +744,8 @@ def _build_course_dates(self, user_id: int, course_data: dict, active_content_da course_dates = [] course_location = course_data["location"] - for field in "start", "end": - content_date_key = course_location, field + for field in ("start", "end"): + content_date_key = (course_location, field) content_date_id = active_content_dates.get(content_date_key) if not content_date_id: continue @@ -784,8 +784,8 @@ def _map_target_course_dates(user_id: int, course_data: dict, active_content_dat target_map = {} course_location = course_data["location"] - for field in "start", "end": - content_date_key = course_location, field + for field in ("start", "end"): + content_date_key = (course_location, field) content_date_id = active_content_dates.get(content_date_key) if not content_date_id: continue From 46cc01ccc0a231d2c102baf4e43459859a76aae5 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Thu, 4 Sep 2025 20:38:55 +0300 Subject: [PATCH 13/22] docs: add docstrings to private helper methods --- edx_when/api.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/edx_when/api.py b/edx_when/api.py index f1beb1ef..1fcf7e30 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -726,21 +726,27 @@ def sync_for_user(self, user_id: int, assignments: list, course_data: dict | Non log.exception(f"UserDate sync failed for user_id={user_id} in {self.course_key}") raise + # ------------------------- + # Private helper methods + # ------------------------- @staticmethod def _validate(obj: UserDate): + """Validate a UserDate object before saving, raising InvalidDateError if invalid.""" try: obj.full_clean() except ValidationError as error: raise InvalidDateError(obj.actual_date) from error def _map_active_content_dates(self) -> dict: + """Return a mapping of (block_key, field) โ†’ content_date_id for active ContentDates in this course.""" return { (str(cd.location), cd.field): cd.id for cd in ContentDate.objects.filter(course_id=self.course_key, active=True) } def _build_course_dates(self, user_id: int, course_data: dict, active_content_dates: dict) -> list[UserDate]: + """Return a list of UserDate objects to be created for course-level start/end dates.""" course_dates = [] course_location = course_data["location"] @@ -761,6 +767,7 @@ def _build_course_dates(self, user_id: int, course_data: dict, active_content_da return course_dates def _build_assignment_dates(self, user_id: int, assignments: list, active_content_dates: dict) -> list[UserDate]: + """Return a list of UserDate objects to be created for assignment due dates.""" assignment_dates = [] for assignment in assignments: @@ -781,6 +788,7 @@ def _build_assignment_dates(self, user_id: int, assignments: list, active_conten @staticmethod def _map_target_course_dates(user_id: int, course_data: dict, active_content_dates: dict) -> dict: + """For course-level start/end dates, map combinations of user and ContentDates to desired UserDate attributes.""" target_map = {} course_location = course_data["location"] @@ -800,6 +808,7 @@ def _map_target_course_dates(user_id: int, course_data: dict, active_content_dat @staticmethod def _map_target_assignment_dates(user_id: int, assignments: list, active_content_dates: dict) -> dict: + """For assignment-level due dates, map combinations of user and ContentDates to desired UserDate attributes.""" target_map = {} for assignment in assignments: @@ -818,10 +827,18 @@ def _map_target_assignment_dates(user_id: int, assignments: list, active_content return target_map def _map_existing_dates(self, user_id: int) -> dict: + """For all of user's UserDates within the course, map their user_id+content_date_id to the actual object.""" existing_user_dates = UserDate.objects.filter(user_id=user_id, content_date__course_id=self.course_key) return {(ud.user_id, ud.content_date_id): ud for ud in existing_user_dates} def _diff_creates_and_updates(self, user_id: int, target_dates: dict, existing_dates: dict) -> tuple: + """ + Compare target and existing UserDates and make two buckets of UserDate objects: + those to be created and those to be updated. + + Returns: + tuple: (to_create, to_update) lists of UserDate objects. + """ to_create = [] to_update = [] @@ -840,6 +857,7 @@ def _diff_creates_and_updates(self, user_id: int, target_dates: dict, existing_d @staticmethod def _bulk_commit(to_create: list, to_update: list, to_delete: list) -> None: + """Perform the create, update, and delete operations in bulk.""" if to_create: UserDate.objects.bulk_create(to_create, batch_size=500) From c225b09f208bd5644687c026e600f2b0bc706853 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Thu, 4 Sep 2025 20:52:57 +0300 Subject: [PATCH 14/22] style: expand type hints for return values --- edx_when/api.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/edx_when/api.py b/edx_when/api.py index 1fcf7e30..43a8d303 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta import logging +from typing import Any from django.core.exceptions import ValidationError from django.db import transaction @@ -731,14 +732,14 @@ def sync_for_user(self, user_id: int, assignments: list, course_data: dict | Non # ------------------------- @staticmethod - def _validate(obj: UserDate): + def _validate(obj: UserDate) -> None: """Validate a UserDate object before saving, raising InvalidDateError if invalid.""" try: obj.full_clean() except ValidationError as error: raise InvalidDateError(obj.actual_date) from error - def _map_active_content_dates(self) -> dict: + def _map_active_content_dates(self) -> dict[tuple[str, str], int]: """Return a mapping of (block_key, field) โ†’ content_date_id for active ContentDates in this course.""" return { (str(cd.location), cd.field): cd.id @@ -787,7 +788,7 @@ def _build_assignment_dates(self, user_id: int, assignments: list, active_conten return assignment_dates @staticmethod - def _map_target_course_dates(user_id: int, course_data: dict, active_content_dates: dict) -> dict: + def _map_target_course_dates(user_id: int, course_data: dict, active_content_dates: dict) -> dict[tuple[int, int], dict[str, Any]]: """For course-level start/end dates, map combinations of user and ContentDates to desired UserDate attributes.""" target_map = {} course_location = course_data["location"] @@ -807,7 +808,7 @@ def _map_target_course_dates(user_id: int, course_data: dict, active_content_dat return target_map @staticmethod - def _map_target_assignment_dates(user_id: int, assignments: list, active_content_dates: dict) -> dict: + def _map_target_assignment_dates(user_id: int, assignments: list, active_content_dates: dict) -> dict[tuple[int, int], dict[str, Any]]: """For assignment-level due dates, map combinations of user and ContentDates to desired UserDate attributes.""" target_map = {} @@ -826,12 +827,12 @@ def _map_target_assignment_dates(user_id: int, assignments: list, active_content return target_map - def _map_existing_dates(self, user_id: int) -> dict: + def _map_existing_dates(self, user_id: int) -> dict[tuple[int, int], UserDate]: """For all of user's UserDates within the course, map their user_id+content_date_id to the actual object.""" existing_user_dates = UserDate.objects.filter(user_id=user_id, content_date__course_id=self.course_key) return {(ud.user_id, ud.content_date_id): ud for ud in existing_user_dates} - def _diff_creates_and_updates(self, user_id: int, target_dates: dict, existing_dates: dict) -> tuple: + def _diff_creates_and_updates(self, user_id: int, target_dates: dict, existing_dates: dict) -> tuple[list[UserDate], list[UserDate]]: """ Compare target and existing UserDates and make two buckets of UserDate objects: those to be created and those to be updated. From 14a08cbd6cc43272145bb08888e005f209bcdbec Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Thu, 4 Sep 2025 21:28:21 +0300 Subject: [PATCH 15/22] style: make UPDATE_FIELDS attribute --- edx_when/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/edx_when/api.py b/edx_when/api.py index 43a8d303..2862f68a 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -651,6 +651,7 @@ class UserDateHandler: """ course_key: str + UPDATE_FIELDS = ("abs_date", "rel_date", "first_component_block_id", "is_content_gated") def create_for_user(self, user_id: int, assignments: list, course_data: dict) -> None: """ @@ -856,8 +857,7 @@ def _diff_creates_and_updates(self, user_id: int, target_dates: dict, existing_d return to_create, to_update - @staticmethod - def _bulk_commit(to_create: list, to_update: list, to_delete: list) -> None: + def _bulk_commit(self, to_create: list, to_update: list, to_delete: list) -> None: """Perform the create, update, and delete operations in bulk.""" if to_create: UserDate.objects.bulk_create(to_create, batch_size=500) @@ -865,7 +865,7 @@ def _bulk_commit(to_create: list, to_update: list, to_delete: list) -> None: if to_update: UserDate.objects.bulk_update( to_update, - fields=["abs_date", "rel_date", "first_component_block_id", "is_content_gated"], + fields=self.UPDATE_FIELDS, batch_size=500 ) From d1ce16d6f9488c62ce8e86d750ddc3d7acc82e26 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Wed, 10 Sep 2025 14:07:00 +0300 Subject: [PATCH 16/22] refactor: make DB_BULK_BATCH_SIZE constant --- edx_when/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/edx_when/api.py b/edx_when/api.py index 2862f68a..642ca555 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -27,6 +27,7 @@ log = logging.getLogger(__name__) FIELDS_TO_EXTRACT = ('due', 'start', 'end') +DB_BULK_BATCH_SIZE = 500 def _content_dates_cache_key(course_key, query_dict, subsection_and_higher_only, published_version): @@ -860,13 +861,13 @@ def _diff_creates_and_updates(self, user_id: int, target_dates: dict, existing_d def _bulk_commit(self, to_create: list, to_update: list, to_delete: list) -> None: """Perform the create, update, and delete operations in bulk.""" if to_create: - UserDate.objects.bulk_create(to_create, batch_size=500) + UserDate.objects.bulk_create(to_create, batch_size=DB_BULK_BATCH_SIZE) if to_update: UserDate.objects.bulk_update( to_update, fields=self.UPDATE_FIELDS, - batch_size=500 + batch_size=DB_BULK_BATCH_SIZE ) if to_delete: From 2029b855a8a53ed4b8f961b2d858934e01bf6704 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Wed, 10 Sep 2025 14:14:31 +0300 Subject: [PATCH 17/22] refactor: use log.debug instead of log.info in UserDateHandler --- edx_when/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/edx_when/api.py b/edx_when/api.py index 642ca555..01310d87 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -720,7 +720,7 @@ def sync_for_user(self, user_id: int, assignments: list, course_data: dict | Non with transaction.atomic(): self._bulk_commit(to_create, to_update, to_delete) - log.info( + log.debug( f"UserDates synced for user_id={user_id} in {self.course_key}: " f"len({to_create}) created, len({to_update}) updated, len({to_delete}) deleted." ) From 26d624fb5cecfe5a277b0e796dad508c3cc41b90 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Wed, 10 Sep 2025 14:55:59 +0300 Subject: [PATCH 18/22] style: use NoReturn to type hint return value --- edx_when/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/edx_when/api.py b/edx_when/api.py index 01310d87..777a0c01 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import Any +from typing import Any, NoReturn from django.core.exceptions import ValidationError from django.db import transaction @@ -686,7 +686,7 @@ def delete_for_user(self, user_id: int): """ UserDate.objects.filter(user_id=user_id, content_date__course_id=self.course_key).delete() - def sync_for_user(self, user_id: int, assignments: list, course_data: dict | None = None): + def sync_for_user(self, user_id: int, assignments: list, course_data: dict | None = None) -> None | NoReturn: """ Synchronize UserDates for a user with the current course configuration. This ensures that UserDates match the desired state: @@ -734,7 +734,7 @@ def sync_for_user(self, user_id: int, assignments: list, course_data: dict | Non # ------------------------- @staticmethod - def _validate(obj: UserDate) -> None: + def _validate(obj: UserDate) -> None | NoReturn: """Validate a UserDate object before saving, raising InvalidDateError if invalid.""" try: obj.full_clean() From c9721f381970207c07d4ab1e3bd08849a0ec60fd Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Wed, 24 Sep 2025 12:58:50 +0300 Subject: [PATCH 19/22] chore: squash 0009 and 0010 migrations --- ...t_title_contentdate_course_name_and_more.py | 7 ++++++- .../0010_userdate_is_content_gated.py | 18 ------------------ 2 files changed, 6 insertions(+), 19 deletions(-) delete mode 100644 edx_when/migrations/0010_userdate_is_content_gated.py diff --git a/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py b/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py index f664107a..3aa1bbf8 100644 --- a/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py +++ b/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.22 on 2025-08-20 09:11 +# Generated by Django 4.2.22 on 2025-09-24 09:56 from django.db import migrations, models import opaque_keys.edx.django.models @@ -31,6 +31,11 @@ class Migration(migrations.Migration): name='first_component_block_id', field=opaque_keys.edx.django.models.UsageKeyField(blank=True, db_index=True, max_length=255, null=True), ), + migrations.AddField( + model_name='userdate', + name='is_content_gated', + field=models.BooleanField(default=False), + ), migrations.AddIndex( model_name='contentdate', index=models.Index(fields=['assignment_title', 'course_id'], name='edx_when_assignment_course_idx'), diff --git a/edx_when/migrations/0010_userdate_is_content_gated.py b/edx_when/migrations/0010_userdate_is_content_gated.py deleted file mode 100644 index 02fff731..00000000 --- a/edx_when/migrations/0010_userdate_is_content_gated.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.22 on 2025-08-20 15:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('edx_when', '0009_contentdate_assignment_title_contentdate_course_name_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='userdate', - name='is_content_gated', - field=models.BooleanField(default=False), - ), - ] From c435a15461cbd49db6d2d403c92bbf382069d955 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Fri, 3 Oct 2025 12:59:02 +0300 Subject: [PATCH 20/22] feat: add learner_has_access property on UserDate --- edx_when/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/edx_when/models.py b/edx_when/models.py index 677688d6..f498cbe4 100644 --- a/edx_when/models.py +++ b/edx_when/models.py @@ -170,6 +170,13 @@ def location(self): """ return self.content_date.location + @property + def learner_has_access(self): + """ + Return a boolean indicating whether the piece of content is accessible to the learner. + """ + return not self.is_content_gated + def clean(self): """ Validate data before saving. From ded7e78cd2ad3d4473e9e00fdde167696f735941 Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Fri, 3 Oct 2025 13:12:55 +0300 Subject: [PATCH 21/22] feat: pass contains_gated_content to update_or_create ContentDate --- edx_when/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/edx_when/api.py b/edx_when/api.py index 777a0c01..78d1233f 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -553,6 +553,7 @@ def update_or_create_assignments_due_dates(course_key, assignments: list[_Assign location=assignment.block_key, field='due', block_type=assignment.assignment_type, + contains_gated_content=assignment.contains_gated_content, defaults={ 'policy': models.DatePolicy.objects.get_or_create(abs_date=assignment.date)[0], 'assignment_title': assignment.title, From 4c21f5c88177ff551fab7bc40deadbbe00333bde Mon Sep 17 00:00:00 2001 From: Serhii Nanai Date: Fri, 3 Oct 2025 13:13:42 +0300 Subject: [PATCH 22/22] feat: pass user to set dates for course/block --- edx_when/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/edx_when/api.py b/edx_when/api.py index 78d1233f..1daa0a62 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -81,11 +81,12 @@ def is_enabled_for_course(course_key): return models.ContentDate.objects.filter(course_id=course_key, active=True).exists() -def set_dates_for_course(course_key, items): +def set_dates_for_course(course_key, items, user=None): """ Set dates for blocks. items: iterator of (location, field metadata dictionary) + user: user object to set dates for """ with transaction.atomic(): active_date_ids = [] @@ -97,7 +98,7 @@ def set_dates_for_course(course_key, items): if val: log.info('Setting date for %r, %s, %r', location, field, val) active_date_ids.append( - set_date_for_block(course_key, location, field, val) + set_date_for_block(course_key, location, field, val, user) ) # Now clear out old dates that we didn't touch