diff --git a/edx_when/api.py b/edx_when/api.py index f56b4299..1daa0a62 100644 --- a/edx_when/api.py +++ b/edx_when/api.py @@ -2,17 +2,20 @@ API for retrieving and setting dates. """ +from dataclasses import dataclass +from datetime import datetime, timedelta import logging -from datetime import timedelta +from typing import Any, NoReturn 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 from . import models +from .models import UserDate, ContentDate from .utils import get_schedule_for_user try: @@ -24,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): @@ -77,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 = [] @@ -93,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 @@ -506,6 +511,116 @@ 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 + contains_gated_content: bool + first_component_block_id: 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, + contains_gated_content=assignment.contains_gated_content, + 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 + } + ) + + +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 @@ -516,3 +631,246 @@ 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 + 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: + """ + 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) -> None | NoReturn: + """ + 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.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." + ) + + except Exception: # pylint: disable=broad-except + log.exception(f"UserDate sync failed for user_id={user_id} in {self.course_key}") + raise + + # ------------------------- + # Private helper methods + # ------------------------- + + @staticmethod + def _validate(obj: UserDate) -> None | NoReturn: + """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[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 + 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"] + + 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]: + """Return a list of UserDate objects to be created for assignment due dates.""" + 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[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"] + + 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[tuple[int, int], dict[str, Any]]: + """For assignment-level due dates, map combinations of user and ContentDates to desired UserDate attributes.""" + 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[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[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. + + Returns: + tuple: (to_create, to_update) lists of UserDate objects. + """ + 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 + + 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=DB_BULK_BATCH_SIZE) + + if to_update: + UserDate.objects.bulk_update( + to_update, + fields=self.UPDATE_FIELDS, + batch_size=DB_BULK_BATCH_SIZE + ) + + if to_delete: + UserDate.objects.filter(id__in=to_delete).delete() 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..3aa1bbf8 --- /dev/null +++ b/edx_when/migrations/0009_contentdate_assignment_title_contentdate_course_name_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.22 on 2025-09-24 09: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='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'), + ), + 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'), + ), + ] diff --git a/edx_when/models.py b/edx_when/models.py index e540bdfa..f498cbe4 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,15 @@ 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) + 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'), + ] @property def actual_date(self): @@ -148,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. @@ -169,3 +198,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'content_date={self.content_date.id})') 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/README.md b/edx_when/rest_api/v1/README.md new file mode 100644 index 00000000..b7b6a76e --- /dev/null +++ b/edx_when/rest_api/v1/README.md @@ -0,0 +1,91 @@ +### ๐Ÿ“˜ `GET /api/edx_when/v1/user-dates/` +### ๐Ÿ“˜ `GET /api/edx_when/v1/user-dates/{course_id}` + +### Description +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. + +--- + +### ๐Ÿ” 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 | โŒ No | 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 + +#### 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/__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..4e04914a --- /dev/null +++ b/edx_when/rest_api/v1/tests/test_views.py @@ -0,0 +1,155 @@ +""" +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, {}) + + @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 new file mode 100644 index 00000000..f0c9bccc --- /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..dc6dcd76 --- /dev/null +++ b/edx_when/rest_api/v1/views.py @@ -0,0 +1,84 @@ +""" +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 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. + + **Response Values** + + Body consists of the following fields: + + * 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. + * 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(',') + + enrolled_courses = ( + [course_id] + if course_id + else request.user.courseenrollment_set.filter(is_active=True).values_list( + "course_id", flat=True + ) + ) + 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) 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..f899fe57 --- /dev/null +++ b/edx_when/tests/test_api.py @@ -0,0 +1,715 @@ +""" +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, timedelta, timezone + +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() + + +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]) + + +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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + content_date = 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) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + ContentDate.objects.create( + course_id=course_id, + location=block_key, + field='due', + active=True, + policy=due_policy, + block_type='sequential' + ) + + start_policy = DatePolicy.objects.create(abs_date=datetime(2023, 1, 10, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + ContentDate.objects.create( + course_id=course_id, + location=seq_key, + field='due', + active=True, + policy=seq_due_policy, + block_type='sequential' + ) + + seq_start_policy = DatePolicy.objects.create(abs_date=datetime(2023, 1, 10, 10, 0, 0)) + ContentDate.objects.create( + course_id=course_id, + location=seq_key, + field='start', + active=True, + policy=seq_start_policy, + block_type='sequential' + ) + + vert_due_policy = DatePolicy.objects.create(abs_date=datetime(2023, 1, 16, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + 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 = DatePolicy.objects.create(rel_date=timedelta(days=7)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + 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 = DatePolicy.objects.create(abs_date=datetime(2023, 1, 15, 10, 0, 0)) + content_date = 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 = 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 = 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)) + + +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()) 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,)