Compare commits

..
117 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
Ross 7674f29124 . 2025-12-08 12:56:39 +00:00
Ross d39879bbca Enhance ExamSearchSelectMultipleWidget: improve button styling and add functionality for adding multiple exams 2025-12-08 12:54:52 +00:00
Ross 012c86340d . 2025-12-08 12:51:17 +00:00
Ross 6f50c53519 . 2025-12-08 12:37:24 +00:00
Ross 53aefa29df . 2025-12-08 12:35:13 +00:00
Ross 4617a643d2 Refactor exam collection form layout for improved readability and structure 2025-12-08 12:34:23 +00:00
Ross d7afab15e1 Refactor templates and views for improved structure and maintainability 2025-12-08 12:31:58 +00:00
Ross ad5e7aeed8 . 2025-12-08 12:17:16 +00:00
Ross b4cf5b9d70 Refactor exam collection templates to improve navigation structure and enhance user experience 2025-12-08 12:12:54 +00:00
Ross f156f4c85e Refactor navbar styles and improve dropdown accessibility for better mobile usability 2025-12-08 12:12:00 +00:00
Ross 4ab2e3a15e Enhance ExamSearch widgets to support customizable default filters and improve metadata display 2025-12-08 12:11:24 +00:00
Ross 6daa643df8 Refactor navigation structure for improved readability and maintainability 2025-12-08 12:07:24 +00:00
Ross f8874a2556 Refactor navigation to use Bootstrap navbar with dropdowns for better organization and accessibility 2025-12-08 12:07:00 +00:00
Ross b4f5992986 . 2025-12-08 11:56:39 +00:00
Ross 9a3a8b932a . 2025-12-08 11:51:36 +00:00
Ross eb06e63f77 . 2025-12-08 11:18:47 +00:00
Ross a4f4ba464a Add advanced search help panel and functionality to ExamSearchSelectMultipleWidget 2025-12-08 11:15:03 +00:00
Ross 6fafccc907 . 2025-12-08 11:10:55 +00:00
Ross d96dd77cdc Add user authentication to index and user collections views; update CSRF trusted origins and internal IPs 2025-12-08 10:57:38 +00:00
Ross 4e47083fcd . 2025-12-08 10:35:58 +00:00
Ross bf895e516e . 2025-12-08 10:32:30 +00:00
Ross 49229b0408 Enhance local-up script to ensure Loki and nginx log directories exist with appropriate permissions 2025-12-08 10:30:00 +00:00
Ross 997b5374c2 . 2025-12-08 10:29:52 +00:00
Ross 9d434ec80d Merge branch 'dockerprod' into master 2025-12-08 10:13:08 +00:00
Ross af616a9f4a Add tooltips for open access settings and improve UI elements in exam overview 2025-12-08 10:05:44 +00:00
Ross d29c23a799 . 2025-12-08 09:54:15 +00:00
Ross 0917947a98 . 2025-12-08 09:52:18 +00:00
78 changed files with 5958 additions and 1738 deletions
+10 -2
View File
@@ -18,5 +18,13 @@ GUNICORN_WORKERS=3
GUNICORN_LOGLEVEL=info
# Nginx host ports for local development (override prod defaults)
NGINX_HTTP_PORT=8000
NGINX_HTTPS_PORT=8443
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
+4 -2
View File
@@ -14,5 +14,7 @@ venv
temp/
# Loki runtime data
/docker/loki-data/
/docker/loki-wal/
docker/loki-data/
docker/loki-wal/
# Local runtime logs collected by Promtail
/logs/
+55
View File
@@ -0,0 +1,55 @@
README
======
Quick notes for running this repository (development & observability)
Running locally (development)
- Use the included compose files. To run the dev stack (overrides) set `COMPOSE_ENV=dev` so the correct env file is loaded:
```sh
# run nginx on non-privileged ports (see .env.dev)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
```
- By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are:
- `NGINX_HTTP_PORT=8080`
- `NGINX_HTTPS_PORT=8444`
Logging & observability (Loki + Promtail + Grafana)
- Promtail is configured to scrape `/var/log/rad/*.log` and push to Loki. Promtail's config is at `docker/promtail-config.yml` and the Loki config is at `docker/loki-config/local-config.yaml`.
- The nginx configs (both `deploy/nginx/prod.conf` and `deploy/nginx/dev.conf`) are configured to write access and error logs to `/var/log/rad/nginx.access.log` and `/var/log/rad/nginx.error.log` respectively.
- The compose files mount a host `logs/` folder into `/var/log/rad` so both nginx (write) and promtail (read) can access the same files:
- `../logs:/var/log/rad:rw` (nginx)
- `../logs:/var/log/rad:ro` (promtail)
Host setup for logs
- Create the host logs folder at the repo root (this repo's `logs/`). In development you can use permissive permissions so containers can write to it quickly:
```sh
mkdir -p logs
# development: make writable by all (change this for production)
sudo chown $USER:$USER logs
chmod 0777 logs
```
- In production prefer setting the folder owner to the nginx worker UID (or run nginx under a specific user) and use `0755` or `0750` as appropriate. Example:
```sh
# run on the host (determine nginx UID as needed)
sudo chown -R 101:101 /srv/rad/logs
sudo chmod 0755 /srv/rad/logs
```
Viewing logs in Grafana
- Grafana is available on the port exposed by compose (default `3000`).
- The Loki datasource is configured to use `http://loki:3100` (see compose). In Grafana Explore choose the Loki datasource and run queries such as:
- `{job="rad_app"}` (all collected logs)
- `{job="rad_app", filename="/var/log/rad/nginx.access.log"}`
Notes & recommendations
- In development the `logs/` folder is ignored by git (`/.gitignore` updated). Do not commit runtime logs.
- Consider a log rotation/retention policy for production (e.g., `logrotate` or use Loki retention policies).
- If you need nginx to re-resolve the backend service IP without restarting, consider using `resolver` and variable-based upstreams in the nginx config. For many development workflows restarting nginx after web restarts is the simplest approach.
If you want, I can also add a short `docs/OBSERVABILITY.md` with more details and recommended production settings (TLS/auth for Loki, retention, log rotation).
+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
+50 -46
View File
@@ -108,51 +108,51 @@
{% else %}
<div class="btn-group link-buttons" role="group">
<button type="button" class="btn btn-primary" onclick="window.location.href='/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}'">View case in OHIF</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open in new tab</a>
</li>
</ul>
</div>
<div class="btn-group link-buttons" role="group">
<button type="button" class="btn btn-primary" onclick="window.location.href='/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}'">View case in OHIF</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open in new tab</a>
</li>
</ul>
</div>
{% endif %}
<div class="clearfix mb-2">
<div class="float-start">
<span class="id">ID: {{ case.id }}</span>
<span class="mx-1">/</span>
<span class="date">{{ case.created_date|date:"d/m/Y" }}</span>
</div>
<div class="clearfix mb-2">
<div class="float-start">
<span class="id">ID: {{ case.id }}</span>
<span class="mx-1">/</span>
<span class="date">{{ case.created_date|date:"d/m/Y" }}</span>
</div>
<div class="float-end text-end">
<div class="float-end text-end">
{# Subspecialty badges (preserve any anchor returned by get_link) #}
{% if case.subspecialty.all %}
{% for sub in case.subspecialty.all %}
<span class="badge bg-secondary ms-1">{{ sub.get_link|safe }}</span>
{% endfor %}
{% else %}
<span class="text-muted me-2">No subspecialty</span>
{% endif %}
{% if case.subspecialty.all %}
{% for sub in case.subspecialty.all %}
<span class="badge bg-secondary ms-1">{{ sub.get_link|safe }}</span>
{% endfor %}
{% else %}
<span class="text-muted me-2">No subspecialty</span>
{% endif %}
{# Pathological process badges #}
{% if case.pathological_process.all %}
{% for p in case.pathological_process.all %}
<span class="badge bg-secondary ms-1">{{ p.get_link|safe }}</span>
{% endfor %}
{% endif %}
{% if case.pathological_process.all %}
{% for p in case.pathological_process.all %}
<span class="badge bg-secondary ms-1">{{ p.get_link|safe }}</span>
{% endfor %}
{% endif %}
{# Diagnostic certainty as a badge #}
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
{# Normal toggle HTMX block #}
<div class="mt-1">
{% include "atlas/partials/_normal_toggle.html" %}
<div class="mt-1">
{% include "atlas/partials/_normal_toggle.html" %}
</div>
</div>
</div>
</div>
<details id="dicom-viewer-details">
<summary>Viewer</summary>
@@ -175,7 +175,7 @@
</ul>
</p>
<div class="row g-3 mb-3">
<div class="row g-3 mb-3">
<div class="col-12 col-md-12">
<div class="card h-100">
<div class="card-header">History</div>
@@ -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>
@@ -462,12 +466,12 @@
</ul>
</p>
{% if casedetail %}
{% include 'atlas/partials/collection_question_block.html' with can_edit=can_edit %}
{% endif %}
@@ -72,29 +72,6 @@
{% csrf_token %}
{% crispy form form.helper %}
{% comment %} <h3>Cases:</h3>
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
<input type="button" value="Add More Cases" id="add_more_case">
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
<div id="case_formset" class="sortable list_formset">
<ol>
{% for form in case_formset %}
<li class="no-error case-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form }}
</li>
{% endfor %}
</ol>
</div>
{{ case_formset.management_form }}
</form>
<div id="empty_case_form" style="display:none">
<li class='no_error case-formset'>
{{ case_formset.empty_form }}
</li>
</div> {% endcomment %}
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
</form>
<script>
@@ -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);
+61 -60
View File
@@ -1,76 +1,77 @@
{% extends 'atlas/base.html' %}
{% block content %}
<div class="container my-4">
<div class="row">
<div class="col-12">
<h2 class="mb-3">Atlas</h2>
</div>
</div>
<div class="row">
<div class="col-12 col-md-8">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Collections</h5>
<p class="card-text">Quick access to collections you can view or take.</p>
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">My cases / collections</h5>
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
<p>
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
</a>
</p>
</div>
<div class="container my-4">
<div class="row">
<div class="col-12">
<h2 class="mb-3">Atlas</h2>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
<ul class="list-unstyled mb-0">
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
</ul>
<div class="row">
<div class="col-12 col-md-8">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Collections</h5>
<p class="card-text">Quick access to collections you can view or take.</p>
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">My cases / collections</h5>
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a> or <a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">my collections</a>.</p>
<p>
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
</a>
</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Search cases</h5>
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="case-search-input" name="q" type="search"
placeholder="Search cases by title or description"
hx-get="{% url 'atlas:case_search_partial' %}"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#case-search-results"
hx-indicator="#case-search-indicator" />
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading</span>
</span>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
<ul class="list-unstyled mb-0">
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
<li><a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">My collections</a></li>
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
</ul>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Search cases</h5>
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="case-search-input" name="q" type="search"
placeholder="Search cases by title or description"
hx-get="{% url 'atlas:case_search_partial' %}"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#case-search-results"
hx-indicator="#case-search-indicator" />
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading</span>
</span>
</div>
</div>
<style>
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
#case-search-indicator { display: none; }
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
</style>
<div id="case-search-results">
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
</div>
</div>
<style>
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
#case-search-indicator { display: none; }
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
</style>
<div id="case-search-results">
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -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 %}
+17 -1
View File
@@ -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 %}
@@ -29,7 +45,7 @@
<dd class="col-sm-9">
<a href="{{ resource.file.url }}" target="_blank" rel="noopener">Download</a>
{% if resource.file.name %}
<small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small>
<small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small>
{% endif %}
</dd>
{% endif %}
+172 -26
View File
@@ -1,8 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<title>Viewer</title>
<head>
<title>Viewer</title>
<style type="text/css">
body, html
{
@@ -11,33 +11,179 @@
#viewer
{
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
}
</style>
</head>
<body>
<p>Awating load request</p>
This page will display series when loaded from other pages.
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px;" ></iframe>
</head>
<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;
<script type="text/javascript">
var win2 = false;
document.addEventListener("DOMContentLoaded", function() {
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
bc.onmessage = (event) => {
console.log("message received");
console.log(event);
if (event.data.type == "open") {
document.getElementById("viewer").src = event.data.url;
//document.querySelectorAll("[data-cy='seriesList-btn']")[0].click()
bc.postMessage({"type": "open-complete", "url" : event.data.url});
document.addEventListener("DOMContentLoaded", function() {
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;
}
};
});
</script>
</body>
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;
bc.postMessage({"type": "open-complete", "url" : event.data.url});
}
};
});
</script>
</body>
</html>
+99 -23
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,36 +86,106 @@
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") {
// Hide modal when data is loaded
document.getElementById("awaiting-modal").style.display = "none";
document.getElementById("root").dataset.images = event.data.images;
if (
event.data.viewerstate &&
Object.keys(event.data.viewerstate).length > 0
) {
document.getElementById("root").dataset.viewerstate = event.data.viewerstate;
} else {
delete document.getElementById("root").dataset.viewerstate;
}
console.log("Annotations:", event.data.annotations);
if (
event.data.annotations &&
Object.keys(event.data.annotations).length > 0
) {
document.getElementById("root").dataset.annotationjson = event.data.annotations;
} else {
delete document.getElementById("root").dataset.annotationjson;
// 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;
}
window.mountDicomViewers();
bc.postMessage({"type": "open-complete-local"});
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;
if (
event.data.viewerstate &&
Object.keys(event.data.viewerstate).length > 0
) {
document.getElementById("root").dataset.viewerstate = event.data.viewerstate;
} else {
delete document.getElementById("root").dataset.viewerstate;
}
console.log("Annotations:", event.data.annotations);
if (
event.data.annotations &&
Object.keys(event.data.annotations).length > 0
) {
document.getElementById("root").dataset.annotationjson = event.data.annotations;
} else {
delete document.getElementById("root").dataset.annotationjson;
}
window.mountDicomViewers();
bc.postMessage({"type": "open-complete-local"});
}
};
});
</script>
+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,
+165 -11
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(),
},
)
@@ -1090,10 +1093,12 @@ def author_list(request):
return render(request, "atlas/author_list.html", {"authors": authors})
@login_required
def index(request):
return render(request, "atlas/index.html", {})
@login_required
def user_collections(request):
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
available_collections = request.user.user_casecollection_exams.all()
@@ -1566,8 +1571,10 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs):
context = super(CaseCollectionCreate, self).get_context_data(**kwargs)
if self.request.POST:
# Only bind the case formset when the POST actually contains formset data.
# We don't require adding cases on create, so if the template doesn't
# include the casedetail management fields we should skip validation.
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
context["case_formset"] = CaseCollectionCaseFormSet(
self.request.POST,
self.request.FILES,
@@ -1587,14 +1594,19 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
context = self.get_context_data(form=form)
case_formset = context["case_formset"]
if case_formset.is_valid():
response = super().form_valid(form)
case_formset.instance = self.object
case_formset.save()
return response
else:
return super().form_invalid(form)
# If the POST included case formset data, validate and save it. Otherwise
# skip formset processing so creating a collection without cases redirects
# normally to the detail view.
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
if case_formset.is_valid():
response = super().form_valid(form)
case_formset.instance = self.object
case_formset.save()
return response
else:
return super().form_invalid(form)
# No formset data present: proceed with normal create redirect.
return super().form_valid(form)
def get_success_url(self):
"""Redirect to the collection detail page after successful creation."""
@@ -3998,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)
+4
View File
@@ -7,6 +7,10 @@ server {
listen [::]:80;
server_name _;
# Write logs to the host-mounted folder so Promtail can scrape them
access_log /var/log/rad/nginx.access.log combined;
error_log /var/log/rad/nginx.error.log warn;
client_max_body_size 100M;
location /static/ {
+8
View File
@@ -8,6 +8,10 @@ server {
listen [::]:80;
server_name _;
# Write logs to the host-mounted folder so Promtail can scrape them
access_log /var/log/rad/nginx.access.log combined;
error_log /var/log/rad/nginx.error.log warn;
client_max_body_size 100M;
# Serve static files from the static volume
@@ -112,6 +116,10 @@ server {
client_max_body_size 4G;
# Write logs to the host-mounted folder so Promtail can scrape them
access_log /var/log/rad/nginx.access.log combined;
error_log /var/log/rad/nginx.error.log warn;
# Same static/media handling as above
location /static/ { alias /usr/src/app/static/; }
location /media { alias /usr/src/app/media; }
+9 -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 = {
@@ -117,6 +121,10 @@ SECURE_HSTS_SECONDS = 0
CSRF_TRUSTED_ORIGINS = [
"http://127.0.0.1:8000",
"http://localhost:8000",
"http://127.0.0.1:8080",
"http://localhost:8080",
"http://127.0.0.1:8080/",
"http://localhost:8080/",
]
REMOTE_URL = "http://127.0.0.1:8000"
+1
View File
@@ -24,6 +24,7 @@ services:
image: nginx:1.25-alpine
volumes:
- ../deploy/nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro
- ../logs:/var/log/rad:rw
loki:
image: grafana/loki:2.8.2
+1
View File
@@ -45,6 +45,7 @@ services:
- web
volumes:
- ../deploy/nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
- ../logs:/var/log/rad:rw
- static_volume:/usr/src/app/static:ro
- media_volume:/usr/src/app/media:ro
- ../deploy/nginx/certs:/etc/letsencrypt:ro
@@ -1 +0,0 @@
{"UID":"f8cade59-3cf9-4ae3-965d-8b0a57007d58","created_at":"2025-12-01T17:14:05.101735276Z","version":{"version":"2.8.2","revision":"9f809eda7","branch":"HEAD","buildUser":"root@e401cfcb874f","buildDate":"2023-05-03T11:07:54Z","goVersion":"go1.20.4"}}
+34 -290
View File
@@ -20,6 +20,7 @@ from django.contrib.auth.models import User
from generic.models import (
CidUser,
CidUserExam,
CidUserGroup,
Examination,
ExamCollection,
@@ -81,6 +82,7 @@ from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidg
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.contrib.auth.models import User
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget, ExamSearchWidget
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
@@ -240,7 +242,7 @@ class ExamAuthorFormMixin(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
self.fields["author"] = ModelMultipleChoiceField(
queryset=User.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False),
widget=UserSearchWidget(),
)
@@ -252,7 +254,7 @@ class ExamMarkerFormMixin(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
self.fields["markers"] = ModelMultipleChoiceField(
queryset=User.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Markers", is_stacked=False),
widget=UserSearchWidget(),
)
self.fields["markers"].required = False
@@ -425,6 +427,7 @@ class CidUserForm(ModelForm):
"internal_candidate",
"name",
"email",
"notes",
"supervisor",
"login_email_sent",
"results_email_sent",
@@ -540,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}]"
@@ -556,219 +572,7 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
class UserSearchSelectMultipleWidget:
"""Reusable lightweight widget renderer for a searchable multi-user selector.
Usage: instantiate with (name, value) where value is an iterable of user ids
and call .render() to get the HTML fragment. This mirrors the previous
nested widget but is available module-wide for reuse.
"""
def __init__(self, name, value):
self.name = name
self.value = value or []
def render(self):
field_id = f"id_{self.name}"
search_id = f"user_search_{self.name}"
results_id = f"user_search_results_{self.name}"
selected_list_id = f"selected_users_{self.name}"
users = User.objects.filter(pk__in=self.value) if self.value else []
options_html = ""
selected_html = ""
for u in users:
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
grade_text = ""
try:
up = getattr(u, "userprofile", None)
if up and getattr(up, "grade", None):
g = up.grade
grade_text = getattr(g, "name", str(g)) if g is not None else ""
except Exception:
grade_text = ""
selected_html += (
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
+ '</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
f'</li>'
)
url = reverse("generic:user_search_widget")
grades_options = '<option value="">All grades</option>'
try:
from generic.models import UserGrades
grades_qs = UserGrades.objects.all()
for g in grades_qs:
grades_options += f'<option value="{g.pk}">{g.name}</option>'
except Exception:
grades_options = '<option value="">All grades</option>'
html = f"""
<div class="user-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html}
</select>
<div class="mb-2">
<div class="d-flex gap-2 align-items-start">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
{grades_options}
</select>
<!-- Help button for advanced search features -->
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
</div>
<div id="{results_id}" class="mt-2"></div>
<!-- Collapsible help panel (hidden by default) -->
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong>
<ul class="mb-0 mt-1">
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> or <code>full_name:</code> matches first OR last name (e.g. <code>name:smith</code>).</li>
<li><code>first:</code> or <code>first_name:</code> matches first name.</li>
<li><code>last:</code> or <code>last_name:</code> matches last name.</li>
<li><code>email:</code> matches email address.</li>
<li><code>username:</code> matches username.</li>
<li><code>id:</code> or <code>pk:</code> match by numeric user id (e.g. <code>id:123</code>).</li>
<li><code>grade:</code> match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
</ul>
</li>
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
</ul>
</div>
</div>
<div>
<label class="form-label small">Selected users</label>
<ul id="{selected_list_id}" class="list-group list-group-flush">
{selected_html}
</ul>
</div>
<script>
(function() {{
const search = document.getElementById('{search_id}');
const results = document.getElementById('{results_id}');
const select = document.getElementById('{field_id}');
const selectedList = document.getElementById('{selected_list_id}');
const gradeSelect = document.getElementById('grade_filter_{self.name}');
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
let timeout = null;
function fetchResults(q) {{
const grade = gradeSelect ? gradeSelect.value : '';
if (!q || q.trim().length === 0) {{
results.innerHTML = '';
return;
}}
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
fetch(url)
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
// re-run search with same query when grade changes
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 50);
}});
// Toggle help panel visibility
if (helpBtn && helpPanel) {{
helpBtn.addEventListener('click', function(e) {{
e.preventDefault();
helpPanel.classList.toggle('d-none');
const expanded = !helpPanel.classList.contains('d-none');
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}});
}}
// fieldName for this widget instance
const widgetFieldName = '{self.name}';
// Add button click delegation: results list buttons have data attributes
results.addEventListener('click', function(e) {{
const btn = e.target.closest('.user-add-btn');
if (!btn) return;
const id = btn.getAttribute('data-user-id');
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
const grade = btn.getAttribute('data-user-grade') || '';
addUserToField(widgetFieldName, id, text, grade);
}});
// Wire up the "Add all results" button if present
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
if (addAllBtn) {{
addAllBtn.addEventListener('click', function(e) {{
e.preventDefault();
addAllFromResults(widgetFieldName);
}});
}}
window.addUserToField = function(fieldName, id, text, grade) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
// prevent duplicate
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
sel.appendChild(opt);
// add to visible list (include grade if provided)
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.id = 'selected_user_' + fieldName + '_' + id;
const span = document.createElement('span');
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
li.appendChild(span); li.appendChild(btn);
selectedList.appendChild(li);
}}
window.removeUserFromField = function(fieldName, id) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
if (li && li.parentNode) li.parentNode.removeChild(li);
}}
window.addAllFromResults = function(fieldName) {{
const list = document.getElementById('user_search_results_list_' + fieldName);
if (!list) return;
const items = list.querySelectorAll('li[data-user-id]');
items.forEach(function(it) {{
const id = it.getAttribute('data-user-id');
const text = it.getAttribute('data-user-text') || it.textContent.trim();
const grade = it.getAttribute('data-user-grade') || '';
addUserToField(fieldName, id, text, grade);
}});
}}
}})();
</script>
</div>
"""
return mark_safe(html)
@@ -789,61 +593,9 @@ class UserUserGroupForm(ModelForm):
if 'users' in self.fields:
orig_field = self.fields['users']
class RenderWrapper:
def __init__(self, field):
self.field = field
# Minimal widget-compatible wrapper used by Django templates and
# form machinery. We implement the small subset of the Widget
# API that the templates/checks expect: `is_hidden`,
# `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`.
is_hidden = False
needs_multipart_form = False
use_fieldset = False
# attrs is expected by BoundField.id_for_label (widget.attrs.get('id'))
attrs = {}
def render(self, name, value, attrs=None, renderer=None):
# store attrs so template helpers can inspect them
self.attrs = attrs or {}
value = value or []
widget = UserSearchSelectMultipleWidget(name, value)
return widget.render()
def value_from_datadict(self, data, files, name):
# data is usually QueryDict with getlist
if hasattr(data, 'getlist'):
return data.getlist(name)
# fallback
val = data.get(name)
if val is None:
return []
return [val]
def id_for_label(self, id_):
# Called by BoundField.id_for_label; prefer explicit id in attrs
try:
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
return self.attrs.get('id')
except Exception:
pass
return id_
def use_required_attribute(self, initial):
# Called by Django to decide whether to add the required HTML attribute.
# We defer to the underlying field/widget if available; otherwise
# return False to avoid unexpected 'required' attributes.
try:
if hasattr(self, 'field') and getattr(self, 'field') is not None:
# If original field/widget had such method, prefer it.
orig_widget = getattr(self.field, 'widget', None)
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
return orig_widget.use_required_attribute(initial)
except Exception:
pass
return False
self.fields['users'].widget = RenderWrapper(orig_field)
# Use the reusable UserSearchWidget wrapper so templates and other
# modules can reuse the same behaviour consistently.
self.fields['users'].widget = UserSearchWidget(orig_field)
class Meta:
model = UserUserGroup
@@ -886,36 +638,32 @@ class UserGroupExamForm(ModelForm):
self.fields["anatomy_user_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=AnatomyExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(
verbose_name="Anatomy Exams", is_stacked=False
),
widget=ExamSearchWidget(exam_model=AnatomyExam),
)
self.fields["rapid_user_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=RapidsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
widget=ExamSearchWidget(exam_model=RapidsExam),
)
self.fields["shorts_user_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=ShortsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Shorts Exams", is_stacked=False),
widget=ExamSearchWidget(exam_model=ShortsExam),
)
self.fields["longs_user_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=LongsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Longs Exams", is_stacked=False),
widget=ExamSearchWidget(exam_model=LongsExam),
)
self.fields["physics_user_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=PhysicsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(
verbose_name="Physics Exams", is_stacked=False
),
widget=ExamSearchWidget(exam_model=PhysicsExam),
)
self.fields["sba_user_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=SbasExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False),
widget=ExamSearchWidget(exam_model=SbasExam),
)
class Meta:
@@ -983,31 +731,27 @@ class CidGroupExamForm(ModelForm):
self.fields["anatomy_cid_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=AnatomyExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(
verbose_name="Anatomy Exams", is_stacked=False
),
widget=ExamSearchWidget(exam_model=AnatomyExam),
)
self.fields["rapid_cid_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=RapidsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False),
widget=ExamSearchWidget(exam_model=RapidsExam),
)
self.fields["longs_cid_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=LongsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Longs Exams", is_stacked=False),
widget=ExamSearchWidget(exam_model=LongsExam),
)
self.fields["physics_cid_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=PhysicsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(
verbose_name="Physics Exams", is_stacked=False
),
widget=ExamSearchWidget(exam_model=PhysicsExam),
)
self.fields["sba_cid_user_groups"] = ModelMultipleChoiceField(
required=False,
queryset=SbasExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False),
widget=ExamSearchWidget(exam_model=SbasExam),
)
class Meta:
@@ -1132,12 +876,12 @@ class ExamCollectionForm(ModelForm):
self.fields[reverse_prop] = ModelMultipleChoiceField(
required=False,
queryset=exam_obj.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False),
widget=ExamSearchWidget(exam_model=exam_obj),
)
self.fields["author"] = ModelMultipleChoiceField(
queryset=User.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), required=False,
widget=UserSearchWidget(), required=False,
)
def save(self, commit=True):
+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',),
},
),
]
+3
View File
@@ -65,6 +65,9 @@ class AuthorMixin():
def is_author(self, user: User) -> bool:
"""Returns True if the user is an author of the object"""
if user.is_superuser:
return True
return self.author.filter(id=user.id).exists()
+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"),
+38 -11
View File
@@ -15,16 +15,43 @@
{% block content %}
{% endblock %}
{% block navigation %}
Candidates:
{% if request.user.is_authenticated %}
<a href="{% url 'generic:manage_cids' %}">Cids</a> /
<a href="{% url 'generic:manage_cid_exams' %}">Cids (exams)</a> /
<a href="{% url 'generic:cid_group_view' %}">Cid Groups</a> /
<a href="{% url 'generic:user_group_view' %}">User Groups</a> /
<a href="{% url 'trainees' %}">Trainees</a> /
<a href="{% url 'accounts_list' %}">Manage Users</a> /
<a href="{% url 'generic:supervisor' %}">Manage Supervisors</a> /
<a href="{% url 'generic:examcollection_list' %}">Collections</a>
<nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Candidates</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#genericNavbar" aria-controls="genericNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"><i class="bi bi-text-indent-right"></i></span>
</button>
<div class="collapse navbar-collapse" id="genericNavbar">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navCids" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-journal-text me-1"></i> Cids</a>
<ul class="dropdown-menu" aria-labelledby="navCids">
<li><a class="dropdown-item" href="{% url 'generic:manage_cids' %}"><i class="bi bi-file-earmark-text me-1"></i> All Cids</a></li>
<li><a class="dropdown-item" href="{% url 'generic:manage_cid_exams' %}"><i class="bi bi-mortarboard me-1"></i> Cids (exams)</a></li>
</ul>
</li>
{% endif %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navGroups" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-people me-1"></i> Groups</a>
<ul class="dropdown-menu" aria-labelledby="navGroups">
<li><a class="dropdown-item" href="{% url 'generic:cid_group_view' %}"><i class="bi bi-people-fill me-1"></i> Cid Groups</a></li>
<li><a class="dropdown-item" href="{% url 'generic:user_group_view' %}"><i class="bi bi-person-lines-fill me-1"></i> User Groups</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navUsers" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-person-badge me-1"></i> People</a>
<ul class="dropdown-menu" aria-labelledby="navUsers">
<li><a class="dropdown-item" href="{% url 'trainees' %}"><i class="bi bi-people me-1"></i> Trainees</a></li>
<li><a class="dropdown-item" href="{% url 'accounts_list' %}"><i class="bi bi-gear me-1"></i> Manage Users</a></li>
<li><a class="dropdown-item" href="{% url 'generic:supervisor' %}"><i class="bi bi-person-workspace me-1"></i> Manage Supervisors</a></li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %}
@@ -1,142 +1,251 @@
{% 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 >
<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>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<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>
</details>
<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.
{% 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>
{% with group.GetGroupExams as exam_map %}
{% for key, value in exam_map.items %}
<h4>{{key}}</h4>
<div>
{% for exam in value %}
<div class="d-flex">
<a href="{{exam.get_absolute_url}}" class="px-2">{{exam}}</a>
{% if group_type == "cid" %}
<a href="{{exam.get_cid_edit_url}}" class="edit-link" px-2>Edit candidates</a>
<form>
<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>
</form>
{% else %}
<a href="{{exam.get_user_edit_url}}" class="edit-link px-2">Edit candidates</a>
<form>
<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>
</form>
{% endif %}
<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>
<div class="px-2 ms-auto ">
{% if group_type == "cid" %}
(Cid candidate count: {{exam.valid_cid_users.count}})
{% else %}
(User candidate count: {{exam.valid_user_users.count}})
{% endif %}
{% 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 %}
</div>
{% with group.GetGroupExams as exam_map %}
{% for key, value in exam_map.items %}
<h5 class="mt-3">{{ key }}</h5>
<div class="list-group list-group-flush">
{% for exam in value %}
<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 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 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 %}
<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 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-3 ms-2 small text-muted">
{% if group_type == "cid" %}
(Cid candidate count: {{ exam.valid_cid_users.count }})
{% else %}
(User candidate count: {{ exam.valid_user_users.count }})
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
{% endwith %}
{% 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">
{% if cidusergroup %}
<p>Users can be added to the group <a href="{% url 'generic:manage_cids' %}">here</a></p>
<h3>Users</h3>
<ul>
{% 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>
{% empty %}
<li>Group currently has no users.</li>
{% endfor %}
</ul>
Count: {{cidusergroup.ciduser_set.count}}
{% endif %}
<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 %}
<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 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 class="list-group-item bg-transparent border-0 px-0 py-1 text-muted">Group currently has no users.</li>
{% endfor %}
</ul>
<div class="small text-muted mt-2">Count: {{ cidusergroup.ciduser_set.count }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</form>
{% endblock %}
+289 -132
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>
{% 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>
<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>
<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>
</ul>
</div>
</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>
</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}}">
<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>
</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}} /
</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>
{% 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")
(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;
}
if (el.dataset.user != "None") {
$(`.user-user[data-user="${el.dataset.user}"]`).addClass("submitted")
// 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 %}
+265 -82
View File
@@ -1,97 +1,280 @@
{% include "generic/exam_candidate_headers.html" %}
<h3>CID Users</h3>
{% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
{% 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.
{% 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>
<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 %}
<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 %}
<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 %}
</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="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>
{% endfor %}
{% for cid in available_cid_users %}
<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>
<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>
{% endif %}
<button class="add-all-btn">Add all</button>
<button class="remove-all-btn">Remove all</button>
</p>
{% 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`)
} else {
parent.removeClass("current");
toastr.info(`User ${el.target.dataset.cid} 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 {
toastr.error(data.status)
btn.disabled = false;
}
})
.fail(function (data) {
console.log("fail", data);
toastr.error(data.responseJSON.status)
const s = btn.querySelector('.btn-spinner'); if(s) s.remove();
}
}catch(e){ /* ignore */ }
}
})
.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);
});
});
})
</script>
<style>
.current {
color: lightgray;
}
.current::before {
content: "[ADDED TO EXAM] - "
}
// 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);
});
});
button {
padding: 0;
}
// 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>
/* 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>
@@ -93,7 +93,7 @@
<div class="card">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<div title="Defines if this exam is freely available to all users.">
Open access:
<span class="fw-bold">{{ exam.open_access }}</span>
</div>
@@ -121,10 +121,10 @@
</div>
</div>
<div class="card">
<div class="card-header p-2">
<div class="card-header p-2" title="Allow bulk setting of question open access status. Note: questions and exams have different open access settings.">
{# Use d-block on small screens so the summary text wraps nicely; md uses flex for spacing #}
<a class="d-block d-md-flex justify-content-between align-items-center" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk">
<span>Open access (questions)</span>
<a class="d-block d-md-flex justify-content-between align-items-center text-decoration-none text-reset" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk">
<small>Open access (questions)</small>
<small class="text-muted">Expand to change selected questions open access status</small>
</a>
</div>
@@ -133,7 +133,6 @@
<form hx-post="{% url 'generic:generic_exam_set_open_access' %}" hx-target="#action-result" hx-swap="innerHTML" class="row g-2 align-items-center">
<input type="hidden" name="type" value="sbas" />
<input type="hidden" name="exam_id" value="{{ exam.pk }}" />
<div class="col-12 small text-muted">Select questions on the list above (checkboxes) then click one of the actions below.</div>
<div class="col-12 d-flex flex-column flex-md-row gap-2 justify-content-md-end">
<button class="btn btn-sm btn-outline-success" type="submit" name="set_open_access" value="true" hx-include="input[name='selection']:checked">Set Open Access = True</button>
<button class="btn btn-sm btn-outline-danger" type="submit" name="set_open_access" value="false" hx-include="input[name='selection']:checked">Set Open Access = False</button>
+30 -14
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>
@@ -79,10 +79,18 @@
</tr>
{% endfor %}
</table>
</div>
</div>
<div>
<h3>Answers as a table</h3>
<table class="table table-dark table-striped table-hover table-sm col-sm">
<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>
<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>
@@ -127,7 +135,9 @@
<td>{{user_scores|get_item:cid}}</td>
{% endfor %}
</tr>
</table>
</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 %}
+103 -86
View File
@@ -1,103 +1,120 @@
{% include "generic/exam_candidate_headers.html" %}
<h3>Users</h3>
{% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} .
{% 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.
{% endif %}
<ol>
{% for user in current_user_users %}
<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 %}
<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 %}
<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 %}
</div>
<div class="btn-group">
{% if current_user_users or available_user_users %}
<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="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>
<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>
<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>
{% 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>
{% if current_user_users or available_user_users %}
<button class="toggle-all-btn">Toggle all</button>
{% endif %}
<button class="add-all-btn">Add all</button>
<button class="remove-all-btn">Remove all</button>
</p>
{% 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){} }
}
}
})
.always(function () {
})
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); });
}
});
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;
@@ -1,10 +1,30 @@
{% extends 'generic/base.html' %}
{% extends 'base.html' %}
{% block title %}
Exam Collections
{% endblock %}
{% block navigation %}
{{block.super}}
<br/>
{% comment %} <a href="{% url 'generic:cid_group_detail' cidusergroup.pk %}">Collections</a> / {% endcomment %}
<a href="{% url 'generic:examcollection_create' %}">Create Exam Collection</a>
<nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'generic:examcollection_list' %}">Collections</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examCollectionNavbar" aria-controls="examCollectionNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"><i class="bi bi-text-indent-right"></i></span>
</button>
<div class="collapse navbar-collapse" id="examCollectionNavbar">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'generic:examcollection_list' %}"><i class="bi bi-collection me-1" aria-hidden="true"></i> View Collections</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'generic:examcollection_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i> Create Collection</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %}
@@ -2,106 +2,154 @@
{% block content %}
<h1>
{{ object.name }}
{% if object.date %}
[{{object.date}}]
{% endif %}
</h1>
<div class="container py-4">
<div class="row align-items-start mb-3">
<div class="col">
<h1 class="h3 mb-0">{{ object.name }} {% if object.date %}<small class="text-muted">[{{ object.date }}]</small>{% endif %}</h1>
<div class="small text-muted mt-1">Authors: {{ object.get_authors }}</div>
</div>
<div class="col-auto">
<div class="btn-group" role="group" aria-label="collection-actions">
<a class="btn btn-outline-primary btn-sm" href="{% url 'generic:examcollection_edit' object.pk %}"><i class="bi bi-pencil"></i> Edit</a>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'generic:examcollection_clone' object.pk %}"><i class="bi bi-files"></i> Clone</a>
<a class="btn btn-outline-danger btn-sm" href="{% url 'generic:examcollection_delete' object.pk %}"><i class="bi bi-trash"></i> Delete</a>
</div>
</div>
</div>
<p>
<a href="{% url 'generic:examcollection_edit' object.pk %}">Edit</a>
<a href="{% url 'generic:examcollection_clone' object.pk %}">Clone</a>
<a href="{% url 'generic:examcollection_delete' object.pk %}">Delete</a>
</p>
<div>Authors: {{ object.get_authors }}</div>
<p>This collection contains the following exams<p>
<p class="lead small text-muted">This collection contains the following exams</p>
{% if object.anatomy_exams.all %}
<h2>Anatomy Exams:</h2>
{% with exams=object.anatomy_exams.all app_name="anatomy" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<a href="{% url 'anatomy:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Anatomy Exams</button>
{% 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>
<div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:exam_list_collection' object.pk %}">View exam list</a>
<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" 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 %}
<h2>Longs Exams:</h2>
{% with exams=object.longs_exams.all app_name="longs" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<a href="{% url 'longs:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Longs Exams</button>
{% 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>
<div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'longs:exam_list_collection' object.pk %}">View exam list</a>
<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" 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 %}
<h2>Rapids Exams:</h2>
{% with exams=object.rapids_exams.all app_name="rapids" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<a href="{% url 'rapids:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Rapids Exams</button>
{% 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>
<div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'rapids:exam_list_collection' object.pk %}">View exam list</a>
<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" 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 %}
<h2>Shorts Exams:</h2>
{% with exams=object.shorts_exams.all app_name="shorts" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<a href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Shorts Exams</button>
{% 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>
<div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
<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" 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 %}
<h2>Physics Exams:</h2>
{% with exams=object.physics_exams.all app_name="physics" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<a href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a>
{% 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" 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 %}
<h2>SBAs Exams:</h2>
{% with exams=object.sbas_exams.all app_name="sbas" %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
<a href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a>
{% 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" 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 %}
<br/>
<div id="bulk-add-groups">
<button class="btn-sm" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
</div>
<div class="d-flex gap-2 mt-3">
<div id="bulk-add-groups">
<button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
</div>
<div id="add-user">
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
</div>
<div id="add-user">
<button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
</div>
</div>
{% include 'exam_overview_js.html' %}
{% include 'exam_overview_js.html' %}
</div> {# .container end #}
{% endblock %}
{% block css %}
<style>
.add-marker-button {
opacity: 0.5;
}
/* subtle visual tweaks for the new layout */
.card-body .exam-list { margin: 0; }
.card .card-footer.add-marker { min-height: 1.5rem; }
.card-header strong { font-weight: 600; }
</style>
{% endblock %}
@@ -1,4 +1,4 @@
{% extends "generic/base.html" %}
{% extends "generic/examcollection_base.html" %}
<!-- {% load static from static %} -->
{% load crispy_forms_tags %}
@@ -7,20 +7,144 @@
{% endblock %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
{{form.media}}
{{form.media}}
<script type="text/javascript">
</script>
<script type="text/javascript">
</script>
<!-- {{ form.media }} -->
{% endblock %}
{% block content %}
<h2 class="mb-4">Edit Exam Collection <small class="text-muted">{{ object.name }}</small></h2>
<h2>Edit Exam Collection {{object.name}}</h2>
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form">
{% csrf_token %}
{{ form|crispy }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form" class="needs-validation" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
{% endif %}
<style>
/* Improve visual separation of form sections for this collection form */
#examcollection-form .mb-3 {
padding: .75rem 1rem;
margin-bottom: 1rem;
border-radius: .375rem;
background: rgba(255,255,255,0.02);
border-left: 3px solid rgba(255,255,255,0.03);
}
#examcollection-form .mb-3:nth-child(odd) {
background: rgba(255,255,255,0.015);
}
#examcollection-form .form-section-divider {
height: 1px;
background: rgba(255,255,255,0.04);
margin: .5rem 0 1rem 0;
border-radius: 2px;
}
#examcollection-form .form-section-title {
font-size: .95rem;
margin-bottom: .5rem;
font-weight: 600;
color: var(--bs-light);
}
</style>
<div class="mb-3">
<div class="row gy-3">
<div class="col-md-6">
<div class="mb-2">
<label for="id_name" class="form-label requiredField">Name <span class="text-danger">*</span></label>
{{ form.name }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">{{ form.name.errors }}</div>
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="mb-2">
<label for="id_date" class="form-label">Date</label>
{{ form.date }}
{% if form.date.errors %}
<div class="invalid-feedback d-block">{{ form.date.errors }}</div>
{% endif %}
</div>
</div>
<div class="col-md-3 d-flex align-items-center">
<div class="form-check mb-2">
{{ form.archive }}
<label for="id_archive" class="form-check-label ms-2">Archive</label>
</div>
</div>
</div>
<hr class="my-3" />
<div class="mb-3">
<h5 class="mb-2">Authors</h5>
<div>
{{ form.author }}
{% if form.author.errors %}
<div class="invalid-feedback d-block">{{ form.author.errors }}</div>
{% endif %}
</div>
</div>
<div class="row row-cols-1 row-cols-md-2 g-3">
{% for label, field_name, bound in exam_groups %}
<div class="col">
<div class="p-3 border rounded h-100">
<h6 class="mb-2">{{ label }}</h6>
<div>
{{ bound }}
{% if bound.errors %}
<div class="invalid-feedback d-block">{{ bound.errors }}</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
// Insert subtle dividers between logical groups (.mb-3 blocks)
(function(){
var form = document.getElementById('examcollection-form');
if (!form) return;
// find direct .mb-3 children inside the form
var groups = Array.from(form.querySelectorAll(':scope > .mb-3'));
if (groups.length <= 1) return;
for (var i = 1; i < groups.length; i++) {
var divider = document.createElement('div');
divider.className = 'form-section-divider';
groups[i].parentNode.insertBefore(divider, groups[i]);
}
})();
</script>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i> Save</button>
<a href="{% url 'generic:examcollection_list' %}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1" aria-hidden="true"></i> Back to collections</a>
<button type="reset" class="btn btn-light">Reset</button>
</div>
</form>
<script>
// Simple client-side bootstrap validation
(function () {
'use strict'
var forms = document.querySelectorAll('.needs-validation')
Array.prototype.slice.call(forms).forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
})()
</script>
{% endblock %}
@@ -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 %}
@@ -0,0 +1,28 @@
{% if results %}
<ul class="list-unstyled mb-0" id="exam_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center"
data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}"
data-exam-archive="{{ item.archive|yesno:'1,0' }}" data-exam-open-access="{{ item.open_access|yesno:'1,0' }}"
data-exam-exam-mode="{{ item.exam_mode|yesno:'1,0' }}" data-exam-candidates-only="{{ item.candidates_only|yesno:'1,0' }}">
<div class="flex-grow-1">
{{ item.text|escape }}
{% if item.archive %}<span class="badge bg-secondary ms-2">Archived</span>{% endif %}
{% if item.open_access %}<span class="badge bg-success ms-2">Open</span>{% endif %}
{% if item.exam_mode %}<span class="badge bg-info text-dark ms-2">Exam mode</span>{% endif %}
{% if item.candidates_only %}<span class="badge bg-warning text-dark ms-2">Candidates only</span>{% endif %}
</div>
<div>
<button type="button" class="btn btn-sm btn-primary exam-add-btn" data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}">Add</button>
</div>
</li>
{% endfor %}
</ul>
{% if results %}
<div class="mt-2 text-end">
<button type="button" class="btn btn-sm btn-outline-success exam-add-all-btn" data-field="{{ field }}">Add all results</button>
</div>
{% endif %}
{% else %}
<div><em>No exams found</em></div>
{% endif %}
@@ -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 %}
{% 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>
@@ -1,15 +1,15 @@
{% if results %}
<ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escape }}" data-user-grade="{{ item.grade|default_if_none:''|escape }}">
<div class="flex-grow-1">
{{ item.text|escape }}
{% if item.grade %}
<small class="text-muted ms-2">{{ item.grade|escape }}</small>
<span class="badge bg-secondary ms-2">{{ item.grade|escape }}</span>
{% endif %}
</div>
<div>
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">Add</button>
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escape }}" data-user-grade="{{ item.grade|default_if_none:''|escape }}">Add</button>
</div>
</li>
{% endfor %}
@@ -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>
+20 -33
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 %}
<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 class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_view' %}">View active</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_view_all' %}">View all</a>
{% endif %}
</div>
</div>
<script type="text/javascript">
$(".small-url-button").click((evt) => {
window.location = evt.currentTarget.dataset.url;
})
</script>
{% if view_all %}
<a href="{% url 'generic:user_group_view' %}">View active</a>
{% else %}
<a href="{% url 'generic:user_group_view_all' %}">View all</a>
{% endif %}
<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 %}
+42
View File
@@ -59,6 +59,7 @@ urlpatterns = [
),
path("user-search/", views.user_search, name="user_search"),
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
path("exam-search-widget/", views.exam_search_widget, name="exam_search_widget"),
path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
path(
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
@@ -87,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",
@@ -113,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(),
@@ -178,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(),
@@ -258,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,
@@ -415,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,
@@ -485,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",
+872 -68
View File
File diff suppressed because it is too large Load Diff
+738
View File
@@ -0,0 +1,738 @@
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.conf import settings
import json
# Default filters for exam search widget. Can be overridden in Django settings
# as `GENERIC_EXAM_SEARCH_DEFAULT_FILTERS` mapping, e.g.:
# GENERIC_EXAM_SEARCH_DEFAULT_FILTERS = { 'archive': False, 'exam_mode': True }
DEFAULT_EXAM_SEARCH_FILTERS = getattr(
settings, "GENERIC_EXAM_SEARCH_DEFAULT_FILTERS", {"archive": False, "exam_mode": True}
)
class UserSearchSelectMultipleWidget:
"""Reusable lightweight widget renderer for a searchable multi-user selector.
Usage: instantiate with (name, value) where value is an iterable of user ids
and call .render() to get the HTML fragment.
"""
def __init__(self, name, value):
self.name = name
self.value = value or []
def render(self):
field_id = f"id_{self.name}"
search_id = f"user_search_{self.name}"
results_id = f"user_search_results_{self.name}"
selected_list_id = f"selected_users_{self.name}"
users = User.objects.filter(pk__in=self.value) if self.value else []
options_html = ""
selected_html = ""
for u in users:
# determine grade_text first, then include data-user-grade attribute on option
grade_text = ""
try:
up = getattr(u, "userprofile", None)
if up and getattr(up, "grade", None):
g = up.grade
grade_text = getattr(g, "name", str(g)) if g is not None else ""
except Exception:
grade_text = ""
safe_grade_attr = (grade_text.replace('"', '&quot;') if grade_text else '')
options_html += f'<option value="{u.pk}" selected data-user-grade="{safe_grade_attr}">{u.get_full_name() or u.username} - {u.email}</option>'
# Build the selected item HTML incrementally to avoid accidental
# mixing of boolean values into string concatenation (was causing
# a TypeError in some environments).
selected_html += (
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
)
if grade_text:
selected_html += f' <span class="badge bg-light text-muted ms-2 small border">{grade_text}</span>'
selected_html += (
'</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
'</li>'
)
url = reverse("generic:user_search_widget")
grades_options = '<option value="">All grades</option>'
try:
from generic.models import UserGrades
grades_qs = UserGrades.objects.all()
for g in grades_qs:
grades_options += f'<option value="{g.pk}">{g.name}</option>'
except Exception:
grades_options = '<option value="">All grades</option>'
html = f"""
<div class="user-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html}
</select>
<div class="mb-2">
<div class="d-flex gap-2 align-items-start">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
{grades_options}
</select>
<!-- Help button for advanced search features -->
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
</div>
<div id="{results_id}" class="mt-2"></div>
<!-- Collapsible help panel (hidden by default) -->
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong>
<ul class="mb-0 mt-1">
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> or <code>full_name:</code> matches first OR last name (e.g. <code>name:smith</code>).</li>
<li><code>first:</code> or <code>first_name:</code> matches first name.</li>
<li><code>last:</code> or <code>last_name:</code> matches last name.</li>
<li><code>email:</code> matches email address.</li>
<li><code>username:</code> matches username.</li>
<li><code>id:</code> or <code>pk:</code> match by numeric user id (e.g. <code>id:123</code>).</li>
<li><code>grade:</code> match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
</ul>
</li>
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
</ul>
</div>
</div>
<div>
<label class="form-label small">Selected users</label>
<ul id="{selected_list_id}" class="list-group list-group-flush">
{selected_html}
</ul>
</div>
<script>
(function() {{
const search = document.getElementById('{search_id}');
const results = document.getElementById('{results_id}');
const select = document.getElementById('{field_id}');
const selectedList = document.getElementById('{selected_list_id}');
const gradeSelect = document.getElementById('grade_filter_{self.name}');
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
let timeout = null;
function fetchResults(q) {{
const grade = gradeSelect ? gradeSelect.value : '';
if (!q || q.trim().length === 0) {{
results.innerHTML = '';
return;
}}
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
fetch(url)
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
// Prevent Enter from submitting the surrounding form; run the search instead
search.addEventListener('keydown', function(e) {{
if (e.key === 'Enter' || e.keyCode === 13) {{
e.preventDefault();
e.stopPropagation();
clearTimeout(timeout);
fetchResults(search.value);
}}
}});
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
// re-run search with same query when grade changes
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 50);
}});
// Toggle help panel visibility
if (helpBtn && helpPanel) {{
helpBtn.addEventListener('click', function(e) {{
e.preventDefault();
helpPanel.classList.toggle('d-none');
const expanded = !helpPanel.classList.contains('d-none');
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}});
}}
// fieldName for this widget instance
const widgetFieldName = '{self.name}';
// Add button click delegation: results list buttons have data attributes
results.addEventListener('click', function(e) {{
const btn = e.target.closest('.user-add-btn');
if (!btn) return;
const id = btn.getAttribute('data-user-id');
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
// Try button attribute, then list-item dataset, then visible badge text
let grade = btn.getAttribute('data-user-grade') || '';
if (!grade) {{
const li = btn.closest('li[data-user-id]');
if (li && li.dataset && li.dataset.userGrade) grade = li.dataset.userGrade;
else {{
const badge = btn.closest('li') ? btn.closest('li').querySelector('.badge') : null;
if (badge) grade = badge.textContent.trim();
}}
}}
addUserToField(widgetFieldName, id, text, grade);
}});
// Wire up the "Add all results" button if present
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
if (addAllBtn) {{
addAllBtn.addEventListener('click', function(e) {{
e.preventDefault();
addAllFromResults(widgetFieldName);
}});
}}
window.addUserToField = function(fieldName, id, text, grade) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
// prevent duplicate
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
sel.appendChild(opt);
// add to visible list (include grade if provided)
const listEl = document.getElementById('selected_users_' + fieldName);
if (!listEl) return;
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.id = 'selected_user_' + fieldName + '_' + id;
const span = document.createElement('span');
span.innerHTML = text + (grade ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
li.appendChild(span); li.appendChild(btn);
listEl.appendChild(li);
}}
window.removeUserFromField = function(fieldName, id) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
if (li && li.parentNode) li.parentNode.removeChild(li);
}}
window.addAllFromResults = function(fieldName) {{
const list = document.getElementById('user_search_results_list_' + fieldName);
if (!list) return;
const items = list.querySelectorAll('li[data-user-id]');
items.forEach(function(it) {{
const id = it.getAttribute('data-user-id');
const text = it.getAttribute('data-user-text') || it.textContent.trim();
const grade = it.getAttribute('data-user-grade') || '';
addUserToField(fieldName, id, text, grade);
}});
}}
// Initialize visible selected-list from any pre-existing <option selected> entries
(function initSelectedListFromOptions() {{
try {{
const sel = document.getElementById('{field_id}');
const listEl = document.getElementById('{selected_list_id}');
if (!sel || !listEl) return;
for (let i=0;i<sel.options.length;i++) {{
const opt = sel.options[i];
const id = opt.value;
if (!id) continue;
const liId = 'selected_user_' + '{self.name}' + '_' + id;
const existingLi = document.getElementById(liId);
const grade = opt.getAttribute('data-user-grade') || '';
if (existingLi) {{
// If server-rendered li exists but has no badge, add one from the option attribute
const hasBadge = existingLi.querySelector('.badge');
if (!hasBadge && grade) {{
const span = existingLi.querySelector('span');
if (span) {{
const badge = document.createElement('span');
badge.className = 'badge bg-secondary ms-2';
badge.textContent = grade;
span.appendChild(badge);
}}
}}
continue;
}}
// build li from option when not rendered server-side
const text = opt.textContent || opt.innerText || opt.text;
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.id = liId;
const span = document.createElement('span');
span.innerHTML = text + (grade ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
btn.addEventListener('click', function() {{ removeUserFromField('{self.name}', id); }});
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
}}
}} catch (e) {{ /* ignore init errors */ }}
}})();
}})();
</script>
</div>
"""
return mark_safe(html)
class UserSearchWidget:
"""Django-widget-compatible lightweight wrapper around
`UserSearchSelectMultipleWidget` so it can be reused as a field widget.
Implements the small subset of the Widget API that Django's forms
and templates expect: `render`, `value_from_datadict`, `id_for_label`,
`use_required_attribute`, and a couple of simple attributes.
"""
is_hidden = False
needs_multipart_form = False
use_fieldset = False
def __init__(self, field=None):
# keep a reference to the original field if available (used for
# delegation such as `use_required_attribute`).
self.field = field
self.attrs = {}
def render(self, name, value, attrs=None, renderer=None):
self.attrs = attrs or {}
value = value or []
widget = UserSearchSelectMultipleWidget(name, value)
return widget.render()
def value_from_datadict(self, data, files, name):
# Most form backends provide getlist
if hasattr(data, "getlist"):
return data.getlist(name)
val = data.get(name)
if val is None:
return []
return [val]
def id_for_label(self, id_):
try:
if hasattr(self, "attrs") and self.attrs and self.attrs.get("id"):
return self.attrs.get("id")
except Exception:
pass
return id_
def use_required_attribute(self, initial):
try:
if hasattr(self, "field") and getattr(self, "field") is not None:
orig_widget = getattr(self.field, "widget", None)
if orig_widget and hasattr(orig_widget, "use_required_attribute"):
return orig_widget.use_required_attribute(initial)
except Exception:
pass
return False
class ExamSearchSelectMultipleWidget:
"""Lightweight renderer for selecting exams across types.
Parameters
- name, value: same as form widget usage
- exam_model: optional Django model class for an exam type (used to
adapt display and search behaviour)
- display_fields: optional list of extra fields to show in results
"""
def __init__(self, name, value, exam_model=None, display_fields=None, default_filters=None):
self.name = name
self.value = value or []
self.exam_model = exam_model
self.display_fields = display_fields or []
# default filters for this widget instance; fall back to module defaults
self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS
def render(self):
field_id = f"id_{self.name}"
search_id = f"exam_search_{self.name}"
results_id = f"exam_search_results_{self.name}"
selected_list_id = f"selected_exams_{self.name}"
# Render currently selected exams as <option>s
options_html = ""
selected_html = ""
try:
if self.value:
from django.apps import apps
# Accept either pks or model instances
instances = []
for v in self.value:
if hasattr(v, "pk"):
instances.append(v)
else:
# try to resolve by pk across possible models
# when exam_model is provided prefer that
if self.exam_model is not None:
try:
inst = self.exam_model.objects.filter(pk=v).first()
if inst:
instances.append(inst)
continue
except Exception:
pass
# fallback: try generic lookup across known exam models is expensive;
# just attempt to treat v as a pk for display-less option
instances.append(type("_", (), {"pk": v, "__str__": lambda s: str(v)}))
for ex in instances:
label = str(ex)
pk = getattr(ex, "pk", "")
options_html += f'<option value="{pk}" selected>{label}</option>'
# badges for metadata where available
# use smaller, less vibrant badges: low-opacity background + matching text color
archive_badge = '<span class="badge small border bg-secondary bg-opacity-10 text-secondary ms-2">Archived</span>' if getattr(ex, 'archive', False) else ''
open_badge = '<span class="badge small border bg-success bg-opacity-10 text-success ms-2">Open</span>' if getattr(ex, 'open_access', False) else ''
mode_badge = '<span class="badge small border bg-info bg-opacity-10 text-info ms-2">Exam mode</span>' if getattr(ex, 'exam_mode', False) else ''
cand_badge = '<span class="badge small border bg-warning bg-opacity-10 text-warning ms-2">Candidates only</span>' if getattr(ex, 'candidates_only', False) else ''
selected_html += (
f'<li id="selected_exam_{self.name}_{pk}" class="list-group-item d-flex justify-content-between align-items-center">'
f'<span>{label}{archive_badge}{open_badge}{mode_badge}{cand_badge}</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeExamFromField(\'{self.name}\', {pk})">Remove</button>'
f'</li>'
)
except Exception:
options_html = ""
selected_html = ""
url = reverse("generic:exam_search_widget") if reverse else ""
html = f"""
<div class="exam-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html}
</select>
<div class="mb-2">
<div class="d-flex gap-2 align-items-start">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search exams by name or code" />
<!-- optional type indicator -->
{f'<div class="ms-2"><span class="badge bg-primary text-white small fw-semibold" aria-hidden="true">{self.exam_model._meta.verbose_name.title()}</span><span class="visually-hidden">Exam type: {self.exam_model._meta.verbose_name.title()}</span></div>' if self.exam_model is not None else ''}
<button type="button" id="exam_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="exam_search_help_panel_{self.name}" title="Advanced search help">?</button>
</div>
<div id="exam_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong>
<div class="mb-2 mt-2">
<strong class="small">Filters</strong>
<div class="d-flex gap-2 mt-1">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_archive_{self.name}" />
<label class="form-check-label small" for="filter_archive_{self.name}">Archived only</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_open_access_{self.name}" />
<label class="form-check-label small" for="filter_open_access_{self.name}">Open access only</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_exam_mode_{self.name}" />
<label class="form-check-label small" for="filter_exam_mode_{self.name}">Exam mode only</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_candidates_only_{self.name}" />
<label class="form-check-label small" for="filter_candidates_only_{self.name}">Candidates only</label>
</div>
</div>
</div>
<ul class="mb-0 mt-1">
<li>Basic: type any part of an exam's name, code or identifier (case-insensitive substring match).</li>
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> matches the exam name.</li>
<li><code>code:</code> matches an exam code or short identifier.</li>
<li><code>id:</code> or <code>pk:</code> match by numeric id.</li>
<li><code>date:</code> match by date (YYYY-MM-DD) where available.</li>
<li><code>type:</code> narrow to an exam type (e.g. <code>type:anatomy</code>).</li>
</ul>
</li>
<li>To add exams, click the <em>Add</em> button beside a result. Use <em>Add all results</em> to import visible matches.</li>
<li>Wildcard: use <code>*</code> to broaden results; wildcards may return more results and are rate-limited.</li>
</ul>
</div>
<div id="{results_id}" class="mt-2"></div>
</div>
<div>
<label class="form-label small">Selected exams</label>
<ul id="{selected_list_id}" class="list-group list-group-flush">
{selected_html}
</ul>
<div class="mt-2 text-end">
<button type="button" id="exam_remove_all_{self.name}" class="btn btn-sm btn-outline-danger exam-remove-all-btn d-none py-0 px-1 small" data-field="{self.name}">Remove all</button>
</div>
</div>
<script>
(function() {{
const search = document.getElementById('{search_id}');
const results = document.getElementById('{results_id}');
const select = document.getElementById('{field_id}');
const selectedList = document.getElementById('{selected_list_id}');
const helpBtn = document.getElementById('exam_search_help_btn_{self.name}');
const helpPanel = document.getElementById('exam_search_help_panel_{self.name}');
const defaultModel = '{self.exam_model._meta.label if self.exam_model is not None else ''}';
const defaultFilters = {json.dumps(self.default_filters)};
const widgetFieldName = '{self.name}';
let timeout = null;
function fetchResults(q) {{
if (!q || q.trim().length === 0) {{
results.innerHTML = '';
return;
}}
const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : '';
const f_archive = document.getElementById('filter_archive_{self.name}');
const f_open = document.getElementById('filter_open_access_{self.name}');
const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}');
const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
let extra = '';
// Initialize checkbox states from defaults (if provided)
try {{
if (f_archive && defaultFilters.hasOwnProperty('archive')) f_archive.checked = !!defaultFilters.archive;
if (f_open && defaultFilters.hasOwnProperty('open_access')) f_open.checked = !!defaultFilters.open_access;
if (f_exam_mode && defaultFilters.hasOwnProperty('exam_mode')) f_exam_mode.checked = !!defaultFilters.exam_mode;
if (f_candidates && defaultFilters.hasOwnProperty('candidates_only')) f_candidates.checked = !!defaultFilters.candidates_only;
}} catch (e) {{ /* ignore */ }}
// For each supported filter, send the param if there's a default or the checkbox exists.
function appendFilterParam(name, checkboxEl, defaultVal) {{
if (typeof defaultVal !== 'undefined') {{
const val = checkboxEl ? checkboxEl.checked : defaultVal;
extra += '&' + encodeURIComponent(name) + '=' + (val ? '1' : '0');
}} else if (checkboxEl && checkboxEl.checked) {{
extra += '&' + encodeURIComponent(name) + '=1';
}}
}}
appendFilterParam('archive', f_archive, defaultFilters.archive);
appendFilterParam('open_access', f_open, defaultFilters.open_access);
appendFilterParam('exam_mode', f_exam_mode, defaultFilters.exam_mode);
appendFilterParam('candidates_only', f_candidates, defaultFilters.candidates_only);
const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(q) + modelParam + extra;
fetch(url)
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
// Prevent Enter from submitting the surrounding form; run the search instead
search.addEventListener('keydown', function(e) {{
if (e.key === 'Enter' || e.keyCode === 13) {{
e.preventDefault();
e.stopPropagation();
clearTimeout(timeout);
fetchResults(search.value);
}}
}});
// Toggle help panel visibility
if (helpBtn && helpPanel) {{
helpBtn.addEventListener('click', function(e) {{
e.preventDefault();
helpPanel.classList.toggle('d-none');
const expanded = !helpPanel.classList.contains('d-none');
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}});
}}
// Delegate clicks from results: add single or add-all (works for dynamically inserted content)
results.addEventListener('click', function(e) {{
const addBtn = e.target.closest('.exam-add-btn');
if (addBtn) {{
const id = addBtn.getAttribute('data-exam-id');
const text = addBtn.getAttribute('data-exam-text') || addBtn.textContent.trim();
addExamToField(widgetFieldName, id, text);
return;
}}
const addAllBtnDynamic = e.target.closest('.exam-add-all-btn');
if (addAllBtnDynamic) {{
e.preventDefault();
addAllExamsFromResults(widgetFieldName);
return;
}}
}});
// Wire up "Add all results" static button if present (fallback)
const addAllBtn = results.parentElement.querySelector('.exam-add-all-btn');
if (addAllBtn) {{
addAllBtn.addEventListener('click', function(e) {{
e.preventDefault();
addAllExamsFromResults(widgetFieldName);
}});
}}
// Initialize remove-all visibility based on any pre-populated selections
try {{
var initList = document.getElementById('selected_exams_' + widgetFieldName);
var remBtnInit = document.getElementById('exam_remove_all_' + widgetFieldName);
if (remBtnInit && initList) {{
remBtnInit.classList.toggle('d-none', initList.querySelectorAll('li').length < 2);
}}
}} catch (e) {{ /* ignore in older browsers */ }}
// Wire up "Remove all" button for this widget instance
const removeAllBtn = document.getElementById('exam_remove_all_' + widgetFieldName);
if (removeAllBtn) {{
removeAllBtn.addEventListener('click', function(e) {{
e.preventDefault();
removeAllExamsFromField(widgetFieldName);
}});
}}
window.addExamToField = function(fieldName, id, text) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
sel.appendChild(opt);
const listEl = document.getElementById('selected_exams_' + fieldName);
if (!listEl) return;
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.id = 'selected_exam_' + fieldName + '_' + id;
const span = document.createElement('span'); span.innerHTML = text;
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
btn.addEventListener('click', function() {{ removeExamFromField(fieldName, id); }});
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
// update the remove-all button visibility
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
if (remBtn) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
}}
window.removeExamFromField = function(fieldName, id) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
const li = document.getElementById('selected_exam_' + fieldName + '_' + id);
if (li && li.parentNode) li.parentNode.removeChild(li);
// update the remove-all button visibility
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
var listEl = document.getElementById('selected_exams_' + fieldName);
if (remBtn && listEl) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
}}
window.addAllExamsFromResults = function(fieldName) {{
const list = document.getElementById('exam_search_results_list_' + fieldName);
if (!list) return;
const items = list.querySelectorAll('li[data-exam-id]');
items.forEach(function(it) {{
const id = it.getAttribute('data-exam-id');
const text = it.getAttribute('data-exam-text') || it.textContent.trim();
addExamToField(fieldName, id, text);
}});
// ensure remove-all button is visible if more than one
var remBtn = document.getElementById('exam_remove_all_' + fieldName);
var listEl = document.getElementById('selected_exams_' + fieldName);
if (remBtn && listEl) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
}}
window.removeAllExamsFromField = function(fieldName) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
// remove all options
for (let i=sel.options.length-1;i>=0;i--) {{ sel.remove(i); }}
// clear visible list
const listEl = document.getElementById('selected_exams_' + fieldName);
if (listEl) {{ listEl.innerHTML = ''; }}
// hide remove-all button
const remBtn = document.getElementById('exam_remove_all_' + fieldName);
if (remBtn) {{ remBtn.classList.add('d-none'); }}
}}
}})();
</script>
</div>
"""
return mark_safe(html)
class ExamSearchWidget:
"""Widget wrapper that is compatible with Django form machinery.
Accepts `exam_model` to specialise behaviour/display.
"""
is_hidden = False
needs_multipart_form = False
use_fieldset = False
def __init__(self, field=None, exam_model=None, display_fields=None, default_filters=None):
self.field = field
self.attrs = {}
self.exam_model = exam_model
self.display_fields = display_fields or []
# allow overriding defaults per widget instance
self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS
def render(self, name, value, attrs=None, renderer=None):
self.attrs = attrs or {}
value = value or []
widget = ExamSearchSelectMultipleWidget(
name,
value,
exam_model=self.exam_model,
display_fields=self.display_fields,
default_filters=self.default_filters,
)
return widget.render()
def value_from_datadict(self, data, files, name):
if hasattr(data, 'getlist'):
return data.getlist(name)
val = data.get(name)
if val is None:
return []
return [val]
def id_for_label(self, id_):
try:
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
return self.attrs.get('id')
except Exception:
pass
return id_
def use_required_attribute(self, initial):
try:
if hasattr(self, 'field') and getattr(self, 'field') is not None:
orig_widget = getattr(self.field, 'widget', None)
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
return orig_widget.use_required_attribute(initial)
except Exception:
pass
return False
+229 -75
View File
@@ -12,10 +12,15 @@
{% include "exam_clock.html" %}
<div class="no-select">
<h2>
{{ exam }}: Question
[<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>]
</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">
@@ -31,7 +36,7 @@
<div>
<p><span id="question-stem">{{ question.stem|safe }}</span></p>
</div>
<div id="question-fragment"
hx-trigger="load"
@@ -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 %}
@@ -80,20 +187,20 @@
.exam-take-container { max-width: 980px; margin: 0 auto; padding: 1rem; }
.no-select { user-select: none; }
.flex-container { display: flex; align-items: center; gap: 0.75rem; }
.flex-container { display: flex; align-items: center; gap: 0.75rem; }
/* fieldWrapper is used around each label + input; ensure it stretches the full width */
.fieldWrapper { width: 100%; display: flex; align-items: center; gap: 0.75rem; }
.fieldWrapper { width: 100%; display: flex; align-items: center; gap: 0.75rem; }
/* We keep two main columns inside each fieldWrapper:
- the label (question-text) which should take the bulk of space and sit to the left on wide screens
- the input container (.flex-1) which holds the input and a post-input label; the post-input should float right
*/
.flex-8 { flex: 8; }
.flex-1 { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
.flex-8 { flex: 8; }
.flex-1 { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
/* Left-align label text on wide screens so the question text appears on the left, input between, and the
post-input label floats to the right. Padding keeps visual separation. */
.question-text { margin: 0; text-align: left; padding-right: 0.5rem; }
.question-text { margin: 0; text-align: left; padding-right: 0.5rem; }
/* Answer items styled as cards */
.physics-answer-list { list-style: none; padding: 0; margin: 0; }
@@ -113,8 +220,8 @@
.question-text { margin: 0; }
/* Post-input badge (shows True/False text next to inputs) */
.postinput { display: inline-block; min-width: 3rem; text-align: right; color: var(--muted); }
.physics-answer-list input { margin: 0 0.25rem 0 0; }
.postinput { display: inline-block; min-width: 3rem; text-align: right; color: var(--muted); }
.physics-answer-list input { margin: 0 0.25rem 0 0; }
/* show a compact post-label on the right of the input container */
.physics-answer-list input:checked + .postinput::after {
content: 'True'; color: var(--success); font-weight: 600; margin-left: 0.4rem;
@@ -172,15 +279,15 @@
.mt-2 .save.btn.btn-default { background: var(--feedback-bg); border: 1px solid var(--card-border); color: var(--text); }
/* Overview button - make it stand out using the primary color */
#overview-button { background: var(--primary); border-color: var(--primary); color: #fff; }
#overview-button:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
#overview-button { background: var(--primary); border-color: var(--primary); color: #fff; }
#overview-button:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
/* Previous / Next button colors */
.mt-2 button[name="previous"] { background: var(--info); border-color: var(--info); color: #fff; }
.mt-2 button[name="previous"]:hover { filter: brightness(0.95); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
.mt-2 button[name="previous"] { background: var(--info); border-color: var(--info); color: #fff; }
.mt-2 button[name="previous"]:hover { filter: brightness(0.95); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
.mt-2 button[name="next"] { background: var(--info); border-color: var(--info); color: #fff; }
.mt-2 button[name="next"]:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
.mt-2 button[name="next"] { background: var(--info); border-color: var(--info); color: #fff; }
.mt-2 button[name="next"]:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
/* Small helper: make labels wrap nicely on small screens */
@media (max-width: 576px) {
@@ -192,81 +299,128 @@
.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>
<script>
/* HTMX swap helpers: preserve scroll and container height to avoid page jumping
Listens for htmx:beforeSwap and htmx:afterSwap on the question fragment container.
*/
(function(){
function getTarget(evt){
return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
}
(function(){
function getTarget(evt){
return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
}
document.addEventListener('htmx:beforeSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
document.addEventListener('htmx:beforeSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
// save scroll position
target.__savedScrollY = window.scrollY || window.pageYOffset || 0;
target.__savedScrollY = window.scrollY || window.pageYOffset || 0;
// preserve current height to avoid layout shift while fragment is swapping
target.style.minHeight = target.offsetHeight + 'px';
}catch(e){ console && console.error && console.error(e); }
});
target.style.minHeight = target.offsetHeight + 'px';
}catch(e){ console && console.error && console.error(e); }
});
document.addEventListener('htmx:afterSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
var y = target.__savedScrollY;
if(typeof y === 'number') window.scrollTo(0, y);
document.addEventListener('htmx:afterSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
var y = target.__savedScrollY;
if(typeof y === 'number') window.scrollTo(0, y);
// remove the minHeight after a short delay to allow content to layout
setTimeout(function(){ target.style.minHeight = ''; }, 60);
setTimeout(function(){ target.style.minHeight = ''; }, 60);
// notify fragment-loaded so fragment init code can listen if required
target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true }));
}catch(e){ console && console.error && console.error(e); }
});
})();
</script>
<script>
target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true }));
}catch(e){ console && console.error && console.error(e); }
});
})();
</script>
<script>
// Global listener for server-triggered save confirmations.
// The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs.
document.addEventListener('saved', function(evt){
try {
document.addEventListener('saved', function(evt){
try {
// Prefer toastr if available
if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') {
toastr.success('Saved');
return;
}
if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') {
toastr.success('Saved');
return;
}
// Lightweight fallback toast
const id = 'htmx-save-toast';
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.style.position = 'fixed';
el.style.top = '1rem';
el.style.right = '1rem';
el.style.zIndex = 2000;
el.style.padding = '0.6rem 0.9rem';
el.style.background = 'rgba(40,167,69,0.95)';
el.style.color = '#fff';
el.style.borderRadius = '0.4rem';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
el.style.fontWeight = '600';
el.style.opacity = '0';
el.style.transition = 'opacity 220ms ease-in-out';
document.body.appendChild(el);
}
el.textContent = 'Saved';
const id = 'htmx-save-toast';
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.style.position = 'fixed';
el.style.top = '1rem';
el.style.right = '1rem';
el.style.zIndex = 2000;
el.style.padding = '0.6rem 0.9rem';
el.style.background = 'rgba(40,167,69,0.95)';
el.style.color = '#fff';
el.style.borderRadius = '0.4rem';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
el.style.fontWeight = '600';
el.style.opacity = '0';
el.style.transition = 'opacity 220ms ease-in-out';
document.body.appendChild(el);
}
el.textContent = 'Saved';
// show
requestAnimationFrame(function(){ el.style.opacity = '1'; });
requestAnimationFrame(function(){ el.style.opacity = '1'; });
// hide after 1.6s
setTimeout(function(){ el.style.opacity = '0'; }, 1600);
} catch (e) { console && console.error && console.error(e); }
});
</script>
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>
{% endblock %}
@@ -2,11 +2,11 @@
{% block content %}
<span id="user-id">
{% if not cid %}
User: {{request.user}}
{% else %}
CID: {{cid}}
{% endif %}
{% if not cid %}
User: {{request.user}}
{% else %}
CID: {{cid}}
{% endif %}
</span>
{% include "exam_clock.html" %}
@@ -14,7 +14,7 @@
{% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert">
Exam is in review mode. Score are available
Exam is in review mode. Score are available
{% if request.user.is_authenticated %}
<a href="{% url 'physics:exam_scores_user' pk=exam.id %}">here</a>
@@ -24,7 +24,7 @@
</div>
{% elif cid_user_exam.completed %}
<div class="alert alert-primary review-mode-alert" role="alert">
Exam completed. Answers are available
Exam completed. Answers are available
{% if request.user.is_authenticated %}
<a href="{% url 'physics:exam_scores_user' pk=exam.id %}">here</a>
@@ -37,42 +37,44 @@
You have unanswered questions.
</div>
{% endif %}
<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 %}
</div>
<div id="time-details">
Start time: {{cid_user_exam.start_time}}<br/>
Last change time: {{cid_user_exam.end_time}}<br/>
Completed: {{cid_user_exam.completed}}
Start time: {{cid_user_exam.start_time}}<br/>
Last change time: {{cid_user_exam.end_time}}<br/>
Completed: {{cid_user_exam.completed}}
</div>
{% 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 %}
@@ -97,62 +99,80 @@
{% endblock %}
{% block css %}
<style>
<style>
/* Overview page specific styles */
.overview-text {
margin-bottom: 1rem;
font-weight: 500;
}
.overview-text {
margin-bottom: 1rem;
font-weight: 500;
}
.physics-finish-list {
display: grid;
.physics-finish-list {
display: grid;
/* Limit the maximum width of each tile so buttons don't stretch too wide on large screens.
Each tile will be between 80px and 140px. The grid will wrap responsively. */
grid-template-columns: repeat(auto-fit, minmax(80px, 140px));
justify-content: start;
gap: .5rem;
align-items: stretch;
margin-bottom: 1rem;
}
grid-template-columns: repeat(auto-fit, minmax(80px, 140px));
justify-content: start;
gap: .5rem;
align-items: stretch;
margin-bottom: 1rem;
}
.overview-question-btn {
.overview-question-btn {
/* Fill the grid cell but the cell itself is capped by the grid-template-columns above. */
width: 100%;
box-sizing: border-box;
text-align: left;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: .5rem .75rem;
display: flex;
align-items: center;
justify-content: flex-start;
}
width: 100%;
box-sizing: border-box;
text-align: left;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: .5rem .75rem;
display: flex;
align-items: center;
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;
color: #dc3545 !important;
background: rgba(220,53,69,0.03);
}
.overview-question-btn[title] {
border-color: #dc3545 !important;
color: #dc3545 !important;
background: rgba(220,53,69,0.03);
}
#time-details {
font-size: .95rem;
color: #9aa0a6;
margin-top: .5rem;
}
#time-details {
font-size: .95rem;
color: #9aa0a6;
margin-top: .5rem;
}
/* Make the finish button stand out on small screens */
.btn-primary.mt-3 {
min-width: 160px;
}
@media (max-width: 575.98px) {
.physics-finish-list {
/* On very small screens allow slightly narrower tiles but still wrap */
grid-template-columns: repeat(auto-fit, minmax(70px, 110px));
.btn-primary.mt-3 {
min-width: 160px;
}
.overview-question-btn { font-size: .95rem }
}
</style>
@media (max-width: 575.98px) {
.physics-finish-list {
/* On very small screens allow slightly narrower tiles but still wrap */
grid-template-columns: repeat(auto-fit, minmax(70px, 110px));
}
.overview-question-btn { font-size: .95rem }
}
</style>
{% endblock %}
@@ -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,7 +1,8 @@
<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">
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">
{% csrf_token %}
{{ form.non_field_errors }}
@@ -55,21 +56,36 @@
</div>
<div class="mt-2">
{% 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>
{% endif %}
<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" 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>
{% 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>
{% endif %}
{% 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" 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>
<br />
<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>
<!-- 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>
{% endif %}
{% endif %}
<br />
<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>
@@ -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') {
@@ -108,7 +134,7 @@
if (el == li_el.find('input').get(0).checked) {
li_el.addClass('answer-correct');
} else {
li_el.addClass('answer-incorrect');
li_el.addClass('answer-incorrect');
}
});
@@ -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;
$('#goto-button').click();
});
// 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",
+154 -10
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",
@@ -419,13 +492,84 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
"previous": previous,
"exam_length": exam_length,
"pos": pos,
"saved_answer": saved_answer,
"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)):
+20 -2
View File
@@ -31,6 +31,8 @@ DEBUG = int(os.environ.get("DEBUG", default=0))
ALLOWED_HOSTS = [
"localhost",
"localhost:8000",
"localhost:8080",
"127.0.0.1",
"161.35.163.87",
"46.101.13.46",
@@ -241,7 +243,7 @@ MEDIA_ROOT = "media/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
INTERNAL_IPS = ["localhost", "127.0.0.1"]
INTERNAL_IPS = ["localhost", "127.0.0.1", "localhost:8000", "localhost:8080"]
#LOGGING = {
# "version": 1,
@@ -255,6 +257,18 @@ INTERNAL_IPS = ["localhost", "127.0.0.1"]
CORS_ORIGIN_ALLOW_ALL = True
# Development: trust local origins (including ports) so the dev nginx reverse
# proxy and the Django runserver can POST/PUT without CSRF Origin errors.
if DEBUG:
CSRF_TRUSTED_ORIGINS = [
"http://localhost",
"http://127.0.0.1",
"http://localhost:8000",
"http://127.0.0.1:8000",
"http://localhost:8080",
"http://127.0.0.1:8080",
]
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
@@ -311,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)
+2 -1
View File
@@ -58,4 +58,5 @@ requests
pygments
celery[redis]
redis
pywatchman
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())
+57
View File
@@ -41,4 +41,61 @@ if [ -f "$ENV_FILE" ]; then
done < "$ENV_FILE"
fi
# Ensure runtime directories exist for Loki and nginx logs so compose startup
# doesn't fail with permission/missing-folder errors. We create the minimal
# subfolders Loki expects and a host `logs/` for nginx/Promtail.
echo "Ensuring loki and logs folders exist under $REPO_ROOT/docker and $REPO_ROOT/logs"
mkdir -p "$REPO_ROOT/docker/loki-data/index" \
"$REPO_ROOT/docker/loki-data/cache" \
"$REPO_ROOT/docker/loki-data/chunks" \
"$REPO_ROOT/docker/loki-data/compactor" \
"$REPO_ROOT/docker/loki-wal" \
"$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.
# 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
if command -v sudo >/dev/null 2>&1; then
echo "Attempting to chown loki folders to UID 10001 (may prompt for sudo password)"
sudo chown -R 10001:10001 "$REPO_ROOT/docker/loki-data" "$REPO_ROOT/docker/loki-wal" || \
echo "sudo chown failed or was cancelled; Loki may not start until these folders are owned by UID 10001"
else
echo "Note: sudo not available. Created loki folders but did not chown them."
echo "If Loki fails with permission errors, run:"
echo " sudo chown -R 10001:10001 $REPO_ROOT/docker/loki-data $REPO_ROOT/docker/loki-wal"
fi
fi
COMPOSE_ENV=$COMPOSE_ENV docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up --build
+173 -170
View File
@@ -110,76 +110,76 @@
}
}
/* Improve navbar tap targets and mobile dropdown behaviour */
@media (max-width: 991.98px) {
@media (max-width: 991.98px) {
/* Make navbar links larger and easier to tap */
.navbar-nav .nav-link {
padding: .75rem 1rem;
font-size: 1.05rem;
min-height: 44px; /* recommended minimum touch target */
display: block;
margin: .25rem 0;
border-radius: .375rem;
background-color: var(--bs-primary);
color: #fff !important;
text-align: center;
box-shadow: 0 1px 0 rgba(0,0,0,0.05) inset;
}
.navbar-nav .nav-link {
padding: .75rem 1rem;
font-size: 1.05rem;
min-height: 44px; /* recommended minimum touch target */
display: block;
margin: .25rem 0;
border-radius: .375rem;
background-color: var(--bs-primary);
color: #fff !important;
text-align: center;
box-shadow: 0 1px 0 rgba(0,0,0,0.05) inset;
}
/* Make links look like outline buttons for secondary links */
.navbar-nav .nav-link.btn-outline {
background-color: transparent;
color: var(--bs-primary) !important;
border: 1px solid rgba(255,255,255,0.08);
}
.navbar-nav .nav-link.btn-outline {
background-color: transparent;
color: var(--bs-primary) !important;
border: 1px solid rgba(255,255,255,0.08);
}
/* Make dropdown items full-width and larger */
.navbar-nav .dropdown-menu .dropdown-item {
padding: .75rem 1rem;
font-size: 1.05rem;
background: transparent;
color: inherit;
border-radius: .25rem;
margin: .125rem 0;
}
.navbar-nav .dropdown-menu .dropdown-item {
padding: .75rem 1rem;
font-size: 1.05rem;
background: transparent;
color: inherit;
border-radius: .25rem;
margin: .125rem 0;
}
/* When the navbar is collapsed, render dropdown menus as static stacked blocks
so nested items are easy to tap (prevents absolute-position overlap issues). */
.navbar-collapse .dropdown-menu {
position: static !important;
float: none !important;
display: block !important;
width: 100% !important;
margin-top: .25rem;
}
.navbar-collapse .dropdown-menu {
position: static !important;
float: none !important;
display: block !important;
width: 100% !important;
margin-top: .25rem;
}
/* Ensure dropdown toggles are full-width when collapsed */
.navbar-collapse .dropdown > .nav-link {
width: 100%;
text-align: left;
display: block;
}
.navbar-collapse .dropdown > .nav-link {
width: 100%;
text-align: left;
display: block;
}
html, body {
html, body {
/* Slightly increase root font-size so rem-based components scale */
font-size: 182%;
line-height: 1.45;
}
font-size: 182%;
line-height: 1.45;
}
/* Slightly larger headings for clear hierarchy */
h1 { font-size: 1.8rem; }
h2 { font-size: 1.4rem; }
h3 { font-size: 1.15rem; }
h1 { font-size: 1.8rem; }
h2 { font-size: 1.4rem; }
h3 { font-size: 1.15rem; }
/* Improve spacing for paragraphs and lists */
p, li { font-size: 1rem; }
p, li { font-size: 1rem; }
/* Make form controls and buttons a bit larger for touch */
.form-control, .btn { font-size: 1rem; padding: .6rem .75rem; }
.form-control, .btn { font-size: 1rem; padding: .6rem .75rem; }
}
}
</style>
{% block css %}
@@ -224,7 +224,10 @@
<a class="dropdown-item" href="{% url 'sbas:index' %}">SBAs</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'generic:examcollection_list' %}">Exam Collection</a>
<a class="dropdown-item d-flex justify-content-between align-items-center" href="{% url 'generic:examcollection_list' %}">
<span><i class="bi bi-collection me-1" aria-hidden="true"></i> Exam Collections</span>
<span class="badge bg-secondary ms-2">Manage</span>
</a>
</li>
</ul>
</li>
@@ -267,12 +270,12 @@
<div class="col-md-12">
<div id="htmx-error"></div>
<span
id="cimar-login-needed"
hx-get='{% url "cimar_status" %}'
hx-trigger="cimar-login-needed from:body"
>
</span>
<span
id="cimar-login-needed"
hx-get='{% url "cimar_status" %}'
hx-trigger="cimar-login-needed from:body"
>
</span>
{% block content %}
{% endblock %}
</div>
@@ -313,10 +316,10 @@
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function() {
<script>
document.addEventListener('DOMContentLoaded', function() {
// Wire up all row-selection control blocks to the nearest .js-row-selectable table.
function findTableForControl(elem) {
function findTableForControl(elem) {
// Walk up ancestors and look for a table that either has the
// `js-row-selectable` class or contains a checkbox named 'selection'.
//var ancestor = elem.closest('div, section, main, article, form, body');
@@ -336,151 +339,151 @@ document.addEventListener('DOMContentLoaded', function() {
// ancestor = ancestor.parentElement;
//}
// fallback to any table on the page that looks like it has selection
var any = document.querySelector('table.js-row-selectable') //|| Array.from(document.querySelectorAll('table')).find(function(t){ return t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]')); });
return any || null;
}
var any = document.querySelector('table.js-row-selectable') //|| Array.from(document.querySelectorAll('table')).find(function(t){ return t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]')); });
return any || null;
}
// Helper to build id with optional token
function suffixed(idBase, token) {
return token ? idBase + '-' + token : idBase;
}
function suffixed(idBase, token) {
return token ? idBase + '-' + token : idBase;
}
// Ensure every selectable table has a row-selection control block. If the
// page author didn't render the controls, create them automatically and
// insert them before the table so the behavior is available out-of-the-box.
function ensureControlsForTable(table) {
if (!table) return;
function ensureControlsForTable(table) {
if (!table) return;
// detect an existing nearby toggle button
var existing = table.previousElementSibling && table.previousElementSibling.querySelector && table.previousElementSibling.querySelector('[id^="toggle-row-selection"]');
if (existing) return; // already present
var existing = table.previousElementSibling && table.previousElementSibling.querySelector && table.previousElementSibling.querySelector('[id^="toggle-row-selection"]');
if (existing) return; // already present
// generate a short token for unique IDs
var token = (Date.now().toString(16) + Math.floor(Math.random()*0xffff).toString(16));
var token = (Date.now().toString(16) + Math.floor(Math.random()*0xffff).toString(16));
var wrapper = document.createElement('div');
wrapper.className = 'row-selection-controls-wrapper';
wrapper.innerHTML = '<div class="d-flex justify-content-between align-items-center mb-2">'
+ ' <div>'
+ ' <button class="btn btn-outline-secondary btn-sm" id="toggle-row-selection-' + token + '" type="button">Enable row selection</button>'
+ ' <div id="selection-controls-' + token + '" class="btn-group ms-2" role="group" style="display:none;">'
+ ' <button class="btn btn-sm btn-outline-primary" id="select-all-' + token + '">Select all</button>'
+ ' <button class="btn btn-sm btn-outline-secondary" id="clear-selection-' + token + '">Clear</button>'
+ ' </div>'
+ ' </div>'
+ ' <div>'
+ ' <small class="text-muted">Selected: <span id="selected-count-' + token + '">0</span></small>'
+ ' </div>'
+ '</div>';
var wrapper = document.createElement('div');
wrapper.className = 'row-selection-controls-wrapper';
wrapper.innerHTML = '<div class="d-flex justify-content-between align-items-center mb-2">'
+ ' <div>'
+ ' <button class="btn btn-outline-secondary btn-sm" id="toggle-row-selection-' + token + '" type="button">Enable row selection</button>'
+ ' <div id="selection-controls-' + token + '" class="btn-group ms-2" role="group" style="display:none;">'
+ ' <button class="btn btn-sm btn-outline-primary" id="select-all-' + token + '">Select all</button>'
+ ' <button class="btn btn-sm btn-outline-secondary" id="clear-selection-' + token + '">Clear</button>'
+ ' </div>'
+ ' </div>'
+ ' <div>'
+ ' <small class="text-muted">Selected: <span id="selected-count-' + token + '">0</span></small>'
+ ' </div>'
+ '</div>';
// Insert the controls immediately before the table (or its responsive wrapper)
var container = table.closest('.table-responsive') || table;
container.parentNode.insertBefore(wrapper, container);
}
var container = table.closest('.table-responsive') || table;
container.parentNode.insertBefore(wrapper, container);
}
// Create controls for any table that looks selectable but lacks a control block
Array.from(document.querySelectorAll('table')).forEach(function(t){
if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) {
ensureControlsForTable(t);
}
});
Array.from(document.querySelectorAll('table')).forEach(function(t){
if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) {
ensureControlsForTable(t);
}
});
// For every toggle button (supports both unsuffixed and suffixed IDs)
document.querySelectorAll('button[id^="toggle-row-selection"]').forEach(function(toggleBtn) {
document.querySelectorAll('button[id^="toggle-row-selection"]').forEach(function(toggleBtn) {
// derive token (empty for unsuffixed)
var token = '';
if (toggleBtn.id !== 'toggle-row-selection') token = toggleBtn.id.replace('toggle-row-selection-', '');
var token = '';
if (toggleBtn.id !== 'toggle-row-selection') token = toggleBtn.id.replace('toggle-row-selection-', '');
var controlsEl = document.getElementById(suffixed('selection-controls', token));
var selectAllBtn = document.getElementById(suffixed('select-all', token));
var clearBtn = document.getElementById(suffixed('clear-selection', token));
var selectedCountSpan = document.getElementById(suffixed('selected-count', token));
var controlsEl = document.getElementById(suffixed('selection-controls', token));
var selectAllBtn = document.getElementById(suffixed('select-all', token));
var clearBtn = document.getElementById(suffixed('clear-selection', token));
var selectedCountSpan = document.getElementById(suffixed('selected-count', token));
var table = findTableForControl(toggleBtn);
if (!table) return; // nothing to do
var table = findTableForControl(toggleBtn);
if (!table) return; // nothing to do
var selectionEnabled = false;
var selectionEnabled = false;
function findRowCheckboxes() {
if (!table) return [];
function findRowCheckboxes() {
if (!table) return [];
// prefer named selection inputs but fall back to any checkbox
var checks = Array.from(table.querySelectorAll('input[type="checkbox"][name="selection"]'));
if (!checks.length) checks = Array.from(table.querySelectorAll('input[type="checkbox"]'));
return checks;
}
var checks = Array.from(table.querySelectorAll('input[type="checkbox"][name="selection"]'));
if (!checks.length) checks = Array.from(table.querySelectorAll('input[type="checkbox"]'));
return checks;
}
function updateSelectedCount() {
if (!selectedCountSpan) return;
var checks = findRowCheckboxes();
var count = checks.filter(function(c){ return c.checked; }).length;
selectedCountSpan.textContent = count;
checks.forEach(function(cb){ var tr = cb.closest('tr'); if (tr) tr.classList.toggle('table-active', cb.checked); });
}
function updateSelectedCount() {
if (!selectedCountSpan) return;
var checks = findRowCheckboxes();
var count = checks.filter(function(c){ return c.checked; }).length;
selectedCountSpan.textContent = count;
checks.forEach(function(cb){ var tr = cb.closest('tr'); if (tr) tr.classList.toggle('table-active', cb.checked); });
}
function setCheckboxesDisabled(disabled) {
findRowCheckboxes().forEach(function(cb){ cb.disabled = disabled; });
}
function setCheckboxesDisabled(disabled) {
findRowCheckboxes().forEach(function(cb){ cb.disabled = disabled; });
}
function hideSelectionColumn() {
if (!table) return;
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = 'none'; });
}
function hideSelectionColumn() {
if (!table) return;
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = 'none'; });
}
function showSelectionColumn() {
if (!table) return;
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = ''; });
}
function showSelectionColumn() {
if (!table) return;
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = ''; });
}
// initialise state
setCheckboxesDisabled(true);
hideSelectionColumn();
// row click toggling (attach to table to avoid global handlers)
table.addEventListener('click', function(e){
if (!selectionEnabled) return;
var tag = (e.target.tagName || '').toLowerCase();
if (['input','a','button','select','textarea','label'].indexOf(tag) !== -1) return;
var tr = e.target.closest('tr');
if (!tr || !table.contains(tr)) return;
var cb = tr.querySelector('input[type="checkbox"][name="selection"]') || tr.querySelector('input[type="checkbox"]');
if (cb && !cb.disabled) { cb.checked = !cb.checked; tr.classList.toggle('table-active', cb.checked); updateSelectedCount(); }
}, true);
// listen for changes within the table to keep counts in sync
table.addEventListener('change', function(e){ if (e.target && e.target.matches && e.target.matches('input[type="checkbox"]')) updateSelectedCount(); }, true);
// wire controls
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
selectionEnabled = !selectionEnabled;
if (selectionEnabled) {
toggleBtn.textContent = 'Disable row selection';
if (controlsEl) controlsEl.style.display = '';
setCheckboxesDisabled(false);
showSelectionColumn();
} else {
toggleBtn.textContent = 'Enable row selection';
if (controlsEl) controlsEl.style.display = 'none';
setCheckboxesDisabled(true);
hideSelectionColumn();
findRowCheckboxes().forEach(function(cb){ cb.checked = false; });
updateSelectedCount();
}
});
}
if (selectAllBtn) {
selectAllBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = true; }); updateSelectedCount(); });
}
// row click toggling (attach to table to avoid global handlers)
table.addEventListener('click', function(e){
if (!selectionEnabled) return;
var tag = (e.target.tagName || '').toLowerCase();
if (['input','a','button','select','textarea','label'].indexOf(tag) !== -1) return;
var tr = e.target.closest('tr');
if (!tr || !table.contains(tr)) return;
var cb = tr.querySelector('input[type="checkbox"][name="selection"]') || tr.querySelector('input[type="checkbox"]');
if (cb && !cb.disabled) { cb.checked = !cb.checked; tr.classList.toggle('table-active', cb.checked); updateSelectedCount(); }
}, true);
if (clearBtn) {
clearBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = false; }); updateSelectedCount(); });
}
// listen for changes within the table to keep counts in sync
table.addEventListener('change', function(e){ if (e.target && e.target.matches && e.target.matches('input[type="checkbox"]')) updateSelectedCount(); }, true);
// wire controls
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
selectionEnabled = !selectionEnabled;
if (selectionEnabled) {
toggleBtn.textContent = 'Disable row selection';
if (controlsEl) controlsEl.style.display = '';
setCheckboxesDisabled(false);
showSelectionColumn();
} else {
toggleBtn.textContent = 'Enable row selection';
if (controlsEl) controlsEl.style.display = 'none';
setCheckboxesDisabled(true);
hideSelectionColumn();
findRowCheckboxes().forEach(function(cb){ cb.checked = false; });
updateSelectedCount();
}
});
}
if (selectAllBtn) {
selectAllBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = true; }); updateSelectedCount(); });
}
if (clearBtn) {
clearBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = false; }); updateSelectedCount(); });
}
// initial count
updateSelectedCount();
});
});
</script>
updateSelectedCount();
});
});
</script>
</body>
+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 %}
+46 -19
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">
{{exam.valid_cid_users.count}}
</span> / <span title="Number of active User users">{{exam.valid_user_users.count}}]</span></span>
<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 }}
{% 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 {
@@ -114,6 +140,7 @@
{% if collection %}
<h2>Collection: {{collection.name}}</h2>
<p class="muted"><a href="{% url 'generic:examcollection_detail' collection.pk %}">← Back to collection</a></p>
{% endif %}
<details class="help-text">
+37 -90
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);
// 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');
}
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}]
Active state changed to: ${data.active}.`)
}
// 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);
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');
}
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}]
Published state changed to: ${data.publish_results}.`)
}
// 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 (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,
@@ -144,7 +91,7 @@
})
$("#button-edit-order").click(function (evt) {
// hide the edit button while in edit mode so we can restore it after saving
$(this).addClass('d-none').prop('disabled', true);
$(this).addClass('d-none').prop('disabled', true);
sortable('.sortable');
$("#full-question-list").addClass('sorting');
@@ -152,11 +99,11 @@
$("#full-question-list li").attr("title", "Drag to reorder");
$("#full-question-list").after($("<button id='random-order-button'>Randomise order</button>").click(() =>{
var ul = document.getElementById("full-question-list");
for (var i = ul.children.length; i >= 0; i--) {
ul.appendChild(ul.children[Math.random() * i | 0]);
}
renumber();
var ul = document.getElementById("full-question-list");
for (var i = ul.children.length; i >= 0; i--) {
ul.appendChild(ul.children[Math.random() * i | 0]);
}
renumber();
}))
@@ -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 %}