Add flagging functionality for exam questions with a reusable Flag model and toggle feature
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-05 12:41
|
||||||
|
|
||||||
|
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', '0030_ciduser_notes'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Flag',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('object_id', models.PositiveIntegerField()),
|
||||||
|
('note', models.TextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('cid_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to='generic.ciduser')),
|
||||||
|
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1704,6 +1704,48 @@ class CidUserExam(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Flag(models.Model):
|
||||||
|
"""A reusable flag model that can be attached to any object via a
|
||||||
|
GenericForeignKey. Useful for marking questions, items or other objects
|
||||||
|
across apps.
|
||||||
|
"""
|
||||||
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
object_id = models.PositiveIntegerField()
|
||||||
|
content_object = GenericForeignKey("content_type", "object_id")
|
||||||
|
|
||||||
|
# Optional actor who created the flag (either a user or a CID user)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="flags",
|
||||||
|
)
|
||||||
|
cid_user = models.ForeignKey(
|
||||||
|
"CidUser",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="flags",
|
||||||
|
)
|
||||||
|
|
||||||
|
note = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
actor = None
|
||||||
|
if self.user:
|
||||||
|
actor = f"user:{self.user.username}"
|
||||||
|
elif self.cid_user:
|
||||||
|
actor = f"cid:{self.cid_user.cid}"
|
||||||
|
else:
|
||||||
|
actor = "anon"
|
||||||
|
return f"Flag [{actor}] -> {self.content_type}({self.object_id})"
|
||||||
|
|
||||||
|
|
||||||
CID_GROUP_EXAMS = (
|
CID_GROUP_EXAMS = (
|
||||||
("SBAs", "sba_cid_user_groups"),
|
("SBAs", "sba_cid_user_groups"),
|
||||||
("Physics", "physics_cid_user_groups"),
|
("Physics", "physics_cid_user_groups"),
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% comment %}A small flag/unflag button partial. Re-rendered by HTMX when toggled.{% endcomment %}
|
||||||
|
<div id="flag-button-container">
|
||||||
|
{% load static %}
|
||||||
|
{% if flagged %}
|
||||||
|
<form method="POST" hx-post="{% if cid %}{% url 'physics:exam_toggle_flag' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_toggle_flag_user' pk=exam.pk sk=pos %}{% endif %}" hx-target="#flag-button-container" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="set" value="false" />
|
||||||
|
<button type="submit" class="btn btn-warning btn-sm" title="Unflag question for review">Unflag <i class="bi bi-flag-fill"></i></button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" hx-post="{% if cid %}{% url 'physics:exam_toggle_flag' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_toggle_flag_user' pk=exam.pk sk=pos %}{% endif %}" hx-target="#flag-button-container" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="set" value="true" />
|
||||||
|
<button type="submit" class="btn btn-outline-warning btn-sm" title="Flag question for review">Flag <i class="bi bi-flag"></i></button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
<div id="flag-button-container" class="mb-2">
|
||||||
|
{% include 'physics/partials/exam_flag_button.html' %}
|
||||||
|
</div>
|
||||||
{% if previous > -1 %}
|
{% if previous > -1 %}
|
||||||
<button type="submit" name="previous" class="save btn btn-secondary" title="Click to save your answer(s) and go to the previous question">Previous</button>
|
<button type="submit" name="previous" class="save btn btn-secondary" title="Click to save your answer(s) and go to the previous question">Previous</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ urlpatterns.extend(
|
|||||||
views.exam_take_fragment,
|
views.exam_take_fragment,
|
||||||
name="exam_take_fragment",
|
name="exam_take_fragment",
|
||||||
),
|
),
|
||||||
|
# Flag toggle (HTMX)
|
||||||
|
path(
|
||||||
|
"exam/<int:pk>/<int:sk>/toggle_flag",
|
||||||
|
views.exam_toggle_flag,
|
||||||
|
name="exam_toggle_flag_user",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"exam/<int:pk>/<int:sk>/<str:cid>/<str:passcode>/toggle_flag",
|
||||||
|
views.exam_toggle_flag,
|
||||||
|
name="exam_toggle_flag",
|
||||||
|
),
|
||||||
path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
|
path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
|
||||||
path(
|
path(
|
||||||
"exam/<int:pk>/<str:cid>/<str:passcode>/finish",
|
"exam/<int:pk>/<str:cid>/<str:passcode>/finish",
|
||||||
|
|||||||
+95
-1
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from generic.models import CidUser, ExamUserStatus, CidUserExam
|
from generic.models import CidUser, ExamUserStatus, CidUserExam, Flag
|
||||||
from physics.decorators import user_is_author_or_physics_checker
|
from physics.decorators import user_is_author_or_physics_checker
|
||||||
from physics.filters import QuestionFilter, UserAnswerFilter
|
from physics.filters import QuestionFilter, UserAnswerFilter
|
||||||
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
|
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
|
||||||
@@ -352,6 +352,18 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
|
|||||||
if answer is not None:
|
if answer is not None:
|
||||||
saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]
|
saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]
|
||||||
|
|
||||||
|
# compute flagged state for this question+actor
|
||||||
|
try:
|
||||||
|
ct = ContentType.objects.get_for_model(question)
|
||||||
|
flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
|
||||||
|
if cid is not None:
|
||||||
|
flags_qs = flags_qs.filter(cid_user__cid=cid)
|
||||||
|
else:
|
||||||
|
flags_qs = flags_qs.filter(user=request.user)
|
||||||
|
flagged = flags_qs.exists()
|
||||||
|
except Exception:
|
||||||
|
flagged = False
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"physics/exam_take.html",
|
"physics/exam_take.html",
|
||||||
@@ -367,6 +379,7 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
|
|||||||
"saved_answer": saved_answer,
|
"saved_answer": saved_answer,
|
||||||
"passcode": passcode,
|
"passcode": passcode,
|
||||||
"cid_user_exam": cid_user_exam,
|
"cid_user_exam": cid_user_exam,
|
||||||
|
"flagged": flagged,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -416,6 +429,18 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
|
|||||||
form = UserAnswerForm()
|
form = UserAnswerForm()
|
||||||
saved_answer = False
|
saved_answer = False
|
||||||
|
|
||||||
|
# compute flagged state for this question+actor so fragment renders correctly
|
||||||
|
try:
|
||||||
|
ct = ContentType.objects.get_for_model(question)
|
||||||
|
flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
|
||||||
|
if cid is not None:
|
||||||
|
flags_qs = flags_qs.filter(cid_user__cid=cid)
|
||||||
|
else:
|
||||||
|
flags_qs = flags_qs.filter(user=request.user)
|
||||||
|
flagged = flags_qs.exists()
|
||||||
|
except Exception:
|
||||||
|
flagged = False
|
||||||
|
|
||||||
previous = -1
|
previous = -1
|
||||||
if sk > 0:
|
if sk > 0:
|
||||||
previous = sk - 1
|
previous = sk - 1
|
||||||
@@ -436,12 +461,81 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
|
|||||||
"exam_length": exam_length,
|
"exam_length": exam_length,
|
||||||
"pos": pos,
|
"pos": pos,
|
||||||
"saved_answer": saved_answer,
|
"saved_answer": saved_answer,
|
||||||
|
"answer": answer,
|
||||||
|
"flagged": flagged,
|
||||||
"passcode": passcode,
|
"passcode": passcode,
|
||||||
"cid_user_exam": cid_user_exam,
|
"cid_user_exam": cid_user_exam,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def exam_toggle_flag(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
|
||||||
|
"""Toggle the flagged state for the current user's answer to a question.
|
||||||
|
|
||||||
|
Returns the small flag-button partial so HTMX clients can swap it in-place.
|
||||||
|
"""
|
||||||
|
exam = get_object_or_404(Exam, pk=pk)
|
||||||
|
|
||||||
|
if not exam.active:
|
||||||
|
return exam_inactive(request, context={"exam": exam})
|
||||||
|
|
||||||
|
exam.check_user_can_take(cid, passcode, request.user)
|
||||||
|
|
||||||
|
# canonical questions list
|
||||||
|
questions = list(exam.get_questions())
|
||||||
|
try:
|
||||||
|
index = int(sk)
|
||||||
|
except Exception:
|
||||||
|
raise Http404("Invalid question index")
|
||||||
|
|
||||||
|
if index < 0 or index >= len(questions):
|
||||||
|
raise Http404("Question not found in exam")
|
||||||
|
|
||||||
|
question = questions[index]
|
||||||
|
|
||||||
|
# Determine actor: either a CidUser (for cid flows) or the logged-in user
|
||||||
|
cid_user_obj = None
|
||||||
|
if cid is not None:
|
||||||
|
try:
|
||||||
|
cid_user_obj = CidUser.objects.filter(cid=cid).first()
|
||||||
|
except Exception:
|
||||||
|
cid_user_obj = None
|
||||||
|
|
||||||
|
# Find existing flag for this question+actor
|
||||||
|
ct = ContentType.objects.get_for_model(question)
|
||||||
|
flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
|
||||||
|
if cid_user_obj is not None:
|
||||||
|
flags_qs = flags_qs.filter(cid_user=cid_user_obj)
|
||||||
|
else:
|
||||||
|
flags_qs = flags_qs.filter(user=request.user)
|
||||||
|
|
||||||
|
flagged = flags_qs.exists()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
# set param explicitly controls state; otherwise toggle
|
||||||
|
set_val = request.POST.get("set")
|
||||||
|
if set_val is None:
|
||||||
|
# toggle
|
||||||
|
if flagged:
|
||||||
|
flags_qs.delete()
|
||||||
|
flagged = False
|
||||||
|
else:
|
||||||
|
Flag.objects.create(content_type=ct, object_id=question.pk, user=(None if cid_user_obj else request.user), cid_user=cid_user_obj)
|
||||||
|
flagged = True
|
||||||
|
else:
|
||||||
|
desired = str(set_val).lower() in ("1", "true", "yes")
|
||||||
|
if desired and not flagged:
|
||||||
|
Flag.objects.create(content_type=ct, object_id=question.pk, user=(None if cid_user_obj else request.user), cid_user=cid_user_obj)
|
||||||
|
flagged = True
|
||||||
|
elif not desired and flagged:
|
||||||
|
flags_qs.delete()
|
||||||
|
flagged = False
|
||||||
|
|
||||||
|
# Render the partial button for the current state
|
||||||
|
return render(request, "physics/partials/exam_flag_button.html", {"flagged": flagged, "exam": exam, "pos": index, "cid": cid, "passcode": passcode})
|
||||||
|
|
||||||
|
|
||||||
# def loadJsonAnswer(answer):
|
# def loadJsonAnswer(answer):
|
||||||
# # As access is not restricted make sure the data appears valid
|
# # As access is not restricted make sure the data appears valid
|
||||||
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
|
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
|
||||||
|
|||||||
Reference in New Issue
Block a user