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)