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)
|