Files
MathApp/family/views.py

307 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
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)