From 8669e99ca593f502755efcf1bc6cddae24c4189a Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 14 Jun 2026 09:51:46 +0330 Subject: [PATCH] feat(analytics): add admin dashboard api --- apps/analytics/__init__.py | 0 apps/analytics/api/__init__.py | 0 apps/analytics/api/schemas.py | 113 ++++++++++ apps/analytics/api/views.py | 387 +++++++++++++++++++++++++++++++++ config/api.py | 2 + 5 files changed, 502 insertions(+) create mode 100644 apps/analytics/__init__.py create mode 100644 apps/analytics/api/__init__.py create mode 100644 apps/analytics/api/schemas.py create mode 100644 apps/analytics/api/views.py diff --git a/apps/analytics/__init__.py b/apps/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/analytics/api/__init__.py b/apps/analytics/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/analytics/api/schemas.py b/apps/analytics/api/schemas.py new file mode 100644 index 0000000..0619c4b --- /dev/null +++ b/apps/analytics/api/schemas.py @@ -0,0 +1,113 @@ +from typing import Literal + +from ninja import Schema + + +class AnalyticsPointSchema(Schema): + label: str + value: int | float + + +class AnalyticsTrendPointSchema(Schema): + date: str + label: str + value: int | float + + +class AnalyticsRegistrationStatusSchema(Schema): + status: str + label: str + value: int + + +class AnalyticsTopEventSchema(Schema): + id: int + title: str + slug: str + attendees: int + capacity: int | None = None + fill_rate: float | None = None + revenue: int = 0 + + +class AnalyticsPostPopularitySchema(Schema): + id: int + title: str + slug: str + likes: int + saves: int + comments: int + + +class AnalyticsTopPostSchema(AnalyticsPostPopularitySchema): + score: int + + +class AnalyticsSummarySchema(Schema): + total_users: int + verified_users: int + total_events: int + total_registrations: int + total_revenue: int + total_discount: int + published_posts: int + total_likes: int + total_saves: int + total_comments: int + + +class AnalyticsUsersSchema(Schema): + signup_trend: list[AnalyticsTrendPointSchema] + by_major: list[AnalyticsPointSchema] + by_university: list[AnalyticsPointSchema] + by_year: list[AnalyticsPointSchema] + + +class AnalyticsEventsSchema(Schema): + registration_status: list[AnalyticsRegistrationStatusSchema] + by_major: list[AnalyticsPointSchema] + by_university: list[AnalyticsPointSchema] + top_events: list[AnalyticsTopEventSchema] + registration_trend: list[AnalyticsTrendPointSchema] + + +class AnalyticsRevenueSchema(Schema): + trend: list[AnalyticsTrendPointSchema] + by_event: list[AnalyticsPointSchema] + payment_status: list[AnalyticsRegistrationStatusSchema] + total_paid: int + total_discount: int + total_base: int + + +class AnalyticsBlogSchema(Schema): + totals: dict[str, int] + post_popularity: list[AnalyticsPostPopularitySchema] + top_posts: list[AnalyticsTopPostSchema] + activity_trend: list[dict[str, int | str]] + by_category: list[AnalyticsPointSchema] + by_tag: list[AnalyticsPointSchema] + + +class AnalyticsAchievementsSchema(Schema): + distinct_participants: int + learning_hours: float + published_content: int + community_engagement: int + + +class AnalyticsFiltersSchema(Schema): + date_from: str | None = None + date_to: str | None = None + event_id: int | None = None + granularity: Literal["day", "week", "month"] + + +class AdminDashboardAnalyticsSchema(Schema): + filters: AnalyticsFiltersSchema + summary: AnalyticsSummarySchema + users: AnalyticsUsersSchema + events: AnalyticsEventsSchema + revenue: AnalyticsRevenueSchema + blog: AnalyticsBlogSchema + achievements: AnalyticsAchievementsSchema diff --git a/apps/analytics/api/views.py b/apps/analytics/api/views.py new file mode 100644 index 0000000..733126f --- /dev/null +++ b/apps/analytics/api/views.py @@ -0,0 +1,387 @@ +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, + }, + } diff --git a/config/api.py b/config/api.py index ce8d259..3586d78 100644 --- a/config/api.py +++ b/config/api.py @@ -1,5 +1,6 @@ from ninja import Router +from apps.analytics.api.views import analytics_router from apps.blog.api.views import blog_router from apps.certificates.api.views import certificates_router from apps.communications.api.views import communications_router @@ -12,6 +13,7 @@ from apps.users.api.views import auth_router from core.api.views import health_router router = Router() +router.add_router("analytics/", analytics_router, tags=["Analytics"]) router.add_router("auth/", auth_router, tags=["Authentication"]) router.add_router("blog/", blog_router, tags=["Blog"]) router.add_router("gallery/", gallery_router, tags=["Gallery"])