Files
MathApp/notifications/services.py
Zareian 39247f6d53 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>
2026-10-02 02:38:43 +03:30

86 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
اپ Notifications برخلاف بقیه‌ی اپ‌ها به هیچ اپی رفرنس سبک نمی‌ده و خودش
تولیدکننده‌ی رویداد نیست؛ فقط event-listener‌ه (طبق سند معماری داده).
چون این پروژه یک مونولیت جنگویی‌ه، «گوش دادن به رویداد» با یک تابع کمکی
پیاده‌سازی شده که سایر اپ‌ها (Progress، Payment، Support) مستقیماً
از این ماژول import و صدا می‌زنن — دقیقاً همون الگوی call_payment_gateway
در payment/services.py و create_order که سرور-به-سرور از subscription
صدا زده می‌شه.
⚙️ افزوده شد: پارامترِ channel (فقط 'in_app' یا 'sms'، پیش‌فرض 'in_app')
تا notify() علاوه‌بر ثبتِ Notificationِ داخل‌اپ، بتونه مستقیماً پیامک هم
بفرسته. برای sms هیچ منطقِ provider تکرار نشده — دقیقاً همون
accounts.services.send_sms که campaigns.services هم برای ارسالِ کمپینِ
پیامکی ازش استفاده می‌کنه، اینجا هم صدا زده می‌شه. این اپ همچنان به
accounts.models رفرنسی نداره (طبق پاراگرافِ بالا)؛ به همین دلیل شماره‌تلفن
از خودِ notify() استخراج نمی‌شه و caller (که از قبل User رو در اختیار
داره) باید phone رو صریحاً پاس بده.
"""
from accounts.services import send_sms
from .models import Notification
CHANNEL_IN_APP = 'in_app'
CHANNEL_SMS = 'sms'
CHANNEL_CHOICES = {CHANNEL_IN_APP, CHANNEL_SMS}
def notify(
*,
user_id: int,
type: str,
title: str,
body: str = '',
related_id: int | None = None,
channel: str = CHANNEL_IN_APP,
phone: str | None = None,
) -> Notification | None:
"""
ثبت یک اعلانِ جدید برای کاربر (channel='in_app'، پیش‌فرض و بدونِ هیچ
تغییری در رفتارِ قبلی) یا ارسالِ همون پیام به‌صورتِ پیامک
(channel='sms').
نمونه‌ی استفاده‌ی in_app (بدون تغییر نسبت به قبل):
from notifications.services import notify
notify(user_id=order.user_id, type='order_paid',
title='پرداخت با موفقیت انجام شد', related_id=order.id)
نمونه‌ی استفاده‌ی sms:
notify(user_id=user.id, type='order_paid', title='...',
body='سفارش شما با موفقیت پرداخت شد.',
channel='sms', phone=user.phone)
برای channel='sms':
- phone الزامیه؛ بدونِ اون ValueError می‌ده (نه یک ارسالِ خاموش/ناقص).
- هیچ رکوردِ Notification ساخته نمی‌شه — پیامک مستقیماً از طریقِ
accounts.services.send_sms ارسال می‌شه، پس مقدارِ برگشتی None‌ه.
- متنِ پیامک، body است؛ اگه body خالی باشه از title استفاده می‌شه (تا
caller مجبور به تکرارِ متن در هر دو فیلد نباشه).
- SMSDeliveryError (که send_sms در صورتِ شکستِ ارسال raise می‌کنه)
عمداً اینجا catch نمی‌شه؛ caller باید تصمیم بگیره با شکستِ ارسال چه
کار کنه — دقیقاً هم‌الگو با این‌که send_sms خودش هیچ حالتِ سکوت/
best-effort نداره.
مقدارِ نامعتبر برای channel (نه in_app نه sms) با ValueError رد
می‌شه، نه با یک ارسالِ خاموش/پیش‌فرض.
"""
if channel not in CHANNEL_CHOICES:
raise ValueError(f"Unsupported notification channel: {channel!r}")
if channel == CHANNEL_SMS:
if not phone:
raise ValueError("phone is required when channel='sms'")
send_sms(phone=phone, message=body or title)
return None
return Notification.objects.create(
user_id=user_id,
type=type,
title=title,
body=body,
related_id=related_id,
)