diff --git a/generic/migrations/0031_flag.py b/generic/migrations/0031_flag.py
new file mode 100644
index 00000000..e2887c2d
--- /dev/null
+++ b/generic/migrations/0031_flag.py
@@ -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',),
+ },
+ ),
+ ]
diff --git a/generic/models.py b/generic/models.py
index 6b973fb2..2110b507 100644
--- a/generic/models.py
+++ b/generic/models.py
@@ -1704,6 +1704,48 @@ class CidUserExam(models.Model):
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 = (
("SBAs", "sba_cid_user_groups"),
("Physics", "physics_cid_user_groups"),
diff --git a/physics/templates/physics/partials/exam_flag_button.html b/physics/templates/physics/partials/exam_flag_button.html
new file mode 100644
index 00000000..e5d0cfec
--- /dev/null
+++ b/physics/templates/physics/partials/exam_flag_button.html
@@ -0,0 +1,17 @@
+{% comment %}A small flag/unflag button partial. Re-rendered by HTMX when toggled.{% endcomment %}
+
+ {% load static %}
+ {% if flagged %}
+
+ {% else %}
+
+ {% endif %}
+
diff --git a/physics/templates/physics/partials/exam_take_fragment.html b/physics/templates/physics/partials/exam_take_fragment.html
index bf696598..4a4286f8 100644
--- a/physics/templates/physics/partials/exam_take_fragment.html
+++ b/physics/templates/physics/partials/exam_take_fragment.html
@@ -55,6 +55,9 @@
+
+ {% include 'physics/partials/exam_flag_button.html' %}
+
{% if previous > -1 %}
{% endif %}
@@ -294,59 +297,59 @@
})();
-
-
-
-
-
-
+
-
+ bsModal.show();
+ }catch(e){ reject(e); }
+ });
+ }
+
diff --git a/physics/urls.py b/physics/urls.py
index d3355101..d848c131 100644
--- a/physics/urls.py
+++ b/physics/urls.py
@@ -35,6 +35,17 @@ urlpatterns.extend(
views.exam_take_fragment,
name="exam_take_fragment",
),
+ # Flag toggle (HTMX)
+ path(
+ "exam/
//toggle_flag",
+ views.exam_toggle_flag,
+ name="exam_toggle_flag_user",
+ ),
+ path(
+ "exam/////toggle_flag",
+ views.exam_toggle_flag,
+ name="exam_toggle_flag",
+ ),
path("exam//start", views.exam_start, name="exam_start"),
path(
"exam////finish",
diff --git a/physics/views.py b/physics/views.py
index dbcb21e7..db7fa189 100644
--- a/physics/views.py
+++ b/physics/views.py
@@ -1,6 +1,6 @@
from django.contrib.contenttypes.models import ContentType
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.filters import QuestionFilter, UserAnswerFilter
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:
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(
request,
"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,
"passcode": passcode,
"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()
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
if sk > 0:
previous = sk - 1
@@ -435,13 +460,82 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
"previous": previous,
"exam_length": exam_length,
"pos": pos,
- "saved_answer": saved_answer,
+ "saved_answer": saved_answer,
+ "answer": answer,
+ "flagged": flagged,
"passcode": passcode,
"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):
# # As access is not restricted make sure the data appears valid
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):