- 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>
149 lines
6.7 KiB
Python
149 lines
6.7 KiB
Python
"""
|
||
گزارشِ هفتگیِ خودِ کاربر — همان چیزی که فرانت با عنوانِ «گزارشِ والدین» نشان میدهد.
|
||
|
||
از ۲۰۲۶-۱۰-۰۱ جای اپِ `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,
|
||
},
|
||
}
|