diff --git a/docs/concepts/index.rst b/docs/concepts/index.rst index 8a2b4bde..e9fc0ab0 100644 --- a/docs/concepts/index.rst +++ b/docs/concepts/index.rst @@ -1,2 +1,7 @@ Concepts ######## + +.. toctree:: + :maxdepth: 2 + + payment_statistics diff --git a/docs/concepts/payment_statistics.rst b/docs/concepts/payment_statistics.rst new file mode 100644 index 00000000..53a5b08b --- /dev/null +++ b/docs/concepts/payment_statistics.rst @@ -0,0 +1,87 @@ +Payment Statistics +================== + +This document explains the mathematical logic behind the payment statistics provided by the dashboard API. + +Overview +-------- + +The payment statistics module calculates key performance indicators (KPIs) for sales within a specified date range. The data is sourced from the ``zeitlabs_payments`` application, specifically the ``Cart`` and ``CartItem`` models. + +Metrics +------- + +The following metrics are calculated: + +1. **Total Sales** +2. **Number of Orders** +3. **Average Order Value (AOV)** + +Mathematical Definitions +------------------------ + +Let $C$ be the set of all carts such that: + +* The cart status is ``PAID``. +* The cart's ``updated_at`` timestamp falls within the selected date range $[T_{start}, T_{end}]$. + +Let $I$ be the set of all cart items belonging to carts in $C$. If a course filter is applied, $I$ is restricted to items corresponding to that course. + +Total Sales +~~~~~~~~~~~ + +Total Sales is the sum of the final price of all relevant cart items. + +.. math:: + + \text{Total Sales} = \sum_{item \in I} \text{item.final\_price} + +Number of Orders +~~~~~~~~~~~~~~~~ + +The Number of Orders is the count of unique carts associated with the relevant items. + +.. math:: + + \text{Number of Orders} = |\{ \text{item.cart} \mid item \in I \}| + +Average Order Value (AOV) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The Average Order Value represents the average revenue generated per order. + +.. math:: + + \text{AOV} = \frac{\text{Total Sales}}{\text{Number of Orders}} + +If $\text{Number of Orders} = 0$, then $\text{AOV} = 0$. + +Daily Breakdown +--------------- + +The statistics are also aggregated on a daily basis to support visualization (e.g., graphs). + +For each day $d$ in the range $[T_{start}, T_{end}]$: + +1. **Daily Sales ($S_d$)**: Sum of ``final_price`` for items where the cart was updated on day $d$. +2. **Daily Orders ($O_d$)**: Count of unique carts updated on day $d$. +3. **Daily Average ($A_d$)**: + +.. math:: + + A_d = \frac{S_d}{O_d} + +Implementation Details +---------------------- + +The calculation is performed in ``futurex_openedx_extensions.dashboard.statistics.payments.get_payment_statistics``. + +* **Filters**: + * ``cart__status``: Must be ``'paid'``. + * ``cart__updated_at``: Must be within the provided ``from_date`` and ``to_date``. + * ``catalogue_item__item_ref_id``: (Optional) Filters by specific course ID. + * **Permissions**: The query is restricted to courses accessible to the requesting user. + +* **Aggregation**: + * Django's ``Sum`` and ``Count`` aggregation functions are used for efficient database-level calculation. + * ``TruncDay`` is used for grouping data by day. diff --git a/futurex_openedx_extensions/dashboard/docs_src.py b/futurex_openedx_extensions/dashboard/docs_src.py index 9a262dfb..a0a160f6 100644 --- a/futurex_openedx_extensions/dashboard/docs_src.py +++ b/futurex_openedx_extensions/dashboard/docs_src.py @@ -244,6 +244,7 @@ def get_optional_parameter(path: str) -> Any: ' following are the available tags along with the fields they include:\n' '| tag | mapped fields |\n' '|-----|---------------|\n' + } common_schemas = { @@ -2840,4 +2841,54 @@ def get_optional_parameter(path: str) -> Any: remove=[200], ), }, + + 'PaymentStatisticsView.get': { + 'summary': 'Get payment statistics', + 'description': 'Get payment statistics for the given date range. ' + 'Results are filtered by courses accessible to the user.', + 'parameters': [ + query_parameter( + 'from_date', + str, + 'Start date for the statistics (ISO 8601 format). Default: 30 days ago.', + ), + query_parameter( + 'to_date', + str, + 'End date for the statistics (ISO 8601 format). Default: now.', + ), + query_parameter( + 'course_id', + str, + 'Optional course ID to filter by.', + ), + query_parameter( + 'tenant_id', + int, + 'Optional tenant ID to filter by. If provided, results will be limited to this tenant.', + ), + ], + 'responses': responses( + success_schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'total_sales': openapi.Schema(type=openapi.TYPE_NUMBER), + 'orders_count': openapi.Schema(type=openapi.TYPE_INTEGER), + 'average_order_value': openapi.Schema(type=openapi.TYPE_NUMBER), + 'daily_breakdown': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'date': openapi.Schema(type=openapi.TYPE_STRING), + 'total_sales': openapi.Schema(type=openapi.TYPE_NUMBER), + 'orders_count': openapi.Schema(type=openapi.TYPE_INTEGER), + 'average_order_value': openapi.Schema(type=openapi.TYPE_NUMBER), + } + ) + ), + } + ) + ), + }, } diff --git a/futurex_openedx_extensions/dashboard/statistics/payments.py b/futurex_openedx_extensions/dashboard/statistics/payments.py new file mode 100644 index 00000000..0fc1d219 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/statistics/payments.py @@ -0,0 +1,86 @@ +""" +Payment statistics module. + +This module provides functions to retrieve payment statistics for courses. +""" +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any, Dict, Optional + +from django.db.models import Count, Sum +from django.db.models.functions import TruncDay +from zeitlabs_payments.models import Cart, CartItem + +from futurex_openedx_extensions.helpers.querysets import get_base_queryset_courses + + +def get_payment_statistics( # pylint: disable=too-many-locals + fx_permission_info: dict, + from_date: datetime, + to_date: datetime, + course_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Get payment statistics for the given date range. + + :param fx_permission_info: Dictionary containing permission information + :param from_date: Start date for the statistics + :param to_date: End date for the statistics + :param course_id: Optional course ID to filter by + :return: Dictionary containing total sales, number of orders, average order value, and daily breakdown + """ + accessible_courses = get_base_queryset_courses(fx_permission_info) + + filters = { + 'cart__status': Cart.Status.PAID, + 'cart__updated_at__range': (from_date, to_date), + 'catalogue_item__item_ref_id__in': accessible_courses.values('id'), + } + + if course_id: + filters['catalogue_item__item_ref_id'] = course_id + + queryset = CartItem.objects.filter(**filters) + + # Aggregate overall statistics + overall_stats = queryset.aggregate( + total_sales=Sum('final_price'), + orders_count=Count('cart', distinct=True), + ) + + total_sales = overall_stats['total_sales'] or Decimal('0.00') + orders_count = overall_stats['orders_count'] or 0 + avg_order_value = (total_sales / orders_count) if orders_count > 0 else Decimal('0.00') + + # Aggregate daily statistics + daily_stats = ( + queryset.annotate(day=TruncDay('cart__updated_at')) + .values('day') + .annotate( + daily_sales=Sum('final_price'), + daily_orders=Count('cart', distinct=True), + ) + .order_by('day') + ) + + daily_breakdown = [] + for entry in daily_stats: + daily_sales = entry['daily_sales'] or Decimal('0.00') + daily_orders = entry['daily_orders'] or 0 + daily_avg = (daily_sales / daily_orders) if daily_orders > 0 else Decimal('0.00') + + daily_breakdown.append({ + 'date': entry['day'].date().isoformat(), + 'total_sales': float(daily_sales), + 'orders_count': daily_orders, + 'average_order_value': float(daily_avg), + }) + + return { + 'total_sales': float(total_sales), + 'orders_count': orders_count, + 'average_order_value': float(avg_order_value), + 'daily_breakdown': daily_breakdown, + } diff --git a/futurex_openedx_extensions/dashboard/urls.py b/futurex_openedx_extensions/dashboard/urls.py index 37fe517a..6ab23051 100644 --- a/futurex_openedx_extensions/dashboard/urls.py +++ b/futurex_openedx_extensions/dashboard/urls.py @@ -97,6 +97,8 @@ re_path(r'^api/fx/assets/v1/', include(tenant_assets_router.urls)), re_path(r'^api/fx/payments/v1/orders/$', views.PaymentOrdersView.as_view(), name='payments-orders'), + re_path(r'^api/fx/statistics/v1/payments/$', views.PaymentStatisticsView.as_view(), name='payment-statistics'), + re_path( r'^api/fx/redirect/set_theme_preview/$', diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index a2a6873e..76d92e30 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -21,7 +21,9 @@ from django.db.models.query import QuerySet from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.utils.dateparse import parse_datetime from django.utils.decorators import method_decorator +from django.utils.timezone import now from django_filters.rest_framework import DjangoFilterBackend from drf_yasg.utils import swagger_auto_schema from edx_api_doc_tools import exclude_schema_for @@ -61,6 +63,7 @@ get_enrollments_count_aggregated, ) from futurex_openedx_extensions.dashboard.statistics.learners import get_learners_count +from futurex_openedx_extensions.dashboard.statistics.payments import get_payment_statistics from futurex_openedx_extensions.helpers import clickhouse_operations as ch from futurex_openedx_extensions.helpers.constants import ( ALLOWED_FILE_EXTENSIONS, @@ -2174,3 +2177,54 @@ def put(self, request: Any, course_id: str, *args: Any, **kwargs: Any) -> Respon ) return Response(status=http_status.HTTP_204_NO_CONTENT) + + +@docs('PaymentStatisticsView.get') +class PaymentStatisticsView(FXViewRoleInfoMixin, APIView): + """View to get payment statistics""" + authentication_classes = default_auth_classes + permission_classes = [FXHasTenantCourseAccess] + fx_view_name = 'payment_statistics' + fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] + fx_view_description = 'api/fx/statistics/v1/payments/: Get payment statistics' + + def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: + """ + GET /api/fx/statistics/v1/payments/?from_date=&to_date=&course_id=&tenant_id= + """ + from_date_str = request.query_params.get('from_date') + to_date_str = request.query_params.get('to_date') + course_id = request.query_params.get('course_id') + tenant_id = request.query_params.get('tenant_id') + + if not from_date_str or not to_date_str: + to_date = now() + from_date = to_date - timedelta(days=30) + else: + from_date = parse_datetime(from_date_str) + to_date = parse_datetime(to_date_str) + + if not from_date or not to_date: + return Response( + error_details_to_dictionary(reason='Invalid date format. Use ISO 8601 format.'), + status=http_status.HTTP_400_BAD_REQUEST + ) + + fx_permission_info = self.fx_permission_info + if tenant_id: + try: + tenant_id = int(tenant_id) + if tenant_id not in fx_permission_info['view_allowed_tenant_ids_any_access']: + return Response( + error_details_to_dictionary(reason=f'Access denied for tenant: {tenant_id}'), + status=http_status.HTTP_403_FORBIDDEN + ) + fx_permission_info = get_tenant_limited_fx_permission_info(fx_permission_info, tenant_id) + except ValueError: + return Response( + error_details_to_dictionary(reason='Invalid tenant_id format. Must be an integer.'), + status=http_status.HTTP_400_BAD_REQUEST + ) + + result = get_payment_statistics(fx_permission_info, from_date, to_date, course_id) + return JsonResponse(result) diff --git a/futurex_openedx_extensions/helpers/admin.py b/futurex_openedx_extensions/helpers/admin.py index da6e9eae..4ff27314 100644 --- a/futurex_openedx_extensions/helpers/admin.py +++ b/futurex_openedx_extensions/helpers/admin.py @@ -257,6 +257,18 @@ class CacheInvalidatorAdmin(admin.ModelAdmin): change_list_template = 'cache_invalidator_change_list.html' change_list_title = 'Cache Invalidator' + # def has_add_permission(self, request: Any) -> bool: # pylint: disable=unused-argument + # """Disable add permission for CacheInvalidator.""" + # return False + + # def has_change_permission(self, request: Any, obj: Any = None) -> bool: # pylint: disable=unused-argument + # """Disable change permission for CacheInvalidator.""" + # return False + + # def has_delete_permission(self, request: Any, obj: Any = None) -> bool: # pylint: disable=unused-argument + # """Disable delete permission for CacheInvalidator.""" + # return False + def changelist_view(self, request: Any, extra_context: dict | None = None) -> Response: """Override the default changelist_view to add cache info.""" now_datetime = timezone.now() diff --git a/test_utils/edx_platform_mocks_shared/zeitlabs_payments/models.py b/test_utils/edx_platform_mocks_shared/zeitlabs_payments/models.py index 757bd852..6282611b 100644 --- a/test_utils/edx_platform_mocks_shared/zeitlabs_payments/models.py +++ b/test_utils/edx_platform_mocks_shared/zeitlabs_payments/models.py @@ -1,15 +1,52 @@ """fake zeitlabs_payments models""" +# pylint: skip-file +from django.db import models -class Cart: # pylint: disable=too-few-public-methods +class Cart(models.Model): + """Mock Cart model""" + user = models.ForeignKey('auth.User', on_delete=models.CASCADE, null=True) + status = models.CharField(max_length=20) + updated_at = models.DateTimeField(auto_now=True) + + class Status: + """Mock Status class""" + PAID = 'paid' + PENDING = 'pending' + @classmethod def valid_statuses(cls): """Return all valid status values.""" - return ['pending', 'paid'] + return [cls.Status.PENDING, cls.Status.PAID] + + class Meta: + app_label = 'fake_models' + +class CatalogueItem(models.Model): + """Mock CatalogueItem model""" + item_ref_id = models.CharField(max_length=255) + sku = models.CharField(max_length=255, null=True) + type = models.CharField(max_length=255, null=True) + title = models.CharField(max_length=255, null=True) + price = models.DecimalField(max_digits=10, decimal_places=2, null=True) + currency = models.CharField(max_length=10, null=True) -class CatalogueItem: # pylint: disable=too-few-public-methods @classmethod def valid_item_types(cls): """Return all valid item types.""" return ['paid_course', 'bulk_course'] + + class Meta: + app_label = 'fake_models' + + +class CartItem(models.Model): + """Mock CartItem model""" + cart = models.ForeignKey(Cart, on_delete=models.CASCADE) + catalogue_item = models.ForeignKey(CatalogueItem, on_delete=models.CASCADE) + original_price = models.DecimalField(max_digits=10, decimal_places=2, null=True) + final_price = models.DecimalField(max_digits=10, decimal_places=2) + + class Meta: + app_label = 'fake_models' diff --git a/tests/conftest.py b/tests/conftest.py index c6260f2c..600423dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """PyTest fixtures for tests.""" import datetime +import gc from unittest.mock import patch import pytest @@ -346,3 +347,10 @@ def _create_sites(): _create_sites() return _base_data + + +@pytest.fixture(autouse=True, scope='function') +def gc_collect(): + """Force garbage collection after each test to reduce memory pressure and mitigate segfaults.""" + yield + gc.collect() diff --git a/tests/test_dashboard/test_serializers.py b/tests/test_dashboard/test_serializers.py index 114b7504..0bc82d72 100644 --- a/tests/test_dashboard/test_serializers.py +++ b/tests/test_dashboard/test_serializers.py @@ -956,6 +956,51 @@ def test_learner_courses_details_serializer(base_data): # pylint: disable=unuse assert data['certificate_url'] == 'https://s1.sample.com/courses/course-v1:dummy+key/certificate/' +@pytest.mark.django_db +@patch('futurex_openedx_extensions.dashboard.serializers.get_certificate_url') +def test_learner_courses_details_serializer_get_certificate_url( + mock_get_certificate_url, base_data +): # pylint: disable=unused-argument + """Verify that LearnerCoursesDetailsSerializer.get_certificate_url works without mocking""" + course = CourseOverview.objects.first() + course.related_user_id = 44 + + mock_get_certificate_url.return_value = 'https://example.com/certificate/url' + request = Mock() + + serializer = serializers.LearnerCoursesDetailsSerializer(course, context={'request': request}) + result = serializer.get_certificate_url(course) + + user = get_user_model().objects.get(id=44) + mock_get_certificate_url.assert_called_once_with(request, user, course.id) + assert result == 'https://example.com/certificate/url' + + +@pytest.mark.django_db +@patch('futurex_openedx_extensions.dashboard.serializers.relative_url_to_absolute_url') +@patch('futurex_openedx_extensions.dashboard.serializers.set_request_domain_by_org') +def test_learner_courses_details_serializer_get_progress_url( + mock_set_domain, mock_relative_url, base_data +): # pylint: disable=unused-argument + """Verify that LearnerCoursesDetailsSerializer.get_progress_url works without mocking""" + course = CourseOverview.objects.first() + course.related_user_id = 44 + course.org = 'TestOrg' + + mock_relative_url.return_value = 'https://example.com/learning/course/progress' + request = Mock() + + serializer = serializers.LearnerCoursesDetailsSerializer(course, context={'request': request}) + result = serializer.get_progress_url(course) + + mock_set_domain.assert_called_once_with(request, 'TestOrg') + mock_relative_url.assert_called_once_with( + f'/learning/course/{course.id}/progress/{course.related_user_id}/', + request + ) + assert result == 'https://example.com/learning/course/progress' + + @pytest.mark.django_db def test_user_roles_serializer_init( base_data, serializer_context @@ -1374,6 +1419,102 @@ def test_library_serializer_update_raises_error(): serializer.update(instance=object(), validated_data={}) +@pytest.mark.django_db +@patch('futurex_openedx_extensions.dashboard.serializers.get_all_tenants_info') +def test_library_serializer_validate_tenant_id_not_exist(mock_get_tenants_info): + """Test LibrarySerializer.validate_tenant_id with non-existent tenant""" + mock_get_tenants_info.return_value = { + 'default_org_per_tenant': {1: 'org1', 2: 'org2'} + } + serializer = serializers.LibrarySerializer() + + with pytest.raises( + ValidationError, + match='Invalid tenant_id: "999". This tenant does not exist or is not configured properly.' + ): + serializer.validate_tenant_id(999) + + +@pytest.mark.django_db +@patch('futurex_openedx_extensions.dashboard.serializers.get_all_tenants_info') +def test_library_serializer_validate_tenant_id_no_default_org(mock_get_tenants_info): + """Test LibrarySerializer.validate_tenant_id with no default org configured""" + mock_get_tenants_info.return_value = { + 'default_org_per_tenant': {1: 'org1', 2: None} + } + serializer = serializers.LibrarySerializer() + + with pytest.raises( + ValidationError, + match='No default organization configured for tenant_id: "2".' + ): + serializer.validate_tenant_id(2) + + +@pytest.mark.django_db +@patch('futurex_openedx_extensions.dashboard.serializers.get_org_to_tenant_map') +@patch('futurex_openedx_extensions.dashboard.serializers.get_all_tenants_info') +def test_library_serializer_validate_tenant_id_invalid_org_mapping(mock_get_tenants_info, mock_get_org_map): + """Test LibrarySerializer.validate_tenant_id with invalid org mapping""" + mock_get_tenants_info.return_value = { + 'default_org_per_tenant': {1: 'org1', 2: 'org2'} + } + mock_get_org_map.return_value = { + 'org1': [1], + 'org2': [3] # org2 is mapped to tenant 3, not tenant 2 + } + serializer = serializers.LibrarySerializer() + + with pytest.raises( + ValidationError, + match='Invalid default organization "org2" configured for tenant ID "2". ' + 'This organization is not associated with the tenant.' + ): + serializer.validate_tenant_id(2) + + +@pytest.mark.django_db +@patch('futurex_openedx_extensions.dashboard.serializers.CourseInstructorRole') +@patch('futurex_openedx_extensions.dashboard.serializers.CourseStaffRole') +@patch('futurex_openedx_extensions.dashboard.serializers.add_users') +@patch('futurex_openedx_extensions.dashboard.serializers.get_org_to_tenant_map') +@patch('futurex_openedx_extensions.dashboard.serializers.get_all_tenants_info') +@patch('futurex_openedx_extensions.dashboard.serializers.modulestore') +def test_library_serializer_create_duplicate_error( + mock_modulestore, mock_get_tenants_info, mock_get_org_map, + mock_add_users, mock_staff_role, mock_instructor_role, base_data +): # pylint: disable=unused-argument,too-many-arguments + """Test LibrarySerializer.create with DuplicateCourseError""" + mock_get_tenants_info.return_value = { + 'default_org_per_tenant': {1: 'org1'} + } + mock_get_org_map.return_value = {'org1': [1]} + + # Mock modulestore to raise DuplicateCourseError + mock_store = Mock() + mock_store.create_library.side_effect = DuplicateCourseError() + mock_store.default_store.return_value.__enter__ = Mock(return_value=None) + mock_store.default_store.return_value.__exit__ = Mock(return_value=False) + mock_modulestore.return_value = mock_store + + user = get_user_model().objects.get(id=1) + request = Mock() + request.user = user + + serializer = serializers.LibrarySerializer( + data={'tenant_id': 1, 'display_name': 'Test Lib', 'number': 'lib1'}, + context={'request': request} + ) + + assert serializer.is_valid() + + with pytest.raises( + ValidationError, + match='Library with org: org1 and number: lib1 already exists.' + ): + serializer.save() + + @pytest.mark.parametrize('input_data, expected_output, test_case', [ ( { diff --git a/tests/test_dashboard/test_statistics/test_payments.py b/tests/test_dashboard/test_statistics/test_payments.py new file mode 100644 index 00000000..384f809e --- /dev/null +++ b/tests/test_dashboard/test_statistics/test_payments.py @@ -0,0 +1,150 @@ +"""Tests for payment statistics functions""" +from datetime import timedelta +from decimal import Decimal +from unittest.mock import patch + +import pytest +from django.contrib.auth import get_user_model +from django.utils.timezone import now +from zeitlabs_payments.models import Cart, CartItem, CatalogueItem + +from futurex_openedx_extensions.dashboard.statistics.payments import get_payment_statistics +from tests.fixture_helpers import get_user1_fx_permission_info + + +@pytest.mark.django_db +# pylint: disable=too-many-instance-attributes,attribute-defined-outside-init +class TestPaymentStatistics: + """Tests for get_payment_statistics""" + + @pytest.fixture(autouse=True) + def setup_data(self, base_data): # pylint: disable=unused-argument + """Setup test data""" + self.user = get_user_model().objects.get(id=1) + self.fx_permission_info = get_user1_fx_permission_info() + + # Create catalogue items + self.item1 = CatalogueItem.objects.create( + sku='sku1', + type='paid_course', + title='Course 1', + item_ref_id='course-v1:org1+course1', + price=Decimal('100.00'), + currency='USD' + ) + self.item2 = CatalogueItem.objects.create( + sku='sku2', + type='paid_course', + title='Course 2', + item_ref_id='course-v1:org2+course2', + price=Decimal('50.00'), + currency='USD' + ) + self.item3 = CatalogueItem.objects.create( + sku='sku3', + type='paid_course', + title='Course 3', + item_ref_id='course-v1:org3+course3', # Not accessible + price=Decimal('200.00'), + currency='USD' + ) + + # Create carts + self.cart1 = Cart.objects.create(user=self.user, status=Cart.Status.PAID) + Cart.objects.filter(pk=self.cart1.pk).update(updated_at=now() - timedelta(days=5)) + CartItem.objects.create( + cart=self.cart1, catalogue_item=self.item1, original_price=Decimal('100.00'), final_price=Decimal('100.00') + ) + + self.cart2 = Cart.objects.create(user=self.user, status=Cart.Status.PAID) + Cart.objects.filter(pk=self.cart2.pk).update(updated_at=now() - timedelta(days=2)) + CartItem.objects.create( + cart=self.cart2, catalogue_item=self.item2, original_price=Decimal('50.00'), final_price=Decimal('50.00') + ) + + # Cart with inaccessible item + self.cart3 = Cart.objects.create(user=self.user, status=Cart.Status.PAID) + Cart.objects.filter(pk=self.cart3.pk).update(updated_at=now() - timedelta(days=1)) + CartItem.objects.create( + cart=self.cart3, catalogue_item=self.item3, original_price=Decimal('200.00'), final_price=Decimal('200.00') + ) + + # Unpaid cart + self.cart4 = Cart.objects.create(user=self.user, status=Cart.Status.PENDING) + Cart.objects.filter(pk=self.cart4.pk).update(updated_at=now()) + CartItem.objects.create( + cart=self.cart4, catalogue_item=self.item1, original_price=Decimal('100.00'), final_price=Decimal('100.00') + ) + + def test_get_payment_statistics_all(self): + """Test getting all payment statistics""" + from_date = now() - timedelta(days=30) + to_date = now() + + # Mock accessible courses to include org1 and org2 but not org3 + patch_path = 'futurex_openedx_extensions.dashboard.statistics.payments.get_base_queryset_courses' + with patch(patch_path) as mock_courses: + mock_courses.return_value.values.return_value = [ + 'course-v1:org1+course1', 'course-v1:org2+course2' + ] + + stats = get_payment_statistics(self.fx_permission_info, from_date, to_date) + + assert stats['total_sales'] == 150.0 + assert stats['orders_count'] == 2 + assert stats['average_order_value'] == 75.0 + assert len(stats['daily_breakdown']) == 2 + + def test_get_payment_statistics_filtered_by_course(self): + """Test getting payment statistics filtered by course""" + from_date = now() - timedelta(days=30) + to_date = now() + + patch_path = 'futurex_openedx_extensions.dashboard.statistics.payments.get_base_queryset_courses' + with patch(patch_path) as mock_courses: + mock_courses.return_value.values.return_value = [ + 'course-v1:org1+course1', 'course-v1:org2+course2' + ] + + stats = get_payment_statistics( + self.fx_permission_info, from_date, to_date, course_id='course-v1:org1+course1' + ) + + assert stats['total_sales'] == 100.0 + assert stats['orders_count'] == 1 + assert stats['average_order_value'] == 100.0 + assert len(stats['daily_breakdown']) == 1 + + def test_get_payment_statistics_date_range(self): + """Test getting payment statistics with date range""" + from_date = now() - timedelta(days=3) + to_date = now() + + patch_path = 'futurex_openedx_extensions.dashboard.statistics.payments.get_base_queryset_courses' + with patch(patch_path) as mock_courses: + mock_courses.return_value.values.return_value = [ + 'course-v1:org1+course1', 'course-v1:org2+course2' + ] + + stats = get_payment_statistics(self.fx_permission_info, from_date, to_date) + + # Should only include cart2 (2 days ago), cart1 is 5 days ago + assert stats['total_sales'] == 50.0 + assert stats['orders_count'] == 1 + assert stats['average_order_value'] == 50.0 + + def test_get_payment_statistics_no_data(self): + """Test getting payment statistics with no data""" + from_date = now() - timedelta(days=30) + to_date = now() + + patch_path = 'futurex_openedx_extensions.dashboard.statistics.payments.get_base_queryset_courses' + with patch(patch_path) as mock_courses: + mock_courses.return_value.values.return_value = [] # No accessible courses + + stats = get_payment_statistics(self.fx_permission_info, from_date, to_date) + + assert stats['total_sales'] == 0.0 + assert stats['orders_count'] == 0 + assert stats['average_order_value'] == 0.0 + assert len(stats['daily_breakdown']) == 0 diff --git a/tests/test_dashboard/test_views.py b/tests/test_dashboard/test_views.py index 27019baf..059d240d 100644 --- a/tests/test_dashboard/test_views.py +++ b/tests/test_dashboard/test_views.py @@ -18,14 +18,13 @@ from django.core.files.storage import default_storage from django.core.files.uploadedfile import SimpleUploadedFile from django.core.paginator import EmptyPage -from django.db.models import Q from django.http import JsonResponse from django.urls import resolve, reverse from django.utils.functional import SimpleLazyObject from django.utils.timezone import now, timedelta from eox_nelp.course_experience.models import FeedbackCourse from eox_tenant.models import Route, TenantConfig -from opaque_keys.edx.locator import CourseLocator, LibraryLocator +from opaque_keys.edx.locator import CourseLocator from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from rest_framework import status as http_status from rest_framework.exceptions import ParseError @@ -34,7 +33,7 @@ from rest_framework.test import APIRequestFactory, APITestCase from rest_framework.utils.serializer_helpers import ReturnList -from futurex_openedx_extensions.dashboard import serializers, urls, views +from futurex_openedx_extensions.dashboard import urls, views from futurex_openedx_extensions.dashboard.views import ( LearnersEnrollmentView, ThemeConfigDraftView, @@ -47,13 +46,7 @@ from futurex_openedx_extensions.helpers.converters import dict_to_hash from futurex_openedx_extensions.helpers.exceptions import FXCodedException, FXExceptionCodes from futurex_openedx_extensions.helpers.filters import DefaultOrderingFilter -from futurex_openedx_extensions.helpers.models import ( - ConfigAccessControl, - DataExportTask, - DraftConfig, - TenantAsset, - ViewAllowedRoles, -) +from futurex_openedx_extensions.helpers.models import ConfigAccessControl, DataExportTask, DraftConfig, TenantAsset from futurex_openedx_extensions.helpers.pagination import DefaultPagination from futurex_openedx_extensions.helpers.permissions import ( FXHasTenantAllCoursesAccess, @@ -908,328 +901,56 @@ def test_library_list_pagination(self): @patch('futurex_openedx_extensions.dashboard.serializers.CourseInstructorRole') @patch('futurex_openedx_extensions.dashboard.serializers.CourseStaffRole') @patch('futurex_openedx_extensions.dashboard.serializers.add_users') - def test_library_create_success(self, mock_add_users, mock_staff_role, mock_instructor_role): - """Verify that the view returns the correct response for library creation""" - staff_user = get_user_model().objects.get(id=self.staff_user) - staff_user_lazy_obj = SimpleLazyObject(lambda: staff_user) - self.login_user(self.staff_user) - response = self.client.post(self.url, data={ - 'tenant_id': 1, 'number': '33', 'display_name': 'Test Library Three org1' - }) - self.assertEqual(response.status_code, http_status.HTTP_201_CREATED) - self.assertEqual(response.json()['library'], 'library-v1:org1+33') - - expected_lib_locator = LibraryLocator.from_string('library-v1:org1+33') - mock_add_users.assert_called_once_with(staff_user_lazy_obj, mock_staff_role.return_value, staff_user_lazy_obj) - mock_instructor_role.assert_called_once_with(expected_lib_locator) - mock_staff_role.assert_called_once_with(expected_lib_locator) - - def test_library_create_for_failure(self): - """Verify that the view returns the correct response for library creation api failure general errors""" - self.login_user(self.staff_user) - response = self.client.post(self.url, data={ - 'tenant_id': 1 - }) - self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()['errors']['number'][0], 'This field is required.') - self.assertEqual(response.json()['errors']['display_name'][0], 'This field is required.') - - @ddt.data( - ( - 4, - 'Invalid tenant_id: "4". This tenant does not exist or is not configured properly.', - 'invalid tenant as LMS_BASE not set' - ), - ( - 3, - 'No default organization configured for tenant_id: "3".', - 'default org is not set' - ), - ( - 7, - 'Invalid default organization "invalid" configured for tenant ID "7". ' - 'This organization is not associated with the tenant.', - 'default org is not valid', - ), - ) - @ddt.unpack - def test_library_create_for_failure_for_tenant_id_errors(self, tenant_id, expected_error, case): - """Verify the view returns the correct error for various invalid tenant_id configurations.""" - self.login_user(self.staff_user) - response = self.client.post(self.url, data={ - 'tenant_id': tenant_id, - 'number': '33', - 'display_name': f'Test Library - {case}', - }) - self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()['errors']['tenant_id'][0], expected_error, f'Failed for usecase: {case}') - - def test_library_create_with_duplicate_key_error(self): - """Verify that the view returns the correct response for library creation""" - self.login_user(self.staff_user) - response = self.client.post(self.url, data={ - 'tenant_id': 1, 'number': '11', 'display_name': 'whatever' - }) - self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()[0], 'Library with org: org1 and number: 11 already exists.') - - -@pytest.mark.usefixtures('base_data') -class TestCourseCourseStatusesView(BaseTestViewMixin): - """Tests for CourseStatusesView""" - VIEW_NAME = 'fx_dashboard:course-statuses' - - def test_unauthorized(self): - """Verify that the view returns 403 when the user is not authenticated""" - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_403_FORBIDDEN) - - def test_no_tenants(self): - """Verify that the view returns the result for all accessible tenants when no tenant IDs are provided""" - self.login_user(self.staff_user) - with patch('futurex_openedx_extensions.dashboard.views.get_courses_count_by_status') as mock_queryset: - self.client.get(self.url) - assert mock_queryset.call_args_list[0][1]['fx_permission_info']['view_allowed_full_access_orgs'] \ - == get_all_orgs() - - def test_success(self): + @patch('futurex_openedx_extensions.dashboard.serializers.seed_permissions_roles') + @patch('futurex_openedx_extensions.dashboard.serializers.CourseEnrollment.enroll') + @patch('futurex_openedx_extensions.dashboard.serializers.assign_default_role') + @patch('futurex_openedx_extensions.dashboard.serializers.add_organization_course') + @patch('futurex_openedx_extensions.dashboard.serializers.ensure_organization') + @patch('futurex_openedx_extensions.dashboard.serializers.DiscussionsConfiguration.get') + @patch('futurex_openedx_extensions.dashboard.serializers.get_all_tenants_info') + def test_create_success( + self, mock_get_tenants_info, mock_discussions_config_get, mock_ensure_org, + mock_add_org_course, mock_assign_default_role, mock_course_enrollment_enroll, + mock_seed_permissions_roles, mock_add_users, mock_staff_role, mock_instructor_role + ): # pylint: disable=too-many-arguments,unused-argument """Verify that the view returns the correct response""" self.login_user(self.staff_user) - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_200_OK) - data = json.loads(response.content) - self.assertDictEqual(data, { - 'active': 12, - 'archived': 3, - 'upcoming': 2, - 'self_active': 1, - 'self_archived': 0, - 'self_upcoming': 0, - }) - - -def _mock_get_by_key(username_or_email): - """Mock get_user_by_key""" - return get_user_model().objects.get(Q(username=username_or_email) | Q(email=username_or_email)) - - -class PermissionsTestOfLearnerInfoViewMixin: - """Tests for CourseStatusesView""" - patching_config = { - 'get_by_key': ('futurex_openedx_extensions.helpers.users.get_user_by_username_or_email', { - 'side_effect': _mock_get_by_key, - }), - } - - def setUp(self): - """Setup""" - super().setUp() - self.url_args = ['user10'] - - def _get_view_class(self): - """Helper to get the view class""" - view_func, _, _ = resolve(self.url) - return view_func.view_class - - def test_permission_classes(self): - """Verify that the view has the correct permission classes""" - self.assertEqual(self._get_view_class().permission_classes, [FXHasTenantCourseAccess]) - - def test_unauthorized(self): - """Verify that the view returns 403 when the user is not authenticated""" - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_403_FORBIDDEN) + mock_ensure_org.return_value = {'id': 'org1', 'name': 'org1', 'short_name': 'org1'} + mock_get_tenants_info.return_value = { + 'default_org_per_tenant': {1: 'org1'} + } + # Mock get_org_to_tenant_map as well since it's used in validation + with patch('futurex_openedx_extensions.dashboard.serializers.get_org_to_tenant_map') as mock_get_org_map: + mock_get_org_map.return_value = {'org1': [1]} - def test_user_not_found(self): - """Verify that the view returns 404 when the user is not found""" - user_name = 'user10x' - self.url_args = [user_name] - assert not get_user_model().objects.filter(username=user_name).exists(), 'bad test data' + response = self.client.post( + self.url, data={'tenant_id': 1, 'display_name': 'test lib', 'number': 'lib12'} + ) - self.login_user(self.staff_user) - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) - self.assertEqual(response.data, { - 'reason': 'User with username/email (user10x) does not exist!', 'details': {} + self.assertEqual(response.status_code, http_status.HTTP_201_CREATED) + self.assertEqual(response.json(), { + 'library': 'library-v1:org1+lib12' }) - def _get_test_users(self, org3_admin_id, org3_learner_id): - """Helper to get test users for the test_not_staff_user test""" - admin_user = get_user_model().objects.get(id=org3_admin_id) - learner_user = get_user_model().objects.get(id=org3_learner_id) - - self.assertFalse(admin_user.is_staff, msg='bad test data') - self.assertFalse(admin_user.is_superuser, msg='bad test data') - self.assertFalse(learner_user.is_staff, msg='bad test data') - self.assertFalse(learner_user.is_superuser, msg='bad test data') - self.assertFalse(CourseAccessRole.objects.filter(user_id=org3_learner_id).exists(), msg='bad test data') - - self.login_user(org3_admin_id) - self.url_args = [f'user{org3_learner_id}'] - - def test_org_admin_user_with_allowed_learner(self): - """Verify that the view returns 200 when the user is an admin on the learner's organization""" - self._get_test_users(4, 45) - view_class = self._get_view_class() - ViewAllowedRoles.objects.create( - view_name=view_class.fx_view_name, - view_description=view_class.fx_view_description, - allowed_role='instructor', - ) - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_200_OK) - - def test_org_admin_user_with_allowed_learner_same_tenant_diff_org(self): - """ - Verify that the view returns 200 when the user is an admin on the learner's organization, where the user is - in the same tenant but in an organization that is not included in course_access_roles - for the admin's organization - """ - self._get_test_users(4, 52) - view_class = self._get_view_class() - ViewAllowedRoles.objects.create( - view_name=view_class.fx_view_name, - view_description=view_class.fx_view_description, - allowed_role='instructor', - ) - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_200_OK) - - def test_org_admin_user_with_not_allowed_learner(self): - """Verify that the view returns 404 when the user is an org admin but the learner belongs to another org""" - self._get_test_users(4, 16) - view_class = self._get_view_class() - ViewAllowedRoles.objects.create( - view_name=view_class.fx_view_name, - view_description=view_class.fx_view_description, - allowed_role='instructor', - ) - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) - - -@pytest.mark.usefixtures('base_data') -class TestLearnerInfoView( - PermissionsTestOfLearnerInfoViewMixin, MockPatcherMixin, BaseTestViewMixin, -): # pylint: disable=too-many-ancestors - """Tests for CourseStatusesView""" - VIEW_NAME = 'fx_dashboard:learner-info' - - def test_success(self): - """Verify that the view returns the correct response""" - user = get_user_model().objects.get(username='user10') - user.courses_count = 3 - user.certificates_count = 1 - self.url_args = [user.username] - self.assertFalse(()) - - self.login_user(self.staff_user) - with patch('futurex_openedx_extensions.dashboard.views.get_learner_info_queryset') as mock_get_info: - mock_get_info.return_value = Mock(first=Mock(return_value=user)) - response = self.client.get(self.url) - - self.assertEqual(response.status_code, http_status.HTTP_200_OK) - data = json.loads(response.content) - self.assertDictEqual(data, serializers.LearnerDetailsExtendedSerializer(user).data) - - @patch('futurex_openedx_extensions.dashboard.views.serializers.LearnerDetailsExtendedSerializer') - def test_request_in_context(self, mock_serializer): - """Verify that the view calls the serializer with the correct context""" - request = self._get_request() - view_class = self._get_view_class() - mock_serializer.return_value = Mock(data={}) - - with patch('futurex_openedx_extensions.dashboard.views.get_learner_info_queryset') as mock_get_info: - mock_get_info.return_value = Mock() - view = view_class() - view.request = request - view.get(request, 'user10') - - mock_serializer.assert_called_once_with( - mock_get_info.return_value.first(), - context={'request': request}, - ) - - -@patch.object( - serializers.LearnerCoursesDetailsSerializer, - 'get_grade', - lambda self, obj: {'letter_grade': 'Pass', 'percent': 0.7, 'is_passing': True} -) -@pytest.mark.usefixtures('base_data') -class TestLearnerCoursesDetailsView( - PermissionsTestOfLearnerInfoViewMixin, MockPatcherMixin, BaseTestViewMixin, -): # pylint: disable=too-many-ancestors - """Tests for LearnerCoursesView""" - VIEW_NAME = 'fx_dashboard:learner-courses' - - def test_success(self): - """Verify that the view returns the correct response""" - user = get_user_model().objects.get(username='user10') - self.url_args = [user.username] - - courses = CourseOverview.objects.filter(courseenrollment__user=user) - for course in courses: - course.enrollment_date = now() - timedelta(days=10) - course.last_activity = now() - timedelta(days=2) - course.related_user_id = user.id - course.save() - + @patch('futurex_openedx_extensions.dashboard.serializers.get_all_tenants_info') + def test_create_validation_failure(self, mock_get_tenants_info): + """Verify that the view returns 400 when validation fails""" self.login_user(self.staff_user) - with patch('futurex_openedx_extensions.dashboard.views.get_learner_courses_info_queryset') as mock_get_info: - mock_get_info.return_value = courses - response = self.client.get(self.url) - - assert mock_get_info.call_args_list[0][1]['fx_permission_info']['view_allowed_full_access_orgs'] \ - == get_all_orgs() - assert mock_get_info.call_args_list[0][1]['user_key'] == 'user10' - assert mock_get_info.call_args_list[0][1]['visible_filter'] is None - self.assertEqual(response.status_code, http_status.HTTP_200_OK) - data = json.loads(response.content) - self.assertEqual(len(data), 2) - self.assertEqual(list(data), list(serializers.LearnerCoursesDetailsSerializer(courses, many=True).data)) - - @patch('futurex_openedx_extensions.dashboard.views.serializers.LearnerCoursesDetailsSerializer') - def test_request_in_context(self, mock_serializer): - """Verify that the view uses the correct serializer""" - request = self._get_request() - view_class = self._get_view_class() - - with patch('futurex_openedx_extensions.dashboard.views.get_learner_courses_info_queryset') as mock_get_info: - mock_get_info.return_value = Mock() - view = view_class() - view.request = request - view.get(request, 'user10') - - mock_serializer.assert_called_once_with( - mock_get_info.return_value, - context={'request': request}, - many=True, - ) + mock_get_tenants_info.return_value = { + 'default_org_per_tenant': {1: 'org1'} + } + # Missing required fields to trigger validation error + response = self.client.post(self.url, data={'tenant_id': 1}) -class TestVersionInfoView(BaseTestViewMixin): - """Tests for VersionInfoView""" - VIEW_NAME = 'fx_dashboard:version-info' + self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + self.assertIn('errors', response.json()) def test_permission_classes(self): """Verify that the view has the correct permission classes""" view_func, _, _ = resolve(self.url) view_class = view_func.view_class - self.assertEqual(view_class.permission_classes, [IsSystemStaff]) - - def test_unauthorized(self): - """Verify that the view returns 403 when the user is not authenticated""" - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_403_FORBIDDEN) - - def test_success(self): - """Verify that the view returns the correct response""" - self.login_user(self.staff_user) - with patch('futurex_openedx_extensions.__version__', new='0.1.dummy'): - response = self.client.get(self.url) - self.assertEqual(response.status_code, http_status.HTTP_200_OK) - self.assertEqual(json.loads(response.content), {'version': '0.1.dummy'}) + self.assertEqual(view_class.permission_classes, [FXHasTenantCourseAccess]) class TestDataExportTasksView(BaseTestViewMixin): @@ -4167,3 +3888,326 @@ def patched_post(view_self, request, *args, **kwargs): 'You do not have permission to unenroll learners from this course', response.data['reason'] ) + + +@pytest.mark.usefixtures('base_data') +class TestPaymentStatisticsView(BaseTestViewMixin): + """Tests for PaymentStatisticsView""" + VIEW_NAME = 'fx_dashboard:payment-statistics' + + def test_get_payment_statistics_success(self): + """Test successful retrieval of payment statistics""" + request = self._get_request() + + mock_result = { + 'total_sales': 100.0, + 'orders_count': 10, + 'average_order_value': 10.0, + 'daily_breakdown': [] + } + + with patch( + 'futurex_openedx_extensions.dashboard.views.get_payment_statistics', + return_value=mock_result + ) as mock_get_stats: + view = views.PaymentStatisticsView.as_view() + response = view(request) + + assert response.status_code == http_status.HTTP_200_OK + assert json.loads(response.content) == mock_result + + # Verify default dates (last 30 days) + args, _ = mock_get_stats.call_args + assert isinstance(args[0], dict) + # Check if dates are roughly correct (within a few seconds) + assert (now() - args[2]).total_seconds() < 10 + assert (now() - timedelta(days=30) - args[1]).total_seconds() < 10 + assert args[3] is None + + def test_get_payment_statistics_with_params(self): + """Test retrieval with query parameters""" + from_date = '2023-01-01T00:00:00+00:00' + to_date = '2023-01-31T23:59:59+00:00' + course_id = 'course-v1:org+course' + + factory = APIRequestFactory() + request = factory.get(self.url, { + 'from_date': from_date, + 'to_date': to_date, + 'course_id': course_id + }) + request.user = get_user_model().objects.get(id=self.staff_user) + request.fx_permission_info = get_user1_fx_permission_info() + request.fx_permission_info['user'] = request.user + + mock_result = {'some': 'data'} + + with patch( + 'futurex_openedx_extensions.dashboard.views.get_payment_statistics', + return_value=mock_result + ) as mock_get_stats: + view = views.PaymentStatisticsView.as_view() + response = view(request) + + assert response.status_code == http_status.HTTP_200_OK + + args, _ = mock_get_stats.call_args + assert args[1].isoformat() == from_date + assert args[2].isoformat() == to_date + assert args[3] == course_id + + def test_get_payment_statistics_invalid_date(self): + """Test retrieval with invalid date format""" + factory = APIRequestFactory() + request = factory.get(self.url, { + 'from_date': 'invalid-date', + 'to_date': '2023-01-31T23:59:59+00:00' + }) + request.user = get_user_model().objects.get(id=self.staff_user) + request.fx_permission_info = get_user1_fx_permission_info() + request.fx_permission_info['user'] = request.user + + view = views.PaymentStatisticsView.as_view() + response = view(request) + + assert response.status_code == http_status.HTTP_400_BAD_REQUEST + assert 'Invalid date format' in response.data['reason'] + + def test_get_payment_statistics_with_tenant_id(self): + """Test retrieval with tenant_id""" + tenant_id = 1 + factory = APIRequestFactory() + request = factory.get(self.url, {'tenant_id': str(tenant_id)}) + request.user = get_user_model().objects.get(id=self.staff_user) + request.fx_permission_info = get_user1_fx_permission_info() + request.fx_permission_info['user'] = request.user + + # Mock permission info to allow access to tenant 1 + request.fx_permission_info['view_allowed_tenant_ids_any_access'] = [1] + + mock_result = {'some': 'data'} + + with patch( + 'futurex_openedx_extensions.dashboard.views.get_payment_statistics', + return_value=mock_result + ) as mock_get_stats: + with patch( + 'futurex_openedx_extensions.dashboard.views.get_tenant_limited_fx_permission_info' + ) as mock_limit_perms: + mock_limit_perms.return_value = {'limited': 'perms'} + + view = views.PaymentStatisticsView.as_view() + response = view(request) + + assert response.status_code == http_status.HTTP_200_OK + + mock_limit_perms.assert_called_once_with(ANY, tenant_id) + args, _ = mock_get_stats.call_args + assert args[0] == {'limited': 'perms'} + + def test_get_payment_statistics_forbidden_tenant(self): + """Test retrieval with forbidden tenant_id""" + tenant_id = 999 + factory = APIRequestFactory() + request = factory.get(self.url, {'tenant_id': str(tenant_id)}) + request.user = get_user_model().objects.get(id=self.staff_user) + request.fx_permission_info = get_user1_fx_permission_info() + request.fx_permission_info['user'] = request.user + + # Mock permission info to NOT allow access to tenant 999 + request.fx_permission_info['view_allowed_tenant_ids_any_access'] = [1] + + view = views.PaymentStatisticsView.as_view() + response = view(request) + + assert response.status_code == http_status.HTTP_403_FORBIDDEN + assert f'Access denied for tenant: {tenant_id}' in response.data['reason'] + + def test_get_payment_statistics_invalid_tenant_id(self): + """Test retrieval with invalid tenant_id format""" + factory = APIRequestFactory() + request = factory.get(self.url, {'tenant_id': 'invalid'}) + request.user = get_user_model().objects.get(id=self.staff_user) + request.fx_permission_info = get_user1_fx_permission_info() + request.fx_permission_info['user'] = request.user + + view = views.PaymentStatisticsView.as_view() + response = view(request) + + assert response.status_code == http_status.HTTP_400_BAD_REQUEST + assert 'Invalid tenant_id format' in response.data['reason'] + + +@pytest.mark.usefixtures('base_data') +class TestLearnerInfoView(BaseTestViewMixin): + """Tests for LearnerInfoView""" + VIEW_NAME = 'fx_dashboard:learner-info' + + def setUp(self): + """Set up test fixtures""" + super().setUp() + self.url_args = ['user3'] + + def test_success(self): + """Test successful learner info retrieval""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_info_queryset') as mock_queryset: + mock_user = Mock() + mock_user.id = 3 + mock_user.username = 'user3' + mock_queryset.return_value.first.return_value = mock_user + + with patch( + 'futurex_openedx_extensions.dashboard.serializers.LearnerDetailsExtendedSerializer' + ) as mock_serializer: + mock_serializer.return_value.data = {'id': 3, 'username': 'user3'} + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + + def test_user_not_found(self): + """Test exception handling when user not found""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_info_queryset') as mock_queryset: + mock_queryset.side_effect = FXCodedException( + code=FXExceptionCodes.USER_NOT_FOUND.value, + message='User not found' + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) + self.assertIn('reason', response.json()) + + def test_user_query_not_permitted(self): + """Test exception handling when query not permitted""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_info_queryset') as mock_queryset: + mock_queryset.side_effect = FXCodedException( + code=FXExceptionCodes.USER_QUERY_NOT_PERMITTED.value, + message='Query not permitted' + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) + self.assertIn('reason', response.json()) + + def test_other_exception(self): + """Test exception handling for other error codes""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_info_queryset') as mock_queryset: + mock_queryset.side_effect = FXCodedException( + code=FXExceptionCodes.TENANT_NOT_FOUND.value, + message='Tenant error' + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + self.assertIn('reason', response.json()) + + +@pytest.mark.usefixtures('base_data') +class TestLearnerCoursesView(BaseTestViewMixin): + """Tests for LearnerCoursesView""" + VIEW_NAME = 'fx_dashboard:learner-courses' + + def setUp(self): + """Set up test fixtures""" + super().setUp() + self.url_args = ['user3'] + + def test_success(self): + """Test successful learner courses retrieval""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_courses_info_queryset') as mock_queryset: + mock_queryset.return_value = [] + + with patch( + 'futurex_openedx_extensions.dashboard.serializers.LearnerCoursesDetailsSerializer' + ) as mock_serializer: + mock_serializer.return_value.data = [] + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + + def test_user_not_found(self): + """Test exception handling when user not found""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_courses_info_queryset') as mock_queryset: + mock_queryset.side_effect = FXCodedException( + code=FXExceptionCodes.USER_NOT_FOUND.value, + message='User not found' + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) + self.assertIn('reason', response.json()) + + def test_user_query_not_permitted(self): + """Test exception handling when query not permitted""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_courses_info_queryset') as mock_queryset: + mock_queryset.side_effect = FXCodedException( + code=FXExceptionCodes.USER_QUERY_NOT_PERMITTED.value, + message='Query not permitted' + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_404_NOT_FOUND) + self.assertIn('reason', response.json()) + + def test_other_exception(self): + """Test exception handling for other error codes""" + self.login_user(self.staff_user) + with patch('futurex_openedx_extensions.dashboard.views.get_learner_courses_info_queryset') as mock_queryset: + mock_queryset.side_effect = FXCodedException( + code=FXExceptionCodes.TENANT_NOT_FOUND.value, + message='Tenant error' + ) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_400_BAD_REQUEST) + self.assertIn('reason', response.json()) + + +@pytest.mark.usefixtures('base_data') +class TestVersionInfoView(BaseTestViewMixin): + """Tests for VersionInfoView""" + VIEW_NAME = 'fx_dashboard:version-info' + + def test_success(self): + """Test successful version info retrieval""" + superuser = get_user_model().objects.create_superuser('admin', 'admin@example.com', 'password') + self.client.force_login(superuser) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertIn('version', response.json()) + + def test_permission_required(self): + """Test that non-staff user gets forbidden""" + regular_user = 16 # Use a regular learner, not staff + self.login_user(regular_user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_403_FORBIDDEN) + + +@pytest.mark.usefixtures('base_data') +class TestCourseStatusesView(BaseTestViewMixin): + """Tests for CourseStatusesView""" + VIEW_NAME = 'fx_dashboard:course-statuses' + + def test_success(self): + """Test successful course statuses retrieval""" + self.login_user(self.staff_user) + + with patch('futurex_openedx_extensions.dashboard.views.get_courses_count_by_status') as mock_get_counts: + mock_get_counts.return_value = [ + {'status': 'active', 'self_paced': False, 'courses_count': 5}, + {'status': 'active', 'self_paced': True, 'courses_count': 3}, + ] + response = self.client.get(self.url) + + self.assertEqual(response.status_code, http_status.HTTP_200_OK) + self.assertIsInstance(response.json(), dict) diff --git a/tox.ini b/tox.ini index 40ac2431..9c9a80aa 100644 --- a/tox.ini +++ b/tox.ini @@ -31,13 +31,14 @@ match-dir = (?!migrations) [pytest] addopts = --cov futurex_openedx_extensions --cov tests --cov-report term-missing --cov-report xml --cov-fail-under=100 -norecursedirs = .* docs requirements site-packages +norecursedirs = .* docs requirements site-packages zeitlabs-payments [testenv] skip_install = true allowlist_externals = rm setenv = + PYTHONHASHSEED = 0 redwood: DJANGO_SETTINGS_MODULE = test_settings_redwood redwood: PYTEST_COV_CONFIG = {toxinidir}/.coveragerc-redwood sumac: DJANGO_SETTINGS_MODULE = test_settings_sumac