Initial commit

This commit is contained in:
2026-08-11 16:43:12 +03:30
commit e1d830f6a9
127 changed files with 4454 additions and 0 deletions

241
family/views.py Normal file
View File

@@ -0,0 +1,241 @@
from datetime import timedelta
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:
code = get_random_string(6, allowed_chars='0123456789')
while FamilyLink.objects.filter(invite_code=code).exists():
code = get_random_string(6, allowed_chars='0123456789')
link = FamilyLink.objects.create(
parent_user_id=request.user.id,
child_phone=v['childPhone'],
invite_code=code,
)
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
link = get_object_or_404(FamilyLink, invite_code=v['inviteCode'], status='pending')
# ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode
# می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای
# شماره‌ی او نبود (مثلاً کد رو حدس بزنه یا از جای دیگه‌ای ببینه).
# حالا اگه دعوت برای یک شماره‌ی مشخص صادر شده باشه، فقط کاربری که
# شماره‌اش با child_phone یکی باشه می‌تونه اون رو accept کنه.
if link.child_phone and link.child_phone != request.user.phone:
raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.')
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())
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)