Files
MathApp/progress/services.py
Zareian 2c708088e4 Spike readiness: AI gets its own slice of threads, cheaper hot paths, classroom-friendly login
- /api/ai/ask: at most AI_MAX_CONCURRENT_PER_WORKER (12 of 32 threads) answers per worker;
  beyond that 503 assistant_busy + Retry-After before any quota is reserved. Without it a
  burst of questions (3-40 s each) took every thread and the map and quizzes queued behind.
  The slot is held until the stream is actually finished or closed.
- Answer submission 21 -> 14 queries, 15.4 -> 11.0 ms: one query for all choices, locked
  get_or_create for node progress and streak, and today's streak recorded once per day
  via the cache instead of a transaction per answer.
- /api/home 12.4 -> 8.6 ms: the user's completions are read by user_id instead of an IN list
  of ~600 lesson ids; the dict is returned directly (serializer stays the Swagger contract,
  HomeMatchesItsSerializerTests keeps them equal).
- otp_login per-IP 30 -> 300/hour: a class of 30 on one school Wi-Fi filled it; guessing is
  bounded per code and per phone.
- /api/health aiConcurrency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:55:24 +03:30

77 lines
3.2 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.
"""
استریک: روزهای پیاپیِ فعالیت.
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای کد StreakRecord یا ActiveDay نمی‌نوشت — فقط
seed_test_data. یعنی «روز پیاپی» روی صفحه‌ی خانه برای هر کاربرِ واقعی برای
همیشه صفر بود و فقط کاربرهای دمو عدد داشتند.
«فعالیت» یعنی پاسخ به سوال یا تمام کردنِ درس/جزیره. هر روز (به وقتِ تهران،
TIME_ZONE) یک بار شمرده می‌شود؛ صدا زدنِ مکرر در یک روز بی‌اثر است.
"""
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from .models import ActiveDay, StreakRecord
def record_activity(user_id, today=None):
"""امروز را روزِ فعال ثبت می‌کند و استریک را جلو می‌برد. idempotent در یک روز."""
today = today or timezone.localdate()
with transaction.atomic():
streak, _ = StreakRecord.objects.select_for_update().get_or_create(user_id=user_id)
last = (
ActiveDay.objects.filter(streak=streak)
.order_by('-date').values_list('date', flat=True).first()
)
if last == today:
return streak
if last == today - timedelta(days=1):
streak.current_streak += 1
else:
streak.current_streak = 1
streak.longest_streak = max(streak.longest_streak, streak.current_streak)
streak.save(update_fields=['current_streak', 'longest_streak'])
ActiveDay.objects.get_or_create(streak=streak, date=today)
return streak
def effective_current_streak(streak, today=None):
"""
استریکی که الان باید نشان داده شود.
current_streak فقط در لحظه‌ی فعالیت به‌روز می‌شود؛ کاربری که دو روز
نیامده هنوز عددِ قبلی را در دیتابیس دارد. اگر آخرین روزِ فعال نه امروز
است نه دیروز، زنجیره شکسته و عددِ واقعی صفر است.
"""
if streak is None:
return 0
today = today or timezone.localdate()
last = (
streak.active_days.order_by('-date').values_list('date', flat=True).first()
)
if last is None or last < today - timedelta(days=1):
return 0
return streak.current_streak
def streak_state(streak, today=None):
"""
وضعیتِ زنجیره برای نمایش: (current, active_today, lost).
lost طولِ زنجیره‌ای است که شکسته (آخرین روزِ فعال پیش از دیروز)؛ current_streakِ دیتابیس تا فعالیتِ بعدی
همان عددِ قبلی را نگه می‌دارد (record_activity آن را به ۱ برمی‌گرداند)، پس همین عدد است.
"""
if streak is None:
return 0, False, 0
today = today or timezone.localdate()
last = streak.active_days.order_by('-date').values_list('date', flat=True).first()
if last is None:
return 0, False, 0
if last < today - timedelta(days=1):
return 0, False, streak.current_streak
return streak.current_streak, last == today, 0