Like/dislike + report under every question and lesson, lesson reports and vote columns in admin; practice islands refresh from file; audit fixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -465,6 +465,14 @@ anyone who can read this repo can forge a token for any user.**
|
|||||||
سوال در `question_io`؛ (۲) migration `content.0021` روی سوال/گزینه/درس/پیامهای دستیارِ دیتابیس (و حذفِ سوالِ تکراریِ بعد از تبدیل)؛ (۳) `rag.services.mathtext.to_plain`
|
سوال در `question_io`؛ (۲) migration `content.0021` روی سوال/گزینه/درس/پیامهای دستیارِ دیتابیس (و حذفِ سوالِ تکراریِ بعد از تبدیل)؛ (۳) `rag.services.mathtext.to_plain`
|
||||||
روی پاسخِ زنده و تاریخچهی دستیار (`ai_tutor/views.py`)؛ (۴) `MathApp_frontend/src/lib/plainMath.ts` در `MathText` بهعنوانِ آخرین تور (دو پیادهسازی باید همرفتار بمانند).
|
روی پاسخِ زنده و تاریخچهی دستیار (`ai_tutor/views.py`)؛ (۴) `MathApp_frontend/src/lib/plainMath.ts` در `MathText` بهعنوانِ آخرین تور (دو پیادهسازی باید همرفتار بمانند).
|
||||||
توانِ پیچیده بهصورتِ «a به توانِ (…)» نوشته میشود، نه `^`. تست: `content/tests_plain_math.py` (seed بدونِ نشانه)، `ai_tutor/tests_plain_history.py`.
|
توانِ پیچیده بهصورتِ «a به توانِ (…)» نوشته میشود، نه `^`. تست: `content/tests_plain_math.py` (seed بدونِ نشانه)، `ai_tutor/tests_plain_history.py`.
|
||||||
|
- **بازخوردِ دانشآموز** (`content.0023`): زیرِ هر سوالِ آزمون و هر درس سه دکمه است: 👍 «خوب بود»، 👎 «خوب نبود»، «گزارشِ ایراد»
|
||||||
|
(`MathApp_frontend/src/components/FeedbackBar.tsx`). `POST /api/questions/{id}/vote` و `/api/lessons/{id}/vote` (`value` ۱/−۱/۰)،
|
||||||
|
`POST /api/lessons/{id}/report` (جدیدِ درس) و `…/questions/{id}/report` (از قبل). ادمین: ستونِ 👍/👎 در فهرستِ سوالها و درسها،
|
||||||
|
صفِ «گزارشهای سوال» و «گزارشهای درس». رأی هر کاربر روی هر هدف یکی و تغییرپذیر است (`ContentVote`).
|
||||||
|
- **اصلاحِ جزیرههای تمرین از فایل:** `seed_sync._refresh_practice` گزینههای سوالِ موجودِ «تمرین: …» را در جا (همان idها) با فایل یکی میکند
|
||||||
|
و سوالِ تحویلشدهی حذفشده از فایل را اگر جوابی ندارد پاک میکند؛ سوالِ دستیِ ادمین هرگز.
|
||||||
|
- **بازبینیِ خودکارِ سریع** (۲۰۲۶-۱۰-۱۲): بازحسابِ مستقلِ ۳۴۲ سوالِ حسابِ ساده برابرِ کلید بود؛ ۱۲ سوالِ گزینهی همارزِ جواب (۰/۳ و ۰) و ۵ درسِ
|
||||||
|
با سوگیریِ «جوابِ درست بلندترین گزینه» پیدا و اصلاح شد. این جایگزینِ بازبینیِ معلم نیست.
|
||||||
- جزیرههای تمرین آخرِ هر فصلاند و قفلِ ترتیبی دارند؛ فصلِ اولِ هر کتاب رایگان است، پس تمرینهایش هم رایگاناند.
|
- جزیرههای تمرین آخرِ هر فصلاند و قفلِ ترتیبی دارند؛ فصلِ اولِ هر کتاب رایگان است، پس تمرینهایش هم رایگاناند.
|
||||||
|
|
||||||
## کتابها (Subject)، جزیرههای بیشتر و شکلِ درسهای کتابهای تازه — ۲۰۲۶-۱۰-۱۲
|
## کتابها (Subject)، جزیرههای بیشتر و شکلِ درسهای کتابهای تازه — ۲۰۲۶-۱۰-۱۲
|
||||||
|
|||||||
@@ -575,6 +575,7 @@ REST_FRAMEWORK = {
|
|||||||
'token_refresh': os.environ.get('TOKEN_REFRESH_IP_RATE', '600/hour'),
|
'token_refresh': os.environ.get('TOKEN_REFRESH_IP_RATE', '600/hour'),
|
||||||
'question_answer': '60/min',
|
'question_answer': '60/min',
|
||||||
'question_report': '20/hour',
|
'question_report': '20/hour',
|
||||||
|
'content_vote': '300/hour',
|
||||||
# هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسهی درسی؛
|
# هر درس اینجا یک نوع مسئله است با یکی-دو سوال، نه یک جلسهی درسی؛
|
||||||
# ۱۰ در ساعت را دانشآموزِ عادی میزد و پیشرفتش ۴۲۹ میگرفت.
|
# ۱۰ در ساعت را دانشآموزِ عادی میزد و پیشرفتش ۴۲۹ میگرفت.
|
||||||
'lesson_complete': '120/hour',
|
'lesson_complete': '120/hour',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from .curriculum_io import parse as parse_curriculum
|
|||||||
from .question_io import ImportError_, export_questions, import_questions, parse
|
from .question_io import ImportError_, export_questions, import_questions, parse
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Subject, Unit,
|
Choice, ContentVote, Grade, Lesson, LessonReport, Node, NodeProgress, Question, QuestionReport, Subject, Unit,
|
||||||
UserAnswer, UserLessonCompletion,
|
UserAnswer, UserLessonCompletion,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,9 +108,57 @@ class NodeAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('title', 'unit__title')
|
search_fields = ('title', 'unit__title')
|
||||||
|
|
||||||
|
|
||||||
|
def _vote_counts(kind):
|
||||||
|
"""Subquery: (likes, dislikes) برای هر هدف؛ ContentVote ارجاعِ سبک است، پس annotate مستقیم ممکن نیست."""
|
||||||
|
from django.db.models import IntegerField, OuterRef, Subquery
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
def sub(value):
|
||||||
|
return Coalesce(Subquery(
|
||||||
|
ContentVote.objects.filter(kind=kind, target_id=OuterRef('pk'), value=value)
|
||||||
|
.order_by().values('target_id').annotate(n=Count('id')).values('n'),
|
||||||
|
output_field=IntegerField()), 0)
|
||||||
|
return sub(1), sub(-1)
|
||||||
|
|
||||||
|
|
||||||
|
class VoteColumnsMixin:
|
||||||
|
vote_kind = ''
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
likes, dislikes = _vote_counts(self.vote_kind)
|
||||||
|
return super().get_queryset(request).annotate(_likes=likes, _dislikes=dislikes)
|
||||||
|
|
||||||
|
@admin.display(description='👍', ordering='_likes')
|
||||||
|
def likes(self, obj):
|
||||||
|
return obj._likes or '—'
|
||||||
|
|
||||||
|
@admin.display(description='👎', ordering='_dislikes')
|
||||||
|
def dislikes(self, obj):
|
||||||
|
if not obj._dislikes:
|
||||||
|
return '—'
|
||||||
|
return format_html('<span style="color:#b91c1c;font-weight:600">{}</span>', obj._dislikes)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(LessonReport)
|
||||||
|
class LessonReportAdmin(admin.ModelAdmin):
|
||||||
|
"""گزارشِ دانشآموزان روی متنِ درسها."""
|
||||||
|
|
||||||
|
list_display = ('id', 'lesson', 'reason', 'status', 'note', 'created_at')
|
||||||
|
list_filter = ('status', 'reason', 'lesson__node__unit__grade')
|
||||||
|
search_fields = ('lesson__title', 'note')
|
||||||
|
autocomplete_fields = ['lesson']
|
||||||
|
actions = ['mark_resolved']
|
||||||
|
|
||||||
|
@admin.action(description='علامت زدن بهعنوانِ بررسیشده', permissions=['change'])
|
||||||
|
def mark_resolved(self, request, queryset):
|
||||||
|
updated = queryset.update(status='resolved', resolved_at=timezone.now())
|
||||||
|
self.message_user(request, f'{updated} گزارش بررسیشده علامت خورد.', messages.SUCCESS)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Lesson)
|
@admin.register(Lesson)
|
||||||
class LessonAdmin(admin.ModelAdmin):
|
class LessonAdmin(VoteColumnsMixin, admin.ModelAdmin):
|
||||||
list_display = ('id', 'title', 'node')
|
vote_kind = 'lesson'
|
||||||
|
list_display = ('id', 'title', 'node', 'likes', 'dislikes')
|
||||||
list_filter = ('node',)
|
list_filter = ('node',)
|
||||||
# بدونِ این، انتخابِ درس در فرمِ سوال یک دراپداونِ بیش از هزارتایی است
|
# بدونِ این، انتخابِ درس در فرمِ سوال یک دراپداونِ بیش از هزارتایی است
|
||||||
# که عملاً غیرقابلِ استفاده است.
|
# که عملاً غیرقابلِ استفاده است.
|
||||||
@@ -118,7 +166,8 @@ class LessonAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Question)
|
@admin.register(Question)
|
||||||
class QuestionAdmin(admin.ModelAdmin):
|
class QuestionAdmin(VoteColumnsMixin, admin.ModelAdmin):
|
||||||
|
vote_kind = 'question'
|
||||||
"""
|
"""
|
||||||
سوالها علاوه بر ویرایشِ تکی، از همینجا فلهای وارد و خارج میشوند.
|
سوالها علاوه بر ویرایشِ تکی، از همینجا فلهای وارد و خارج میشوند.
|
||||||
|
|
||||||
@@ -127,7 +176,7 @@ class QuestionAdmin(admin.ModelAdmin):
|
|||||||
میپذیرد، پس چرخهی ویرایش بسته است.
|
میپذیرد، پس چرخهی ویرایش بسته است.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'wrong_percent')
|
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'likes', 'dislikes', 'wrong_percent')
|
||||||
list_filter = ('type', 'lesson__node__unit__grade', 'lesson__node__unit')
|
list_filter = ('type', 'lesson__node__unit__grade', 'lesson__node__unit')
|
||||||
inlines = [ChoiceInline]
|
inlines = [ChoiceInline]
|
||||||
actions = ['export_selected']
|
actions = ['export_selected']
|
||||||
|
|||||||
46
content/migrations/0023_lesson_report_content_vote.py
Normal file
46
content/migrations/0023_lesson_report_content_vote.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-10-12 09:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('content', '0022_plain_math_messages_again'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContentVote',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('kind', models.CharField(choices=[('question', 'سوال'), ('lesson', 'درس')], max_length=10)),
|
||||||
|
('target_id', models.IntegerField()),
|
||||||
|
('user_id', models.IntegerField()),
|
||||||
|
('value', models.SmallIntegerField()),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['kind', 'target_id'], name='content_con_kind_87301f_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('kind', 'target_id', 'user_id'), name='one_vote_per_user_target')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='LessonReport',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('user_id', models.IntegerField(db_index=True)),
|
||||||
|
('reason', models.CharField(choices=[('wrong_content', 'مطلبِ درس اشتباه است'), ('unclear', 'توضیح گنگ است'), ('typo', 'غلطِ املایی/نگارشی'), ('bad_figure', 'شکل اشکال دارد'), ('other', 'موردِ دیگر')], default='other', max_length=20)),
|
||||||
|
('note', models.TextField(blank=True)),
|
||||||
|
('status', models.CharField(choices=[('open', 'باز'), ('resolved', 'بررسی\u200cشده'), ('rejected', 'رد شد')], db_index=True, default='open', max_length=15)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('resolved_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='content.lesson')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['lesson', 'status'], name='content_les_lesson__a6a0ce_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -398,3 +398,50 @@ class SeedRecord(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'seed-{self.kind}-{self.key[:8]}'
|
return f'seed-{self.kind}-{self.key[:8]}'
|
||||||
|
|
||||||
|
|
||||||
|
class LessonReport(models.Model):
|
||||||
|
"""گزارشِ ایراد روی متنِ یک درس (توضیح، مثال، قدمها)؛ همتای `QuestionReport`."""
|
||||||
|
|
||||||
|
REASON_CHOICES = [
|
||||||
|
('wrong_content', 'مطلبِ درس اشتباه است'),
|
||||||
|
('unclear', 'توضیح گنگ است'),
|
||||||
|
('typo', 'غلطِ املایی/نگارشی'),
|
||||||
|
('bad_figure', 'شکل اشکال دارد'),
|
||||||
|
('other', 'موردِ دیگر'),
|
||||||
|
]
|
||||||
|
STATUS_CHOICES = [('open', 'باز'), ('resolved', 'بررسیشده'), ('rejected', 'رد شد')]
|
||||||
|
|
||||||
|
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='reports')
|
||||||
|
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||||||
|
reason = models.CharField(max_length=20, choices=REASON_CHOICES, default='other')
|
||||||
|
note = models.TextField(blank=True)
|
||||||
|
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='open', db_index=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [models.Index(fields=['lesson', 'status'])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'report on lesson {self.lesson_id} ({self.get_reason_display()})'
|
||||||
|
|
||||||
|
|
||||||
|
class ContentVote(models.Model):
|
||||||
|
"""
|
||||||
|
لایک/دیسلایکِ دانشآموز روی یک سوال یا یک درس. هر کاربر روی هر هدف یک رأی دارد (تغییرپذیر).
|
||||||
|
ارجاعِ سبک (kind + target_id) تا یک جدول هر دو را بپوشاند؛ شمارش در ادمین با Subquery است.
|
||||||
|
"""
|
||||||
|
|
||||||
|
KIND_CHOICES = [('question', 'سوال'), ('lesson', 'درس')]
|
||||||
|
|
||||||
|
kind = models.CharField(max_length=10, choices=KIND_CHOICES)
|
||||||
|
target_id = models.IntegerField()
|
||||||
|
user_id = models.IntegerField()
|
||||||
|
value = models.SmallIntegerField() # ۱ = لایک، −۱ = دیسلایک
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [models.UniqueConstraint(fields=['kind', 'target_id', 'user_id'], name='one_vote_per_user_target')]
|
||||||
|
indexes = [models.Index(fields=['kind', 'target_id'])]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -204,6 +204,8 @@ def _sync_questions(rows, ledger, counts):
|
|||||||
to_create.append(({**{k: v for k, v in row.items() if k != 'path'}, 'lessonId': lesson_id}, qkey))
|
to_create.append(({**{k: v for k, v in row.items() if k != 'path'}, 'lessonId': lesson_id}, qkey))
|
||||||
existing.add((lesson_id, prompt))
|
existing.add((lesson_id, prompt))
|
||||||
|
|
||||||
|
_refresh_practice(rows, lessons, ledger, counts)
|
||||||
|
|
||||||
if missing:
|
if missing:
|
||||||
raise question_io.ImportError_(
|
raise question_io.ImportError_(
|
||||||
'این درسها در برنامهی درسی پیدا نشدند (اول کوریکولوم را وارد کنید): '
|
'این درسها در برنامهی درسی پیدا نشدند (اول کوریکولوم را وارد کنید): '
|
||||||
@@ -216,3 +218,61 @@ def _sync_questions(rows, ledger, counts):
|
|||||||
counts['created'] += result['created']
|
counts['created'] += result['created']
|
||||||
for _row, qkey in to_create:
|
for _row, qkey in to_create:
|
||||||
ledger.record(qkey, 'question')
|
ledger.record(qkey, 'question')
|
||||||
|
|
||||||
|
|
||||||
|
PRACTICE_PREFIX = 'تمرین: '
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_practice(rows, lessons, ledger, counts):
|
||||||
|
"""
|
||||||
|
جزیرههای «تمرین: …» را فایل میسازد و ادمین سوالهایش را کموبیش دست نمیزند؛ پس اصلاحِ فایل
|
||||||
|
(گزینهی همارزِ جواب، جوابِ غلط) باید به سوالِ موجود برسد، وگرنه سید فقط «سوالِ تازه» میسازد و نسخهی
|
||||||
|
معیوب میماند:
|
||||||
|
- همان صورت، گزینهها/پاسخِ درست فرق دارد → گزینهها جا به جا (همان idها) بهروز میشود تا پاسخهای
|
||||||
|
ثبتشده نشکنند؛
|
||||||
|
- سوالی که خودِ سید قبلاً تحویل داده (دفتر) و حالا در فایل نیست و کسی جوابش نداده → پاک میشود.
|
||||||
|
سوالِ دستیِ ادمین (در دفتر نیست) هرگز دست نمیخورد. آزموده: content/tests_seed_content.py.
|
||||||
|
"""
|
||||||
|
practice_lessons = {
|
||||||
|
lid for path, lid in lessons.items() if path[2].startswith(PRACTICE_PREFIX)
|
||||||
|
}
|
||||||
|
if not practice_lessons:
|
||||||
|
return
|
||||||
|
want = {}
|
||||||
|
file_keys = set()
|
||||||
|
for row in rows:
|
||||||
|
path = tuple(str(p).strip() for p in row.get('path', []))
|
||||||
|
if len(path) != 4 or not path[2].startswith(PRACTICE_PREFIX):
|
||||||
|
continue
|
||||||
|
lid = lessons.get(path)
|
||||||
|
prompt = (row.get('prompt') or '').strip()
|
||||||
|
if lid is None:
|
||||||
|
continue
|
||||||
|
want[(lid, prompt)] = row
|
||||||
|
file_keys.add(_key('question', *path, prompt))
|
||||||
|
by_id = {}
|
||||||
|
for lid, path in ((v, k) for k, v in lessons.items() if v in practice_lessons):
|
||||||
|
by_id[lid] = path
|
||||||
|
qs = Question.objects.filter(lesson_id__in=practice_lessons).prefetch_related('choices')
|
||||||
|
stale = []
|
||||||
|
for q in qs.iterator(chunk_size=500):
|
||||||
|
row = want.get((q.lesson_id, q.prompt))
|
||||||
|
if row is None:
|
||||||
|
if _key('question', *by_id[q.lesson_id], q.prompt) in ledger.known:
|
||||||
|
stale.append(q.pk)
|
||||||
|
continue
|
||||||
|
new_choices = [str(c).strip() for c in row['choices']]
|
||||||
|
old = sorted(q.choices.all(), key=lambda c: c.pk)
|
||||||
|
if len(old) != len(new_choices):
|
||||||
|
continue
|
||||||
|
for index, choice in enumerate(old):
|
||||||
|
value, correct = new_choices[index], index == row['correct']
|
||||||
|
if choice.value != value or choice.is_correct != correct:
|
||||||
|
choice.value, choice.is_correct = value, correct
|
||||||
|
choice.save(update_fields=['value', 'is_correct'])
|
||||||
|
counts['updated'] += 1
|
||||||
|
if stale:
|
||||||
|
removable = list(Question.objects.filter(pk__in=stale, answers__isnull=True).values_list('pk', flat=True))
|
||||||
|
if removable:
|
||||||
|
Question.objects.filter(pk__in=removable).delete()
|
||||||
|
counts['retired'] += len(removable)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import Grade, QuestionReport
|
from .models import Grade, LessonReport, QuestionReport
|
||||||
|
|
||||||
|
|
||||||
# ---- Grades ----
|
# ---- Grades ----
|
||||||
@@ -258,3 +258,8 @@ class QuestionReportResponseSerializer(serializers.Serializer):
|
|||||||
success = serializers.BooleanField()
|
success = serializers.BooleanField()
|
||||||
reportId = serializers.IntegerField()
|
reportId = serializers.IntegerField()
|
||||||
alreadyReported = serializers.BooleanField()
|
alreadyReported = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class LessonReportRequestSerializer(serializers.Serializer):
|
||||||
|
reason = serializers.ChoiceField(choices=LessonReport.REASON_CHOICES)
|
||||||
|
note = serializers.CharField(required=False, allow_blank=True, max_length=1000)
|
||||||
|
|||||||
52
content/tests_feedback.py
Normal file
52
content/tests_feedback.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""لایک/دیسلایک و گزارشِ درس/سوال: API و ستونهای ادمین."""
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from accounts.authentication import issue_access_token
|
||||||
|
from accounts.models import User
|
||||||
|
from content.models import Choice, ContentVote, Grade, Lesson, LessonReport, Node, Question, Unit
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
g = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||||
|
u = Unit.objects.create(grade=g, title='فصل ۱', order=1)
|
||||||
|
n = Node.objects.create(unit=u, title='مبحث', order=1)
|
||||||
|
self.lesson = Lesson.objects.create(node=n, title='درس')
|
||||||
|
self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='؟')
|
||||||
|
Choice.objects.create(question=self.q, value='الف', is_correct=True)
|
||||||
|
Choice.objects.create(question=self.q, value='ب', is_correct=False)
|
||||||
|
self.user = User.objects.create(phone='09120000051')
|
||||||
|
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||||||
|
|
||||||
|
def _post(self, url, body):
|
||||||
|
return self.client.post(url, body, content_type='application/json', **self.auth)
|
||||||
|
|
||||||
|
def test_vote_toggle_and_retract(self):
|
||||||
|
url = f'/api/questions/{self.q.pk}/vote'
|
||||||
|
self.assertEqual(self._post(url, {'value': 1}).status_code, 200)
|
||||||
|
self.assertEqual(self._post(url, {'value': -1}).status_code, 200)
|
||||||
|
self.assertEqual(ContentVote.objects.get().value, -1)
|
||||||
|
self._post(url, {'value': 0})
|
||||||
|
self.assertEqual(ContentVote.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_vote_rejects_bad_value_and_anonymous(self):
|
||||||
|
url = f'/api/lessons/{self.lesson.pk}/vote'
|
||||||
|
self.assertEqual(self._post(url, {'value': 5}).status_code, 400)
|
||||||
|
self.assertEqual(self.client.post(url, {'value': 1}, content_type='application/json').status_code, 401)
|
||||||
|
|
||||||
|
def test_lesson_report_is_deduplicated(self):
|
||||||
|
url = f'/api/lessons/{self.lesson.pk}/report'
|
||||||
|
r1 = self._post(url, {'reason': 'typo', 'note': 'غلط'})
|
||||||
|
r2 = self._post(url, {'reason': 'typo'})
|
||||||
|
self.assertEqual((r1.status_code, r2.status_code), (201, 200))
|
||||||
|
self.assertEqual(LessonReport.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_admin_lists_show_votes(self):
|
||||||
|
ContentVote.objects.create(kind='question', target_id=self.q.pk, user_id=self.user.id, value=-1)
|
||||||
|
from django.contrib.auth.models import User as DjUser
|
||||||
|
DjUser.objects.create_superuser('root', 'r@x.ir', 'pw')
|
||||||
|
self.client.login(username='root', password='pw')
|
||||||
|
for path in ('/admin/content/question/', '/admin/content/lesson/', '/admin/content/lessonreport/'):
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 200, path)
|
||||||
@@ -287,3 +287,35 @@ class SeedFilesInRepoTests(TestCase):
|
|||||||
|
|
||||||
positions = {row['correct'] for row in rows}
|
positions = {row['correct'] for row in rows}
|
||||||
self.assertGreater(len(positions), 2)
|
self.assertGreater(len(positions), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class PracticeRefreshTests(TestCase):
|
||||||
|
"""اصلاحِ فایلِ جزیرهی تمرین به سوالِ موجود میرسد؛ سوالِ دستیِ ادمین دست نمیخورد."""
|
||||||
|
|
||||||
|
def _rows(self, choices, correct, prompt='۲ + ۳ = ؟'):
|
||||||
|
return [{'path': ['پایه سوم', 'فصل ۱', 'تمرین: جمع', 'درس'], 'prompt': prompt, 'type': 'multiple_choice',
|
||||||
|
'choices': choices, 'correct': correct}]
|
||||||
|
|
||||||
|
def _grades(self):
|
||||||
|
return [{'title': 'پایه سوم', 'icon': '', 'color': '', 'units': [{
|
||||||
|
'title': 'فصل ۱', 'order': 1, 'subject': 'ریاضی', 'blurb': '', 'nodes': [{
|
||||||
|
'title': 'تمرین: جمع', 'order': 1, 'lessons': [{
|
||||||
|
'title': 'درس', 'intro_text': 'متن', 'example': '', 'steps': []}]}]}]}]
|
||||||
|
|
||||||
|
def test_changed_choices_update_in_place_and_stale_is_removed(self):
|
||||||
|
from content import seed_sync
|
||||||
|
from content.models import Choice, Question
|
||||||
|
seed_sync.sync(self._grades(), self._rows(['۴', '۵', '۶', '۷'], 1))
|
||||||
|
q = Question.objects.get()
|
||||||
|
ids = list(q.choices.order_by('pk').values_list('pk', flat=True))
|
||||||
|
seed_sync.sync(self._grades(), self._rows(['۴', '۵', '۸', '۷'], 3))
|
||||||
|
q.refresh_from_db()
|
||||||
|
got = list(q.choices.order_by('pk').values_list('pk', 'value', 'is_correct'))
|
||||||
|
self.assertEqual([g[0] for g in got], ids)
|
||||||
|
self.assertEqual(got[3][1:], ('۷', True))
|
||||||
|
# سوالی که از فایل رفته و جوابی ندارد پاک میشود؛ سوالِ دستی نه
|
||||||
|
manual = Question.objects.create(lesson=q.lesson, type='multiple_choice', prompt='دستی')
|
||||||
|
Choice.objects.create(question=manual, value='الف', is_correct=True)
|
||||||
|
seed_sync.sync(self._grades(), self._rows(['۱', '۲'], 0, prompt='سوالِ دیگر'))
|
||||||
|
self.assertFalse(Question.objects.filter(prompt='۲ + ۳ = ؟').exists())
|
||||||
|
self.assertTrue(Question.objects.filter(prompt='دستی').exists())
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ from .views import (
|
|||||||
NodeProgressListView,
|
NodeProgressListView,
|
||||||
QuestionAnswerView,
|
QuestionAnswerView,
|
||||||
QuestionReportView,
|
QuestionReportView,
|
||||||
|
LessonReportView,
|
||||||
|
LessonVoteView,
|
||||||
|
QuestionVoteView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -22,6 +25,9 @@ urlpatterns = [
|
|||||||
path('lessons/<int:lesson_id>/complete', LessonCompleteView.as_view(), name='lesson-complete'),
|
path('lessons/<int:lesson_id>/complete', LessonCompleteView.as_view(), name='lesson-complete'),
|
||||||
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
|
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
|
||||||
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'),
|
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'),
|
||||||
|
path('questions/<int:question_id>/vote', QuestionVoteView.as_view(), name='question-vote'),
|
||||||
|
path('lessons/<int:lesson_id>/vote', LessonVoteView.as_view(), name='lesson-vote'),
|
||||||
|
path('lessons/<int:lesson_id>/report', LessonReportView.as_view(), name='lesson-report'),
|
||||||
path('nodes/<int:node_id>/lessons', NodeLessonsView.as_view(), name='node-lessons'),
|
path('nodes/<int:node_id>/lessons', NodeLessonsView.as_view(), name='node-lessons'),
|
||||||
path('nodes/<int:node_id>/quiz', NodeQuizView.as_view(), name='node-quiz'),
|
path('nodes/<int:node_id>/quiz', NodeQuizView.as_view(), name='node-quiz'),
|
||||||
path('nodes/<int:node_id>/quiz/complete', NodeQuizCompleteView.as_view(), name='node-quiz-complete'),
|
path('nodes/<int:node_id>/quiz/complete', NodeQuizCompleteView.as_view(), name='node-quiz-complete'),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from .services import (
|
|||||||
playable_lesson_ids, quiz_completed_node_ids, verified_correct_count,
|
playable_lesson_ids, quiz_completed_node_ids, verified_correct_count,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Subject, UserAnswer,
|
Choice, ContentVote, Grade, Lesson, LessonReport, Node, NodeProgress, Question, QuestionReport, Subject, UserAnswer,
|
||||||
UserLessonCompletion, UserNodeCompletion,
|
UserLessonCompletion, UserNodeCompletion,
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
@@ -37,6 +37,7 @@ from .serializers import (
|
|||||||
LessonCompleteRequestSerializer,
|
LessonCompleteRequestSerializer,
|
||||||
LessonCompleteResponseSerializer,
|
LessonCompleteResponseSerializer,
|
||||||
LessonIntroResponseSerializer,
|
LessonIntroResponseSerializer,
|
||||||
|
LessonReportRequestSerializer,
|
||||||
LessonQuestionsResponseSerializer,
|
LessonQuestionsResponseSerializer,
|
||||||
NodeLessonsResponseSerializer,
|
NodeLessonsResponseSerializer,
|
||||||
NodeQuizCompleteRequestSerializer,
|
NodeQuizCompleteRequestSerializer,
|
||||||
@@ -988,6 +989,79 @@ class QuestionReportView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _VoteBase(APIView):
|
||||||
|
"""POST …/vote {value: 1|-1|0} — لایک/دیسلایکِ دانشآموز؛ ۰ یعنی رأی را پس میگیرد."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
throttle_scope = 'content_vote'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
kind = ''
|
||||||
|
|
||||||
|
def _exists(self, target_id) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def post(self, request, **kwargs):
|
||||||
|
target_id = next(iter(kwargs.values()))
|
||||||
|
if not self._exists(target_id):
|
||||||
|
raise NotFound('پیدا نشد.')
|
||||||
|
value = request.data.get('value')
|
||||||
|
if value not in (1, -1, 0) or isinstance(value, bool):
|
||||||
|
raise ValidationError({'value': 'باید ۱، −۱ یا ۰ باشد.'})
|
||||||
|
if value == 0:
|
||||||
|
ContentVote.objects.filter(kind=self.kind, target_id=target_id, user_id=request.user.id).delete()
|
||||||
|
else:
|
||||||
|
ContentVote.objects.update_or_create(
|
||||||
|
kind=self.kind, target_id=target_id, user_id=request.user.id, defaults={'value': value})
|
||||||
|
return Response({'value': value})
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionVoteView(_VoteBase):
|
||||||
|
kind = 'question'
|
||||||
|
|
||||||
|
@extend_schema(request=None, responses=OpenApiTypes.OBJECT, tags=['Curriculum (content)'])
|
||||||
|
def post(self, request, question_id: int):
|
||||||
|
return super().post(request, question_id=question_id)
|
||||||
|
|
||||||
|
def _exists(self, target_id):
|
||||||
|
return Question.objects.filter(pk=target_id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class LessonVoteView(_VoteBase):
|
||||||
|
kind = 'lesson'
|
||||||
|
|
||||||
|
@extend_schema(request=None, responses=OpenApiTypes.OBJECT, tags=['Curriculum (content)'])
|
||||||
|
def post(self, request, lesson_id: int):
|
||||||
|
return super().post(request, lesson_id=lesson_id)
|
||||||
|
|
||||||
|
def _exists(self, target_id):
|
||||||
|
return Lesson.objects.filter(pk=target_id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class LessonReportView(APIView):
|
||||||
|
"""POST /api/lessons/{id}/report — «متنِ این درس ایراد دارد» (همتای گزارشِ سوال)."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
throttle_scope = 'question_report'
|
||||||
|
throttle_classes = [ScopedRateThrottle]
|
||||||
|
|
||||||
|
@extend_schema(request=LessonReportRequestSerializer, responses=QuestionReportResponseSerializer,
|
||||||
|
tags=['Curriculum (content)'])
|
||||||
|
def post(self, request, lesson_id: int):
|
||||||
|
lesson = Lesson.objects.filter(pk=lesson_id).first()
|
||||||
|
if lesson is None:
|
||||||
|
raise NotFound('درس یافت نشد.')
|
||||||
|
serializer = LessonReportRequestSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
report, created = LessonReport.objects.get_or_create(
|
||||||
|
lesson=lesson, user_id=request.user.id, status='open',
|
||||||
|
defaults={'reason': serializer.validated_data['reason'], 'note': serializer.validated_data.get('note', '')},
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
QuestionReportResponseSerializer({'success': True, 'reportId': report.pk, 'alreadyReported': not created}).data,
|
||||||
|
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NodeQuizView(APIView):
|
class NodeQuizView(APIView):
|
||||||
"""
|
"""
|
||||||
GET /api/nodes/{id}/quiz — یک آزمونِ آماده برای کلِ مبحث.
|
GET /api/nodes/{id}/quiz — یک آزمونِ آماده برای کلِ مبحث.
|
||||||
|
|||||||
Reference in New Issue
Block a user