Initial commit
This commit is contained in:
241
family/views.py
Normal file
241
family/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user