Update project
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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 '?'}"
|
||||
|
||||
125
family/views.py
125
family/views.py
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user