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:
2026-08-30 08:45:18 +03:30
parent eeb6bd6138
commit 35218bf6a1
3 changed files with 231 additions and 21 deletions

View File

@@ -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 = {