Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown, with rag/services/mathtext.py converting whatever still slips through; full step-by-step solutions with a check before the final answer; three tones by grade; the last 6 messages of the conversation go to the model and follow-ups are no longer served from another user's cache; book solutions the checker disputes are kept out of the context; a questionId gives the tutor the practice question and the student's wrong answer. /api/health reports when the AI credit runs out. rag_model_benchmark compares chat models on verified book problems (run on the server; credit ran out mid-run). Map: only lessons that have questions count toward finishing an island, lessonIds[0] / nextLessonId is always the next lesson to play, and a finished island stays finished. The Seyar characters (sigma/delta/pi) are seeded. Audit fixes, each reproduced before fixing: - the plan is stored on the order, so paying a monthly order after starting a yearly checkout no longer grants a year - opening checkout no longer demotes an active subscription; renewals extend from the current expiry; expired subscriptions are not reported active - one discount code per order and only before the gateway; a 100% code completes the purchase; lost Zarinpal confirmations are re-verified - the unused /api/payment/webhook "mark paid" endpoint is removed - XP counts only answers recorded on the server - 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout - a guest who signs up keeps their account and progress (test phones excluded) - affiliate commission on the first real payment; guests excluded - parents with several children no longer get a 500 - SQLite runs transactions IMMEDIATE, since it ignores select_for_update - admin login is rate-limited; display names drop phone numbers and links; userQuestion is capped at 2000 characters - server-side heart enforcement exists behind HEARTS_ENFORCED (off until the frontend handles 403 no_hearts) Not done on purpose: rotating the server's secret key and moving to Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
3.9 KiB
Python
109 lines
3.9 KiB
Python
from django.conf import settings
|
||
from django.core import signing
|
||
from django.core.cache import cache
|
||
from rest_framework.authentication import BaseAuthentication
|
||
from rest_framework.exceptions import AuthenticationFailed
|
||
|
||
from .models import User
|
||
|
||
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
|
||
|
||
# فیلدهای غیرِرابطهایِ User که کش میشن؛ همهی concrete fieldها تا بازسازیِ
|
||
# instance برای save(update_fields=...) امن بمونه.
|
||
_USER_CACHE_FIELDS = [f.attname for f in User._meta.concrete_fields]
|
||
|
||
|
||
def _auth_user_cache_key(user_id: int) -> str:
|
||
return f'auth:user:{user_id}'
|
||
|
||
|
||
def invalidate_user_cache(user_id: int) -> None:
|
||
cache.delete(_auth_user_cache_key(user_id))
|
||
|
||
|
||
def _load_user_or_fail(user_id: int) -> User:
|
||
try:
|
||
return User.objects.get(pk=user_id)
|
||
except User.DoesNotExist:
|
||
raise AuthenticationFailed('کاربر پیدا نشد.')
|
||
|
||
|
||
def _get_request_user(user_id: int) -> User:
|
||
"""
|
||
ردیفِ کاربر برای request جاری. با AUTH_USER_CACHE_TTL>0 از کش خونده
|
||
میشه (یک SELECT کمتر در هر request authenticated). instanceِ بازسازیشده
|
||
کاملِ همهی فیلدهاست، پس save(update_fields=[...]) روش امنه.
|
||
"""
|
||
ttl = getattr(settings, 'AUTH_USER_CACHE_TTL', 0)
|
||
if not ttl:
|
||
return _load_user_or_fail(user_id)
|
||
|
||
key = _auth_user_cache_key(user_id)
|
||
data = cache.get(key)
|
||
if data is None:
|
||
user = _load_user_or_fail(user_id)
|
||
cache.set(key, {f: getattr(user, f) for f in _USER_CACHE_FIELDS}, ttl)
|
||
return user
|
||
|
||
user = User(**data)
|
||
user._state.adding = False
|
||
user._state.db = 'default'
|
||
return user
|
||
|
||
|
||
def issue_access_token(user_id: int) -> str:
|
||
"""
|
||
ساخت access token امضاشده (stateless) — دیتابیس ذخیره نمیشه، فقط
|
||
امضا و expiry داره. با django.core.signing (بدون نیاز به کتابخونهی JWT).
|
||
"""
|
||
signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT)
|
||
return signer.sign(str(user_id))
|
||
|
||
|
||
def decode_access_token(token: str) -> int:
|
||
signer = signing.TimestampSigner(salt=ACCESS_TOKEN_SALT)
|
||
try:
|
||
value = signer.unsign(token, max_age=settings.ACCESS_TOKEN_TTL_SECONDS)
|
||
except signing.SignatureExpired:
|
||
raise AuthenticationFailed('توکن منقضی شده است.')
|
||
except signing.BadSignature:
|
||
raise AuthenticationFailed('توکن نامعتبر است.')
|
||
return int(value)
|
||
|
||
|
||
def _issued_at(token: str) -> int:
|
||
"""
|
||
ثانیهی صدورِ توکن. قالبِ TimestampSigner «مقدار:زمان:امضا» است و زمان
|
||
base62؛ فقط بعد از unsignِ موفق صدا زده میشود، پس ساختار معتبر است.
|
||
"""
|
||
return signing.b62_decode(token.rsplit(':', 2)[-2])
|
||
|
||
|
||
class TadrisinoTokenAuthentication(BaseAuthentication):
|
||
"""
|
||
هدر مورد انتظار: Authorization: Bearer <access_token>
|
||
اگه هدر نباشه، درخواست بهصورت anonymous ادامه پیدا میکنه (None, None)
|
||
و View خودش تصمیم میگیره که آیا auth اجباریه (IsAuthenticated) یا نه.
|
||
"""
|
||
|
||
keyword = 'Bearer'
|
||
|
||
def authenticate(self, request):
|
||
auth_header = request.headers.get('Authorization', '')
|
||
if not auth_header:
|
||
return None
|
||
|
||
parts = auth_header.split()
|
||
if len(parts) != 2 or parts[0] != self.keyword:
|
||
return None
|
||
|
||
token = parts[1]
|
||
user_id = decode_access_token(token)
|
||
user = _get_request_user(user_id)
|
||
if user.tokens_valid_after is not None and _issued_at(token) < int(user.tokens_valid_after.timestamp()):
|
||
raise AuthenticationFailed('از حساب خارج شدهاید؛ دوباره وارد شوید.')
|
||
return (user, token)
|
||
|
||
def authenticate_header(self, request):
|
||
return self.keyword
|