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

0
family/__init__.py Normal file
View File

33
family/admin.py Normal file
View File

@@ -0,0 +1,33 @@
from django.contrib import admin
from .models import FamilyLink, ParentalSettings, WeeklyReportLog
class ParentalSettingsInline(admin.StackedInline):
model = ParentalSettings
extra = 0
class WeeklyReportLogInline(admin.TabularInline):
model = WeeklyReportLog
extra = 0
readonly_fields = ('sent_at',)
@admin.register(FamilyLink)
class FamilyLinkAdmin(admin.ModelAdmin):
list_display = ('id', 'parent_user_id', 'child_user_id', 'status', 'invite_code', 'invited_at', 'accepted_at')
list_filter = ('status',)
search_fields = ('invite_code', 'child_phone')
inlines = [ParentalSettingsInline, WeeklyReportLogInline]
@admin.register(ParentalSettings)
class ParentalSettingsAdmin(admin.ModelAdmin):
list_display = ('id', 'family_link', 'daily_limit_minutes', 'quiet_hours_start', 'quiet_hours_end')
@admin.register(WeeklyReportLog)
class WeeklyReportLogAdmin(admin.ModelAdmin):
list_display = ('id', 'family_link', 'week_start', 'sent_at', 'opened_at')
list_filter = ('week_start',)

6
family/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FamilyConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'family'

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.2.17 on 2026-08-10 12:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='FamilyLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('parent_user_id', models.IntegerField(db_index=True)),
('child_user_id', models.IntegerField(blank=True, db_index=True, null=True)),
('child_phone', models.CharField(blank=True, max_length=15)),
('invite_code', models.CharField(max_length=10, unique=True)),
('status', models.CharField(choices=[('pending', 'در انتظار'), ('accepted', 'پذیرفته‌شده')], default='pending', max_length=10)),
('invited_at', models.DateTimeField(auto_now_add=True)),
('accepted_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['-invited_at'],
},
),
migrations.CreateModel(
name='ParentalSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('daily_limit_minutes', models.PositiveIntegerField(default=60)),
('quiet_hours_start', models.TimeField(blank=True, null=True)),
('quiet_hours_end', models.TimeField(blank=True, null=True)),
('family_link', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='family.familylink')),
],
),
migrations.CreateModel(
name='WeeklyReportLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('week_start', models.DateField()),
('sent_at', models.DateTimeField(blank=True, null=True)),
('opened_at', models.DateTimeField(blank=True, null=True)),
('family_link', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='weekly_reports', to='family.familylink')),
],
options={
'ordering': ['-week_start'],
'unique_together': {('family_link', 'week_start')},
},
),
]

View File

55
family/models.py Normal file
View File

