diff --git a/generic/migrations/0026_questionreview.py b/generic/migrations/0026_questionreview.py
new file mode 100644
index 00000000..4d571f8b
--- /dev/null
+++ b/generic/migrations/0026_questionreview.py
@@ -0,0 +1,29 @@
+# Generated by Django 5.1.4 on 2025-10-22 19:19
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('generic', '0025_cimarseriesthumbnail'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='QuestionReview',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('object_id', models.PositiveIntegerField()),
+ ('comment', models.TextField(blank=True)),
+ ('status', models.CharField(choices=[('AC', 'Accepted'), ('OD', 'Outdated'), ('ER', 'Error'), ('RJ', 'Rejected')], default='AC', max_length=2)),
+ ('created_on', models.DateTimeField(auto_now_add=True)),
+ ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
+ ],
+ ),
+ ]
diff --git a/generic/templates/generic/partials/question_review_block.html b/generic/templates/generic/partials/question_review_block.html
new file mode 100644
index 00000000..7f713267
--- /dev/null
+++ b/generic/templates/generic/partials/question_review_block.html
@@ -0,0 +1,15 @@
+
+
+
+
Latest review
+
+
{{ review.get_status_display }}
+
{{ review.comment }}
+
by {{ review.get_author_str }} on {{ review.created_on }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/generic/templates/generic/partials/question_review_form.html b/generic/templates/generic/partials/question_review_form.html
new file mode 100644
index 00000000..a60db9fe
--- /dev/null
+++ b/generic/templates/generic/partials/question_review_form.html
@@ -0,0 +1,20 @@
+
\ No newline at end of file
diff --git a/generic/urls.py b/generic/urls.py
index 444b8e92..0b652dd9 100755
--- a/generic/urls.py
+++ b/generic/urls.py
@@ -276,6 +276,11 @@ def generic_view_urls(generic_views: GenericViewBase):
path(
"question//", generic_views.question_detail, name="question_detail"
),
+ path(
+ "question//set_review",
+ generic_views.question_set_review,
+ name="question_set_review",
+ ),
path("question//thumbnail/fail", generic_views.question_thumbnail_fail, name="series_thumbnail_fail"),
path("question//thumbnail", generic_views.question_thumbnail, name="series_thumbnail"),
path(
diff --git a/generic/views.py b/generic/views.py
index cb43c3e8..6d41bc02 100644
--- a/generic/views.py
+++ b/generic/views.py
@@ -1988,6 +1988,7 @@ class ExamViews(View, LoginRequiredMixin):
"view_feedback": view_feedback,
"can_edit": can_edit,
"remote_url": settings.REMOTE_URL,
+ "app_name": self.app_name,
},
)
@@ -2947,6 +2948,56 @@ class GenericViewBase:
question_json = question.get_question_json(based=False, feedback=True)
return JsonResponse(question_json)
+ def question_set_review(self, request, pk):
+ """HTMX endpoint to get/set the latest QuestionReview for a question.
+
+ GET: renders the current review block or the form (if ?edit=1)
+ POST: creates a new QuestionReview and returns the rendered block
+ """
+ from django.contrib.contenttypes.models import ContentType
+ from generic.models import QuestionReview
+
+ logger.debug("question_set_review called")
+
+ question = get_object_or_404(self.question_object, pk=pk)
+
+ ct = ContentType.objects.get_for_model(question)
+ latest = (
+ QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
+ .order_by("-created_on").first()
+ )
+
+ # If GET and edit requested, return the form. If `new` is set, render with no latest so the
+ # form is blank for creating a fresh review.
+ if request.method == "GET" and request.GET.get("edit"):
+ if request.GET.get("new"):
+ return render(request, "generic/partials/question_review_form.html", {"question": question, "latest": None, "app_name": self.app_name})
+ return render(request, "generic/partials/question_review_form.html", {"question": question, "latest": latest, "app_name": self.app_name})
+
+ if request.method == "POST":
+ # Require login to post a review
+ if not request.user.is_authenticated:
+ return HttpResponse(status=403)
+
+ status = request.POST.get("status")
+ comment = request.POST.get("comment", "")
+
+ review = QuestionReview.objects.create(
+ content_type=ct,
+ object_id=question.pk,
+ author=request.user,
+ status=status,
+ comment=comment,
+ )
+
+ return render(request, "generic/partials/question_review_block.html", {"review": review, "question": question, "app_name": self.app_name})
+
+ # Default: render the block (latest if exists, otherwise form)
+ if latest:
+ return render(request, "generic/partials/question_review_block.html", {"review": latest, "question": question, "app_name": self.app_name})
+ else:
+ return render(request, "generic/partials/question_review_form.html", {"question": question, "latest": latest, "app_name": self.app_name})
+
@method_decorator(user_passes_test(lambda u: u.is_superuser))
def user_answer_delete_multiple(self, request):
print(request.POST["answer_ids"])
@@ -2999,6 +3050,7 @@ class GenericViewBase:
"view_feedback": view_feedback,
"remote_url": settings.REMOTE_URL,
"can_edit": question.can_edit(request.user),
+ "app_name": self.app_name,
},
)
diff --git a/sbas/templates/sbas/question_detail.html b/sbas/templates/sbas/question_detail.html
index 38fc7c57..69470105 100644
--- a/sbas/templates/sbas/question_detail.html
+++ b/sbas/templates/sbas/question_detail.html
@@ -3,6 +3,9 @@
{% block content %}
+
+ {# Placeholder while HTMX fetches review/form #}
+
{{ question.title|default:"(No title set)" }}
@@ -14,6 +17,7 @@
Delete
Admin
+