feat(analytics): add admin dashboard api
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-14 09:51:46 +03:30
parent c2abcd7b97
commit 8669e99ca5
5 changed files with 502 additions and 0 deletions

View File

View File

View File

@@ -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

387
apps/analytics/api/views.py Normal file
View File

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