Refactor CID group view: enhance user display with improved layout, add help section, and implement user sorting functionality
This commit is contained in:
@@ -1,120 +1,135 @@
|
|||||||
{% extends 'generic/cid_group_base.html' %}
|
{% extends 'generic/cid_group_base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Group: {{group.name}}</h2>
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
<details>
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<summary>Help</summary>
|
|
||||||
<p>This page shows an overview of the candidates and exams that are associated with the group.</p>
|
|
||||||
|
|
||||||
<p>Once a candidate has been added to a group and the group has been added to an exam individual candidates can be allocated to the exam. Please note: simply adding the group to the exam will not automatically give the group candidates access to that exam, they will have to be added individually/as a group.</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<h3>Users</h3>
|
|
||||||
<p title="The number of users that have been added to this group">Count: {{users|length}}</p>
|
|
||||||
|
|
||||||
{% if users %}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
|
|
||||||
{% if group_type == "cid" %}
|
|
||||||
<tr><th>Name</th><th>Email</th><th>CID</th><th>Passcode</th></tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><th>User</th></tr>
|
|
||||||
{% endif %}
|
|
||||||
</thead>
|
|
||||||
{% for user in users %}
|
|
||||||
{% if group_type == "cid" %}
|
|
||||||
<tr><td>{{user.name}}</td><td>{{user.email}}</td><td>{{user.cid}}</td><td>{{user.passcode}}</td></tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><td>{{user.username}}</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
This group has no users.
|
|
||||||
|
|
||||||
{% if group_type == "cid" %}
|
|
||||||
They can be created (or added) <a href="{% url 'generic:manage_cids' %}">here</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="border border-secondary">
|
|
||||||
<details>
|
|
||||||
<summary >Add users by email</summary>
|
|
||||||
<div>
|
<div>
|
||||||
<form method="post">
|
<h2 class="card-title mb-1">Group: {{ group.name }}</h2>
|
||||||
{% csrf_token %}
|
<div class="small text-muted">This page shows an overview of the candidates and exams associated with the group.</div>
|
||||||
<textarea name="emails" placeholder="Email addresses (one per line)"></textarea>
|
</div>
|
||||||
<button type="submit"
|
<div class="text-end">
|
||||||
hx-post="{% url 'generic:user_group_add_by_email_user' group.pk %}"
|
<div class="small text-muted">Users in group</div>
|
||||||
hx-target="#add-users-by-email-results"
|
<div class="fw-bold">{{ users|length }}</div>
|
||||||
>Add</button>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
<div id="add-users-by-email-results"></div>
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<details>
|
||||||
|
<summary class="small">Help</summary>
|
||||||
|
<div class="small text-muted mt-2">
|
||||||
|
Once a candidate has been added to a group and the group has been added to an exam individual candidates can be allocated to the exam. Adding a group to an exam does not automatically give group members access — they must be added.
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Exams</h3>
|
<div class="card mb-3">
|
||||||
The group is currently associated with the following exams. This allows the users within the group to be easily added to the exam. It will not automatically give members of the group access to the exam.
|
<div class="card-body">
|
||||||
|
<h3 class="card-title mb-2">Users</h3>
|
||||||
|
{% if users %}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for user in users %}
|
||||||
|
<li class="list-group-item bg-transparent border-0 py-2">
|
||||||
|
{% if group_type == "cid" %}
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold">{{ user.name }}</div>
|
||||||
|
<div class="small text-muted">{{ user.email }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto text-end small text-muted">
|
||||||
|
<div>CID: {{ user.cid }}</div>
|
||||||
|
<div>Passcode: {{ user.passcode }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="fw-bold">{{ user.username }}</div>
|
||||||
|
<div class="ms-2 small text-muted">{{ user.email }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="small text-muted">This group has no users.
|
||||||
|
{% if group_type == "cid" %}
|
||||||
|
You can create or add users <a href="{% url 'generic:manage_cids' %}">here</a>.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title mb-2">Add users by email</h4>
|
||||||
|
<form method="post" class="mb-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<textarea name="emails" placeholder="Email addresses (one per line)" class="form-control form-control-sm" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary"
|
||||||
|
hx-post="{% url 'generic:user_group_add_by_email_user' group.pk %}"
|
||||||
|
hx-target="#add-users-by-email-results">Add</button>
|
||||||
|
</form>
|
||||||
|
<div id="add-users-by-email-results" class="small text-muted"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title mb-2">Exams</h3>
|
||||||
|
<div class="small text-muted mb-2">The group is associated with these exams. Adding the group to an exam helps add members quickly but does not grant automatic access.</div>
|
||||||
|
<div class="mb-2">
|
||||||
{% if group_type == "cid" %}
|
{% if group_type == "cid" %}
|
||||||
Add more <a href='{% url "generic:cid_group_exams" group.pk %}'>here</a>.
|
Add more <a href='{% url "generic:cid_group_exams" group.pk %}'>here</a>.
|
||||||
{% else %}
|
{% else %}
|
||||||
Add more <a href='{% url "generic:user_group_exams" group.pk %}'>here</a>.
|
Add more <a href='{% url "generic:user_group_exams" group.pk %}'>here</a>.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p>It is possible to quickly add or remove all group candidates from an exam by clicking the buttons below. This will only affect the <b>{{users|length}}</b> candidates in this group. You will need to refresh the page to see updated candidate counts.</p>
|
</div>
|
||||||
|
|
||||||
{% with group.GetGroupExams as exam_map %}
|
{% with group.GetGroupExams as exam_map %}
|
||||||
{% for key, value in exam_map.items %}
|
{% for key, value in exam_map.items %}
|
||||||
<h4>{{key}}</h4>
|
<h5 class="mt-3">{{ key }}</h5>
|
||||||
<div>
|
<div class="list-group list-group-flush">
|
||||||
{% for exam in value %}
|
{% for exam in value %}
|
||||||
<div class="d-flex">
|
<div class="list-group-item bg-transparent border-0 py-2 d-flex align-items-center">
|
||||||
<a href="{{exam.get_absolute_url}}" class="px-2">{{exam}}</a>
|
<div>
|
||||||
|
<a href="{{ exam.get_absolute_url }}" class="fw-bold">{{ exam }}</a>
|
||||||
|
<div class="small text-muted">{{ exam }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ms-3">
|
||||||
{% if group_type == "cid" %}
|
{% if group_type == "cid" %}
|
||||||
<a href="{{exam.get_cid_edit_url}}" class="edit-link" px-2>Edit candidates</a>
|
<a href="{{ exam.get_cid_edit_url }}" class="edit-link small">Edit candidates</a>
|
||||||
<form>
|
{% else %}
|
||||||
|
<a href="{{ exam.get_user_edit_url }}" class="edit-link small">Edit candidates</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ms-auto">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
{% if group_type == "cid" %}
|
||||||
|
<form class="m-0 d-inline">
|
||||||
<input type="hidden" name="exam_id" value="{{ exam.pk }}">
|
<input type="hidden" name="exam_id" value="{{ exam.pk }}">
|
||||||
<input type="hidden" name="exam_type" value="{{ exam.app_name }}">
|
<input type="hidden" name="exam_type" value="{{ exam.app_name }}">
|
||||||
<button
|
<button class="btn btn-sm btn-outline-success" hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}" name="action" value="add">Add</button>
|
||||||
class="btn btn-sm px-2"
|
<button class="btn btn-sm btn-outline-danger" hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}" name="action" value="remove">Remove</button>
|
||||||
hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}"
|
|
||||||
title="Add all group candidates to this exam"
|
|
||||||
name="action"
|
|
||||||
value="add"
|
|
||||||
>Add group candidates</button>
|
|
||||||
<button
|
|
||||||
hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}"
|
|
||||||
title="Remove all group candidates to this exam"
|
|
||||||
class="btn btn-sm px-2"
|
|
||||||
name="action"
|
|
||||||
value="remove"
|
|
||||||
>Remove group candidates</button>
|
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{exam.get_user_edit_url}}" class="edit-link px-2">Edit candidates</a>
|
<form class="m-0 d-inline">
|
||||||
<form>
|
|
||||||
<input type="hidden" name="exam_id" value="{{ exam.pk }}">
|
<input type="hidden" name="exam_id" value="{{ exam.pk }}">
|
||||||
<input type="hidden" name="exam_type" value="{{ exam.app_name }}">
|
<input type="hidden" name="exam_type" value="{{ exam.app_name }}">
|
||||||
<button
|
<button class="btn btn-sm btn-outline-success" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" name="action" value="add">Add</button>
|
||||||
class="btn btn-sm px-2"
|
<button class="btn btn-sm btn-outline-danger" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" name="action" value="remove">Remove</button>
|
||||||
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
|
|
||||||
title="Add all group candidates to this exam"
|
|
||||||
name="action"
|
|
||||||
value="add"
|
|
||||||
>Add group candidates</button>
|
|
||||||
<button
|
|
||||||
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
|
|
||||||
title="Remove all group candidates to this exam"
|
|
||||||
class="btn btn-sm px-2"
|
|
||||||
name="action"
|
|
||||||
value="remove"
|
|
||||||
>Remove group candidates</button>
|
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-3 ms-2 small text-muted">
|
||||||
<div class="px-2 ms-auto ">
|
|
||||||
{% if group_type == "cid" %}
|
{% if group_type == "cid" %}
|
||||||
(Cid candidate count: {{ exam.valid_cid_users.count }})
|
(Cid candidate count: {{ exam.valid_cid_users.count }})
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -126,17 +141,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<style>
|
<style>
|
||||||
td, th { padding-left: 10px }
|
.edit-link { font-size: 0.85rem; opacity: 0.7; }
|
||||||
|
.list-group-item .fw-bold { font-size: 0.95rem; }
|
||||||
.edit-link {
|
.card-title { font-size: 1rem; }
|
||||||
font-size: smaller;
|
/* keep existing small visual tweaks */
|
||||||
opacity: 60%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -23,6 +23,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="me-auto small text-muted">Sort by:</div>
|
||||||
|
<div class="btn-group btn-group-sm user-sort-group" role="group" aria-label="Sort">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="cid">Username <span class="sort-caret"></span></button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% include 'generic/partials/user_user_list.html' with exam=exam current_user_users=current_user_users available_user_users=available_user_users %}
|
{% include 'generic/partials/user_user_list.html' with exam=exam current_user_users=current_user_users available_user_users=available_user_users %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +81,33 @@
|
|||||||
// Delegated click handler for per-item toggle buttons (HTMX preferred)
|
// Delegated click handler for per-item toggle buttons (HTMX preferred)
|
||||||
document.body.addEventListener('click', function(e){ const btn = e.target.closest && e.target.closest('.toggle-btn'); if(!btn) return; const form = btn.closest('form'); if(!form) return; e.preventDefault(); setLoading(btn, true); if(window.htmx && typeof htmx.submit === 'function'){ htmx.submit(form); return; } (async function(){ try{ const url = form.action || form.getAttribute('hx-post'); const fd = new FormData(form); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value; const headers = { 'HX-Request': 'true' }; if(csrf) headers['X-CSRFToken'] = csrf; const resp = await fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' }); const text = await resp.text(); try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newLi = doc.querySelector('li'); const oldLi = form.closest('li'); if(newLi && oldLi){ oldLi.replaceWith(newLi); } const trigger = resp.headers.get('HX-Trigger'); if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} } }catch(err){ console.error('toggle parse/swap failed', err); } }catch(err){ console.error('toggle request failed', err); } finally{ setLoading(btn, false); } })(); });
|
document.body.addEventListener('click', function(e){ const btn = e.target.closest && e.target.closest('.toggle-btn'); if(!btn) return; const form = btn.closest('form'); if(!form) return; e.preventDefault(); setLoading(btn, true); if(window.htmx && typeof htmx.submit === 'function'){ htmx.submit(form); return; } (async function(){ try{ const url = form.action || form.getAttribute('hx-post'); const fd = new FormData(form); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value; const headers = { 'HX-Request': 'true' }; if(csrf) headers['X-CSRFToken'] = csrf; const resp = await fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' }); const text = await resp.text(); try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newLi = doc.querySelector('li'); const oldLi = form.closest('li'); if(newLi && oldLi){ oldLi.replaceWith(newLi); } const trigger = resp.headers.get('HX-Trigger'); if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} } }catch(err){ console.error('toggle parse/swap failed', err); } }catch(err){ console.error('toggle request failed', err); } finally{ setLoading(btn, false); } })(); });
|
||||||
|
|
||||||
|
// Sorting for user list (scoped to this card)
|
||||||
|
(function(){
|
||||||
|
const sortGroup = document.querySelector('.user-sort-group');
|
||||||
|
if(!sortGroup) return;
|
||||||
|
let userSort = { key: 'cid', dir: 'asc' };
|
||||||
|
function updateUserCarets(){
|
||||||
|
const buttons = sortGroup.querySelectorAll('.sort-btn');
|
||||||
|
buttons.forEach(function(b){ const key = b.dataset.key; const caret = b.querySelector('.sort-caret'); if(caret) caret.textContent = (userSort.key === key) ? (userSort.dir === 'asc' ? ' ▲' : ' ▼') : ''; });
|
||||||
|
}
|
||||||
|
function getUserValue(li, key){
|
||||||
|
if(!li) return '';
|
||||||
|
if(key === 'cid') return (li.dataset.username || '').toString().toLowerCase();
|
||||||
|
return (li.dataset[key] || '').toString().toLowerCase();
|
||||||
|
}
|
||||||
|
function sortUserList(key){
|
||||||
|
const ul = document.getElementById('user-users-list'); if(!ul) return;
|
||||||
|
const items = Array.from(ul.children);
|
||||||
|
const dir = (userSort.key === key && userSort.dir === 'asc') ? 'desc' : 'asc';
|
||||||
|
items.sort(function(a,b){ const va = getUserValue(a,key); const vb = getUserValue(b,key); if(va < vb) return dir === 'asc' ? -1 : 1; if(va > vb) return dir === 'asc' ? 1 : -1; return 0; });
|
||||||
|
items.forEach(function(it){ ul.appendChild(it); });
|
||||||
|
userSort = { key: key, dir: dir };
|
||||||
|
updateUserCarets();
|
||||||
|
}
|
||||||
|
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortUserList(b.dataset.key); }); });
|
||||||
|
updateUserCarets();
|
||||||
|
})();
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user