Initial commit
This commit is contained in:
0
family/__init__.py
Normal file
0
family/__init__.py
Normal file
33
family/admin.py
Normal file
33
family/admin.py
Normal 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
6
family/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FamilyConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'family'
|
||||
55
family/migrations/0001_initial.py
Normal file
55
family/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
family/migrations/__init__.py
Normal file
0
family/migrations/__init__.py
Normal file
55
family/models.py
Normal file
55
family/models.py
Normal 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
51
family/serializers.py
Normal 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
3
family/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
17
family/urls.py
Normal file
17
family/urls.py
Normal 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
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