diff --git a/apps/analytics/api/schemas.py b/apps/analytics/api/schemas.py index 0619c4b..5a77d6c 100644 --- a/apps/analytics/api/schemas.py +++ b/apps/analytics/api/schemas.py @@ -8,6 +8,12 @@ class AnalyticsPointSchema(Schema): value: int | float +class AnalyticsPointGroupSchema(Schema): + top_items: list[AnalyticsPointSchema] + other_count: int = 0 + total_count: int = 0 + + class AnalyticsTrendPointSchema(Schema): date: str label: str @@ -39,6 +45,12 @@ class AnalyticsPostPopularitySchema(Schema): comments: int +class AnalyticsPostPopularityGroupSchema(Schema): + top_items: list[AnalyticsPostPopularitySchema] + other_count: int = 0 + total_count: int = 0 + + class AnalyticsTopPostSchema(AnalyticsPostPopularitySchema): score: int @@ -111,3 +123,88 @@ class AdminDashboardAnalyticsSchema(Schema): revenue: AnalyticsRevenueSchema blog: AnalyticsBlogSchema 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 diff --git a/apps/analytics/api/views.py b/apps/analytics/api/views.py index 733126f..a2c9471 100644 --- a/apps/analytics/api/views.py +++ b/apps/analytics/api/views.py @@ -12,7 +12,13 @@ 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.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 @@ -21,6 +27,7 @@ from core.authentication import jwt_auth analytics_router = Router() UNKNOWN_LABEL = "نامشخص" +TOP_ITEMS_LIMIT = 12 PARTICIPANT_STATUSES = [ Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED, @@ -30,6 +37,19 @@ GRANULARITY_TRUNC = { "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: @@ -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): trunc = GRANULARITY_TRUNC[granularity] 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: return int(queryset.aggregate(total=Coalesce(Sum(field), 0))["total"] or 0) def _status_label(value) -> str: try: - return Payment.OrderStatusChoices(value).label + 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, @@ -178,7 +572,7 @@ def admin_dashboard( participant_qs = registrations_qs.filter(status__in=PARTICIPANT_STATUSES) 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") ] payment_status = [