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:
111
content/tests.py
111
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)
|
||||
|
||||
Reference in New Issue
Block a user