Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
2 changes: 1 addition & 1 deletion edx_when/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Central source of course block dates for the LMS.
"""

__version__ = '3.1.0'
__version__ = '3.2.0'
Original file line number Diff line number Diff line change
@@ -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),
),
]
34 changes: 31 additions & 3 deletions edx_when/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 = [
Expand All @@ -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):
"""
Expand All @@ -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):
Expand All @@ -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.
Expand All @@ -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})')
41 changes: 40 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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