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:
29
progress/migrations/0006_drop_family_app.py
Normal file
29
progress/migrations/0006_drop_family_app.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-01 21:01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
# اپِ family (حسابِ جدای والد، کدِ دعوت، تنظیماتِ والدین) ۲۰۲۶-۱۰-۰۱ از ریشه حذف شد؛
|
||||
# جایش «گزارشِ والدین» در حسابِ خودِ دانشآموز است (progress/report.py). خودِ اپ دیگر
|
||||
# نصب نیست، پس جدولهایش اینجا drop میشوند. IF EXISTS: دیتابیسِ تازه هیچوقت آنها را
|
||||
# نداشته. دادهی آنها نگه داشته نمیشود؛ پیش از migrate بکاپ بگیرید.
|
||||
FAMILY_TABLES = ('family_parentalsettings', 'family_weeklyreportlog', 'family_familylink')
|
||||
|
||||
|
||||
def forget_family_app(apps, schema_editor):
|
||||
"""ردِ اپ در جدولهای خودِ جنگو: نوعها و مجوزهای ادمین، و تاریخچهی migration."""
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
ContentType.objects.filter(app_label='family').delete() # مجوزها با CASCADE میروند
|
||||
schema_editor.execute("DELETE FROM django_migrations WHERE app = 'family'")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('progress', '0005_tedi_character'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
*[migrations.RunSQL(f'DROP TABLE IF EXISTS {table}', migrations.RunSQL.noop) for table in FAMILY_TABLES],
|
||||
migrations.RunPython(forget_family_app, migrations.RunPython.noop),
|
||||
]
|
||||
148
progress/report.py
Normal file
148
progress/report.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
گزارشِ هفتگیِ خودِ کاربر — همان چیزی که فرانت با عنوانِ «گزارشِ والدین» نشان میدهد.
|
||||
|
||||
از ۲۰۲۶-۱۰-۰۱ جای اپِ `family` را گرفته است (تصمیمِ سینیور پس از ارائه): والد
|
||||
دیگر حسابِ جدا و کدِ دعوت ندارد و همین صفحه را در حسابِ خودِ دانشآموز میبیند.
|
||||
|
||||
همهچیز از فعالیتِ ثبتشده خوانده میشود و هیچ جدولِ تازهای ندارد:
|
||||
- `questions` / `correct`: `UserAnswer` (هر جوابِ ثبتشده، چه در آزمونِ درس چه جزیره).
|
||||
- `xp`: درسها و جزیرههای تمامشده — همان تعریفی که `progress/leaderboard.py` و
|
||||
`UserStats.xp` دارند؛ `UserAnswer.xp_earned` به `UserStats.xp` اضافه نمیشود، پس
|
||||
اینجا هم شمرده نمیشود.
|
||||
- `aiQuestions`: پیامهای کاربر به دستیار.
|
||||
|
||||
⚠️ `minutes` اندازهگیری نیست، **تخمین** است. اپ زمانِ حضور را نمیفرستد؛ زمان از
|
||||
فاصلهی رویدادهای پشتِ سرِ هم ساخته میشود (پایینِ `estimate_minutes`). خواندنِ یک
|
||||
درس بدونِ جواب دادن، یا فکرِ طولانیتر از `SESSION_GAP` روی یک سوال، شمرده نمیشود،
|
||||
پس عدد معمولاً از زمانِ واقعی کمتر است، نه بیشتر. چرا heartbeat نه: هر کاربرِ آنلاین
|
||||
هر دقیقه یک نوشتن روی SQLite میشد و تاریخچهی قبلی هم صفر میماند. فرانت عدد را
|
||||
«حدود» نشان میدهد.
|
||||
|
||||
پنجرهها غلتاناند (۷ روزِ آخر تا امروز، و ۷ روزِ پیش از آن)، نه هفتهی تقویمی: در
|
||||
شنبهی هفتهی تقویمی هر کاربری «کمتر از هفتهی پیش» دیده میشد. لیدربردِ هفتگی هم
|
||||
غلتان است.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
WINDOW_DAYS = 7
|
||||
# رویدادهایی که فاصلهشان از این کمتر است یک نشستِ پیوسته حساب میشوند. ۱۰ دقیقه
|
||||
# جا برای فکر روی یک سوالِ سخت یا منتظرِ جوابِ دستیار ماندن میگذارد.
|
||||
SESSION_GAP = timedelta(minutes=10)
|
||||
# اولین رویدادِ هر نشست هم زمان برده (خواندنِ سوالِ اول)؛ بدونِ این، کسی که فقط یک
|
||||
# سوال جواب داده «۰ دقیقه» میدید.
|
||||
SESSION_START = timedelta(minutes=1)
|
||||
|
||||
|
||||
def estimate_minutes(stamps):
|
||||
"""دقیقههای تخمینیِ یک روز از زمانِ رویدادهایش (هر ترتیبی)."""
|
||||
if not stamps:
|
||||
return 0
|
||||
stamps = sorted(stamps)
|
||||
total = SESSION_START
|
||||
for prev, cur in zip(stamps, stamps[1:]):
|
||||
gap = cur - prev
|
||||
total += gap if gap <= SESSION_GAP else SESSION_START
|
||||
return round(total.total_seconds() / 60)
|
||||
|
||||
|
||||
def _empty_day(day):
|
||||
return {
|
||||
'date': day, 'minutes': 0, 'questions': 0, 'correct': 0,
|
||||
'xp': 0, 'lessons': 0, 'islands': 0, 'aiQuestions': 0,
|
||||
}
|
||||
|
||||
|
||||
def _totals(days, lesson_ids, node_ids):
|
||||
total = {
|
||||
key: sum(d[key] for d in days)
|
||||
for key in ('minutes', 'questions', 'correct', 'xp', 'aiQuestions')
|
||||
}
|
||||
# درس/جزیرهای که دو بار تکرار شده یک بار شمرده میشود.
|
||||
total['lessons'] = len(lesson_ids)
|
||||
total['islands'] = len(node_ids)
|
||||
total['activeDays'] = sum(1 for d in days if d['minutes'] > 0)
|
||||
return total
|
||||
|
||||
|
||||
def weekly_report(user_id, today=None):
|
||||
from ai_tutor.models import Message
|
||||
from content.models import UserAnswer, UserLessonCompletion, UserNodeCompletion
|
||||
from progress.models import StreakRecord
|
||||
from progress.services import effective_current_streak
|
||||
|
||||
today = today or timezone.localdate()
|
||||
first_day = today - timedelta(days=2 * WINDOW_DAYS - 1)
|
||||
since = timezone.make_aware(datetime.combine(first_day, time.min))
|
||||
|
||||
days = {first_day + timedelta(days=i): _empty_day(first_day + timedelta(days=i))
|
||||
for i in range(2 * WINDOW_DAYS)}
|
||||
stamps = defaultdict(list)
|
||||
# (روزِ پنجره، شناسه) برای شمارشِ یکتای درس و جزیره در هر پنجره.
|
||||
lessons_by_day = defaultdict(set)
|
||||
nodes_by_day = defaultdict(set)
|
||||
|
||||
def bucket(created_at):
|
||||
day = timezone.localtime(created_at).date()
|
||||
stamps[day].append(created_at)
|
||||
return days.get(day)
|
||||
|
||||
for created_at, correct in UserAnswer.objects.filter(
|
||||
user_id=user_id, created_at__gte=since,
|
||||
).values_list('created_at', 'correct'):
|
||||
d = bucket(created_at)
|
||||
if d is not None:
|
||||
d['questions'] += 1
|
||||
d['correct'] += int(correct)
|
||||
|
||||
for created_at, lesson_id, xp in UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, created_at__gte=since,
|
||||
).values_list('created_at', 'lesson_id', 'xp_earned'):
|
||||
d = bucket(created_at)
|
||||
if d is not None:
|
||||
d['xp'] += xp
|
||||
lessons_by_day[d['date']].add(lesson_id)
|
||||
|
||||
for created_at, node_id, xp in UserNodeCompletion.objects.filter(
|
||||
user_id=user_id, created_at__gte=since,
|
||||
).values_list('created_at', 'node_id', 'xp_earned'):
|
||||
d = bucket(created_at)
|
||||
if d is not None:
|
||||
d['xp'] += xp
|
||||
nodes_by_day[d['date']].add(node_id)
|
||||
|
||||
for created_at in Message.objects.filter(
|
||||
conversation__user_id=user_id, role='user', created_at__gte=since,
|
||||
).values_list('created_at', flat=True):
|
||||
d = bucket(created_at)
|
||||
if d is not None:
|
||||
d['aiQuestions'] += 1
|
||||
|
||||
for day, d in days.items():
|
||||
d['minutes'] = estimate_minutes(stamps[day])
|
||||
d['lessons'] = len(lessons_by_day[day])
|
||||
d['islands'] = len(nodes_by_day[day])
|
||||
|
||||
ordered = [days[k] for k in sorted(days)]
|
||||
previous, current = ordered[:WINDOW_DAYS], ordered[WINDOW_DAYS:]
|
||||
|
||||
def ids(sets_by_day, window):
|
||||
out = set()
|
||||
for d in window:
|
||||
out |= sets_by_day[d['date']]
|
||||
return out
|
||||
|
||||
streak = StreakRecord.objects.filter(user_id=user_id).first()
|
||||
return {
|
||||
'today': today,
|
||||
'days': current,
|
||||
'previousDays': previous,
|
||||
'thisWeek': _totals(current, ids(lessons_by_day, current), ids(nodes_by_day, current)),
|
||||
'lastWeek': _totals(previous, ids(lessons_by_day, previous), ids(nodes_by_day, previous)),
|
||||
'streak': {
|
||||
'current': effective_current_streak(streak, today=today),
|
||||
'longest': streak.longest_streak if streak else 0,
|
||||
},
|
||||
}
|
||||
@@ -65,3 +65,34 @@ class SelectCharacterRequestSerializer(serializers.Serializer):
|
||||
class SelectCharacterResponseSerializer(serializers.Serializer):
|
||||
characterId = serializers.CharField()
|
||||
selected = serializers.BooleanField()
|
||||
|
||||
|
||||
class WeeklyReportDaySerializer(serializers.Serializer):
|
||||
date = serializers.DateField()
|
||||
minutes = serializers.IntegerField(help_text='تخمینی؛ از فاصلهی رویدادها، نه زمانِ واقعیِ باز بودنِ اپ.')
|
||||
questions = serializers.IntegerField()
|
||||
correct = serializers.IntegerField()
|
||||
xp = serializers.IntegerField()
|
||||
lessons = serializers.IntegerField()
|
||||
islands = serializers.IntegerField()
|
||||
aiQuestions = serializers.IntegerField()
|
||||
|
||||
|
||||
class WeeklyReportTotalsSerializer(serializers.Serializer):
|
||||
minutes = serializers.IntegerField()
|
||||
activeDays = serializers.IntegerField()
|
||||
questions = serializers.IntegerField()
|
||||
correct = serializers.IntegerField()
|
||||
xp = serializers.IntegerField()
|
||||
lessons = serializers.IntegerField(help_text='درسهای یکتا؛ تکرارِ یک درس یک بار شمرده میشود.')
|
||||
islands = serializers.IntegerField(help_text='جزیرههای یکتا.')
|
||||
aiQuestions = serializers.IntegerField()
|
||||
|
||||
|
||||
class WeeklyReportResponseSerializer(serializers.Serializer):
|
||||
today = serializers.DateField()
|
||||
days = WeeklyReportDaySerializer(many=True, help_text='۷ روزِ آخر تا امروز، قدیمی به جدید.')
|
||||
previousDays = WeeklyReportDaySerializer(many=True, help_text='۷ روزِ پیش از آن، قدیمی به جدید.')
|
||||
thisWeek = WeeklyReportTotalsSerializer()
|
||||
lastWeek = WeeklyReportTotalsSerializer()
|
||||
streak = StreakSummarySerializer()
|
||||
|
||||
158
progress/tests_weekly_report.py
Normal file
158
progress/tests_weekly_report.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
GET /api/user/weekly-report — گزارشی که فرانت با عنوانِ «گزارشِ والدین» نشان میدهد.
|
||||
|
||||
هر تست یک راهِ شکستن را امتحان میکند: دیدنِ دیتای کاربرِ دیگر، افتادنِ فعالیت در
|
||||
روز یا هفتهی غلط (مرزِ نیمهشبِ تهران)، و دو بار شمرده شدنِ درسِ تکراری.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from ai_tutor.models import Conversation, Message
|
||||
from content.models import (
|
||||
Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion,
|
||||
)
|
||||
|
||||
from .report import estimate_minutes
|
||||
|
||||
|
||||
class EstimateMinutesTests(SimpleTestCase):
|
||||
def at(self, *minutes):
|
||||
base = datetime(2026, 10, 1, 10, 0)
|
||||
return [base + timedelta(minutes=m) for m in minutes]
|
||||
|
||||
def test_no_activity_is_zero(self):
|
||||
self.assertEqual(estimate_minutes([]), 0)
|
||||
|
||||
def test_a_single_answer_counts_one_minute(self):
|
||||
self.assertEqual(estimate_minutes(self.at(0)), 1)
|
||||
|
||||
def test_close_events_are_one_continuous_session(self):
|
||||
self.assertEqual(estimate_minutes(self.at(0, 3, 5)), 6)
|
||||
|
||||
def test_a_long_break_is_not_counted_as_study(self):
|
||||
# دو نشستِ جدا: ۱ + ۲ دقیقه + ۱ (شروعِ نشستِ دوم)؛ دو ساعتِ وسط شمرده نمیشود.
|
||||
self.assertEqual(estimate_minutes(self.at(0, 2, 120)), 4)
|
||||
|
||||
def test_order_does_not_matter(self):
|
||||
self.assertEqual(estimate_minutes(self.at(5, 0, 3)), estimate_minutes(self.at(0, 3, 5)))
|
||||
|
||||
|
||||
class WeeklyReportApiTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120009001', is_guest=False)
|
||||
self.other = User.objects.create(phone='09120009002', is_guest=False)
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1),
|
||||
title='جزیره', order=1)
|
||||
self.lesson = Lesson.objects.create(node=node, title='درس')
|
||||
self.question = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='؟')
|
||||
self.right = Choice.objects.create(question=self.question, value='الف', is_correct=True)
|
||||
self.wrong = Choice.objects.create(question=self.question, value='ب', is_correct=False)
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def client_for(self, user):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
return client
|
||||
|
||||
def answer(self, user, correct, when):
|
||||
row = UserAnswer.objects.create(user_id=user.id, question=self.question,
|
||||
choice=self.right if correct else self.wrong, correct=correct)
|
||||
UserAnswer.objects.filter(pk=row.pk).update(created_at=when)
|
||||
|
||||
def report(self, user=None, **params):
|
||||
response = self.client_for(user or self.user).get('/api/user/weekly-report', params)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
return response.json()
|
||||
|
||||
def test_requires_a_token(self):
|
||||
self.assertIn(APIClient().get('/api/user/weekly-report').status_code, (401, 403))
|
||||
|
||||
def test_empty_account_gets_fourteen_zero_days(self):
|
||||
data = self.report()
|
||||
self.assertEqual(len(data['days']), 7)
|
||||
self.assertEqual(len(data['previousDays']), 7)
|
||||
self.assertEqual(data['days'][-1]['date'], timezone.localdate().isoformat())
|
||||
self.assertEqual(data['thisWeek']['minutes'], 0)
|
||||
self.assertEqual(data['lastWeek']['activeDays'], 0)
|
||||
|
||||
def test_only_the_callers_own_activity_is_reported(self):
|
||||
now = timezone.now()
|
||||
self.answer(self.other, True, now)
|
||||
self.answer(self.other, True, now)
|
||||
# پارامترِ userId (مثلِ /api/progress/nodes) نادیده گرفته میشود.
|
||||
data = self.report(userId=self.other.id)
|
||||
self.assertEqual(data['thisWeek']['questions'], 0)
|
||||
self.assertEqual(self.report(self.other)['thisWeek']['questions'], 2)
|
||||
|
||||
def test_this_week_and_last_week_are_split_at_seven_days(self):
|
||||
now = timezone.now()
|
||||
self.answer(self.user, True, now)
|
||||
self.answer(self.user, False, now - timedelta(days=8))
|
||||
self.answer(self.user, True, now - timedelta(days=20)) # بیرون از هر دو پنجره
|
||||
data = self.report()
|
||||
self.assertEqual((data['thisWeek']['questions'], data['thisWeek']['correct']), (1, 1))
|
||||
self.assertEqual((data['lastWeek']['questions'], data['lastWeek']['correct']), (1, 0))
|
||||
|
||||
def test_day_follows_tehran_midnight_not_utc(self):
|
||||
# ۲۲:۰۰ UTC = ۰۱:۳۰ بامدادِ روزِ بعد در تهران.
|
||||
today = timezone.localdate()
|
||||
yesterday_utc_night = timezone.make_aware(
|
||||
datetime.combine(today - timedelta(days=1), datetime.min.time()) + timedelta(hours=22),
|
||||
timezone=timezone.get_fixed_timezone(0),
|
||||
)
|
||||
self.answer(self.user, True, yesterday_utc_night)
|
||||
data = self.report()
|
||||
self.assertEqual(data['days'][-1]['questions'], 1)
|
||||
self.assertEqual(data['days'][-2]['questions'], 0)
|
||||
|
||||
def test_repeating_a_lesson_counts_it_once(self):
|
||||
for _ in range(2):
|
||||
UserLessonCompletion.objects.create(user_id=self.user.id, lesson=self.lesson,
|
||||
correct_count=1, stars=3, xp_earned=10)
|
||||
week = self.report()['thisWeek']
|
||||
self.assertEqual(week['lessons'], 1)
|
||||
self.assertEqual(week['xp'], 20)
|
||||
|
||||
def test_tutor_questions_are_counted_but_not_its_answers(self):
|
||||
conversation = Conversation.objects.create(user_id=self.user.id)
|
||||
Message.objects.create(conversation=conversation, role='user', text='سوال')
|
||||
Message.objects.create(conversation=conversation, role='assistant', text='جواب')
|
||||
other = Conversation.objects.create(user_id=self.other.id)
|
||||
Message.objects.create(conversation=other, role='user', text='سوالِ دیگری')
|
||||
week = self.report()['thisWeek']
|
||||
self.assertEqual(week['aiQuestions'], 1)
|
||||
self.assertEqual(week['activeDays'], 1)
|
||||
|
||||
|
||||
class NodeProgressIsOwnOnlyTests(TestCase):
|
||||
"""تا ۲۰۲۶-۱۰-۰۱ والدِ وصلشده با ?userId= پیشرفتِ فرزند را میدید؛ پارامتر حذف شد."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120009003', is_guest=False)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
|
||||
def test_someone_elses_id_returns_only_your_own_progress(self):
|
||||
other = User.objects.create(phone='09120009004', is_guest=False)
|
||||
node = Node.objects.create(
|
||||
unit=Unit.objects.create(grade=Grade.objects.create(title='پایه', icon='', color=''),
|
||||
title='فصل', order=1),
|
||||
title='جزیره', order=1)
|
||||
NodeProgress.objects.create(user_id=other.id, node=node, status='in_progress')
|
||||
response = self.client.get(f'/api/progress/nodes?userId={other.id}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()['progress'], [])
|
||||
|
||||
def test_junk_parameter_is_not_a_server_error(self):
|
||||
self.assertEqual(self.client.get('/api/progress/nodes?userId=abc').status_code, 200)
|
||||
@@ -7,10 +7,12 @@ from .views import (
|
||||
StreakCalendarView,
|
||||
UserHeartsView,
|
||||
UserSummaryView,
|
||||
WeeklyReportView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('user/summary', UserSummaryView.as_view(), name='user-summary'),
|
||||
path('user/weekly-report', WeeklyReportView.as_view(), name='user-weekly-report'),
|
||||
path('user/streak/calendar', StreakCalendarView.as_view(), name='streak-calendar'),
|
||||
path('leaderboard', LeaderboardView.as_view(), name='leaderboard'),
|
||||
path('user/hearts', UserHeartsView.as_view(), name='user-hearts'),
|
||||
|
||||
@@ -32,6 +32,7 @@ from .serializers import (
|
||||
StreakCalendarResponseSerializer,
|
||||
UserHeartsResponseSerializer,
|
||||
UserSummaryResponseSerializer,
|
||||
WeeklyReportResponseSerializer,
|
||||
)
|
||||
|
||||
|
||||
@@ -100,6 +101,26 @@ class StreakCalendarView(APIView):
|
||||
return Response(StreakCalendarResponseSerializer(data).data)
|
||||
|
||||
|
||||
class WeeklyReportView(APIView):
|
||||
"""
|
||||
GET /api/user/weekly-report — گزارشِ ۷ روزِ اخیرِ خودِ کاربر در کنارِ ۷ روزِ قبلش.
|
||||
|
||||
فرانت آن را با عنوانِ «گزارشِ والدین» نشان میدهد؛ والد همین را در حسابِ خودِ
|
||||
دانشآموز میبیند و حسابِ جدا ندارد. فقط دیتای خودِ درخواستدهنده را برمیگرداند.
|
||||
محاسبه و اینکه `minutes` تخمینی است: `progress/report.py`.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_scope = 'read'
|
||||
throttle_classes = [GuestAwareReadThrottle]
|
||||
|
||||
@extend_schema(responses=WeeklyReportResponseSerializer, tags=['Progress & Gamification (progress)'])
|
||||
def get(self, request):
|
||||
from .report import weekly_report
|
||||
|
||||
return Response(WeeklyReportResponseSerializer(weekly_report(request.user.id)).data)
|
||||
|
||||
|
||||
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ همزمان هر بار جدول را
|
||||
# از نو حساب نکنند.
|
||||
LEADERBOARD_CACHE_TTL = 60
|
||||
|
||||
Reference in New Issue
Block a user