feat(analytics): split dashboard metrics by domain
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-15 16:17:48 +03:30
parent 8669e99ca5
commit 38e7baef9c
2 changed files with 494 additions and 3 deletions

View File

@@ -8,6 +8,12 @@ class AnalyticsPointSchema(Schema):
value: int | float value: int | float
class AnalyticsPointGroupSchema(Schema):
top_items: list[AnalyticsPointSchema]
other_count: int = 0
total_count: int = 0
class AnalyticsTrendPointSchema(Schema): class AnalyticsTrendPointSchema(Schema):
date: str date: str
label: str label: str
@@ -39,6 +45,12 @@ class AnalyticsPostPopularitySchema(Schema):
comments: int comments: int
class AnalyticsPostPopularityGroupSchema(Schema):
top_items: list[AnalyticsPostPopularitySchema]
other_count: int = 0
total_count: int = 0
class AnalyticsTopPostSchema(AnalyticsPostPopularitySchema): class AnalyticsTopPostSchema(AnalyticsPostPopularitySchema):
score: int score: int
@@ -111,3 +123,88 @@ class AdminDashboardAnalyticsSchema(Schema):
revenue: AnalyticsRevenueSchema revenue: AnalyticsRevenueSchema
blog: AnalyticsBlogSchema blog: AnalyticsBlogSchema
achievements: AnalyticsAchievementsSchema achievements: AnalyticsAchievementsSchema
class AnalyticsEventOptionSchema(Schema):
value: str
label: str
description: str | None = None
class AnalyticsEventOptionsSchema(Schema):
count: int
results: list[AnalyticsEventOptionSchema]
class UserAnalyticsSummarySchema(Schema):
total_users: int
verified_users: int
unverified_users: int
profile_completion_rate: float
class UserAnalyticsSchema(Schema):
filters: AnalyticsFiltersSchema
summary: UserAnalyticsSummarySchema
signup_trend: list[AnalyticsTrendPointSchema]
by_major: AnalyticsPointGroupSchema
by_university: AnalyticsPointGroupSchema
by_year: AnalyticsPointGroupSchema
class EventAnalyticsFiltersSchema(Schema):
date_from: str | None = None
date_to: str | None = None
event_id: int | None = None
class EventAnalyticsSummarySchema(Schema):
total_events: int
total_registrations: int
distinct_participants: int
total_revenue: int
total_discount: int
total_base: int
learning_hours: float
class AnalyticsTopEventGroupSchema(Schema):
top_items: list[AnalyticsTopEventSchema]
other_count: int = 0
total_count: int = 0
class EventAnalyticsSchema(Schema):
filters: EventAnalyticsFiltersSchema
summary: EventAnalyticsSummarySchema
registration_status: list[AnalyticsRegistrationStatusSchema]
payment_status: list[AnalyticsRegistrationStatusSchema]
attendee_by_major: AnalyticsPointGroupSchema
attendee_by_university: AnalyticsPointGroupSchema
registration_trend: list[AnalyticsTrendPointSchema]
revenue_trend: list[AnalyticsTrendPointSchema]
revenue_by_event: AnalyticsPointGroupSchema
top_events: AnalyticsTopEventGroupSchema
class BlogAnalyticsFiltersSchema(Schema):
date_from: str | None = None
date_to: str | None = None
class BlogAnalyticsSummarySchema(Schema):
published_posts: int
total_likes: int
total_saves: int
total_comments: int
community_engagement: int
class BlogAnalyticsSchema(Schema):
filters: BlogAnalyticsFiltersSchema
summary: BlogAnalyticsSummarySchema
activity_trend: list[dict[str, int | str]]
post_popularity: AnalyticsPostPopularityGroupSchema
top_posts: list[AnalyticsTopPostSchema]
by_category: AnalyticsPointGroupSchema
by_tag: AnalyticsPointGroupSchema

View File

@@ -12,7 +12,13 @@ from django.utils.dateparse import parse_date, parse_datetime
from ninja import Router from ninja import Router
from ninja.errors import HttpError from ninja.errors import HttpError
from apps.analytics.api.schemas import AdminDashboardAnalyticsSchema from apps.analytics.api.schemas import (
AdminDashboardAnalyticsSchema,
AnalyticsEventOptionsSchema,
BlogAnalyticsSchema,
EventAnalyticsSchema,
UserAnalyticsSchema,
)
from apps.blog.models import Category, Comment, Like, Post, SavedPost, Tag from apps.blog.models import Category, Comment, Like, Post, SavedPost, Tag
from apps.events.models import Event, Registration from apps.events.models import Event, Registration
from apps.payments.models import Payment from apps.payments.models import Payment
@@ -21,6 +27,7 @@ from core.authentication import jwt_auth
analytics_router = Router() analytics_router = Router()
UNKNOWN_LABEL = "نامشخص" UNKNOWN_LABEL = "نامشخص"
TOP_ITEMS_LIMIT = 12
PARTICIPANT_STATUSES = [ PARTICIPANT_STATUSES = [
Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED, Registration.StatusChoices.ATTENDED,
@@ -30,6 +37,19 @@ GRANULARITY_TRUNC = {
"week": TruncWeek, "week": TruncWeek,
"month": TruncMonth, "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: def _parse_boundary(value: str | None, *, is_end: bool = False) -> datetime | None:
@@ -76,6 +96,32 @@ def _point_queryset(queryset, label_field: str, *, limit: int = 12):
] ]
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): def _trend_queryset(queryset, field: str, granularity: str):
trunc = GRANULARITY_TRUNC[granularity] trunc = GRANULARITY_TRUNC[granularity]
rows = ( rows = (
@@ -94,17 +140,365 @@ def _trend_queryset(queryset, field: str, granularity: str):
] ]
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: def _sum(queryset, field: str) -> int:
return int(queryset.aggregate(total=Coalesce(Sum(field), 0))["total"] or 0) return int(queryset.aggregate(total=Coalesce(Sum(field), 0))["total"] or 0)
def _status_label(value) -> str: def _status_label(value) -> str:
try: try:
return Payment.OrderStatusChoices(value).label return PAYMENT_STATUS_LABELS.get(Payment.OrderStatusChoices(value), str(value))
except ValueError: except ValueError:
return str(value) 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) @analytics_router.get("/admin/dashboard", response=AdminDashboardAnalyticsSchema, auth=jwt_auth)
def admin_dashboard( def admin_dashboard(
request, request,
@@ -178,7 +572,7 @@ def admin_dashboard(
participant_qs = registrations_qs.filter(status__in=PARTICIPANT_STATUSES) participant_qs = registrations_qs.filter(status__in=PARTICIPANT_STATUSES)
registration_status = [ registration_status = [
{"status": item["status"], "label": Registration.StatusChoices(item["status"]).label, "value": item["value"]} {"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") for item in registrations_qs.values("status").annotate(value=Count("id")).order_by("status")
] ]
payment_status = [ payment_status = [