adding
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 = _("مانیتورینگ و آمار اپ")
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
Reference in New Issue
Block a user