Update project

This commit is contained in:
2026-08-19 17:15:55 +03:30
parent 02d40e1238
commit f945f960c9
25 changed files with 1092 additions and 287 deletions

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.17 on 2026-08-19 10:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('family', '0001_initial'),
]
operations = [
migrations.AddConstraint(
model_name='familylink',
constraint=models.UniqueConstraint(condition=models.Q(('status', 'pending')), fields=('parent_user_id', 'child_phone'), name='uniq_pending_family_invite_per_parent_child'),
),
]

View File

@@ -26,6 +26,22 @@ class FamilyLink(models.Model):
class Meta:
ordering = ['-invited_at']
constraints = [
# 🔒 اصلاح race condition: قبلاً فقط با یک چکِ application-level
# (filter().first() قبل از create) جلوی دعوتِ تکراری برای یک
# parent/child_phone گرفته می‌شد که در برابر دو درخواستِ هم‌زمان
# کافی نبود (هر دو می‌تونستن ببینن که pending وجود نداره و هر دو
# create کنن). این UniqueConstraint سطح DB تضمین می‌کنه که برای
# هر (parent_user_id, child_phone) فقط یک دعوتِ pending هم‌زمان
# می‌تونه وجود داشته باشه؛ FamilyLinkInviteView حالا
# IntegrityError ناشی از این قید رو می‌گیره و رکورد pending
# موجود رو برمی‌گردونه (idempotent).
models.UniqueConstraint(
fields=['parent_user_id', 'child_phone'],
condition=models.Q(status='pending'),
name='uniq_pending_family_invite_per_parent_child',
),
]
def __str__(self):
return f"famlink-{self.pk}-parent{self.parent_user_id}-child{self.child_user_id or '?'}"

View File

@@ -1,5 +1,6 @@
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
@@ -51,15 +52,40 @@ class FamilyLinkInviteView(APIView):
).first()
if not link:
code = get_random_string(6, allowed_chars='0123456789')
while FamilyLink.objects.filter(invite_code=code).exists():
# ⚛️🔒 اصلاح race condition: قبلاً یک IntegrityError فقط برای
# برخورد روی uniq_pending_family_invite_per_parent_child مدیریت
# می‌شد؛ اگه برخورد واقعاً روی invite_code بود (دو کاربر مختلف،
# کد ۶رقمی تصادفی یکسان، هم‌زمان — همون چیزی که چکِ
# pre-check بالا هم به‌خاطر TOCTOU نمی‌تونه صد-در-صد جلوش رو
# بگیره)، link پیدا نمی‌شد و exception دوباره raise می‌شد → ۵۰۰.
# حالا در یک retry loop، اگه برخورد مربوط به دعوتِ همین
# parent/child نبود (یعنی احتمالاً روی invite_code بوده)، یک کد
# جدید تولید و دوباره تلاش می‌کنیم؛ اگه بود، همون رکورد
# pending رو idempotent برمی‌گردونیم — دقیقاً همون الگویی که
# create_order در payment/services.py برای race روی
# idempotency_key استفاده می‌کنه.
for attempt in range(5):
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,
)
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)
@@ -80,32 +106,46 @@ class FamilyLinkAcceptView(APIView):
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
link = get_object_or_404(FamilyLink, invite_code=v['inviteCode'], status='pending')
# ⚛️🔒 race condition: قبلاً get_object_or_404 بدون قفل انجام می‌شد —
# دو درخواستِ همزمان برای accept کردنِ همون invite code می‌تونستن هر
# دو link.status='pending' رو ببینن، هر دو accept رو کامل کنن، و
# دوبار notify() برای والد بفرستن. حالا خودِ ردیفِ FamilyLink داخل
# transaction.atomic قفل می‌شه (select_for_update)، پس درخواستِ دومِ
# همزمان تا commit شدنِ اولی معطل می‌مونه و بعدش status رو
# 'accepted' می‌بینه (نه 'pending')، پس دیگه match نمی‌کنه.
with transaction.atomic():
link = get_object_or_404(
FamilyLink.objects.select_for_update(),
invite_code=v['inviteCode'], status='pending',
)
# ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode
# می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای
# شماره‌ی او نبود (مثلاً کد رو حدس بزنه یا از جای دیگه‌ای ببینه).
# حالا اگه دعوت برای یک شماره‌ی مشخص صادر شده باشه، فقط کاربری که
# شماره‌اش با child_phone یکی باشه می‌تونه اون رو accept کنه.
if link.child_phone and link.child_phone != request.user.phone:
raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.')
# ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ 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'])
# ⚛️ atomic: تغییر وضعیت لینک + ساختِ Notification یک واحدند —
# وگرنه ممکنه اتصال به‌عنوان accepted ثبت بشه ولی هیچ اعلانی برای
# والد ساخته نشه.
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
# رویداد 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,
)
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)
@@ -201,20 +241,21 @@ class FamilyWeeklyReportView(APIView):
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,
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