The Swagger header was a list of features. That is what the project does, not what a caller needs. It now opens with the three things anyone integrating against this API asks first: how do I get a token, what does an error look like, and when do I get rate limited -- plus the field-naming and date conventions, and a note that the schema is the contract and clients should be generated from it rather than written from memory. Swagger UI and Redoc both render Markdown, so it is structured rather than one long paragraph, which also fixes how badly the old mixed Persian/Latin line wrapped right-to-left. Two defects surfaced while checking the rendered schema: - /api/health was absent from the schema entirely. The trailing-slash alias was excluded with extend_schema(exclude=True)(HealthView), but that decorator mutates the class, and both routes pointed at the same class, so the canonical route disappeared with it. A subclass now carries the exclusion, and the endpoint is back, exactly once. Endpoint count is 56, not 55. - One endpoint was tagged 'محتوا (content)' while every other content endpoint used 'Curriculum (content)', which split the group into two sections. Tags are now declared with descriptions and ordered by the path a real user takes, rather than alphabetically. Comment pass over the codebase, keeping the substance and dropping the noise: the "⚠️ اضافه شده" prefixes dated comments against a moment no current reader shares; "فاز ۲ / بخش ۴" referenced a plan that no longer exists and only repeated the app name on the next line; "فاز اول - ۴ از ۱۱ اپ" had drifted (there are fourteen). INSTALLED_APPS is now grouped by responsibility. The TEST_LOGIN_PHONES block in settings shrank from 29 lines to 8 now that the same explanation lives in docs/operations.md and .env.example -- a comment beside the code should carry what a code reader needs, not a copy of the runbook. Checked mechanically that no comment references an identifier that no longer exists: zero. The dated measurements in rag/services/ingest.py and generate_questions_from_chunks.py were deliberately left alone -- a measurement carrying the date it was taken is the useful kind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
307 lines
14 KiB
Python
307 lines
14 KiB
Python
from datetime import timedelta
|
||
|
||
from django.db import IntegrityError, transaction
|
||
from django.db.models import Q, Sum
|
||
from django.shortcuts import get_object_or_404
|
||
from django.utils import timezone
|
||
from django.utils.crypto import get_random_string
|
||
from drf_spectacular.utils import extend_schema
|
||
from rest_framework.exceptions import NotFound, PermissionDenied
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from .models import FamilyLink, ParentalSettings, WeeklyReportLog
|
||
from .serializers import (
|
||
FamilyLinkAcceptRequestSerializer,
|
||
FamilyLinkAcceptResponseSerializer,
|
||
FamilyLinkInviteRequestSerializer,
|
||
FamilyLinkInviteResponseSerializer,
|
||
FamilyLinkStatusResponseSerializer,
|
||
FamilySettingsResponseSerializer,
|
||
FamilySettingsUpdateRequestSerializer,
|
||
FamilySettingsUpdateResponseSerializer,
|
||
FamilyWeeklyReportResponseSerializer,
|
||
)
|
||
|
||
|
||
def _week_start(dt):
|
||
"""دوشنبهی هفتهی جاری (ISO weekday) برای dt دادهشده."""
|
||
return (dt - timedelta(days=dt.weekday())).date()
|
||
|
||
|
||
class GuestAwareFamilyThrottle(ScopedRateThrottle):
|
||
"""
|
||
همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) اینجا
|
||
هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده از
|
||
request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان (تا سقفِ
|
||
guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope
|
||
'family_invite_accept' بگیره و عملاً محدودیتِ ۱۰/ساعتِ brute-force
|
||
رویِ inviteCode ۶رقمی رو دور بزنه.
|
||
|
||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||
generation/authorization/notify اکسپت دستنخوردهست.
|
||
"""
|
||
|
||
def get_cache_key(self, request, view):
|
||
user = request.user
|
||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||
ident = f'guest-ip:{self.get_ident(request)}'
|
||
elif user and user.is_authenticated:
|
||
ident = user.pk
|
||
else:
|
||
ident = self.get_ident(request)
|
||
|
||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||
|
||
|
||
class FamilyLinkInviteView(APIView):
|
||
"""POST /api/family/link/invite — دعوت فرزند توسط والد (تولید کد اتصال)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
# بدونِ throttle، یک کاربر میتونست با فرستادنِ پیدرپیِ شمارههای مختلف
|
||
# تعدادِ زیادی FamilyLink بسازه. GuestAwareFamilyThrottle برای مهمانها
|
||
# کلید رو بر اساسِ IP میسازه؛ scopeِ جدا از family_invite_accept چون
|
||
# تولید و مصرفِ کدِ دعوت باید مستقل قابلتنظیم باشن.
|
||
throttle_scope = 'family_invite_create'
|
||
throttle_classes = [GuestAwareFamilyThrottle]
|
||
|
||
@extend_schema(
|
||
request=FamilyLinkInviteRequestSerializer,
|
||
responses=FamilyLinkInviteResponseSerializer,
|
||
tags=['Family (family)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = FamilyLinkInviteRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
|
||
# دعوت pending موجود برای همین شماره، بهجای رکورد تکراری، برگردانده میشود.
|
||
link = FamilyLink.objects.filter(
|
||
parent_user_id=request.user.id, child_phone=v['childPhone'], status='pending'
|
||
).first()
|
||
|
||
if not link:
|
||
# اگر برخورد روی invite_code باشد (نه دعوت همین parent/child)، کد جدید
|
||
# تولید و دوباره تلاش میشود؛ در غیر این صورت رکورد pending موجود برگردانده میشود.
|
||
for attempt in range(5):
|
||
code = get_random_string(6, allowed_chars='0123456789')
|
||
try:
|
||
with transaction.atomic():
|
||
link = FamilyLink.objects.create(
|
||
parent_user_id=request.user.id,
|
||
child_phone=v['childPhone'],
|
||
invite_code=code,
|
||
)
|
||
break
|
||
except IntegrityError:
|
||
link = FamilyLink.objects.filter(
|
||
parent_user_id=request.user.id, child_phone=v['childPhone'], status='pending'
|
||
).first()
|
||
if link is not None:
|
||
break
|
||
# برخورد روی invite_code بوده؛ کد جدید ساخته و دوباره تلاش میشود.
|
||
continue
|
||
else:
|
||
raise IntegrityError(
|
||
'Could not generate a unique family invite code after several attempts.'
|
||
)
|
||
|
||
data = {'familyLinkId': str(link.pk), 'inviteCode': link.invite_code, 'status': link.status}
|
||
return Response(FamilyLinkInviteResponseSerializer(data).data)
|
||
|
||
|
||
class FamilyLinkAcceptView(APIView):
|
||
"""POST /api/family/link/accept — تأیید دعوت توسط فرزند"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
# inviteCode یک رازِ عددیِ ۶رقمیه؛ بدونِ throttle یه کاربر میتونست فضای
|
||
# ۶رقمی رو brute-force کنه. throttle در APIView.initial() قبل از
|
||
# post() اجرا میشه، پس حدسهای بلاکشده اصلاً به select_for_update()
|
||
# نمیرسن.
|
||
throttle_scope = 'family_invite_accept'
|
||
throttle_classes = [GuestAwareFamilyThrottle]
|
||
|
||
@extend_schema(
|
||
request=FamilyLinkAcceptRequestSerializer,
|
||
responses=FamilyLinkAcceptResponseSerializer,
|
||
tags=['Family (family)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = FamilyLinkAcceptRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
|
||
# ردیف FamilyLink زیر قفل خوانده میشود تا دو accept همزمان روی همان
|
||
# invite_code هر دو کامل نشوند و دو بار notify() ارسال نشود.
|
||
with transaction.atomic():
|
||
link = get_object_or_404(
|
||
FamilyLink.objects.select_for_update(),
|
||
invite_code=v['inviteCode'], status='pending',
|
||
)
|
||
|
||
# اگر دعوت برای شمارهی مشخصی صادر شده باشد، فقط همان شماره میتواند accept کند.
|
||
if link.child_phone and link.child_phone != request.user.phone:
|
||
raise PermissionDenied('این دعوت متعلق به شمارهی دیگری است.')
|
||
|
||
# اتمیک: تغییر وضعیت لینک + ساخت Notification یک واحد است.
|
||
link.child_user_id = request.user.id
|
||
link.status = 'accepted'
|
||
link.accepted_at = timezone.now()
|
||
link.save(update_fields=['child_user_id', 'status', 'accepted_at'])
|
||
|
||
# رویداد FamilyLinkAccepted — طبق سند معماری، Notifications فقط
|
||
# event-listenerه؛ دقیقاً همون الگوی notify() که در
|
||
# support/admin.py هنگام پاسخ تیکت صدا زده میشه.
|
||
from notifications.services import notify
|
||
|
||
notify(
|
||
user_id=link.parent_user_id,
|
||
type='family_link_accepted',
|
||
title='فرزند شما دعوت را پذیرفت',
|
||
related_id=link.id,
|
||
)
|
||
|
||
data = {'familyLinkId': str(link.pk), 'status': link.status, 'acceptedAt': link.accepted_at}
|
||
return Response(FamilyLinkAcceptResponseSerializer(data).data)
|
||
|
||
|
||
class FamilyLinkStatusView(APIView):
|
||
"""GET /api/family/link — وضعیت اتصال والد-فرزند (برای هر دو طرف)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=FamilyLinkStatusResponseSerializer, tags=['Family (family)'])
|
||
def get(self, request):
|
||
link = FamilyLink.objects.filter(
|
||
Q(parent_user_id=request.user.id) | Q(child_user_id=request.user.id)
|
||
).first()
|
||
if not link:
|
||
raise NotFound('اتصال خانوادگی پیدا نشد.')
|
||
|
||
child_name = ''
|
||
if link.child_user_id:
|
||
from accounts.models import User as AccountUser
|
||
|
||
child = AccountUser.objects.filter(id=link.child_user_id).first()
|
||
child_name = child.name if child else ''
|
||
|
||
data = {'familyLinkId': str(link.pk), 'status': link.status, 'childName': child_name}
|
||
return Response(FamilyLinkStatusResponseSerializer(data).data)
|
||
|
||
|
||
class FamilySettingsView(APIView):
|
||
"""GET/PATCH /api/family/settings — تنظیمات کنترل والدین"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def _get_settings(self, request):
|
||
link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted')
|
||
settings_obj, _ = ParentalSettings.objects.get_or_create(family_link=link)
|
||
return settings_obj
|
||
|
||
@extend_schema(responses=FamilySettingsResponseSerializer, tags=['Family (family)'])
|
||
def get(self, request):
|
||
settings_obj = self._get_settings(request)
|
||
data = {
|
||
'dailyLimitMinutes': settings_obj.daily_limit_minutes,
|
||
'quietHoursStart': settings_obj.quiet_hours_start,
|
||
'quietHoursEnd': settings_obj.quiet_hours_end,
|
||
}
|
||
return Response(FamilySettingsResponseSerializer(data).data)
|
||
|
||
@extend_schema(
|
||
request=FamilySettingsUpdateRequestSerializer,
|
||
responses=FamilySettingsUpdateResponseSerializer,
|
||
tags=['Family (family)'],
|
||
)
|
||
def patch(self, request):
|
||
settings_obj = self._get_settings(request)
|
||
serializer = FamilySettingsUpdateRequestSerializer(data=request.data, partial=True)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
|
||
update_fields = []
|
||
if 'dailyLimitMinutes' in v:
|
||
settings_obj.daily_limit_minutes = v['dailyLimitMinutes']
|
||
update_fields.append('daily_limit_minutes')
|
||
if 'quietHoursStart' in v:
|
||
settings_obj.quiet_hours_start = v['quietHoursStart']
|
||
update_fields.append('quiet_hours_start')
|
||
if 'quietHoursEnd' in v:
|
||
settings_obj.quiet_hours_end = v['quietHoursEnd']
|
||
update_fields.append('quiet_hours_end')
|
||
|
||
if update_fields:
|
||
settings_obj.save(update_fields=update_fields)
|
||
|
||
return Response(FamilySettingsUpdateResponseSerializer({'success': True}).data)
|
||
|
||
|
||
class FamilyWeeklyReportView(APIView):
|
||
"""
|
||
GET /api/family/weekly-report — آخرین گزارش هفتگی
|
||
|
||
طبق سند معماری، این گزارش خودش داده تکراری نگه نمیداره و از API
|
||
اپهای Progress و Curriculum خونده میشه؛ چون این پروژه یک مونولیت
|
||
جنگوییه، همون «خوندن» با import مستقیم مدلهای آن دو اپ پیادهسازی
|
||
شده — دقیقاً همون الگویی که progress/views.py (LeaderboardView) برای
|
||
خوندن نام کاربر از accounts.User استفاده میکنه.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=FamilyWeeklyReportResponseSerializer, tags=['Family (family)'])
|
||
def get(self, request):
|
||
link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted')
|
||
|
||
week_start = _week_start(timezone.now())
|
||
with transaction.atomic():
|
||
log, created = WeeklyReportLog.objects.get_or_create(
|
||
family_link=link, week_start=week_start, defaults={'sent_at': timezone.now()}
|
||
)
|
||
|
||
if created:
|
||
from notifications.services import notify
|
||
|
||
notify(
|
||
user_id=link.parent_user_id,
|
||
type='weekly_report_ready',
|
||
title='گزارش هفتگی فرزند شما آماده است',
|
||
related_id=log.id,
|
||
)
|
||
|
||
from content.models import UserLessonCompletion
|
||
from progress.models import StreakRecord
|
||
|
||
completions = UserLessonCompletion.objects.filter(
|
||
user_id=link.child_user_id, created_at__date__gte=week_start
|
||
)
|
||
xp_earned = completions.aggregate(total=Sum('xp_earned'))['total'] or 0
|
||
lessons_completed = completions.count()
|
||
|
||
streak = StreakRecord.objects.filter(user_id=link.child_user_id).first()
|
||
current_streak = streak.current_streak if streak else 0
|
||
|
||
if not log.opened_at:
|
||
log.opened_at = timezone.now()
|
||
log.save(update_fields=['opened_at'])
|
||
|
||
data = {
|
||
'weekStart': week_start,
|
||
'xpEarned': xp_earned,
|
||
'lessonsCompleted': lessons_completed,
|
||
'currentStreak': current_streak,
|
||
'openedAt': log.opened_at,
|
||
}
|
||
return Response(FamilyWeeklyReportResponseSerializer(data).data)
|