From d5de56e7418799830259fa0ea24e9d6c9ba1561f Mon Sep 17 00:00:00 2001 From: Andrii Date: Tue, 1 Jul 2025 17:31:31 +0300 Subject: [PATCH 1/3] 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 df8303940adfc321c9341ef63a77b037d43d4126 Mon Sep 17 00:00:00 2001 From: Andrii Date: Wed, 2 Jul 2025 18:54:13 +0300 Subject: [PATCH 2/3] 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 cf113c35d5c52dd1380c7dcac0f5cb9b86572b09 Mon Sep 17 00:00:00 2001 From: Andrii Date: Mon, 7 Jul 2025 12:47:57 +0300 Subject: [PATCH 3/3] 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)