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