Compare commits

...
90 Commits
Author SHA1 Message Date
Ross 9b6fd31cee Merge branch 'part12026live' into master 2026-01-05 20:55:29 +00:00
Ross 8b7da1554a Refactor exam question fragment to reposition flag button within the button row for improved layout and accessibility during HTMX updates. 2026-01-05 14:53:03 +00:00
Ross d4247adb13 Enhance loading indicators for question fragment during HTMX requests; ensure overview tile text color remains unchanged for flagged questions 2026-01-05 14:50:07 +00:00
Ross b89ca918fa Add loading spinner and blur effect for question fragment during HTMX requests; optimize answered and flagged state queries 2026-01-05 14:48:21 +00:00
Ross 569ddb4343 Enhance question navigation by updating local state on button clicks and ensuring DOM updates after HTMX settle 2026-01-05 14:41:48 +00:00
Ross b040dfcd82 Enhance question menu update logic by adding flag icon management and logging for better debugging 2026-01-05 14:38:03 +00:00
Ross db51ef23be Refactor exam question fragment to move flag button inside the fragment for HTMX updates and enhance local state management for answered and flagged questions. 2026-01-05 14:30:55 +00:00
Ross 8c796c3048 Enhance question menu state management by adding a centralized updater for answered and flagged questions 2026-01-05 14:24:59 +00:00
Ross b837c406cf Refactor exam overview template for improved readability and consistency in HTML structure 2026-01-05 14:22:33 +00:00
Ross 42d209af5a Expose current question index for global listeners and update browser URL to reflect the current question position 2026-01-05 14:19:01 +00:00
Ross 3d1b9a57f9 Enhance exam question fragment by exposing current question index for global listeners and updating question list state locally to reflect answered questions 2026-01-05 14:16:02 +00:00
Ross 8d638cc08b Enhance exam overview and fragment by adding question status indicators for answered and flagged questions 2026-01-05 14:13:52 +00:00
Ross 20a06323d5 Enhance navigation after save by improving URL handling and fallback mechanisms for question transitions 2026-01-05 14:04:30 +00:00
Ross 3928df6d75 Refactor exam question display to include flag button and enhance layout for better usability 2026-01-05 14:01:27 +00:00
Ross bf344f20e1 Add flagging functionality for exam questions with a reusable Flag model and toggle feature 2026-01-05 13:47:45 +00:00
Ross e4cfb1f782 Enhance exam navigation by adding a confirmation modal for unsaved changes when skipping questions. 2026-01-05 12:32:14 +00:00
Ross f44f3bbcfe Enhance exam navigation by adding a skip button for non-saving question transitions and implementing unsaved changes confirmation. 2026-01-05 11:59:23 +00:00
Ross ba0c380f18 Enhance candidate lists by restructuring HTML for improved readability and maintainability. 2026-01-05 11:55:03 +00:00
Ross 459b7f9b93 Enhance candidate lists by adding badge containers for improved visual feedback on submission status. 2026-01-05 11:52:58 +00:00
Ross 378d784435 Enhance submitted candidates display by adding dynamic exam status indicators and improving badge tooltip functionality for better user feedback. 2026-01-05 11:30:53 +00:00
Ross 96e88c05a4 Enhance submission tracking by adding first and last name data attributes for candidates, improving badge tooltip information in the submitted candidates list. 2026-01-05 11:25:10 +00:00
Ross 457fb06dc0 Enhance highlightSubmitted function to add badges for submitted CIDs and users, improving visual feedback in the candidate list. 2026-01-05 11:05:26 +00:00
Ross debe093bc9 Add exam_mode parameter to exam take URL for improved functionality 2026-01-05 10:59:40 +00:00
Ross 086bd8f589 Add permission check for user marker access in mark2 overview 2026-01-05 10:18:41 +00:00
Ross 71dc50b307 Enhance CID and User candidate filtering with clear buttons and indicators for improved usability 2026-01-05 09:51:54 +00:00
Ross c0c12ea9f9 revert last commit 2026-01-04 13:19:36 +00:00
Ross 429adfc376 Optimize exam retrieval by prefetching related data to reduce N+1 queries and enhance access checks for user permissions 2026-01-04 13:15:15 +00:00
Ross 36a809ea40 Update type hints for cid and passcode parameters in ExamViews for improved clarity 2026-01-04 13:11:02 +00:00
Ross 443d93c35f Enhance user marking query to ensure distinct exams and order by name for better organization 2026-01-04 13:02:59 +00:00
Ross d7682ab31d Update exam links in user marking list to use dynamic URL generation based on exam type 2026-01-04 13:01:42 +00:00
Ross e600b8eb91 Fix count annotations in exam collection to use distinct values for accurate user counts 2026-01-04 12:55:53 +00:00
Ross 3e691d774c Refactor exam list annotation to use unfiltered counts for accurate overview matching 2026-01-04 12:50:47 +00:00
Ross c3a70b079d Refactor HTML structure for user marking templates to improve readability and maintainability 2026-01-04 12:40:56 +00:00
Ross 4ffadd3914 add user marknig pages 2026-01-04 12:39:33 +00:00
Ross fa18c33e8a Optimize exam list retrieval by consolidating queries and annotating candidate counts for improved performance 2025-12-29 12:55:15 +00:00
Ross 057c391995 Refactor exam status components to use consistent class names and improve HX target selectors for better maintainability 2025-12-29 12:50:02 +00:00
Ross 94fbd38634 Enhance exam status management with HTMX loading indicators and streamline AJAX handling by removing legacy jQuery code 2025-12-29 12:44:42 +00:00
Ross 4ecc32f837 Implement HTMX lazy-loading for exam collections and optimize queryset annotations for performance 2025-12-29 12:26:56 +00:00
Ross 768e1bf8c5 Enhance ExamCollectionDetail queryset to prefetch related exams and annotate candidate counts for improved performance in templates 2025-12-29 12:18:31 +00:00
Ross 32c4305754 Fix indentation and formatting in exam order editing functionality for improved readability 2025-12-29 12:15:18 +00:00
Ross 465f819973 Improve error handling in state change failure response for better user feedback 2025-12-29 12:15:06 +00:00
Ross 3e1cb4b54f Optimize question loading and user answer retrieval in ExamViews to reduce database queries 2025-12-29 12:09:18 +00:00
Ross b9d86a7639 Remove debug print statement for answers_marks in ExamViews 2025-12-29 12:05:29 +00:00
Ross e5fd99f491 Remove unused exam scores refresh function and related logic for CID user exam scores 2025-12-29 12:04:48 +00:00
Ross efeed2a8b1 Refactor stats and answers display for improved layout and readability 2025-12-29 11:41:16 +00:00
Ross f14ede86d3 Refactor notes display logic in CID user list for improved readability 2025-12-29 11:24:18 +00:00
Ross 517c2ebe06 Add notes display with tooltip support in CID user list 2025-12-29 11:23:52 +00:00
Ross c5f9b830eb Refactor submitted candidates display to use a table format with action buttons and add submission count 2025-12-29 11:18:10 +00:00
Ross 2ab85357a9 Implement HTMX support for CID and User candidates lists, and add submitted candidates summary 2025-12-29 11:14:15 +00:00
Ross 91cf2cab29 Update exam time limit display to show '1Q' for time per question 2025-12-29 10:50:53 +00:00
Ross 34c4c0d830 Add notes display and tooltip support in exam CIDs template 2025-12-29 10:25:27 +00:00
Ross 98cef9ea57 Add notes field to CidUser model and update related forms and templates 2025-12-29 10:17:54 +00:00
Ross df0b26de36 Add user group archiving functionality with HTMX support 2025-12-29 09:53:57 +00:00
Ross 17330681a2 Refactor ExamCollection views to enforce author requirements and improve access control 2025-12-29 09:44:29 +00:00
Ross 112c96dc00 Restrict ExamCollectionList to authenticated users and filter by current user as author 2025-12-29 09:41:45 +00:00
Ross 0bea34d406 Add author requirement to ExamCollectionList view 2025-12-29 09:35:55 +00:00
Ross 06ceb9c2ce Add manual submission flag to CidUserExam and improve logging for exam completion 2025-12-22 12:07:25 +00:00
Ross 8ac292eb0c Add ExamUserStatus logging for exam completion and start events 2025-12-22 11:35:09 +00:00
Ross ff6b8a8a48 Add filtering functionality for CID and User candidates in exam view 2025-12-22 11:23:59 +00:00
Ross 46313cf5bb Refactor exam candidates view for improved layout and user experience 2025-12-22 11:20:39 +00:00
Ross 13a23eee12 Refactor CID candidate list for improved readability and maintainability 2025-12-22 11:14:00 +00:00
Ross a1bc96f99a Add CidUserExam management features: forms, views, and templates for exam records 2025-12-22 11:13:29 +00:00
Ross 9fd5e56356 Enhance exam completion confirmation messages for clarity and user assurance 2025-12-22 10:46:03 +00:00
Ross a02f274a4e Enhance user experience with visual indicators for HTMX actions and streamline button interactions in CID group view 2025-12-22 10:44:50 +00:00
Ross d3d0e865c3 Add conditional preview generation for CID group types in user details 2025-12-22 10:38:45 +00:00
Ross 7163e51335 Add modal for group users table and CSV export functionality 2025-12-22 10:29:47 +00:00
Ross 7b47a75657 Add email preview functionality for candidate details: allow users to preview email content before sending 2025-12-22 10:26:22 +00:00
Ross 54e1cf4ce0 Improve permissions handling in local-up script: only change log and Loki data directory permissions if necessary 2025-12-22 10:06:54 +00:00
Ross c071f38920 Implement custom logging email backend for development: log outgoing emails to Loguru and stdout 2025-12-15 13:14:12 +00:00
Ross 03b23b9178 Configure email backend for local development and update NGINX HTTPS port in environment settings 2025-12-15 12:44:57 +00:00
Ross 00b736d8d6 Implement bulk user management features: add user generation preview and bulk creation functionality in CID group view 2025-12-15 12:23:26 +00:00
Ross 885ab1aad4 Enhance user sorting and email resend functionality in CID group view: add sorting options for user list and improve email resend handling in candidate details view 2025-12-15 12:15:42 +00:00
Ross 77edc17732 Refactor CID group view: enhance user display with improved layout, add help section, and implement user sorting functionality 2025-12-15 12:09:51 +00:00
Ross d98305caff Refactor CID user group form: enhance layout with Bootstrap styling, improve error handling, and update user list presentation 2025-12-15 12:04:42 +00:00
Ross 12bd66d4d8 Refactor bulk user management: update HTMX target attributes to replace wrappers for improved rendering of user lists 2025-12-15 12:01:42 +00:00
Ross 508f8a22a2 Implement bulk user management for exams: add user bulk edit endpoint, update templates for user list rendering, and enhance toggle functionality with HTMX support 2025-12-15 12:01:01 +00:00
Ross 158db133b3 Refactor CID user list rendering: consolidate user list into a single template and enhance toggle feedback with count updates 2025-12-15 11:54:21 +00:00
Ross 83a009e31b Refactor HTMX response handling in ExamViews: streamline rendering of updated CID user list items and ensure consistent response structure 2025-12-15 11:51:49 +00:00
Ross 00dfaf9888 Add bulk edit functionality for CID users: implement endpoint and update templates for bulk add/remove actions 2025-12-15 11:49:06 +00:00
Ross 7e4e66a74f Refactor exam candidate edit script: improve user feedback on add/remove actions and streamline sorting logic 2025-12-15 11:22:47 +00:00
Ross 7a4cf3627d Refactor CID users display: enhance layout with card structure, add sorting functionality, and improve toggle button interactions 2025-12-15 11:21:44 +00:00
Ross 4db1f2ca1a Add toggle functionality for pre-review flag on case resources 2025-12-15 11:15:56 +00:00
Ross cbff1317d0 Enhance resource display: conditionally render resource search panel and update pre/post review badge logic 2025-12-15 10:53:21 +00:00
Ross 0644ed99f4 Refactor resource search panel: simplify input trigger and improve layout for search results 2025-12-15 10:47:42 +00:00
Ross c01000f06e Refactor resource search functionality: replace inline form with a dedicated search panel and improve result handling 2025-12-15 10:42:58 +00:00
Ross 2f014ce1a5 Enhance resource management: refresh case data and notify listeners on resource changes in case attachment/detachment 2025-12-15 10:12:04 +00:00
Ross 654d0ae686 Enhance local viewer: implement centralized hide functionality and improve close handling for resource overlays 2025-12-15 10:08:14 +00:00
Ross 18dbb27a3e Enhance resource handling: add support for loading and displaying non-DICOM resources (images, videos, embeds) in viewer overlays 2025-12-15 10:06:54 +00:00
Ross 4bec329aff Implement resource search functionality and enhance resource management in case details 2025-12-15 09:53:19 +00:00
Ross 1e2300bd70 Add media preview functionality for resources with image or video support 2025-12-15 09:47:07 +00:00
62 changed files with 4307 additions and 1160 deletions
+7
View File
@@ -21,3 +21,10 @@ GUNICORN_LOGLEVEL=info
NGINX_HTTP_PORT=8080
# Pick a non-privileged HTTPS port to avoid conflicts with host installs
NGINX_HTTPS_PORT=8444
# Use Django's file-based email backend for local development so messages
# are written to files under the mounted `./logs/emails` directory and are
# easy to inspect on the host.
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
#EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
EMAIL_FILE_PATH=/var/log/rad/emails
+3 -96
View File
@@ -786,6 +786,9 @@ def mark2_overview(request, pk):
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
exam = get_object_or_404(Exam, pk=pk)
if not GenericExamViews.check_user_marker_access(request.user, pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
@@ -1045,102 +1048,6 @@ def mark2_question_marked(request, pk):
return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context)
#@login_required
#def exam_scores_refresh(request, pk):
# exam = get_object_or_404(Exam, pk=pk)
#
# if not exam.exam_mode:
# raise Http404("Packet not in exam mode")
#
# questions = exam.exam_questions.all() # Don't worry about order here
#
# cids = UserAnswer.objects.filter(question__in=questions, exam__id=pk)
#
# # Force a score update
# for c in cids:
# c.get_answer_score(cached=False)
#
# return render(
# request,
# "anatomy/base.html",
# {
# "simple_content": "Answer scores updated",
# },
# )
def exam_scores_cid_user(request, pk, cid, passcode):
exam = get_object_or_404(Exam, pk=pk)
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
if not exam.check_cid_user(cid, passcode, request.user):
raise Http404("Error accessing exam")
questions = exam.get_questions().prefetch_related("answers")
answers_and_marks = []
answers_marks = []
answers = []
view_all_results = False
if request.user.groups.filter(name="view_all_results").exists():
view_all_results = True
for q in questions:
# Get user answer
user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first()
if not user_answer:
# skip if no answer
answers_marks.append(0)
answers.append("")
answer_score = 0
ans = "Not answered"
else:
ans = user_answer.answer
answer_score = user_answer.get_answer_score()
correct_answer = q.get_primary_answer()
if not exam.publish_results and not view_all_results:
correct_answer = "*****"
answer_score = 0
answers.append(ans)
answers_marks.append(answer_score)
answers_and_marks.append((ans, answer_score, correct_answer))
if "unmarked" in answers_marks:
answered = [i for i in answers_marks if type(i) == int]
total_score = sum(answered)
unmarked_number = len(answers_marks) - len(answered)
total_score = "{} ({} unmarked)".format(total_score, unmarked_number)
else:
total_score = sum(answers_marks)
max_score = len(questions) * 2
return render(
request,
"anatomy/exam_scores_user.html",
{
"exam": exam,
"cid": cid,
"passcode": passcode,
"questions": questions,
"answers": answers,
"answers_marks": answers_marks,
"total_score": total_score,
"max_score": max_score,
"answers_and_marks": answers_and_marks,
"view_all_results": view_all_results,
},
)
class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
model = AnatomyQuestion
form_class = AnatomyQuestionForm
+12 -8
View File
@@ -201,15 +201,19 @@
</div>
</div>
</div>
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %}
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
{% if caseresource.pre_review %}
<span title="This resource will be avalible to review when or taking a case">(pre)</span>
{% else %}
<span title="This resource will only be available when reviewing a completed case">(post)</span>
<div class="mb-3">
<h5>Resources</h5>
{% if can_edit%}
<div class="mb-2">
{% include 'atlas/partials/resource_search_panel.html' %}
</div>
{% endif %}
{% endfor %}
</p>
<div>
{% include 'atlas/partials/case_resources_list.html' with case=case %}
</div>
</div>
<div class="pre-whitespace multi-image-block"><b>Series:</b>
<form>
@@ -71,6 +71,22 @@
<div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div>
{# Attached resources for this case: allow loading into the shared viewer #}
{% if case.caseresource_set.exists %}
<div class="mt-2">
<strong>Resources:</strong>
<div class="d-flex flex-column gap-1">
{% for cr in case.caseresource_set.all %}
{% with res=cr.resource %}
<div>
<span class="me-2">{{ res.name }}</span>
<button class="open-resource btn btn-sm btn-outline-primary ms-1" data-src="{% if res.file %}{{ res.file.url }}{% else %}{{ res.url }}{% endif %}" data-title="{{ res.name|escapejs }}">Load</button>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
{% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary>
{% for ds in case.display_sets.all %}
@@ -335,6 +351,19 @@
//}
//win2 = openSecondaryWindow(url);
})
// Load resource into linked viewer
$('.open-resource').click(function() {
var src = $(this).data('src');
var title = $(this).data('title') || 'Resource';
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Resource:</span> "+title);
$('#current-case-history').html('');
$('#current-case-discussion').html('');
$('#current-case-report').html('');
bc.postMessage({"type": "open", "url": src});
});
$('.send-message').click(function() {
var url = $(this).data('target');
bc.postMessage(url);
@@ -0,0 +1,26 @@
<div id="case-resources-list">
{% if case.caseresource_set.exists %}
<ul class="list-unstyled mb-0">
{% for cr in case.caseresource_set.all %}
<li class="py-1 d-flex justify-content-between align-items-center" data-caseresource-id="{{ cr.id }}" data-pre="{{ cr.pre_review|yesno:'1,0' }}">
<div>
<a href="{% url 'atlas:resource_detail' cr.resource.pk %}">{{ cr.resource.name }}</a>
{% if cr.pre_review %}
<span class="badge bg-info text-dark ms-2 small">pre</span>
{% else %}
<span class="badge bg-secondary text-light ms-2 small">post</span>
{% endif %}
</div>
{% if can_edit %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" title="Toggle pre/post" hx-post="{% url 'atlas:case_toggle_resource_pre' pk=case.pk %}" hx-vals='{"resource_id": "{{ cr.resource.pk }}"}' hx-swap="outerHTML" hx-target="#case-resources-list">Toggle</button>
<button type="button" class="btn btn-sm btn-outline-danger" hx-post="{% url 'atlas:case_detach_resource' pk=case.pk %}" hx-vals='{"resource_id": "{{ cr.resource.pk }}"}' hx-swap="outerHTML" hx-target="#case-resources-list">Remove</button>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-muted">No resources attached to this case.</div>
{% endif %}
</div>
@@ -0,0 +1,146 @@
<div class="resource-media-wrapper" id="resource_media_wrapper_{{ resource.pk }}" data-src="{{ src|escape }}">
<noscript>
<div><a href="{{ src }}" target="_blank" rel="noopener">Open media</a></div>
</noscript>
</div>
<script>
(function(){
var wrapperId = 'resource_media_wrapper_{{ resource.pk }}';
if (document.getElementById(wrapperId).dataset._inited) return;
var wrap = document.getElementById(wrapperId);
if (!wrap) return;
wrap.dataset._inited = '1';
var src = wrap.getAttribute('data-src');
if (!src) return;
var lower = src.toLowerCase();
function createImg(src) {
var img = document.createElement('img');
img.src = src;
img.className = 'img-fluid resource-media-element rounded';
img.style.maxHeight = '60vh';
img.style.objectFit = 'contain';
img.style.cursor = 'zoom-in';
return img;
}
function createVideo(src) {
var video = document.createElement('video');
video.className = 'resource-media-element rounded';
video.controls = true;
video.preload = 'metadata';
video.style.maxWidth = '100%';
video.style.maxHeight = '60vh';
video.style.objectFit = 'contain';
var srcEl = document.createElement('source');
srcEl.src = src;
video.appendChild(srcEl);
return video;
}
function createIframe(src, embedUrl) {
var iframe = document.createElement('iframe');
iframe.src = embedUrl || src;
iframe.setAttribute('frameborder','0');
iframe.setAttribute('allowfullscreen','');
iframe.className = 'resource-media-element rounded';
iframe.style.width = '100%';
iframe.style.height = '60vh';
return iframe;
}
var el = null;
// YouTube
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) {
// build embed url
var videoId = null;
try {
var m = src.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
if (m && m[1]) videoId = m[1];
} catch(e){}
var embed = videoId ? ('https://www.youtube.com/embed/' + videoId) : src;
el = createIframe(src, embed);
} else if (lower.indexOf('vimeo.com') !== -1) {
var m2 = src.match(/vimeo\.com\/(\d+)/);
var embed2 = m2 && m2[1] ? ('https://player.vimeo.com/video/' + m2[1]) : src;
el = createIframe(src, embed2);
} else if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) {
el = createVideo(src);
} else if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) {
el = createImg(src);
} else {
// unknown: try image first, fallback to link
el = createImg(src);
// if image fails to load, replace with link
el.addEventListener('error', function(){
var a = document.createElement('a');
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource';
wrap.innerHTML = ''; wrap.appendChild(a);
});
}
// append preview element
if (el) {
wrap.appendChild(el);
// click -> open modal/full-size
el.addEventListener('click', function(e) {
e.preventDefault();
// create modal container if not present
var modalId = 'resource_media_modal_{{ resource.pk }}';
var existing = document.getElementById(modalId);
if (!existing) {
var modalDiv = document.createElement('div');
modalDiv.className = 'modal fade resource-media-modal';
modalDiv.id = modalId;
modalDiv.tabIndex = -1;
modalDiv.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content bg-dark"><div class="modal-body p-0 text-center"></div></div></div>';
document.body.appendChild(modalDiv);
}
var modalEl = document.getElementById(modalId);
var body = modalEl.querySelector('.modal-body');
body.innerHTML = '';
// create full-size node depending on type
if (el.tagName.toLowerCase() === 'img') {
var big = document.createElement('img');
big.src = src;
big.style.maxWidth = '95vw';
big.style.maxHeight = '95vh';
big.style.objectFit = 'contain';
big.className = 'img-fluid';
body.appendChild(big);
} else if (el.tagName.toLowerCase() === 'video') {
var bigv = document.createElement('video');
bigv.controls = true; bigv.autoplay = false; bigv.style.maxWidth = '95vw'; bigv.style.maxHeight = '95vh'; bigv.style.objectFit = 'contain'; bigv.className = 'w-100';
var s = document.createElement('source'); s.src = src; bigv.appendChild(s);
body.appendChild(bigv);
} else if (el.tagName.toLowerCase() === 'iframe') {
var frame = document.createElement('iframe');
frame.src = el.src;
frame.setAttribute('frameborder','0');
frame.setAttribute('allowfullscreen','');
frame.style.width = '95vw'; frame.style.height = '80vh';
body.appendChild(frame);
} else {
var a = document.createElement('a'); a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource'; body.appendChild(a);
}
// show modal using Bootstrap if available
try {
var bsModal = new bootstrap.Modal(modalEl);
bsModal.show();
} catch (e) {
// fallback: open in new tab
window.open(src, '_blank', 'noopener');
}
});
}
})();
</script>
<style>
.resource-media-wrapper .resource-media-element { display: inline-block; }
.resource-media-wrapper img.resource-media-element { max-width: 100%; height: auto; }
.resource-media-wrapper video.resource-media-element { max-width: 100%; height: auto; }
.resource-media-modal .modal-content { background: rgba(0,0,0,0.95); }
</style>
@@ -0,0 +1,43 @@
<div id="resource-search-panel" class="mb-2">
<div id="resource-search-form" class="row g-2">
<input type="hidden" name="case_id" value="{{ case.pk }}" />
<div class="col-auto" style="flex: 1 1 300px;">
<input type="search" id="resource-search-q" name="q" class="form-control form-control-sm" placeholder="Search resources by name, description or URL"
hx-get="{% url 'atlas:resource_search_widget' %}"
hx-trigger="input delay:500ms"
hx-target="#resource-search-results"
hx-swap="innerHTML"
hx-include="#resource-search-panel"
hx-indicator=".htmx-indicator">
</div>
<div class="htmx-indicator col-auto">
<img src="/img/bars.svg" alt=""/> Searching...
</div>
<div class="col-auto">
<select name="site" class="form-select form-select-sm">
<option value="">All sites</option>
{% for s in site_queryset %}
<option value="{{ s.pk }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="subspecialty" class="form-select form-select-sm">
<option value="">All subspecialties</option>
{% for sp in subspecialty_queryset %}
<option value="{{ sp.pk }}">{{ sp.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="resource-open-access-{{ case.pk }}" name="open_access" value="1">
<label class="form-check-label small" for="resource-open-access-{{ case.pk }}">Open</label>
</div>
</div>
</div>
<div id="resource-search-results" class="mt-2"></div>
</div>
<!-- pure HTMX: no JS fallback; hx-trigger on the input uses input+debounce -->
@@ -0,0 +1,39 @@
{% if results %}
<div id="resource-search-results" hx-swap-oob="true">
<ul class="list-unstyled mb-0" id="resource_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-resource-id="{{ item.id }}">
<div class="flex-grow-1">
<a href="{% url 'atlas:resource_detail' item.pk %}" target="_blank">{{ item.name|escape }}</a>
<div class="small text-muted">{{ item.get_display|safe }}</div>
</div>
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-sm btn-success resource-attach-btn"
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "0"}'
hx-swap="outerHTML"
hx-target="#case-resources-list">
Attach
</button>
<button
type="button"
class="btn btn-sm btn-outline-success resource-attach-pre-btn"
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "1"}'
hx-swap="outerHTML"
hx-target="#case-resources-list">
Attach (pre)
</button>
</div>
</li>
{% endfor %}
</ul>
<div class="mt-2 text-end">
<small class="text-muted">{{ results|length }} results</small>
</div>
</div>
{% else %}
<div id="resource-search-results" hx-swap-oob="true"><em>No resources found</em></div>
{% endif %}
@@ -14,6 +14,22 @@
<small class="text-muted">by {{ resource.get_authors|default:"Unknown" }}</small>
</div>
<div class="card-body">
{# Media preview: image or video autosized, clickable to open full-size modal #}
{% if resource.file or resource.url %}
<div class="resource-media-preview mb-3 text-center">
{% comment %}Prefer local file if present, otherwise try URL{% endcomment %}
{% if resource.file %}
{% with src=resource.file.url %}
{% include 'atlas/partials/resource_media.html' with src=src resource=resource %}
{% endwith %}
{% else %}
{% with src=resource.url %}
{% include 'atlas/partials/resource_media.html' with src=src resource=resource %}
{% endwith %}
{% endif %}
</div>
{% endif %}
{% if resource.description %}
<p class="card-text">{{ resource.description|linebreaksbr }}</p>
{% endif %}
+147 -1
View File
@@ -18,7 +18,14 @@
<body>
<p>Awating load request</p>
This page will display series when loaded from other pages.
<div id="viewerContainer" style="width:100%; height:100%; position:relative;">
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px;" ></iframe>
<div id="resourceDisplay" style="display:none; position:absolute; inset:0; background:#000; align-items:center; justify-content:center; z-index:100000;">
<div id="resourceInner" style="position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center;">
</div>
<button id="resourceClose" style="position:absolute; top:8px; right:8px; z-index:9999;" class="btn btn-sm btn-light">Close</button>
</div>
</div>
<script type="text/javascript">
var win2 = false;
@@ -27,12 +34,151 @@
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
function isMediaUrl(url) {
if (!url) return false;
const lower = url.toLowerCase();
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
return false;
}
function showResource(url) {
const type = isMediaUrl(url);
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
inner.innerHTML = '';
if (!type) {
// fallback: open in iframe
document.getElementById('viewer').src = url;
disp.style.display = 'none';
return;
}
// create a transform wrapper so we can pan/zoom the content uniformly
const transformWrap = document.createElement('div');
transformWrap.style.display = 'flex';
transformWrap.style.alignItems = 'center';
transformWrap.style.justifyContent = 'center';
transformWrap.style.width = '100%';
transformWrap.style.height = '100%';
transformWrap.style.overflow = 'hidden';
transformWrap.style.touchAction = 'none';
transformWrap.style.position = 'relative';
if (type === 'image') {
const img = document.createElement('img');
img.src = url;
img.style.maxWidth = '95%';
img.style.maxHeight = '95%';
img.style.objectFit = 'contain';
img.style.cursor = 'grab';
img.draggable = false;
transformWrap.appendChild(img);
} else if (type === 'video') {
const v = document.createElement('video');
v.controls = true; v.autoplay = false; v.style.maxWidth = '95%'; v.style.maxHeight = '95%'; v.style.objectFit = 'contain';
v.style.cursor = 'grab';
const s = document.createElement('source'); s.src = url; v.appendChild(s); transformWrap.appendChild(v);
} else if (type === 'embed') {
let embed = url;
try {
const m = url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
if (m && m[1]) embed = 'https://www.youtube.com/embed/' + m[1];
else if (url.indexOf('vimeo.com') !== -1) {
const m2 = url.match(/vimeo\.com\/(\d+)/);
if (m2 && m2[1]) embed = 'https://player.vimeo.com/video/' + m2[1];
}
} catch(e){}
const iframe = document.createElement('iframe');
iframe.src = embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen','');
iframe.style.width = '95%'; iframe.style.height = '85%'; iframe.style.maxWidth = '95%'; iframe.style.maxHeight = '95%';
transformWrap.appendChild(iframe);
}
inner.appendChild(transformWrap);
// Setup zoom/pan controls for the transformWrap
(function enablePanZoom(target){
let scale = 1; let minScale = 0.5; let maxScale = 6; let startX=0, startY=0; let translateX=0, translateY=0; let dragging=false;
const content = target.firstElementChild;
function updateTransform(){
if (content) content.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
}
// Wheel to zoom (allow wheel-only zoom while overlay is shown)
target.addEventListener('wheel', function(e){
// require overlay active (safety) - if not active, ignore
const dispEl = document.getElementById('resourceDisplay');
if (!dispEl || dispEl.style.display === 'none') return;
e.preventDefault();
const delta = -e.deltaY || e.wheelDelta;
const zoomFactor = delta > 0 ? 1.12 : 0.9;
const newScale = Math.min(maxScale, Math.max(minScale, scale * zoomFactor));
// adjust translate to zoom to mouse position
const rect = content.getBoundingClientRect();
const mx = e.clientX - rect.left; const my = e.clientY - rect.top;
const dx = (mx - rect.width/2); const dy = (my - rect.height/2);
// compute scale ratio
const ratio = newScale/scale - 1;
translateX -= dx * ratio;
translateY -= dy * ratio;
scale = newScale; updateTransform();
}, {passive:false});
// Pointer drag to pan
target.addEventListener('pointerdown', function(e){
dragging = true; startX = e.clientX; startY = e.clientY; target.setPointerCapture(e.pointerId); content.style.cursor = 'grabbing';
});
target.addEventListener('pointermove', function(e){
if (!dragging) return; e.preventDefault(); const dx = e.clientX - startX; const dy = e.clientY - startY; startX = e.clientX; startY = e.clientY; translateX += dx; translateY += dy; updateTransform();
});
target.addEventListener('pointerup', function(e){ dragging = false; try{ target.releasePointerCapture(e.pointerId);}catch(_){} if (content) content.style.cursor = 'grab'; });
target.addEventListener('pointercancel', function(e){ dragging=false; if (content) content.style.cursor='grab'; });
// double click to reset
target.addEventListener('dblclick', function(e){ scale = 1; translateX = 0; translateY = 0; updateTransform(); });
// Escape to close
document.addEventListener('keydown', function(ev){ if (ev.key === 'Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
})(transformWrap);
}
disp.style.display = 'flex';
}
// Centralized hide helper
function hideResource() {
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
if (disp) disp.style.display = 'none';
if (inner) inner.innerHTML = '';
}
// Robust close: delegate clicks on the overlay so pointer capture doesn't block the button
(function(){
const disp = document.getElementById('resourceDisplay');
if (!disp) return;
disp.addEventListener('click', function(e){
// if click was on the close button (or inside it), hide
const close = e.target.closest && e.target.closest('#resourceClose');
if (close) { hideResource(); }
});
})();
bc.onmessage = (event) => {
console.log("message received");
console.log(event);
// Always hide any existing resource overlay on a new load
try { hideResource(); } catch (e) { /* ignore if not available */ }
if (event.data.type == "open") {
// If URL looks like an image/video/embed, render in the resource panel
if (event.data.url && isMediaUrl(event.data.url)) {
showResource(event.data.url);
bc.postMessage({"type": "open-complete", "url" : event.data.url});
return;
}
document.getElementById("viewer").src = event.data.url;
//document.querySelectorAll("[data-cy='seriesList-btn']")[0].click()
bc.postMessage({"type": "open-complete", "url" : event.data.url});
}
};
+76
View File
@@ -73,6 +73,12 @@
data-auto-cache-stack="true"
></div>
<!-- Overlay for non-DICOM resources (images/videos/embeds) -->
<div id="resourceDisplay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.96); z-index:100000; align-items:center; justify-content:center;">
<div id="resourceInner" style="max-width:95vw; max-height:95vh; display:flex; align-items:center; justify-content:center;"></div>
<button id="resourceClose" style="position:fixed; top:12px; right:12px; z-index:2001;" class="btn btn-sm btn-light">Close</button>
</div>
<script type="text/javascript">
var win2 = false;
@@ -80,10 +86,80 @@
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
// Centralized hide helper and delegated close handling for local viewer
function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; }
(function(){ const disp=document.getElementById('resourceDisplay'); if(!disp) return; disp.addEventListener('click', function(e){ const close = e.target.closest && e.target.closest('#resourceClose'); if (close) hideResourceLocal(); }); })();
bc.onmessage = (event) => {
// Ensure any existing overlay is closed on new load
try { hideResourceLocal(); } catch(_) {}
console.log("message received");
console.log(event);
if (event.data.type == "open") {
// If a resource URL is provided (image/video/embed), show it in overlay
function isMediaUrl(url) {
if (!url) return false;
const lower = url.toLowerCase();
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
return false;
}
function showResource(url) {
const type = isMediaUrl(url);
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
inner.innerHTML = '';
if (!type) return false;
const transformWrap = document.createElement('div');
transformWrap.style.display = 'flex'; transformWrap.style.alignItems = 'center'; transformWrap.style.justifyContent = 'center';
transformWrap.style.width = '100%'; transformWrap.style.height = '100%'; transformWrap.style.overflow = 'hidden'; transformWrap.style.touchAction = 'none';
if (type === 'image') {
const img = document.createElement('img'); img.src = url; img.style.maxWidth = '95%'; img.style.maxHeight = '95%'; img.style.objectFit = 'contain'; img.style.cursor='grab'; img.draggable=false; transformWrap.appendChild(img);
} else if (type === 'video') {
const v = document.createElement('video'); v.controls=true; v.autoplay=false; v.style.maxWidth='95%'; v.style.maxHeight='95%'; v.style.objectFit='contain'; const s=document.createElement('source'); s.src=url; v.appendChild(s); transformWrap.appendChild(v);
} else if (type === 'embed') {
let embed = url; try{ const m=url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); if (m&&m[1]) embed='https://www.youtube.com/embed/'+m[1]; else if (url.indexOf('vimeo.com')!==-1){ const m2=url.match(/vimeo\.com\/(\d+)/); if(m2&&m2[1]) embed='https://player.vimeo.com/video/'+m2[1]; } }catch(e){}
const iframe=document.createElement('iframe'); iframe.src=embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen',''); iframe.style.width='95%'; iframe.style.height='85%'; transformWrap.appendChild(iframe);
}
inner.appendChild(transformWrap);
// pan/zoom
(function enablePanZoom(target){
let scale=1, minScale=0.5, maxScale=6, startX=0, startY=0, translateX=0, translateY=0, dragging=false;
const content=target.firstElementChild;
function update(){ if(content) content.style.transform=`translate(${translateX}px, ${translateY}px) scale(${scale})`; }
target.addEventListener('wheel', function(e){
const dispEl = document.getElementById('resourceDisplay');
if (!dispEl || dispEl.style.display === 'none') return;
e.preventDefault();
const delta=-e.deltaY||e.wheelDelta; const zoom=delta>0?1.12:0.9; const newScale=Math.min(maxScale, Math.max(minScale, scale*zoom)); const rect=content.getBoundingClientRect(); const mx=e.clientX-rect.left, my=e.clientY-rect.top; const ratio=newScale/scale-1; translateX -= (mx-rect.width/2)*ratio; translateY -= (my-rect.height/2)*ratio; scale=newScale; update();
}, {passive:false});
target.addEventListener('pointerdown', function(e){ dragging=true; startX=e.clientX; startY=e.clientY; target.setPointerCapture(e.pointerId); if(content) content.style.cursor='grabbing'; });
target.addEventListener('pointermove', function(e){ if(!dragging) return; e.preventDefault(); const dx=e.clientX-startX, dy=e.clientY-startY; startX=e.clientX; startY=e.clientY; translateX+=dx; translateY+=dy; update(); });
target.addEventListener('pointerup', function(e){ dragging=false; try{target.releasePointerCapture(e.pointerId)}catch(_){} if(content) content.style.cursor='grab'; });
target.addEventListener('dblclick', function(){ scale=1; translateX=0; translateY=0; update(); });
document.addEventListener('keydown', function(ev){ if(ev.key==='Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
})(transformWrap);
disp.style.display='flex'; return true;
}
if (event.data.url && isMediaUrl(event.data.url)) {
// show resource overlay and do not attempt to mount DICOM viewer
if (showResource(event.data.url)) {
bc.postMessage({"type": "open-complete-local"});
return;
}
}
// Hide modal when data is loaded
document.getElementById("awaiting-modal").style.display = "none";
document.getElementById("root").dataset.images = event.data.images;
+12
View File
@@ -49,6 +49,8 @@ urlpatterns = [
path("resource/<int:pk>/view", views.resource_view, name="resource_view"),
path("resource/<int:pk>/update", views.ResourceUpdate.as_view(), name="resource_update"),
path("resource/<int:pk>/delete", views.ResourceDelete.as_view(), name="resource_delete"),
path("resource-search-widget/", views.resource_search_widget, name="resource_search_widget"),
path(
"resource/create",
views.ResourceCreate.as_view(),
@@ -177,6 +179,11 @@ urlpatterns = [
views.collection_case_details,
name="collection_case_details",
),
# HTMX endpoints for attaching/detaching resources to a case
path("case/<int:pk>/attach-resource/", views.case_attach_resource, name="case_attach_resource"),
path("case/<int:pk>/detach-resource/", views.case_detach_resource, name="case_detach_resource"),
path("case/<int:pk>/toggle-resource-pre/", views.case_toggle_resource_pre, name="case_toggle_resource_pre"),
# Backwards-compatible redirect from legacy case_id-style URLs
path(
"collection/<int:exam_id>/case/id/<int:case_id>/details",
@@ -354,6 +361,11 @@ urlpatterns = [
views.GenericExamViews.exam_json_edit,
name="exam_json_edit",
),
path(
"collection/<int:pk>/cid_bulk_edit",
views.GenericExamViews.exam_cid_bulk_edit,
name="exam_cid_bulk_edit",
),
path(
"collection/<int:pk>/dicom_json",
views.collection_dicom_json,
+146 -1
View File
@@ -46,7 +46,7 @@ from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType
from generic.models import CidUser, CidUserExam, CimarCase
from generic.models import CidUser, CidUserExam, CimarCase, Site
from atlas.helpers import get_cases_available_to_user
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
@@ -478,6 +478,9 @@ def case_detail(request, pk):
"is_atlas_editor": is_atlas_editor,
"is_case_author": is_case_author,
"can_mark_normal": can_mark_normal,
# Provide helper lists for resource search filters
"site_queryset": Site.objects.all(),
"subspecialty_queryset": Subspecialty.objects.all(),
},
)
@@ -4007,6 +4010,148 @@ def case_dicom_json(request, pk):
return JsonResponse(case.get_case_dicom_json())
@login_required
def resource_search_widget(request):
"""HTMX endpoint: search existing `Resource` objects with simple filters.
GET params:
- q: query string (name/description)
- site: site id (optional)
- subspecialty: subspecialty id (optional)
- open_access: '1' or '0' (optional)
Returns rendered partial `atlas/partials/resource_search_widget_results.html`.
"""
q = request.GET.get("q", "").strip()
site = request.GET.get("site")
subspecialty = request.GET.get("subspecialty")
open_access = request.GET.get("open_access")
qs = Resource.objects.all()
if q:
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q) | Q(url__icontains=q))
if site:
try:
qs = qs.filter(sites__id=int(site))
except Exception:
pass
if subspecialty:
try:
qs = qs.filter(subspecialty__id=int(subspecialty))
except Exception:
pass
if open_access in ["0", "1"]:
qs = qs.filter(open_access=(open_access == "1"))
# Restrict to what the user may see: open_access or authored (unless editor)
if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
qs = qs.filter(Q(open_access=True) | Q(author__id=request.user.id)).distinct()
results = qs.order_by("name")[:50]
field = request.GET.get("field", "resource_search")
case_id = request.GET.get("case_id")
return render(request, "atlas/partials/resource_search_widget_results.html", {"results": results, "field": field, "case_id": case_id})
@login_required
@require_POST
def case_attach_resource(request, pk):
"""Attach an existing resource to the case (HTMX POST).
POST params:
- resource_id: integer
- pre_review: '1' or '0' (optional)
Returns the updated `atlas/partials/case_resources_list.html` fragment.
"""
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
pre_review = request.POST.get("pre_review", "0") == "1"
try:
resource = Resource.objects.get(pk=int(resource_id))
except Exception:
return HttpResponse(status=400)
# create if not exists
cr, created = CaseResource.objects.get_or_create(case=case, resource=resource, defaults={"pre_review": pre_review})
if not created:
# update flag
cr.pre_review = pre_review
cr.save()
# Refresh case from DB to ensure related sets reflect changes and render updated list
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
resp = HttpResponse(html)
# Notify any HTMX listeners that the case resources have changed
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
return resp
@login_required
@require_POST
def case_detach_resource(request, pk):
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
try:
cr = CaseResource.objects.filter(case=case, resource__id=int(resource_id))
cr.delete()
except Exception:
pass
# Refresh case from DB and return updated partial
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
return resp
@login_required
@require_POST
def case_toggle_resource_pre(request, pk):
"""Toggle the pre_review flag on a CaseResource (HTMX POST).
POST params:
- resource_id: integer
Returns the updated `atlas/partials/case_resources_list.html` fragment.
"""
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
try:
cr = CaseResource.objects.get(case=case, resource__id=int(resource_id))
except Exception:
return HttpResponse(status=400)
cr.pre_review = not bool(cr.pre_review)
cr.save()
# Refresh case from DB to ensure related sets reflect changes and render updated list
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
return resp
def series_dicom_json(request, pk):
series = get_object_or_404(Series, pk=pk)
+5 -1
View File
@@ -43,7 +43,11 @@ DEBUG_TOOLBAR_CONFIG = {
# deployment README. Adjust as needed.
STATIC_ROOT = '/usr/src/app/static/'
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
import os
# Allow environment to override the email backend (so dev .env can select
# a file-based backend while production keeps SMTP).
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
if not DEBUG:
LOGGING = {
+15
View File
@@ -20,6 +20,7 @@ from django.contrib.auth.models import User
from generic.models import (
CidUser,
CidUserExam,
CidUserGroup,
Examination,
ExamCollection,
@@ -426,6 +427,7 @@ class CidUserForm(ModelForm):
"internal_candidate",
"name",
"email",
"notes",
"supervisor",
"login_email_sent",
"results_email_sent",
@@ -541,6 +543,19 @@ class CidUserExamForm(ModelForm):
fields = []
class CidUserExamRecordForm(ModelForm):
class Meta:
model = CidUserExam
fields = [
"start_time",
"end_time",
"completed",
"manual_submission",
"share_with_supervisor",
"results_emailed_status",
]
class CidUserGroupModelChoiceField(ModelMultipleChoiceField):
def label_from_instance(self, obj):
return f"{obj.cid} [{obj.email}]"
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-12-29 10:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0029_add_unique_constraint_supervisor_email'),
]
operations = [
migrations.AddField(
model_name='ciduser',
name='notes',
field=models.TextField(blank=True, help_text='Internal notes about the candidate'),
),
]
+32
View File
@@ -0,0 +1,32 @@
# Generated by Django 5.2.7 on 2026-01-05 12:41
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('generic', '0030_ciduser_notes'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Flag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('note', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('cid_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to='generic.ciduser')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-created_at',),
},
),
]
+78 -21
View File
@@ -1442,6 +1442,8 @@ class CidUser(models.Model):
"CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True
)
notes = models.TextField(blank=True, help_text="Internal notes about the candidate")
class Meta:
indexes = [models.Index(fields=["cid"])]
@@ -1552,27 +1554,9 @@ class CidUser(models.Model):
if self.login_email_sent and not resend:
return False, "Already sent."
login_url = reverse("cid_selector")
msg = f"""
See below for your details for: {self.group}
---------
CID: {self.cid}
Passcode: {self.passcode}
---------
You will need these to access to the test system.
Direct link to login:
{settings.REMOTE_URL}{login_url}?cid={self.cid}&passcode={self.passcode}
The above details are unique to you. Please do not share as otherwise others will be able to access and submit your results.
Please note this inbox is not monitored. Direct all enquiries to plh-tr.radiology-academy@nhs.net
"""
# Build the message payload. Exposed as a separate method so callers
# can preview the email without actually sending it.
msg = self.generate_email_details_msg()
try:
# send_mail(
@@ -1597,6 +1581,36 @@ class CidUser(models.Model):
self.save()
return True, ""
def generate_email_details_msg(self) -> str:
"""Generate the candidate details email body without sending it.
Returns:
str: The plaintext email body that would be sent.
"""
login_url = reverse("cid_selector")
msg = f"""
See below for your details for: {self.group}
---------
CID: {self.cid}
Passcode: {self.passcode}
---------
You will need these to access to the test system.
Direct link to login:
{settings.REMOTE_URL}{login_url}?cid={self.cid}&passcode={self.passcode}
The above details are unique to you. Please do not share as otherwise others will be able to access and submit your results.
Please note this inbox is not monitored. Direct all enquiries to plh-tr.radiology-academy@nhs.net
"""
return msg
def get_email_details_url(self):
return reverse("generic:candidate_email_details", kwargs={"cid": self.cid})
@@ -1686,9 +1700,52 @@ class CidUserExam(models.Model):
# TODO add examuserstatus?
self.end_time = timezone.now()
self.completed = True
self.manual_submission = True
self.save()
class Flag(models.Model):
"""A reusable flag model that can be attached to any object via a
GenericForeignKey. Useful for marking questions, items or other objects
across apps.
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
# Optional actor who created the flag (either a user or a CID user)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="flags",
)
cid_user = models.ForeignKey(
"CidUser",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="flags",
)
note = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-created_at",)
def __str__(self) -> str:
actor = None
if self.user:
actor = f"user:{self.user.username}"
elif self.cid_user:
actor = f"cid:{self.cid_user.cid}"
else:
actor = "anon"
return f"Flag [{actor}] -> {self.content_type}({self.object_id})"
CID_GROUP_EXAMS = (
("SBAs", "sba_cid_user_groups"),
("Physics", "physics_cid_user_groups"),
@@ -1,120 +1,124 @@
{% extends 'generic/cid_group_base.html' %}
{% block content %}
<h2>Group: {{group.name}}</h2>
<details>
<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 class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<form method="post">
{% csrf_token %}
<textarea name="emails" placeholder="Email addresses (one per line)"></textarea>
<button type="submit"
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"></div>
<h2 class="card-title mb-1">Group: {{ group.name }}</h2>
<div class="small text-muted">This page shows an overview of the candidates and exams associated with the group.</div>
</div>
<div class="text-end">
<div class="small text-muted">Users in group</div>
<div class="fw-bold">{{ users|length }}</div>
</div>
</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>
</details>
</div>
</div>
</div>
<h3>Exams</h3>
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 mb-3">
<div class="card-body">
<h3 class="card-title mb-2">Users</h3>
<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-secondary sort-btn" data-key="username">{% if group_type == 'cid' %}CID{% else %}Username{% endif %} <span class="sort-caret"></span></button>
<button type="button" class="btn btn-sm btn-outline-secondary sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
<button type="button" class="btn btn-sm btn-outline-secondary sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
</div>
</div>
{% if group_type == 'cid' %}
<div class="mb-2">
<form method="post" hx-post="{% url 'generic:cid_group_generate_preview' group.pk %}" hx-target="#cid-generate-preview" hx-swap="innerHTML">
{% csrf_token %}
<label class="small text-muted">Quick generate users (one per line: name,email)</label>
<textarea name="rows" class="form-control form-control-sm mb-2" rows="5" placeholder="John Smith,j.smith@example.org\nJane Doe,jane@example.org"></textarea>
<div>
<button type="submit" class="btn btn-sm btn-outline-primary">Preview</button>
</div>
</form>
<div id="cid-generate-preview"></div>
</div>
{% endif %}
<div id="group-users-wrap">
{% if users %}
{% include 'generic/partials/group_users_list.html' with users=users group_type=group_type %}
{% 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>
<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" %}
Add more <a href='{% url "generic:cid_group_exams" group.pk %}'>here</a>.
{% else %}
Add more <a href='{% url "generic:user_group_exams" group.pk %}'>here</a>.
{% 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 %}
{% for key, value in exam_map.items %}
<h4>{{key}}</h4>
<div>
<h5 class="mt-3">{{ key }}</h5>
<div class="list-group list-group-flush">
{% for exam in value %}
<div class="d-flex">
<a href="{{exam.get_absolute_url}}" class="px-2">{{exam}}</a>
<div class="list-group-item bg-transparent border-0 py-2 d-flex align-items-center">
<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" %}
<a href="{{exam.get_cid_edit_url}}" class="edit-link" px-2>Edit candidates</a>
<form>
<a href="{{ exam.get_cid_edit_url }}" class="edit-link small">Edit candidates</a>
{% 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_type" value="{{ exam.app_name }}">
<button
class="btn btn-sm px-2"
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>
<button class="btn btn-sm btn-outline-success hx-action-btn" hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}" name="action" value="add">Add</button>
<button class="btn btn-sm btn-outline-danger hx-action-btn" hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}" name="action" value="remove">Remove</button>
</form>
{% else %}
<a href="{{exam.get_user_edit_url}}" class="edit-link px-2">Edit candidates</a>
<form>
<form class="m-0 d-inline">
<input type="hidden" name="exam_id" value="{{ exam.pk }}">
<input type="hidden" name="exam_type" value="{{ exam.app_name }}">
<button
class="btn btn-sm px-2"
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>
<button class="btn btn-sm btn-outline-success hx-action-btn" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" name="action" value="add">Add</button>
<button class="btn btn-sm btn-outline-danger hx-action-btn" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" name="action" value="remove">Remove</button>
</form>
{% endif %}
</div>
</div>
<div class="px-2 ms-auto ">
<div class="px-3 ms-2 small text-muted">
{% if group_type == "cid" %}
(Cid candidate count: {{ exam.valid_cid_users.count }})
{% else %}
@@ -126,17 +130,122 @@
</div>
{% endfor %}
{% endwith %}
</div>
</div>
{% endblock %}
{% block js %}
<style>
td, th { padding-left: 10px }
.edit-link {
font-size: smaller;
opacity: 60%;
}
.edit-link { font-size: 0.85rem; opacity: 0.7; }
.list-group-item .fw-bold { font-size: 0.95rem; }
.card-title { font-size: 1rem; }
/* keep existing small visual tweaks */
.sort-caret { font-weight: 600; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function(){
(function(){
const sortGroup = document.querySelector('.user-sort-group');
if(!sortGroup) return;
let sortState = { key: 'username', dir: 'asc' };
function updateCarets(){
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){
const key = b.dataset.key;
const caret = b.querySelector('.sort-caret');
if(!caret) return;
caret.textContent = (sortState.key === key) ? (sortState.dir === 'asc' ? ' ▲' : ' ▼') : '';
});
}
function getVal(li, key){
if(!li) return '';
return (li.dataset[key] || '').toString().toLowerCase();
}
function sortList(key){
const ul = document.getElementById('group-users-list'); if(!ul) return;
const items = Array.from(ul.children);
const dir = (sortState.key === key && sortState.dir === 'asc') ? 'desc' : 'asc';
items.sort(function(a,b){ const va = getVal(a,key); const vb = getVal(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); });
sortState = { key: key, dir: dir };
updateCarets();
}
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortList(b.dataset.key); }); });
updateCarets();
})();
});
</script>
<script>
// Send/resend login email handlers for CID users
document.addEventListener('DOMContentLoaded', function(){
document.body.addEventListener('click', function(e){
const btn = e.target.closest && e.target.closest('.send-login-email-btn');
if(!btn) return;
e.preventDefault();
const url = btn.dataset.url + '?resend=1';
const cid = btn.dataset.cid;
// show spinner on button
btn.disabled = true;
const spinner = document.createElement('span'); spinner.className = 'spinner-border spinner-border-sm ms-2'; spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true'); btn.appendChild(spinner);
fetch(url, { method: 'GET', credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const li = btn.closest('li');
if(!li) return;
const status = li.querySelector('.login-email-status');
if(data.sent){
if(status){ status.textContent = 'Email sent'; status.className = 'badge bg-success ms-1 login-email-status'; }
} else {
if(status){ status.textContent = 'Not sent'; status.className = 'badge bg-secondary ms-1 login-email-status'; }
if(data.comment){ console.error('Email send comment:', data.comment); }
}
}).catch(err => { console.error('Failed to send login email', err); })
.finally(()=>{ if(btn){ const s = btn.querySelector('.spinner-border'); if(s) s.remove(); btn.disabled = false; } });
});
});
// Visual indicators for HTMX add/remove actions
document.addEventListener('DOMContentLoaded', function(){
document.body.addEventListener('htmx:beforeRequest', function(evt){
try{
const elt = evt.detail.elt;
if(!elt) return;
const btn = elt.closest && elt.closest('.hx-action-btn') ? elt.closest('.hx-action-btn') : (elt.classList && elt.classList.contains('hx-action-btn') ? elt : null);
if(!btn) return;
// stash original HTML for restore
btn.dataset._origHtml = btn.innerHTML;
btn.disabled = true;
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true');
spinner.dataset._hx_spinner = '1';
btn.appendChild(spinner);
}catch(e){ console.error(e); }
});
document.body.addEventListener('htmx:afterRequest', function(evt){
try{
const elt = evt.detail.elt;
if(!elt) return;
const btn = elt.closest && elt.closest('.hx-action-btn') ? elt.closest('.hx-action-btn') : (elt.classList && elt.classList.contains('hx-action-btn') ? elt : null);
if(!btn) return;
// remove spinner(s)
const sp = btn.querySelectorAll('span[data-_hx_spinner]');
sp.forEach(s => s.remove());
// show transient status
const status = document.createElement('span');
status.className = 'badge bg-success ms-2 hx-action-status';
status.textContent = 'Done';
btn.appendChild(status);
// restore after short delay
setTimeout(function(){
if(btn.dataset._origHtml){ btn.innerHTML = btn.dataset._origHtml; delete btn.dataset._origHtml; }
btn.disabled = false;
}, 1500);
}catch(e){ console.error(e); }
});
});
</script>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "generic/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>Edit CidUserExam record</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary">Save</button>
</form>
{% endblock %}
@@ -23,28 +23,46 @@
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
{% csrf_token %}
<table>
{{ form.as_table }}
</table>
<input type="submit" class="submit-button" value="Submit" name="submit">
<div class="card mb-3">
<div class="card-body">
<div class="row">
<div class="col-12 col-md-8">
{% for field in form.visible_fields %}
<div class="mb-3">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<div class="form-text text-muted">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="text-danger small">{{ field.errors|striptags }}</div>
{% endif %}
</div>
{% endfor %}
<div class="mt-2">
<button type="submit" class="btn btn-primary btn-sm">Save group</button>
<a href="{% url 'generic:manage_cids' %}" class="btn btn-link btn-sm ms-2">Manage CIDs</a>
</div>
</div>
<div class="col-12 col-md-4">
{% if cidusergroup %}
<p>Users can be added to the group <a href="{% url 'generic:manage_cids' %}">here</a></p>
<h3>Users</h3>
<ul>
<div class="small text-muted mb-2">Users currently in this group</div>
<ul class="list-group list-group-flush small">
{% for user in cidusergroup.ciduser_set.all %}
<li><a href="{% url 'generic:update_cid' user.pk %}">{{user.cid}}</a>: {{user.name}} [{{user.email}} {{user.last_name}}] {{user.userprofile.grade}}</li>
<li class="list-group-item bg-transparent border-0 px-0 py-1">
<a href="{% url 'generic:update_cid' user.pk %}">{{ user.cid }}</a>
<div class="text-muted">{{ user.name }} &middot; {{ user.email }}</div>
</li>
{% empty %}
<li>Group currently has no users.</li>
<li class="list-group-item bg-transparent border-0 px-0 py-1 text-muted">Group currently has no users.</li>
{% endfor %}
</ul>
Count: {{cidusergroup.ciduser_set.count}}
<div class="small text-muted mt-2">Count: {{ cidusergroup.ciduser_set.count }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</form>
{% endblock %}
+284 -127
View File
@@ -1,147 +1,304 @@
<div id="history-modal"
class="modal modal-blur fade"
style="display: none"
aria-hidden="false"
tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<h2>Exam user history:
<span id="history-user"></span>
</h2>
<div class="modal-content"></div>
</div>
</div>
<div class="card text-white bg-dark">
<h3>CID candidates</h3>
{% if cid_users %}
<p>{{cid_user_count}} CID candidates.</p>
<div id="htmx-info"></div>
<ol class="cid-candidate-list">
{% for cid in cid_users %}
<li class="cid-user" data-cid={{cid.cid}}><div class="cid-data">
<i class="bi bi-clock-history"
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 href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}]</div><div class="cid-name">{{cid.name}}</div> <div class="cid-email">Email: {{cid.email}}</div>
</li>
{% endfor %}
</ol>
{% else %}
<p>This exam has no CID candidates. Add some with the below link.</p>
{% endif %}
<a href="{% url exam.app_name|add:':exam_cids_edit' exam.pk %}">Edit Exam CIDs</a>
</div>
<br/>
<div class="card text-white bg-dark">
<h3>User candidates</h3>
{% if user_users %}
<p>{{user_user_count}} User candidates.</p>
<ol class="user-candidate-list">
{% for user in user_users %}
<li class="user-user" data-user="{{user.username}}">
{% block content %}
<div class="container py-3">
<div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center">
<h3 class="mb-0">Candidates for {{ exam.get_exam_name }}</h3>
<div>
<i class="bi bi-clock-history"
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 href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
{{user.username}}</div>
<div>Email: {{user.email}}</div>
</li>
{% endfor %}
</ol>
{% else %}
<p>This exam has no User candidates. Add some with the below link.</p>
{% endif %}
<a href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
<a class="btn btn-sm btn-outline-light" href="{% url exam.app_name|add:':exam_cids_edit' exam.pk %}">Edit Exam CIDs</a>
<a class="btn btn-sm btn-outline-light ms-2" href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
</div>
</div>
</div>
<br/>
{% if user_exam_data %}
<div class="card text-white bg-dark">
<details>
<summary>
<h3>Submitted candidates</h3>
</summary>
The following candidate submissions have been received. Candidates with submissions will be shown in green above.
<ul id="submitted-candidates">
{% 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 time: {{user_data.start_time}} /
End time: {{user_data.end_time}} /
<div class="row">
<div class="col-lg-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">CID candidates <small class="text-muted">({{ cid_user_count }})</small></h5>
<div class="mb-2 input-group input-group-sm">
<input id="cid-filter" class="form-control form-control-sm" placeholder="Filter CID candidates by CID, name, email or passcode">
<button type="button" id="cid-filter-clear" class="btn btn-outline-secondary d-none" title="Clear filter">×</button>
<span id="cid-filter-indicator" class="input-group-text d-none">Filtered</span>
</div>
<div id="htmx-info"></div>
<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">
<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>
{% endfor %}
</ul>
</details>
</div>
<br/>
{% endif %}
<span
title="View all user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status' exam.id %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
>
Exam history <i class="bi bi-clock-history"></i>
</span>
</div>
</div>
</div>
<div class="col-lg-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">User candidates <small class="text-muted">({{ user_user_count }})</small></h5>
<div class="mb-2 input-group input-group-sm">
<input id="user-filter" class="form-control form-control-sm" placeholder="Filter User candidates by username or email">
<button type="button" id="user-filter-clear" class="btn btn-outline-secondary d-none" title="Clear filter">×</button>
<span id="user-filter-indicator" class="input-group-text d-none">Filtered</span>
</div>
<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">
<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>
</div>
</div>
</div>
</div>
</div>
{% 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>
</div>
</div>
{% endif %}
<div class="row mt-3">
<div class="col-12">
<button class="btn btn-sm btn-outline-secondary" title="View all user exam history" hx-get="{% url exam.app_name|add:':exam_user_status' exam.id %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal">Exam history <i class="bi bi-clock-history"></i></button>
</div>
</div>
<!-- HTMX modal for history / partials -->
<div id="history-modal" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Exam user history: <span id="history-user"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script>
$(document).ready(function() {
// Highlight users that have submitted answers
$("#submitted-candidates li").each(function(n, el) {
console.log(el);
if (el.dataset.cid != "None") {
$(`.cid-user[data-cid=${el.dataset.cid}]`).addClass("submitted")
}
if (el.dataset.user != "None") {
$(`.user-user[data-user="${el.dataset.user}"]`).addClass("submitted")
(function(){
// Utilities to run both on initial load and after HTMX swaps
function normalizeText(s){ return (s||'').toString().toLowerCase(); }
function filterList(inputElem, listSelector, indicatorId, clearBtnId){
const q = normalizeText(inputElem.value.trim());
document.querySelectorAll(listSelector).forEach(function(li){
const text = normalizeText(li.textContent || '');
if(!q || text.indexOf(q) !== -1){
try{ li.style.removeProperty('display'); }catch(e){}
li.classList.remove('d-none');
} else {
try{ li.style.setProperty('display','none','important'); }catch(e){ li.style.display = 'none'; }
li.classList.add('d-none');
}
});
})
// Toggle indicator & clear button
try{
const ind = document.getElementById(indicatorId);
const clearBtn = document.getElementById(clearBtnId);
if(ind) ind.classList.toggle('d-none', !q);
if(clearBtn) clearBtn.classList.toggle('d-none', !q);
}catch(e){}
}
function initTooltips(){
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el){
try{ new bootstrap.Tooltip(el); }catch(e){/* ignore if bootstrap not available */}
});
}
function highlightSubmitted(){
// Find any submitted rows (table rows or list items) that include
// data attributes for cid/user. Then insert a small badge on matching list items.
// Collect submitted identifiers first to avoid duplicate processing
const submittedEls = document.querySelectorAll('#submitted-candidates [data-cid], #submitted-candidates [data-user]');
// Build maps of submitted identifiers -> their timestamps so we
// can attach tooltip text to the badges we create below.
const submittedCids = new Map();
const submittedUsers = new Map();
submittedEls.forEach(function(el){
try{
const cid = el.dataset && el.dataset.cid ? el.dataset.cid.toString() : null;
const user = el.dataset && el.dataset.user ? el.dataset.user.toString() : null;
const first = el.dataset && el.dataset.first ? el.dataset.first.toString() : '';
const last = el.dataset && el.dataset.last ? el.dataset.last.toString() : '';
if(cid && cid !== 'None') submittedCids.set(cid, {first:first, last:last});
if(user && user !== 'None') submittedUsers.set(user, {first:first, last:last});
}catch(e){ console.error('highlightSubmitted collect error', e); }
});
// Helper to ensure a single badge exists inside an li and update its tooltip
function ensureBadge(li, selector, className, text, title){
// Prefer the explicit badge container if present
const container = li.querySelector('.badge-container') || li;
let b = container.querySelector(selector);
if(!b){
b = document.createElement('span');
b.className = className;
b.textContent = text;
container.appendChild(b);
}
if(title){
b.setAttribute('title', title);
b.setAttribute('data-bs-toggle', 'tooltip');
}
return b;
}
// Add/update badges for CID list items
if(submittedCids.size){
document.querySelectorAll('#cid-list .list-group-item').forEach(function(li){
try{
const link = li.querySelector && li.querySelector('.cid-link');
const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim();
if(submittedCids.has(text)){
const info = submittedCids.get(text) || {first:'', last:''};
// add Started badge if start time exists
if(info.first){
const t = `Started\nFirst: ${info.first}`;
ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t);
}
// add Submitted badge if end time exists (or always keep submitted)
const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : 'Submitted';
ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Submitted', title);
}
}catch(e){ console.error('highlightSubmitted CID item error', e); }
});
}
// Add/update badges for User list items
if(submittedUsers.size){
document.querySelectorAll('#user-list .list-group-item').forEach(function(li){
try{
const txt = (li.textContent||'').trim();
for(const user of submittedUsers.keys()){
if(txt.indexOf(user) !== -1){
const info = submittedUsers.get(user) || {first:'', last:''};
if(info.first){
const t = `Started\nFirst: ${info.first}`;
ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t);
}
const title = (info.first || info.last) ? `Submitted\nFirst: ${info.first}\nLast: ${info.last}` : 'Submitted';
ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Submitted', title);
break;
}
}
}catch(e){ console.error('highlightSubmitted User item error', e); }
});
}
}
// Wire filters
document.addEventListener('DOMContentLoaded', function(){
const cidFilter = document.getElementById('cid-filter');
if(cidFilter && !cidFilter.dataset.bound){
cidFilter.addEventListener('input', function(){ filterList(cidFilter, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); });
cidFilter.dataset.bound = '1';
}
const userFilter = document.getElementById('user-filter');
if(userFilter && !userFilter.dataset.bound){
userFilter.addEventListener('input', function(){ filterList(userFilter, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); });
userFilter.dataset.bound = '1';
}
// Clear buttons
const cidClear = document.getElementById('cid-filter-clear');
if(cidClear && !cidClear.dataset.bound){
cidClear.addEventListener('click', function(){ const f=document.getElementById('cid-filter'); if(f){ f.value=''; filterList(f, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); }});
cidClear.dataset.bound = '1';
}
const userClear = document.getElementById('user-filter-clear');
if(userClear && !userClear.dataset.bound){
userClear.addEventListener('click', function(){ const f=document.getElementById('user-filter'); if(f){ f.value=''; filterList(f, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); }});
userClear.dataset.bound = '1';
}
// initial runs (may be no partials yet)
initTooltips();
highlightSubmitted();
});
// 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();
// Re-run current filters for newly-loaded partials
try{
const cf = document.getElementById('cid-filter');
if(cf) filterList(cf, '#cid-list .list-group-item');
const uf = document.getElementById('user-filter');
if(uf) filterList(uf, '#user-list .list-group-item');
}catch(e){ /* ignore */ }
}
}catch(e){ console.error(e); }
});
})();
</script>
{% endblock js %}
{% endblock %}
{% block css %}
<style>
.submitted {
color: green;
}
.cid-name { margin-top: 0.25rem; }
.cid-link { font-size: 1.05rem; }
.submitted { color: green; }
.cid-notes { font-style: italic; color: #6c757d; }
</style>
{% endblock css %}
{% endblock %}
+261 -78
View File
@@ -1,97 +1,280 @@
{% include "generic/exam_candidate_headers.html" %}
<h3>CID Users</h3>
<div class="card mb-2 bg-dark text-light border-0">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-0">CID Users</h5>
{% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
<div class="small text-muted mt-1">Users from the following groups are available:
{% for group in groups %}
<a href='{{group.get_absolute_url}}' class="badge bg-light text-dark border ms-1">{{group}}</a>
{% endfor %}
</div>
{% else %}
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.
<div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
{% endif %}
<ol>
{% for cid in current_cid_users %}
<li class="current"><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</li>
{% endfor %}
{% for cid in available_cid_users %}
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</li>
{% endfor %}
</ol>
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
<p>{% if current_cid_users or available_cid_users %}
<button class="toggle-all-btn">Toggle all</button>
</div>
<div class="btn-group">
{% if current_cid_users or available_cid_users %}
<button class="btn btn-sm btn-outline-light toggle-all-btn">Toggle all</button>
{% endif %}
<button class="add-all-btn">Add all</button>
<button class="remove-all-btn">Remove all</button>
<button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Add all</button>
<button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Remove all</button>
</div>
</div>
</p>
<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" role="group" aria-label="Sort">
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="cid">CID <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/cid_user_list.html' with exam=exam current_cid_users=current_cid_users available_cid_users=available_cid_users %}
</div>
</div>
<script>
$(document).ready(() => {
$(".remove-all-btn").click((el) => {
$(".current .toggle-btn").click();
});
$(".add-all-btn").click((el) => {
$(":not(.current) .toggle-btn").click();
});
$(".toggle-all-btn").click((el) => {
$(".toggle-btn").click();
});
$(".toggle-btn").click((el) => {
let parent = $(el.target).parent()
$.ajax({
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
data: {
csrfmiddlewaretoken: "{{csrf_token}}",
edit_cid_user: el.target.dataset.pk,
add: !parent.hasClass("current"),
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log("done", data);
document.addEventListener('DOMContentLoaded', function(){
// Bulk Add / Remove using a single HTMX POST
const bulkForm = document.createElement('form');
bulkForm.id = 'bulk-cid-form';
bulkForm.className = 'd-none';
bulkForm.setAttribute('hx-target', '#cid-users-wrap');
bulkForm.setAttribute('hx-swap', 'outerHTML');
const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input'; bulkForm.appendChild(bulkInput);
const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input'; bulkForm.appendChild(bulkAddInput);
// include csrf token
const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
document.body.appendChild(bulkForm);
if (data.status == "success") {
if (data.added) {
parent.addClass("current");
toastr.info(`User ${el.target.dataset.cid} added to exam`)
function setLoading(btn, loading){
if(!btn) return;
try{
if(loading){
btn.dataset._origDisabled = btn.disabled ? '1' : '0';
btn.disabled = true;
const spinner = document.createElement('span');
spinner.className = 'btn-spinner spinner-border spinner-border-sm ms-2';
spinner.setAttribute('role','status');
spinner.setAttribute('aria-hidden','true');
btn.appendChild(spinner);
} else {
parent.removeClass("current");
toastr.info(`User ${el.target.dataset.cid} removed from exam`)
}
if(typeof btn.dataset._origDisabled !== 'undefined'){
btn.disabled = btn.dataset._origDisabled === '1';
delete btn.dataset._origDisabled;
} else {
toastr.error(data.status)
btn.disabled = false;
}
const s = btn.querySelector('.btn-spinner'); if(s) s.remove();
}
}catch(e){ /* ignore */ }
}
})
.fail(function (data) {
console.log("fail", data);
toastr.error(data.responseJSON.status)
})
.always(function () {
})
function submitBulk(url, pks, add, triggeringButton){
// prefer HTMX if available
if (window.htmx && typeof htmx.submit === 'function'){
if(triggeringButton) setLoading(triggeringButton, true);
bulkForm.setAttribute('hx-post', url);
bulkInput.value = JSON.stringify(pks);
if (add === null) {
bulkAddInput.disabled = true;
} else {
bulkAddInput.disabled = false;
bulkAddInput.value = add ? 'true' : 'false';
}
htmx.submit(bulkForm);
return;
}
// fallback: use fetch and swap the list manually
if(triggeringButton) setLoading(triggeringButton, true);
const fd = new FormData();
fd.append('bulk_pks', JSON.stringify(pks));
if (add !== null) fd.append('add', add ? 'true' : 'false');
const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
const headers = { 'X-CSRFToken': csrf };
fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' })
.then(resp => resp.text().then(text => ({ resp, text })))
.then(({ resp, text }) => {
// swap the list
try{
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
// Prefer replacing the whole wrapper (contains counts + list)
const newWrap = doc.querySelector('#cid-users-wrap');
if (newWrap){
const oldWrap = document.querySelector('#cid-users-wrap');
if (oldWrap) oldWrap.replaceWith(newWrap);
} else {
const newList = doc.querySelector('#cid-users-list');
if (newList){
const old = document.querySelector('#cid-users-list');
if (old) old.replaceWith(newList);
}
}
}catch(e){ console.error('bulk swap failed', e); }
// dispatch HX-Trigger equivalent if present
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('bulk request failed', err); })
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
}
document.querySelectorAll('.add-all-btn').forEach(function(btn){
btn.addEventListener('click', function(){
const url = btn.dataset.bulkUrl;
const items = document.querySelectorAll('#cid-users-list li:not(.current)');
const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number);
if (!pks.length) return;
submitBulk(url, pks, true, btn);
});
});
document.querySelectorAll('.remove-all-btn').forEach(function(btn){
btn.addEventListener('click', function(){
if(!confirm('Remove all selected users from the exam?')) return;
const url = btn.dataset.bulkUrl;
const items = document.querySelectorAll('#cid-users-list li.current');
const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number);
if (!pks.length) return;
submitBulk(url, pks, false, btn);
});
});
})
// Toggle all: send mixed per-item {pk, add} payload so server can apply add/remove per item
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){
btn.addEventListener('click', function(){
const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl;
// build mixed list: for each li include {pk, add}
const items = Array.from(document.querySelectorAll('#cid-users-list li')).map(function(li){
return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') };
});
if (!items.length) return;
submitBulk(url, items, null, btn);
});
});
// HTMX: listen for server HX-Trigger to show toasts for toggles and update counts
document.body.addEventListener('cid_toggled', function(evt){
try {
const detail = evt.detail || {};
const added = detail.added;
if (window.toastr) {
if (added) toastr.info(`User added to exam`);
else toastr.info(`User removed from exam`);
}
// Update counts for single-item toggles (bulk swaps replace the whole wrapper)
const addedEl = document.getElementById('cid-count-added');
const availEl = document.getElementById('cid-count-available');
if (addedEl && availEl){
let addedCount = parseInt(addedEl.textContent || '0') || 0;
let availCount = parseInt(availEl.textContent || '0') || 0;
if (added){ addedCount += 1; availCount = Math.max(0, availCount - 1); }
else { addedCount = Math.max(0, addedCount - 1); availCount += 1; }
addedEl.textContent = String(addedCount);
availEl.textContent = String(availCount);
}
} catch(e) { /* ignore */ }
});
// HTMX: clear loading indicators when requests finish
document.body.addEventListener('htmx:afterRequest', function(evt){
try{
const elt = (evt.detail && evt.detail.elt) || null;
let btn = null;
if(elt){
if(elt.tagName === 'BUTTON') btn = elt;
else if(elt.tagName === 'FORM') btn = elt.querySelector('button[type=submit]');
else btn = elt.closest && elt.closest('button') ? elt.closest('button') : null;
}
if(btn) setLoading(btn, false);
}catch(e){ }
});
// Delegate toggle button clicks so newly-inserted items work
document.body.addEventListener('click', function(e){
const btn = e.target.closest && e.target.closest('.toggle-btn');
if(!btn) return;
// find enclosing form
const form = btn.closest('form');
if(!form) return;
e.preventDefault();
setLoading(btn, true);
if(window.htmx && typeof htmx.submit === 'function'){
htmx.submit(form);
return;
}
// fallback: send via fetch and swap the nearest li with returned HTML
(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 to parse HTML and replace the closest li
try{
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
// the server should return the li outerHTML
const newLi = doc.querySelector('li');
const oldLi = form.closest('li');
if(newLi && oldLi){ oldLi.replaceWith(newLi); }
// dispatch any HX-Trigger header events
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: pure JS implementation
let currentSort = { key: 'cid', dir: 'asc' };
function updateCarets(){
document.querySelectorAll('.sort-btn').forEach(function(b){
const key = b.dataset.key;
const caret = b.querySelector('.sort-caret');
if (caret) caret.textContent = (currentSort.key === key) ? (currentSort.dir === 'asc' ? ' ▲' : ' ▼') : '';
});
}
function sortList(key){
const ul = document.getElementById('cid-users-list');
const items = Array.from(ul.children);
const dir = (currentSort.key === key && currentSort.dir === 'asc') ? 'desc' : 'asc';
items.sort(function(a,b){
const va = (a.dataset[key] || '').toString().toLowerCase();
const vb = (b.dataset[key] || '').toString().toLowerCase();
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); });
currentSort = { key: key, dir: dir };
updateCarets();
}
document.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortList(b.dataset.key); }); });
updateCarets();
});
</script>
<style>
.current {
color: lightgray;
}
.current::before {
content: "[ADDED TO EXAM] - "
}
button {
padding: 0;
}
<style>
/* Compact, dark-mode adjustments */
.card.bg-dark { background-color: #111 !important; }
.card-body { padding: .5rem !important; }
.list-group-item { padding: .35rem .5rem; font-size: .88rem; border-left: 0; border-right: 0; }
.list-group-item.current { background: rgba(255,255,255,0.03); border-left: 4px solid rgba(13,110,253,0.25); }
.list-group-item .small { display:block; color: #bfc6cf; }
.btn-group .btn { padding: .25rem .45rem; font-size: .8rem; }
.btn-outline-light { color: #e9ecef; border-color: rgba(233,236,239,0.12); }
.btn-outline-light:hover { color:#fff; background: rgba(233,236,239,0.06); }
</style>
@@ -41,7 +41,7 @@
<div class="mt-2 small text-muted">
This exam has <strong>{{ question_number }}</strong> questions &nbsp;&nbsp;
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">per question</span>)
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">1Q</span>)
</div>
<div class="mt-2 small text-muted">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
+28 -12
View File
@@ -39,17 +39,17 @@
</div>
<div id="stats-plot">{{plot|safe}}</div>
<div id="stats-block" title="low scores (<20% max) are excluded from statistics">
<h3>Stats</h3>
Candidates: {{cids|length}}<br />
Questions: <span id="question-number">{{question_number}}</span><br />
<span title="Max score">Available marks: {{max_score}}</span><br />
Mean: {{mean}}, Median {{median}}, Mode {{mode}}<br />
Top score: {{exam.stats_max}}
<div id="stats-block" title="low scores (<20% max) are excluded from statistics" class="mb-2 small">
<h5 class="mb-1">Stats</h5>
<div>Candidates: <span class="fw-semibold">{{cids|length}}</span></div>
<div>Questions: <span id="question-number">{{question_number}}</span></div>
<div title="Max score">Available marks: {{max_score}}</div>
<div>Mean: {{mean}}, Median {{median}}, Mode {{mode}}</div>
<div>Top score: {{exam.stats_max}}</div>
</div>
<div>
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable">
<div class="mb-3">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable small">
<tr>
<th title="click to sort by candidate / user id">Candidate ID</th>
<th title="click to sort by score">Score</th>
@@ -80,9 +80,17 @@
{% endfor %}
</table>
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 class="mb-0">Answers as a table</h5>
<div>
<h3>Answers as a table</h3>
<table class="table table-dark table-striped table-hover table-sm col-sm">
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#answers-table" aria-expanded="true">Toggle answers</button>
</div>
</div>
<div class="collapse show" id="answers-table">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover table-sm col-sm small">
<thead class="thead-dark">
<tr>
<th>Candidate</th>
@@ -128,6 +136,8 @@
{% endfor %}
</tr>
</table>
</div>
</div>
{% block breakdown %}{% endblock breakdown %}
@@ -192,6 +202,12 @@
display: inline-block;
color: darkblue;
}
/* Reduce vertical spacing for dense score tables */
.cid-score-table td, .cid-score-table th, table.table-sm td, table.table-sm th {
padding: .25rem .4rem;
line-height: 1;
}
#stats-block { font-size: .9rem; }
</style>
{% endblock %}
+97 -80
View File
@@ -1,103 +1,120 @@
{% include "generic/exam_candidate_headers.html" %}
<h3>Users</h3>
<div class="card mb-2 bg-dark text-light border-0">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-0">Users</h5>
{% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
<div class="small text-muted mt-1">Users from the following groups are available:
{% for group in groups %}
<a href='{{group.get_absolute_url}}' class="badge bg-light text-dark border ms-1">{{group}}</a>
{% endfor %}
</div>
{% else %}
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.
<div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
{% endif %}
<ol>
{% for user in current_user_users %}
<li class="current">{{user.username}} /
Email: {{user.email}}
{% if user.userprofile.grade %}
[{{user.userprofile.grade}}]
{% endif %}
{% if request.user.is_superuser %}
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
</li>
{% endfor %}
{% for user in available_user_users %}
<li>{{user.username}} /
Email: {{user.email}}
{% if user.userprofile.grade %}
[{{user.userprofile.grade}}]
{% endif %}
{% if request.user.is_superuser %}
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
</li>
{% endfor %}
</ol>
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
<p>
</div>
<div class="btn-group">
{% if current_user_users or available_user_users %}
<button class="toggle-all-btn">Toggle all</button>
<button class="btn btn-sm btn-outline-light toggle-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Toggle all</button>
{% endif %}
<button class="add-all-btn">Add all</button>
<button class="remove-all-btn">Remove all</button>
<button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Add all</button>
<button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Remove all</button>
</div>
</div>
</p>
<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 %}
</div>
</div>
<script>
$(document).ready(() => {
$(".remove-all-btn").click((el) => {
$(".current .toggle-btn").click();
});
$(".add-all-btn").click((el) => {
$("li:not(.current) .toggle-btn").click();
});
$(".toggle-all-btn").click((el) => {
$(".toggle-btn").click();
});
$(".toggle-btn").click((el) => {
parent = $(el.target).parent()
$.ajax({
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
data: {
csrfmiddlewaretoken: "{{csrf_token}}",
edit_user_user: el.target.dataset.pk,
add: !parent.hasClass("current"),
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
// Mirror the HTMX-enabled behavior used for CID users.
document.addEventListener('DOMContentLoaded', function(){
// Bulk form for users
const bulkForm = document.createElement('form');
bulkForm.id = 'bulk-user-form';
bulkForm.className = 'd-none';
bulkForm.setAttribute('hx-target', '#user-users-wrap');
bulkForm.setAttribute('hx-swap', 'outerHTML');
const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input_user'; bulkForm.appendChild(bulkInput);
const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input_user'; bulkForm.appendChild(bulkAddInput);
const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
document.body.appendChild(bulkForm);
if (data.status == "success") {
if (data.added) {
parent.addClass("current");
toastr.info(`User ${el.target.dataset.user} added to exam`)
} else {
parent.removeClass("current");
toastr.info(`User ${el.target.dataset.user} removed from exam`)
function setLoading(btn, loading){ if(!btn) return; try{ if(loading){ btn.dataset._origDisabled = btn.disabled ? '1':'0'; btn.disabled = true; const spinner = document.createElement('span'); spinner.className='btn-spinner spinner-border spinner-border-sm ms-2'; spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true'); btn.appendChild(spinner);} else { if(typeof btn.dataset._origDisabled !== 'undefined'){ btn.disabled = btn.dataset._origDisabled === '1'; delete btn.dataset._origDisabled;} else { btn.disabled = false;} const s = btn.querySelector('.btn-spinner'); if(s) s.remove(); } }catch(e){} }
function submitBulk(url, pks, add, triggeringButton){
if (window.htmx && typeof htmx.submit === 'function'){
if(triggeringButton) setLoading(triggeringButton, true);
bulkForm.setAttribute('hx-post', url);
bulkInput.value = JSON.stringify(pks);
if (add === null) bulkAddInput.disabled = true; else { bulkAddInput.disabled = false; bulkAddInput.value = add ? 'true' : 'false'; }
htmx.submit(bulkForm); return;
}
if(triggeringButton) setLoading(triggeringButton, true);
const fd = new FormData(); fd.append('bulk_pks', JSON.stringify(pks)); if (add !== null) fd.append('add', add ? 'true' : 'false'); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value; const headers = { 'X-CSRFToken': csrf };
fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' })
.then(resp => resp.text().then(text => ({ resp, text })))
.then(({ resp, text }) => { try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newWrap = doc.querySelector('#user-users-wrap'); if (newWrap){ const oldWrap = document.querySelector('#user-users-wrap'); if (oldWrap) oldWrap.replaceWith(newWrap); } else { const newList = doc.querySelector('#user-users-list'); if (newList){ const old = document.querySelector('#user-users-list'); if (old) old.replaceWith(newList); } } }catch(e){ console.error('bulk swap failed', e);} 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('bulk request failed', err))
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
}
})
.always(function () {
})
const defaultBulkUrl = "{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}";
document.querySelectorAll('.add-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ const url = btn.dataset.bulkUrl || defaultBulkUrl; const items = document.querySelectorAll('#user-users-list li:not(.current)'); const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number); if (!pks.length) return; submitBulk(url, pks, true, btn); }); });
document.querySelectorAll('.remove-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ if(!confirm('Remove all selected users from the exam?')) return; const url = btn.dataset.bulkUrl || defaultBulkUrl; const items = document.querySelectorAll('#user-users-list li.current'); const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number); if (!pks.length) return; submitBulk(url, pks, false, btn); }); });
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl || defaultBulkUrl; const items = Array.from(document.querySelectorAll('#user-users-list li')).map(function(li){ return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') }; }); if (!items.length) return; submitBulk(url, items, null, btn); }); });
// Listen for single-item toggle events to update counts and show toasts
document.body.addEventListener('user_toggled', function(evt){ try { const detail = evt.detail || {}; const added = detail.added; if (window.toastr) { if (added) toastr.info('User added to exam'); else toastr.info('User removed from exam'); } const addedEl = document.getElementById('user-count-added'); const availEl = document.getElementById('user-count-available'); if (addedEl && availEl){ let addedCount = parseInt(addedEl.textContent || '0') || 0; let availCount = parseInt(availEl.textContent || '0') || 0; if (added){ addedCount += 1; availCount = Math.max(0, availCount - 1); } else { addedCount = Math.max(0, addedCount - 1); availCount += 1; } addedEl.textContent = String(addedCount); availEl.textContent = String(availCount); } } catch(e){} });
// 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); } })(); });
// 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>
<style>
.current {
color: lightgray;
}
.current::after {
content: "[ADDED TO EXAM]"
}
/* removed decorative pseudo-element that showed [ADDED TO EXAM] */
button {
padding: 0;
@@ -22,7 +22,7 @@
{% if object.anatomy_exams.all %}
{% if object.anatomy_count %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Anatomy Exams</strong>
@@ -31,16 +31,17 @@
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div>
</div>
<div class="card-body p-2">
{% with exams=object.anatomy_exams.all app_name="anatomy" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'anatomy' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading anatomy exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
<div class="card-footer add-marker small"></div>
</div>
{% endif %}
{% if object.longs_exams.all %}
{% if object.longs_count %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Longs Exams</strong>
@@ -49,16 +50,17 @@
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div>
</div>
<div class="card-body p-2">
{% with exams=object.longs_exams.all app_name="longs" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'longs' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading longs exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
<div class="card-footer add-marker small"></div>
</div>
{% endif %}
{% if object.rapids_exams.all %}
{% if object.rapids_count %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Rapids Exams</strong>
@@ -67,16 +69,17 @@
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div>
</div>
<div class="card-body p-2">
{% with exams=object.rapids_exams.all app_name="rapids" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'rapids' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading rapids exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
<div class="card-footer add-marker small"></div>
</div>
{% endif %}
{% if object.shorts_exams.all %}
{% if object.shorts_count %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Shorts Exams</strong>
@@ -85,39 +88,42 @@
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div>
</div>
<div class="card-body p-2">
{% with exams=object.shorts_exams.all app_name="shorts" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'shorts' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading shorts exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
<div class="card-footer add-marker small"></div>
</div>
{% endif %}
{% if object.physics_exams.all %}
{% if object.physics_count %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Physics Exams</strong>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a>
</div>
<div class="card-body p-2">
{% with exams=object.physics_exams.all app_name="physics" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'physics' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading physics exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
</div>
{% endif %}
{% if object.sbas_exams.all %}
{% if object.sbas_count %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>SBAs Exams</strong>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a>
</div>
<div class="card-body p-2">
{% with exams=object.sbas_exams.all app_name="sbas" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'sbas' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading SBAs exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
</div>
{% endif %}
@@ -0,0 +1,22 @@
{% comment %} Preview partial for parsed rows; includes a form to create users {% endcomment %}
<div id="cid-generate-preview" class="mt-2">
<h6 class="small">Preview ({{ rows|length }} rows)</h6>
<div class="table-responsive">
<table class="table table-sm table-borderless">
<thead>
<tr><th>Name</th><th>Email</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr><td>{{ r.name }}</td><td>{{ r.email }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<form method="post" hx-post="{% url 'generic:cid_group_create_bulk' group.pk %}" hx-target="#group-users-wrap" hx-swap="outerHTML">
{% csrf_token %}
<textarea name="rows" class="d-none">{{ original|escape }}</textarea>
<button type="submit" class="btn btn-sm btn-primary">Create users and add to group</button>
</form>
</div>
@@ -0,0 +1,32 @@
<li class="list-group-item d-flex justify-content-between align-items-center {% if current %}current{% endif %} py-1 px-2 bg-transparent border-secondary" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}" data-name="{{cid.name|escape}}" data-email="{{cid.email|escape}}">
<div class="me-2">
<div class="d-flex align-items-center">
<div class="fw-bold small mb-0">{{cid.cid}} <span class="text-muted">[{{cid.passcode}}]</span></div>
<span class="badge {% if current %}bg-success{% else %}bg-secondary text-dark{% endif %} ms-2 small">{% if current %}Added{% else %}Not added{% endif %}</span>
</div>
<div class="small text-muted">{{cid.name}} &middot; {{cid.email}}</div>
</div>
<div class="d-flex align-items-center ms-2">
{% if cid.notes %}
<div class="small text-muted me-2">Notes:
<span class="cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">{{ cid.notes|truncatechars:60 }}</span>
</div>
{% endif %}
{% if cid.group and cid.group in exam.cid_user_groups.all %}
<div class="small text-muted me-2"><span class="badge bg-info text-dark">{{ cid.group.name }}</span></div>
{% endif %}
<div class="btn-group">
<a href="{% url 'generic:update_cid' cid.pk %}" class="btn btn-sm btn-outline-light">Edit</a>
<form method="post" class="d-inline m-0 p-0"
action="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
hx-post="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
hx-target="closest li"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="edit_cid_user" value="{{cid.pk}}">
<input type="hidden" name="add" value="{% if current %}false{% else %}true{% endif %}">
<button type="submit" class="btn btn-sm btn-outline-light toggle-btn">Toggle</button>
</form>
</div>
</div>
</li>
@@ -0,0 +1,14 @@
<div id="cid-users-wrap">
<div class="d-flex justify-content-between align-items-center mb-2 small text-muted">
<div>Added: <span id="cid-count-added">{{ current_cid_users|length }}</span></div>
<div>Available: <span id="cid-count-available">{{ available_cid_users|length }}</span></div>
</div>
<ul class="list-group list-group-flush" id="cid-users-list">
{% for cid in current_cid_users %}
{% include 'generic/partials/cid_user_li.html' with cid=cid current=True exam=exam %}
{% endfor %}
{% for cid in available_cid_users %}
{% include 'generic/partials/cid_user_li.html' with cid=cid current=False exam=exam %}
{% endfor %}
</ul>
</div>
@@ -0,0 +1,22 @@
<div class="p-3">
<h4>CidUserExam: {% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.cid }}{% elif cid_user_exam.user_user %}{{ cid_user_exam.user_user.username }}{% else %}-{% endif %}</h4>
<table class="table table-sm">
<tbody>
<tr><th>CID</th><td>{% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.cid }}{% else %}-{% endif %}</td></tr>
<tr><th>User</th><td>{% if cid_user_exam.user_user %}{{ cid_user_exam.user_user.username }}{% else %}-{% endif %}</td></tr>
<tr><th>Start time</th><td>{{ cid_user_exam.start_time }}</td></tr>
<tr><th>End time</th><td>{{ cid_user_exam.end_time }}</td></tr>
<tr><th>Completed</th><td>{{ cid_user_exam.completed }}</td></tr>
<tr><th>Manual submission</th><td>{{ cid_user_exam.manual_submission }}</td></tr>
<tr><th>Share with supervisor</th><td>{{ cid_user_exam.share_with_supervisor }}</td></tr>
<tr><th>Results emailed status</th><td>{{ cid_user_exam.results_emailed_status }}</td></tr>
<tr><th>CID User email</th><td>{% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.email }}{% else %}-{% endif %}</td></tr>
</tbody>
</table>
<div class="d-flex justify-content-end">
{% if cid_user_exam %}
<a class="btn btn-sm btn-outline-primary me-2" href="{% url 'generic:update_cid_user_exam' cid_user_exam.pk %}">Edit</a>
{% endif %}
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
@@ -0,0 +1,32 @@
{% 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 class="badge-container d-flex align-items-center ms-3"></div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">This exam has no CID candidates.</p>
{% endif %}
@@ -0,0 +1,50 @@
{% comment %} Partial: submitted candidates fragment for HTMX swap {% endcomment %}
{% if user_exam_data %}
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-0">Submitted candidates</h5>
<div class="small text-muted">The following candidate submissions have been received. Candidates with submissions are highlighted above.</div>
</div>
<div class="small text-muted">Count: <span class="fw-semibold">{{ user_exam_data|length }}</span></div>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th scope="col">CID</th>
<th scope="col">User</th>
<th scope="col">Start</th>
<th scope="col">End</th>
<th scope="col" class="text-end">Actions</th>
</tr>
</thead>
<tbody id="submitted-candidates">
{% for user_data in user_exam_data %}
<tr data-cid="{{ user_data.cid_user }}" data-user="{{ user_data.user_user }}" data-first="{{ user_data.start_time|default_if_none:'' }}" data-last="{{ user_data.end_time|default_if_none:'' }}" {% if not user_data.in_exam %}class="table-warning"{% endif %}>
<td class="fw-bold">{{ user_data.cid_user }}{% if not user_data.in_exam %} <span class="badge bg-danger ms-2" title="Candidate/User not added to exam">Not added</span>{% endif %}</td>
<td>{{ user_data.user_user }}</td>
<td class="text-nowrap small {% if user_data.in_exam %}text-muted{% else %}text-dark{% endif %}">{{ user_data.start_time }}</td>
<td class="text-nowrap small {% if user_data.in_exam %}text-muted{% else %}text-dark{% endif %}">{{ user_data.end_time }}</td>
<td class="text-end">
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" title="View details" hx-get="{% url exam.get_app_name|add:':exam_user_status' exam.id %}?cid={{ user_data.cid_user }}" hx-target="#history-modal .modal-body" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-eye"></i></button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body">
<p class="mb-0 small text-muted">No submissions yet.</p>
</div>
</div>
{% 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="badge-container d-flex align-items-center ms-3"></div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">This exam has no User candidates.</p>
{% endif %}
@@ -0,0 +1,6 @@
{% load partials %}
{% comment %} Minimal fragment rendering the exam-list partialdef for HTMX swaps. {% endcomment %}
{% with exams=exams app_name=app_name marking=marking collection=collection %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
@@ -1,30 +1,32 @@
{% load partials %}
{% partialdef publish-results %}
<div id="publish-results-status" class="d-flex align-items-center gap-2">
<div class="me-2 small text-muted">Publish results</div>
<div class="exam-status-container d-flex align-items-center gap-2">
<div class="me-2 small text-muted">Results</div>
<div>
{% if exam.publish_results %}
<button
class="btn btn-sm btn-success"
class="btn btn-sm btn-success published-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
hx-swap="outerHTML"
hx-target="#publish-results-status"
hx-target="closest .exam-status-container"
title="Click to unpublish results"
aria-label="Unpublish results"
hx-disabled-elt="this"
>Published</button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% else %}
<button
class="btn btn-sm btn-secondary"
class="btn btn-sm btn-secondary published-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
hx-swap="outerHTML"
hx-target="#publish-results-status"
hx-target="closest .exam-status-container"
title="Click to publish results"
aria-label="Publish results"
hx-disabled-elt="this"
>Unpublished</button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% endif %}
</div>
</div>
@@ -32,30 +34,60 @@
{% partialdef exam-active %}
<div id="exam-active-status" class="d-flex align-items-center gap-2">
<div class="me-2 small text-muted">Exam active</div>
<div class="exam-status-container d-flex align-items-center gap-2">
<div class="me-2 small text-muted">Exam status:</div>
<div>
{% if exam.active %}
<button
class="btn btn-sm btn-success"
class="btn btn-sm btn-success active-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
hx-swap="outerHTML"
hx-target="#exam-active-status"
hx-target="closest .exam-status-container"
title="Click to deactivate this exam"
aria-label="Deactivate exam"
hx-disabled-elt="this"
>Active</button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% else %}
<button
class="btn btn-sm btn-secondary"
class="btn btn-sm btn-secondary active-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
hx-swap="outerHTML"
hx-target="#exam-active-status"
hx-target="closest .exam-status-container"
title="Click to activate this exam"
aria-label="Activate exam"
hx-disabled-elt="this"
>Inactive</button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% endif %}
</div>
</div>
{% endpartialdef exam-active %}
{% partialdef exam-archived %}
<div class="exam-status-container d-flex align-items-center gap-2">
<div>
{% if exam.archive %}
<button
class="btn btn-sm btn-warning archived-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_archived' exam.pk %}"
hx-swap="outerHTML"
hx-target="closest .exam-status-container"
title="Click to unarchive this exam"
aria-label="Unarchive exam"
><i class="bi bi-archive"></i></button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% else %}
<button
class="btn btn-sm btn-secondary archived-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_archived' exam.pk %}"
hx-swap="outerHTML"
hx-target="closest .exam-status-container"
title="Click to archive this exam"
aria-label="Archive exam"
><i class="bi bi-archive"></i></button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% endif %}
</div>
</div>
{% endpartialdef %}
@@ -0,0 +1,140 @@
{% comment %} Partial: renders the users <ul> for group detail pages {% endcomment %}
<div class="d-flex align-items-center mb-2">
<div class="me-auto"></div>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary show-group-table-btn">Show table</button>
</div>
</div>
<ul id="group-users-list" class="list-group list-group-flush">
{% for user in users %}
<li class="list-group-item bg-transparent border-0 py-2"
data-username="{% if group_type == 'cid' %}{{ user.cid|default:''|escape }}{% else %}{{ user.username|default:''|escape }}{% endif %}"
data-name="{% if group_type == 'cid' %}{{ user.name|default:''|escape }}{% else %}{{ user.get_full_name|default:user.username|escape }}{% endif %}"
data-email="{{ user.email|default:''|escape }}"
{% if group_type == 'cid' %}data-cid="{{ user.cid|default:''|escape }}" data-passcode="{{ user.passcode|default:''|escape }}"{% endif %}>
{% 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>
{% if user.notes %}
<div class="small text-muted mt-1">Notes: {{ user.notes|truncatechars:120 }}</div>
{% endif %}
</div>
<div class="ms-auto text-end small text-muted">
<div>CID: {{ user.cid }}</div>
<div>Passcode: {{ user.passcode }}</div>
<div class="mt-1">
{% if user.login_email_sent %}
<span class="badge bg-success ms-1 login-email-status">Email sent</span>
{% else %}
<span class="badge bg-secondary ms-1 login-email-status">Not sent</span>
{% endif %}
{% if user.email %}
<button type="button" class="btn btn-sm btn-outline-primary ms-2 send-login-email-btn" data-cid="{{ user.cid }}" data-url="{% url 'generic:candidate_email_details' user.cid %}">Send Login Email</button>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ user.get_email_details_url }}?preview=1" target="_blank">View Login Email</a>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'generic:update_cid' pk=user.pk %}">Edit Cid User</a>
{% else %}
<span class="small text-muted ms-2">No email</span>
{% endif %}
</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>
<!-- Modal: Group users table / CSV export -->
<div class="modal fade" id="groupUsersModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Group users</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="table-responsive">
<table class="table table-sm" id="group-users-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Username / CID</th>
<th>Passcode</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-outline-primary" id="download-group-csv-btn">Download CSV</button>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
const showBtn = document.querySelector('.show-group-table-btn');
const modalEl = document.getElementById('groupUsersModal');
if(!showBtn || !modalEl) return;
const bsModal = new bootstrap.Modal(modalEl);
function buildTable(){
const tbody = modalEl.querySelector('#group-users-table tbody');
tbody.innerHTML = '';
const items = document.querySelectorAll('#group-users-list > li');
items.forEach(function(li){
const name = li.dataset.name || '';
const email = li.dataset.email || '';
const username = li.dataset.username || '';
const cid = li.dataset.cid || '';
const passcode = li.dataset.passcode || '';
const userCell = cid ? cid : username;
const tr = document.createElement('tr');
tr.innerHTML = `<td>${escapeHtml(name)}</td><td>${escapeHtml(email)}</td><td>${escapeHtml(userCell)}</td><td>${escapeHtml(passcode)}</td>`;
tbody.appendChild(tr);
});
}
function escapeHtml(s){
if(!s) return '';
return s.toString().replace(/&/g, '&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
showBtn.addEventListener('click', function(){
buildTable();
bsModal.show();
});
document.getElementById('download-group-csv-btn').addEventListener('click', function(){
const rows = [];
const header = ['Name','Email','Username/CID','Passcode'];
rows.push(header);
document.querySelectorAll('#group-users-table tbody tr').forEach(function(tr){
const cols = Array.from(tr.children).map(td => td.textContent || '');
rows.push(cols);
});
const csv = rows.map(r => r.map(cell => `"${(cell||'').toString().replace(/"/g,'""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const ts = new Date().toISOString().replace(/[:.]/g,'-');
a.download = `group-users-${ts}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
});
});
</script>
@@ -0,0 +1,30 @@
{% comment %}Partial for a single user group card. Rendered by HTMX after archive toggles.{% endcomment %}
<div class="card mb-3" id="group-card-{{ group.pk }}">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0"><a href="{% url 'generic:user_group_detail' group.pk %}">{{ group.name }}</a></h5>
{% if group.description %}
<p class="mb-0 text-muted small">{{ group.description }}</p>
{% endif %}
</div>
<div class="btn-group" role="group" aria-label="Group actions">
<a class="btn btn-sm btn-outline-primary" href="{% url 'generic:user_group_update' pk=group.pk %}">Edit</a>
<a class="btn btn-sm btn-outline-danger" href="{% url 'generic:user_group_delete' pk=group.pk %}">Delete</a>
<form hx-post="{% url 'generic:user_group_toggle_archive' pk=group.pk %}" hx-swap="outerHTML" method="post" class="d-inline-block ms-1">
{% csrf_token %}
{% if view_all %}
<input type="hidden" name="view_all" value="1">
{% endif %}
{% if group.archive %}
<input type="hidden" name="archive" value="false">
<button class="btn btn-sm btn-outline-success">Unarchive</button>
{% else %}
<input type="hidden" name="archive" value="true">
<button class="btn btn-sm btn-outline-secondary">Archive</button>
{% endif %}
</form>
</div>
</div>
</div>
@@ -0,0 +1,34 @@
<li class="list-group-item d-flex justify-content-between align-items-center {% if current %}current{% endif %} py-1 px-2 bg-transparent border-secondary" data-pk="{{ user.pk }}" data-username="{{ user.username }}" data-name="{{ user.get_full_name|default:user.username|escape }}" data-email="{{ user.email|escape }}">
<div class="me-2">
<div class="d-flex align-items-center">
<div class="fw-bold small mb-0">{{ user.username }}</div>
<span class="badge {% if current %}bg-success{% else %}bg-secondary text-dark{% endif %} ms-2 small">{% if current %}Added{% else %}Not added{% endif %}</span>
</div>
<div class="small text-muted">{{ user.get_full_name|default:user.username }} &middot; {{ user.email }}</div>
</div>
<div class="d-flex align-items-center ms-2">
{% comment %} show any exam user-groups this user belongs to as badges {% endcomment %}
<div class="me-2">
{% for g in exam.user_user_groups.all %}
{% if user in g.users.all %}
<span class="badge bg-info text-dark me-1">{{ g.name }}</span>
{% endif %}
{% endfor %}
</div>
<div class="btn-group">
{% if request.user.is_superuser %}
<a href='{% url "account_profile" user.username %}' class="btn btn-sm btn-outline-light">Profile</a>
{% endif %}
<form method="post" class="d-inline m-0 p-0"
action="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
hx-post="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
hx-target="closest li"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="edit_user_user" value="{{ user.pk }}">
<input type="hidden" name="add" value="{% if current %}false{% else %}true{% endif %}">
<button type="submit" class="btn btn-sm btn-outline-light toggle-btn">Toggle</button>
</form>
</div>
</div>
</li>
@@ -0,0 +1,14 @@
<div id="user-users-wrap">
<div class="d-flex justify-content-between align-items-center mb-2 small text-muted">
<div>Added: <span id="user-count-added">{{ current_user_users|length }}</span></div>
<div>Available: <span id="user-count-available">{{ available_user_users|length }}</span></div>
</div>
<ul class="list-group list-group-flush" id="user-users-list">
{% for user in current_user_users %}
{% include 'generic/partials/user_user_li.html' with user=user current=True exam=exam %}
{% endfor %}
{% for user in available_user_users %}
{% include 'generic/partials/user_user_li.html' with user=user current=False exam=exam %}
{% endfor %}
</ul>
</div>
+18 -31
View File
@@ -1,38 +1,25 @@
{% extends 'generic/base.html' %}
{% block content %}
<h2>User Groups</h2>
<p>
<a href="{% url 'generic:user_group_create' %}">Create new group</a>
</p>
{% for group in groups %}
<p><a href="{% url 'generic:user_group_detail' group.pk %}"><h4>{{group.name}}</h4></a>
{% comment %} <button class="small-url-button" data-url="{% url 'generic:group_email' pk=group.pk %}
">Email candidate details</button>
<button class="small-url-button" data-url="{% url 'generic:group_email_resend' pk=group.pk %}
">Resend candidate details</button>
<button class="small-url-button" data-url="{% url 'generic:group_email_results' pk=group.pk %}
">Email candidate results</button>
<button class="small-url-button" data-url="{% url 'generic:group_email_results_resend' pk=group.pk %}
">Resend candidate results</button> {% endcomment %}
<button class="small-url-button" data-url="{% url 'generic:user_group_update' pk=group.pk %}
">Edit</button>
<button class="small-url-button" data-url="{% url 'generic:user_group_delete' pk=group.pk %}
">Delete</button>
</p>
{% endfor %}
<script type="text/javascript">
$(".small-url-button").click((evt) => {
window.location = evt.currentTarget.dataset.url;
})
</script>
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">User Groups</h2>
<div>
<a class="btn btn-sm btn-primary me-2" href="{% url 'generic:user_group_create' %}">Create new group</a>
{% if view_all %}
<a href="{% url 'generic:user_group_view' %}">View active</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_view' %}">View active</a>
{% else %}
<a href="{% url 'generic:user_group_view_all' %}">View all</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_view_all' %}">View all</a>
{% endif %}
</div>
</div>
<div id="groups-list">
{% for group in groups %}
{% include 'generic/partials/user_group_card.html' with group=group view_all=view_all %}
{% empty %}
<div class="alert alert-info">No groups found.</div>
{% endfor %}
</div>
</div>
{% endblock %}
+41
View File
@@ -88,6 +88,16 @@ urlpatterns = [
views.cid_group_view_detail,
name="cid_group_detail",
),
path(
"cids/group/<int:group_id>/generate_preview",
views.cid_group_generate_preview,
name="cid_group_generate_preview",
),
path(
"cids/group/<int:group_id>/create_bulk",
views.cid_group_create_bulk,
name="cid_group_create_bulk",
),
path("cids/group/<int:pk>/email", views.group_email, name="group_email"),
path(
"cids/group/<int:pk>/exams",
@@ -114,6 +124,18 @@ urlpatterns = [
views.CidUserGroupUpdate.as_view(),
name="cid_group_update",
),
# HTMX partial to show a CidUserExam record for an exam (used from exam lists)
path(
"exam/<str:app_name>/<int:exam_id>/cid/<int:cid>/ciduserexam",
views.cid_user_exam_partial,
name="cid_user_exam_partial",
),
path(
"cids/ciduserexam/<int:pk>/update",
views.CidUserExamRecordUpdate.as_view(),
name="update_cid_user_exam",
),
path(
"cids/group/<int:pk>/delete",
views.CidUserGroupDelete.as_view(),
@@ -179,6 +201,11 @@ urlpatterns = [
views.UserUserGroupDelete.as_view(),
name="user_group_delete",
),
path(
"user/group/<int:pk>/archive",
views.user_group_toggle_archive,
name="user_group_toggle_archive",
),
path(
"user/group/create",
views.UserUserGroupCreate.as_view(),
@@ -259,6 +286,7 @@ urlpatterns = [
path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"),
path("collection/<int:collection_id>/bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"),
path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"),
path("collection/<int:collection_id>/exams/<str:exam_type>/partial", views.exam_collection_exams_partial, name="exam_collection_exams_partial"),
path(
"share_with_supervisor_toggle/<int:pk>/",
views.toggle_share_with_supervisor,
@@ -416,6 +444,16 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
generic_exam_view.exam_json_edit,
name="exam_json_edit",
),
path(
"exam/<int:pk>/cid_bulk_edit",
generic_exam_view.exam_cid_bulk_edit,
name="exam_cid_bulk_edit",
),
path(
"exam/<int:pk>/user_bulk_edit",
generic_exam_view.exam_user_bulk_edit,
name="exam_user_bulk_edit",
),
path(
"exam/<int:pk>/scores",
generic_exam_view.exam_scores_all,
@@ -486,6 +524,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",
+616 -60
View File
@@ -21,6 +21,7 @@ from django.core.exceptions import PermissionDenied, FieldError
from django.http import Http404, HttpRequest, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.template.loader import render_to_string
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
@@ -55,7 +56,7 @@ from reversion.views import RevisionMixin
from atlas.models import CaseCollection, CaseDetail
from generic.decorators import user_is_cid_user_manager
from generic.filters import CidUserFilter, ExaminationFilter, SupervisorFilter
from generic.models import UserUserGroup
from generic.models import UserUserGroup, CidUserExam
from generic.tables import (
CidUserExamTable,
@@ -109,6 +110,109 @@ from .models import (
)
@user_is_cid_user_manager
def cid_user_exam_partial(request, app_name: str, exam_id: int, cid: int):
"""Return an HTMX partial showing details for a CidUserExam record.
Matches by content_type app_label and object_id (exam id), and cid value.
"""
# Find the matching CidUserExam
try:
cux = CidUserExam.objects.select_related("cid_user", "user_user", "content_type").filter(
content_type__app_label=app_name, object_id=exam_id, cid_user__cid=cid
).first()
except Exception:
cux = None
if not cux:
return HttpResponse("No record found", status=404)
return render(request, "generic/partials/ciduserexam_detail_partial.html", {"cid_user_exam": cux})
def exam_collection_exams_partial(request, collection_id: int, exam_type: str):
"""Return the `exam-list` partial for a given collection and exam type.
This endpoint is intended for HTMX lazy-loading. It annotates candidate counts
and prefetches authors to avoid per-exam DB queries in the template.
"""
collection = get_object_or_404(ExamCollection, pk=collection_id)
# Map exam_type to related name and model
EXAM_MAP = {
"anatomy": ("anatomy_exams", "anatomy"),
"longs": ("longs_exams", "longs"),
"rapids": ("rapids_exams", "rapids"),
"shorts": ("shorts_exams", "shorts"),
"physics": ("physics_exams", "physics"),
"sbas": ("sbas_exams", "sbas"),
}
if exam_type not in EXAM_MAP:
return HttpResponse("Unknown exam type", status=400)
rel_name, app_label = EXAM_MAP[exam_type]
# Attempt to import the model class for the app to annotate counts
ExamModel = None
try:
if exam_type == "anatomy":
from anatomy.models import Exam as AnatomyExam
ExamModel = AnatomyExam
elif exam_type == "longs":
from longs.models import Exam as LongsExam
ExamModel = LongsExam
elif exam_type == "rapids":
from rapids.models import Exam as RapidsExam
ExamModel = RapidsExam
elif exam_type == "shorts":
from shorts.models import Exam as ShortsExam
ExamModel = ShortsExam
elif exam_type == "physics":
from physics.models import Exam as PhysicsExam
ExamModel = PhysicsExam
elif exam_type == "sbas":
from sbas.models import Exam as SbasExam
ExamModel = SbasExam
except Exception:
ExamModel = None
# Build queryset on the related manager; filter archived false by default
related_qs = getattr(collection, rel_name).all().filter(archive=False)
if ExamModel is not None:
# Avoid evaluating the related manager multiple times; use ids
pks = list(related_qs.values_list("pk", flat=True))
related_qs = (
ExamModel.objects.filter(pk__in=pks)
.annotate(
valid_cid_users_count=Count("valid_cid_users", distinct=True),
valid_user_users_count=Count("valid_user_users", distinct=True),
)
.prefetch_related("author")
.order_by("name")
)
context = {
"exams": related_qs,
"app_name": app_label,
"marking": request.user.has_perm("generic.can_mark") if request.user.is_authenticated else False,
"collection": collection,
}
# Render only the fragment template which includes the `exam-list` partialdef
html = render_to_string(
"generic/partials/exam_list_fragment.html", context, request=request
)
return HttpResponse(html)
from rapids.models import (
Rapid as RapidQuestion,
ExamQuestionDetail as RapidsExamQuestionDetail,
@@ -1008,9 +1112,25 @@ class ExamViews(View, LoginRequiredMixin):
exam = get_object_or_404(self.Exam, pk=pk)
# Support both legacy JSON POSTs and HTMX button toggles.
# If this is an HTMX request, toggle the archive flag and
# return the `exam-archived` partial for in-place replacement.
is_htmx = request.headers.get("HX-Request") == "true"
if is_htmx:
exam.archive = not exam.archive
else:
# Legacy POST expects 'archive' == 'true'
exam.archive = True if request.POST.get("archive") == "true" else False
print(f"{exam.archive=}")
exam.save()
if is_htmx:
return render(
request,
"generic/partials/exams/exam_status.html#exam-archived",
{"exam": exam, "collection": exam},
)
data = {
"status": "success",
"archive": exam.archive,
@@ -1782,27 +1902,32 @@ class ExamViews(View, LoginRequiredMixin):
@method_decorator(login_required)
def exam_list(self, request, all=False, collection=None):
# Build a single queryset and annotate candidate counts to avoid per-item queries
if collection is None:
if not self.check_user_access(request.user):
# raise PermissionDenied
exam_list = self.Exam.objects.filter(
author__id=request.user.id, exam_mode=True
).order_by("name")
exam_list = exam_list | self.Exam.objects.filter(
markers__id=request.user.id, exam_mode=True
).order_by("name")
else:
# Use a single queryset with Q to include authored or marked exams
exam_list = (
self.Exam.objects.prefetch_related(
"valid_user_users", "valid_cid_users"
self.Exam.objects.filter(
(Q(author__id=request.user.id) | Q(markers__id=request.user.id)),
exam_mode=True,
)
.filter(exam_mode=True)
.distinct()
.order_by("name")
)
else:
exam_list = self.Exam.objects.filter(exam_mode=True, examcollection__in=[collection]).order_by("name")
exam_list = self.Exam.objects.filter(exam_mode=True).order_by("name")
else:
exam_list = (
self.Exam.objects.filter(exam_mode=True, examcollection__in=[collection])
.order_by("name")
)
# Annotate counts for template usage so `.count()` is not called per-object.
# Use unfiltered counts to match the per-exam `exam_overview` counts.
exam_list = exam_list.annotate(
valid_cid_users_count=Count("valid_cid_users", distinct=True),
valid_user_users_count=Count("valid_user_users", distinct=True),
).select_related("examcollection").prefetch_related("author")
if not all:
exam_list = exam_list.filter(archive=False).order_by("name")
@@ -2162,23 +2287,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,
}
@@ -2187,6 +2309,69 @@ 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
# Explicitly query the CidUserExam entries for this exam to avoid any
# ambiguity from GenericRelation managers and to ensure we only return
# submissions that belong to this exam instance.
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(self.Exam)
user_exam_data = (
CidUserExam.objects.filter(content_type=ct, object_id=exam.pk)
.select_related("cid_user", "user_user")
.order_by("start_time")
)
# Determine which submitted entries correspond to candidates/users
# that are actually added to the exam so we can highlight those that
# are not. Collect valid IDs once to avoid per-row queries.
valid_cid_pks = set(exam.valid_cid_users.values_list("pk", flat=True))
valid_user_pks = set(exam.valid_user_users.values_list("pk", flat=True))
for ue in user_exam_data:
in_exam = False
try:
if ue.cid_user_id and ue.cid_user_id in valid_cid_pks:
in_exam = True
if ue.user_user_id and ue.user_user_id in valid_user_pks:
in_exam = True
except Exception:
in_exam = False
# Attach a dynamic attribute the template can read
setattr(ue, "in_exam", in_exam)
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)
@@ -2378,8 +2563,17 @@ class ExamViews(View, LoginRequiredMixin):
else:
app_exam_map[self.app_name].remove(pk)
data = {"status": "success", "added": add}
return JsonResponse(data, status=200)
# Always return the rendered list-item HTML so HTMX (or our fetch fallback)
# can swap the updated `<li>` into the DOM. Also provide an `HX-Trigger`
# header so client-side toasts/handlers can respond.
html = render_to_string(
'generic/partials/cid_user_li.html',
{'cid': cid_user, 'exam': exam, 'current': add},
request=request,
)
response = HttpResponse(html, content_type='text/html')
response['HX-Trigger'] = json.dumps({'cid_toggled': {'pk': cid_user.pk, 'added': add}})
return response
if "edit_user_user" in request.POST:
user_id = request.POST.get("edit_user_user")
@@ -2402,8 +2596,15 @@ class ExamViews(View, LoginRequiredMixin):
else:
app_exam_map[self.app_name].remove(pk)
data = {"status": "success", "added": add}
return JsonResponse(data, status=200)
# Return rendered list-item HTML for HTMX (or fetch fallback) and an HX-Trigger
html = render_to_string(
'generic/partials/user_user_li.html',
{'user': user_user, 'exam': exam, 'current': add},
request=request,
)
response = HttpResponse(html, content_type='text/html')
response['HX-Trigger'] = json.dumps({'user_toggled': {'pk': user_user.pk, 'added': add}})
return response
if "add_exam_questions" in request.POST:
question_ids = json.loads(request.POST.get("add_exam_questions"))
@@ -2502,6 +2703,180 @@ class ExamViews(View, LoginRequiredMixin):
data = {"status": "error"}
return JsonResponse(data, status=400)
@method_decorator(login_required)
def exam_cid_bulk_edit(self, request, pk):
"""Bulk add/remove CidUsers to/from an exam. Expects POST with 'bulk_pks' (JSON list)
and 'add' ('true'/'false'). Returns a rendered list partial for HTMX swaps.
"""
if request.method != 'POST':
return JsonResponse({'status': 'error, invalid method'}, status=400)
if request.user.groups.filter(name="cid_user_manager").exists():
pass
elif not self.check_user_edit_access(request.user, exam_id=pk):
data = {"status": "invalid permisions"}
return JsonResponse(data, status=403)
exam = get_object_or_404(self.Exam, pk=pk)
bulk_pks_raw = request.POST.get('bulk_pks') or request.POST.get('bulk_pks[]')
items = []
if bulk_pks_raw:
try:
items = json.loads(bulk_pks_raw)
except Exception:
# attempt to parse comma separated
try:
items = [int(x) for x in bulk_pks_raw.split(',') if x.strip()]
except Exception:
items = []
else:
sel = request.POST.getlist('selection') or request.POST.getlist('selection[]')
for s in sel:
try:
items.append(int(s))
except Exception:
continue
# 'add' may be provided for uniform bulk operations; otherwise items may be a list of {pk, add}
add_flag = None
if 'add' in request.POST:
add_flag = request.POST.get('add') == 'true'
changed = []
for entry in items:
# entry may be an int PK or a dict {pk, add}
if isinstance(entry, dict):
uid = entry.get('pk')
this_add = bool(entry.get('add'))
else:
uid = entry
this_add = add_flag if add_flag is not None else True
try:
cid_user = CidUser.objects.get(pk=uid)
except Exception:
continue
app_exam_map = {}
app_exam_map["rapids"] = cid_user.rapid_exams
app_exam_map["shorts"] = cid_user.shorts_exams
app_exam_map["anatomy"] = cid_user.anatomy_exams
app_exam_map["longs"] = cid_user.longs_exams
app_exam_map["physics"] = cid_user.physics_exams
app_exam_map["sbas"] = cid_user.sba_exams
app_exam_map["atlas"] = cid_user.casecollection_exams
try:
if this_add:
app_exam_map[self.app_name].add(pk)
else:
app_exam_map[self.app_name].remove(pk)
changed.append(cid_user.pk)
except Exception:
continue
# Rebuild lists for rendering
current_cid_users = exam.valid_cid_users.all()
exam_groups = exam.cid_user_groups.all()
available_cid_users = CidUser.objects.filter(group__in=exam_groups).difference(current_cid_users)
html = render_to_string('generic/partials/cid_user_list.html', {
'exam': exam,
'current_cid_users': current_cid_users,
'available_cid_users': available_cid_users,
}, request=request)
response = HttpResponse(html, content_type='text/html')
# add_flag may be None for mixed add/remove operations
response['HX-Trigger'] = json.dumps({'cid_bulk_toggled': {'changed': changed, 'add': add_flag, 'mixed': add_flag is None}})
return response
@method_decorator(login_required)
def exam_user_bulk_edit(self, request, pk):
"""Bulk add/remove User objects to/from an exam. Expects POST with 'bulk_pks' (JSON list)
and 'add' ('true'/'false'). Returns a rendered user list partial for HTMX swaps.
"""
if request.method != 'POST':
return JsonResponse({'status': 'error, invalid method'}, status=400)
if request.user.groups.filter(name="cid_user_manager").exists():
pass
elif not self.check_user_edit_access(request.user, exam_id=pk):
data = {"status": "invalid permisions"}
return JsonResponse(data, status=403)
exam = get_object_or_404(self.Exam, pk=pk)
bulk_pks_raw = request.POST.get('bulk_pks') or request.POST.get('bulk_pks[]')
items = []
if bulk_pks_raw:
try:
items = json.loads(bulk_pks_raw)
except Exception:
try:
items = [int(x) for x in bulk_pks_raw.split(',') if x.strip()]
except Exception:
items = []
else:
sel = request.POST.getlist('selection') or request.POST.getlist('selection[]')
for s in sel:
try:
items.append(int(s))
except Exception:
continue
add_flag = None
if 'add' in request.POST:
add_flag = request.POST.get('add') == 'true'
changed = []
for entry in items:
if isinstance(entry, dict):
uid = entry.get('pk')
this_add = bool(entry.get('add'))
else:
uid = entry
this_add = add_flag if add_flag is not None else True
try:
user_obj = User.objects.get(pk=uid)
except Exception:
continue
app_exam_map = {}
app_exam_map["rapids"] = user_obj.user_rapid_exams
app_exam_map["shorts"] = user_obj.user_shorts_exams
app_exam_map["anatomy"] = user_obj.user_anatomy_exams
app_exam_map["longs"] = user_obj.user_longs_exams
app_exam_map["physics"] = user_obj.user_physics_exams
app_exam_map["sbas"] = user_obj.user_sba_exams
app_exam_map["atlas"] = user_obj.user_casecollection_exams
try:
if this_add:
app_exam_map[self.app_name].add(pk)
else:
app_exam_map[self.app_name].remove(pk)
changed.append(user_obj.pk)
except Exception:
continue
# Rebuild lists for rendering
current_user_users = exam.valid_user_users.all()
exam_groups = exam.user_user_groups.all()
available_user_users = User.objects.filter(user_groups__in=exam_groups).difference(current_user_users)
html = render_to_string('generic/partials/user_user_list.html', {
'exam': exam,
'current_user_users': current_user_users,
'available_user_users': available_user_users,
}, request=request)
response = HttpResponse(html, content_type='text/html')
response['HX-Trigger'] = json.dumps({'user_bulk_toggled': {'changed': changed, 'add': add_flag, 'mixed': add_flag is None}})
return response
@method_decorator(login_required)
def mark_overview(self, request, pk):
exam: ExamBase = get_object_or_404(self.Exam, pk=pk)
@@ -2699,8 +3074,8 @@ class ExamViews(View, LoginRequiredMixin):
request: HttpRequest,
json: bool = True,
based: bool = True,
cid: int = None,
passcode: str = None,
cid: int | None = None,
passcode: str | None = None,
):
exams = self.Exam.objects.filter(archive=False)
@@ -3198,29 +3573,30 @@ class ExamViews(View, LoginRequiredMixin):
if user is None:
user = request.user
questions = (
exam.get_questions()
) # .prefetch_related("cid_user_answers", "answers")
# Load questions; prefetch 'answers' for apps that use them to avoid repeated DB hits
questions_qs = exam.get_questions()
if self.app_name not in ("physics",):
questions_qs = questions_qs.prefetch_related("answers")
# cid_user_answers = list(UserAnswer.objects.filter(cid=cid, exam__id=pk).prefetch_related("question"))
# Materialize questions list for iteration
questions = list(questions_qs)
# cid_user_answers_q_map = {}
# Fetch all relevant UserAnswer objects up-front to avoid per-question queries
if cid is not None:
user_answers_qs = self.UserAnswer.objects.filter(cid=cid, exam__id=pk).select_related("question")
else:
user_answers_qs = self.UserAnswer.objects.filter(user=user, exam__id=pk).select_related("question")
# for ans in cid_user_answers:
# cid_user_answers_q_map[ans.question] = ans
# Map question_id -> UserAnswer for O(1) lookup
user_answers_map = {ua.question_id: ua for ua in user_answers_qs}
answers_and_marks = []
answers_marks = []
answers = []
for q in questions:
# Get user answer
if cid is not None:
user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first()
else:
user_answer = q.cid_user_answers.filter(
user=request.user, exam__id=pk
).first()
# Get user answer from the in-memory map (avoids N+1 queries)
user_answer = user_answers_map.get(q.pk)
# user_answer = cid_user_answers_q_map[q]
feedback = None
@@ -3282,8 +3658,6 @@ class ExamViews(View, LoginRequiredMixin):
else:
answers_and_marks.append((ans, answer_score, correct_answer))
print(answers_marks)
match self.app_name:
case "physics":
total_score = sum(sum(i) for i in answers_marks)
@@ -4370,6 +4744,78 @@ def cid_group_view_detail(request, group_id):
)
@user_is_cid_user_manager
def cid_group_generate_preview(request, group_id):
"""Parse submitted rows and return a preview fragment for HTMX."""
if not request.htmx:
raise Http404()
group = get_object_or_404(CidUserGroup, pk=group_id)
rows_text = request.POST.get('rows', '')
original = rows_text
parsed = []
for line in rows_text.splitlines():
line = line.strip()
if not line:
continue
# Accept CSV or tab-separated: name,email or just email
parts = [p.strip() for p in line.replace('\t', ',').split(',') if p.strip()]
if len(parts) == 1:
email = parts[0]
name = ''
else:
name = parts[0]
email = parts[1]
parsed.append({'name': name, 'email': email})
html = render_to_string('generic/partials/cid_group_generate_preview.html', {'rows': parsed, 'original': original, 'group': group}, request=request)
return HttpResponse(html)
@user_is_cid_user_manager
def cid_group_create_bulk(request, group_id):
"""Create CidUser rows from posted rows and add them to the group, returning updated users list HTML."""
if not request.htmx:
raise Http404()
group = get_object_or_404(CidUserGroup, pk=group_id)
rows_text = request.POST.get('rows', '')
created = []
import secrets
for line in rows_text.splitlines():
line = line.strip()
if not line:
continue
parts = [p.strip() for p in line.replace('\t', ',').split(',') if p.strip()]
if len(parts) == 1:
email = parts[0]
name = ''
else:
name = parts[0]
email = parts[1]
# generate cid and passcode
try:
cid_val = get_next_cid()
except Exception:
# fallback: max+1
last = CidUser.objects.order_by('-cid').first()
cid_val = (last.cid if last and last.cid else 0) + 1
passcode = secrets.token_hex(3)
cu = CidUser.objects.create(cid=cid_val, passcode=passcode, name=name, email=email, group=group)
created.append(cu)
# re-render the users list
users = group.ciduser_set.filter(active=True)
inner = render_to_string('generic/partials/group_users_list.html', {'users': users, 'group_type': 'cid'}, request=request)
# return the wrapper so HTMX can replace the container even if it didn't exist before
html = f"<div id=\"group-users-wrap\">{inner}</div>"
return HttpResponse(html)
@user_is_cid_user_manager
def cid_group_view_all(request):
groups = CidUserGroup.objects.filter()
@@ -4636,6 +5082,37 @@ def user_group_view_all(request):
)
@user_is_cid_user_manager
def user_group_toggle_archive(request, pk):
"""HTMX endpoint to archive or unarchive a UserUserGroup.
POST params:
- archive: 'true' or 'false'
- view_all: optional '1' to indicate the caller is viewing the all-groups list
Returns an HTML fragment for the updated group card, or an empty
response when the card should be removed from the page (e.g. archiving
from the active-only view).
"""
if request.method != "POST":
return HttpResponse(status=405)
group = get_object_or_404(UserUserGroup, pk=pk)
archive_val = request.POST.get("archive", "true")
group.archive = True if archive_val == "true" else False
group.save()
view_all = True if request.POST.get("view_all") == "1" else False
# If the group is now archived and the caller is not viewing archived
# groups, remove the card from the page by returning an empty fragment.
if group.archive and not view_all:
return HttpResponse("")
return render(request, "generic/partials/user_group_card.html", {"group": group, "view_all": view_all})
@login_required
def user_toggle_group(request, user_id, group_id):
"""Toggle membership of a user in either a UserUserGroup or an auth Group.
@@ -4768,11 +5245,39 @@ def group_email_results(request, pk, resend=False):
@user_is_cid_user_manager
def candidate_email_details(request, cid, resend=False):
user = CidUser.objects.get(cid=cid)
user = get_object_or_404(CidUser, cid=cid)
email_sent, comment = user.email_details(resend=False)
# Allow caller to request a resend via querystring or POST param
resend_flag = False
try:
if request.method == 'POST':
resend_flag = str(request.POST.get('resend', '')).lower() in ('1', 'true', 'yes')
else:
resend_flag = str(request.GET.get('resend', '')).lower() in ('1', 'true', 'yes')
except Exception:
resend_flag = False
return JsonResponse({"sent": email_sent, "comment": comment})
# Allow a preview mode which returns the generated email body without
# actually sending the email. Useful for reviewing content in the UI.
preview_flag = False
try:
if request.method == 'POST':
preview_flag = str(request.POST.get('preview', '')).lower() in ('1', 'true', 'yes')
else:
preview_flag = str(request.GET.get('preview', '')).lower() in ('1', 'true', 'yes')
except Exception:
preview_flag = False
if preview_flag:
# Return plaintext body for preview (opens cleanly in a new tab/window)
msg = user.generate_email_details_msg()
return HttpResponse(msg, content_type='text/plain')
email_sent, comment = user.email_details(resend=resend_flag)
logger.debug(f"Email details sent to CID {cid}: sent={email_sent}, comment={comment}")
return JsonResponse({"sent": email_sent, "comment": str(comment)})
@user_is_cid_user_manager
@@ -5004,6 +5509,8 @@ class CidUserUpdate(CidManagerRequiredMixin, UpdateView):
class CidUserExamUpdate(CidManagerRequiredMixin, UpdateView):
# This view edits the CidUser (which stores which exams the CID is registered for).
# Keep model as `CidUser` so existing URL `update_cid_exams` continues to work.
model = CidUser
form_class = CidUserExamForm
template_name = "generic/ciduserexam_form.html"
@@ -5025,6 +5532,22 @@ class CidUserGroupCreate(RevisionMixin, CidManagerRequiredMixin, CreateView):
form_class = CidUserGroupForm
class CidUserExamRecordUpdate(CidManagerRequiredMixin, UpdateView):
"""Update a CidUserExam record (individual exam record for a CID/user).
Separate from `CidUserExamUpdate` which edits a `CidUser`'s exam memberships.
"""
model = CidUserExam
form_class = None
template_name = "generic/ciduserexam_record_form.html"
def get_form_class(self):
# import locally to avoid circular imports at module import time
from .forms import CidUserExamRecordForm
return CidUserExamRecordForm
class CidUserGroupUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView):
model = CidUserGroup
form_class = CidUserGroupForm
@@ -5405,7 +5928,7 @@ class SupervisorList(CidManagerRequiredMixin, SingleTableMixin, FilterView):
filterset_class = SupervisorFilter
class ExamCollectionList(ListView):
class ExamCollectionList(LoginRequiredMixin, ListView):
model = ExamCollection
def get_queryset(self):
@@ -5413,7 +5936,12 @@ class ExamCollectionList(ListView):
# allow toggling whether archived collections are included via ?show_archived=1
show_archived = self.request.GET.get("show_archived")
base_qs = ExamCollection.objects.all()
# If user is anonymous return empty set; otherwise limit to collections
# where the current user is listed as an author.
if not self.request.user.is_authenticated:
return ExamCollection.objects.none()
base_qs = ExamCollection.objects.filter(author=self.request.user)
if not show_archived:
base_qs = base_qs.filter(archive=False)
@@ -5482,11 +6010,39 @@ class ExamCollectionList(ListView):
return qs
class ExamCollectionDetail(DetailView, AuthorRequiredMixin):
class ExamCollectionDetail(AuthorRequiredMixin, DetailView):
model = ExamCollection
# Annotate counts for each exam type (non-archived) so template can check presence
# without evaluating entire related querysets.
def get_queryset(self):
qs = super().get_queryset()
counts = {
"anatomy_count": Count(
"anatomy_exams", filter=Q(anatomy_exams__archive=False), distinct=True
),
"longs_count": Count(
"longs_exams", filter=Q(longs_exams__archive=False), distinct=True
),
"physics_count": Count(
"physics_exams", filter=Q(physics_exams__archive=False), distinct=True
),
"rapids_count": Count(
"rapids_exams", filter=Q(rapids_exams__archive=False), distinct=True
),
"sbas_count": Count(
"sbas_exams", filter=Q(sbas_exams__archive=False), distinct=True
),
"shorts_count": Count(
"shorts_exams", filter=Q(shorts_exams__archive=False), distinct=True
),
}
try:
return qs.annotate(**counts)
except Exception:
return qs
class ExamCollectionEdit(UpdateView, AuthorRequiredMixin):
class ExamCollectionEdit(AuthorRequiredMixin, UpdateView):
model = ExamCollection
form_class = ExamCollectionForm
@@ -5505,7 +6061,7 @@ class ExamCollectionEdit(UpdateView, AuthorRequiredMixin):
return context
class ExamCollectionCreate(CreateView, AuthorRequiredMixin):
class ExamCollectionCreate(AuthorRequiredMixin, CreateView):
model = ExamCollection
form_class = ExamCollectionForm
@@ -5523,7 +6079,7 @@ class ExamCollectionCreate(CreateView, AuthorRequiredMixin):
return context
class ExamCollectionClone(CreateView, AuthorRequiredMixin):
class ExamCollectionClone(AuthorRequiredMixin, CreateView):
model = ExamCollection
template_name = "generic/examcollection_clone_form.html"
form_class = ExamCollectionCloneForm
@@ -5637,7 +6193,7 @@ class ExamCollectionClone(CreateView, AuthorRequiredMixin):
return HttpResponseRedirect(object.get_absolute_url())
class ExamCollectionDelete(DeleteView, AuthorRequiredMixin):
class ExamCollectionDelete(AuthorRequiredMixin, DeleteView):
model = ExamCollection
template_name = "confirm_delete.html"
success_url = reverse_lazy("generic:examcollection_list")
@@ -5657,7 +6213,7 @@ class ExaminationDelete(RevisionMixin, SuperuserRequiredMixin, DeleteView):
success_url = reverse_lazy("generic:examination_view")
class ExaminationUpdate(UpdateView, SuperuserRequiredMixin):
class ExaminationUpdate(SuperuserRequiredMixin, UpdateView):
model = Examination
form_class = ExaminationForm
+155 -1
View File
@@ -12,10 +12,15 @@
{% include "exam_clock.html" %}
<div class="no-select">
<h2>
<div class="d-flex justify-content-between align-items-start">
<h2 class="mb-0">
{{ exam }}: Question
[<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>]
</h2>
<div class="ms-3">
<!-- flag button moved into the question fragment so it updates with HTMX swaps -->
</div>
</div>
{% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert">
@@ -44,6 +49,108 @@
<h4>Questions</h4>
<div id="menu-list"></div>
<script>
// Minimal per-question status injected by view: answered/flagged arrays
window.examQuestionAnswered = {{ answered_json|safe }};
window.examQuestionFlagged = {{ flagged_json|safe }};
// Update the visual state of the question menu based on global arrays
window.updateQuestionMenu = function(){
console.log("Updating question menu");
try{
const answered = window.examQuestionAnswered || [];
const flagged = window.examQuestionFlagged || [];
const pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
document.querySelectorAll('#menu-list .question-menu-item').forEach(btn => {
const idx = parseInt(btn.dataset.qn, 10);
btn.classList.toggle('answered', !!answered[idx]);
btn.classList.toggle('flagged', !!flagged[idx]);
btn.classList.toggle('current-question', idx === pos);
// manage flag icon presence
const hasIcon = btn.querySelector('.question-flag') !== null;
if (flagged[idx] && !hasIcon) {
const el = document.createElement('span'); el.className = 'question-flag'; el.setAttribute('aria-hidden','true'); el.textContent = '⚑'; btn.appendChild(el);
} else if (!flagged[idx] && hasIcon) {
const el = btn.querySelector('.question-flag'); if(el) el.remove();
}
});
}catch(e){ console && console.error && console.error(e); }
};
</script>
<script>
// Show a blur + spinner on the question fragment while HTMX loads it
(function(){
function showLoading(){
try{
const frag = document.getElementById('question-fragment');
if(!frag) return;
frag.classList.add('loading');
if(!frag.querySelector('.loading-spinner')){
const s = document.createElement('div');
s.className = 'loading-spinner';
s.setAttribute('aria-hidden','true');
frag.appendChild(s);
}
}catch(e){console && console.error && console.error(e);}
}
function hideLoading(){
try{
const frag = document.getElementById('question-fragment');
if(!frag) return;
frag.classList.remove('loading');
const s = frag.querySelector('.loading-spinner'); if(s) s.remove();
}catch(e){console && console.error && console.error(e);}
}
document.body.addEventListener('htmx:beforeRequest', function(evt){
try{
const target = (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
// if the request targets the question fragment, show loading
if(target && (target.id === 'question-fragment' || (target.closest && target.closest('#question-fragment')))){
showLoading();
}
}catch(e){/* ignore */}
});
document.body.addEventListener('htmx:afterSwap', function(evt){
try{
const target = (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
if(target && (target.id === 'question-fragment' || (target.closest && target.closest('#question-fragment')))){
hideLoading();
}
}catch(e){}
});
// also hide after settle to be safe
document.body.addEventListener('htmx:afterSettle', function(evt){ hideLoading(); });
})();
</script>
<script>
// Also update after HTMX settle to ensure DOM changes are applied
document.body.addEventListener('htmx:afterSettle', function(){
try{ if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu(); }catch(e){}
});
</script>
<script>
// Listen for flag button swaps (HTMX) and update the local menu state
document.addEventListener('htmx:afterSwap', function(evt){
try{
// read the current flag button in DOM (safer than relying on event target)
var container = document.getElementById('flag-button-container');
if(!container) return;
var btn = container.querySelector('button');
if(!btn) return;
var flaggedNow = btn.classList.contains('btn-warning');
var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
if(!isNaN(pos)){
window.examQuestionFlagged = window.examQuestionFlagged || new Array({{ exam_length }}).fill(false);
window.examQuestionFlagged[pos] = !!flaggedNow;
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
}
}catch(e){ console && console.error && console.error(e); }
});
</script>
{% include "physics/exam_take_help.html" %}
{% endblock %}
@@ -192,9 +299,42 @@
.postinput { margin-top: 0.4rem; text-align: left; }
.physics-answer-list li { padding: 0.6rem; }
}
/* Question menu item states (small pill buttons) */
button.question-menu-item {
color: var(--text);
border: 1px solid var(--card-border);
background: var(--card-bg);
padding: 0.25rem 0.5rem;
border-radius: 0.35rem;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
button.question-menu-item.answered {
border-color: var(--success) !important; /* answered -> success border */
}
/* flagged items show an icon only; no border change here */
button.question-menu-item .question-flag {
margin-left: 0.25rem;
font-size: 0.85em;
color: #ffc107;
line-height: 1;
}
/* Loading blur + spinner for question fragment */
#question-fragment.loading { filter: blur(3px); opacity: 0.9; pointer-events: none; position: relative; }
#question-fragment .loading-spinner {
position: absolute;
left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 36px; height: 36px; border-radius: 50%;
border: 4px solid rgba(255,255,255,0.2); border-top-color: var(--primary);
animation: spin 800ms linear infinite; z-index: 1200;
}
@keyframes spin { from { transform: translate(-50%,-50%) rotate(0deg); } to { transform: translate(-50%,-50%) rotate(360deg); } }
</style>
{% endblock %}
{% block head_js %}{% endblock %}
{% block js %}
<script>
/* HTMX swap helpers: preserve scroll and container height to avoid page jumping
@@ -266,6 +406,20 @@ document.addEventListener('saved', function(evt){
requestAnimationFrame(function(){ el.style.opacity = '1'; });
// hide after 1.6s
setTimeout(function(){ el.style.opacity = '0'; }, 1600);
// Update the question list state locally so the menu reflects answered questions
try {
var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : (document.getElementById('question-number') ? parseInt(document.getElementById('question-number').textContent, 10) - 1 : NaN);
if (!isNaN(pos)) {
window.examQuestionAnswered = window.examQuestionAnswered || new Array({{ exam_length }}).fill(false);
window.examQuestionAnswered[pos] = true;
if(typeof window.updateQuestionMenu === 'function'){
window.updateQuestionMenu();
} else {
var btn = document.querySelector('#menu-list [data-qn="' + pos + '"]');
if (btn) btn.classList.add('answered');
}
}
} catch (e) { console && console.error && console.error(e); }
} catch (e) { console && console.error && console.error(e); }
});
</script>
@@ -42,22 +42,24 @@
<div class="overview-text">{{answer_count}} out of {{exam_length}} questions answered. Unanswered questions are shown in red. <span class="d-block">Click any tile to jump to that question.</span></div>
<div class="physics-finish-list" role="list">
{% for question, answer in question_answer_tuples %}
{% for question, answer, flagged in question_answer_tuples %}
{% comment %} Use an anchor styled as a button rather than nesting button inside anchor (invalid HTML). Keep link targets unchanged. {% endcomment %}
{% if not cid %}
<a role="listitem" href="{% url 'physics:exam_take_user' pk=exam.id sk=forloop.counter0 %}"
class="overview-question-btn btn btn-outline-secondary"
class="overview-question-btn btn btn-outline-secondary {% if flagged %}flagged{% endif %}"
aria-label="Go to question {{ forloop.counter }}"
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %}
<span class="qn-num">{{forloop.counter}}</span>{% if answer and answer.answer %}<span class="qn-ans">: {{answer.answer}}</span>{% endif %}
{% if flagged %}<span class="question-flag" aria-hidden="true"></span>{% endif %}
</a>
{% else %}
<a role="listitem" href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}"
class="overview-question-btn btn btn-outline-secondary"
class="overview-question-btn btn btn-outline-secondary {% if flagged %}flagged{% endif %}"
aria-label="Go to question {{ forloop.counter }}"
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %}
<span class="qn-num">{{forloop.counter}}</span>{% if answer and answer.answer %}<span class="qn-ans">: {{answer.answer}}</span>{% endif %}
{% if flagged %}<span class="question-flag" aria-hidden="true"></span>{% endif %}
</a>
{% endif %}
{% endfor %}
@@ -70,9 +72,9 @@
{% if not cid_user_exam.completed %}
{% if not cid %}
<button class="btn btn-primary mt-3" hx-get="{% url 'physics:exam_complete_user' pk=exam.id %}" hx-swap="outerHTML" hx-confirm="Finish exam?">Finish Exam</button>
<button class="btn btn-primary mt-3" hx-get="{% url 'physics:exam_complete_user' pk=exam.id %}" hx-swap="outerHTML" hx-confirm="Finish exam?. This will end your attempt and you will not be able to change your answers. Are you sure?">Finish Exam</button>
{% else %}
<button class="btn btn-primary mt-3" hx-get="{% url 'physics:exam_complete' pk=exam.id cid=cid passcode=passcode %}" hx-swap="outerHTML" hx-confirm="Finish exam?">Finish Exam</button>
<button class="btn btn-primary mt-3" hx-get="{% url 'physics:exam_complete' pk=exam.id cid=cid passcode=passcode %}" hx-swap="outerHTML" hx-confirm="Finish exam?. This will end your attempt and you will not be able to change your answers. Are you sure?">Finish Exam</button>
{% endif %}
{% endif %}
@@ -129,6 +131,24 @@
justify-content: flex-start;
}
/* Flagged questions show only the flag icon; do not change border color */
/* Ensure global `.flagged` rules don't change the overview tile text colour. */
.overview-question-btn.flagged {
color: inherit !important;
}
.overview-question-btn.flagged .qn-num,
.overview-question-btn.flagged .qn-ans {
color: inherit !important;
}
.overview-question-btn .question-flag {
margin-left: auto;
font-size: 0.95rem;
color: #ffc107;
line-height: 1;
}
/* Unanswered questions should draw attention */
.overview-question-btn[title] {
border-color: #dc3545 !important;
@@ -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>
@@ -1,4 +1,5 @@
<div class="exam-question-fragment">
<!-- Flag button lives inside the fragment so it updates together with other question controls -->
<form method="POST" class="post-form"
hx-post="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">
@@ -55,12 +56,21 @@
</div>
<div class="mt-2">
<div class="d-flex align-items-center justify-content-between">
<div class="btn-row">
{% 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>
<button type="submit" name="previous" class="save btn btn-secondary" title="Click to save your answer(s) and go to the previous question" onclick="try{ window.currentQuestionPos = {{ previous }}; if(typeof updateQuestionMenu==='function') updateQuestionMenu(); }catch(e){}">Previous</button>
{% endif %}
{% if next %}
<button type="submit" name="next" class="save btn btn-secondary" title="Click to save your answer(s) and go to the next question">Next</button>
<button type="submit" name="next" class="save btn btn-secondary" title="Click to save your answer(s) and go to the next question" onclick="try{ window.currentQuestionPos = {{ pos|add:1 }}; if(typeof updateQuestionMenu==='function') updateQuestionMenu(); }catch(e){}">Next</button>
<!-- Skip: load the next question fragment without submitting the form -->
<button type="button" id="skip-button" class="btn btn-outline-secondary ms-2" title="Skip to next question without saving"
hx-get="{% if cid %}{% url 'physics:exam_take_fragment' pk=exam.pk sk=pos|add:1 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_fragment_user' pk=exam.pk sk=pos|add:1 %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">Skip</button>
<!-- Fallback link for non-HTMX clients: simple navigation without saving -->
<a class="d-none" href="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos|add:1 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos|add:1 %}{% endif %}"></a>
{% else %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="save btn btn-default" title="Click to save your current answer(s)">Save</button>
@@ -71,6 +81,12 @@
<button type="submit" id="overview-button" name="finish" class="save btn btn-default" title="Click to go to the overview page (your answers will be saved).">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div>
<div class="flag-container ms-3">
{% include 'physics/partials/exam_flag_button.html' %}
</div>
</div>
</div>
</form>
<script>
@@ -80,11 +96,21 @@
try {
var qnum = document.getElementById('question-number');
if (qnum) { qnum.textContent = '{{ pos|add:"1" }}'; }
// expose current question index for global listeners
window.currentQuestionPos = {{ pos }};
var qstem = document.getElementById('question-stem');
if (qstem) { qstem.innerHTML = '{{ question.stem|escapejs }}'; }
} catch (err) {
console.warn('Could not update global question header from fragment', err);
}
// expose current question index for global listeners
window.currentQuestionPos = {{ pos }};
// Update the browser URL to reflect the current question (so Prev/Next/Skip update URL)
try {
const pageUrlTemplate = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`;
const pageUrl = pageUrlTemplate.replace('/0/', '/' + window.currentQuestionPos + '/');
history.replaceState(null, '', pageUrl);
} catch (e) { /* ignore */ }
{% if not exam.publish_results and not cid_user_exam.completed %}
let time_limit = '{{ exam.time_limit }}';
if (time_limit != 'None') {
@@ -118,16 +144,289 @@
// build the question menu locally so it is available after fragment swaps
$('#menu-list').empty();
const serverAnswered = {{ answered_json|default:'null'|safe }};
const serverFlagged = {{ flagged_json|default:'null'|safe }};
// Always refresh client-side arrays from server-provided state when fragment loads
if (Array.isArray(serverAnswered)) {
window.examQuestionAnswered = serverAnswered;
}
if (Array.isArray(serverFlagged)) {
window.examQuestionFlagged = serverFlagged;
}
const answeredArr = (window.examQuestionAnswered && Array.isArray(window.examQuestionAnswered)) ? window.examQuestionAnswered : null;
const flaggedArr = (window.examQuestionFlagged && Array.isArray(window.examQuestionFlagged)) ? window.examQuestionFlagged : null;
for (let i = 0; i < {{ exam_length }}; i++) {
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`);
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}"><span class="qnum">${i+1}</span></button>`);
if (answeredArr && answeredArr[i]) { qbutton.addClass('answered'); }
if (flaggedArr && flaggedArr[i]) {
qbutton.addClass('flagged');
// ensure flag icon exists only once
if (qbutton.find('.question-flag').length === 0) qbutton.append('<span class="question-flag" aria-hidden="true"></span>');
}
if (i == {{ pos }}) { qbutton.addClass('current-question'); }
$('#menu-list').append(qbutton);
}
$('button.question-menu-item').on('click', (e) => {
document.getElementById('goto-button').value = e.currentTarget.dataset.qn;
// Let a central updater ensure classes are consistent (handles cases where menu exists before arrays)
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
// Template URL for loading a fragment — use sk=0 as placeholder to replace later
const fragUrlTemplate = `{% if cid %}{% url 'physics:exam_take_fragment' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_fragment_user' pk=exam.pk sk=0 %}{% endif %}`;
// Use event delegation on the menu list to reliably catch clicks
(function(){
const menu = document.getElementById('menu-list');
if(!menu) return;
if(menu.dataset.delegateBound) return; // idempotent
menu.dataset.delegateBound = '1';
menu.addEventListener('click', function(e){
const btn = e.target.closest && e.target.closest('.question-menu-item');
if(!btn) return;
const destIndex = btn.dataset.qn;
try{
const allFalse = Array.from(document.querySelectorAll('ol.physics-answer-list li')).every(li => {
const inp = li.querySelector('input');
return !(inp && inp.checked);
});
if(allFalse){
// Use modal prompt instead of native confirm
try{
// prevent default while we prompt
e.preventDefault(); e.stopImmediatePropagation();
}catch(ex){}
openSaveSkipModal('All answers are false. Save these answers or skip without saving?').then(function(choice){
if(choice === 'save'){
const form = document.querySelector('.post-form');
if(form){
const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp);
// One-time htmx listener to detect the save response and then navigate
let handled = false;
function htmxHandler(evt){
try{
const xhr = evt.detail.xhr;
const hxTrigger = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Trigger');
if(hxTrigger && hxTrigger.indexOf('saved') !== -1){
handled = true;
if(window.htmx && typeof htmx.off === 'function') htmx.off('htmx:afterRequest', htmxHandler);
try{
const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/');
window.location = pageUrl;
}catch(e){
if(document.getElementById('goto-button')){
document.getElementById('goto-button').value = destIndex;
$('#goto-button').click();
}
}
}
}catch(e){/* ignore */}
}
if(window.htmx && typeof htmx.on === 'function'){
htmx.on('htmx:afterRequest', htmxHandler);
}
// As a fallback listen for the custom 'saved' event
const onSavedFallback = function(){ if(handled) return; handled = true; document.removeEventListener('saved', onSavedFallback); try{ const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/'); window.location = pageUrl; }catch(e){ if(document.getElementById('goto-button')){ document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); } } };
document.addEventListener('saved', onSavedFallback);
tmp.click();
// final timeout fallback
setTimeout(function(){ if(!handled){ try{ document.removeEventListener('saved', onSavedFallback); if(window.htmx && typeof htmx.off === 'function') htmx.off('htmx:afterRequest', htmxHandler); const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/'); window.location = pageUrl; }catch(e){ try{ if(document.getElementById('goto-button')){ document.getElementById('goto-button').value = destIndex; $('#goto-button').click(); } }catch(e){} } } }, 1500);
return;
}
}
// skip or cancel -> load fragment directly via HTMX and update URL
try{
const url = fragUrlTemplate.replace('/0/', '/' + destIndex + '/');
if(window.htmx && typeof htmx.ajax === 'function'){
htmx.ajax('GET', url, {target: '#question-fragment', swap: 'innerHTML'});
} else {
window.location.href = url;
}
try{
const pageUrl = `{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=0 cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=0 %}{% endif %}`.replace('/0/', '/' + destIndex + '/');
history.replaceState(null, '', pageUrl);
}catch(e){}
}catch(e){
document.getElementById('goto-button').value = destIndex;
$('#goto-button').click();
}
}).catch(function(){
// on error, fallback to navigation
document.getElementById('goto-button').value = destIndex;
$('#goto-button').click();
});
return;
}
}catch(ex){ console && console.error && console.error(ex); }
// Default: perform the form-based navigation (which will save)
document.getElementById('goto-button').value = destIndex;
$('#goto-button').click();
});
})();
// Intercept Overview button to offer Save or Skip when all answers false
(function(){
const overview = document.getElementById('overview-button');
if(!overview) return;
const overviewUrl = `{% if cid %}{% url 'physics:exam_take_overview' pk=exam.pk cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_overview_user' pk=exam.pk %}{% endif %}`;
overview.addEventListener('click', function(evt){
try{
const allFalse = Array.from(document.querySelectorAll('ol.physics-answer-list li')).every(li => {
const inp = li.querySelector('input');
return !(inp && inp.checked);
});
if(!allFalse) return; // allow normal submit (which will save)
// prevent the default submit while we prompt
evt.preventDefault(); evt.stopImmediatePropagation();
openSaveSkipModal('All answers are false. Save these answers or skip without saving?').then(function(choice){
if(choice === 'save'){
const form = document.querySelector('.post-form');
if(form){
const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp);
const onSaved = function(){ document.removeEventListener('saved', onSaved); window.location = overviewUrl; };
document.addEventListener('saved', onSaved);
tmp.click();
setTimeout(function(){ document.removeEventListener('saved', onSaved); window.location = overviewUrl; }, 1500);
return;
}
}
// skip or cancel -> navigate without saving
window.location = overviewUrl;
}).catch(function(){ window.location = overviewUrl; });
}catch(ex){ console && console.error && console.error(ex); }
});
})();
// Skip confirmation: if the user's current selections differ from
// the saved answer, prompt before allowing the HTMX skip to proceed.
(function(){
const skip = document.getElementById('skip-button');
if(!skip) return;
// Build a JS array of saved answers (booleans) or null
{% if saved_answer %}
const savedAnswers = [{% for s in saved_answer %}{{ s|yesno:"true,false" }}{% if not forloop.last %}, {% endif %}{% endfor %}];
{% else %}
const savedAnswers = null;
{% endif %}
function readCurrent(){
const lis = document.querySelectorAll('ol.physics-answer-list li');
return Array.from(lis).map(li => {
const inp = li.querySelector('input');
return !!(inp && inp.checked);
});
}
skip.addEventListener('click', function(evt){
try{
const current = readCurrent();
let changed = false;
if(savedAnswers === null){
// no previously-saved answer; any current selection is a change
changed = current.some(Boolean);
} else {
if(current.length === savedAnswers.length){
for(let i=0;i<current.length;i++){
if(!!current[i] !== !!savedAnswers[i]){ changed = true; break; }
}
} else {
// differing lengths — consider changed if any selection present
changed = current.some(Boolean);
}
}
if(changed){
evt.preventDefault(); evt.stopImmediatePropagation();
openSaveSkipModal('You have unsaved changes. Skip to the next question without saving?').then(function(choice){
if(choice === 'skip'){
// trigger the HTMX GET on the button by invoking its click handler programmatically
const hxGet = skip.getAttribute('hx-get');
if(hxGet){
if(window.htmx && typeof htmx.ajax === 'function'){
htmx.ajax('GET', hxGet, {target: '#question-fragment', swap: 'innerHTML'});
} else {
window.location.href = hxGet;
}
}
} else if(choice === 'save'){
const form = document.querySelector('.post-form');
if(form){
const tmp = document.createElement('button'); tmp.type='submit'; tmp.name='save'; tmp.style.display='none'; form.appendChild(tmp);
tmp.click();
}
}
// cancel => do nothing
}).catch(function(){/* ignore */});
return false;
}
// allow the HTMX attribute on the button to proceed
}catch(e){ console && console.error && console.error(e); }
});
})();
})();
</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>
</div>
</div>
<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 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'); }
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);
bsModal.show();
}catch(e){ reject(e); }
});
}
</script>
</div>
+11
View File
@@ -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",
+153 -9
View File
@@ -1,6 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from generic.models import CidUser
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
@@ -69,6 +69,8 @@ from .decorators import (
user_is_exam_author_or_physics_checker,
)
from loguru import logger
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
@@ -148,6 +150,19 @@ def exam_complete(request, pk, cid=None, passcode=None):
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
cid_user_exam.complete_exam()
try:
ct = ContentType.objects.get_for_model(exam)
ExamUserStatus.objects.create(
content_type=ct,
object_id=exam.pk,
cid_user_exam=cid_user_exam,
status="completed",
extra="physics",
)
except Exception:
logger.error("Failed to log exam completion status")
# avoid crashing the user flow if logging fails
pass
return HttpResponse("<div role='alert' class='alert alert-info'>Exam completed</div>")
@@ -164,22 +179,33 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
else:
answers = UserAnswer.objects.filter(user=request.user, exam=exam)
answer_question_map = {}
for ans in answers:
answer_question_map[ans.question] = ans
# Map answers by question id for quick lookup
answer_question_map = {ans.question_id: ans for ans in answers}
# Prepare bulk flagged set to avoid per-question queries
q_ids = [q.pk for q in questions]
try:
ct = ContentType.objects.get_for_model(Question)
if cid is not None:
flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, cid_user__cid=cid)
else:
flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, user=request.user)
flagged_set = set(flags_qs.values_list('object_id', flat=True))
except Exception:
flagged_set = set()
question_answer_tuples = []
answer_count = 0
for q in questions:
# if q in answer_question_map and answer_question_map[q].answer:
if q in answer_question_map: # might need to improve this
question_answer_tuples.append((q, answer_question_map[q]))
ans = answer_question_map.get(q.pk)
if ans is not None:
answer_count += 1
else:
question_answer_tuples.append((q, None))
flagged = q.pk in flagged_set
question_answer_tuples.append((q, ans, flagged))
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
return render(
request,
"physics/exam_take_overview.html",
@@ -336,6 +362,27 @@ 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
# Precompute answered and flagged sets in bulk to avoid repeated queries
q_ids = [q.pk for q in questions]
if cid is not None:
answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
else:
answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))
return render(
request,
"physics/exam_take.html",
@@ -351,6 +398,11 @@ 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,
# minimal per-question status for client-side menu: answered/flagged lists
# compute answered/flagged sets in bulk to avoid N+1 queries
"answered_json": json.dumps([q.pk in answered_set for q in questions]),
"flagged_json": json.dumps([q.pk in flagged_set for q in questions]),
},
)
@@ -400,6 +452,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
@@ -407,6 +471,15 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
if sk == exam_length - 1:
next = False
# Bulk compute answered and flagged sets for the fragment
q_ids = [q.pk for q in questions]
if cid is not None:
frag_answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
else:
frag_answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))
return render(
request,
"physics/partials/exam_take_fragment.html",
@@ -420,12 +493,83 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
"exam_length": exam_length,
"pos": pos,
"saved_answer": saved_answer,
"answer": answer,
"flagged": flagged,
"answered_json": json.dumps([q.pk in frag_answered_set for q in questions]),
"flagged_json": json.dumps([q.pk in frag_flagged_set for q in questions]),
"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)):
+5 -1
View File
@@ -325,7 +325,11 @@ TINYMCE_DEFAULT_CONFIG = {
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
# Path used by Django's file-based email backend. In dev this should be
# mounted into the container at `/var/log/rad/emails` (see `docker-compose.dev.yml`).
EMAIL_FILE_PATH = os.environ.get("EMAIL_FILE_PATH", os.path.join(BASE_DIR, "../logs/emails"))
# SENDGRID_API_KEY = os.getenv('SENDGRID_API_KEY')
SENDGRID_API_KEY = (
+2
View File
@@ -106,6 +106,8 @@ urlpatterns = [
path("cid/<int:cid>/", views.cid_scores_admin, name="cid_scores_admin"),
path("user/scores", views.user_scores, name="user_scores"),
path("user/scores/<int:user_id>", views.user_scores_admin, name="user_scores_admin"),
path("user/marking", views.user_marking, name="user_marking"),
path("user/marking/<str:exam_type>", views.user_marking_partial, name="user_marking_partial"),
path("cid/", views.cid_selector, name="cid_selector"),
path("cid/request_cid_details/", views.request_cid_details, name="request_cid_details"),
# Global url that registers RTS compatible exams
+90
View File
@@ -12,6 +12,7 @@ from generic.views import (
RedirectMixin,
get_question_and_content_type,
get_user_exams,
get_exam_model_from_app_name,
)
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.shortcuts import render, get_object_or_404, redirect
@@ -266,6 +267,95 @@ def user_scores(request, user=None):
)
@login_required
def user_marking(request):
"""Main page for user marking overview. Uses HTMX to fetch per-app partials."""
apps = ["physics", "anatomy", "rapids", "shorts", "longs", "sbas"]
return render(request, "rad/user_marking.html", {"apps": apps})
@login_required
def user_marking_partial(request, exam_type: str):
"""Return an HTMX partial listing exams the user can mark for a given exam type.
Each exam is checked for whether it has any unmarked answers (using
question.get_unmarked_user_answer_count when available).
"""
user = request.user
try:
ExamModel = get_exam_model_from_app_name(exam_type)
except Exception:
return HttpResponse("Unknown exam type", status=400)
# Exams where user is an author or an authorised marker
exams_qs = (
ExamModel.objects.filter(archive=False, exam_mode=True)
.filter(Q(author=user) | Q(markers=user))
.distinct()
.order_by("name")
.prefetch_related("author", "markers")
)
exams_data = []
for exam in exams_qs:
has_unmarked = False
unmarked_count = 0
# iterate questions and use their helper to count unmarked answers when available
for q in exam.get_questions():
count = 0
# Prefer question-provided helper if present
helper = getattr(q, "get_unmarked_user_answer_count", None)
if callable(helper):
try:
count = helper(exam_pk=exam.pk, marker=user)
except TypeError:
count = helper(exam_pk=exam.pk)
except Exception:
count = 0
else:
# Fallback: inspect related answer manager if present
rel_manager = None
for rel_name in ("cid_user_answers", "user_answers", "answers", "useranswer_set"):
if hasattr(q, rel_name):
rel_manager = getattr(q, rel_name)
break
if rel_manager is not None:
try:
ans_model = rel_manager.model
# If the answer model has a `score` field, count answers that are unmarked
field_names = [f.name for f in ans_model._meta.get_fields()]
if "score" in field_names:
# Try to detect an UNMARKED constant on the model
unmarked_vals = [None, ""]
model_score_const = getattr(ans_model, "ScoreOptions", None) or getattr(ans_model, "MarkOptions", None)
if model_score_const is not None:
# attempt to use any UNMARKED attribute
unmarked_val = getattr(model_score_const, "UNMARKED", None)
if unmarked_val is not None:
unmarked_vals.append(unmarked_val)
count = rel_manager.filter(exam__id=exam.pk).filter(score__in=unmarked_vals).count()
else:
# No score field — likely auto-marked; treat as 0
count = 0
except Exception:
count = 0
try:
c = int(count)
except Exception:
c = 0
if c:
has_unmarked = True
unmarked_count += c
exams_data.append({"exam": exam, "has_unmarked": has_unmarked, "unmarked_count": unmarked_count})
return render(request, "generic/partials/user_marking_exam_list.html", {"exams_data": exams_data, "exam_type": exam_type})
def cid_scores(request, cid, passcode):
# exam = get_object_or_404(Exam, pk=pk)
+1
View File
@@ -59,3 +59,4 @@ pygments
celery[redis]
redis
pywatchman
numpy==2.4.0
+33 -1
View File
@@ -1,7 +1,7 @@
from reversion.views import RevisionMixin
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from django.views.generic.detail import DetailView
from generic.models import CidUser
from generic.models import CidUser, ExamUserStatus, CidUserExam
from sbas.forms import UserAnswerForm, ExamAuthorForm, ExamForm
from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import csrf_exempt
@@ -306,6 +306,17 @@ def exam_complete(request, pk, cid=None, passcode=None):
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
cid_user_exam.complete_exam()
try:
ct = ContentType.objects.get_for_model(exam)
ExamUserStatus.objects.create(
content_type=ct,
object_id=exam.pk,
cid_user_exam=cid_user_exam,
status="completed",
extra="sbas",
)
except Exception:
pass
return HttpResponse("<div role='alert' class='alert alert-info'>Exam completed</div>")
@@ -366,8 +377,29 @@ def exam_take(request, pk: int, sk: int, cid: int = None, passcode: str = None):
exam.check_user_can_take(cid, passcode, request.user)
# log start only when a new CidUserExam is created
content_type = ContentType.objects.get_for_model(exam)
existing = False
if cid is not None:
cid_user_obj = CidUser.objects.filter(cid=cid).first()
existing = CidUserExam.objects.filter(content_type=content_type, object_id=exam.pk, cid_user=cid_user_obj).exists()
else:
existing = CidUserExam.objects.filter(content_type=content_type, object_id=exam.pk, user_user=request.user).exists()
cid_user_exam = exam.get_or_create_cid_user_exam(cid = cid, user_user=request.user)
if not existing:
try:
ExamUserStatus.objects.create(
content_type=content_type,
object_id=exam.pk,
cid_user_exam=cid_user_exam,
status="started",
extra="sbas",
)
except Exception:
pass
question = exam.get_questions()[sk]
exam_length = len(exam.exam_questions.all())
+24 -1
View File
@@ -53,14 +53,37 @@ mkdir -p "$REPO_ROOT/docker/loki-data/index" \
"$REPO_ROOT/logs"
# Set permissive perms for logs so nginx/promtail can use it in development.
# Only change permissions if the directory doesn't already have the desired mode.
if [ -d "$REPO_ROOT/logs" ]; then
_mode=$(stat -c %a "$REPO_ROOT/logs" 2>/dev/null || true)
if [ -z "$_mode" ]; then
chmod -R 0777 "$REPO_ROOT/logs" || true
else
if [ "$_mode" != "777" ]; then
chmod -R 0777 "$REPO_ROOT/logs" || true
fi
fi
fi
# Ensure loki data directories exist and have reasonable perms. The Loki
# container runs as numeric UID 10001; if possible try to chown the folders to
# that UID so Loki can write to them. If sudo is available the script will
# attempt it (you may be prompted). If it fails we'll continue and leave a
# message for manual intervention.
chmod -R 0755 "$REPO_ROOT/docker/loki-data" "$REPO_ROOT/docker/loki-wal" || true
# Ensure loki data directories exist and have reasonable perms. Only chmod
# each path if its current mode differs from the desired one.
for _p in "$REPO_ROOT/docker/loki-data" "$REPO_ROOT/docker/loki-wal"; do
if [ -d "$_p" ]; then
_mode=$(stat -c %a "$_p" 2>/dev/null || true)
if [ -z "$_mode" ]; then
chmod -R 0755 "$_p" || true
else
if [ "$_mode" != "755" ]; then
chmod -R 0755 "$_p" || true
fi
fi
fi
done
if [ "$(id -u)" -eq 0 ]; then
chown -R 10001:10001 "$REPO_ROOT/docker/loki-data" "$REPO_ROOT/docker/loki-wal" || true
else
+1 -1
View File
@@ -22,7 +22,7 @@
{% for exam in exams %}
<li class="exam" data-exam-id="{{exam.pk}}">{{exam}}
{% if exam.active %}
<a href="{{exam.get_take_url}}?cid={{cid}}&passcode={{passcode}}" target="_blank" title="Click to take exam">
<a href="{{exam.get_take_url}}?cid={{cid}}&passcode={{passcode}}&exam_mode=true" target="_blank" title="Click to take exam">
<button class="small-url-button start-button">Start</button>
</a>
{% endif %}
+44 -18
View File
@@ -17,18 +17,36 @@
{% if app_name == 'anatomy' %}<a href="{% url 'anatomy:mark2_overview' pk=exam.pk %}" class="flex-col-half">Mark2</a>{% endif %}
{% endif %}
<span class="flex-col">
<a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}">Candidates</a> <span class="candidate-counts">[<span title="Number of active CID users">
<a href="{% url app_name|add:':exam_cids' exam_id=exam.pk %}">Candidates</a>
<span class="candidate-counts">[
<span title="Number of active CID users">
{% if exam.valid_cid_users_count is not None %}
{{ exam.valid_cid_users_count }}
{% else %}
{{ exam.valid_cid_users.count }}
</span> / <span title="Number of active User users">{{exam.valid_user_users.count}}]</span></span>
{% endif %}
</span>
/
<span title="Number of active User users">
{% if exam.valid_user_users_count is not None %}
{{ exam.valid_user_users_count }}
{% else %}
{{ exam.valid_user_users.count }}
{% endif %}
]</span>
</span>
</span>
<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}" class="flex-col-half">Scores</a>
<input type="checkbox" id="active-{{exam.pk}}" class="exam-active-switch" data-posturl="{% url app_name|add:':exam_toggle_active' pk=exam.pk %}" {% if exam.active %}checked{% endif %}>
<label for="active-{{exam.pk}}" class="flex-col icon-container active-icon" title="Click to toggle active state">Exam Active</label>
<input type="checkbox" id="published-{{exam.pk}}" class="exam-publish-results-switch" data-posturl="{% url app_name|add:':exam_toggle_results_published' pk=exam.pk %}" {% if exam.publish_results %}checked{% endif %}>
<label for="published-{{exam.pk}}" class="flex-col icon-container published-icon" title="Click to toggle published state">Results Published</label>
<span class="flex-col">
{% include "generic/partials/exams/exam_status.html#exam-active" %}
</span>
<span class="flex-col">
{% include "generic/partials/exams/exam_status.html#publish-results" %}
</span>
<a href="{% url app_name|add:':exam_update' pk=exam.pk %}" class="flex-shrink exam-list-edit-button"><i class="bi bi-pencil-square"></i></a>
<input type="checkbox" id="archived-{{exam.pk}}" class="exam-archived-switch" data-posturl="{% url app_name|add:':exam_toggle_archived' pk=exam.pk %}" {% if exam.archive %}checked{% endif %}>
<label for="archived-{{exam.pk}}" class="flex-shrink icon-archive archived-icon" title="Click to toggle archived state"><i class="bi bi-archive"></i></label>
<span class="flex-shrink">
{% include "generic/partials/exams/exam_status.html#exam-archived" %}
</span>
</li>
{% endfor %}
</ul>
@@ -55,6 +73,11 @@
-webkit-user-select: none;
-moz-user-select: none;
text-align: center;
padding: 0.15rem 0.45rem;
font-size: 0.85rem;
line-height: 1;
vertical-align: middle;
min-width: 3.4rem;
}
.archived-icon {
@@ -67,23 +90,26 @@
opacity: 100%;
}
.exam-active-switch:checked + .active-icon {
border: 1px solid green;
color: green;
opacity: 100%;
display: inline-block;
/* Styling for active/published/archive buttons to match previous compact look */
.active-icon.btn-success, .active-icon.btn-primary {
border: 1px solid #198754;
color: #198754;
opacity: 1;
background: transparent;
}
.exam-publish-results-switch:checked + .published-icon {
.published-icon.btn-success {
border: 1px solid purple;
color: purple;
opacity: 100%;
display: inline-block;
opacity: 1;
background: transparent;
}
.exam-archived-switch:checked + .archived-icon {
.archived-icon.btn-warning {
color: darkblue;
opacity: 100%;
opacity: 1;
background: transparent;
border: 1px solid #ffc107;
}
.exam-publish-results-switch, .exam-active-switch, .exam-archived-switch {
+29 -82
View File
@@ -1,91 +1,38 @@
<script type="text/javascript">
$(document).ready(function () {
// send request to change the is_private state on customSwitches toggle
$("#exam-active-switch, .exam-active-switch").on("change", function (evt) {
$.ajax({
url: evt.currentTarget.dataset.posturl,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
active: this.checked // true if checked else false
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.status == "success") {
toastr.info(`Exam: ${data.name} [${data.id}]
Active state changed to: ${data.active}.`)
// HTMX loading indicator handlers: show .exam-loading near trigger or in closest .card-body
document.body.addEventListener('htmx:beforeRequest', function(evt){
try {
var trigger = evt.detail && evt.detail.elt ? evt.detail.elt : null;
if (!trigger) return;
var spinner = null;
// prefer spinner sibling of the triggering element
if (trigger.parentElement) spinner = trigger.parentElement.querySelector('.exam-loading');
// fallback to nearest card-body spinner
if (!spinner) {
var card = trigger.closest && trigger.closest('.card-body') ? trigger.closest('.card-body') : null;
if (card) spinner = card.querySelector('.exam-loading');
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.fail(function (data) {
console.log(data)
toastr.error(`Failed to change state<br/>${data.responseJSON.status}`);
})
.always(function () {
console.log('[Done]');
})
})
$("#exam-publish-results-switch, .exam-publish-results-switch").on("change", function (evt) {
$.ajax({
url: evt.currentTarget.dataset.posturl,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
publish_results: this.checked // true if checked else false
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (spinner) spinner.classList.remove('d-none');
} catch (e) { console.error('htmx beforeRequest spinner error', e); }
});
if (data.status == "success") {
toastr.info(`Exam: ${data.name} [${data.id}]
Published state changed to: ${data.publish_results}.`)
document.body.addEventListener('htmx:afterRequest', function(evt){
try {
var trigger = evt.detail && evt.detail.elt ? evt.detail.elt : null;
if (!trigger) return;
var spinner = null;
if (trigger.parentElement) spinner = trigger.parentElement.querySelector('.exam-loading');
if (!spinner) {
var card = trigger.closest && trigger.closest('.card-body') ? trigger.closest('.card-body') : null;
if (card) spinner = card.querySelector('.exam-loading');
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.fail(function (data) {
toastr.error(`Failed to change state`);
})
.always(function () {
console.log('[Done]');
})
})
$(".exam-archived-switch").on("change", function (evt) {
$.ajax({
url: evt.currentTarget.dataset.posturl,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
archive: this.checked // true if checked else false
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (spinner) spinner.classList.add('d-none');
} catch (e) { console.error('htmx afterRequest spinner error', e); }
});
if (data.status == "success") {
toastr.info(`Exam: ${data.name} [${data.id}]
Archived state changed to: ${data.archive}.`)
}
// show some message according to the response.
// For eg. A message box showing that the status has been changed
})
.fail(function (data) {
toastr.error(`Failed to change state`);
})
.always(function () {
console.log('[Done]');
})
})
// HTMX handles active/publish/archive toggles via server-rendered partials.
// Removed legacy jQuery AJAX handlers for exam-active, publish-results and archived toggles.
$("#button-open-access").click(function (evt) {
$.ajax({
url: evt.currentTarget.dataset.posturl,
@@ -0,0 +1,30 @@
<div class="user-marking-list">
{% if exams_data %}
<ul class="list-group">
{% for item in exams_data %}
{% with exam=item.exam %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
{% if exam_type == 'anatomy' %}
<a href="{% url exam_type|add:':mark2_overview' pk=exam.pk %}" class="fw-semibold">{{ exam.name }}</a>
{% else %}
<a href="{% url exam_type|add:':mark_overview' pk=exam.pk %}" class="fw-semibold">{{ exam.name }}</a>
{% endif %}
<div class="small text-muted">Authors: {% for a in exam.author.all %}{{ a.get_full_name|default:a.username }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
</div>
<div class="text-end">
<div class="small text-muted">Markers: {{ exam.markers.all.count }}</div>
{% if item.has_unmarked %}
<span class="badge bg-danger mt-1">Needs marking ({{ item.unmarked_count }})</span>
{% else %}
<span class="badge bg-secondary mt-1">No unmarked</span>
{% endif %}
</div>
</li>
{% endwith %}
{% endfor %}
</ul>
{% else %}
<div class="alert alert-info mb-0">No exams found for this exam type.</div>
{% endif %}
</div>
+3
View File
@@ -93,6 +93,9 @@
<h6 class="card-title">Quick links</h6>
<ul class="list-unstyled mb-0">
<li><a href="http://www.penracourses.org.uk/rts" class="link-primary">RTS platform</a></li>
{% if request.user.is_authenticated %}
<li><a href="{% url 'user_marking' %}" class="link-primary">Marking overview</a></li>
{% endif %}
{% if not request.user.is_authenticated %}<li><a href="{% url 'cid_selector' %}" class="link-primary">CID login</a></li>{% endif %}
{% if request.user.supervisor %}<li><a href='{% url "generic:supervisor_overview" request.user.supervisor.pk %}' class="link-primary">Supervisor overview</a></li>{% endif %}
{% if request.user|has_group:"cid_user_manager" %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block content %}
<h1 class="mb-4">Marking: Exams you can mark</h1>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline-block; }
</style>
<div class="row">
<div class="col-12">
<p class="text-muted">Click an exam type to load exams — each section loads independently.</p>
</div>
</div>
<div class="row g-4">
{% for app in apps %}
<div class="col-md-6">
<div class="card">
<div class="card-body d-flex align-items-center justify-content-between">
<h5 class="card-title mb-0">{{ app|capfirst }}</h5>
<div>
<button class="btn btn-sm btn-primary" hx-get="{% url 'user_marking_partial' app %}" hx-target="#marking-{{ app }}" hx-swap="innerHTML" hx-indicator="#indicator-{{ app }}">Load / Refresh</button>
<div id="indicator-{{ app }}" class="htmx-indicator ms-2 spinner-border spinner-border-sm text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
</div>
</div>
<div id="marking-{{ app }}" class="card-body border-top">
<div class="text-muted small">No data loaded — click the button to load.</div>
</div>
</div>
</div>
{% endfor %}
</div>
<script>
// Show/hide indicators referenced by triggering element's hx-indicator attribute
document.body.addEventListener('htmx:beforeRequest', function(evt){
try{
var elt = evt.detail.elt || evt.target;
var sel = elt && elt.getAttribute && elt.getAttribute('hx-indicator');
if(sel){
var indicator = document.querySelector(sel);
if(indicator) indicator.classList.add('visible');
}
}catch(e){console.warn(e)}
});
document.body.addEventListener('htmx:afterRequest', function(evt){
try{
var elt = evt.detail.elt || evt.target;
var sel = elt && elt.getAttribute && elt.getAttribute('hx-indicator');
if(sel){
var indicator = document.querySelector(sel);
if(indicator) indicator.classList.remove('visible');
}
}catch(e){console.warn(e)}
});
</script>
<style>
.htmx-indicator { display: none; }
.htmx-indicator.visible { display: inline-block !important; }
</style>
{% endblock %}