senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -146,7 +146,7 @@ class UserAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Profile)
|
||||
class ProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user', 'birth_year', 'parent_phone')
|
||||
list_display = ('id', 'user', 'birth_date', 'birth_year')
|
||||
|
||||
|
||||
@admin.register(Session)
|
||||
|
||||
94
accounts/jalali.py
Normal file
94
accounts/jalali.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
تبدیلِ تاریخِ شمسی ↔ میلادی برای تاریخِ تولد.
|
||||
|
||||
الگوریتمِ jalaali (همان jalaali-js، بورکوفسکی): کبیسهها از جدولِ نقطههای شکستِ
|
||||
چرخه حساب میشوند، نه از قاعدهی سیوسهسالهی ساده، و برای سالهای ۱۱۷۸ تا ۱۶۳۳
|
||||
با تقویمِ رسمی یکی است. تستها (`accounts/tests_birth_date.py`) چند تاریخِ
|
||||
شناختهشدهی دو طرفِ اسفندِ کبیسه را میسنجند.
|
||||
|
||||
چرا در بکاند: فرانت فقط روز/ماه/سالِ شمسی میفرستد. اگر هر دو طرف تبدیل
|
||||
میکردند، دو پیادهسازی میتوانست یک روز با هم اختلاف داشته باشد.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
_BREAKS = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210,
|
||||
1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178]
|
||||
|
||||
|
||||
def _cal(jy):
|
||||
"""(leap, gy, march) — leap: سالِ مانده در چرخه؛ march: روزِ مارسِ نوروز."""
|
||||
if jy < _BREAKS[0] or jy >= _BREAKS[-1]:
|
||||
raise ValueError(f'سالِ شمسیِ خارج از بازه: {jy}')
|
||||
gy = jy + 621
|
||||
leap_j = -14
|
||||
jp = _BREAKS[0]
|
||||
jump = 0
|
||||
for jm in _BREAKS[1:]:
|
||||
jump = jm - jp
|
||||
if jy < jm:
|
||||
break
|
||||
leap_j += (jump // 33) * 8 + (jump % 33) // 4
|
||||
jp = jm
|
||||
n = jy - jp
|
||||
leap_j += (n // 33) * 8 + ((n % 33) + 3) // 4
|
||||
if jump % 33 == 4 and jump - n == 4:
|
||||
leap_j += 1
|
||||
leap_g = gy // 4 - ((gy // 100 + 1) * 3) // 4 - 150
|
||||
march = 20 + leap_j - leap_g
|
||||
if jump - n < 6:
|
||||
n = n - jump + ((jump + 4) // 33) * 33
|
||||
leap = (((n + 1) % 33) - 1) % 4
|
||||
if leap == -1:
|
||||
leap = 4
|
||||
return leap, gy, march
|
||||
|
||||
|
||||
def is_leap(jy: int) -> bool:
|
||||
return _cal(jy)[0] == 0
|
||||
|
||||
|
||||
def month_length(jy: int, jm: int) -> int:
|
||||
if jm <= 6:
|
||||
return 31
|
||||
if jm <= 11:
|
||||
return 30
|
||||
return 30 if is_leap(jy) else 29
|
||||
|
||||
|
||||
def to_gregorian(jy: int, jm: int, jd: int) -> date:
|
||||
"""ValueError برای تاریخِ ناموجود (مثلاً ۳۰ اسفندِ سالِ غیرکبیسه)."""
|
||||
if not 1 <= jm <= 12 or not 1 <= jd <= month_length(jy, jm):
|
||||
raise ValueError('چنین روزی در تقویمِ شمسی وجود ندارد.')
|
||||
_, gy, march = _cal(jy)
|
||||
nowruz = date(gy, 3, march)
|
||||
day_of_year = (jm - 1) * 31 - (jm - 7) * (jm >= 7) + jd - 1
|
||||
return date.fromordinal(nowruz.toordinal() + day_of_year)
|
||||
|
||||
|
||||
def from_gregorian(d: date) -> tuple[int, int, int]:
|
||||
jy = d.year - 621
|
||||
_, gy, march = _cal(jy)
|
||||
k = d.toordinal() - date(gy, 3, march).toordinal()
|
||||
if k < 0:
|
||||
jy -= 1
|
||||
_, gy, march = _cal(jy)
|
||||
k = d.toordinal() - date(gy, 3, march).toordinal()
|
||||
if k < 186:
|
||||
return jy, k // 31 + 1, k % 31 + 1
|
||||
k -= 186
|
||||
return jy, k // 30 + 7, k % 30 + 1
|
||||
|
||||
|
||||
def parse(value: str) -> date:
|
||||
"""«1385-04-12» (ارقامِ فارسی هم) → تاریخِ میلادی."""
|
||||
from accounts.phone import _DIGITS
|
||||
|
||||
parts = str(value).translate(_DIGITS).strip().replace('/', '-').split('-')
|
||||
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
||||
raise ValueError('تاریخ باید به شکلِ سال-ماه-روز باشد، مثلاً 1385-04-12.')
|
||||
return to_gregorian(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
|
||||
|
||||
def to_string(d: date) -> str:
|
||||
jy, jm, jd = from_gregorian(d)
|
||||
return f'{jy:04d}-{jm:02d}-{jd:02d}'
|
||||
98
accounts/management/commands/delete_users.py
Normal file
98
accounts/management/commands/delete_users.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
پاک کردنِ کاملِ چند کاربر و همهی دادهشان در همهی اپها.
|
||||
|
||||
چرا لازم شد (۲۰۲۶-۱۰-۰۲): پیش از رسیدنِ کاربرِ واقعی، حسابهایی که تیم با شمارهی واقعیِ خودش
|
||||
ساخته بود باید پاک شوند تا همان شمارهها دوباره مسیرِ «کاربرِ تازه» را ببینند. پاک کردنِ ردیفِ
|
||||
`accounts.User` کافی نیست: اپها به کاربر فقط ارجاعِ سبک (`user_id`) دارند، نه FK، پس امتیاز،
|
||||
گفتگو، سفارش و اعلانِ او یتیم میماند و کاربرِ تازهای که همان شناسه را بگیرد آنها را میدید
|
||||
(روی SQLite شناسه دوباره داده نمیشود، ولی روی Postgres پس از بازنشانیِ sequence ممکن است).
|
||||
|
||||
مدلها خودکار پیدا میشوند: هر مدلی که ستونِ `user_id` یا `referred_user_id` دارد. پس اپِ تازه
|
||||
بیویرایشِ این فایل پوشش داده میشود. کدهای OTPِ همان شمارهها هم پاک میشوند تا سقفِ روزانهی
|
||||
پیامکِ آن شماره از صفر شروع شود.
|
||||
|
||||
پیشفرض فقط گزارش است و چیزی پاک نمیکند:
|
||||
|
||||
python manage.py delete_users --phones 09121234567 09351234567
|
||||
python manage.py delete_users --ids 17 22 --yes
|
||||
|
||||
سفارشِ پرداختشده سندِ مالی است؛ کاربری که سفارشِ «paid» دارد بدونِ --include-paid پاک نمیشود.
|
||||
"""
|
||||
from django.apps import apps
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from accounts.models import OTPCode, User
|
||||
from accounts.phone import normalize_phone
|
||||
|
||||
USER_COLUMNS = ('user_id', 'referred_user_id')
|
||||
|
||||
|
||||
def user_scoped_models():
|
||||
"""[(model, column)] برای هر مدلی که به کاربر ارجاع میدهد، جز خودِ User."""
|
||||
found = []
|
||||
for model in apps.get_models():
|
||||
if model is User:
|
||||
continue
|
||||
names = {f.attname for f in model._meta.concrete_fields}
|
||||
found += [(model, column) for column in USER_COLUMNS if column in names]
|
||||
return found
|
||||
|
||||
|
||||
def plan(user_ids):
|
||||
"""{'app.Model.column': count} — آنچه پاک میشود، پیش از پاک شدن."""
|
||||
counts = {}
|
||||
for model, column in user_scoped_models():
|
||||
n = model.objects.filter(**{f'{column}__in': user_ids}).count()
|
||||
if n:
|
||||
counts[f'{model._meta.label}.{column}'] = n
|
||||
return counts
|
||||
|
||||
|
||||
def delete_users(user_ids):
|
||||
phones = list(User.objects.filter(pk__in=user_ids).exclude(phone=None).values_list('phone', flat=True))
|
||||
with transaction.atomic():
|
||||
for model, column in user_scoped_models():
|
||||
model.objects.filter(**{f'{column}__in': user_ids}).delete()
|
||||
OTPCode.objects.filter(phone__in=phones).delete()
|
||||
User.objects.filter(pk__in=user_ids).delete()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'کاربر و همهی دادهاش را در همهی اپها پاک میکند (پیشفرض: فقط گزارش).'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--phones', nargs='*', default=[])
|
||||
parser.add_argument('--ids', nargs='*', type=int, default=[])
|
||||
parser.add_argument('--yes', action='store_true', help='واقعاً پاک کن.')
|
||||
parser.add_argument('--include-paid', action='store_true',
|
||||
help='کاربرِ دارای سفارشِ پرداختشده را هم پاک کن.')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
phones = [normalize_phone(p) or p for p in options['phones']]
|
||||
users = list(User.objects.filter(phone__in=phones)) + list(User.objects.filter(pk__in=options['ids']))
|
||||
users = list({u.pk: u for u in users}.values())
|
||||
missing = set(phones) - {u.phone for u in users}
|
||||
if missing:
|
||||
self.stdout.write(f'این شمارهها کاربری ندارند: {len(missing)}')
|
||||
if not users:
|
||||
raise CommandError('کاربری برای پاک کردن پیدا نشد.')
|
||||
|
||||
from payment.models import Order
|
||||
paid = set(Order.objects.filter(user_id__in=[u.pk for u in users], status='paid')
|
||||
.values_list('user_id', flat=True))
|
||||
if paid and not options['include_paid']:
|
||||
raise CommandError(f'کاربرهای {sorted(paid)} سفارشِ پرداختشده دارند؛ با --include-paid پاک میشوند.')
|
||||
|
||||
ids = [u.pk for u in users]
|
||||
for u in users:
|
||||
phone = f'{u.phone[:4]}***{u.phone[-3:]}' if u.phone else 'مهمان'
|
||||
self.stdout.write(f' کاربرِ {u.pk} {phone} ساختهشده {u.created_at:%Y-%m-%d}')
|
||||
for label, n in sorted(plan(ids).items()):
|
||||
self.stdout.write(f' {label}: {n}')
|
||||
|
||||
if not options['yes']:
|
||||
self.stdout.write(self.style.WARNING('فقط گزارش بود؛ برای پاک کردن --yes بدهید.'))
|
||||
return
|
||||
delete_users(ids)
|
||||
self.stdout.write(self.style.SUCCESS(f'{len(ids)} کاربر و همهی دادهشان پاک شدند.'))
|
||||
@@ -4,9 +4,9 @@
|
||||
یک کلید طبیعی/یکتا (phone، device_id، code، idempotency_key و ...) انجام
|
||||
میشه، نه با create ساده.
|
||||
|
||||
هدف: پوشش کمینهی جریانهای اصلی API در هر ۱۱ اپ (accounts، content،
|
||||
هدف: پوشش کمینهی جریانهای اصلی API در هر ۱۰ اپ (accounts، content،
|
||||
progress، ai_tutor، subscription، payment، discount، notifications،
|
||||
affiliate، support، family) با کمترین حجم دادهی لازم برای اینکه هر
|
||||
affiliate، support) با کمترین حجم دادهی لازم برای اینکه هر
|
||||
endpoint اصلی بدون نیاز به ساختن دستی پیشنیازهاش قابل تست باشه.
|
||||
|
||||
⚠️ این دستور فقط برای محیط لوکال/staging است، نه پروداکشن:
|
||||
@@ -44,8 +44,6 @@ from accounts.models import OTPCode, Profile, Session, User
|
||||
# چون این فقط دیتای لوکال/staging است نه پروداکشن، همین کافیه. با پیشوند
|
||||
# یکسان و بازهی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده میمونه.
|
||||
MAIN_PHONE = '09120000001'
|
||||
PARENT_PHONE = '09120000002'
|
||||
CHILD_PHONE = '09120000003'
|
||||
REFERRED_PHONE = '09120000004'
|
||||
GUEST_DEVICE_ID = 'seed-guest-device-0001'
|
||||
|
||||
@@ -101,14 +99,13 @@ class Command(BaseCommand):
|
||||
from affiliate.models import AffiliateAccount, Conversion
|
||||
from content.models import Grade, NodeProgress, UserAnswer, UserLessonCompletion
|
||||
from discount.models import DiscountCode, Offer, Redemption, UserOffer
|
||||
from family.models import FamilyLink
|
||||
from notifications.models import Notification
|
||||
from payment.models import Order
|
||||
from progress.models import LeaderboardEntry, StreakRecord, UserHearts, UserStats
|
||||
from subscription.models import Plan, Subscription
|
||||
from support.models import BugReport, Ticket
|
||||
|
||||
phones = [MAIN_PHONE, PARENT_PHONE, CHILD_PHONE, REFERRED_PHONE]
|
||||
phones = [MAIN_PHONE, REFERRED_PHONE]
|
||||
user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True))
|
||||
guest = User.objects.filter(device_id=GUEST_DEVICE_ID, is_guest=True).first()
|
||||
if guest:
|
||||
@@ -132,7 +129,6 @@ class Command(BaseCommand):
|
||||
Conversion.objects.filter(referred_user_id__in=user_ids).delete()
|
||||
Ticket.objects.filter(user_id__in=user_ids).delete()
|
||||
BugReport.objects.filter(user_id__in=user_ids).delete()
|
||||
FamilyLink.objects.filter(parent_user_id__in=user_ids).delete()
|
||||
|
||||
from progress.models import Character
|
||||
Character.objects.filter(slug__startswith='seed-').delete()
|
||||
@@ -154,7 +150,7 @@ class Command(BaseCommand):
|
||||
|
||||
# -- seed -------------------------------------------------------------
|
||||
def _seed(self):
|
||||
main_user, guest_user, parent_user, child_user, referred_user = self._seed_accounts()
|
||||
main_user, guest_user, referred_user = self._seed_accounts()
|
||||
self._seed_content_and_progress(main_user)
|
||||
self._seed_characters()
|
||||
self._seed_ai_tutor(main_user)
|
||||
@@ -163,12 +159,10 @@ class Command(BaseCommand):
|
||||
self._seed_notifications(main_user)
|
||||
self._seed_affiliate(main_user, referred_user)
|
||||
self._seed_support(main_user)
|
||||
self._seed_family(parent_user, child_user)
|
||||
|
||||
tokens = [
|
||||
('کاربر اصلی (ثبتنامشده)', main_user, issue_access_token(main_user.pk)),
|
||||
('کاربر مهمان', guest_user, issue_access_token(guest_user.pk)),
|
||||
('والد', parent_user, issue_access_token(parent_user.pk)),
|
||||
]
|
||||
return {'tokens': tokens}
|
||||
|
||||
@@ -179,7 +173,7 @@ class Command(BaseCommand):
|
||||
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', is_guest=False, grade_id=None),
|
||||
)
|
||||
Profile.objects.get_or_create(
|
||||
user=main_user, defaults=dict(birth_year=2015, parent_phone=PARENT_PHONE, bio=f'{SEED_TAG} پروفایل تستی')
|
||||
user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی')
|
||||
)
|
||||
Session.objects.get_or_create(
|
||||
user=main_user,
|
||||
@@ -206,18 +200,12 @@ class Command(BaseCommand):
|
||||
),
|
||||
)
|
||||
|
||||
parent_user, _ = User.objects.get_or_create(
|
||||
phone=PARENT_PHONE, defaults=dict(name=f'{SEED_TAG} والد', is_guest=False)
|
||||
)
|
||||
child_user, _ = User.objects.get_or_create(
|
||||
phone=CHILD_PHONE, defaults=dict(name=f'{SEED_TAG} فرزند', is_guest=False)
|
||||
)
|
||||
referred_user, _ = User.objects.get_or_create(
|
||||
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفیشده', is_guest=False)
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('accounts: 5 کاربر (اصلی/مهمان/والد/فرزند/معرفیشده) آماده شد.'))
|
||||
return main_user, guest_user, parent_user, child_user, referred_user
|
||||
self.stdout.write(self.style.SUCCESS('accounts: 3 کاربر (اصلی/مهمان/معرفیشده) آماده شد.'))
|
||||
return main_user, guest_user, referred_user
|
||||
|
||||
# -- content + progress --------------------------------------------------
|
||||
def _seed_content_and_progress(self, main_user):
|
||||
@@ -469,28 +457,3 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('support: 1 تیکت با یک پیام، و 1 گزارش باگ آماده شد.'))
|
||||
|
||||
# -- family -----------------------------------------------------------------------
|
||||
def _seed_family(self, parent_user, child_user):
|
||||
from family.models import FamilyLink, ParentalSettings, WeeklyReportLog
|
||||
|
||||
link, _ = FamilyLink.objects.get_or_create(
|
||||
parent_user_id=parent_user.pk,
|
||||
child_phone=CHILD_PHONE,
|
||||
defaults=dict(
|
||||
child_user_id=child_user.pk,
|
||||
invite_code='SEEDFAM01',
|
||||
status='accepted',
|
||||
accepted_at=timezone.now(),
|
||||
),
|
||||
)
|
||||
ParentalSettings.objects.get_or_create(
|
||||
family_link=link, defaults=dict(daily_limit_minutes=60)
|
||||
)
|
||||
WeeklyReportLog.objects.get_or_create(
|
||||
family_link=link,
|
||||
week_start=(timezone.now() - timedelta(days=timezone.now().weekday())).date(),
|
||||
defaults=dict(sent_at=timezone.now()),
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('family: 1 اتصالِ خانواده (پذیرفتهشده) با تنظیمات و گزارشِ هفتگی آماده شد.'))
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-01 21:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0005_normalize_user_phones'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='profile',
|
||||
name='parent_phone',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='birth_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -49,8 +49,11 @@ class User(models.Model):
|
||||
|
||||
class Profile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||
# سالِ تولدِ شمسی. از ۲۰۲۶-۱۰-۰۱ از birth_date پر میشود؛ برای کاربرهای
|
||||
# قدیمی که فقط سال دادهاند تنها منبع است.
|
||||
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
parent_phone = models.CharField(max_length=15, blank=True)
|
||||
# تاریخِ تولدِ کامل (میلادی ذخیره میشود، در API شمسی است؛ accounts/jalali.py).
|
||||
birth_date = models.DateField(null=True, blank=True)
|
||||
bio = models.CharField(max_length=200, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -51,16 +51,10 @@ class SetGradeRequestSerializer(serializers.Serializer):
|
||||
gradeId = serializers.IntegerField()
|
||||
|
||||
|
||||
def _jalali_year_now() -> int:
|
||||
"""
|
||||
سالِ جاریِ شمسی. تبدیلِ سن به سالِ تولد بدونِ این، سالِ میلادی میداد و
|
||||
اعتبارسنجیِ birthYear (بازهی ۱۳۰۰ تا ۱۵۰۰) ردش میکرد.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
today = timezone.localdate()
|
||||
# فروردین ۱ برابرِ ۲۱ مارس است؛ پیش از آن هنوز سالِ شمسیِ قبلی است.
|
||||
return today.year - 622 if (today.month, today.day) >= (3, 21) else today.year - 623
|
||||
# همان بازهای که پیشتر برای «سن» پذیرفته میشد. بزرگسال هم میآید (سینیور،
|
||||
# ۲۰۲۶-۱۰-۰۱): کسی که فقط یک مبحثِ ریاضی را میخواهد یاد بگیرد.
|
||||
MIN_AGE = 5
|
||||
MAX_AGE = 99
|
||||
|
||||
|
||||
class UpdateProfileRequestSerializer(serializers.Serializer):
|
||||
@@ -81,27 +75,36 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
|
||||
نمیدهد تا صفحهی پروفایلِ اپ گیر نکند؛ فقط بقیهی نام ذخیره میشود.
|
||||
"""
|
||||
return clean_display_name(value)
|
||||
# فقط سال (روشِ قدیمِ اپ). birthDate دقیقتر است و ترجیح دارد.
|
||||
birthYear = serializers.IntegerField(required=False, allow_null=True,
|
||||
min_value=1300, max_value=1500)
|
||||
parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True)
|
||||
# تاریخِ تولدِ شمسی «1385-04-12». سرور میلادی ذخیره میکند و birthYear را هم
|
||||
# از آن پر میکند؛ تبدیل فقط در accounts/jalali.py است.
|
||||
birthDate = serializers.CharField(required=False, allow_null=True, max_length=10)
|
||||
bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||
# صفحهی ثبتنامِ فرانت سن میپرسد، نه سالِ تولد. ذخیرهسازی همچنان
|
||||
# birthYear است چون کهنه نمیشود؛ این فقط ورودی را ترجمه میکند تا فرانت
|
||||
# مجبور نباشد خودش تبدیل کند و دو طرف حسابِ متفاوت درنیاورند.
|
||||
age = serializers.IntegerField(required=False, allow_null=True,
|
||||
min_value=5, max_value=99, write_only=True)
|
||||
|
||||
def validate_birthDate(self, value):
|
||||
if value in (None, ''):
|
||||
return None
|
||||
from django.utils import timezone
|
||||
|
||||
from . import jalali
|
||||
|
||||
try:
|
||||
born = jalali.parse(value)
|
||||
except ValueError as exc:
|
||||
raise serializers.ValidationError(str(exc))
|
||||
today = timezone.localdate()
|
||||
age = today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||
if not MIN_AGE <= age <= MAX_AGE:
|
||||
raise serializers.ValidationError(f'سن باید بینِ {MIN_AGE} و {MAX_AGE} سال باشد.')
|
||||
return born
|
||||
|
||||
def validate(self, attrs):
|
||||
if not attrs:
|
||||
raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.')
|
||||
|
||||
age = attrs.pop('age', None)
|
||||
if age is not None:
|
||||
if attrs.get('birthYear') is not None:
|
||||
raise serializers.ValidationError(
|
||||
'یکی از age یا birthYear را بفرستید، نه هر دو را.',
|
||||
)
|
||||
attrs['birthYear'] = _jalali_year_now() - age
|
||||
if 'birthDate' in attrs and 'birthYear' in attrs:
|
||||
raise serializers.ValidationError('یکی از birthDate یا birthYear را بفرستید، نه هر دو را.')
|
||||
return attrs
|
||||
|
||||
|
||||
@@ -156,6 +159,8 @@ class RequestOTPResponseSerializer(serializers.Serializer):
|
||||
|
||||
class ProfileResponseSerializer(serializers.Serializer):
|
||||
"""پاسخ GET /api/user/profile — فقط دادهی لازم برای تب Profile اپ موبایل."""
|
||||
# شناسهی کاربر همان شمارهی ثبتِ اوست (کاربرِ چندم)؛ اپ آن را زیرِ نام نشان میدهد
|
||||
# تا در پشتیبانی بشود حساب را بیشمارهی تلفن پیدا کرد.
|
||||
userId = serializers.CharField()
|
||||
phone = serializers.CharField(allow_null=True)
|
||||
name = serializers.CharField()
|
||||
@@ -163,7 +168,7 @@ class ProfileResponseSerializer(serializers.Serializer):
|
||||
isGuest = serializers.BooleanField()
|
||||
gradeId = serializers.IntegerField(allow_null=True)
|
||||
birthYear = serializers.IntegerField(allow_null=True)
|
||||
parentPhone = serializers.CharField()
|
||||
birthDate = serializers.CharField(allow_null=True, help_text='شمسی، «1385-04-12»؛ null اگر فقط سال داده شده.')
|
||||
bio = serializers.CharField()
|
||||
|
||||
|
||||
|
||||
@@ -893,7 +893,7 @@ class UserProfileViewTests(TestCase):
|
||||
self.assertIsNone(data['phone'])
|
||||
self.assertIsNone(data['gradeId'])
|
||||
self.assertIsNone(data['birthYear'])
|
||||
self.assertEqual(data['parentPhone'], '')
|
||||
self.assertIsNone(data['birthDate'])
|
||||
self.assertEqual(data['bio'], '')
|
||||
# get_or_create باید یک رکورد Profile واقعی ساخته باشه.
|
||||
self.assertTrue(Profile.objects.filter(user_id=user.id).exists())
|
||||
@@ -902,7 +902,7 @@ class UserProfileViewTests(TestCase):
|
||||
user = User.objects.create(
|
||||
phone='09120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
|
||||
)
|
||||
Profile.objects.create(user=user, birth_year=2005, parent_phone='09120000002', bio='سلام')
|
||||
Profile.objects.create(user=user, birth_year=1384, bio='سلام')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
response = self._get_profile(token)
|
||||
@@ -914,8 +914,7 @@ class UserProfileViewTests(TestCase):
|
||||
self.assertEqual(data['avatar'], 'avatar.png')
|
||||
self.assertEqual(data['isGuest'], False)
|
||||
self.assertEqual(data['gradeId'], 2)
|
||||
self.assertEqual(data['birthYear'], 2005)
|
||||
self.assertEqual(data['parentPhone'], '09120000002')
|
||||
self.assertEqual(data['birthYear'], 1384)
|
||||
self.assertEqual(data['bio'], 'سلام')
|
||||
|
||||
def test_does_not_leak_other_users_profile(self):
|
||||
|
||||
@@ -141,23 +141,6 @@ class HeartsTests(TestCase):
|
||||
self.assertEqual(response.json()['code'], 'no_hearts')
|
||||
|
||||
|
||||
class FamilyTwoChildrenTests(TestCase):
|
||||
def test_a_parent_of_two_can_see_each_child(self):
|
||||
parent = User.objects.create(phone='09121234010', is_guest=False)
|
||||
kids = []
|
||||
for phone in ('09121234011', '09121234012'):
|
||||
kid = User.objects.create(phone=phone, is_guest=False)
|
||||
code = _client(parent).post('/api/family/link/invite', {'childPhone': phone},
|
||||
format='json').json()['inviteCode']
|
||||
_client(kid).post('/api/family/link/accept', {'inviteCode': code}, format='json')
|
||||
kids.append(kid)
|
||||
client = _client(parent)
|
||||
self.assertEqual(client.get('/api/family/settings').status_code, 200)
|
||||
self.assertEqual(client.get('/api/family/weekly-report').status_code, 200)
|
||||
self.assertEqual(client.get(f'/api/family/settings?childUserId={kids[0].id}').status_code, 200)
|
||||
self.assertEqual(client.get('/api/family/settings?childUserId=999999').status_code, 404)
|
||||
|
||||
|
||||
class AdminLoginThrottleTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
90
accounts/tests_birth_date.py
Normal file
90
accounts/tests_birth_date.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
تاریخِ تولدِ شمسی در پروفایل (۲۰۲۶-۱۰-۰۱). اپ روز/ماه/سالِ شمسی میفرستد؛ سرور
|
||||
میلادی ذخیره میکند و birthYear را از آن میسازد. راههای شکستن: تاریخِ ناموجود
|
||||
(۳۰ اسفندِ غیرکبیسه)، ارقامِ فارسی، سنِ خارج از بازه، و جدا افتادنِ سال از تاریخ.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts import jalali
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import Profile, User
|
||||
|
||||
|
||||
class JalaliConversionTests(SimpleTestCase):
|
||||
KNOWN = [
|
||||
((1405, 1, 1), date(2026, 3, 21)),
|
||||
((1403, 12, 30), date(2025, 3, 20)), # اسفندِ کبیسه
|
||||
((1404, 12, 29), date(2026, 3, 20)), # آخرین روزِ سالِ غیرکبیسه
|
||||
((1385, 4, 12), date(2006, 7, 3)),
|
||||
((1367, 10, 11), date(1989, 1, 1)),
|
||||
]
|
||||
|
||||
def test_known_dates_both_ways(self):
|
||||
for jalali_date, gregorian in self.KNOWN:
|
||||
self.assertEqual(jalali.to_gregorian(*jalali_date), gregorian)
|
||||
self.assertEqual(jalali.from_gregorian(gregorian), jalali_date)
|
||||
|
||||
def test_thirtieth_of_esfand_exists_only_in_leap_years(self):
|
||||
self.assertTrue(jalali.is_leap(1403))
|
||||
self.assertFalse(jalali.is_leap(1404))
|
||||
with self.assertRaises(ValueError):
|
||||
jalali.to_gregorian(1404, 12, 30)
|
||||
|
||||
def test_every_day_of_a_century_round_trips(self):
|
||||
start = date(1940, 1, 1).toordinal()
|
||||
for offset in range(0, 36600, 7):
|
||||
day = date.fromordinal(start + offset)
|
||||
self.assertEqual(jalali.to_gregorian(*jalali.from_gregorian(day)), day)
|
||||
|
||||
def test_persian_digits_and_slashes_are_accepted(self):
|
||||
self.assertEqual(jalali.parse('۱۳۸۵/۰۴/۱۲'), date(2006, 7, 3))
|
||||
|
||||
|
||||
class ProfileBirthDateApiTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(device_id='birth-date-device', is_guest=True)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
|
||||
def patch(self, body):
|
||||
return self.client.patch('/api/user/profile', body, format='json')
|
||||
|
||||
def test_date_is_stored_and_returned_in_jalali_with_its_year(self):
|
||||
response = self.patch({'birthDate': '1385-04-12'})
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertEqual(response.json()['birthDate'], '1385-04-12')
|
||||
self.assertEqual(response.json()['birthYear'], 1385)
|
||||
self.assertEqual(Profile.objects.get(user=self.user).birth_date, date(2006, 7, 3))
|
||||
self.assertEqual(self.client.get('/api/user/profile').json()['birthDate'], '1385-04-12')
|
||||
|
||||
def test_adults_are_accepted(self):
|
||||
self.assertEqual(self.patch({'birthDate': '1360-01-15'}).status_code, 200)
|
||||
|
||||
def test_impossible_dates_are_rejected(self):
|
||||
for bad in ('1404-12-30', '1385-13-01', '1385-07-31', '1385-4', 'دیروز'):
|
||||
self.assertEqual(self.patch({'birthDate': bad}).status_code, 400, bad)
|
||||
|
||||
def test_future_and_too_young_or_old_are_rejected(self):
|
||||
for bad in ('1410-01-01', '1403-01-01', '1300-01-01'):
|
||||
self.assertEqual(self.patch({'birthDate': bad}).status_code, 400, bad)
|
||||
|
||||
def test_date_and_year_together_are_ambiguous(self):
|
||||
self.assertEqual(self.patch({'birthDate': '1385-04-12', 'birthYear': 1390}).status_code, 400)
|
||||
|
||||
def test_a_different_year_alone_clears_the_old_date(self):
|
||||
self.patch({'birthDate': '1385-04-12'})
|
||||
data = self.patch({'birthYear': 1388}).json()
|
||||
self.assertEqual((data['birthYear'], data['birthDate']), (1388, None))
|
||||
|
||||
def test_null_clears_the_date(self):
|
||||
self.patch({'birthDate': '1385-04-12'})
|
||||
data = self.patch({'birthDate': None}).json()
|
||||
self.assertEqual((data['birthYear'], data['birthDate']), (None, None))
|
||||
|
||||
def test_parent_phone_is_gone(self):
|
||||
self.assertNotIn('parentPhone', self.client.get('/api/user/profile').json())
|
||||
51
accounts/tests_delete_users.py
Normal file
51
accounts/tests_delete_users.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""delete_users: کاربر و همهی دادهاش در همهی اپها، و هیچ چیزِ کاربرِ دیگری."""
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from accounts.models import OTPCode, Profile, User
|
||||
from ai_tutor.models import Conversation, Message
|
||||
from notifications.models import Notification
|
||||
from payment.models import Order
|
||||
from progress.models import UserStats
|
||||
|
||||
|
||||
class DeleteUsersTests(TestCase):
|
||||
def setUp(self):
|
||||
self.doomed = User.objects.create(phone='09121110001', is_guest=False)
|
||||
self.kept = User.objects.create(phone='09121110002', is_guest=False)
|
||||
for user in (self.doomed, self.kept):
|
||||
Profile.objects.create(user=user)
|
||||
UserStats.objects.create(user_id=user.id, xp=10)
|
||||
Message.objects.create(conversation=Conversation.objects.create(user_id=user.id), role='user', text='؟')
|
||||
Notification.objects.create(user_id=user.id, type='xp_earned', title='ت')
|
||||
OTPCode.objects.create(phone=user.phone, code='123456', expires_at='2030-01-01T00:00Z')
|
||||
|
||||
def run_command(self, *args):
|
||||
out = StringIO()
|
||||
call_command('delete_users', *args, stdout=out)
|
||||
return out.getvalue()
|
||||
|
||||
def test_dry_run_changes_nothing(self):
|
||||
out = self.run_command('--phones', self.doomed.phone)
|
||||
self.assertIn('progress.UserStats.user_id: 1', out)
|
||||
self.assertTrue(User.objects.filter(pk=self.doomed.pk).exists())
|
||||
|
||||
def test_everything_of_that_user_goes_and_nothing_else(self):
|
||||
self.run_command('--phones', self.doomed.phone, '--yes')
|
||||
self.assertFalse(User.objects.filter(pk=self.doomed.pk).exists())
|
||||
for model in (UserStats, Conversation, Notification):
|
||||
self.assertFalse(model.objects.filter(user_id=self.doomed.pk).exists(), model)
|
||||
self.assertTrue(model.objects.filter(user_id=self.kept.pk).exists(), model)
|
||||
self.assertFalse(OTPCode.objects.filter(phone=self.doomed.phone).exists())
|
||||
self.assertTrue(OTPCode.objects.filter(phone=self.kept.phone).exists())
|
||||
self.assertEqual(Message.objects.count(), 1)
|
||||
|
||||
def test_paid_orders_need_explicit_permission(self):
|
||||
Order.objects.create(user_id=self.doomed.id, amount=1000, status='paid', idempotency_key='k1')
|
||||
with self.assertRaises(CommandError):
|
||||
self.run_command('--ids', str(self.doomed.pk), '--yes')
|
||||
self.assertTrue(User.objects.filter(pk=self.doomed.pk).exists())
|
||||
self.run_command('--ids', str(self.doomed.pk), '--yes', '--include-paid')
|
||||
self.assertFalse(Order.objects.filter(user_id=self.doomed.pk).exists())
|
||||
@@ -529,6 +529,22 @@ class SetUserGradeView(APIView):
|
||||
return Response(SetGradeResponseSerializer(data).data)
|
||||
|
||||
|
||||
def _profile_payload(user, profile) -> dict:
|
||||
from . import jalali
|
||||
|
||||
return {
|
||||
'userId': str(user.id),
|
||||
'phone': user.phone,
|
||||
'name': user.name,
|
||||
'avatar': user.avatar,
|
||||
'isGuest': user.is_guest,
|
||||
'gradeId': user.grade_id,
|
||||
'birthYear': profile.birth_year,
|
||||
'birthDate': jalali.to_string(profile.birth_date) if profile.birth_date else None,
|
||||
'bio': profile.bio,
|
||||
}
|
||||
|
||||
|
||||
class UserProfileView(APIView):
|
||||
"""
|
||||
GET /api/user/profile — اطلاعات پروفایل کاربر جاری
|
||||
@@ -562,18 +578,7 @@ class UserProfileView(APIView):
|
||||
# زده میشه (OneToOne روی user_id جاری، نه ورودیِ خارجی).
|
||||
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||||
|
||||
data = {
|
||||
'userId': str(user.id),
|
||||
'phone': user.phone,
|
||||
'name': user.name,
|
||||
'avatar': user.avatar,
|
||||
'isGuest': user.is_guest,
|
||||
'gradeId': user.grade_id,
|
||||
'birthYear': profile.birth_year,
|
||||
'parentPhone': profile.parent_phone,
|
||||
'bio': profile.bio,
|
||||
}
|
||||
return Response(ProfileResponseSerializer(data).data)
|
||||
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
|
||||
|
||||
@extend_schema(
|
||||
request=UpdateProfileRequestSerializer,
|
||||
@@ -589,7 +594,7 @@ class UserProfileView(APIView):
|
||||
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||||
|
||||
user_fields = {'name': 'name', 'avatar': 'avatar'}
|
||||
profile_fields = {'birthYear': 'birth_year', 'parentPhone': 'parent_phone', 'bio': 'bio'}
|
||||
profile_fields = {'birthYear': 'birth_year', 'birthDate': 'birth_date', 'bio': 'bio'}
|
||||
|
||||
changed_user = [attr for key, attr in user_fields.items() if key in v]
|
||||
changed_profile = [attr for key, attr in profile_fields.items() if key in v]
|
||||
@@ -600,6 +605,17 @@ class UserProfileView(APIView):
|
||||
for key, attr in profile_fields.items():
|
||||
if key in v:
|
||||
setattr(profile, attr, v[key])
|
||||
# سال و تاریخ نباید از هم جدا بیفتند: تاریخ سال را میسازد، و سالِ تنها
|
||||
# (اپِ قدیمی) تاریخی را که دیگر با آن نمیخواند پاک میکند.
|
||||
if 'birthDate' in v:
|
||||
from . import jalali
|
||||
profile.birth_year = jalali.from_gregorian(v['birthDate'])[0] if v['birthDate'] else None
|
||||
changed_profile.append('birth_year')
|
||||
elif 'birthYear' in v and profile.birth_date is not None:
|
||||
from . import jalali
|
||||
if jalali.from_gregorian(profile.birth_date)[0] != v['birthYear']:
|
||||
profile.birth_date = None
|
||||
changed_profile.append('birth_date')
|
||||
|
||||
# update_fields: فقط همان ستونهایی که واقعاً عوض شدهاند نوشته شوند تا
|
||||
# PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دستنخورده را بازنویسی نکند.
|
||||
@@ -608,15 +624,4 @@ class UserProfileView(APIView):
|
||||
if changed_profile:
|
||||
profile.save(update_fields=changed_profile)
|
||||
|
||||
data = {
|
||||
'userId': str(user.id),
|
||||
'phone': user.phone,
|
||||
'name': user.name,
|
||||
'avatar': user.avatar,
|
||||
'isGuest': user.is_guest,
|
||||
'gradeId': user.grade_id,
|
||||
'birthYear': profile.birth_year,
|
||||
'parentPhone': profile.parent_phone,
|
||||
'bio': profile.bio,
|
||||
}
|
||||
return Response(ProfileResponseSerializer(data).data)
|
||||
return Response(ProfileResponseSerializer(_profile_payload(user, profile)).data)
|
||||
|
||||
Reference in New Issue
Block a user