diff --git a/futurex_openedx_extensions/dashboard/details/courses.py b/futurex_openedx_extensions/dashboard/details/courses.py index 00d1594e..991bd374 100644 --- a/futurex_openedx_extensions/dashboard/details/courses.py +++ b/futurex_openedx_extensions/dashboard/details/courses.py @@ -1,6 +1,7 @@ """Courses details collectors""" from __future__ import annotations +from datetime import date from typing import List from common.djangoapps.student.models import CourseEnrollment @@ -298,7 +299,7 @@ def get_courses_feedback_queryset( # pylint: disable=too-many-arguments return queryset -def get_courses_orders_queryset( # pylint: disable=too-many-arguments +def get_courses_orders_queryset( # pylint: disable=too-many-arguments, too-many-locals fx_permission_info: dict, user_ids: list = None, course_ids: list = None, @@ -311,6 +312,8 @@ def get_courses_orders_queryset( # pylint: disable=too-many-arguments include_user_details: bool = False, status: str | None = None, item_type: str | None = None, + date_from: date | None = None, + date_to: date | None = None, ) -> QuerySet: """ Returns a filtered queryset of Cart Orders based on provided criteria. @@ -356,4 +359,6 @@ def get_courses_orders_queryset( # pylint: disable=too-many-arguments item_type=item_type, include_invoice=include_invoice, include_user_details=include_user_details, + date_from=date_from, + date_to=date_to, ) diff --git a/futurex_openedx_extensions/dashboard/docs_src.py b/futurex_openedx_extensions/dashboard/docs_src.py index 9a262dfb..a9d7cf4f 100644 --- a/futurex_openedx_extensions/dashboard/docs_src.py +++ b/futurex_openedx_extensions/dashboard/docs_src.py @@ -1694,6 +1694,22 @@ def get_optional_parameter(path: str) -> Any: 'Note: right now only paid_course is implemented.' ) ), + query_parameter( + 'date_from', + str, + description=( + 'The start date of the range for filtering results. Must be provided in `YYYY-MM-DD` format. ' + 'Can be used together with `date_to` to limit results within a specific date range.' + ), + ), + query_parameter( + 'date_to', + str, + description=( + 'The end date of the range for filtering results. Must be provided in `YYYY-MM-DD` format. ' + 'Can be used together with `date_from` to limit results within a specific date range.' + ), + ), common_parameters['include_staff'], common_parameters['download'], ], diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index f665c3b6..baeb8cf5 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -1601,3 +1601,15 @@ def create(self, validated_data: Any) -> Any: def update(self, instance: Any, validated_data: Any) -> Any: """Not implemented: Update an existing object.""" raise ValueError('This serializer does not support update.') + + +class ReportDateFilterSerializer(ReadOnlySerializer): + """Serializer for report date filters.""" + date_from = serializers.DateField( + required=False, + input_formats=['%Y-%m-%d'], + ) + date_to = serializers.DateField( + required=False, + input_formats=['%Y-%m-%d'], + ) diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index a2a6873e..2efd6867 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -305,16 +305,13 @@ def _load_query_params(self, request: Any) -> None: self.fill_missing_periods = request.query_params.get('fill_missing_periods', '1') == '1' - date_from = request.query_params.get('date_from') - date_to = request.query_params.get('date_to') - - try: - self.date_from = datetime.strptime(date_from, '%Y-%m-%d').date() if date_from else None - self.date_to = datetime.strptime(date_to, '%Y-%m-%d').date() if date_to else None - except (ValueError, TypeError) as exc: + serializer = serializers.ReportDateFilterSerializer(data=request.query_params) + if not serializer.is_valid(raise_exception=False): raise ParseError( - 'Invalid dates. You must provide a valid date_from and date_to formated as YYYY-MM-DD' - ) from exc + 'Invalid dates. date_from and date_to must be formated as YYYY-MM-DD when provided.', + ) + self.date_from = serializer.validated_data.get('date_from') + self.date_to = serializer.validated_data.get('date_to') def _get_certificates_count_data(self, one_tenant_permission_info: dict) -> int: """Get the count of certificates for the given tenant""" @@ -1915,6 +1912,13 @@ def get_queryset(self) -> QuerySet: course_ids = self.request.query_params.get('course_ids', '') user_ids = self.request.query_params.get('user_ids', '') usernames = self.request.query_params.get('usernames', '') + + date_serializer = serializers.ReportDateFilterSerializer(data=self.request.query_params) + if not date_serializer.is_valid(raise_exception=False): + raise ParseError( + 'Invalid dates. date_from and date_to must be formated as YYYY-MM-DD when provided.', + ) + course_ids_list = [ course.strip() for course in course_ids.split(',') ] if course_ids else None @@ -1952,6 +1956,8 @@ def get_queryset(self) -> QuerySet: include_user_details=self.request.query_params.get('include_user_details', '0') == '1', status=status, item_type=item_type, + date_from=date_serializer.validated_data.get('date_from'), + date_to=date_serializer.validated_data.get('date_to'), ) self._cached_course_map = getattr(qs, 'courses_map', {}) return qs diff --git a/test_utils/edx_platform_mocks_shared/zeitlabs_payments/querysets.py b/test_utils/edx_platform_mocks_shared/zeitlabs_payments/querysets.py index f9c9de07..78478307 100644 --- a/test_utils/edx_platform_mocks_shared/zeitlabs_payments/querysets.py +++ b/test_utils/edx_platform_mocks_shared/zeitlabs_payments/querysets.py @@ -1,4 +1,6 @@ """Mock""" +from datetime import date + from django.db.models.query import QuerySet @@ -10,6 +12,8 @@ def get_orders_queryset( # pylint: disable=too-many-arguments,unused-argument item_type: str | None = None, include_invoice: bool = False, include_user_details: bool = False, + date_from: date | None = None, + date_to: date | None = None, ): """ Mock. diff --git a/tests/test_dashboard/test_details/test_details_courses.py b/tests/test_dashboard/test_details/test_details_courses.py index 80edd022..b9c3888c 100644 --- a/tests/test_dashboard/test_details/test_details_courses.py +++ b/tests/test_dashboard/test_details/test_details_courses.py @@ -1,4 +1,5 @@ """Tests for courses details collectors""" +from datetime import datetime from unittest.mock import Mock, patch import pytest @@ -247,6 +248,7 @@ def test_get_courses_orders_queryset( # pylint: disable=too-many-locals include_user_details = True status = 'paid' item_type = 'paid_course' + date_from = datetime.strptime('2025-01-01', '%Y-%m-%d').date() mock_accessible_users = Mock(name='UsersQS') mock_accessible_courses = Mock(name='CoursesQS') mock_get_users_and_courses.return_value = ( @@ -269,6 +271,8 @@ def test_get_courses_orders_queryset( # pylint: disable=too-many-locals user_ids=user_ids, item_type=item_type, learner_search=learner_search, + date_from=date_from, + date_to=None, ) mock_get_users_and_courses.assert_called_once_with( @@ -290,6 +294,8 @@ def test_get_courses_orders_queryset( # pylint: disable=too-many-locals item_type=item_type, include_invoice=include_invoice, include_user_details=include_user_details, + date_from=date_from, + date_to=None, ) assert result == mock_orders_qs diff --git a/tests/test_dashboard/test_views.py b/tests/test_dashboard/test_views.py index 27019baf..5734b120 100644 --- a/tests/test_dashboard/test_views.py +++ b/tests/test_dashboard/test_views.py @@ -229,13 +229,13 @@ def test_invalid_stats(self): ( 'day', 'invalid', '2024-01-02', - 'Invalid dates. You must provide a valid date_from and date_to formated as YYYY-MM-DD' + 'Invalid dates. date_from and date_to must be formated as YYYY-MM-DD when provided.', ), ( 'day', '2024-01-01', 'invalid', - 'Invalid dates. You must provide a valid date_from and date_to formated as YYYY-MM-DD' + 'Invalid dates. date_from and date_to must be formated as YYYY-MM-DD when provided.', ), ('day', '2024-01-03', '2024-01-02', None), ) @@ -268,6 +268,7 @@ def test_load_query_params( response = self.client.get(url) if error_message: self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + print(response.data) self.assertEqual(str(response.data['detail']), error_message) else: self.assertEqual(response.status_code, http_status.HTTP_200_OK) @@ -3303,7 +3304,7 @@ def test_invalid_item_type(self, item_valid_types): self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) @patch('futurex_openedx_extensions.dashboard.views.get_courses_orders_queryset') - def test_success_witchout_cached_course_map(self, mock_qs): + def test_success_without_cached_course_map(self, mock_qs): """Verify that the view returns the correct response""" mock_qs.return_value = [] self.login_user(self.staff_user) @@ -3331,6 +3332,16 @@ def test_success_with_cached_course_map(self, mock_get_qs): response = self.client.get(self.url) self.assertEqual(response.status_code, http_status.HTTP_200_OK) + def test_invalid_date(self): + """Verify that the view returns the correct response""" + self.login_user(self.staff_user) + response = self.client.get(f'{self.url}?date_from=invalid-date') + self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data['detail'], + 'Invalid dates. date_from and date_to must be formated as YYYY-MM-DD when provided.', + ) + @ddt.ddt class TestCategoriesView(MockPatcherMixin, BaseTestViewMixin):