Implement HTMX support for CID and User candidates lists, and add submitted candidates summary
This commit is contained in:
@@ -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"> </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"> </div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">This exam has no User candidates.</p>
|
||||
{% endif %}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user