feat(analytics): add admin dashboard api
This commit is contained in:
0
apps/analytics/__init__.py
Normal file
0
apps/analytics/__init__.py
Normal file
0
apps/analytics/api/__init__.py
Normal file
0
apps/analytics/api/__init__.py
Normal file
113
apps/analytics/api/schemas.py
Normal file
113
apps/analytics/api/schemas.py
Normal 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
387
apps/analytics/api/views.py
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user