Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1c7febc
feat: [AXM-2272] add new field for Content-/UserDate models to suppor…
Jun 23, 2025
5e6131f
Merge pull request #1 from raccoongang/hantkovskyi/axm-2272/apply-cha…
NiedielnitsevIvan Jun 25, 2025
6ed30b3
feat: [AXM-2300] add signal handler to save assignment dates to Conte…
Jun 25, 2025
e84dac9
refactor: create api method to get assignments and process them
Jun 26, 2025
c819f24
feat: implement get_user_dates() method to get all user dates for a c…
Jun 27, 2025
fb8c889
Merge pull request #4 from raccoongang/hantkovskyi/axm-2279/implement…
SergiiKalinchuk Jul 2, 2025
da0e717
Merge branch 'axm-mobile-fc-7879' into hantkovskyi/axm-2300/listen-to…
SergiiKalinchuk Jul 7, 2025
c45d0cc
Merge pull request #3 from raccoongang/hantkovskyi/axm-2300/listen-to…
SergiiKalinchuk Jul 7, 2025
b2c49bc
feat: add REST API for user dates retrieval
Jul 1, 2025
ba2b68f
docs: add README.md with url description
Jul 2, 2025
9d0de4d
refactor: add processing for all enrolled courses if no course_id pro…
Jul 7, 2025
192edec
Merge pull request #5 from raccoongang/hantkovskyi/axm-2286/implement…
SergiiKalinchuk Jul 7, 2025
b93b009
feat: update UserDate model and migrations
Serj-N Aug 26, 2025
0762b4f
feat: add new fields to _Assignment dataclass
Serj-N Aug 26, 2025
12f89d5
feat: add UserDateHandler class
Serj-N Aug 26, 2025
0638537
test: add test for UserDateHandler
Serj-N Aug 26, 2025
8916aa0
style: enclose tuples in parentheses
Serj-N Sep 4, 2025
46cc01c
docs: add docstrings to private helper methods
Serj-N Sep 4, 2025
c225b09
style: expand type hints for return values
Serj-N Sep 4, 2025
14a08cb
style: make UPDATE_FIELDS attribute
Serj-N Sep 4, 2025
d1ce16d
refactor: make DB_BULK_BATCH_SIZE constant
Serj-N Sep 10, 2025
2029b85
refactor: use log.debug instead of log.info in UserDateHandler
Serj-N Sep 10, 2025
26d624f
style: use NoReturn to type hint return value
Serj-N Sep 10, 2025
c9721f3
chore: squash 0009 and 0010 migrations
Serj-N Sep 24, 2025
98795f7
Merge pull request #7 from raccoongang/nanai/axm-2707/fill-user-dates
Serj-N Sep 25, 2025
c435a15
feat: add learner_has_access property on UserDate
Serj-N Oct 3, 2025
ded7e78
feat: pass contains_gated_content to update_or_create ContentDate
Serj-N Oct 3, 2025
4c21f5c
feat: pass user to set dates for course/block
Serj-N Oct 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
366 changes: 362 additions & 4 deletions edx_when/api.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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'),
),
]
37 changes: 37 additions & 0 deletions edx_when/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,18 @@ 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."""

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):
Expand All @@ -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):
"""
Expand All @@ -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):
Expand All @@ -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.
Expand All @@ -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})')
Empty file added edx_when/rest_api/__init__.py
Empty file.
91 changes: 91 additions & 0 deletions edx_when/rest_api/v1/README.md
Original file line number Diff line number Diff line change
@@ -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 <your_jwt_token>"
```

```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 <your_jwt_token>"
```

---
Empty file.
Empty file.
155 changes: 155 additions & 0 deletions edx_when/rest_api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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
)
16 changes: 16 additions & 0 deletions edx_when/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -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',
),
]
Loading
Loading