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, AnalyticsEventOptionsSchema, BlogAnalyticsSchema, EventAnalyticsSchema, UserAnalyticsSchema, ) 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 = "نامشخص" TOP_ITEMS_LIMIT = 12 PARTICIPANT_STATUSES = [ Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED, ] GRANULARITY_TRUNC = { "day": TruncDay, "week": TruncWeek, "month": TruncMonth, } REGISTRATION_STATUS_LABELS = { Registration.StatusChoices.PENDING: "در انتظار", Registration.StatusChoices.CONFIRMED: "تایید شده", Registration.StatusChoices.CANCELLED: "لغو شده", Registration.StatusChoices.ATTENDED: "حاضر شده", } PAYMENT_STATUS_LABELS = { Payment.OrderStatusChoices.INIT: "ایجاد شده", Payment.OrderStatusChoices.PENDING: "در انتظار پرداخت", Payment.OrderStatusChoices.PAID: "پرداخت موفق", Payment.OrderStatusChoices.FAILED: "پرداخت ناموفق", Payment.OrderStatusChoices.CANCELED: "لغو شده", } 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 _point_group_queryset(queryset, label_field: str, *, limit: int = TOP_ITEMS_LIMIT, sum_field: str | None = None): if sum_field: rows = list( queryset.values(label_field) .annotate(value=Coalesce(Sum(sum_field), 0)) .order_by("-value", label_field) ) else: rows = list( queryset.values(label_field) .annotate(value=Count("id")) .order_by("-value", label_field) ) total_count = len(rows) top_rows = rows[:limit] other_rows = rows[limit:] return { "top_items": [ {"label": _label(item.get(label_field)), "value": int(item["value"] or 0)} for item in top_rows ], "other_count": len(other_rows), "total_count": total_count, } 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 _trend_sum_queryset(queryset, field: str, sum_field: str, granularity: str): trunc = GRANULARITY_TRUNC[granularity] rows = ( queryset.annotate(bucket=trunc(field)) .values("bucket") .annotate(value=Coalesce(Sum(sum_field), 0)) .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": int(item["value"] or 0), } 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_STATUS_LABELS.get(Payment.OrderStatusChoices(value), str(value)) except ValueError: return str(value) def _registration_status_label(value: str) -> str: try: return REGISTRATION_STATUS_LABELS.get(Registration.StatusChoices(value), value) except ValueError: return value def _event_learning_hours(events_queryset) -> float: learning_hours = 0.0 for event in events_queryset.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 return round(learning_hours, 1) def _filters_payload(start: datetime | None, end: datetime | None, *, event_id: int | None = None, include_granularity: bool = False): payload = { "date_from": start.isoformat() if start else None, "date_to": end.isoformat() if end else None, } if event_id is not None: payload["event_id"] = event_id if include_granularity: payload["granularity"] = _auto_granularity(start, end) return payload def _require_staff(user): if not (user and (user.is_staff or user.is_superuser)): raise HttpError(403, "اجازه دسترسی ندارید.") @analytics_router.get("/admin/events/options", response=AnalyticsEventOptionsSchema, auth=jwt_auth) def admin_event_options( request, search: str | None = None, limit: int = 20, offset: int = 0, ): _require_staff(request.auth) safe_limit = max(1, min(limit, 50)) safe_offset = max(offset, 0) queryset = Event.objects.filter(is_deleted=False) if search: queryset = queryset.filter(Q(title__icontains=search) | Q(slug__icontains=search)) queryset = queryset.order_by("-start_time", "-id") count = queryset.count() results = [ { "value": str(event.id), "label": event.title, "description": event.start_time.date().isoformat() if event.start_time else None, } for event in queryset[safe_offset : safe_offset + safe_limit] ] return {"count": count, "results": results} @analytics_router.get("/admin/users", response=UserAnalyticsSchema, auth=jwt_auth) def admin_users_analytics(request, date_from: str | None = None, date_to: str | None = None): _require_staff(request.auth) 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.") granularity = _auto_granularity(start, end) User = get_user_model() users_qs = _apply_range(User.objects.filter(is_deleted=False), "date_joined", start, end) total_users = users_qs.count() verified_users = users_qs.filter(is_mobile_verified=True).count() completed_profiles = users_qs.exclude(first_name="").exclude(last_name="").filter( mobile__isnull=False, major__isnull=False, university__isnull=False, ).count() return { "filters": _filters_payload(start, end, include_granularity=True), "summary": { "total_users": total_users, "verified_users": verified_users, "unverified_users": max(total_users - verified_users, 0), "profile_completion_rate": round((completed_profiles / total_users) * 100, 1) if total_users else 0, }, "signup_trend": _trend_queryset(users_qs, "date_joined", granularity), "by_major": _point_group_queryset(users_qs, "major__name"), "by_university": _point_group_queryset(users_qs, "university__name"), "by_year": _point_group_queryset(users_qs, "year_of_study"), } @analytics_router.get("/admin/events", response=EventAnalyticsSchema, auth=jwt_auth) def admin_events_analytics( request, date_from: str | None = None, date_to: str | None = None, event_id: int | None = None, ): _require_staff(request.auth) 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.") granularity = _auto_granularity(start, end) selected_event = None if event_id is not None: selected_event = get_object_or_404(Event, id=event_id, is_deleted=False) events_qs = Event.objects.filter(is_deleted=False) if selected_event: events_qs = events_qs.filter(id=selected_event.id) else: events_qs = _apply_range(events_qs, "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 selected_event: registrations_qs = registrations_qs.filter(event_id=selected_event.id) participant_qs = registrations_qs.filter(status__in=PARTICIPANT_STATUSES) 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 selected_event: paid_payments_qs = paid_payments_qs.filter(event_id=selected_event.id) all_payments_qs = all_payments_qs.filter(event_id=selected_event.id) registration_status = [ {"status": item["status"], "label": _registration_status_label(item["status"]), "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 selected_event: top_events_qs = top_events_qs.filter(id=selected_event.id) top_events_annotated = list( 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") ) top_events = [] for event in top_events_annotated[:TOP_ITEMS_LIMIT]: 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), } ) total_revenue = _sum(paid_payments_qs, "amount") total_discount = _sum(paid_payments_qs, "discount_amount") total_base = _sum(paid_payments_qs, "base_amount") return { "filters": _filters_payload(start, end, event_id=event_id), "summary": { "total_events": events_qs.count(), "total_registrations": registrations_qs.count(), "distinct_participants": participant_qs.values("user_id").distinct().count(), "total_revenue": total_revenue, "total_discount": total_discount, "total_base": total_base, "learning_hours": _event_learning_hours(events_qs), }, "registration_status": registration_status, "payment_status": payment_status, "attendee_by_major": _point_group_queryset(participant_qs, "user__major__name"), "attendee_by_university": _point_group_queryset(participant_qs, "user__university__name"), "registration_trend": _trend_queryset(registrations_qs, "registered_at", granularity), "revenue_trend": _trend_sum_queryset(paid_payments_qs, "verified_at", "amount", granularity), "revenue_by_event": _point_group_queryset(paid_payments_qs, "event__title", sum_field="amount"), "top_events": { "top_items": top_events, "other_count": max(len(top_events_annotated) - TOP_ITEMS_LIMIT, 0), "total_count": len(top_events_annotated), }, } @analytics_router.get("/admin/blog", response=BlogAnalyticsSchema, auth=jwt_auth) def admin_blog_analytics(request, date_from: str | None = None, date_to: str | None = None): _require_staff(request.auth) 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.") granularity = _auto_granularity(start, end) 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, ) like_filter = Q() save_filter = Q() comment_filter = Q(comments__is_deleted=False, comments__is_hidden=False, comments__is_approved=True) if start: like_filter &= Q(likes__created_at__gte=start) save_filter &= Q(saves__created_at__gte=start) comment_filter &= Q(comments__created_at__gte=start) if end: like_filter &= Q(likes__created_at__lte=end) save_filter &= Q(saves__created_at__lte=end) comment_filter &= Q(comments__created_at__lte=end) post_popularity_all = list( Post.objects.filter(is_deleted=False, status=Post.StatusChoices.PUBLISHED) .annotate( likes_total=Count("likes", filter=like_filter, distinct=True), saves_total=Count("saves", filter=save_filter, distinct=True), comments_total=Count("comments", filter=comment_filter, distinct=True), ) .filter(Q(likes_total__gt=0) | Q(saves_total__gt=0) | Q(comments_total__gt=0)) .order_by("-likes_total", "-saves_total", "-comments_total", "-published_at") ) 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_all[:TOP_ITEMS_LIMIT] ] 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) ] 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[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"] total_likes = likes_qs.count() total_saves = saves_qs.count() total_comments = visible_comments_qs.count() return { "filters": _filters_payload(start, end), "summary": { "published_posts": published_posts_qs.count(), "total_likes": total_likes, "total_saves": total_saves, "total_comments": total_comments, "community_engagement": total_likes + total_saves + total_comments, }, "activity_trend": list(activity_buckets.values()), "post_popularity": { "top_items": post_popularity, "other_count": max(len(post_popularity_all) - TOP_ITEMS_LIMIT, 0), "total_count": len(post_popularity_all), }, "top_posts": top_posts, "by_category": _point_group_queryset(published_posts_qs, "category__name"), "by_tag": _point_group_queryset(published_posts_qs.filter(tags__is_deleted=False), "tags__name"), } @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_status_label(item["status"]), "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, }, }