diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bb7397a4..30026875 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,9 @@ Change Log Unreleased ~~~~~~~~~~ +[3.2.0] - 2026-01-12 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* Extend ContentDate/UserDate models to support enhanced assignment metadata and future scheduling use cases. [3.1.0] - 2026-01-09 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/edx_when/__init__.py b/edx_when/__init__.py index 4fabbaa0..febb40cb 100644 --- a/edx_when/__init__.py +++ b/edx_when/__init__.py @@ -2,4 +2,4 @@ Central source of course block dates for the LMS. """ -__version__ = '3.1.0' +__version__ = '3.2.0' 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..331d62c1 --- /dev/null +++ b/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.22 on 2025-09-24 09:56 + +import opaque_keys.edx.django.models +from django.db import migrations, 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, 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, default='', max_length=255), + ), + migrations.AddField( + model_name='userdate', + name='first_component_block_id', + field=opaque_keys.edx.django.models.UsageKeyField(blank=True, max_length=255, null=True), + ), + 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 e540bdfa..245bee10 100644 --- a/edx_when/models.py +++ b/edx_when/models.py @@ -29,7 +29,7 @@ class DatePolicy(TimeStampedModel): rel_date = models.DurationField(null=True, blank=True, db_index=True) class Meta: - """Django Metadata.""" + """Metadata for DatePolicy model — defines plural display name in the Django admin.""" verbose_name_plural = 'Date policies' @@ -93,9 +93,12 @@ 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='') + course_name = models.CharField(max_length=255, blank=True, default='') + subsection_name = models.CharField(max_length=255, blank=True, default='') class Meta: - """Django Metadata.""" + """Metadata for ContentDate model — enforces uniqueness and adds query performance indexes.""" unique_together = ('policy', 'location', 'field') indexes = [ @@ -109,6 +112,14 @@ def __str__(self): # Location already holds course id return f'ContentDate({self.policy}, {self.location}, {self.field}, {self.block_type})' + def __repr__(self): # pragma: no cover + """ + 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 +136,8 @@ 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) + is_content_gated = models.BooleanField(default=False) @property def actual_date(self): @@ -148,6 +161,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. @@ -162,10 +182,18 @@ def clean(self): if self.abs_date is not None and isinstance(policy_date, datetime) and self.abs_date < policy_date: raise ValidationError(_("Override date must be later than policy date")) - def __str__(self): + def __str__(self): # pragma: no cover """ Get a string representation of this model instance. """ # 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): # pragma: no cover + """ + Get a detailed representation of this model instance. + """ + return (f'UserDate(id={self.id}, user="{self.user.username}", ' # pylint: disable=no-member + f'first_component_block_id={self.first_component_block_id}, ' + f'content_date={self.content_date.id})') diff --git a/tests/test_models.py b/tests/test_models.py index 8ce09745..47988366 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,8 +9,9 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey -from edx_when.models import DatePolicy, MissingScheduleError +from edx_when.models import ContentDate, DatePolicy, MissingScheduleError, UserDate from tests.test_models_app.models import DummySchedule User = get_user_model() @@ -64,3 +65,41 @@ def test_actual_date_schedule_after_cutoff(self): def test_mixed_dates(self): with self.assertRaises(ValidationError): DatePolicy(abs_date=datetime(2020, 1, 1), rel_date=timedelta(days=1)).full_clean() + + +class TestUserDateModel(TestCase): + """Tests for the UserDate model.""" + + def setUp(self): + """Set up a user and content date for the tests.""" + 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' + ) + + def test_learner_has_access_when_not_gated(self): + """learner_has_access should be True when is_content_gated is False.""" + user_date = UserDate.objects.create( + user=self.user, + content_date=self.content_date, + is_content_gated=False, + ) + assert user_date.learner_has_access is True + + def test_learner_has_access_when_gated(self): + """learner_has_access should be False when is_content_gated is True.""" + user_date = UserDate.objects.create( + user=self.user, + content_date=self.content_date, + is_content_gated=True, + ) + assert user_date.learner_has_access is False