Ilo/apps/enrollments/api/analytics.py
2026-05-02 20:01:30 +03:30

325 lines
12 KiB
Python

"""Instructor analytics endpoints — KPIs, monthly revenue chart, activity feed."""
from datetime import timedelta
from decimal import Decimal
from typing import Optional
from django.db.models import Avg, Count, F, FloatField, IntegerField, OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce, TruncMonth
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import ListAPIView
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from django.utils import timezone
from apps.accounts.models import Role, User
from apps.courses.models import Course, Lesson, LessonStatus
from apps.enrollments.models import ActivityEvent, Enrollment, LessonProgress
from apps.payments.models import Order, OrderStatus
# --------------------------------------------------------------------------- helpers
def _ensure_teacher(request):
if request.user.role != Role.TEACHER:
raise PermissionDenied("Only teachers can access this analytics view.")
def _instructor_courses(user):
return Course.objects.filter(instructor=user)
def _start_of_month(dt):
return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def _percent_delta(current: int, previous: int) -> Optional[int]:
if previous == 0:
return None
return int(round((current - previous) * 100 / previous))
# --------------------------------------------------------------------------- KPIs
class KPISparkSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
value = serializers.IntegerField()
unit = serializers.CharField(allow_null=True)
delta_percent = serializers.IntegerField(allow_null=True)
good = serializers.BooleanField(allow_null=True)
spark = serializers.ListField(child=serializers.IntegerField())
class InstructorKPIsView(APIView):
"""Four KPI cards for the instructor home: revenue, active students, avg progress, satisfaction."""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["analytics"],
summary="Instructor KPI snapshot (4 cards)",
description=(
"Returns the four KPI cards shown on the instructor home, in the design's shape: "
"revenue this month (Toman, instructor share), active students (last 7 days), "
"average progress %, satisfaction (review average — null until reviews ship). "
"Each card includes a `delta_percent` vs the previous period and a 12-point sparkline."
),
responses={200: KPISparkSerializer(many=True)},
)
def get(self, request):
_ensure_teacher(request)
user = request.user
now = timezone.now()
this_month_start = _start_of_month(now)
prev_month_start = _start_of_month(this_month_start - timedelta(days=1))
seven_days_ago = now - timedelta(days=7)
prev_seven_days_ago = now - timedelta(days=14)
# ---- Revenue (this month vs last month) -----
rev_q = Order.objects.filter(
course__instructor=user, status=OrderStatus.PAID, paid_at__isnull=False
)
revenue_this = rev_q.filter(paid_at__gte=this_month_start).aggregate(
t=Coalesce(Sum("instructor_share_toman"), 0)
)["t"]
revenue_prev = rev_q.filter(
paid_at__gte=prev_month_start, paid_at__lt=this_month_start
).aggregate(t=Coalesce(Sum("instructor_share_toman"), 0))["t"]
# 12-month sparkline
spark_revenue = self._monthly_spark(
queryset=rev_q,
month_field="paid_at",
agg=Sum("instructor_share_toman"),
)
# ---- Active students -----
active_now = User.objects.filter(
role=Role.STUDENT,
last_active_at__gte=seven_days_ago,
enrollments__course__instructor=user,
).distinct().count()
active_prev = User.objects.filter(
role=Role.STUDENT,
last_active_at__gte=prev_seven_days_ago,
last_active_at__lt=seven_days_ago,
enrollments__course__instructor=user,
).distinct().count()
# Sparkline: enrollments per month over the last 12 months (proxy for "students" growth)
spark_students = self._monthly_spark(
queryset=Enrollment.objects.filter(course__instructor=user),
month_field="enrolled_at",
agg=Count("id"),
)
# ---- Average progress % -----
enrollments = list(
Enrollment.objects.filter(course__instructor=user).select_related("course")
)
if enrollments:
avg_progress = int(round(sum(e.progress_percent() for e in enrollments) / len(enrollments)))
else:
avg_progress = 0
spark_progress = [avg_progress] * 12 # no historical snapshot model yet — flat line
# ---- Satisfaction (placeholder until Reviews ship) -----
satisfaction_value = 0
satisfaction_unit = None
spark_satisfaction = [0] * 12
kpis = [
{
"id": "revenue",
"label": "Revenue this month",
"value": int(revenue_this or 0),
"unit": "Toman",
"delta_percent": _percent_delta(revenue_this, revenue_prev),
"good": (revenue_this or 0) >= (revenue_prev or 0),
"spark": spark_revenue,
},
{
"id": "active_students",
"label": "Active students (7d)",
"value": active_now,
"unit": "students",
"delta_percent": _percent_delta(active_now, active_prev),
"good": active_now >= active_prev,
"spark": spark_students,
},
{
"id": "avg_progress",
"label": "Average progress",
"value": avg_progress,
"unit": "%",
"delta_percent": None,
"good": None,
"spark": spark_progress,
},
{
"id": "satisfaction",
"label": "Student satisfaction",
"value": satisfaction_value,
"unit": satisfaction_unit,
"delta_percent": None,
"good": None,
"spark": spark_satisfaction,
},
]
return Response(KPISparkSerializer(kpis, many=True).data)
@staticmethod
def _monthly_spark(*, queryset, month_field: str, agg) -> list[int]:
"""Return a 12-element list of monthly aggregate values, oldest-to-newest."""
now = timezone.now()
first_month = _start_of_month(now) - timedelta(days=365) # roughly 12 months back
first_month = _start_of_month(first_month + timedelta(days=2))
rows = (
queryset.filter(**{f"{month_field}__gte": first_month})
.annotate(_m=TruncMonth(month_field))
.values("_m")
.annotate(v=Coalesce(agg, 0))
.order_by("_m")
)
bucket = {row["_m"].replace(day=1).date(): int(row["v"] or 0) for row in rows}
out = []
cursor = _start_of_month(now)
# Build oldest → newest, 12 entries
months = []
for i in range(11, -1, -1):
month_start = _start_of_month(cursor - timedelta(days=30 * i))
months.append(month_start.date().replace(day=1))
for m in months:
out.append(bucket.get(m, 0))
return out
# --------------------------------------------------------------------------- Monthly revenue
class MonthlyRevenueSerializer(serializers.Serializer):
month = serializers.DateField()
label = serializers.CharField()
revenue_toman = serializers.IntegerField()
orders_count = serializers.IntegerField()
class InstructorMonthlyRevenueView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["analytics"],
summary="Monthly revenue chart",
description="Returns the last 12 calendar months of revenue (instructor share) and order count.",
responses={200: MonthlyRevenueSerializer(many=True)},
)
def get(self, request):
_ensure_teacher(request)
now = timezone.now()
# Build the 12 month buckets oldest → newest
cursor = _start_of_month(now)
months = []
for i in range(11, -1, -1):
month_start = _start_of_month(cursor - timedelta(days=30 * i))
months.append(month_start.replace(day=1))
rows = (
Order.objects.filter(
course__instructor=request.user,
status=OrderStatus.PAID,
paid_at__gte=months[0],
)
.annotate(_m=TruncMonth("paid_at"))
.values("_m")
.annotate(
rev=Coalesce(Sum("instructor_share_toman"), 0),
cnt=Count("id"),
)
)
bucket = {row["_m"].replace(day=1).date(): row for row in rows}
out = []
for m in months:
key = m.date().replace(day=1)
row = bucket.get(key)
out.append({
"month": key,
"label": key.strftime("%Y-%m"),
"revenue_toman": int(row["rev"] if row else 0),
"orders_count": int(row["cnt"] if row else 0),
})
return Response(MonthlyRevenueSerializer(out, many=True).data)
# --------------------------------------------------------------------------- Activity feed
class ActivityEventSerializer(serializers.ModelSerializer):
actor_name = serializers.CharField(source="actor.full_name", read_only=True, allow_null=True)
course_slug = serializers.CharField(source="course.slug", read_only=True)
course_title = serializers.CharField(source="course.title", read_only=True)
class Meta:
model = ActivityEvent
fields = [
"id",
"kind",
"course",
"course_slug",
"course_title",
"actor",
"actor_name",
"payload",
"created_at",
]
read_only_fields = fields
class _ActivityPagination(PageNumberPagination):
page_size = 30
page_size_query_param = "page_size"
max_page_size = 100
class InstructorActivityFeedView(ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = ActivityEventSerializer
pagination_class = _ActivityPagination
@extend_schema(
tags=["analytics"],
summary="Activity feed (instructor home)",
description=(
"Most recent events across the instructor's courses. Filter by `?course=<id>` or "
"`?kind=enrolled|paid|lesson_completed|course_completed|announcement_posted`."
),
parameters=[
OpenApiParameter(name="course", type=int, location=OpenApiParameter.QUERY, required=False),
OpenApiParameter(name="kind", type=str, location=OpenApiParameter.QUERY, required=False),
],
)
def get(self, request, *args, **kwargs):
_ensure_teacher(request)
return super().get(request, *args, **kwargs)
def get_queryset(self):
qs = (
ActivityEvent.objects
.filter(course__instructor=self.request.user)
.select_related("course", "actor")
)
course_id = self.request.query_params.get("course")
if course_id:
qs = qs.filter(course_id=course_id)
kind = self.request.query_params.get("kind")
if kind:
qs = qs.filter(kind=kind)
return qs