fix HomeView N+1 and cache the curriculum tree
GET /api/home is the most-hit authenticated endpoint (every app open) and
was running one COUNT per node. Now it is a flat number of queries
regardless of grade size, and the shared curriculum skeleton is served
from cache; per-user progress/stats stay live.
content/views.py
- _node_status(): takes a precomputed set of completed lesson_ids and a
node's lesson_ids instead of firing its own COUNT query.
- HomeView.get(): one query for all completed lesson_ids of the user in
the grade, status computed in Python; grade tree (units -> nodes ->
lesson_ids, no per-user data) read from cache
(content:tree:grade:{id}:v{N}); missing grade -> 404 (was an
unhandled 500); grade with no units -> empty units, not 404.
- GradesListView: served from cache (content:grades:v{N}).
- curriculum_version() / bump_curriculum_version(): one shared version
key; bumping it invalidates every content cache key at once.
- both GET views get throttle_scope='read' + GuestAwareContentThrottle.
content/receivers.py
- post_save/post_delete on Grade/Unit/Node/Lesson -> bump_curriculum_version().
content/tests.py
- HomeViewScalabilityTests: query count flat for 2 vs 20 nodes; 2nd call
hits cache (no content_unit/node SQL); a new Node invalidates the tree;
missing grade 404; empty grade 200.
- GradesListCacheTests: list cached, invalidated on new Grade.
- ReadThrottleTests: /api/home returns 429 past the limit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 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
|
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:
|
if not created:
|
||||||
return
|
return
|
||||||
update_node_progress_on_lesson_completion(instance.user_id, instance.lesson)
|
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()
|
||||||
|
|||||||
111
content/tests.py
111
content/tests.py
@@ -541,3 +541,114 @@ class QuestionAnswerMascotMoodTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(first.data['mascotMood'], 'encourage')
|
self.assertEqual(first.data['mascotMood'], 'encourage')
|
||||||
self.assertEqual(replay.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)
|
||||||
|
|||||||
122
content/views.py
122
content/views.py
@@ -1,4 +1,6 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
@@ -45,28 +47,91 @@ class GuestAwareContentThrottle(ScopedRateThrottle):
|
|||||||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
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):
|
class GradesListView(APIView):
|
||||||
"""GET /api/grades — لیست پایهها"""
|
"""GET /api/grades — لیست پایهها"""
|
||||||
|
|
||||||
|
throttle_scope = 'read'
|
||||||
|
throttle_classes = [GuestAwareContentThrottle]
|
||||||
|
|
||||||
@extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)'])
|
@extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)'])
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
grades = Grade.objects.all().order_by('id')
|
return Response(GradesListResponseSerializer(_grades_payload()).data)
|
||||||
data = {'grades': [
|
|
||||||
{'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} for g in grades
|
|
||||||
]}
|
|
||||||
return Response(GradesListResponseSerializer(data).data)
|
|
||||||
|
|
||||||
|
|
||||||
def _node_status(node, user_id, is_first_unlocked_candidate):
|
def _node_status(lesson_ids, completed_ids, is_first_unlocked_candidate):
|
||||||
lesson_ids = list(node.lessons.values_list('id', flat=True))
|
|
||||||
if not lesson_ids:
|
if not lesson_ids:
|
||||||
return 'locked'
|
return 'locked'
|
||||||
completed_count = UserLessonCompletion.objects.filter(
|
done = sum(1 for lid in lesson_ids if lid in completed_ids)
|
||||||
user_id=user_id, lesson_id__in=lesson_ids
|
if done >= len(lesson_ids):
|
||||||
).values('lesson_id').distinct().count()
|
|
||||||
if completed_count >= len(lesson_ids):
|
|
||||||
return 'completed'
|
return 'completed'
|
||||||
if completed_count > 0 or is_first_unlocked_candidate:
|
if done > 0 or is_first_unlocked_candidate:
|
||||||
return 'unlocked'
|
return 'unlocked'
|
||||||
return 'locked'
|
return 'locked'
|
||||||
|
|
||||||
@@ -76,6 +141,8 @@ class HomeView(APIView):
|
|||||||
|
|
||||||
# userId همیشه از request.user.id (توکن معتبر) گرفته میشود، نه ورودی کلاینت (IDOR).
|
# userId همیشه از request.user.id (توکن معتبر) گرفته میشود، نه ورودی کلاینت (IDOR).
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
throttle_scope = 'read'
|
||||||
|
throttle_classes = [GuestAwareContentThrottle]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
parameters=[
|
parameters=[
|
||||||
@@ -88,19 +155,36 @@ class HomeView(APIView):
|
|||||||
grade_id = request.query_params.get('grade')
|
grade_id = request.query_params.get('grade')
|
||||||
user_id = request.user.id
|
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 = []
|
units_payload = []
|
||||||
for unit in units_qs:
|
for unit in tree:
|
||||||
nodes = list(unit.nodes.all())
|
|
||||||
node_payload = []
|
node_payload = []
|
||||||
unlock_next = True # اولین نودِ قفلنشده در هر واحد، باز میمونه
|
unlock_next = True # اولین نودِ قفلنشده در هر واحد، باز میمونه
|
||||||
for node in nodes:
|
for node in unit['nodes']:
|
||||||
node_status = _node_status(node, user_id, unlock_next)
|
node_status = _node_status(node['lesson_ids'], completed_ids, unlock_next)
|
||||||
if node_status != 'completed':
|
if node_status != 'completed':
|
||||||
unlock_next = False
|
unlock_next = False
|
||||||
node_payload.append({'id': str(node.id), 'title': node.title, 'status': node_status})
|
node_payload.append({'id': node['id'], 'title': node['title'], 'status': node_status})
|
||||||
units_payload.append({'id': str(unit.id), 'title': unit.title, 'nodes': node_payload})
|
units_payload.append({'id': unit['id'], 'title': unit['title'], 'nodes': node_payload})
|
||||||
|
|
||||||
stats = UserStats.objects.filter(user_id=user_id).first()
|
stats = UserStats.objects.filter(user_id=user_id).first()
|
||||||
stats_payload = {
|
stats_payload = {
|
||||||
|
|||||||
Reference in New Issue
Block a user