From 35bfc0e6a04c12b25b08234942f7fa40edc28761 Mon Sep 17 00:00:00 2001 From: Tehreem Sadat Date: Mon, 8 Dec 2025 15:34:36 +0500 Subject: [PATCH 1/2] fix: add date filter in payments api --- .../dashboard/details/courses.py | 7 +++- .../dashboard/docs_src.py | 16 ++++++++++ futurex_openedx_extensions/dashboard/views.py | 19 ++++++----- .../helpers/converters.py | 13 ++++++++ .../zeitlabs_payments/querysets.py | 4 +++ .../test_details/test_details_courses.py | 6 ++++ tests/test_dashboard/test_views.py | 4 +-- tests/test_helpers/test_converters.py | 32 ++++++++++++++++++- 8 files changed, 89 insertions(+), 12 deletions(-) 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/views.py b/futurex_openedx_extensions/dashboard/views.py index a2a6873e..eaf9baef 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -74,7 +74,11 @@ FX_VIEW_DEFAULT_AUTH_CLASSES, RATING_RANGE, ) -from futurex_openedx_extensions.helpers.converters import dict_to_hash, error_details_to_dictionary +from futurex_openedx_extensions.helpers.converters import ( + date_str_to_date_obj, + dict_to_hash, + error_details_to_dictionary, +) from futurex_openedx_extensions.helpers.course_categories import CourseCategories from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes from futurex_openedx_extensions.helpers.export_mixins import ExportCSVMixin @@ -308,13 +312,8 @@ def _load_query_params(self, request: Any) -> None: 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: - raise ParseError( - 'Invalid dates. You must provide a valid date_from and date_to formated as YYYY-MM-DD' - ) from exc + self.date_from = date_str_to_date_obj(date_from, 'date_from') + self.date_to = date_str_to_date_obj(date_to, '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 +1914,8 @@ 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_from = self.request.query_params.get('date_from', '') + date_to = self.request.query_params.get('date_to', '') course_ids_list = [ course.strip() for course in course_ids.split(',') ] if course_ids else None @@ -1952,6 +1953,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_str_to_date_obj(date_from, 'date_from'), + date_to=date_str_to_date_obj(date_to, 'date_to'), ) self._cached_course_map = getattr(qs, 'courses_map', {}) return qs diff --git a/futurex_openedx_extensions/helpers/converters.py b/futurex_openedx_extensions/helpers/converters.py index b7e0f378..46244abc 100644 --- a/futurex_openedx_extensions/helpers/converters.py +++ b/futurex_openedx_extensions/helpers/converters.py @@ -9,6 +9,7 @@ from urllib.parse import urljoin from dateutil.relativedelta import relativedelta +from rest_framework.exceptions import ParseError from futurex_openedx_extensions.helpers import constants as cs @@ -281,3 +282,15 @@ def to_indian_numerals(text: str) -> str: :return: The text with Arabic numerals converted to Indian numerals. """ return _text_translate(text, '0123456789', '٠١٢٣٤٥٦٧٨٩') + + +def date_str_to_date_obj(date_str: str, field_name: str) -> date | None: + """Convert date of format (YYYY-MM-DD) to a Python date""" + if not date_str: + return None + try: + return datetime.strptime(date_str, '%Y-%m-%d').date() + except (ValueError, TypeError) as exc: + raise ParseError( + f'Invalid {field_name}. You must provide a valid date formated as YYYY-MM-DD' + ) from exc 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..276fac64 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 date_from. You must provide a valid date formated as YYYY-MM-DD' ), ( 'day', '2024-01-01', 'invalid', - 'Invalid dates. You must provide a valid date_from and date_to formated as YYYY-MM-DD' + 'Invalid date_to. You must provide a valid date formated as YYYY-MM-DD' ), ('day', '2024-01-03', '2024-01-02', None), ) diff --git a/tests/test_helpers/test_converters.py b/tests/test_helpers/test_converters.py index baa9e542..5657f2c2 100644 --- a/tests/test_helpers/test_converters.py +++ b/tests/test_helpers/test_converters.py @@ -4,9 +4,10 @@ from unittest.mock import Mock, patch import pytest +from rest_framework.exceptions import ParseError from futurex_openedx_extensions.helpers import converters -from futurex_openedx_extensions.helpers.converters import DateMethods +from futurex_openedx_extensions.helpers.converters import DateMethods, date_str_to_date_obj @pytest.mark.parametrize('ids_string, expected', [ @@ -200,3 +201,32 @@ def test_to_arabic_numerals(input_text, expected_output, test_case): def test_to_indian_numerals(input_text, expected_output, test_case): """Verify that to_indian_numerals returns correct data""" assert converters.to_indian_numerals(input_text) == expected_output, f'Failed: {test_case}' + + +@pytest.mark.parametrize( + 'usecase,input_value,expected,should_raise', + [ + ('Valid date', '2025-01-01', date(2025, 1, 1), False), + ('Empty string returns None', '', None, False), + ('None input returns None', None, None, False), + ('Wrong format', '01-01-2025', None, True), + ('Wrong separator', '2025/01/01', None, True), + ('Invalid month >12', '2025-13-01', None, True), + ('Invalid day 0', '2025-01-00', None, True), + ('Random string', 'abcd-ef-gh', None, True), + ('Integer input', 12345, None, True), + ], +) +def test_date_str_to_date_obj(usecase, input_value, expected, should_raise): + """Test date_str_to_date_obj with valid and invalid inputs.""" + if should_raise: + with pytest.raises(ParseError) as exc_info: + date_str_to_date_obj(input_value, 'date_from') + assert 'Invalid date_from' in str(exc_info.value), ( + f"Failed usecase '{usecase}': input={repr(input_value)}, exception={repr(exc_info.value)}" + ) + else: + result = date_str_to_date_obj(input_value, 'date_from') + assert result == expected, ( + f"Failed usecase '{usecase}': input={repr(input_value)}, expected={repr(expected)}, got={repr(result)}" + ) From 5bd0c5e0d941a838bfcdebc6feaf67db0c732826 Mon Sep 17 00:00:00 2001 From: shadinaif Date: Sat, 24 Jan 2026 18:32:16 +0300 Subject: [PATCH 2/2] refactor: use serializer to validate date_from and date_to --- .../dashboard/serializers.py | 12 +++++++ futurex_openedx_extensions/dashboard/views.py | 31 ++++++++++-------- .../helpers/converters.py | 13 -------- tests/test_dashboard/test_views.py | 17 ++++++++-- tests/test_helpers/test_converters.py | 32 +------------------ 5 files changed, 44 insertions(+), 61 deletions(-) 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 eaf9baef..2efd6867 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -74,11 +74,7 @@ FX_VIEW_DEFAULT_AUTH_CLASSES, RATING_RANGE, ) -from futurex_openedx_extensions.helpers.converters import ( - date_str_to_date_obj, - dict_to_hash, - error_details_to_dictionary, -) +from futurex_openedx_extensions.helpers.converters import dict_to_hash, error_details_to_dictionary from futurex_openedx_extensions.helpers.course_categories import CourseCategories from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes from futurex_openedx_extensions.helpers.export_mixins import ExportCSVMixin @@ -309,11 +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') - - self.date_from = date_str_to_date_obj(date_from, 'date_from') - self.date_to = date_str_to_date_obj(date_to, 'date_to') + serializer = serializers.ReportDateFilterSerializer(data=request.query_params) + if not serializer.is_valid(raise_exception=False): + raise ParseError( + '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""" @@ -1914,8 +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_from = self.request.query_params.get('date_from', '') - date_to = self.request.query_params.get('date_to', '') + + 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 @@ -1953,8 +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_str_to_date_obj(date_from, 'date_from'), - date_to=date_str_to_date_obj(date_to, 'date_to'), + 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/futurex_openedx_extensions/helpers/converters.py b/futurex_openedx_extensions/helpers/converters.py index 46244abc..b7e0f378 100644 --- a/futurex_openedx_extensions/helpers/converters.py +++ b/futurex_openedx_extensions/helpers/converters.py @@ -9,7 +9,6 @@ from urllib.parse import urljoin from dateutil.relativedelta import relativedelta -from rest_framework.exceptions import ParseError from futurex_openedx_extensions.helpers import constants as cs @@ -282,15 +281,3 @@ def to_indian_numerals(text: str) -> str: :return: The text with Arabic numerals converted to Indian numerals. """ return _text_translate(text, '0123456789', '٠١٢٣٤٥٦٧٨٩') - - -def date_str_to_date_obj(date_str: str, field_name: str) -> date | None: - """Convert date of format (YYYY-MM-DD) to a Python date""" - if not date_str: - return None - try: - return datetime.strptime(date_str, '%Y-%m-%d').date() - except (ValueError, TypeError) as exc: - raise ParseError( - f'Invalid {field_name}. You must provide a valid date formated as YYYY-MM-DD' - ) from exc diff --git a/tests/test_dashboard/test_views.py b/tests/test_dashboard/test_views.py index 276fac64..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 date_from. You must provide a valid date 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 date_to. You must provide a valid date 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): diff --git a/tests/test_helpers/test_converters.py b/tests/test_helpers/test_converters.py index 5657f2c2..baa9e542 100644 --- a/tests/test_helpers/test_converters.py +++ b/tests/test_helpers/test_converters.py @@ -4,10 +4,9 @@ from unittest.mock import Mock, patch import pytest -from rest_framework.exceptions import ParseError from futurex_openedx_extensions.helpers import converters -from futurex_openedx_extensions.helpers.converters import DateMethods, date_str_to_date_obj +from futurex_openedx_extensions.helpers.converters import DateMethods @pytest.mark.parametrize('ids_string, expected', [ @@ -201,32 +200,3 @@ def test_to_arabic_numerals(input_text, expected_output, test_case): def test_to_indian_numerals(input_text, expected_output, test_case): """Verify that to_indian_numerals returns correct data""" assert converters.to_indian_numerals(input_text) == expected_output, f'Failed: {test_case}' - - -@pytest.mark.parametrize( - 'usecase,input_value,expected,should_raise', - [ - ('Valid date', '2025-01-01', date(2025, 1, 1), False), - ('Empty string returns None', '', None, False), - ('None input returns None', None, None, False), - ('Wrong format', '01-01-2025', None, True), - ('Wrong separator', '2025/01/01', None, True), - ('Invalid month >12', '2025-13-01', None, True), - ('Invalid day 0', '2025-01-00', None, True), - ('Random string', 'abcd-ef-gh', None, True), - ('Integer input', 12345, None, True), - ], -) -def test_date_str_to_date_obj(usecase, input_value, expected, should_raise): - """Test date_str_to_date_obj with valid and invalid inputs.""" - if should_raise: - with pytest.raises(ParseError) as exc_info: - date_str_to_date_obj(input_value, 'date_from') - assert 'Invalid date_from' in str(exc_info.value), ( - f"Failed usecase '{usecase}': input={repr(input_value)}, exception={repr(exc_info.value)}" - ) - else: - result = date_str_to_date_obj(input_value, 'date_from') - assert result == expected, ( - f"Failed usecase '{usecase}': input={repr(input_value)}, expected={repr(expected)}, got={repr(result)}" - )