diff --git a/content/admin.py b/content/admin.py index 4db1946..b4457d7 100644 --- a/content/admin.py +++ b/content/admin.py @@ -1,17 +1,21 @@ import json from django.contrib import admin, messages +from django.db.models import Count, Q from django.http import HttpResponse from django.shortcuts import redirect, render from django.urls import path, reverse +from django.utils import timezone from django.utils.html import format_html +from . import admin_browser from .curriculum_io import CurriculumError, export_curriculum, import_curriculum from .curriculum_io import parse as parse_curriculum from .question_io import ImportError_, export_questions, import_questions, parse from .models import ( - Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion, + Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Unit, UserAnswer, + UserLessonCompletion, ) @@ -108,8 +112,8 @@ class QuestionAdmin(admin.ModelAdmin): می‌پذیرد، پس چرخه‌ی ویرایش بسته است. """ - list_display = ('id', 'prompt', 'lesson', 'type') - list_filter = ('type', 'lesson') + list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'wrong_percent') + list_filter = ('type', 'lesson__node__unit__grade', 'lesson__node__unit') inlines = [ChoiceInline] actions = ['export_selected'] change_list_template = 'admin/content/question/change_list.html' @@ -117,6 +121,33 @@ class QuestionAdmin(admin.ModelAdmin): autocomplete_fields = ['lesson'] search_fields = ('prompt',) + def get_queryset(self, request): + return super().get_queryset(request).annotate( + _open_reports=Count('reports', filter=Q(reports__status='open'), distinct=True), + _total_answers=Count('answers', distinct=True), + _wrong_answers=Count('answers', filter=Q(answers__correct=False), distinct=True), + ) + + @admin.display(description='گزارشِ باز', ordering='_open_reports') + def open_reports(self, obj): + if not obj._open_reports: + return '—' + url = (reverse('admin:content_questionreport_changelist') + + f'?question__id__exact={obj.pk}&status__exact=open') + return format_html('{}', + url, obj._open_reports) + + @admin.display(description='٪ غلط', ordering='_wrong_answers') + def wrong_percent(self, obj): + # زیرِ ۵ پاسخ عددی نشان نمی‌دهیم: یک نمونه‌ی کوچک سوالِ سالم را + # «مشکل‌دار» جلوه می‌دهد. + if obj._total_answers < 5: + return '—' + percent = round(100 * obj._wrong_answers / obj._total_answers) + color = '#b91c1c' if percent >= 70 else '#475569' + return format_html('{}٪ از {}', + color, percent, obj._total_answers) + def get_urls(self): return [ path( @@ -124,6 +155,16 @@ class QuestionAdmin(admin.ModelAdmin): self.admin_site.admin_view(self.import_view), name='content_question_import', ), + # مرورگرِ آبشاریِ محتوا؛ زیرِ همین مدل ثبت می‌شود تا در پنل پیدا باشد. + path('browse/', admin_browser.browser_view, name='content_browse'), + path('browse/units//', admin_browser.units_json, + name='content_browse_units'), + path('browse/nodes//', admin_browser.nodes_json, + name='content_browse_nodes'), + path('browse/lessons//', admin_browser.lessons_json, + name='content_browse_lessons'), + path('browse/questions//', admin_browser.questions_json, + name='content_browse_questions'), ] + super().get_urls() @admin.action(description='دریافتِ فایلِ سوال‌های انتخاب‌شده') @@ -182,3 +223,19 @@ class NodeProgressAdmin(admin.ModelAdmin): ) list_filter = ('status', 'node__unit') search_fields = ('user_id',) + + +@admin.register(QuestionReport) +class QuestionReportAdmin(admin.ModelAdmin): + """گزارشِ کاربران روی سوال‌ها — همان‌جایی که تصمیمِ اصلاحِ محتوا گرفته می‌شود.""" + + list_display = ('id', 'question', 'reason', 'status', 'created_at') + list_filter = ('status', 'reason', 'question__lesson__node__unit__grade') + search_fields = ('question__prompt', 'note') + autocomplete_fields = ['question'] + actions = ['mark_resolved'] + + @admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده') + def mark_resolved(self, request, queryset): + updated = queryset.update(status='resolved', resolved_at=timezone.now()) + self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS) diff --git a/content/admin_browser.py b/content/admin_browser.py new file mode 100644 index 0000000..fa329a9 --- /dev/null +++ b/content/admin_browser.py @@ -0,0 +1,111 @@ +""" +مرورگرِ محتوا در پنلِ ادمین: پایه → فصل → درس → نوعِ مسئله → سوال‌ها. + +چرا: فهرستِ پیش‌فرضِ جنگو برای پیدا کردنِ «سوال‌های فلان مبحث» مناسب نیست — +باید بینِ چند صفحه‌ی جدا با فیلترهای دراپ‌داونی بالا و پایین رفت، و وقتی +۱۳۰۷ درس و هزاران سوال داری عملاً کار نمی‌کند. + +اینجا هر ستون فقط وقتی پر می‌شود که ستونِ قبلی انتخاب شده باشد، پس هیچ‌وقت +فهرستِ هزارتایی جلوی چشم نیست. کنارِ هر سوال دو عددِ تصمیم‌ساز می‌آید: +گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که می‌گوید کدام +سوال ایراد دارد یا بیش از حد سخت است. + +وابستگیِ تازه‌ای اضافه نمی‌کند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده. +""" +from django.contrib.admin.views.decorators import staff_member_required +from django.db.models import Count, Q +from django.http import JsonResponse +from django.shortcuts import render +from django.urls import reverse + +from .models import Grade, Lesson, Node, Question, Unit + + +@staff_member_required +def browser_view(request): + return render(request, 'admin/content/browser.html', { + 'title': 'مرورِ محتوا', + 'grades': Grade.objects.order_by('title').values('id', 'title'), + }) + + +@staff_member_required +def units_json(request, grade_id: int): + rows = ( + Unit.objects.filter(grade_id=grade_id) + .annotate(node_count=Count('nodes', distinct=True)) + .order_by('order', 'id') + .values('id', 'title', 'node_count') + ) + return JsonResponse({'results': list(rows)}) + + +@staff_member_required +def nodes_json(request, unit_id: int): + rows = ( + Node.objects.filter(unit_id=unit_id) + .annotate(lesson_count=Count('lessons', distinct=True)) + .order_by('order', 'id') + .values('id', 'title', 'lesson_count') + ) + return JsonResponse({'results': list(rows)}) + + +@staff_member_required +def lessons_json(request, node_id: int): + rows = ( + Lesson.objects.filter(node_id=node_id) + .annotate(question_count=Count('questions', distinct=True)) + .order_by('id') + .values('id', 'title', 'question_count') + ) + return JsonResponse({'results': list(rows)}) + + +@staff_member_required +def questions_json(request, lesson_id: int): + """ + هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط. + + درصدِ غلط فقط وقتی معنا دارد که تعدادِ پاسخ کم نباشد؛ زیرِ ۵ پاسخ عمداً + None برمی‌گردد تا یک نمونه‌ی تصادفی سوالِ سالم را «مشکل‌دار» نشان ندهد. + """ + questions = ( + Question.objects.filter(lesson_id=lesson_id) + .annotate( + open_reports=Count('reports', filter=Q(reports__status='open'), distinct=True), + total_answers=Count('answers', distinct=True), + wrong_answers=Count('answers', filter=Q(answers__correct=False), distinct=True), + ) + .prefetch_related('choices') + .order_by('id') + ) + + results = [] + for question in questions: + wrong_percent = ( + round(100 * question.wrong_answers / question.total_answers) + if question.total_answers >= 5 else None + ) + results.append({ + 'id': question.pk, + 'prompt': question.prompt, + 'type': question.get_type_display(), + 'choices': [ + {'value': c.value, 'isCorrect': c.is_correct} + for c in question.choices.all() + ], + 'openReports': question.open_reports, + 'totalAnswers': question.total_answers, + 'wrongPercent': wrong_percent, + 'editUrl': reverse('admin:content_question_change', args=[question.pk]), + 'reportsUrl': ( + reverse('admin:content_questionreport_changelist') + + f'?question__id__exact={question.pk}' + ), + }) + + return JsonResponse({ + 'results': results, + 'addUrl': reverse('admin:content_question_add') + f'?lesson={lesson_id}', + }) diff --git a/content/migrations/0005_questionreport.py b/content/migrations/0005_questionreport.py new file mode 100644 index 0000000..4445f6c --- /dev/null +++ b/content/migrations/0005_questionreport.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.17 on 2026-09-13 17:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0004_useranswer_idempotency_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='QuestionReport', + 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_answer', 'گزینه\u200cی درست اشتباه است'), ('unclear', 'صورتِ سوال گنگ است'), ('typo', 'غلطِ املایی/نگارشی'), ('too_hard', 'خیلی سخت است'), ('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)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='content.question')), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['question', 'status'], name='content_que_questio_57be34_idx')], + }, + ), + ] diff --git a/content/models.py b/content/models.py index d301d34..8a9552a 100644 --- a/content/models.py +++ b/content/models.py @@ -177,3 +177,41 @@ class UserAnswer(models.Model): def __str__(self): return f"answer-user{self.user_id}-q{self.question_id}" + + +class QuestionReport(models.Model): + """ + گزارشِ ایراد روی یک سوالِ مشخص. + + چرا جدا از support.BugReport: آن یکی گزارشِ کلیِ اپ است و به سوال وصل + نمی‌شود. تا وقتی گزارش به خودِ سوال نچسبد، کسی که محتوا را نگه می‌دارد + نمی‌فهمد کدام سوال ایراد دارد — باید متنِ گزارش‌ها را می‌خواند و دستی + پیدا می‌کرد. + + سیگنالِ ضمنی (چند درصد غلط جواب داده‌اند) از UserAnswer درمی‌آید و مدلِ + جدا لازم ندارد؛ این مدل فقط برای گزارشِ صریحِ کاربر است. + """ + + REASON_CHOICES = [ + ('wrong_answer', 'گزینه‌ی درست اشتباه است'), + ('unclear', 'صورتِ سوال گنگ است'), + ('typo', 'غلطِ املایی/نگارشی'), + ('too_hard', 'خیلی سخت است'), + ('other', 'موردِ دیگر'), + ] + STATUS_CHOICES = [('open', 'باز'), ('resolved', 'بررسی‌شده'), ('rejected', 'رد شد')] + + question = models.ForeignKey(Question, 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=['question', 'status'])] + + def __str__(self): + return f'report-{self.pk} on q{self.question_id} ({self.get_reason_display()})' diff --git a/content/serializers.py b/content/serializers.py index ad15934..486ee5a 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -1,7 +1,7 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from .models import Grade +from .models import Grade, QuestionReport # ---- Grades ---- @@ -133,3 +133,16 @@ class NodeProgressSerializer(serializers.Serializer): class NodeProgressListResponseSerializer(serializers.Serializer): progress = NodeProgressSerializer(many=True) + + +class QuestionReportRequestSerializer(serializers.Serializer): + """دلیل اجباری‌ست تا گزارش‌ها قابلِ دسته‌بندی باشند؛ توضیح اختیاری.""" + + reason = serializers.ChoiceField(choices=QuestionReport.REASON_CHOICES) + note = serializers.CharField(required=False, allow_blank=True, max_length=1000) + + +class QuestionReportResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() + reportId = serializers.IntegerField() + alreadyReported = serializers.BooleanField() diff --git a/content/templates/admin/content/browser.html b/content/templates/admin/content/browser.html new file mode 100644 index 0000000..b57c5c2 --- /dev/null +++ b/content/templates/admin/content/browser.html @@ -0,0 +1,144 @@ +{% extends "admin/base_site.html" %} +{% block content %} + + +
+

مرورِ محتوا

+

از راست به چپ انتخاب کنید تا به سوال‌ها برسید.

+ +
+
+

پایه

+
+ {% for grade in grades %} +
{{ grade.title }}
+ {% empty %} +
هیچ پایه‌ای ثبت نشده.
+ {% endfor %} +
+
+

فصل

اول پایه را انتخاب کنید.
+

درس

—
+

مبحث

—
+
+ +
+
+ + +{% endblock %} diff --git a/content/templates/admin/content/question/change_list.html b/content/templates/admin/content/question/change_list.html index 91c75eb..b30493e 100644 --- a/content/templates/admin/content/question/change_list.html +++ b/content/templates/admin/content/question/change_list.html @@ -1,6 +1,9 @@ {% extends "admin/change_list.html" %} -{# دکمه‌ی «وارد کردنِ سوال‌ها» کنارِ دکمه‌ی «افزودن» #} +{# ابزارهای محتوا کنارِ دکمه‌ی «افزودن» #} {% block object-tools-items %} +
  • + مرورِ محتوا (پایه → فصل → درس → سوال) +
  • وارد کردنِ سوال‌ها از فایل
  • diff --git a/content/tests_admin_browser.py b/content/tests_admin_browser.py new file mode 100644 index 0000000..8b1de73 --- /dev/null +++ b/content/tests_admin_browser.py @@ -0,0 +1,116 @@ +""" +تست‌های پنلِ ادمینِ محتوا. + +اینجا چیزی که بررسی می‌شود «کد اجرا می‌شود» نیست، بلکه «ادمین واقعاً می‌تواند +کارش را بکند» است: صفحه‌ها با یک کاربرِ لاگین‌شده باز شوند، آبشار درست پر شود، +و سوالِ مشکل‌دار در همان فهرست دیده شود. +""" +from django.contrib.auth.models import User as AuthUser +from django.test import TestCase +from django.urls import reverse + +from content.models import ( + Choice, Grade, Lesson, Node, Question, QuestionReport, Unit, UserAnswer, +) + + +class AdminContentBrowserTests(TestCase): + def setUp(self): + AuthUser.objects.create_superuser('admin', 'a@b.c', 'pw12345!') + self.client.login(username='admin', password='pw12345!') + + self.grade = Grade.objects.create(title='پایه نهم', icon='', color='') + self.unit = Unit.objects.create(grade=self.grade, title='فصل ۴: توان و ریشه', order=4) + self.node = Node.objects.create(unit=self.unit, title='توان صحیح', order=1) + self.lesson = Lesson.objects.create(node=self.node, title='ضرب توان‌ها') + self.question = Question.objects.create( + lesson=self.lesson, type='multiple_choice', prompt='حاصل ۲^۳ چند است؟', + ) + self.right = Choice.objects.create(question=self.question, value='۸', is_correct=True) + self.wrong = Choice.objects.create(question=self.question, value='۶', is_correct=False) + + def test_the_browser_page_opens(self): + response = self.client.get(reverse('admin:content_browse')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'پایه نهم') + + def test_each_column_only_returns_children_of_the_one_before_it(self): + units = self.client.get(reverse('admin:content_browse_units', args=[self.grade.pk])).json() + self.assertEqual([u['title'] for u in units['results']], ['فصل ۴: توان و ریشه']) + self.assertEqual(units['results'][0]['node_count'], 1) + + nodes = self.client.get(reverse('admin:content_browse_nodes', args=[self.unit.pk])).json() + self.assertEqual(nodes['results'][0]['lesson_count'], 1) + + lessons = self.client.get(reverse('admin:content_browse_lessons', args=[self.node.pk])).json() + self.assertEqual(lessons['results'][0]['question_count'], 1) + + def test_questions_come_back_with_their_choices_and_an_edit_link(self): + data = self.client.get( + reverse('admin:content_browse_questions', args=[self.lesson.pk]) + ).json() + row = data['results'][0] + self.assertEqual(row['prompt'], 'حاصل ۲^۳ چند است؟') + self.assertEqual([c['isCorrect'] for c in row['choices']], [True, False]) + self.assertIn(str(self.question.pk), row['editUrl']) + self.assertIn(str(self.lesson.pk), data['addUrl']) + + def test_an_open_report_is_surfaced_on_the_question(self): + QuestionReport.objects.create(question=self.question, user_id=1, reason='wrong_answer') + row = self.client.get( + reverse('admin:content_browse_questions', args=[self.lesson.pk]) + ).json()['results'][0] + self.assertEqual(row['openReports'], 1) + + def test_wrong_rate_is_hidden_until_there_are_enough_answers(self): + for i in range(4): + UserAnswer.objects.create(user_id=i, question=self.question, choice=self.wrong, + correct=False) + row = self.client.get( + reverse('admin:content_browse_questions', args=[self.lesson.pk]) + ).json()['results'][0] + self.assertIsNone(row['wrongPercent'], 'نمونه‌ی کوچک نباید درصد نشان دهد') + + UserAnswer.objects.create(user_id=99, question=self.question, choice=self.wrong, + correct=False) + row = self.client.get( + reverse('admin:content_browse_questions', args=[self.lesson.pk]) + ).json()['results'][0] + self.assertEqual(row['wrongPercent'], 100) + + def test_the_browser_is_closed_to_anonymous_visitors(self): + self.client.logout() + response = self.client.get(reverse('admin:content_browse')) + self.assertIn(response.status_code, (302, 403)) + + def test_the_question_list_shows_the_report_and_difficulty_columns(self): + QuestionReport.objects.create(question=self.question, user_id=1, reason='unclear') + response = self.client.get(reverse('admin:content_question_changelist')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'گزارشِ باز') + self.assertContains(response, '٪ غلط') + + def test_the_import_pages_open(self): + for name in ('admin:content_question_import', 'admin:content_grade_import_curriculum'): + self.assertEqual(self.client.get(reverse(name)).status_code, 200, name) + + +class QuestionReportEndpointTests(TestCase): + """گزارشِ کاربر باید ثبت شود ولی دابل‌کلیک نباید صف را شلوغ کند.""" + + def setUp(self): + grade = Grade.objects.create(title='پایه نهم', icon='', color='') + unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) + node = Node.objects.create(unit=unit, title='درس ۱', order=1) + lesson = Lesson.objects.create(node=node, title='نوع ۱') + self.question = Question.objects.create( + lesson=lesson, type='multiple_choice', prompt='؟', + ) + + def test_reporting_twice_does_not_create_two_open_rows(self): + QuestionReport.objects.create(question=self.question, user_id=7, reason='typo') + report, created = QuestionReport.objects.get_or_create( + question=self.question, user_id=7, status='open', defaults={'reason': 'typo'}, + ) + self.assertFalse(created) + self.assertEqual(QuestionReport.objects.filter(status='open').count(), 1) diff --git a/content/urls.py b/content/urls.py index 84086df..9ac21a9 100644 --- a/content/urls.py +++ b/content/urls.py @@ -8,6 +8,7 @@ from .views import ( LessonQuestionsView, NodeProgressListView, QuestionAnswerView, + QuestionReportView, ) urlpatterns = [ @@ -17,5 +18,6 @@ urlpatterns = [ path('lessons//questions', LessonQuestionsView.as_view(), name='lesson-questions'), path('lessons//complete', LessonCompleteView.as_view(), name='lesson-complete'), path('questions//answer', QuestionAnswerView.as_view(), name='question-answer'), + path('questions//report', QuestionReportView.as_view(), name='question-report'), path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'), ] diff --git a/content/views.py b/content/views.py index 5ee7493..117b6a4 100644 --- a/content/views.py +++ b/content/views.py @@ -4,7 +4,8 @@ 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 -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle @@ -13,7 +14,10 @@ from rest_framework.views import APIView from progress.models import UserHearts, UserStats from subscription.services import has_unlimited_hearts -from .models import Choice, Grade, Lesson, NodeProgress, Question, UserAnswer, UserLessonCompletion +from .models import ( + Choice, Grade, Lesson, NodeProgress, Question, QuestionReport, UserAnswer, + UserLessonCompletion, +) from .serializers import ( AnswerRequestSerializer, AnswerResponseSerializer, @@ -24,6 +28,8 @@ from .serializers import ( LessonIntroResponseSerializer, LessonQuestionsResponseSerializer, NodeProgressListResponseSerializer, + QuestionReportRequestSerializer, + QuestionReportResponseSerializer, ) @@ -535,3 +541,51 @@ class NodeProgressListView(APIView): data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))} return Response(NodeProgressListResponseSerializer(data).data) + + +class QuestionReportView(APIView): + """ + POST /api/questions/{id}/report — «این سوال ایراد دارد» + + چرا لازم بود: تنها بازخوردی که تا امروز جمع می‌شد مالِ چتِ دستیار بود. + روی خودِ سوال‌های مسیرِ آموزشی هیچ راهی نبود که کاربر بگوید گزینه‌ی درست + غلط است یا صورتِ سوال گنگ — و کسی که محتوا را نگه می‌دارد هیچ سیگنالی + نداشت جز حدس. + """ + + permission_classes = [IsAuthenticated] + throttle_scope = 'question_report' + throttle_classes = [ScopedRateThrottle] + + @extend_schema( + request=QuestionReportRequestSerializer, + responses=QuestionReportResponseSerializer, + tags=['محتوا (content)'], + ) + def post(self, request, question_id: int): + question = Question.objects.filter(pk=question_id).first() + if question is None: + raise NotFound('سوال یافت نشد.') + + serializer = QuestionReportRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # گزارشِ بازِ تکراریِ همان کاربر روی همان سوال، ردیفِ تازه نمی‌سازد: + # دابل‌کلیک نباید صف را شلوغ کند و آمار را به‌هم بزند. + report, created = QuestionReport.objects.get_or_create( + question=question, + 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, + )