Files
MathApp/family/views.py
2026-08-19 17:15:55 +03:30

283 lines
13 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.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 برای همین شماره از قبل هست، به‌جای رکورد تکراری
# (مثل الگوی idempotency در payment/services.py) همون رو برمی‌گردونیم.
link = FamilyLink.objects.filter(
parent_user_id=request.user.id, child_phone=v['childPhone'], status='pending'
).first()
if not link:
# ⚛️🔒 اصلاح race condition: قبلاً یک IntegrityError فقط برای
# برخورد روی uniq_pending_family_invite_per_parent_child مدیریت
# می‌شد؛ اگه برخورد واقعاً روی invite_code بود (دو کاربر مختلف،
# کد ۶رقمی تصادفی یکسان، هم‌زمان — همون چیزی که چکِ
# pre-check بالا هم به‌خاطر TOCTOU نمی‌تونه صد-در-صد جلوش رو
# بگیره)، link پیدا نمی‌شد و exception دوباره raise می‌شد → ۵۰۰.
# حالا در یک retry loop، اگه برخورد مربوط به دعوتِ همین
# parent/child نبود (یعنی احتمالاً روی invite_code بوده)، یک کد
# جدید تولید و دوباره تلاش می‌کنیم؛ اگه بود، همون رکورد
# pending رو idempotent برمی‌گردونیم — دقیقاً همون الگویی که
# create_order در payment/services.py برای race روی
# idempotency_key استفاده می‌کنه.
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
# ⚛️🔒 race condition: قبلاً get_object_or_404 بدون قفل انجام می‌شد —
# دو درخواستِ همزمان برای accept کردنِ همون invite code می‌تونستن هر
# دو link.status='pending' رو ببینن، هر دو accept رو کامل کنن، و
# دوبار notify() برای والد بفرستن. حالا خودِ ردیفِ FamilyLink داخل
# transaction.atomic قفل می‌شه (select_for_update)، پس درخواستِ دومِ
# همزمان تا commit شدنِ اولی معطل می‌مونه و بعدش status رو
# 'accepted' می‌بینه (نه 'pending')، پس دیگه match نمی‌کنه.
with transaction.atomic():
link = get_object_or_404(
FamilyLink.objects.select_for_update(),
invite_code=v['inviteCode'], status='pending',
)
# ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode
# می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای
# شماره‌ی او نبود (مثلاً کد رو حدس بزنه یا از جای دیگه‌ای ببینه).
# حالا اگه دعوت برای یک شماره‌ی مشخص صادر شده باشه، فقط کاربری که
# شماره‌اش با child_phone یکی باشه می‌تونه اون رو accept کنه.
if link.child_phone and link.child_phone != request.user.phone:
raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.')
# ⚛️ atomic: تغییر وضعیت لینک + ساختِ Notification یک واحدند —
# وگرنه ممکنه اتصال به‌عنوان accepted ثبت بشه ولی هیچ اعلانی برای
# والد ساخته نشه.
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)