Implement question review flow with category and status filters; add templates for review start, question display, and completion

This commit is contained in:
Ross
2025-10-27 10:21:07 +00:00
parent ec45464e58
commit 8380ca1dd9
8 changed files with 236 additions and 34 deletions
@@ -4,7 +4,6 @@
hx-swap="innerHTML" hx-swap="innerHTML"
class="w-100"> class="w-100">
{% csrf_token %} {% csrf_token %}
<div class="row g-2 align-items-start"> <div class="row g-2 align-items-start">
<div class="col-auto"> <div class="col-auto">
<label class="form-label mb-1">Status</label> <label class="form-label mb-1">Status</label>
@@ -22,16 +21,16 @@
{% endif %} {% endif %}
</div> </div>
<div class="col"> <div class="col-md-12 mt-2">
<label for="{{ form.comment.id_for_label }}" class="form-label mb-1">Comment</label> <label for="{{ form.comment.id_for_label }}" class="form-label mb-1">Comment</label>
{{ form.comment }} {{ form.comment }}
{% if form.comment.errors %} {% if form.comment.errors %}
<div class="invalid-feedback d-block">{{ form.comment.errors|join:", " }}</div> <div class="invalid-feedback d-block">{{ form.comment.errors|join:", " }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="col-auto d-flex align-items-end">
<button class="btn btn-primary" type="submit">Save</button>
</div>
</div> </div>
<div class="col-auto d-flex align-items-end mt-2">
<button class="btn btn-primary" type="submit">Save</button>
</div>
</div>
</form> </form>
@@ -1,9 +0,0 @@
{% extends 'sbas/base.html' %}
{% load partials %}
{% block content %}
Test
{% endblock %}
+6 -1
View File
@@ -287,10 +287,15 @@ def generic_view_urls(generic_views: GenericViewBase):
name="question_reviews", name="question_reviews",
), ),
path( path(
"question/review/", "question/review/start",
generic_views.question_review_start, generic_views.question_review_start,
name="question_review_start", name="question_review_start",
), ),
path(
"question/review/next",
generic_views.question_review_next,
name="question_review_next",
),
path("question/<int:pk>/thumbnail/fail", generic_views.question_thumbnail_fail, name="series_thumbnail_fail"), path("question/<int:pk>/thumbnail/fail", generic_views.question_thumbnail_fail, name="series_thumbnail_fail"),
path("question/<int:pk>/thumbnail", generic_views.question_thumbnail, name="series_thumbnail"), path("question/<int:pk>/thumbnail", generic_views.question_thumbnail, name="series_thumbnail"),
path( path(
+93 -1
View File
@@ -3033,9 +3033,101 @@ class GenericViewBase:
def question_review_start(self, request): def question_review_start(self, request):
# Prepare category choices if the question model exposes a FK named 'category'
categories = None
try:
field = self.question_object._meta.get_field("category")
# Only handle FK-like fields
if hasattr(field, "related_model") and field.related_model is not None:
CategoryModel = field.related_model
# prefer 'category' or 'name' display field if present
order_by = "category" if hasattr(CategoryModel, "category") else "name" if hasattr(CategoryModel, "name") else "pk"
categories = CategoryModel.objects.all().order_by(order_by)
except Exception:
categories = None
return render(request, "generic/question_review_start.html", {"app_name": self.app_name}) # Status options map: label -> status code (or special 'UNREVIEWED')
status_options = [
("ANY", "Any"),
("UNREVIEWED", "Unreviewed"),
("AC", "Accepted"),
("OD", "Outdated"),
("ER", "Error"),
("RJ", "Rejected"),
("IP", "In Progress"),
]
return render(
request,
f"{self.app_name}/question_review_start.html",
{"app_name": self.app_name, "categories": categories, "status_options": status_options},
)
def question_review_next(self, request):
"""Find and redirect to the next question matching the supplied filters.
Accepts POST or GET parameters:
- category: integer primary key of category (optional)
- status: one of the status codes (AC, OD, ER, RJ, IP), or 'UNREVIEWED' or 'ANY'
"""
from django.contrib.contenttypes.models import ContentType
from generic.models import QuestionReview
# Read filters
category = request.POST.get("category") or request.GET.get("category")
status = request.POST.get("status") or request.GET.get("status") or "ANY"
# Build base queryset of questions for this app
qs = self.question_object.objects.all().order_by("pk")
# Restrict by category if provided and the model has such a relation
if category:
try:
qs = qs.filter(category__id=int(category))
except Exception:
pass
# Apply permission filter similar to filters elsewhere: non-checkers see only open_access or their own
if not request.user.groups.filter(name=self.checker_group).exists():
try:
qs = qs.filter(open_access=True) | qs.filter(author__id=request.user.id)
except Exception:
# If model doesn't have those fields, ignore
pass
# Iterate questions and pick first matching status
for question in qs:
# Skip authors_only questions unless user is author or superuser
try:
if question.authors_only and not (
request.user.is_superuser or request.user in question.author.all()
):
continue
except Exception:
pass
ct = ContentType.objects.get_for_model(question)
latest = (
QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
.order_by("-created_on").first()
)
match = False
if status == "ANY":
match = True
elif status == "UNREVIEWED":
match = latest is None
else:
# status is expected to be a QuestionReview.StatusChoices code
if latest is not None and latest.status == status:
match = True
if match:
return render(request, f"{self.app_name}/question_review_question.html", {"question": question})
# Nothing found - render complete page
return render(request, f"{self.app_name}/question_review_complete.html", {"app_name": self.app_name})
@method_decorator(user_passes_test(lambda u: u.is_superuser)) @method_decorator(user_passes_test(lambda u: u.is_superuser))
+44 -17
View File
@@ -118,24 +118,51 @@ ROOT_URLCONF = "rad.urls"
# #
#cached_loaders = [("django.template.loaders.cached.Loader", default_loaders)] #cached_loaders = [("django.template.loaders.cached.Loader", default_loaders)]
TEMPLATES = [ if DEBUG:
{ # Development: do not use cached loader so templates reload on each request
"BACKEND": "django.template.backends.django.DjangoTemplates", TEMPLATES = [
"DIRS": [os.path.join(BASE_DIR, "templates")], {
"APP_DIRS": True, "BACKEND": "django.template.backends.django.DjangoTemplates",
"OPTIONS": { "DIRS": [os.path.join(BASE_DIR, "templates")],
"context_processors": [ "APP_DIRS": True,
"django.template.context_processors.debug", "OPTIONS": {
"django.template.context_processors.request", "context_processors": [
"django.contrib.auth.context_processors.auth", "django.template.context_processors.debug",
"django.contrib.messages.context_processors.messages", "django.template.context_processors.request",
"django.template.context_processors.static", "django.contrib.auth.context_processors.auth",
], "django.contrib.messages.context_processors.messages",
#"loaders": default_loaders if DEBUG else cached_loaders, "django.template.context_processors.static",
],
},
}, },
]
}, else:
] # Production: enable cached loader for performance
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": False,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.static",
],
"loaders": [
(
"django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
)
],
},
},
]
WSGI_APPLICATION = "rad.wsgi.application" WSGI_APPLICATION = "rad.wsgi.application"
@@ -0,0 +1,9 @@
{% extends 'sbas/base.html' %}
{% block content %}
<h2>Review complete</h2>
<p>There are no more questions matching your filters.</p>
{% endblock %}
@@ -0,0 +1,40 @@
{# Generic question display used in review UI. Tries to be resilient across apps. #}
<div class="question-review-question mb-3">
{% if question.title %}
<h5 class="mb-1">{{ question.title }}</h5>
{% endif %}
<div class="mb-2">
{% if question.get_stem_stripped %}
<div class="question-stem">{{ question.get_stem_stripped|safe }}</div>
{% elif question.stem %}
<div class="question-stem">{{ question.stem|safe }}</div>
{% endif %}
</div>
{# Multiple-choice answers (SBA style) #}
{% if question.get_answers is defined or question.a_answer is defined %}
<div class="list-group mb-2">
{% for code,label in (('a','A'),('b','B'),('c','C'),('d','D'),('e','E')) %}
{% with ans=getattr(question, code|add:'_answer', None) %}
{% if ans %}
<div class="list-group-item d-flex justify-content-between align-items-start">
<div>
<strong>{{ label }}.</strong>
<span class="ms-2">{{ ans|safe }}</span>
</div>
{% if question.best_answer and question.best_answer == code %}
<span class="badge bg-success">Correct</span>
{% endif %}
</div>
{% endif %}
{% endwith %}
{% endfor %}
</div>
{% endif %}
{# Generic feedback text if present #}
{% if question.feedback %}
<div class="mt-2"><strong>Feedback:</strong> {{ question.feedback|safe }}</div>
{% endif %}
</div>
@@ -0,0 +1,39 @@
{% extends 'sbas/base.html' %}
{% load partials %}
{% block content %}
<h2>Review Questions</h2>
<p>Filter questions to review. Choose a category and/or a review status, then click Start to open the first matching question.</p>
<form method="post" action="{% url 'sbas:question_review_next' %}">
{% csrf_token %}
<div class="mb-3">
<label for="id_category" class="form-label">Category</label>
{% if categories %}
<select name="category" id="id_category" class="form-select">
<option value="">Any</option>
{% for c in categories %}
<option value="{{ c.pk }}">{{ c }}</option>
{% endfor %}
</select>
{% else %}
<p><em>No category choices available for this question type.</em></p>
{% endif %}
</div>
<div class="mb-3">
<label for="id_status" class="form-label">Review status</label>
<select name="status" id="id_status" class="form-select">
{% for code,label in status_options %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary">Start review</button>
</form>
{% endblock %}