give topic cards a colour before anyone picks one

رنگ و آیکونِ مبحث تازه اضافه شده بودند و هنوز هیچ‌کس پرشان نکرده، یعنی
کارت‌ها تا وقتی کسی از پنلِ ادمین وقت بگذارد بی‌رنگ می‌ماندند.

حالا وقتی فیلد خالی است، مقدارِ پیش‌فرض از پالت و آیکون‌های خودِ اپ می‌آید
(همان رنگ‌های طرح و همان آیکون‌هایی که از lucide وارد می‌شوند)، تا بومی به
نظر برسد نه وصله.

چرخشی بر اساسِ جایگاهِ مبحث در پایه است، نه تصادفی: رنگی که بینِ دو بارگذاری
عوض شود بدتر از بی‌رنگ بودن است، و دو مبحثِ کنارِ هم نباید یک‌رنگ دربیایند.
مقدارِ صریحِ ادمین همیشه برنده است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 02:10:50 +03:30
parent 10ad1e232c
commit 2218f82938
2 changed files with 76 additions and 15 deletions

View File

@@ -150,3 +150,53 @@ class HomeGradeParamTests(TestCase):
def test_an_unknown_grade_is_a_clean_404_not_a_silent_fallback(self):
self.assertEqual(self.client.get('/api/home?gradeId=999999', **self.auth).status_code, 404)
class NodeVisualDefaultsTests(TestCase):
"""
تا وقتی کسی از پنلِ ادمین رنگ نگذاشته، کارتِ مبحث نباید بی‌رنگ بماند.
پیش‌فرض باید *پایدار* باشد نه تصادفی: رنگی که بینِ دو بارگذاری عوض شود،
بدتر از بی‌رنگ بودن است.
"""
def setUp(self):
cache.clear()
self.grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1)
self.nodes = [
Node.objects.create(unit=unit, title=f'مبحث {i}', order=i)
for i in range(1, 4)
]
for node in self.nodes:
Lesson.objects.create(node=node, title='نوع', intro_text='x')
user = User.objects.create(phone='09120000003', grade_id=self.grade.id)
from accounts.authentication import issue_access_token
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'}
def _nodes(self):
return self.client.get('/api/home', **self.auth).json()['units'][0]['nodes']
def test_a_node_without_a_colour_still_gets_one(self):
for node in self._nodes():
self.assertTrue(node['color'].startswith('#'), node)
self.assertTrue(node['icon'])
def test_neighbouring_nodes_do_not_share_a_colour(self):
colors = [n['color'] for n in self._nodes()]
self.assertEqual(len(set(colors)), len(colors))
def test_the_same_node_keeps_the_same_colour_between_requests(self):
first = [n['color'] for n in self._nodes()]
cache.clear()
self.assertEqual(first, [n['color'] for n in self._nodes()])
def test_an_explicit_colour_from_the_admin_wins(self):
self.nodes[0].color = '#123456'
self.nodes[0].icon = 'Trophy'
self.nodes[0].save()
cache.clear()
first = self._nodes()[0]
self.assertEqual(first['color'], '#123456')
self.assertEqual(first['icon'], 'Trophy')

View File

@@ -94,6 +94,16 @@ def _grades_payload() -> dict:
return cache.get_or_set(key, build, _CACHE_TTL)
# پالت و آیکونِ پیش‌فرضِ کارتِ مبحث — هر دو از خودِ اپ گرفته شده‌اند
# (رنگ‌های موجود در طرح، و آیکون‌هایی که از lucide وارد می‌شوند)، تا تا وقتی
# کسی از پنلِ ادمین مقدارِ دلخواه نگذاشته، کارت‌ها بی‌رنگ نمانند.
#
# چرخشی و بر اساسِ جایگاهِ مبحث است، نه تصادفی: رنگ نباید بینِ دو درخواست
# عوض شود، و دو مبحثِ کنارِ هم نباید یک‌رنگ دربیایند.
_NODE_COLORS = ['#ED6956', '#91ADC6', '#97C0C0', '#9B7BFF', '#FF7A3D']
_NODE_ICONS = ['Grid3x3', 'TrendingUp', 'LineChart', 'Sigma', 'Compass', 'Repeat']
def _grade_tree(grade_id: int):
"""اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر.
None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد."""
@@ -110,24 +120,25 @@ def _grade_tree(grade_id: int):
Grade.objects.get(pk=grade_id)
.units.prefetch_related('nodes__lessons').all()
)
tree = [
{
tree = []
position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ
for unit in units:
nodes = []
for node in unit.nodes.all():
nodes.append({
'id': str(node.id),
'title': node.title,
'icon': node.icon or _NODE_ICONS[position % len(_NODE_ICONS)],
'color': node.color or _NODE_COLORS[position % len(_NODE_COLORS)],
'lesson_ids': [lsn.id for lsn in node.lessons.all()],
})
position += 1
tree.append({
'id': str(unit.id),
'title': unit.title,
'blurb': unit.blurb,
'nodes': [
{
'id': str(node.id),
'title': node.title,
'icon': node.icon,
'color': node.color,
'lesson_ids': [lsn.id for lsn in node.lessons.all()],
}
for node in unit.nodes.all()
],
}
for unit in units
]
'nodes': nodes,
})
cache.set(key, tree, _CACHE_TTL)
return tree