325 lines
12 KiB
Python
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
|