-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add payment statistics API endpoint #366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,7 @@ | ||
| Concepts | ||
| ######## | ||
|
|
||
| .. toctree:: | ||
| :maxdepth: 2 | ||
|
|
||
| payment_statistics |
| 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. |
| 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, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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/$', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
|
|
||
| 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() | ||
|
|
||
There was a problem hiding this comment.
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.