"""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=` 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