Implement HTMX support for CID and User candidates lists, and add submitted candidates summary

This commit is contained in:
Ross
2025-12-29 11:14:15 +00:00
parent 91cf2cab29
commit 2ab85357a9
6 changed files with 213 additions and 108 deletions
+98 -97
View File
@@ -19,37 +19,31 @@
<input id="cid-filter" class="form-control form-control-sm" placeholder="Filter CID candidates by CID, name, email or passcode">
</div>
<div id="htmx-info"></div>
{% if cid_users %}
<div id="cid-list" hx-get="{% url exam.app_name|add:':exam_cids_cid_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
<ul class="list-group list-group-flush">
{% for cid in cid_users %}
<li class="list-group-item">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
></i>
<a class="fw-bold me-2 cid-link" href="{% url 'generic:update_cid' cid.pk %}">{{ cid.cid }}</a>
<span class="text-muted">[{{ cid.passcode }}]</span>
<a class="ms-3" title="View CID exam details" hx-get="{% url 'generic:cid_user_exam_partial' exam.app_name exam.id cid.cid %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-box-arrow-up-right"></i></a>
</div>
<div class="small text-muted mt-1">{{ cid.name }}</div>
<div class="small">Email: {{ cid.email }}</div>
{% if cid.notes %}
<div class="small text-muted mt-1 cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">Notes: {{ cid.notes|truncatechars:120 }}</div>
{% endif %}
</div>
</div>
</li>
{% endfor %}
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-2 me-2"></span>
<span class="placeholder col-4 me-2"></span>
<span class="placeholder col-3"></span>
</div>
</li>
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-2 me-2"></span>
<span class="placeholder col-4 me-2"></span>
<span class="placeholder col-3"></span>
</div>
</li>
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-2 me-2"></span>
<span class="placeholder col-4 me-2"></span>
<span class="placeholder col-3"></span>
</div>
</li>
</ul>
{% else %}
<p class="text-muted">This exam has no CID candidates. Add some with the edit link above.</p>
{% endif %}
</div>
</div>
</div>
</div>
@@ -61,49 +55,37 @@
<div class="mb-2">
<input id="user-filter" class="form-control form-control-sm" placeholder="Filter User candidates by username or email">
</div>
{% if user_users %}
<div id="user-list" hx-get="{% url exam.app_name|add:':exam_cids_user_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
<ul class="list-group list-group-flush">
{% for user in user_users %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
></i>
{% if request.user.is_superuser %}
<a class="me-2" href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<span class="fw-bold">{{ user.username }}</span>
</div>
<div class="small">Email: {{ user.email }}</div>
</div>
<div class="small text-muted">&nbsp;</div>
</li>
{% endfor %}
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-3 me-2"></span>
<span class="placeholder col-4"></span>
</div>
</li>
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-3 me-2"></span>
<span class="placeholder col-4"></span>
</div>
</li>
</ul>
{% else %}
<p class="text-muted">This exam has no User candidates. Add some with the edit link above.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% if user_exam_data %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Submitted candidates</h5>
<p class="small text-muted">The following candidate submissions have been received. Candidates with submissions are highlighted above.</p>
<ul id="submitted-candidates" class="list-unstyled small">
{% for user_data in user_exam_data %}
<li data-cid="{{user_data.cid_user}}" data-user="{{user_data.user_user}}">CID: {{user_data.cid_user}} / User: {{user_data.user_user}} / Start: {{user_data.start_time}} / End: {{user_data.end_time}}</li>
{% endfor %}
{% if has_submissions %}
<div id="submitted-list" hx-get="{% url exam.app_name|add:':exam_cids_submitted_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
<div class="card">
<div class="card-body">
<div class="placeholder-glow">
<h5 class="card-title placeholder col-4"></h5>
<p class="placeholder col-8"></p>
<ul class="list-unstyled small">
<li class="placeholder col-6 mb-2"></li>
<li class="placeholder col-6 mb-2"></li>
</ul>
</div>
</div>
@@ -135,23 +117,9 @@
{% block js %}
<script>
document.addEventListener('DOMContentLoaded', function(){
// Highlight users that have submitted answers
document.querySelectorAll('#submitted-candidates li').forEach(function(el){
try{
const cid = el.dataset.cid;
const user = el.dataset.user;
if(cid && cid !== 'None'){
// highlight matching cid element(s)
document.querySelectorAll(`.list-group-item`).forEach(function(li){ if(li.querySelector('.cid-link') && li.querySelector('.cid-link').textContent.trim() === cid.toString()) li.classList.add('submitted'); });
}
if(user && user !== 'None'){
document.querySelectorAll('.list-group-item').forEach(function(li){ if(li.textContent.includes(user)) li.classList.add('submitted'); });
}
}catch(e){ console.error(e); }
});
(function(){
// Utilities to run both on initial load and after HTMX swaps
// Filtering helpers
function normalizeText(s){ return (s||'').toString().toLowerCase(); }
function filterList(inputElem, listSelector){
@@ -166,27 +134,60 @@
});
}
const cidFilter = document.getElementById('cid-filter');
if(cidFilter){
cidFilter.addEventListener('input', function(){
// target CID list items only (left column)
filterList(cidFilter, '.col-lg-6:first-of-type .list-group-item');
function initTooltips(){
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el){
try{ new bootstrap.Tooltip(el); }catch(e){/* ignore if bootstrap not available */}
});
}
// enable Bootstrap tooltips for notes
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el){
try{ new bootstrap.Tooltip(el); }catch(e){/* ignore if bootstrap not available */}
function highlightSubmitted(){
document.querySelectorAll('#submitted-candidates li').forEach(function(el){
try{
const cid = el.dataset.cid;
const user = el.dataset.user;
if(cid && cid !== 'None'){
document.querySelectorAll(`.list-group-item`).forEach(function(li){
try{
const link = li.querySelector('.cid-link');
if(link && link.textContent.trim() === cid.toString()) li.classList.add('submitted');
}catch(e){}
});
}
if(user && user !== 'None'){
document.querySelectorAll('.list-group-item').forEach(function(li){ if(li.textContent.includes(user)) li.classList.add('submitted'); });
}
}catch(e){ console.error(e); }
});
}
// Wire filters
document.addEventListener('DOMContentLoaded', function(){
const cidFilter = document.getElementById('cid-filter');
if(cidFilter){ cidFilter.addEventListener('input', function(){ filterList(cidFilter, '.col-lg-6:first-of-type .list-group-item'); }); }
const userFilter = document.getElementById('user-filter');
if(userFilter){ userFilter.addEventListener('input', function(){ filterList(userFilter, '.col-lg-6:nth-of-type(2) .list-group-item, .col-lg-6:nth-of-type(2) .list-group-item.d-flex'); }); }
// initial runs (may be no partials yet)
initTooltips();
highlightSubmitted();
});
const userFilter = document.getElementById('user-filter');
if(userFilter){
userFilter.addEventListener('input', function(){
// target user list items only (right column)
filterList(userFilter, '.col-lg-6:nth-of-type(2) .list-group-item, .col-lg-6:nth-of-type(2) .list-group-item.d-flex');
});
}
});
// After HTMX swaps, re-run highlight and tooltips when our partials load
document.body.addEventListener('htmx:afterSwap', function(evt){
try{
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
if(!target) return;
const id = target.id || (target.closest && target.closest('[id]') ? target.closest('[id]').id : null);
if(id === 'cid-list' || id === 'user-list' || id === 'submitted-list'){
initTooltips();
// submitted-list may have loaded now; re-run highlighting
highlightSubmitted();
}
}catch(e){ console.error(e); }
});
})();
</script>
{% endblock %}
@@ -0,0 +1,31 @@
{% comment %} Partial: list of CID candidates for HTMX swap {% endcomment %}
{% if cid_users %}
<ul class="list-group list-group-flush">
{% for cid in cid_users %}
<li class="list-group-item">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
></i>
<a class="fw-bold me-2 cid-link" href="{% url 'generic:update_cid' cid.pk %}">{{ cid.cid }}</a>
<span class="text-muted">[{{ cid.passcode }}]</span>
<a class="ms-3" title="View CID exam details" hx-get="{% url 'generic:cid_user_exam_partial' exam.app_name exam.id cid.cid %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-box-arrow-up-right"></i></a>
</div>
<div class="small text-muted mt-1">{{ cid.name }}</div>
<div class="small">Email: {{ cid.email }}</div>
{% if cid.notes %}
<div class="small text-muted mt-1 cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">Notes: {{ cid.notes|truncatechars:120 }}</div>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">This exam has no CID candidates.</p>
{% endif %}
@@ -0,0 +1,16 @@
{% comment %} Partial: submitted candidates fragment for HTMX swap {% endcomment %}
{% if user_exam_data %}
<div class="card">
<div class="card-body">
<h5 class="card-title">Submitted candidates</h5>
<p class="small text-muted">The following candidate submissions have been received. Candidates with submissions are highlighted above.</p>
<ul id="submitted-candidates" class="list-unstyled small">
{% for user_data in user_exam_data %}
<li data-cid="{{user_data.cid_user}}" data-user="{{user_data.user_user}}">CID: {{user_data.cid_user}} / User: {{user_data.user_user}} / Start: {{user_data.start_time}} / End: {{user_data.end_time}}</li>
{% endfor %}
</ul>
</div>
</div>
{% else %}
<p class="small text-muted">No submissions yet.</p>
{% endif %}
@@ -0,0 +1,27 @@
{% comment %} Partial: list of User candidates for HTMX swap {% endcomment %}
{% if user_users %}
<ul class="list-group list-group-flush">
{% for user in user_users %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
></i>
{% if request.user.is_superuser %}
<a class="me-2" href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<span class="fw-bold">{{ user.username }}</span>
</div>
<div class="small">Email: {{ user.email }}</div>
</div>
<div class="small text-muted">&nbsp;</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">This exam has no User candidates.</p>
{% endif %}
+3
View File
@@ -523,6 +523,9 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
name="order_questions",
),
path("exam/<int:exam_id>/cids", generic_exam_view.exam_cids, name="exam_cids"),
path("exam/<int:exam_id>/cids/cid_list", generic_exam_view.exam_cids_cid_list, name="exam_cids_cid_list"),
path("exam/<int:exam_id>/cids/user_list", generic_exam_view.exam_cids_user_list, name="exam_cids_user_list"),
path("exam/<int:exam_id>/cids/submitted_list", generic_exam_view.exam_cids_submitted_list, name="exam_cids_submitted_list"),
# path("exam/<int:exam_id>/groups", generic_exam_view.exam_groups_edit, name="exam_groups_edit"),
path(
"exam/<int:exam_id>/reset_answers",
+38 -11
View File
@@ -2183,23 +2183,20 @@ class ExamViews(View, LoginRequiredMixin):
if not request.user.is_superuser:
raise PermissionDenied
cid_users = (
exam.valid_cid_users.all()
) # .order_by("cid")#.prefetch_related("*")
cid_user_count = len(cid_users)
# Avoid loading large querysets here — the detailed lists are
# rendered by HTMX partials which will fetch the data separately.
cid_user_count = exam.valid_cid_users.count()
user_user_count = exam.valid_user_users.count()
user_users = exam.valid_user_users.all() # .order_by("username")
user_user_count = len(user_users)
user_exam_data = exam.cid_users.all().prefetch_related("cid_user", "user_user")
# Provide a lightweight indicator if there are any submissions so the
# template can decide whether to load the submitted-list partial.
has_submissions = exam.cid_users.exists()
context = {
"exam": exam,
"cid_users": cid_users,
"cid_user_count": cid_user_count,
"user_exam_data": user_exam_data,
"user_users": user_users,
"user_user_count": user_user_count,
"has_submissions": has_submissions,
"app_name": self.app_name,
}
@@ -2208,6 +2205,36 @@ class ExamViews(View, LoginRequiredMixin):
return render(request, "exam_cids.html", context)
def exam_cids_cid_list(self, request, exam_id):
"""HTMX partial returning the CID candidates list for an exam."""
exam = get_object_or_404(self.Exam, pk=exam_id)
if request.user not in exam.author.all() and not request.user.is_superuser:
raise PermissionDenied
cid_users = exam.valid_cid_users.all()
return render(request, "generic/partials/exam_cids_cid_list.html", {"cid_users": cid_users, "exam": exam})
def exam_cids_user_list(self, request, exam_id):
"""HTMX partial returning the User candidates list for an exam."""
exam = get_object_or_404(self.Exam, pk=exam_id)
if request.user not in exam.author.all() and not request.user.is_superuser:
raise PermissionDenied
user_users = exam.valid_user_users.all()
return render(request, "generic/partials/exam_cids_user_list.html", {"user_users": user_users, "exam": exam})
def exam_cids_submitted_list(self, request, exam_id):
"""HTMX partial returning the submitted candidates summary."""
exam = get_object_or_404(self.Exam, pk=exam_id)
if request.user not in exam.author.all() and not request.user.is_superuser:
raise PermissionDenied
user_exam_data = exam.cid_users.all().prefetch_related("cid_user", "user_user")
return render(request, "generic/partials/exam_cids_submitted_list.html", {"user_exam_data": user_exam_data, "exam": exam})
# def exam_groups_edit(self, request, exam_id):
# exam = get_object_or_404(self.Exam, pk=exam_id)