Files
guilan-ace-backend/apps/analytics/api/views.py
Amirhossein Khalili 38e7baef9c
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
feat(analytics): split dashboard metrics by domain
2026-06-15 16:17:48 +03:30

782 lines
29 KiB
Python

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,
},
}