Implement question review flow with category and status filters; add templates for review start, question display, and completion
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
hx-swap="innerHTML"
|
||||
class="w-100">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row g-2 align-items-start">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1">Status</label>
|
||||
@@ -22,15 +21,15 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
{{ form.comment }}
|
||||
{% if form.comment.errors %}
|
||||
<div class="invalid-feedback d-block">{{ form.comment.errors|join:", " }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex align-items-end">
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-end mt-2">
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{% extends 'sbas/base.html' %}
|
||||
|
||||
{% load partials %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
Test
|
||||
|
||||
{% endblock %}
|
||||
+6
-1
@@ -287,10 +287,15 @@ def generic_view_urls(generic_views: GenericViewBase):
|
||||
name="question_reviews",
|
||||
),
|
||||
path(
|
||||
"question/review/",
|
||||
"question/review/start",
|
||||
generic_views.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", generic_views.question_thumbnail, name="series_thumbnail"),
|
||||
path(
|
||||
|
||||
+93
-1
@@ -3033,9 +3033,101 @@ class GenericViewBase:
|
||||
|
||||
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))
|
||||
|
||||
+29
-2
@@ -118,6 +118,8 @@ ROOT_URLCONF = "rad.urls"
|
||||
#
|
||||
#cached_loaders = [("django.template.loaders.cached.Loader", default_loaders)]
|
||||
|
||||
if DEBUG:
|
||||
# Development: do not use cached loader so templates reload on each request
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
@@ -131,9 +133,34 @@ TEMPLATES = [
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"django.template.context_processors.static",
|
||||
],
|
||||
#"loaders": default_loaders if DEBUG else cached_loaders,
|
||||
},
|
||||
|
||||
},
|
||||
]
|
||||
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",
|
||||
],
|
||||
)
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user