Add flagging functionality for exam questions with a reusable Flag model and toggle feature
This commit is contained in:
@@ -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 class="mt-2">
|
||||
<div id="flag-button-container" class="mb-2">
|
||||
{% include 'physics/partials/exam_flag_button.html' %}
|
||||
</div>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
@@ -294,59 +297,59 @@
|
||||
})();
|
||||
</script>
|
||||
<!-- Save / Skip Modal -->
|
||||
<div class="modal fade" id="saveSkipModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="saveSkipModalMessage">Are you sure?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="saveSkipModalSave" class="btn btn-primary">Save</button>
|
||||
<button type="button" id="saveSkipModalSkip" class="btn btn-secondary">Skip</button>
|
||||
<button type="button" id="saveSkipModalCancel" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
<div class="modal fade" id="saveSkipModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="saveSkipModalMessage">Are you sure?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="saveSkipModalSave" class="btn btn-primary">Save</button>
|
||||
<button type="button" id="saveSkipModalSkip" class="btn btn-secondary">Skip</button>
|
||||
<button type="button" id="saveSkipModalCancel" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script>
|
||||
// Modal helper: returns a Promise resolved with 'save'|'skip'|'cancel'
|
||||
function openSaveSkipModal(message){
|
||||
return new Promise(function(resolve, reject){
|
||||
try{
|
||||
let modalEl = document.getElementById('saveSkipModal');
|
||||
if(!modalEl) return resolve('cancel');
|
||||
modalEl.querySelector('#saveSkipModalMessage').textContent = message || '';
|
||||
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
|
||||
function openSaveSkipModal(message){
|
||||
return new Promise(function(resolve, reject){
|
||||
try{
|
||||
let modalEl = document.getElementById('saveSkipModal');
|
||||
if(!modalEl) return resolve('cancel');
|
||||
modalEl.querySelector('#saveSkipModalMessage').textContent = message || '';
|
||||
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static' });
|
||||
|
||||
function cleanup(){
|
||||
saveBtn.removeEventListener('click', onSave);
|
||||
skipBtn.removeEventListener('click', onSkip);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
modalEl.removeEventListener('hidden.bs.modal', onHidden);
|
||||
}
|
||||
function cleanup(){
|
||||
saveBtn.removeEventListener('click', onSave);
|
||||
skipBtn.removeEventListener('click', onSkip);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
modalEl.removeEventListener('hidden.bs.modal', onHidden);
|
||||
}
|
||||
|
||||
function onSave(){ cleanup(); bsModal.hide(); resolve('save'); }
|
||||
function onSkip(){ cleanup(); bsModal.hide(); resolve('skip'); }
|
||||
function onCancel(){ cleanup(); bsModal.hide(); resolve('cancel'); }
|
||||
function onHidden(){ cleanup(); resolve('cancel'); }
|
||||
function onSave(){ cleanup(); bsModal.hide(); resolve('save'); }
|
||||
function onSkip(){ cleanup(); bsModal.hide(); resolve('skip'); }
|
||||
function onCancel(){ cleanup(); bsModal.hide(); resolve('cancel'); }
|
||||
function onHidden(){ cleanup(); resolve('cancel'); }
|
||||
|
||||
const saveBtn = modalEl.querySelector('#saveSkipModalSave');
|
||||
const skipBtn = modalEl.querySelector('#saveSkipModalSkip');
|
||||
const cancelBtn = modalEl.querySelector('#saveSkipModalCancel');
|
||||
const saveBtn = modalEl.querySelector('#saveSkipModalSave');
|
||||
const skipBtn = modalEl.querySelector('#saveSkipModalSkip');
|
||||
const cancelBtn = modalEl.querySelector('#saveSkipModalCancel');
|
||||
|
||||
saveBtn.addEventListener('click', onSave);
|
||||
skipBtn.addEventListener('click', onSkip);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
modalEl.addEventListener('hidden.bs.modal', onHidden);
|
||||
saveBtn.addEventListener('click', onSave);
|
||||
skipBtn.addEventListener('click', onSkip);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
modalEl.addEventListener('hidden.bs.modal', onHidden);
|
||||
|
||||
bsModal.show();
|
||||
}catch(e){ reject(e); }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
bsModal.show();
|
||||
}catch(e){ reject(e); }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,17 @@ urlpatterns.extend(
|
||||
views.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>/<str:cid>/<str:passcode>/finish",
|
||||
|
||||
+96
-2
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user