Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/concepts/index.rst
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
Concepts
########

.. toctree::
:maxdepth: 2

payment_statistics
87 changes: 87 additions & 0 deletions docs/concepts/payment_statistics.rst
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions futurex_openedx_extensions/dashboard/docs_src.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty line 247 appears to be unintentional and breaks the continuity of the docstring table. This should either be removed or the table should be completed if it's incomplete.

Copilot uses AI. Check for mistakes.
}

common_schemas = {
Expand Down Expand Up @@ -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),
}
)
),
}
)
),
},
}
86 changes: 86 additions & 0 deletions futurex_openedx_extensions/dashboard/statistics/payments.py
Original file line number Diff line number Diff line change
@@ -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,
}
2 changes: 2 additions & 0 deletions futurex_openedx_extensions/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),

Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra blank line should be removed to maintain consistency with the rest of the URL patterns in the file.

Copilot uses AI. Check for mistakes.

re_path(
r'^api/fx/redirect/set_theme_preview/$',
Expand Down
54 changes: 54 additions & 0 deletions futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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=<date>&to_date=<date>&course_id=<course_id>&tenant_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)
12 changes: 12 additions & 0 deletions futurex_openedx_extensions/helpers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +260 to +270
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented-out code should be removed. If these permission methods need to be disabled, they should either be removed entirely or uncommented with proper justification. Keeping commented-out code reduces code maintainability and clarity.

Copilot uses AI. Check for mistakes.

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()
Expand Down
Loading