from __future__ import annotations from datetime import datetime, time from typing import Literal from django.contrib.auth import get_user_model from django.db.models import Count, Q, Sum from django.db.models.functions import Coalesce, TruncDay, TruncMonth, TruncWeek from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.dateparse import parse_date, parse_datetime from ninja import Router from ninja.errors import HttpError from apps.analytics.api.schemas import AdminDashboardAnalyticsSchema from apps.blog.models import Category, Comment, Like, Post, SavedPost, Tag from apps.events.models import Event, Registration from apps.payments.models import Payment from core.authentication import jwt_auth analytics_router = Router() UNKNOWN_LABEL = "نامشخص" PARTICIPANT_STATUSES = [ Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED, ] GRANULARITY_TRUNC = { "day": TruncDay, "week": TruncWeek, "month": TruncMonth, } def _parse_boundary(value: str | None, *, is_end: bool = False) -> datetime | None: if not value: return None parsed_datetime = parse_datetime(value) if parsed_datetime is None: parsed_date = parse_date(value) if parsed_date is None: raise HttpError(400, "Invalid date filter.") parsed_datetime = datetime.combine(parsed_date, time.max if is_end else time.min) if timezone.is_naive(parsed_datetime): parsed_datetime = timezone.make_aware(parsed_datetime, timezone.get_current_timezone()) return parsed_datetime def _apply_range(queryset, field: str, start: datetime | None, end: datetime | None): if start: queryset = queryset.filter(**{f"{field}__gte": start}) if end: queryset = queryset.filter(**{f"{field}__lte": end}) return queryset def _auto_granularity(start: datetime | None, end: datetime | None) -> Literal["day", "week", "month"]: if not start or not end: return "month" days = max((end - start).days, 1) if days <= 45: return "day" if days <= 180: return "week" return "month" def _label(value) -> str: return str(value or UNKNOWN_LABEL) def _point_queryset(queryset, label_field: str, *, limit: int = 12): return [ {"label": _label(item.get(label_field)), "value": item["value"]} for item in queryset.values(label_field).annotate(value=Count("id")).order_by("-value", label_field)[:limit] ] def _trend_queryset(queryset, field: str, granularity: str): trunc = GRANULARITY_TRUNC[granularity] rows = ( queryset.annotate(bucket=trunc(field)) .values("bucket") .annotate(value=Count("id")) .order_by("bucket") ) return [ { "date": item["bucket"].date().isoformat() if item["bucket"] else "", "label": item["bucket"].date().isoformat() if item["bucket"] else UNKNOWN_LABEL, "value": item["value"], } for item in rows ] def _sum(queryset, field: str) -> int: return int(queryset.aggregate(total=Coalesce(Sum(field), 0))["total"] or 0) def _status_label(value) -> str: try: return Payment.OrderStatusChoices(value).label except ValueError: return str(value) @analytics_router.get("/admin/dashboard", response=AdminDashboardAnalyticsSchema, auth=jwt_auth) def admin_dashboard( request, date_from: str | None = None, date_to: str | None = None, event_id: int | None = None, granularity: Literal["day", "week", "month", "auto"] = "auto", ): user = request.auth if not (user and (user.is_staff or user.is_superuser)): raise HttpError(403, "اجازه دسترسی ندارید.") start = _parse_boundary(date_from) end = _parse_boundary(date_to, is_end=True) if start and end and start > end: raise HttpError(400, "Start date must be before end date.") selected_granularity = _auto_granularity(start, end) if granularity == "auto" else granularity if event_id is not None: get_object_or_404(Event, id=event_id, is_deleted=False) User = get_user_model() users_qs = _apply_range(User.objects.filter(is_deleted=False), "date_joined", start, end) events_qs = _apply_range(Event.objects.filter(is_deleted=False), "start_time", start, end) registrations_qs = _apply_range( Registration.objects.filter(is_deleted=False).select_related("user", "event", "user__major", "user__university"), "registered_at", start, end, ) if event_id is not None: registrations_qs = registrations_qs.filter(event_id=event_id) paid_payments_qs = Payment.objects.filter(is_deleted=False, status=Payment.OrderStatusChoices.PAID, verified_at__isnull=False) paid_payments_qs = _apply_range(paid_payments_qs.select_related("event"), "verified_at", start, end) all_payments_qs = _apply_range(Payment.objects.filter(is_deleted=False), "created_at", start, end) if event_id is not None: paid_payments_qs = paid_payments_qs.filter(event_id=event_id) all_payments_qs = all_payments_qs.filter(event_id=event_id) published_posts_qs = _apply_range( Post.objects.filter(is_deleted=False, status=Post.StatusChoices.PUBLISHED), "published_at", start, end, ) visible_comments_qs = _apply_range( Comment.objects.filter( is_deleted=False, is_hidden=False, is_approved=True, post__is_deleted=False, post__status=Post.StatusChoices.PUBLISHED, ), "created_at", start, end, ) likes_qs = _apply_range( Like.objects.filter(post__is_deleted=False, post__status=Post.StatusChoices.PUBLISHED), "created_at", start, end, ) saves_qs = _apply_range( SavedPost.objects.filter(post__is_deleted=False, post__status=Post.StatusChoices.PUBLISHED), "created_at", start, end, ) participant_qs = registrations_qs.filter(status__in=PARTICIPANT_STATUSES) registration_status = [ {"status": item["status"], "label": Registration.StatusChoices(item["status"]).label, "value": item["value"]} for item in registrations_qs.values("status").annotate(value=Count("id")).order_by("status") ] payment_status = [ {"status": str(item["status"]), "label": _status_label(item["status"]), "value": item["value"]} for item in all_payments_qs.values("status").annotate(value=Count("id")).order_by("status") ] top_events_qs = Event.objects.filter(is_deleted=False) if event_id is not None: top_events_qs = top_events_qs.filter(id=event_id) top_events = [] for event in ( top_events_qs.annotate( attendees=Count( "registrations", filter=Q(registrations__is_deleted=False, registrations__status__in=PARTICIPANT_STATUSES), distinct=True, ), revenue=Coalesce( Sum( "payments__amount", filter=Q(payments__is_deleted=False, payments__status=Payment.OrderStatusChoices.PAID), ), 0, ), ) .order_by("-attendees", "-revenue", "-start_time")[:10] ): fill_rate = round((event.attendees / event.capacity) * 100, 1) if event.capacity else None top_events.append( { "id": event.id, "title": event.title, "slug": event.slug, "attendees": event.attendees, "capacity": event.capacity, "fill_rate": fill_rate, "revenue": int(event.revenue or 0), } ) revenue_trend = [ { "date": item["bucket"].date().isoformat() if item["bucket"] else "", "label": item["bucket"].date().isoformat() if item["bucket"] else UNKNOWN_LABEL, "value": int(item["value"] or 0), } for item in ( paid_payments_qs.annotate(bucket=GRANULARITY_TRUNC[selected_granularity]("verified_at")) .values("bucket") .annotate(value=Coalesce(Sum("amount"), 0)) .order_by("bucket") ) ] revenue_by_event = [ {"label": _label(item["event__title"]), "value": int(item["value"] or 0)} for item in ( paid_payments_qs.values("event__title") .annotate(value=Coalesce(Sum("amount"), 0)) .order_by("-value", "event__title")[:10] ) ] post_popularity_qs = ( Post.objects.filter(is_deleted=False, status=Post.StatusChoices.PUBLISHED) .annotate( likes_total=Count("likes", distinct=True), saves_total=Count("saves", distinct=True), comments_total=Count( "comments", filter=Q(comments__is_deleted=False, comments__is_hidden=False, comments__is_approved=True), distinct=True, ), ) .order_by("-likes_total", "-saves_total", "-comments_total")[:30] ) post_popularity = [ { "id": post.id, "title": post.title, "slug": post.slug, "likes": post.likes_total, "saves": post.saves_total, "comments": post.comments_total, } for post in post_popularity_qs ] top_posts = [ { **post, "score": post["likes"] + post["saves"] + post["comments"], } for post in sorted(post_popularity, key=lambda item: item["likes"] + item["saves"] + item["comments"], reverse=True)[:10] ] activity_buckets: dict[str, dict[str, int | str]] = {} for key, qs in (("likes", likes_qs), ("saves", saves_qs), ("comments", visible_comments_qs)): for item in ( qs.annotate(bucket=GRANULARITY_TRUNC[selected_granularity]("created_at")) .values("bucket") .annotate(value=Count("id")) .order_by("bucket") ): bucket = item["bucket"].date().isoformat() if item["bucket"] else UNKNOWN_LABEL activity_buckets.setdefault(bucket, {"date": bucket, "likes": 0, "saves": 0, "comments": 0}) activity_buckets[bucket][key] = item["value"] category_engagement = [ {"label": _label(item["name"]), "value": item["value"]} for item in ( Category.objects.filter(is_deleted=False, posts__status=Post.StatusChoices.PUBLISHED, posts__is_deleted=False) .annotate(value=Count("posts", distinct=True)) .order_by("-value", "name")[:10] .values("name", "value") ) ] tag_engagement = [ {"label": _label(item["name"]), "value": item["value"]} for item in ( Tag.objects.filter(is_deleted=False, posts__status=Post.StatusChoices.PUBLISHED, posts__is_deleted=False) .annotate(value=Count("posts", distinct=True)) .order_by("-value", "name")[:10] .values("name", "value") ) ] learning_hours = 0.0 for event in Event.objects.filter(is_deleted=False).annotate( attendees=Count( "registrations", filter=Q(registrations__is_deleted=False, registrations__status__in=PARTICIPANT_STATUSES), distinct=True, ) ): duration = event.end_time - event.start_time learning_hours += max(duration.total_seconds(), 0) / 3600 * event.attendees total_revenue = _sum(paid_payments_qs, "amount") total_discount = _sum(paid_payments_qs, "discount_amount") total_base = _sum(paid_payments_qs, "base_amount") total_likes = likes_qs.count() total_saves = saves_qs.count() total_comments = visible_comments_qs.count() published_posts_count = published_posts_qs.count() return { "filters": { "date_from": start.isoformat() if start else None, "date_to": end.isoformat() if end else None, "event_id": event_id, "granularity": selected_granularity, }, "summary": { "total_users": users_qs.count(), "verified_users": users_qs.filter(is_mobile_verified=True).count(), "total_events": events_qs.count(), "total_registrations": registrations_qs.count(), "total_revenue": total_revenue, "total_discount": total_discount, "published_posts": published_posts_count, "total_likes": total_likes, "total_saves": total_saves, "total_comments": total_comments, }, "users": { "signup_trend": _trend_queryset(users_qs, "date_joined", selected_granularity), "by_major": _point_queryset(users_qs, "major__name"), "by_university": _point_queryset(users_qs, "university__name"), "by_year": _point_queryset(users_qs, "year_of_study"), }, "events": { "registration_status": registration_status, "by_major": _point_queryset(participant_qs, "user__major__name"), "by_university": _point_queryset(participant_qs, "user__university__name"), "top_events": top_events, "registration_trend": _trend_queryset(registrations_qs, "registered_at", selected_granularity), }, "revenue": { "trend": revenue_trend, "by_event": revenue_by_event, "payment_status": payment_status, "total_paid": total_revenue, "total_discount": total_discount, "total_base": total_base, }, "blog": { "totals": { "posts": published_posts_count, "likes": total_likes, "saves": total_saves, "comments": total_comments, }, "post_popularity": post_popularity, "top_posts": top_posts, "activity_trend": list(activity_buckets.values()), "by_category": category_engagement, "by_tag": tag_engagement, }, "achievements": { "distinct_participants": participant_qs.values("user_id").distinct().count(), "learning_hours": round(learning_hours, 1), "published_content": published_posts_count, "community_engagement": total_likes + total_saves + total_comments, }, }