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/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,)