diff --git a/content/receivers.py b/content/receivers.py index 71b9280..de097d6 100644 --- a/content/receivers.py +++ b/content/receivers.py @@ -1,7 +1,7 @@ -from django.db.models.signals import post_save +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver -from .models import UserAnswer, UserLessonCompletion +from .models import Grade, Lesson, Node, Unit, UserAnswer, UserLessonCompletion from .services import touch_node_progress_on_answer, update_node_progress_on_lesson_completion @@ -17,3 +17,18 @@ def _on_lesson_completion_created(sender, instance, created, **kwargs): if not created: return update_node_progress_on_lesson_completion(instance.user_id, instance.lesson) + + +# با هر تغییرِ برنامه‌ی درسی (از ادمین یا seed)، نسخه‌ی کشِ محتوا یک واحد جلو +# می‌رود و همه‌ی کلیدهای content:grades / content:tree را یک‌جا باطل می‌کند. +@receiver(post_save, sender=Grade, dispatch_uid='content_bump_curriculum_grade_save') +@receiver(post_delete, sender=Grade, dispatch_uid='content_bump_curriculum_grade_delete') +@receiver(post_save, sender=Unit, dispatch_uid='content_bump_curriculum_unit_save') +@receiver(post_delete, sender=Unit, dispatch_uid='content_bump_curriculum_unit_delete') +@receiver(post_save, sender=Node, dispatch_uid='content_bump_curriculum_node_save') +@receiver(post_delete, sender=Node, dispatch_uid='content_bump_curriculum_node_delete') +@receiver(post_save, sender=Lesson, dispatch_uid='content_bump_curriculum_lesson_save') +@receiver(post_delete, sender=Lesson, dispatch_uid='content_bump_curriculum_lesson_delete') +def _bump_curriculum_cache_version(sender, **kwargs): + from .views import bump_curriculum_version + bump_curriculum_version() diff --git a/content/tests.py b/content/tests.py index e595de1..88b30e1 100644 --- a/content/tests.py +++ b/content/tests.py @@ -541,3 +541,114 @@ class QuestionAnswerMascotMoodTests(TestCase): ) self.assertEqual(first.data['mascotMood'], 'encourage') self.assertEqual(replay.data['mascotMood'], 'encourage') + + +# ───────────────────────────────────────────────────────────────────────────── +# مقیاس‌پذیریِ HomeView: رفعِ N+1، کشِ برنامه‌ی درسی، ۴۰۴ گریدِ ناموجود +# ───────────────────────────────────────────────────────────────────────────── +from django.core.cache import cache as _django_cache +from django.test.utils import CaptureQueriesContext +from django.db import connection + + +class HomeViewScalabilityTests(TestCase): + def setUp(self): + _django_cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='dev-home-scale', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + + def _grade_with_nodes(self, n_nodes): + grade = Grade.objects.create(title=f'g{n_nodes}', icon='i', color='#fff') + unit = Unit.objects.create(grade=grade, title='u', order=1) + for i in range(n_nodes): + node = Node.objects.create(unit=unit, title=f'n{i}', order=i) + Lesson.objects.create(node=node, title=f'l{i}', intro_text='.') + return grade + + def _home(self, grade_id): + return self.client.get( + f'/api/home?grade={grade_id}', HTTP_AUTHORIZATION=f'Bearer {self.token}') + + def test_query_count_is_flat_regardless_of_node_count(self): + g_small = self._grade_with_nodes(2) + g_big = self._grade_with_nodes(20) + + _django_cache.clear() + with CaptureQueriesContext(connection) as q_small: + self.assertEqual(self._home(g_small.id).status_code, 200) + _django_cache.clear() + with CaptureQueriesContext(connection) as q_big: + self.assertEqual(self._home(g_big.id).status_code, 200) + + # قبلِ رفعِ N+1، q_big چند برابرِ q_small بود (یک COUNT به‌ازای هر node). + self.assertEqual(len(q_small), len(q_big)) + + def test_second_call_hits_cache_not_curriculum_tables(self): + g = self._grade_with_nodes(5) + self.assertEqual(self._home(g.id).status_code, 200) # پر کردنِ کش + + with CaptureQueriesContext(connection) as q: + self.assertEqual(self._home(g.id).status_code, 200) + sql = ' '.join(e['sql'] for e in q).lower() + self.assertNotIn('content_unit', sql) + self.assertNotIn('content_node', sql) + # فقط کوئریِ per-user باید بمونه + self.assertIn('userlessoncompletion', sql.replace('_', '')) + + def test_content_change_invalidates_tree_cache(self): + g = self._grade_with_nodes(3) + self._home(g.id) + unit = g.units.first() + Node.objects.create(unit=unit, title='new-node', order=99) # post_save → bump version + + r = self._home(g.id) + node_titles = [n['title'] for u in r.data['units'] for n in u['nodes']] + self.assertIn('new-node', node_titles) + + def test_missing_grade_returns_404(self): + r = self._home(999999) + self.assertEqual(r.status_code, 404) + + def test_grade_with_no_units_returns_empty_not_404(self): + g = Grade.objects.create(title='empty', icon='i', color='#fff') + r = self._home(g.id) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data['units'], []) + + +class GradesListCacheTests(TestCase): + def setUp(self): + _django_cache.clear() + self.client = APIClient() + + def test_list_is_cached_and_invalidated_on_new_grade(self): + Grade.objects.create(title='اول', icon='i', color='#111') + r1 = self.client.get('/api/grades') + self.assertEqual(len(r1.data['grades']), 1) + + with CaptureQueriesContext(connection) as q: + self.client.get('/api/grades') + self.assertEqual(len([e for e in q if 'content_grade' in e['sql'].lower()]), 0) + + Grade.objects.create(title='دوم', icon='i', color='#222') # post_save → bump + r3 = self.client.get('/api/grades') + self.assertEqual(len(r3.data['grades']), 2) + + +class ReadThrottleTests(TestCase): + def setUp(self): + _django_cache.clear() + self.client = APIClient() + self.user = User.objects.create(device_id='dev-read-throttle', is_guest=True, name='') + self.token = issue_access_token(self.user.id) + self.grade = Grade.objects.create(title='g', icon='i', color='#fff') + + def test_read_endpoint_throttles_after_limit(self): + # rate پیش‌فرض '120/min' — بعد از ۱۲۰ درخواستِ موفق، ۱۲۱اُم باید 429 بشه. + codes = set() + for _ in range(122): + codes.add(self.client.get( + f'/api/home?grade={self.grade.id}', + HTTP_AUTHORIZATION=f'Bearer {self.token}').status_code) + self.assertIn(429, codes) diff --git a/content/views.py b/content/views.py index 12de3d0..91f8f23 100644 --- a/content/views.py +++ b/content/views.py @@ -1,4 +1,6 @@ +from django.core.cache import cache from django.db import IntegrityError, transaction +from django.http import Http404 from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -45,28 +47,91 @@ class GuestAwareContentThrottle(ScopedRateThrottle): return self.cache_format % {'scope': self.scope, 'ident': ident} +# --- کشِ داده‌ی شبهِ‌ثابتِ برنامه‌ی درسی ------------------------------------- +# گریدها و درختِ unit/node/lesson تقریباً هیچ‌وقت عوض نمی‌شوند ولی روی هر بار +# باز شدنِ اپ خوانده می‌شوند. یک نسخه‌کلیدِ مشترک نگه می‌داریم که با هر تغییرِ +# محتوا (سیگنال در content/receivers.py) یک واحد جلو می‌رود و همه‌ی کلیدهای +# کشِ محتوا را یک‌جا باطل می‌کند. دیتای per-user (پیشرفت/آمار) کش نمی‌شود. +_CURRICULUM_VERSION_KEY = 'content:curriculum:ver' +_CACHE_TTL = 3600 + + +def curriculum_version() -> int: + return cache.get_or_set(_CURRICULUM_VERSION_KEY, 1, None) or 1 + + +def bump_curriculum_version() -> None: + try: + cache.incr(_CURRICULUM_VERSION_KEY) + except ValueError: + cache.set(_CURRICULUM_VERSION_KEY, curriculum_version() + 1, None) + + +def _grades_payload() -> dict: + key = f'content:grades:v{curriculum_version()}' + + def build(): + return {'grades': [ + {'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} + for g in Grade.objects.all().order_by('id') + ]} + + return cache.get_or_set(key, build, _CACHE_TTL) + + +def _grade_tree(grade_id: int): + """اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر. + None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد.""" + key = f'content:tree:grade:{grade_id}:v{curriculum_version()}' + cached = cache.get(key) + if cached is not None: + return None if cached is False else cached + + if not Grade.objects.filter(pk=grade_id).exists(): + cache.set(key, False, _CACHE_TTL) + return None + + units = ( + Grade.objects.get(pk=grade_id) + .units.prefetch_related('nodes__lessons').all() + ) + tree = [ + { + 'id': str(unit.id), + 'title': unit.title, + 'nodes': [ + { + 'id': str(node.id), + 'title': node.title, + 'lesson_ids': [lsn.id for lsn in node.lessons.all()], + } + for node in unit.nodes.all() + ], + } + for unit in units + ] + cache.set(key, tree, _CACHE_TTL) + return tree + + class GradesListView(APIView): """GET /api/grades — لیست پایه‌ها""" + throttle_scope = 'read' + throttle_classes = [GuestAwareContentThrottle] + @extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)']) def get(self, request): - grades = Grade.objects.all().order_by('id') - data = {'grades': [ - {'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} for g in grades - ]} - return Response(GradesListResponseSerializer(data).data) + return Response(GradesListResponseSerializer(_grades_payload()).data) -def _node_status(node, user_id, is_first_unlocked_candidate): - lesson_ids = list(node.lessons.values_list('id', flat=True)) +def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate): if not lesson_ids: return 'locked' - completed_count = UserLessonCompletion.objects.filter( - user_id=user_id, lesson_id__in=lesson_ids - ).values('lesson_id').distinct().count() - if completed_count >= len(lesson_ids): + done = sum(1 for lid in lesson_ids if lid in completed_ids) + if done >= len(lesson_ids): return 'completed' - if completed_count > 0 or is_first_unlocked_candidate: + if done > 0 or is_first_unlocked_candidate: return 'unlocked' return 'locked' @@ -76,6 +141,8 @@ class HomeView(APIView): # userId همیشه از request.user.id (توکن معتبر) گرفته می‌شود، نه ورودی کلاینت (IDOR). permission_classes = [IsAuthenticated] + throttle_scope = 'read' + throttle_classes = [GuestAwareContentThrottle] @extend_schema( parameters=[ @@ -88,19 +155,36 @@ class HomeView(APIView): grade_id = request.query_params.get('grade') user_id = request.user.id - units_qs = Grade.objects.get(pk=grade_id).units.all() if grade_id else [] + tree = [] + if grade_id: + try: + tree = _grade_tree(int(grade_id)) + except (TypeError, ValueError): + raise Http404 + if tree is None: + raise Http404 + + # همه‌ی lesson_idهای کامل‌شده‌ی این کاربر در همین گرید — یک کوئری، + # مستقل از تعدادِ node (رفعِ N+1). + all_lesson_ids = [lid for unit in tree for node in unit['nodes'] for lid in node['lesson_ids']] + completed_ids = set() + if all_lesson_ids: + completed_ids = set( + UserLessonCompletion.objects.filter( + user_id=user_id, lesson_id__in=all_lesson_ids + ).values_list('lesson_id', flat=True) + ) units_payload = [] - for unit in units_qs: - nodes = list(unit.nodes.all()) + for unit in tree: node_payload = [] unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه - for node in nodes: - node_status = _node_status(node, user_id, unlock_next) + for node in unit['nodes']: + node_status = _node_status(node['lesson_ids'], completed_ids, unlock_next) if node_status != 'completed': unlock_next = False - node_payload.append({'id': str(node.id), 'title': node.title, 'status': node_status}) - units_payload.append({'id': str(unit.id), 'title': unit.title, 'nodes': node_payload}) + node_payload.append({'id': node['id'], 'title': node['title'], 'status': node_status}) + units_payload.append({'id': unit['id'], 'title': unit['title'], 'nodes': node_payload}) stats = UserStats.objects.filter(user_id=user_id).first() stats_payload = {