Files
MathApp/progress/report.py
Zareian 39247f6d53 senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental
  settings); progress.0006 drops its tables, permissions and migration history, and
  Profile.parent_phone, the two family notification types and ?userId= on
  /api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
  questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
  refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:38:43 +03:30

149 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
گزارشِ هفتگیِ خودِ کاربر — همان چیزی که فرانت با عنوانِ «گزارشِ والدین» نشان می‌دهد.
از ۲۰۲۶-۱۰-۰۱ جای اپِ `family` را گرفته است (تصمیمِ سینیور پس از ارائه): والد
دیگر حسابِ جدا و کدِ دعوت ندارد و همین صفحه را در حسابِ خودِ دانش‌آموز می‌بیند.
همه‌چیز از فعالیتِ ثبت‌شده خوانده می‌شود و هیچ جدولِ تازه‌ای ندارد:
- `questions` / `correct`: `UserAnswer` (هر جوابِ ثبت‌شده، چه در آزمونِ درس چه جزیره).
- `xp`: درس‌ها و جزیره‌های تمام‌شده — همان تعریفی که `progress/leaderboard.py` و
`UserStats.xp` دارند؛ `UserAnswer.xp_earned` به `UserStats.xp` اضافه نمی‌شود، پس
این‌جا هم شمرده نمی‌شود.
- `aiQuestions`: پیام‌های کاربر به دستیار.
⚠️ `minutes` اندازه‌گیری نیست، **تخمین** است. اپ زمانِ حضور را نمی‌فرستد؛ زمان از
فاصله‌ی رویدادهای پشتِ سرِ هم ساخته می‌شود (پایینِ `estimate_minutes`). خواندنِ یک
درس بدونِ جواب دادن، یا فکرِ طولانی‌تر از `SESSION_GAP` روی یک سوال، شمرده نمی‌شود،
پس عدد معمولاً از زمانِ واقعی کمتر است، نه بیشتر. چرا heartbeat نه: هر کاربرِ آنلاین
هر دقیقه یک نوشتن روی SQLite می‌شد و تاریخچه‌ی قبلی هم صفر می‌ماند. فرانت عدد را
«حدود» نشان می‌دهد.
پنجره‌ها غلتان‌اند (۷ روزِ آخر تا امروز، و ۷ روزِ پیش از آن)، نه هفته‌ی تقویمی: در
شنبه‌ی هفته‌ی تقویمی هر کاربری «کمتر از هفته‌ی پیش» دیده می‌شد. لیدربردِ هفتگی هم
غلتان است.
"""
from collections import defaultdict
from datetime import datetime, time, timedelta
from django.utils import timezone
WINDOW_DAYS = 7
# رویدادهایی که فاصله‌شان از این کمتر است یک نشستِ پیوسته حساب می‌شوند. ۱۰ دقیقه
# جا برای فکر روی یک سوالِ سخت یا منتظرِ جوابِ دستیار ماندن می‌گذارد.
SESSION_GAP = timedelta(minutes=10)
# اولین رویدادِ هر نشست هم زمان برده (خواندنِ سوالِ اول)؛ بدونِ این، کسی که فقط یک
# سوال جواب داده «۰ دقیقه» می‌دید.
SESSION_START = timedelta(minutes=1)
def estimate_minutes(stamps):
"""دقیقه‌های تخمینیِ یک روز از زمانِ رویدادهایش (هر ترتیبی)."""
if not stamps:
return 0
stamps = sorted(stamps)
total = SESSION_START
for prev, cur in zip(stamps, stamps[1:]):
gap = cur - prev
total += gap if gap <= SESSION_GAP else SESSION_START
return round(total.total_seconds() / 60)
def _empty_day(day):
return {
'date': day, 'minutes': 0, 'questions': 0, 'correct': 0,
'xp': 0, 'lessons': 0, 'islands': 0, 'aiQuestions': 0,
}
def _totals(days, lesson_ids, node_ids):
total = {
key: sum(d[key] for d in days)
for key in ('minutes', 'questions', 'correct', 'xp', 'aiQuestions')
}
# درس/جزیره‌ای که دو بار تکرار شده یک بار شمرده می‌شود.
total['lessons'] = len(lesson_ids)
total['islands'] = len(node_ids)
total['activeDays'] = sum(1 for d in days if d['minutes'] > 0)
return total
def weekly_report(user_id, today=None):
from ai_tutor.models import Message
from content.models import UserAnswer, UserLessonCompletion, UserNodeCompletion
from progress.models import StreakRecord
from progress.services import effective_current_streak
today = today or timezone.localdate()
first_day = today - timedelta(days=2 * WINDOW_DAYS - 1)
since = timezone.make_aware(datetime.combine(first_day, time.min))
days = {first_day + timedelta(days=i): _empty_day(first_day + timedelta(days=i))
for i in range(2 * WINDOW_DAYS)}
stamps = defaultdict(list)
# (روزِ پنجره، شناسه) برای شمارشِ یکتای درس و جزیره در هر پنجره.
lessons_by_day = defaultdict(set)
nodes_by_day = defaultdict(set)
def bucket(created_at):
day = timezone.localtime(created_at).date()
stamps[day].append(created_at)
return days.get(day)
for created_at, correct in UserAnswer.objects.filter(
user_id=user_id, created_at__gte=since,
).values_list('created_at', 'correct'):
d = bucket(created_at)
if d is not None:
d['questions'] += 1
d['correct'] += int(correct)
for created_at, lesson_id, xp in UserLessonCompletion.objects.filter(
user_id=user_id, created_at__gte=since,
).values_list('created_at', 'lesson_id', 'xp_earned'):
d = bucket(created_at)
if d is not None:
d['xp'] += xp
lessons_by_day[d['date']].add(lesson_id)
for created_at, node_id, xp in UserNodeCompletion.objects.filter(
user_id=user_id, created_at__gte=since,
).values_list('created_at', 'node_id', 'xp_earned'):
d = bucket(created_at)
if d is not None:
d['xp'] += xp
nodes_by_day[d['date']].add(node_id)
for created_at in Message.objects.filter(
conversation__user_id=user_id, role='user', created_at__gte=since,
).values_list('created_at', flat=True):
d = bucket(created_at)
if d is not None:
d['aiQuestions'] += 1
for day, d in days.items():
d['minutes'] = estimate_minutes(stamps[day])
d['lessons'] = len(lessons_by_day[day])
d['islands'] = len(nodes_by_day[day])
ordered = [days[k] for k in sorted(days)]
previous, current = ordered[:WINDOW_DAYS], ordered[WINDOW_DAYS:]
def ids(sets_by_day, window):
out = set()
for d in window:
out |= sets_by_day[d['date']]
return out
streak = StreakRecord.objects.filter(user_id=user_id).first()
return {
'today': today,
'days': current,
'previousDays': previous,
'thisWeek': _totals(current, ids(lessons_by_day, current), ids(nodes_by_day, current)),
'lastWeek': _totals(previous, ids(lessons_by_day, previous), ids(nodes_by_day, previous)),
'streak': {
'current': effective_current_streak(streak, today=today),
'longest': streak.longest_streak if streak else 0,
},
}