This commit is contained in:
2026-08-31 15:11:52 +03:30
parent 236002a87c
commit 463b812e3e
63 changed files with 3254 additions and 1476 deletions
View File
+183
View File
@@ -0,0 +1,183 @@
from datetime import timedelta
from django.contrib import admin
from django.db.models import Avg, Count, DurationField, ExpressionWrapper, F, Max, Q, Sum
from django.template.response import TemplateResponse
from django.utils import timezone
from unfold.admin import ModelAdmin
from apps.account.models import User
from .models import ApiCallLog, AppSession, ClickEvent, ClientErrorLog, PageView
# مانیتورینگِ کلیِ اپ توسط پنل ادمین: صفحه‌ی changelist مدلِ AppSession
# جایگزینِ یک داشبوردِ تجمیعی می‌شود (نه لیستِ خامِ ردیف‌ها) — برای دیدنِ
# لیستِ خام هر رکورد، لینکِ «نمای خام» بالای همان صفحه با ?raw=1 به همان
# changelist معمولیِ جنگو می‌رود.
_WINDOW_DAYS = 30
@admin.register(AppSession)
class AppSessionAdmin(ModelAdmin):
list_display = ("user", "platform", "started_at", "ended_at", "ip_address")
list_filter = ("platform",)
search_fields = ("user__phone", "ip_address")
date_hierarchy = "started_at"
def changelist_view(self, request, extra_context=None):
if request.GET.get("raw") == "1":
return super().changelist_view(request, extra_context)
return self._dashboard_view(request)
def _dashboard_view(self, request):
context = {
**self.admin_site.each_context(request),
"title": "مانیتورینگ اپ",
"opts": self.model._meta,
**self._build_dashboard_context(),
}
return TemplateResponse(request, "admin/telemetry/dashboard.html", context)
def _build_dashboard_context(self):
now = timezone.now()
cutoff = now - timedelta(days=_WINDOW_DAYS)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
sessions_qs = AppSession.objects.filter(started_at__gte=cutoff)
api_qs = ApiCallLog.objects.filter(created_at__gte=cutoff)
pageviews_qs = PageView.objects.filter(entered_at__gte=cutoff)
errors_qs = ClientErrorLog.objects.filter(created_at__gte=cutoff)
overview = {
"active_users_today": sessions_qs.filter(started_at__gte=today_start)
.values("user")
.distinct()
.count(),
"sessions_today": sessions_qs.filter(started_at__gte=today_start).count(),
"sessions_window": sessions_qs.count(),
"page_views_today": pageviews_qs.filter(entered_at__gte=today_start).count(),
"api_calls_today": api_qs.filter(created_at__gte=today_start).count(),
"errors_today": errors_qs.filter(created_at__gte=today_start).count(),
"avg_response_ms": round(api_qs.aggregate(v=Avg("duration_ms"))["v"] or 0, 1),
}
ended = sessions_qs.exclude(ended_at__isnull=True).annotate(
dur=ExpressionWrapper(F("ended_at") - F("started_at"), output_field=DurationField())
)
avg_duration = ended.aggregate(v=Avg("dur"))["v"]
overview["avg_session_minutes"] = round(avg_duration.total_seconds() / 60, 1) if avg_duration else 0
top_screens = list(
pageviews_qs.values("screen_name")
.annotate(count=Count("id"), avg_duration=Avg("duration_seconds"))
.order_by("-count")[:10]
)
max_screen_count = max((s["count"] for s in top_screens), default=1) or 1
for s in top_screens:
s["bar_percent"] = round(100 * s["count"] / max_screen_count)
s["avg_duration"] = round(s["avg_duration"], 1) if s["avg_duration"] else None
endpoints = list(
api_qs.values("path", "method")
.annotate(
count=Count("id"),
avg_ms=Avg("duration_ms"),
max_ms=Max("duration_ms"),
error_count=Count("id", filter=Q(status_code__gte=400)),
)
.order_by("-count")[:20]
)
for e in endpoints:
e["avg_ms"] = round(e["avg_ms"], 1) if e["avg_ms"] else 0
recent_errors = list(errors_qs.select_related("session__user")[:20])
active_user_ids = sessions_qs.values_list("user_id", flat=True).distinct()
user_rows = []
for user in User.objects.filter(id__in=active_user_ids):
user_sessions = sessions_qs.filter(user=user)
ended_user = user_sessions.exclude(ended_at__isnull=True).annotate(
dur=ExpressionWrapper(F("ended_at") - F("started_at"), output_field=DurationField())
)
total_dur = ended_user.aggregate(v=Sum("dur"))["v"]
ips = list(
user_sessions.exclude(ip_address__isnull=True)
.values_list("ip_address", flat=True)
.distinct()
)
top_screen = (
pageviews_qs.filter(session__user=user)
.values("screen_name")
.annotate(c=Count("id"))
.order_by("-c")
.first()
)
last_session = user_sessions.order_by("-started_at").first()
user_rows.append(
{
"user": user,
"session_count": user_sessions.count(),
"total_minutes": round(total_dur.total_seconds() / 60, 1) if total_dur else 0,
"page_view_count": pageviews_qs.filter(session__user=user).count(),
"api_call_count": api_qs.filter(user=user).count(),
"error_count": errors_qs.filter(session__user=user).count(),
"ips": ips,
"referral_count": User.objects.filter(
referred_by=user, referral_bonus_granted=True
).count(),
"top_screen": top_screen["screen_name"] if top_screen else "—",
"last_seen": last_session.started_at if last_session else None,
}
)
user_rows.sort(key=lambda r: r["total_minutes"], reverse=True)
overview_cards = [
("کاربران فعال امروز", overview["active_users_today"]),
("جلسه‌های امروز", overview["sessions_today"]),
(f"جلسه‌ها ({_WINDOW_DAYS} روز اخیر)", overview["sessions_window"]),
("میانگین طول جلسه (دقیقه)", overview["avg_session_minutes"]),
("صفحه‌بازدید امروز", overview["page_views_today"]),
("فراخوانی API امروز", overview["api_calls_today"]),
("میانگین زمان پاسخ (ms)", overview["avg_response_ms"]),
("خطای امروز", overview["errors_today"]),
]
return {
"overview": overview,
"overview_cards": overview_cards,
"top_screens": top_screens,
"endpoints": endpoints,
"recent_errors": recent_errors,
"user_rows": user_rows[:50],
"window_days": _WINDOW_DAYS,
}
@admin.register(ApiCallLog)
class ApiCallLogAdmin(ModelAdmin):
list_display = ("method", "path", "status_code", "duration_ms", "ip_address", "user", "created_at")
list_filter = ("method", "status_code")
search_fields = ("path", "user__phone", "ip_address")
date_hierarchy = "created_at"
@admin.register(PageView)
class PageViewAdmin(ModelAdmin):
list_display = ("screen_name", "session", "entered_at", "duration_seconds")
list_filter = ("screen_name",)
search_fields = ("screen_name",)
@admin.register(ClickEvent)
class ClickEventAdmin(ModelAdmin):
list_display = ("screen_name", "element", "session", "created_at")
list_filter = ("screen_name",)
search_fields = ("screen_name", "element")
@admin.register(ClientErrorLog)
class ClientErrorLogAdmin(ModelAdmin):
list_display = ("screen_name", "message", "session", "created_at")
list_filter = ("screen_name",)
search_fields = ("screen_name", "message")
View File
+31
View File
@@ -0,0 +1,31 @@
from rest_framework import serializers
class TelemetryEventSerializer(serializers.Serializer):
"""یک رویدادِ خام از اپ — نوع‌اش تعیین می‌کند کدام فیلدها معنی دارند.
عمداً یک سریالایزرِ آزاد (نه یکی برای هر مدل) چون این اندپوینت دسته‌ای
(batch) کار می‌کند و ترکیبِ چند نوع رویداد را با هم می‌گیرد."""
type = serializers.ChoiceField(
choices=["session_start", "session_end", "page_view", "click", "error"]
)
at = serializers.DateTimeField()
screen = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
duration_seconds = serializers.FloatField(required=False, allow_null=True, default=None)
element = serializers.CharField(max_length=150, required=False, allow_blank=True, default="")
x = serializers.FloatField(required=False, allow_null=True, default=None)
y = serializers.FloatField(required=False, allow_null=True, default=None)
message = serializers.CharField(required=False, allow_blank=True, default="")
stack = serializers.CharField(required=False, allow_blank=True, default="")
class TelemetryBatchSerializer(serializers.Serializer):
session_id = serializers.CharField(max_length=36)
platform = serializers.CharField(max_length=30, required=False, allow_blank=True, default="")
app_version = serializers.CharField(max_length=30, required=False, allow_blank=True, default="")
events = TelemetryEventSerializer(many=True)
def validate_events(self, value):
if not value:
raise serializers.ValidationError("حداقل یک رویداد لازم است.")
return value
+60
View File
@@ -0,0 +1,60 @@
from django.db import transaction
from apps.account.models import User
from apps.telemetry.models import AppSession, ClickEvent, ClientErrorLog, PageView
class TelemetryIngestService:
@staticmethod
@transaction.atomic
def ingest(user: User, ip_address: str | None, data: dict) -> None:
session, _created = AppSession.objects.get_or_create(
id=data["session_id"],
defaults={
"user": user,
"platform": data.get("platform", ""),
"app_version": data.get("app_version", ""),
"ip_address": ip_address,
"started_at": data["events"][0]["at"],
},
)
# اگر آی‌پی از قبل ثبت نشده یا عوض شده، آخرین مقدار را نگه می‌داریم —
# همان چیزی که «لیست آی‌پی‌هایی که کاربر باهاش اومده» را می‌سازد (از
# طریق تجمیعِ همه‌ی session های همان کاربر، نه فقط این یکی).
if ip_address and session.ip_address != ip_address:
session.ip_address = ip_address
session.save(update_fields=["ip_address"])
for event in data["events"]:
event_type = event["type"]
if event_type == "session_start":
if session.started_at != event["at"]:
session.started_at = min(session.started_at, event["at"])
session.save(update_fields=["started_at"])
elif event_type == "session_end":
session.ended_at = event["at"]
session.save(update_fields=["ended_at"])
elif event_type == "page_view":
PageView.objects.create(
session=session,
screen_name=event.get("screen", ""),
entered_at=event["at"],
duration_seconds=event.get("duration_seconds"),
)
elif event_type == "click":
ClickEvent.objects.create(
session=session,
screen_name=event.get("screen", ""),
element=event.get("element", ""),
x=event.get("x"),
y=event.get("y"),
created_at=event["at"],
)
elif event_type == "error":
ClientErrorLog.objects.create(
session=session,
screen_name=event.get("screen", ""),
message=event.get("message", ""),
stack_trace=event.get("stack", ""),
created_at=event["at"],
)
+24
View File
@@ -0,0 +1,24 @@
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import TelemetryBatchSerializer
from .services import TelemetryIngestService
class TelemetryEventsView(APIView):
"""اپ فلاتر دوره‌ای (و موقعِ بستن) یک دسته رویداد اینجا می‌فرستد —
صفحه‌بازدید، کلیک، خطا، شروع/پایانِ جلسه. لاگین لازم است چون این
مانیتورینگ «به‌ازای کاربر» است، نه ترافیکِ ناشناس."""
permission_classes = [IsAuthenticated]
def post(self, request):
serializer = TelemetryBatchSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
ip = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() or request.META.get(
"REMOTE_ADDR"
)
TelemetryIngestService.ingest(request.user, ip, serializer.validated_data)
return Response({"success": True}, status=status.HTTP_201_CREATED)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class TelemetryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.telemetry"
verbose_name = _("مانیتورینگ و آمار اپ")
+60
View File
@@ -0,0 +1,60 @@
import time
# مانیتورینگ خودکار همه‌ی فراخوانی‌های API — بدون نیازِ به هیچ تغییری توی
# اپ فلاتر، چون توی همان سطحِ میدل‌ور جنگو کار می‌کند. کاربر از JWT توی
# هدرِ Authorization (اگر باشد) استخراج می‌شود — در این لحظه هنوز
# AuthenticationMiddleware کاربر واقعی را روی request نگذاشته (آن فقط برای
# auth مبتنی‌بر session است، نه JWT)، پس خودمان با JWTAuthentication همان
# توکن را می‌خوانیم.
class ApiCallLoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
start = time.monotonic()
response = self.get_response(request)
if request.path.startswith("/api/"):
duration_ms = (time.monotonic() - start) * 1000
try:
self._log(request, response, duration_ms)
except Exception:
# لاگ‌گیریِ مانیتورینگ هیچ‌وقت نباید پاسخِ واقعی به کاربر را
# خراب کند.
pass
return response
def _log(self, request, response, duration_ms):
from .models import ApiCallLog
ApiCallLog.objects.create(
user=self._resolve_user(request),
method=request.method,
path=request.path[:255],
status_code=response.status_code,
duration_ms=duration_ms,
ip_address=self._client_ip(request),
)
@staticmethod
def _resolve_user(request):
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if not auth_header.startswith("Bearer "):
return None
try:
from rest_framework_simplejwt.authentication import JWTAuthentication
result = JWTAuthentication().authenticate(request)
return result[0] if result else None
except Exception:
return None
@staticmethod
def _client_ip(request):
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR") or None
+122
View File
@@ -0,0 +1,122 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# مانیتورینگ اپ: هر فراخوانیِ API خودکار (سمت سرور، بدون نیاز به تغییر اپ
# فلاتر) لاگ می‌شود؛ رویدادهای سمت کلاینت (صفحه‌بازدید/کلیک/خطا) از یک
# اندپوینتِ دسته‌ای ثبت می‌شوند که اپ فلاتر دوره‌ای می‌فرستد.
class ApiCallLog(models.Model):
"""هر درخواستِ API — خودکار توسط ApiCallLoggingMiddleware ثبت می‌شود."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="api_calls",
verbose_name=_("کاربر"),
)
method = models.CharField(max_length=10, verbose_name=_("متد"))
path = models.CharField(max_length=255, verbose_name=_("مسیر"))
status_code = models.PositiveSmallIntegerField(verbose_name=_("کد وضعیت"))
duration_ms = models.FloatField(verbose_name=_("زمان پاسخ (میلی‌ثانیه)"))
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("آی‌پی"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("زمان"))
class Meta:
verbose_name = _("لاگ فراخوانی API")
verbose_name_plural = _("لاگ‌های فراخوانی API")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "created_at"]),
models.Index(fields=["path", "created_at"]),
]
def __str__(self):
return f"{self.method} {self.path} ({self.status_code})"
class AppSession(models.Model):
"""یک دوره‌ی «باز بودنِ اپ» — از سمت کلاینت (اپ فلاتر) با یک UUID شروع
می‌شود، با رویدادِ session_end یا انقضای بی‌فعالیت بسته می‌شود."""
id = models.CharField(max_length=36, primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="app_sessions", verbose_name=_("کاربر")
)
platform = models.CharField(max_length=30, blank=True, verbose_name=_("پلتفرم"))
app_version = models.CharField(max_length=30, blank=True, verbose_name=_("نسخه‌ی اپ"))
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("آی‌پی"))
started_at = models.DateTimeField(verbose_name=_("شروع"))
ended_at = models.DateTimeField(null=True, blank=True, verbose_name=_("پایان"))
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("جلسه‌ی استفاده از اپ")
verbose_name_plural = _("جلسه‌های استفاده از اپ")
ordering = ["-started_at"]
indexes = [models.Index(fields=["user", "started_at"])]
def __str__(self):
return f"{self.user} — {self.started_at:%Y-%m-%d %H:%M}"
@property
def duration_seconds(self) -> float | None:
if not self.ended_at:
return None
return (self.ended_at - self.started_at).total_seconds()
class PageView(models.Model):
"""یک بازدید از یک صفحه/مسیر توی اپ — با ورود و مدت‌زمانِ ماندن."""
session = models.ForeignKey(AppSession, on_delete=models.CASCADE, related_name="page_views")
screen_name = models.CharField(max_length=150, verbose_name=_("نام صفحه"))
entered_at = models.DateTimeField(verbose_name=_("زمان ورود"))
duration_seconds = models.FloatField(null=True, blank=True, verbose_name=_("مدت ماندن (ثانیه)"))
class Meta:
verbose_name = _("بازدید صفحه")
verbose_name_plural = _("بازدیدهای صفحه")
ordering = ["entered_at"]
indexes = [models.Index(fields=["screen_name"])]
def __str__(self):
return self.screen_name
class ClickEvent(models.Model):
"""یک لمس/کلیک — مختصات نسبی (۰ تا ۱) نسبت به عرض/ارتفاع صفحه، برای
این‌که مستقل از سایز گوشی قابل‌مقایسه باشد."""
session = models.ForeignKey(AppSession, on_delete=models.CASCADE, related_name="clicks")
screen_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام صفحه"))
element = models.CharField(max_length=150, blank=True, verbose_name=_("برچسبِ عنصر (اختیاری)"))
x = models.FloatField(null=True, blank=True)
y = models.FloatField(null=True, blank=True)
created_at = models.DateTimeField(verbose_name=_("زمان"))
class Meta:
verbose_name = _("رویداد کلیک")
verbose_name_plural = _("رویدادهای کلیک")
ordering = ["-created_at"]
indexes = [models.Index(fields=["screen_name"])]
class ClientErrorLog(models.Model):
"""خطای سمت کلاینت (uncaught exception توی اپ فلاتر) — «کجا مشکل خورده»."""
session = models.ForeignKey(
AppSession, on_delete=models.SET_NULL, null=True, blank=True, related_name="errors"
)
screen_name = models.CharField(max_length=150, blank=True, verbose_name=_("نام صفحه"))
message = models.TextField(verbose_name=_("پیام خطا"))
stack_trace = models.TextField(blank=True, verbose_name=_("Stack trace"))
created_at = models.DateTimeField(verbose_name=_("زمان"))
class Meta:
verbose_name = _("خطای سمت کاربر")
verbose_name_plural = _("خطاهای سمت کاربر")
ordering = ["-created_at"]
@@ -0,0 +1,143 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<div dir="rtl" class="space-y-6">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
آمار {{ window_days }} روزِ اخیر — برای دیدنِ لیستِ خامِ جلسه‌ها
<a href="?raw=1" class="text-primary-600 dark:text-primary-400 underline">این‌جا</a> را بزن.
</p>
</div>
{# ---------------- خلاصه‌ی کلی ---------------- #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{% for label, value in overview_cards %}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ label }}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-50 mt-1">{{ value }}</p>
</div>
{% endfor %}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{# ---------------- صفحات پربازدید ---------------- #}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4">
<h2 class="font-bold text-gray-900 dark:text-gray-50 mb-3">صفحاتِ پربازدید</h2>
{% if top_screens %}
<div class="space-y-2">
{% for s in top_screens %}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-700 dark:text-gray-200">{{ s.screen_name|default:"—" }}</span>
<span class="text-gray-500 dark:text-gray-400">
{{ s.count }} بازدید
{% if s.avg_duration %}· میانگین {{ s.avg_duration }} ثانیه{% endif %}
</span>
</div>
<div class="h-2 rounded-full bg-gray-100 dark:bg-base-700 overflow-hidden">
<div class="h-full bg-primary-500" style="width: {{ s.bar_percent }}%"></div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-500 dark:text-gray-400">هنوز داده‌ای ثبت نشده.</p>
{% endif %}
</div>
{# ---------------- خطاهای اخیر ---------------- #}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4">
<h2 class="font-bold text-gray-900 dark:text-gray-50 mb-3">خطاهای اخیرِ سمتِ کاربر</h2>
{% if recent_errors %}
<div class="space-y-2 max-h-80 overflow-y-auto">
{% for e in recent_errors %}
<div class="text-sm border-b border-gray-100 dark:border-gray-700 pb-2">
<div class="flex justify-between text-gray-500 dark:text-gray-400 text-xs">
<span>{{ e.screen_name|default:"—" }}</span>
<span>{{ e.created_at|date:"Y-m-d H:i" }}</span>
</div>
<p class="text-gray-800 dark:text-gray-200 truncate">{{ e.message }}</p>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-500 dark:text-gray-400">خطایی ثبت نشده — خوب است.</p>
{% endif %}
</div>
</div>
{# ---------------- عملکرد اندپوینت‌ها ---------------- #}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4 overflow-x-auto">
<h2 class="font-bold text-gray-900 dark:text-gray-50 mb-3">عملکردِ اندپوینت‌ها</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-gray-500 dark:text-gray-400 text-xs border-b border-gray-200 dark:border-gray-700">
<th class="text-right py-2">مسیر</th>
<th class="text-right py-2">متد</th>
<th class="text-right py-2">تعداد فراخوانی</th>
<th class="text-right py-2">میانگین زمان (ms)</th>
<th class="text-right py-2">بیشترین زمان (ms)</th>
<th class="text-right py-2">تعداد خطا</th>
</tr>
</thead>
<tbody>
{% for e in endpoints %}
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 text-gray-800 dark:text-gray-200" dir="ltr">{{ e.path }}</td>
<td class="py-2">{{ e.method }}</td>
<td class="py-2">{{ e.count }}</td>
<td class="py-2">{{ e.avg_ms }}</td>
<td class="py-2">{{ e.max_ms|floatformat:1 }}</td>
<td class="py-2 {% if e.error_count %}text-red-600 dark:text-red-400{% endif %}">{{ e.error_count }}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="py-4 text-center text-gray-500 dark:text-gray-400">هنوز داده‌ای ثبت نشده.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{# ---------------- به‌ازای هر کاربر ---------------- #}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-base-800 p-4 overflow-x-auto">
<h2 class="font-bold text-gray-900 dark:text-gray-50 mb-3">وضعیتِ هر کاربر</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-gray-500 dark:text-gray-400 text-xs border-b border-gray-200 dark:border-gray-700">
<th class="text-right py-2">کاربر</th>
<th class="text-right py-2">زمان در اپ (دقیقه)</th>
<th class="text-right py-2">تعداد جلسه</th>
<th class="text-right py-2">صفحه‌بازدید</th>
<th class="text-right py-2">فراخوانی API</th>
<th class="text-right py-2">خطا</th>
<th class="text-right py-2">صفحه‌ی محبوب</th>
<th class="text-right py-2">تعداد دعوت</th>
<th class="text-right py-2">آی‌پی‌ها</th>
<th class="text-right py-2">آخرین حضور</th>
</tr>
</thead>
<tbody>
{% for row in user_rows %}
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 text-gray-800 dark:text-gray-200" dir="ltr">{{ row.user.phone }}</td>
<td class="py-2">{{ row.total_minutes }}</td>
<td class="py-2">{{ row.session_count }}</td>
<td class="py-2">{{ row.page_view_count }}</td>
<td class="py-2">{{ row.api_call_count }}</td>
<td class="py-2 {% if row.error_count %}text-red-600 dark:text-red-400{% endif %}">{{ row.error_count }}</td>
<td class="py-2">{{ row.top_screen }}</td>
<td class="py-2">{{ row.referral_count }}</td>
<td class="py-2" dir="ltr">{{ row.ips|join:", "|default:"—" }}</td>
<td class="py-2">{{ row.last_seen|date:"Y-m-d H:i"|default:"—" }}</td>
</tr>
{% empty %}
<tr><td colspan="10" class="py-4 text-center text-gray-500 dark:text-gray-400">هنوز کاربری در این بازه فعال نبوده.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+209
View File
@@ -0,0 +1,209 @@
from datetime import datetime, timedelta
from datetime import timezone as dt_timezone
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.account.models import User
from apps.telemetry.admin import AppSessionAdmin
from apps.telemetry.api.services import TelemetryIngestService
from apps.telemetry.models import ApiCallLog, AppSession, ClickEvent, ClientErrorLog, PageView
def _make_user(phone="09120000000") -> User:
return User.objects.create_user(phone=phone, is_active=True, state=User.State.ACTIVE)
def _iso(text: str) -> datetime:
return datetime.fromisoformat(text).replace(tzinfo=dt_timezone.utc)
class TelemetryIngestServiceTests(TestCase):
def setUp(self):
self.user = _make_user()
def test_ingest_creates_session_and_events(self):
TelemetryIngestService.ingest(
self.user,
"5.6.7.8",
{
"session_id": "sess-1",
"platform": "android",
"app_version": "1.0.0",
"events": [
{"type": "session_start", "at": _iso("2026-01-01T10:00:00")},
{
"type": "page_view",
"screen": "LauncherScreen",
"at": _iso("2026-01-01T10:00:05"),
"duration_seconds": 12.5,
"element": "",
"x": None,
"y": None,
"message": "",
"stack": "",
},
{
"type": "click",
"screen": "LauncherScreen",
"element": "invoice_tile",
"at": _iso("2026-01-01T10:00:10"),
"x": 0.4,
"y": 0.5,
"duration_seconds": None,
"message": "",
"stack": "",
},
{
"type": "error",
"screen": "InvoiceHomeScreen",
"message": "boom",
"stack": "trace...",
"at": _iso("2026-01-01T10:01:00"),
"duration_seconds": None,
"element": "",
"x": None,
"y": None,
},
{"type": "session_end", "at": _iso("2026-01-01T10:05:00")},
],
},
)
session = AppSession.objects.get(id="sess-1")
self.assertEqual(session.user, self.user)
self.assertEqual(session.ip_address, "5.6.7.8")
self.assertEqual(session.duration_seconds, 300)
self.assertEqual(PageView.objects.filter(session=session).count(), 1)
self.assertEqual(ClickEvent.objects.filter(session=session).count(), 1)
self.assertEqual(ClientErrorLog.objects.filter(session=session).count(), 1)
def test_ingest_is_idempotent_for_same_session_id(self):
payload = {
"session_id": "sess-2",
"platform": "ios",
"app_version": "1.0.0",
"events": [{"type": "session_start", "at": _iso("2026-01-01T10:00:00")}],
}
TelemetryIngestService.ingest(self.user, "1.1.1.1", payload)
TelemetryIngestService.ingest(self.user, "1.1.1.1", payload)
self.assertEqual(AppSession.objects.filter(id="sess-2").count(), 1)
class TelemetryApiTests(TestCase):
def setUp(self):
self.user = _make_user()
self.client = APIClient()
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
def test_requires_authentication(self):
anon = APIClient()
response = anon.post(reverse("telemetry:events"), {"session_id": "x", "events": []}, format="json")
self.assertEqual(response.status_code, 401)
def test_rejects_empty_events(self):
response = self.client.post(
reverse("telemetry:events"), {"session_id": "sess-3", "events": []}, format="json"
)
self.assertEqual(response.status_code, 400)
def test_accepts_valid_batch(self):
response = self.client.post(
reverse("telemetry:events"),
{
"session_id": "sess-4",
"platform": "android",
"events": [
{"type": "session_start", "at": "2026-01-01T10:00:00Z"},
{"type": "page_view", "screen": "LedgerHomeScreen", "at": "2026-01-01T10:00:05Z"},
],
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(AppSession.objects.filter(id="sess-4", user=self.user).exists())
class ApiCallLoggingMiddlewareTests(TestCase):
"""میدل‌ور باید خودکار (بدون هیچ تغییری توی اپ فلاتر) هر فراخوانی API را
با کاربر، آی‌پی و زمانِ پاسخ لاگ کند."""
def setUp(self):
self.user = _make_user()
self.client = APIClient()
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
def test_api_call_is_logged_with_user_and_status(self):
ApiCallLog.objects.all().delete()
self.client.get("/api/bizapps/customers/")
log = ApiCallLog.objects.filter(path="/api/bizapps/customers/").first()
self.assertIsNotNone(log)
self.assertEqual(log.user, self.user)
self.assertEqual(log.method, "GET")
self.assertGreaterEqual(log.duration_ms, 0)
def test_non_api_paths_are_not_logged(self):
ApiCallLog.objects.all().delete()
self.client.get("/admin/login/")
self.assertEqual(ApiCallLog.objects.count(), 0)
class DashboardContextTests(TestCase):
"""داشبوردِ پنل ادمین — همان چیزی که کاربر خواسته: زمانِ هر کاربر توی
اپ، صفحاتِ بازدیدشده، آی‌پی‌ها، تعداد دعوت، و عملکردِ اندپوینت‌ها."""
def setUp(self):
self.user = _make_user(phone="09121110000")
self.invited = _make_user(phone="09121110001")
self.invited.referred_by = self.user
self.invited.referral_bonus_granted = True
self.invited.save()
def test_dashboard_aggregates_per_user_stats(self):
# عمداً نسبت‌به الان (نه یک تاریخِ ثابت) — داشبورد فقط بازه‌ی ۳۰
# روزِ اخیر را نشان می‌دهد، پس تاریخِ ثابتِ گذشته باعثِ فیلترشدنِ
# همین دیتای تست می‌شد.
base = timezone.now() - timedelta(hours=1)
TelemetryIngestService.ingest(
self.user,
"9.9.9.9",
{
"session_id": "sess-dash",
"platform": "android",
"app_version": "1.0.0",
"events": [
{"type": "session_start", "at": base},
{
"type": "page_view",
"screen": "InvoiceHomeScreen",
"at": base,
"duration_seconds": 20,
"element": "",
"x": None,
"y": None,
"message": "",
"stack": "",
},
{"type": "session_end", "at": base + timedelta(minutes=10)},
],
},
)
ApiCallLog.objects.create(
user=self.user, method="GET", path="/api/bizapps/invoices/", status_code=200, duration_ms=50
)
context = AppSessionAdmin(AppSession, None)._build_dashboard_context()
rows = {r["user"].id: r for r in context["user_rows"]}
self.assertIn(self.user.id, rows)
row = rows[self.user.id]
self.assertEqual(row["session_count"], 1)
self.assertEqual(row["total_minutes"], 10.0)
self.assertEqual(row["page_view_count"], 1)
self.assertEqual(row["api_call_count"], 1)
self.assertEqual(row["referral_count"], 1)
self.assertEqual(row["ips"], ["9.9.9.9"])
self.assertEqual(context["overview"]["sessions_window"], 1)
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .api.views import TelemetryEventsView
app_name = "telemetry"
urlpatterns = [
path("events/", TelemetryEventsView.as_view(), name="events"),
]