"""관리자 대시보드 집계 — ChatMessage(assistant 행)에 이미 쌓인 토큰·비용·시간을 모아서 줌. GET /api/v1/admin/stats?from=YYYY-MM-DD&to=YYYY-MM-DD → { totals, byUser[], byDay[] } (is_superuser 만. 아니면 403 FORBIDDEN) 새로 모으는 건 없음. 기간 기본은 최근 30일(to 포함). """ from datetime import date, datetime, time, timedelta from django.db.models import Avg, Count, F, Sum from django.db.models.functions import TruncDate from django.utils import timezone from rest_framework.response import Response from rest_framework.views import APIView from apps.chat.models import ChatMessage from common.envelope import CodedError MAX_DAYS = 366 def _parse_range(request) -> tuple[date, date]: today = timezone.localdate() raw_from, raw_to = request.query_params.get("from"), request.query_params.get("to") try: d_to = date.fromisoformat(raw_to) if raw_to else today d_from = date.fromisoformat(raw_from) if raw_from else d_to - timedelta(days=29) except ValueError: raise CodedError(400, "VALIDATION_ERROR", "from/to 는 YYYY-MM-DD 여야 해") if d_from > d_to: raise CodedError(400, "VALIDATION_ERROR", "from 이 to 보다 늦어") if (d_to - d_from).days >= MAX_DAYS: raise CodedError(400, "VALIDATION_ERROR", f"기간은 최대 {MAX_DAYS}일") return d_from, d_to def _agg(qs): """공통 집계 — 요청 수·토큰·비용·평균 응답시간.""" return qs.aggregate( requests=Count("id"), input_tokens=Sum("input_tokens"), output_tokens=Sum("output_tokens"), cost_usd=Sum("cost_usd"), avg_elapsed_ms=Avg("elapsed_ms"), ) def _row(d: dict) -> dict: return { "requests": d["requests"] or 0, "inputTokens": d["input_tokens"] or 0, "outputTokens": d["output_tokens"] or 0, "totalTokens": (d["input_tokens"] or 0) + (d["output_tokens"] or 0), "costUsd": round(d["cost_usd"] or 0.0, 6), "avgElapsedMs": round(d["avg_elapsed_ms"]) if d["avg_elapsed_ms"] is not None else None, } class AdminStatsView(APIView): def get(self, request): if not request.user.is_superuser: raise CodedError(403, "FORBIDDEN", "관리자만 볼 수 있어") d_from, d_to = _parse_range(request) tz = timezone.get_current_timezone() start = datetime.combine(d_from, time.min, tzinfo=tz) end = datetime.combine(d_to + timedelta(days=1), time.min, tzinfo=tz) base = ChatMessage.objects.filter(role="assistant", created_at__gte=start, created_at__lt=end) by_user = ( base.values(user_id=F("session__user_id"), email=F("session__user__email"), name=F("session__user__user_name")) .annotate( requests=Count("id"), input_tokens=Sum("input_tokens"), output_tokens=Sum("output_tokens"), cost_usd=Sum("cost_usd"), avg_elapsed_ms=Avg("elapsed_ms"), sessions=Count("session_id", distinct=True), ) .order_by("-cost_usd", "-requests") ) by_day = ( base.annotate(day=TruncDate("created_at", tzinfo=tz)) .values("day") .annotate( requests=Count("id"), input_tokens=Sum("input_tokens"), output_tokens=Sum("output_tokens"), cost_usd=Sum("cost_usd"), avg_elapsed_ms=Avg("elapsed_ms"), ) .order_by("day") ) return Response( { "from": d_from.isoformat(), "to": d_to.isoformat(), "totals": {**_row(_agg(base)), "users": base.values("session__user_id").distinct().count(), "sessions": base.values("session_id").distinct().count()}, "byUser": [ {"userId": r["user_id"], "email": r["email"], "name": r["name"] or "", "sessions": r["sessions"], **_row(r)} for r in by_user ], "byDay": [{"day": r["day"].isoformat(), **_row(r)} for r in by_day], } )