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">
|
<input id="cid-filter" class="form-control form-control-sm" placeholder="Filter CID candidates by CID, name, email or passcode">
|
||||||
</div>
|
</div>
|
||||||
<div id="htmx-info"></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">
|
<ul class="list-group list-group-flush">
|
||||||
{% for cid in cid_users %}
|
<li class="list-group-item">
|
||||||
<li class="list-group-item">
|
<div class="d-flex align-items-center placeholder-glow">
|
||||||
<div class="d-flex align-items-start justify-content-between">
|
<span class="placeholder col-2 me-2"></span>
|
||||||
<div>
|
<span class="placeholder col-4 me-2"></span>
|
||||||
<div class="d-flex align-items-center">
|
<span class="placeholder col-3"></span>
|
||||||
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
|
</div>
|
||||||
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
|
</li>
|
||||||
hx-target=".modal-content"
|
<li class="list-group-item">
|
||||||
data-bs-toggle="modal"
|
<div class="d-flex align-items-center placeholder-glow">
|
||||||
data-bs-target="#history-modal"
|
<span class="placeholder col-2 me-2"></span>
|
||||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
<span class="placeholder col-4 me-2"></span>
|
||||||
></i>
|
<span class="placeholder col-3"></span>
|
||||||
<a class="fw-bold me-2 cid-link" href="{% url 'generic:update_cid' cid.pk %}">{{ cid.cid }}</a>
|
</div>
|
||||||
<span class="text-muted">[{{ cid.passcode }}]</span>
|
</li>
|
||||||
<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>
|
<li class="list-group-item">
|
||||||
</div>
|
<div class="d-flex align-items-center placeholder-glow">
|
||||||
<div class="small text-muted mt-1">{{ cid.name }}</div>
|
<span class="placeholder col-2 me-2"></span>
|
||||||
<div class="small">Email: {{ cid.email }}</div>
|
<span class="placeholder col-4 me-2"></span>
|
||||||
{% if cid.notes %}
|
<span class="placeholder col-3"></span>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
</li>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
</div>
|
||||||
<p class="text-muted">This exam has no CID candidates. Add some with the edit link above.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,49 +55,37 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<input id="user-filter" class="form-control form-control-sm" placeholder="Filter User candidates by username or email">
|
<input id="user-filter" class="form-control form-control-sm" placeholder="Filter User candidates by username or email">
|
||||||
</div>
|
</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">
|
<ul class="list-group list-group-flush">
|
||||||
{% for user in user_users %}
|
<li class="list-group-item">
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
<div class="d-flex align-items-center placeholder-glow">
|
||||||
<div>
|
<span class="placeholder col-3 me-2"></span>
|
||||||
<div class="d-flex align-items-center">
|
<span class="placeholder col-4"></span>
|
||||||
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
|
</div>
|
||||||
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
|
</li>
|
||||||
hx-target=".modal-content"
|
<li class="list-group-item">
|
||||||
data-bs-toggle="modal"
|
<div class="d-flex align-items-center placeholder-glow">
|
||||||
data-bs-target="#history-modal"
|
<span class="placeholder col-3 me-2"></span>
|
||||||
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
|
<span class="placeholder col-4"></span>
|
||||||
></i>
|
</div>
|
||||||
{% if request.user.is_superuser %}
|
</li>
|
||||||
<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>
|
</ul>
|
||||||
{% else %}
|
</div>
|
||||||
<p class="text-muted">This exam has no User candidates. Add some with the edit link above.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if user_exam_data %}
|
{% if has_submissions %}
|
||||||
<div class="row">
|
<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="col-12">
|
<div class="card">
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<div class="placeholder-glow">
|
||||||
<h5 class="card-title">Submitted candidates</h5>
|
<h5 class="card-title placeholder col-4"></h5>
|
||||||
<p class="small text-muted">The following candidate submissions have been received. Candidates with submissions are highlighted above.</p>
|
<p class="placeholder col-8"></p>
|
||||||
<ul id="submitted-candidates" class="list-unstyled small">
|
<ul class="list-unstyled small">
|
||||||
{% for user_data in user_exam_data %}
|
<li class="placeholder col-6 mb-2"></li>
|
||||||
<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>
|
<li class="placeholder col-6 mb-2"></li>
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,23 +117,9 @@
|
|||||||
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function(){
|
(function(){
|
||||||
// Highlight users that have submitted answers
|
// Utilities to run both on initial load and after HTMX swaps
|
||||||
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); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filtering helpers
|
|
||||||
function normalizeText(s){ return (s||'').toString().toLowerCase(); }
|
function normalizeText(s){ return (s||'').toString().toLowerCase(); }
|
||||||
|
|
||||||
function filterList(inputElem, listSelector){
|
function filterList(inputElem, listSelector){
|
||||||
@@ -166,27 +134,60 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const cidFilter = document.getElementById('cid-filter');
|
function initTooltips(){
|
||||||
if(cidFilter){
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el){
|
||||||
cidFilter.addEventListener('input', function(){
|
try{ new bootstrap.Tooltip(el); }catch(e){/* ignore if bootstrap not available */}
|
||||||
// target CID list items only (left column)
|
|
||||||
filterList(cidFilter, '.col-lg-6:first-of-type .list-group-item');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// enable Bootstrap tooltips for notes
|
function highlightSubmitted(){
|
||||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el){
|
document.querySelectorAll('#submitted-candidates li').forEach(function(el){
|
||||||
try{ new bootstrap.Tooltip(el); }catch(e){/* ignore if bootstrap not available */}
|
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');
|
// After HTMX swaps, re-run highlight and tooltips when our partials load
|
||||||
if(userFilter){
|
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||||
userFilter.addEventListener('input', function(){
|
try{
|
||||||
// target user list items only (right column)
|
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
|
||||||
filterList(userFilter, '.col-lg-6:nth-of-type(2) .list-group-item, .col-lg-6:nth-of-type(2) .list-group-item.d-flex');
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% 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",
|
name="order_questions",
|
||||||
),
|
),
|
||||||
path("exam/<int:exam_id>/cids", generic_exam_view.exam_cids, name="exam_cids"),
|
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>/groups", generic_exam_view.exam_groups_edit, name="exam_groups_edit"),
|
||||||
path(
|
path(
|
||||||
"exam/<int:exam_id>/reset_answers",
|
"exam/<int:exam_id>/reset_answers",
|
||||||
|
|||||||
+38
-11
@@ -2183,23 +2183,20 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
if not request.user.is_superuser:
|
if not request.user.is_superuser:
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
cid_users = (
|
# Avoid loading large querysets here — the detailed lists are
|
||||||
exam.valid_cid_users.all()
|
# rendered by HTMX partials which will fetch the data separately.
|
||||||
) # .order_by("cid")#.prefetch_related("*")
|
cid_user_count = exam.valid_cid_users.count()
|
||||||
cid_user_count = len(cid_users)
|
user_user_count = exam.valid_user_users.count()
|
||||||
|
|
||||||
user_users = exam.valid_user_users.all() # .order_by("username")
|
# Provide a lightweight indicator if there are any submissions so the
|
||||||
user_user_count = len(user_users)
|
# template can decide whether to load the submitted-list partial.
|
||||||
|
has_submissions = exam.cid_users.exists()
|
||||||
user_exam_data = exam.cid_users.all().prefetch_related("cid_user", "user_user")
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"exam": exam,
|
"exam": exam,
|
||||||
"cid_users": cid_users,
|
|
||||||
"cid_user_count": cid_user_count,
|
"cid_user_count": cid_user_count,
|
||||||
"user_exam_data": user_exam_data,
|
|
||||||
"user_users": user_users,
|
|
||||||
"user_user_count": user_user_count,
|
"user_user_count": user_user_count,
|
||||||
|
"has_submissions": has_submissions,
|
||||||
"app_name": self.app_name,
|
"app_name": self.app_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2208,6 +2205,36 @@ class ExamViews(View, LoginRequiredMixin):
|
|||||||
|
|
||||||
return render(request, "exam_cids.html", context)
|
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):
|
# def exam_groups_edit(self, request, exam_id):
|
||||||
# exam = get_object_or_404(self.Exam, pk=exam_id)
|
# exam = get_object_or_404(self.Exam, pk=exam_id)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user