@@ -0,0 +1,55 @@
from django.db import models
class FamilyLink(models.Model):
"""
اتصال حساب والد به یک فرزند. طبق سند معماری، این اپ فقط به accounts.User
ارجاع سبک (parent_user_id / child_user_id) می‌ده، نه FK واقعی.
⚠️ افزوده نسبت به سند اولیه: سند models فقط parent_user_id/child_user_id
را مشخص کرده بود، ولی برای پیاده‌سازی واقعی جریان «دعوت با کد» (طبق سند
Endpointها: POST /api/family/link/invite و /api/family/link/accept) باید
خود کد دعوت و شماره‌ی فرزندی که هنوز ثبت‌نام نکرده جایی نگه داشته بشه؛
دقیقاً همون الگویی که accounts.OTPCode برای نگه‌داشتن کد یک‌بارمصرف
استفاده می‌کنه. اگه این دو فیلد قرار بود جای دیگه‌ای (مثلاً یک مدل
Invite جدا) نگه داشته بشن، می‌شه بعداً جابه‌جا کرد.
"""
STATUS_CHOICES = [('pending', 'در انتظار'), ('accepted', 'پذیرفته‌شده')]
parent_user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
child_user_id = models.IntegerField(null=True, blank=True, db_index=True) # ارجاع سبک به accounts.User
child_phone = models.CharField(max_length=15, blank=True) # <-- افزوده شده
invite_code = models.CharField(max_length=10, unique=True) # <-- افزوده شده
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
invited_at = models.DateTimeField(auto_now_add=True)
accepted_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-invited_at']
def __str__(self):
return f"famlink-{self.pk}-parent{self.parent_user_id}-child{self.child_user_id or '?'}"
class ParentalSettings(models.Model):
family_link = models.OneToOneField(FamilyLink, on_delete=models.CASCADE, related_name='settings')
daily_limit_minutes = models.PositiveIntegerField(default=60)
quiet_hours_start = models.TimeField(null=True, blank=True)
quiet_hours_end = models.TimeField(null=True, blank=True)
def __str__(self):
return f"settings-famlink{self.family_link_id}"
class WeeklyReportLog(models.Model):
family_link = models.ForeignKey(FamilyLink, on_delete=models.CASCADE, related_name='weekly_reports')
week_start = models.DateField()
sent_at = models.DateTimeField(null=True, blank=True)
opened_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-week_start']
unique_together = [('family_link', 'week_start')]
def __str__(self):
return f"weekly-famlink{self.family_link_id}-{self.week_start}"

51
family/serializers.py Normal file
View File

@@ -0,0 +1,51 @@
from rest_framework import serializers
class FamilyLinkInviteRequestSerializer(serializers.Serializer):
childPhone = serializers.CharField(max_length=15)
class FamilyLinkInviteResponseSerializer(serializers.Serializer):
familyLinkId = serializers.CharField()
inviteCode = serializers.CharField()
status = serializers.CharField()
class FamilyLinkAcceptRequestSerializer(serializers.Serializer):
inviteCode = serializers.CharField(max_length=10)
class FamilyLinkAcceptResponseSerializer(serializers.Serializer):
familyLinkId = serializers.CharField()
status = serializers.CharField()
acceptedAt = serializers.DateTimeField(allow_null=True)
class FamilyLinkStatusResponseSerializer(serializers.Serializer):
familyLinkId = serializers.CharField()
status = serializers.CharField()
childName = serializers.CharField(allow_blank=True)
class FamilySettingsResponseSerializer(serializers.Serializer):
dailyLimitMinutes = serializers.IntegerField()
quietHoursStart = serializers.TimeField(allow_null=True)
quietHoursEnd = serializers.TimeField(allow_null=True)
class FamilySettingsUpdateRequestSerializer(serializers.Serializer):
dailyLimitMinutes = serializers.IntegerField(required=False, min_value=0)
quietHoursStart = serializers.TimeField(required=False, allow_null=True)
quietHoursEnd = serializers.TimeField(required=False, allow_null=True)
class FamilySettingsUpdateResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class FamilyWeeklyReportResponseSerializer(serializers.Serializer):
weekStart = serializers.DateField()
xpEarned = serializers.IntegerField()
lessonsCompleted = serializers.IntegerField()
currentStreak = serializers.IntegerField()
openedAt = serializers.DateTimeField(allow_null=True)

3
family/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

17
family/urls.py Normal file
View File

@@ -0,0 +1,17 @@
from django.urls import path
from .views import (
FamilyLinkAcceptView,
FamilyLinkInviteView,
FamilyLinkStatusView,
FamilySettingsView,
FamilyWeeklyReportView,
)
urlpatterns = [
path('family/link/invite', FamilyLinkInviteView.as_view(), name='family-link-invite'),
path('family/link/accept', FamilyLinkAcceptView.as_view(), name='family-link-accept'),
path('family/link', FamilyLinkStatusView.as_view(), name='family-link-status'),
path('family/settings', FamilySettingsView.as_view(), name='family-settings'),
path('family/weekly-report', FamilyWeeklyReportView.as_view(), name='family-weekly-report'),
]

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)