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.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 FamilyLinkInviteView(APIView): """POST /api/family/link/invite — دعوت فرزند توسط والد (تولید کد اتصال)""" permission_classes = [IsAuthenticated] @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] @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)