Compare commits

...

168 Commits

Author SHA1 Message Date
Ross cdcdeb761b up dv3d 2026-07-13 16:18:43 +01:00
Ross 8e1d6fdd60 up dv3d 2026-07-13 16:13:20 +01:00
Ross 479e37f346 up dv3d 2026-07-13 16:05:22 +01:00
Ross 0afed5a983 refactor: rename context variable from 'collection' to 'case' in CaseAuthorUpdate 2026-07-13 12:54:47 +01:00
Ross f51faff534 up dv3d 2026-07-13 12:41:28 +01:00
Ross f27936b2fb up dv3d 2026-07-13 12:19:20 +01:00
Ross 685f66854e remove the multiuser sync support 2026-07-13 11:52:33 +01:00
Ross 8ae33e32ee refactor: handle multi-frame DICOM files by extracting the middle frame in get_PIL_image functions 2026-07-13 11:23:04 +01:00
Ross d322806b80 refactor: comment out get_case_named_stacks method for future reference 2026-07-13 10:58:19 +01:00
Ross 2c56fcd4e7 refactor: enhance candidate scores table layout and improve status display 2026-07-13 10:56:36 +01:00
Ross 66cca1a512 refactor: improve score distribution display and enhance HTMX handling in exam views 2026-07-13 10:15:42 +01:00
Ross 3a3ec75b1d . 2026-07-13 10:03:33 +01:00
Ross 89bf6eedc0 refactor: reorganize navigation menu and enhance dropdown for additional features 2026-07-13 09:43:05 +01:00
Ross fa98541e31 . 2026-07-06 22:06:58 +01:00
Ross 50b80fd0c7 try to improve the scores pages 2026-07-06 12:33:30 +01:00
Ross 7bd1475daf refactor: streamline question navigation and loading logic in exam templates 2026-07-06 12:05:28 +01:00
Ross b13261c302 feat: implement HTMX support for exam question navigation and flagging functionality 2026-07-06 11:34:58 +01:00
Ross 94b08c5c21 refactor: simplify submitted answers extraction in ExamTester class 2026-07-06 10:47:27 +01:00
Ross 8f7ec905a6 fix: update exam test logic to handle CSRF token extraction and unanswered question counts 2026-07-06 10:45:41 +01:00
Ross 5139346d28 refactor: rework scores pages for improved usability and performance 2026-07-06 10:40:16 +01:00
Ross c6fe4df763 feat: add exam mode take URL method to Exam and CaseCollection models 2026-07-06 10:25:13 +01:00
Ross 5414a5b4f9 fix: add return button to exam overview in user score header 2026-07-06 10:04:26 +01:00
Ross 5a73409aad fix: update visibility message for exam results based on supervisor access 2026-07-06 09:59:10 +01:00
Ross 1b2715642a Add time answered 2026-07-06 09:56:51 +01:00
Ross 5ae51a7e71 . 2026-07-06 09:49:50 +01:00
Ross edb4e071f4 fix callstate label 2026-07-06 09:49:13 +01:00
Ross e56c1f2d0f feat: restrict user answer compaction to admin users and update related tests 2026-07-06 09:43:01 +01:00
Ross daebdc1844 feat: add exam mode URL handling and update user scores template 2026-07-06 09:29:15 +01:00
Ross 5a2baaeab8 Add user answer viewing and compaction 2026-07-06 09:07:48 +01:00
Ross 5ce4a67b31 feat: implement import exam answers feature with preview and confirmation steps 2026-07-05 22:47:50 +01:00
Ross 89963d6257 feat: add import exam answers functionality with UI and backend handling 2026-07-05 22:37:55 +01:00
Ross 2744758aa8 feat: enhance exam answer submission security and add tests for user authentication 2026-07-05 21:59:02 +01:00
Ross bb6b4c625c max improvement 2026-07-04 23:43:18 +01:00
Ross 55fcf71431 Add refresh buttons and optimize case series update
- Implemented refresh buttons for each exam/packet section and a global refresh button.
- Enhanced resource management in the atlas by allowing inline creation and attachment of resources.
- Redirected users back to the case within a case collection upon saving or cancelling edits.
- Optimized the case series update view to prevent hanging issues on production.
- Fixed an issue with the local-up script not shutting down running containers properly.

Add marker feedback field to useranswer model

- Created migrations to add a 'marker_feedback' field to the 'useranswer' model in both 'longs' and 'shorts' apps.
- The new field is a TextField that allows optional feedback for the marker, which will not be shown to the candidate.
2026-07-04 23:11:19 +01:00
Ross 277322059c feat: add marker feedback field to UserAnswer model and update forms accordingly 2026-07-04 22:49:11 +01:00
Ross d8bacfefa9 feat: add new endpoints for fetching active exams by type and cid; enhance exam handling in views 2026-07-04 22:40:48 +01:00
Ross 856fb0bf08 refactor: remove unnecessary authentication check in SupervisorDetail view 2026-06-30 10:56:34 +01:00
Ross ce323c4973 refactor: update README for observability and add test running instructions; update PostgreSQL port in Docker Compose; bump daphne version in requirements 2026-06-29 17:42:33 +01:00
Ross a60982c30a feat: add sorting to exam user statuses and improve history log filtering 2026-06-29 12:23:20 +01:00
Ross 1296089607 refactor: remove Loki, Promtail, and Grafana services from Docker Compose files 2026-06-29 11:57:58 +01:00
Ross 077c858024 feat: enhance exam user status handling and add autosave verification in tests 2026-06-29 11:49:43 +01:00
Ross 12014fbb23 fix: update Host header to use http_host variable in Nginx config 2026-06-29 11:08:52 +01:00
Ross 17043722e1 fix docker loading 2026-06-29 10:55:30 +01:00
Ross 1317d6a438 start better rts inegration 2026-06-29 10:44:39 +01:00
Ross 2425e62caf feat: add Feedback/Surveys link to dropdown menu 2026-06-29 09:36:50 +01:00
Ross c34dcdfd5e improve surverys 2026-06-29 09:35:12 +01:00
Ross 79ba4db4bf add simple survey support 2026-06-29 09:04:28 +01:00
Ross d07d88bdbf fix a few multisync issues 2026-06-28 21:51:39 +01:00
Ross d32e2a5231 refactor: update Redis configuration for caching and Celery broker 2026-06-22 11:44:45 +01:00
Ross 5ad66cb951 start fixing local dev env 2026-06-22 11:12:44 +01:00
Ross 4e29941ae4 try to fix multiuser sync 2026-06-22 09:36:56 +01:00
Ross 2e11e40b7c and again 2026-06-17 22:43:38 +01:00
Ross ed77af215f keep tring 2026-06-17 22:36:45 +01:00
Ross 28204d7ec1 interate on multiuser view 2026-06-17 22:29:37 +01:00
Ross 0110a6c4d4 Update Celery settings to use dynamic Redis URL extraction 2026-06-17 22:23:04 +01:00
Ross 24d9594446 Refactor Celery settings to use dynamic Redis host configuration 2026-06-17 22:17:23 +01:00
Ross 56cd257474 Fix clearable text area setup and update routing for multiuser websocket 2026-06-17 22:04:20 +01:00
Ross 86a1a935e1 fix a few bugs with the liveview 2026-06-16 22:17:17 +01:00
Ross dbb762bb09 add more multiuser support 2026-06-16 13:14:24 +01:00
Ross 6ebe74d785 start multiuser support 2026-06-16 12:46:33 +01:00
Ross 21eabcf3ec Enhance Select2 integration in caseSeriesModal to ensure proper dropdown behavior 2026-06-15 17:00:35 +01:00
Ross 1ad87d8b31 Add data-bs-focus attribute to caseSeriesModal and prevent Bootstrap from stealing focus from Select2 inputs 2026-06-15 16:57:28 +01:00
Ross 8003dcd714 Add patient grouping and selection functionality to user uploads 2026-06-15 16:48:15 +01:00
Ross b5d844e3aa Update navbar brand name from "Candidates" to "People" 2026-06-15 15:43:16 +01:00
Ross 1b3b21a6f5 Refactor exam image creation and enhance supervisor management tests 2026-06-15 13:13:14 +01:00
Ross a573ed78a4 Enhance Supervisor Dashboard and Trainee Feedback Features
- Redesigned the supervisor overview page to include a header card with supervisor details and trainee statistics.
- Implemented a tabbed interface for viewing trainees and their recent attempts.
- Improved the trainee detail page with a profile card and a table for displaying attempt history.
- Added a new view for supervisors to provide feedback on case collections, including outstanding feedback items and previous conversations.
- Updated URL routing to support new feedback functionality.
- Enhanced backend logic to ensure proper sharing permissions for exam attempts and case collections.
- Added comprehensive tests for supervisor access to trainee data and feedback functionalities.
2026-06-15 12:28:00 +01:00
Ross c52cf46d65 Add functionality to create linked supervisor account when creating a user 2026-06-15 11:32:21 +01:00
Ross 1f0877964b Enhance user view and list UI with improved layout and functionality 2026-06-15 11:28:57 +01:00
Ross c50ace771e Implement hiding and showing functionality for user exams and collections 2026-06-15 11:23:00 +01:00
Ross 35ab7443d2 Implement user search functionality and enhance supervisor management UI 2026-06-15 10:28:32 +01:00
Ross fc9f2ed6af Enhance trainee management UI and functionality
- Improved bulk edit options for user groups with success alerts.
- Updated trainee selection count display.
- Refined HTMX responses for user actions, providing better feedback.
- Enhanced checkbox toggle functionality for trainee selection.
2026-06-15 09:53:21 +01:00
Ross afd4cc3b87 . 2026-06-15 09:26:17 +01:00
Ross aaee3577ad Imrpvoe the case links 2026-06-12 22:55:50 +01:00
Ross 96d5fa4a2e add future relations to cases in collection 2026-06-12 21:15:43 +01:00
Ross 6bb9441748 smoke test the rest of the apps 2026-06-10 22:05:12 +01:00
Ross e670adf2d0 test: add smoke tests for URL endpoints with different user roles 2026-06-10 21:31:06 +01:00
Ross 145aa442f0 refactor: update prerequisites queryset assignment in CaseCollectionForm and improve test assertions 2026-06-10 21:20:30 +01:00
Ross ddfb591921 bump dv3d 2026-06-10 21:06:34 +01:00
Ross e78a69367f try to fix recons 2026-06-08 11:41:46 +01:00
Ross d89df3d0a6 . 2026-06-08 11:20:24 +01:00
Ross 56bc17d1c7 . 2026-06-08 11:18:39 +01:00
Ross 8c0d97d36d remove post save for seriesimage 2026-06-08 11:08:10 +01:00
Ross 161da980cf fix a few things 2026-06-08 10:57:01 +01:00
Ross 9919af2b20 fix some templates 2026-06-08 10:36:28 +01:00
Ross 390ccc0234 . 2026-06-08 09:59:12 +01:00
Ross a9c417d993 fix comment 2026-06-08 09:58:00 +01:00
Ross 0f11f0800e fix logs (and import) 2026-06-03 21:00:05 +01:00
Ross 3d445fe12e try to fix logging 2026-06-03 20:48:00 +01:00
Ross 2e854c43b6 fix many issues 2026-06-03 20:38:29 +01:00
Ross 46518a45c1 add log when improt fails 2026-06-03 20:26:12 +01:00
Ross 213ee17328 improve cases series handling in collection 2026-06-01 12:57:35 +01:00
Ross 92d4ece061 fix via view page 2026-06-01 12:05:39 +01:00
Ross 74f1ad4b2c update viva cases view 2026-06-01 11:54:50 +01:00
Ross 0412aa82e4 tidy formating 2026-06-01 11:29:25 +01:00
Ross f2a52bed23 start new viva view and add window/level sensitivy to dv3d 2026-06-01 11:11:02 +01:00
Ross 1d85666127 improve the linked cases editor 2026-06-01 10:43:19 +01:00
Ross 40cea43578 start working on case_study bulk editiing and add relative dates to the model 2026-06-01 10:23:57 +01:00
Ross 6aada9f991 Enhance styling for multi-select in case series modal for improved user experience 2026-06-01 09:27:44 +01:00
Ross 01d44bc8e4 remove case case 2026-05-29 09:07:43 +01:00
Ross 98df8ec176 remove case size from the table 2026-05-28 23:23:52 +01:00
Ross 4cc758bc66 Refactor case size rendering logic for efficiency and update user uploads field choices 2026-05-28 23:22:07 +01:00
Ross 060333c006 Refactor case size rendering logic and update user uploads template for improved UI 2026-05-28 23:15:01 +01:00
Ross d74cfe73e0 Enhance case size rendering and update form field attributes for better UI integration 2026-05-28 23:10:27 +01:00
Ross c1005712e0 . 2026-05-28 22:57:48 +01:00
Ross 9592568dbb and some fixes 2026-05-28 22:57:42 +01:00
Ross ee4fe2b85b many changes 2026-05-28 22:53:04 +01:00
Ross 6368f525e1 update todo 2026-05-28 22:14:32 +01:00
ross 797c002e8b Update todo 2026-05-27 10:24:19 +00:00
Ross 428db3e55c add todo 2026-05-26 13:39:56 +01:00
Ross fd3e7280fb remove content time as split group 2026-05-21 22:28:10 +01:00
Ross 75be040783 up dv3d 2026-05-21 22:26:48 +01:00
Ross 734460777b Add caching and group detection for series images in series viewer 2026-05-21 22:24:44 +01:00
Ross f85aefe5c7 up dv3d 2026-05-20 22:39:44 +01:00
Ross a7a21fab9c up dv3d 2026-05-20 22:18:47 +01:00
Ross bf9190d177 up dv3d 2026-05-20 21:55:44 +01:00
Ross c9133c2175 improve grouping server side 2026-05-20 21:46:57 +01:00
Ross 8f74b66b97 send group info from server 2026-05-20 21:38:48 +01:00
Ross 679ce2fd00 Refactor code structure for improved readability and maintainability 2026-05-20 21:30:21 +01:00
Ross f2330c8f9f up dv3d 2026-05-20 21:20:23 +01:00
Ross da1dd79ef2 up dv3d 2026-05-20 21:13:08 +01:00
Ross f0ff3e4bfa up dv3d 2026-05-20 21:08:43 +01:00
Ross 46f8fc6256 up d3vd 2026-05-19 14:45:34 +01:00
Ross dad108d256 up d3vd 2026-05-19 14:30:01 +01:00
Ross 69da0d6170 up dv3d 2026-05-19 14:20:47 +01:00
Ross 5e3eb9f985 dv3d 2026-05-19 14:09:20 +01:00
Ross 11d4bf0e71 up dv3d 2026-05-19 13:54:50 +01:00
Ross 3fe3ae5d16 up dv3d 2026-05-19 13:19:57 +01:00
Ross 437cb66e1f up dv3d 2026-05-19 11:52:10 +01:00
Ross bd87c59870 up dv3d 2026-05-19 11:26:11 +01:00
Ross d3c7512aa9 up dv3d 2026-05-18 22:44:25 +01:00
Ross dcddeb8e15 some changes 2026-05-18 22:26:57 +01:00
Ross e361e01af8 Revert "up dv3d"
This reverts commit 0908608280.
2026-05-18 21:58:27 +01:00
Ross dbb68a6801 revert dv3d 2026-05-18 21:57:13 +01:00
Ross 64138c9038 up idv3d 2026-05-18 21:53:46 +01:00
Ross a3ba14f4b5 Revert "up dv3d"
This reverts commit 0908608280.
2026-05-18 21:35:32 +01:00
Ross dcf2ad4af7 up dv3d 2026-05-18 21:18:55 +01:00
Ross 0908608280 up dv3d 2026-05-18 21:00:52 +01:00
Ross 064f039a24 up dv3d 2026-05-18 17:26:49 +01:00
Ross c0845287da update dv3d 2026-05-18 17:15:02 +01:00
Ross 32c6dc6f65 update dv3d 2026-05-18 16:59:12 +01:00
Ross b9f511f636 update dv3d 2026-05-18 16:41:01 +01:00
Ross dcc347793d update dv3d 2026-05-18 16:37:12 +01:00
Ross 63ea4cdf23 Add downsample series functionality and related UI updates
- Implemented asynchronous downsample task for series with progress tracking.
- Added new URL endpoint for downsample status.
- Updated Series model to include source_series_instance_uid.
- Enhanced case display template with new buttons for series actions.
- Added password reset functionality in user profile with appropriate alerts.
- Created migration for new source_series_instance_uid field in Series model.
2026-05-18 16:20:38 +01:00
Ross c55797636a Add functionality to reset stale RUNNING tasks to READY in task overview 2026-05-18 14:15:21 +01:00
Ross e016175d2b Refactor code structure for improved readability and maintainability 2026-05-18 14:03:15 +01:00
Ross 682bde82b9 update d3vd 2026-05-18 13:46:33 +01:00
Ross 53109e7cb2 Remove forced remount sequence for thumbnail stabilization in viewer 2026-05-18 13:13:26 +01:00
Ross 3065a279b1 Refactor code structure for improved readability and maintainability 2026-05-18 13:12:02 +01:00
Ross 2f8b61a4be Add forced remount sequence for thumbnail stabilization in viewer 2026-05-18 13:08:32 +01:00
Ross 2b10f2e73c Implement feature X to enhance user experience and fix bug Y in module Z 2026-05-18 13:03:24 +01:00
Ross 20e24808fc debug thumbs 2026-05-18 12:58:02 +01:00
Ross 714857e3d2 Implement feature X to enhance user experience and fix bug Y in module Z 2026-05-18 12:54:28 +01:00
Ross 7019ac2ef8 debug thums 2026-05-18 12:53:35 +01:00
Ross 16fea039d7 update dv3d 2026-05-18 12:48:13 +01:00
Ross fc38776ce2 Implement feature X to enhance user experience and fix bug Y in module Z 2026-05-18 12:39:48 +01:00
Ross d132625419 Refactor code structure for improved readability and maintainability 2026-05-18 12:32:36 +01:00
Ross 5614961980 Refactor code structure for improved readability and maintainability 2026-05-18 12:17:44 +01:00
Ross f3af6d47ee Refactor code structure for improved readability and maintainability 2026-05-18 11:55:40 +01:00
Ross b09fbee2c9 Implement feature X to enhance user experience and fix bug Y in module Z 2026-05-18 11:36:01 +01:00
Ross 4a104be71f Refactor code structure for improved readability and maintainability 2026-05-18 11:33:59 +01:00
Ross dbf0fc262c style(templates): Refactor HTML structure for improved readability and consistency 2026-05-18 11:16:26 +01:00
Ross 026350f9d4 feat(templates): Add content container class for improved layout consistency 2026-05-18 11:16:20 +01:00
Ross b34970ed7f style(case_display_block): Refactor series panel layout and improve styling 2026-05-18 11:00:43 +01:00
Ross 867d92bff9 feat(series): Enhance series block with interactive features and improved styling 2026-05-18 10:50:53 +01:00
Ross 4d1423134a Add django-tasks and django-tasks-db to requirements
- Updated requirements.txt to include django-tasks and django-tasks-db for task management.
2026-05-18 10:38:14 +01:00
Ross 410b71f07f fix(models): Add type hint for series variable in get_series_blocks method 2026-05-18 10:05:54 +01:00
Ross e51e8358bb fix(workspace): Correctly add uploader path to workspace configuration 2026-05-18 09:50:28 +01:00
Ross 5f69cdcaf9 fix(workspace): Correctly add dv3d path to workspace configuration 2026-05-18 09:49:44 +01:00
521 changed files with 102237 additions and 4438 deletions
+31
View File
@@ -0,0 +1,31 @@
# Ignore git and local environments
.git
.venv
venv
ENV
.vscode
.idea
# Ignore Python caches and test artifacts
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
# Ignore local media and database files
media/
media_testanatomy/
backups/
db.sqlite3
*.log
logs/
# Ignore frontend dependencies and test suites in build context
**/node_modules/
rts/node_modules/
rts/tests/
package-lock.json
+7
View File
@@ -10,11 +10,18 @@ venv
.env
*.log
log.txt
temp/
media_test/*
# Loki runtime data
docker/loki-data/
docker/loki-wal/
# Local runtime logs collected by Promtail
/logs/
# Compiled assets for DV3D
**/dv3d/assets/
/static/dv3d/index.js
+49 -6
View File
@@ -17,6 +17,13 @@ Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL +
- Partials must be standalone fragments — no `{% extends %}`.
- HTMX detection via `django_htmx` middleware (`request.htmx`).
#### HTMX input
- When using htmx input for autoupdating search/other triggers triggers do not use changed in the hx-trigger. Using input is sufficient.
e.g. instead of
hx-trigger="input changed delay:300ms, focus"
use
hx-trigger="input delay:300ms, focus"
### API
- Django Ninja (not DRF). Routers in `{app}/api.py`, registered in `rad/api.py` at `/api/v1/`.
- Use `ModelSchema` with explicit `Meta.fields` lists (avoid `"__all__"` on public endpoints).
@@ -48,7 +55,13 @@ Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL +
- **Template tags**: always load `{% load static %}`, `{% load django_htmx %}`, `{% load crispy_forms_tags %}` as needed at the top of the file.
- **Bootstrap 5** (dark theme). Use existing utility classes; do not add inline styles.
- To include a partial: `{% include 'app/partials/_fragment.html' %}`. Pass context explicitly when using `with` keyword.
- **Multi line comment** NEVER use {# ... #} style comments for multiline comments as it will render into the html.
- **Multi line comment** NEVER use {# ... #} style comments for multiline comments as it will render into the html, it is designed for single line comments only. Use
{% comment "Optional note: You can add a label here if you want" %}
This is a multiline comment.
None of this text or any {% tags %} inside here
will be rendered or visible in the browser's "View Source".
{% endcomment %}
Instead
## Forms
@@ -65,6 +78,10 @@ Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL +
- Do not use `TestCase` unless necessary — prefer plain `pytest` functions with `@pytest.mark.django_db`.
- Run tests: `pytest` or `pytest {app}/tests/`.
## Exceptions
- Avoid unnecessary try: except blocks.
- We should not generate empty excepty blocks, if there is an error or bug this should be surfaced (even if just to the logs).
## Celery / Tasks
- Tasks autodiscovered from `{app}/tasks.py`.
@@ -85,13 +102,39 @@ Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL +
## Docker (Dev)
### Start Dev Environment
Start the development server with local configurations:
```bash
# Start dev environment
COMPOSE_ENV=dev docker compose -f docker/docker-compose.dev.yml up -d --build
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml up -d --build
```
# Run management commands inside the container
docker compose exec web python manage.py migrate
docker compose exec web python manage.py createsuperuser
### Run Django Management Commands inside the Container
Since PostgreSQL and Redis run in docker, run all database/management commands within the web container:
```bash
# Run database migrations
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py migrate
# Generate new migrations
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py makemigrations
# Check if migrations are missing (useful for CI/pre-commit)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py makemigrations --check
# Run system checks
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py check
# Create a Django superuser
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py createsuperuser
```
### Runserver and service commands
To restart the web server/runserver or tail the container output:
```bash
# Restart the runserver/web service container
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml restart web
# View container logs (interactive)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml logs -f web
```
Dev server runs on `http://localhost:8080` (nginx → gunicorn/runserver).
+5 -6
View File
@@ -103,12 +103,6 @@ Host setup for 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.
@@ -116,3 +110,8 @@ Notes & recommendations
- 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).
## Running tests
Ensure the development Docker environment is running (COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml up -d).
+3 -2
View File
@@ -61,8 +61,9 @@ class AnatomyUserAnswerFilter(django_filters.FilterSet):
)
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
if not request.user.groups.filter(name="anatomy_checker").exists():
queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id)
if not request.user.is_superuser:
if not request.user.groups.filter(name="anatomy_checker").exists():
queryset = queryset.filter(exam__open_access=True) | queryset.filter(exam__author__id=request.user.id)
#queryset = queryset.prefetch_related("question", "question__question_type", "exam")
super(AnatomyUserAnswerFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
pass
@@ -0,0 +1,25 @@
# Generated by Django 6.0.1 on 2026-06-28 20:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0027_alter_anatomyquestion_primary_answer_and_more'),
('survey', '__first__'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey',
field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'),
),
migrations.AddField(
model_name='exam',
name='pre_survey',
field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0028_exam_post_survey_exam_pre_survey'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='exam',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-29 09:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0029_exam_post_survey_mandatory_exam_pre_survey_mandatory'),
]
operations = [
migrations.AddField(
model_name='useranswer',
name='time_answered',
field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-07-13 08:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0030_useranswer_time_answered'),
]
operations = [
migrations.AddField(
model_name='exam',
name='pass_mark',
field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True),
),
]
+53 -34
View File
@@ -338,7 +338,21 @@ class Answer(models.Model):
def save(self, *args, **kwargs):
self.clean()
return super(Answer, self).save(*args, **kwargs)
res = super(Answer, self).save(*args, **kwargs)
# Update matching UserAnswer objects in real-time
UserAnswer.objects.filter(
question=self.question,
answer_compare__iexact=self.answer_compare
).update(score=self.status)
return res
def delete(self, *args, **kwargs):
# Update matching UserAnswer objects back to UNMARKED in real-time
UserAnswer.objects.filter(
question=self.question,
answer_compare__iexact=self.answer_compare
).update(score=Answer.MarkOptions.UNMARKED)
return super(Answer, self).delete(*args, **kwargs)
def clean(self):
if self.answer:
@@ -551,11 +565,30 @@ class UserAnswer(UserAnswerBase):
def save(self, *args, **kwargs):
self.clean()
# Auto-mark on save from existing template answers
ans = self.answer_compare
if ans == "":
self.score = Answer.MarkOptions.INCORRECT
else:
marked_ans = Answer.objects.filter(
question_id=self.question_id,
answer_compare__iexact=ans
).first()
if marked_ans is not None:
self.score = marked_ans.status
else:
self.score = Answer.MarkOptions.UNMARKED
return super(UserAnswer, self).save(*args, **kwargs)
def clean(self):
if self.answer:
self.answer = self.answer.strip()
self.answer_compare = get_answer_compare(self.answer)
else:
self.answer = ""
self.answer_compare = ""
# def get_compare_string(self):
# # strip here should be unneccasry (providing clean is now working)
@@ -569,30 +602,34 @@ class UserAnswer(UserAnswerBase):
return self.answer
def get_answer_score(self, cached=True):
if cached and self.score:
if cached and self.score and self.score != Answer.MarkOptions.UNMARKED:
if self.score == Answer.MarkOptions.CORRECT:
mark = 2
return 2
elif self.score == Answer.MarkOptions.HALF_MARK:
mark = 1
return 1
elif self.score == Answer.MarkOptions.INCORRECT:
mark = 0
return mark
return 0
q = self.question
ans = self.answer_compare
if ans == "":
self.score == Answer.MarkOptions.INCORRECT
self.save()
if self.score != Answer.MarkOptions.INCORRECT:
self.score = Answer.MarkOptions.INCORRECT
self.save()
return 0
marked_ans = q.answers.filter(answer_compare__iexact=ans).first()
marked_ans = None
for a in q.answers.all():
if a.answer_compare.lower() == ans.lower():
marked_ans = a
break
mark = "unmarked"
if marked_ans is not None:
self.score = marked_ans.status
self.save()
if self.score != marked_ans.status:
self.score = marked_ans.status
self.save()
if marked_ans.status == Answer.MarkOptions.CORRECT:
mark = 2
@@ -600,27 +637,9 @@ class UserAnswer(UserAnswerBase):
mark = 1
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
mark = 0
return mark
if (
q.answers.filter(
answer_compare__iexact=ans, status=Answer.MarkOptions.CORRECT
).first()
is not None
):
mark = 2
elif (
q.answers.filter(
answer_compare__iexact=ans, status=Answer.MarkOptions.HALF_MARK
).first()
is not None
):
mark = 1
elif q.answers.filter(
answer_compare__iexact=ans, status=Answer.MarkOptions.INCORRECT
).first():
mark = 0
else:
mark = "unmarked"
if self.score != Answer.MarkOptions.UNMARKED:
self.score = Answer.MarkOptions.UNMARKED
self.save()
return mark
+2
View File
@@ -226,7 +226,9 @@ function clearableTextAreaSetup() {
textFields.forEach((field) => {
const wrapper = field.closest(".position-relative");
if (!wrapper) return;
const clearButton = wrapper.querySelector(".clear-input-button");
if (!clearButton) return;
// Function to toggle the visibility of the clear button
const toggleClearButton = () => {
+4 -2
View File
@@ -1,4 +1,4 @@
{% extends 'generic/exam_scores_base.html' %}
{% extends base_template|default:'generic/exam_scores_base.html' %}
{% block table_answers %}
{% for question in questions %}
@@ -10,10 +10,12 @@
{% endfor %} {% endcomment %}
{% for cid in cids %}
{% with by_question|get_item:question|get_item:cid as ans_score %}
<td class="anatomy-ans user-answer-score-{{ans_score.1}}"
<td class="anatomy-ans user-answer-score-{{ans_score.1}} col-cid-cell col-cid-{{ cid|slugify }}"
title="answer score: {{ans_score.1}}"
data-examquestionindex={{forloop.parentloop.counter0}}
data-cid={{cid}}
data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}"
data-cid-val="{{ cid|lower }}"
>{{ans_score.0}}<a class="show-on-hover" href="{% url 'anatomy:exam_question_user_answer' exam.pk forloop.parentloop.counter0 cid|slice:':1' cid|slice:'2:' %}"><i class="bi bi-pen"></i>
</a></td>
{% endwith %}
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="container anatomy my-3">
@@ -18,7 +19,7 @@
</select>
</div>
</div>
<ul class="list-group list-group-flush">
<ul class="list-group list-group-flush score-answer-list">
{% for ans, score, correct_answer in answers_and_marks %}
<li class="list-group-item d-flex justify-content-between align-items-start" data-score="{{ score }}">
<div class="d-flex align-items-start">
@@ -52,6 +53,15 @@
{% if request.user.is_staff %}
<button type="button" class="btn btn-sm btn-outline-secondary ms-2 admin-edit-button" data-qn="{{ forloop.counter0 }}">Edit</button>
{% endif %}
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
{% with q=questions|get_item:forloop.counter0 %}
{% with ua=user_answers_map|get_item:q.pk %}
{% if ua %}
<a href="{% url 'generic:view_answer_history' app_name='anatomy' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2">History</a>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
</div>
</li>
{% endfor %}
+2 -3
View File
@@ -240,12 +240,11 @@ def test_exams(db, client):
res = client.post(reverse("global_exam_answers_submit"), data=post_json)
json_res = json.loads(res.content)
assert json_res["success"] == False
assert json_res["error"] == "Invalid data"
assert json_res["error"] in ("Invalid data", "user / cid mismatch")
valid_answers = []
for questions in exam_json["questions"]:
for qid in questions:
for qid in exam_json["questions"]:
post_data = {
"eid": exam_json["eid"],
"cid": post_cid,
+122
View File
@@ -0,0 +1,122 @@
import pytest
from django.urls import reverse
from django.contrib.auth.models import Group, User
from django.test import Client
SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405}
@pytest.fixture
def candidate_client(db):
user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com")
client = Client()
client.force_login(user)
return client
@pytest.fixture
def superuser_client(db):
user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com")
# Add to editor and manager groups to satisfy all custom mixins
for group_name in ["anatomy_editor", "cid_user_manager"]:
g, _ = Group.objects.get_or_create(name=group_name)
user.groups.add(g)
client = Client()
client.force_login(user)
return client
@pytest.fixture
def anonymous_client(db):
return Client()
@pytest.fixture
def smoke_data(db, settings, tmp_path):
from anatomy.models import AnatomyQuestion, Exam, UserAnswer, QuestionType, Structure, Answer
import tempfile
settings.MEDIA_ROOT = str(tmp_path)
create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com")
qtype, _ = QuestionType.objects.get_or_create(text="Test QType")
structure, _ = Structure.objects.get_or_create(name="Test Structure")
image_file = tempfile.NamedTemporaryFile(dir=settings.MEDIA_ROOT, suffix=".jpg", delete=False)
question = AnatomyQuestion.objects.create(
question_type=qtype,
structure=structure,
image=image_file.name
)
exam = Exam.objects.create(
name="Test Anatomy Exam",
exam_mode=True,
active=True
)
exam.author.add(create_user)
answer = Answer.objects.create(
question=question,
answer="testanswer",
status=Answer.MarkOptions.CORRECT
)
user_answer = UserAnswer.objects.create(
user=create_user,
exam=exam,
question=question
)
return {
"question": question,
"exam": exam,
"user_answer": user_answer,
}
URL_SPECS = [
("anatomy:question_view", None),
("anatomy:question_create", None),
("anatomy:anatomy_question_update", lambda d: {"pk": d["question"].pk}),
("anatomy:question_answer_update", lambda d: {"pk": d["question"].pk}),
("anatomy:question_clone", lambda d: {"pk": d["question"].pk}),
("anatomy:question_delete", lambda d: {"pk": d["question"].pk}),
("anatomy:exam_markers", lambda d: {"pk": d["exam"].pk}),
("anatomy:exam_groups_edit", lambda d: {"pk": d["exam"].pk}),
("anatomy:exam_authors", lambda d: {"pk": d["exam"].pk}),
("anatomy:exam_create", None),
("anatomy:exam_clone", lambda d: {"exam_id": d["exam"].pk}),
("anatomy:exam_update", lambda d: {"pk": d["exam"].pk}),
("anatomy:exam_delete", lambda d: {"pk": d["exam"].pk}),
("anatomy:user_answer_table_view", None),
("anatomy:user_answer_view", lambda d: {"pk": d["user_answer"].pk}),
("anatomy:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}),
("anatomy:mark2_overview", lambda d: {"pk": d["exam"].pk}),
("anatomy:index", None),
("anatomy:help", None),
]
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = anonymous_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous"
assert response.status_code in {200, 302, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous"
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = candidate_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate"
assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate"
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = superuser_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser"
assert response.status_code in {200, 301, 302, 303}, f"Unexpected status code {response.status_code} on {url_name} for Superuser"
+2
View File
@@ -104,6 +104,8 @@ from django.db.models import Count
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
if self.request.user.is_superuser:
return obj
if self.request.user.groups.filter(name="anatomy_checker").exists():
return obj
if self.request.user not in obj.author.all():
+22
View File
@@ -32,6 +32,8 @@ from reversion.admin import VersionAdmin
from django.forms.widgets import RadioSelect
from django.utils.html import format_html
from django.urls import reverse
from django.utils import timezone
from datetime import timedelta
from tinymce.widgets import TinyMCE
@@ -226,6 +228,24 @@ if DBTaskResult is not None:
msg += f" Skipped {blocked} running task(s)."
self.message_user(request, msg, level=messages.SUCCESS)
@admin.action(description="Reset stale RUNNING tasks to READY (>30 min)")
def reset_stale_running_to_ready(self, request, queryset):
cutoff = timezone.now() - timedelta(minutes=30)
updated = queryset.filter(status="RUNNING", started_at__lt=cutoff).update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
self.message_user(
request,
f"Reset {updated} stale RUNNING task(s) to READY (older than 30 minutes).",
level=messages.SUCCESS,
)
@admin.action(description="Delete selected COMPLETED tasks")
def delete_selected_completed(self, request, queryset):
completed_qs = queryset.filter(status="SUCCESSFUL")
@@ -278,6 +298,7 @@ if DBTaskResult is not None:
admin_cls.actions = (
"reset_failed_to_ready",
"reset_selected_to_ready",
"reset_stale_running_to_ready",
"delete_selected_completed",
"delete_selected_failed",
)
@@ -339,6 +360,7 @@ if DBTaskResult is not None:
admin_cls.traceback_pretty = traceback_pretty
admin_cls.reset_failed_to_ready = reset_failed_to_ready
admin_cls.reset_selected_to_ready = reset_selected_to_ready
admin_cls.reset_stale_running_to_ready = reset_stale_running_to_ready
admin_cls.delete_selected_completed = delete_selected_completed
admin_cls.delete_selected_failed = delete_selected_failed
admin_cls.has_add_permission = has_add_permission
+45 -6
View File
@@ -3,8 +3,17 @@ from .models import Case, CaseCollection, Series
def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(function):
"""Decorator to check if user is author of the series,
an atlas editor, an atlas marker, or if the series is open access.
Used for series-level permissions.
Requires the decorated view to have a "pk" or "series_id" kwarg to identify the series.
"""
def wrap(request, *args, **kwargs):
series = Series.objects.get(pk=kwargs["pk"])
if "pk" in kwargs:
series = Series.objects.get(pk=kwargs["pk"])
elif "series_id" in kwargs:
series = Series.objects.get(pk=kwargs["series_id"])
if (
request.user in series.get_author_objects()
or series.open_access
@@ -23,7 +32,10 @@ def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(functi
def user_is_author_or_atlas_series_checker(function):
def wrap(request, *args, **kwargs):
series = Series.objects.get(pk=kwargs["pk"])
if "pk" in kwargs:
series = Series.objects.get(pk=kwargs["pk"])
elif "series_id" in kwargs:
series = Series.objects.get(pk=kwargs["series_id"])
if (
request.user in series.get_author_objects()
or request.user.groups.filter(name="atlas_editor").exists()
@@ -40,7 +52,10 @@ def user_is_author_or_atlas_series_checker(function):
def user_has_case_view_access(function):
def wrap(request, *args, **kwargs):
atlas = Case.objects.get(pk=kwargs["pk"])
if "pk" in kwargs:
atlas = Case.objects.get(pk=kwargs["pk"])
elif "case_id" in kwargs:
atlas = Case.objects.get(pk=kwargs["case_id"])
# If open access everyone can view
if atlas.open_access:
@@ -62,7 +77,10 @@ def user_has_case_view_access(function):
def user_is_author_or_atlas_editor(function):
def wrap(request, *args, **kwargs):
atlas = Case.objects.get(pk=kwargs["pk"])
if "pk" in kwargs:
atlas = Case.objects.get(pk=kwargs["pk"])
elif "case_id" in kwargs:
atlas = Case.objects.get(pk=kwargs["case_id"])
if (
request.user in atlas.author.all()
or request.user.groups.filter(name="atlas_editor").exists()
@@ -90,8 +108,29 @@ def user_is_collection_author_or_atlas_editor(function):
or request.user.is_superuser
):
return function(request, *args, **kwargs)
else:
raise PermissionDenied
# Allow access if request.user is the supervisor of the target user and sharing is enabled
user_pk = kwargs.get("user_pk") or kwargs.get("user_id")
if user_pk:
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from generic.models import CidUserExam
try:
target_user = get_user_model().objects.get(pk=user_pk)
sup = target_user.userprofile.supervisor
if sup and sup.user_id == request.user.id:
ct = ContentType.objects.get_for_model(CaseCollection)
cid_user_exam = CidUserExam.objects.filter(
content_type=ct,
object_id=atlas.pk,
user_user=target_user
).first()
if (cid_user_exam and cid_user_exam.share_with_supervisor) or atlas.results_supervisor_visible:
return function(request, *args, **kwargs)
except Exception:
pass
raise PermissionDenied
wrap.__doc__ = function.__doc__
wrap.__name__ = function.__name__
+117 -19
View File
@@ -180,6 +180,60 @@ class ConditionForm(ModelForm):
pass
class ConditionRelationshipForm(Form):
parents = ModelMultipleChoiceField(
required=False,
queryset=Condition.objects.none(),
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
)
children = ModelMultipleChoiceField(
required=False,
queryset=Condition.objects.none(),
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
condition_qs = Condition.objects.all().order_by("name")
if self.instance is not None and getattr(self.instance, "pk", None):
condition_qs = condition_qs.exclude(pk=self.instance.pk)
self.fields["parents"].queryset = condition_qs
self.fields["children"].queryset = condition_qs
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Fieldset(
"Condition relationships",
"parents",
"children",
)
)
def save(self, commit=True):
if self.instance is None:
raise ValueError("ConditionRelationshipForm requires an instance")
parents = list(self.cleaned_data.get("parents", []))
children = list(self.cleaned_data.get("children", []))
self.instance.parent.set(parents)
current_children = list(self.instance.child.all())
for child in current_children:
if child not in children:
self.instance.child.remove(child)
for child in children:
if child not in current_children:
self.instance.child.add(child)
return self.instance
class CaseCollectionForm(ModelForm):
class Meta:
model = CaseCollection
@@ -211,6 +265,19 @@ class CaseCollectionForm(ModelForm):
label=label,
)
if "prerequisites" in self.fields:
self.fields["prerequisites"].widget = autocomplete.ModelSelect2Multiple(
url="atlas:casecollection-prerequisite-autocomplete",
attrs={
"data-placeholder": "Search authored collections...",
"data-minimum-input-length": 0,
},
)
authored_collections = CaseCollection.objects.filter(author=self.user).distinct().order_by("name")
if getattr(self.instance, "pk", None):
authored_collections = authored_collections.exclude(pk=self.instance.pk)
self.fields["prerequisites"].queryset = authored_collections
# Identify show_ fields
show_fields = [f for f in self.fields if f.startswith("show_")]
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
@@ -294,6 +361,10 @@ class CaseCollectionForm(ModelForm):
"question_time_limit",
"answer_entry_grace_period",
"prerequisites",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
Fieldset(
"Custom Start Screen",
"start_screen_content",
@@ -630,8 +701,9 @@ class CaseForm(ModelForm):
# This is populated if we create the case from an exam/collection
self.collection = None
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
collections = kwargs["initial"].pop("exams")
initial = kwargs.setdefault("initial", {})
if initial is not None and "exams" in initial:
collections = initial.pop("exams")
logger.debug(collections)
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
@@ -678,7 +750,8 @@ class CaseForm(ModelForm):
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
#cimar_uuid",
"study_date",
),
)
@@ -711,7 +784,8 @@ class CaseForm(ModelForm):
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
#cimar_uuid",
"study_date",
]
# fields = ['question', 'findings', 'subspecialty', 'references']
widgets = {
@@ -768,23 +842,23 @@ class CaseForm(ModelForm):
self.save_m2m = save_m2m
# Do we need to save all changes now?
# if commit:
instance.save()
self.save_m2m()
if commit:
instance.save()
self.save_m2m()
logger.debug(f"{self.collection=}")
if self.collection is not None:
logger.debug(f"{self.collection=}")
case_no = self.collection.cases.count() + 1
logger.debug(f"{case_no=}")
logger.debug(f"{instance=}")
# Create through model
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
self.collection.cases.add(
instance, through_defaults={"sort_order": case_no}
)
if self.collection is not None:
logger.debug(f"{self.collection=}")
case_no = self.collection.cases.count() + 1
logger.debug(f"{case_no=}")
logger.debug(f"{instance=}")
# Create through model
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
self.collection.cases.add(
instance, through_defaults={"sort_order": case_no}
)
return instance
@@ -883,7 +957,13 @@ CaseCollectionCaseFormSet = inlineformset_factory(
CaseCollection,
Case.casecollection_set.through,
form=CaseCollectionCaseForm,
exclude=["question_schema", "question_answers"],
exclude=[
"question_schema",
"question_answers",
"series_visibility_config",
"learner_comment",
"learner_review_comment",
],
can_delete=True,
extra=0,
max_num=50,
@@ -1300,6 +1380,8 @@ class CaseDetailForm(ModelForm):
fields = [
"redact_history",
"override_history",
"learner_comment",
"learner_review_comment",
"question_time_limit_override",
"answer_entry_grace_period_override",
]
@@ -1373,6 +1455,11 @@ class CaseDetailForm(ModelForm):
HTML(history_block),
Field("override_history"),
Field("redact_history"),
Fieldset(
"Learner Comments",
Field("learner_comment"),
Field("learner_review_comment"),
),
Fieldset(
"Case Timing Overrides",
Field("question_time_limit_override"),
@@ -1408,10 +1495,21 @@ class CaseSeriesForm(forms.ModelForm):
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
super().__init__(*args, **kwargs)
self.fields["series"].queryset = queryset
self.fields["series"].label_from_instance = lambda obj: f"Series {obj.pk}: {obj.description or ''} ({obj.modality or ''})"
class Meta:
model = SeriesDetail # through model for Case.series
fields = ["series", "sort_order", "feedback"]
widgets = {
"series": autocomplete.ModelSelect2(
url="atlas:series-autocomplete",
attrs={
"data-placeholder": "Search by Series ID or description...",
"data-minimum-input-length": 0,
}
)
}
class CaseUpdateSeriesForm(forms.ModelForm):
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("atlas", "0103_casecollection_auto_apply_question_template_and_more"),
]
operations = [
migrations.AddField(
model_name="series",
name="source_series_instance_uid",
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-01 09:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0104_series_source_series_instance_uid'),
]
operations = [
migrations.AddField(
model_name='case',
name='study_date',
field=models.DateField(blank=True, help_text='Optional manually overridden study date for linked case series.', null=True),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.1 on 2026-06-01 11:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0105_case_study_date'),
]
operations = [
migrations.AddField(
model_name='casedetail',
name='learner_comment',
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner while taking the case and on review.'),
),
migrations.AddField(
model_name='casedetail',
name='learner_review_comment',
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner only on review.'),
),
migrations.AddField(
model_name='casedetail',
name='series_visibility_config',
field=models.JSONField(blank=True, default=dict, help_text='Per-series visibility map for this case in this collection.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-11 20:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0106_casedetail_learner_comment_and_more'),
]
operations = [
migrations.AddField(
model_name='caseprior',
name='relation_type',
field=models.CharField(choices=[('PR', 'Prior'), ('FU', 'Future')], default='PR', help_text='Defines whether the related case is a prior or a future exam', max_length=2),
),
]
+27
View File
@@ -0,0 +1,27 @@
# Generated by Django 6.0.1 on 2026-06-16 11:53
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0107_caseprior_relation_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MultiuserSession',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('active_case', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='atlas.case')),
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_multiuser_sessions', to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,25 @@
# Generated by Django 6.0.1 on 2026-06-28 20:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0108_multiusersession'),
('survey', '__first__'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='post_survey',
field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'),
),
migrations.AddField(
model_name='casecollection',
name='pre_survey',
field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0109_casecollection_post_survey_casecollection_pre_survey'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='casecollection',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
@@ -0,0 +1,16 @@
# Generated by Django 6.0.1 on 2026-07-13 10:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0110_casecollection_post_survey_mandatory_and_more'),
]
operations = [
migrations.DeleteModel(
name='MultiuserSession',
),
]
+601 -43
View File
@@ -3,6 +3,7 @@ import json
import os
import pathlib
from typing import Tuple
import uuid
from django.http import Http404, HttpRequest
from generic.mixins import AuthorMixin, QuestionMixin
@@ -10,8 +11,11 @@ from rad.settings import REMOTE_URL
from django.db.models.fields.files import ImageField
from django.db.models.fields.related import ForeignKey
from django.db import models
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.core.cache import cache
import pydicom
@@ -105,6 +109,227 @@ def findMiddle(input_list):
return (input_list[int(middle)], input_list[int(middle - 1)])
def _normalise_group_rule_value(value):
if value is None:
return ""
if isinstance(value, pydicom.multival.MultiValue):
value = list(value)
if isinstance(value, (list, tuple)):
return ",".join([str(v).strip() for v in value if str(v).strip()])
text = str(value).strip()
if text == "":
return ""
try:
numeric = float(text)
if abs(numeric - round(numeric)) < 1e-6:
return str(int(round(numeric)))
return f"{numeric:.1f}"
except (TypeError, ValueError):
return text
def _series_declared_groups_cache_key(series_id):
return f"atlas:series_declared_groups:{series_id}"
def invalidate_series_declared_groups_cache(series_id):
if not series_id:
return
try:
cache.delete(_series_declared_groups_cache_key(series_id))
except Exception:
pass
def _build_declared_series_groups(series, series_images_with_urls):
cache_key = _series_declared_groups_cache_key(series.pk)
signature = {
"series_modified": int(series.modified_date.timestamp()) if getattr(series, "modified_date", None) else 0,
"image_count": len(series_images_with_urls),
"images": [
{
"pk": getattr(image_obj, "pk", None),
"position": getattr(image_obj, "position", None),
"url": image_url,
}
for image_obj, image_url in series_images_with_urls
],
}
try:
cached = cache.get(cache_key)
except Exception:
cached = None
if isinstance(cached, dict):
if cached.get("signature") == signature:
return cached.get("groups", [])
rules = [
"dwiBValue",
"dwiDirection",
"temporalPosition",
"triggerTime",
#"contentTime",
"echoTime",
"echoNumber",
]
rows = []
for image_obj, image_url in series_images_with_urls:
ds = image_obj.get_dicom_data()
def get_value(*keys):
for key in keys:
if key in ds:
normalized = _normalise_group_rule_value(ds[key].value)
if normalized != "":
return normalized
return ""
def get_private_b_value():
# Siemens private (0019,100C)
try:
elem = ds.get((0x0019, 0x100C))
if elem is not None:
normalized = _normalise_group_rule_value(getattr(elem, "value", elem))
if normalized != "":
return normalized
except Exception:
pass
# GE private (0043,1039) can be "b\something" format
try:
elem = ds.get((0x0043, 0x1039))
if elem is not None:
raw = getattr(elem, "value", elem)
if isinstance(raw, pydicom.multival.MultiValue):
raw = raw[0] if len(raw) > 0 else ""
text = str(raw).split("\\")[0].strip()
normalized = _normalise_group_rule_value(text)
if normalized != "":
return normalized
except Exception:
pass
return ""
rule_values = {
"dwiBValue": get_value("DiffusionBValue", "DiffusionbValue") or get_private_b_value(),
"dwiDirection": get_value("DiffusionGradientDirection", "DiffusionGradientOrientation"),
"temporalPosition": get_value("TemporalPositionIdentifier"),
"triggerTime": get_value("TriggerTime"),
"contentTime": get_value("ContentTime", "AcquisitionTime"),
"echoTime": get_value("EchoTime"),
"echoNumber": get_value("EchoNumbers", "EchoNumber"),
}
b_value = None
try:
if rule_values["dwiBValue"] != "":
b_value = float(rule_values["dwiBValue"])
except (TypeError, ValueError):
b_value = None
rows.append(
{
"imageId": image_url,
"ruleValues": rule_values,
"bValue": b_value,
}
)
if len(rows) == 0:
return []
active_rules = []
for rule_id in rules:
values = [row["ruleValues"][rule_id] for row in rows if row["ruleValues"][rule_id] != ""]
distinct = set(values)
coverage = len(values) / len(rows)
if len(distinct) > 1 and len(distinct) < max(2, len(rows)) and coverage >= 0.7:
active_rules.append(rule_id)
if len(active_rules) == 0:
result = [
{
"key": f"SERVER_SERIES_{series.pk}_0",
"label": "Series 1",
"imageIds": [row["imageId"] for row in rows],
"seriesInstanceUID": series.series_instance_uid,
"bValue": None,
"groupValues": {},
}
]
try:
cache.set(cache_key, {"signature": signature, "groups": result}, timeout=60 * 60)
except Exception:
pass
return result
grouped = {}
for row in rows:
key_parts = []
for rule_id in active_rules:
value = row["ruleValues"][rule_id]
key_parts.append(f"{rule_id}:{value}" if value != "" else f"{rule_id}:")
group_key = "|".join(key_parts)
if group_key not in grouped:
grouped[group_key] = {
"imageIds": [],
"groupValues": {rule_id: row["ruleValues"][rule_id] for rule_id in active_rules if row["ruleValues"][rule_id] != ""},
"bValue": row["bValue"],
}
grouped[group_key]["imageIds"].append(row["imageId"])
if grouped[group_key]["bValue"] is None and row["bValue"] is not None:
grouped[group_key]["bValue"] = row["bValue"]
ordered_groups = []
for idx, (_, payload) in enumerate(grouped.items()):
details = []
group_values = payload.get("groupValues", {})
if group_values.get("dwiBValue"):
details.append(f"b={group_values['dwiBValue']}")
if group_values.get("dwiDirection"):
details.append(f"dir={group_values['dwiDirection']}")
if group_values.get("temporalPosition"):
details.append(f"tp={group_values['temporalPosition']}")
if group_values.get("triggerTime"):
details.append(f"tr={group_values['triggerTime']}")
if group_values.get("contentTime"):
details.append(f"t={group_values['contentTime']}")
if group_values.get("echoTime"):
details.append(f"TE={group_values['echoTime']}")
if group_values.get("echoNumber"):
details.append(f"echo={group_values['echoNumber']}")
label = "".join(details) if len(details) > 0 else f"Series {idx + 1}"
ordered_groups.append(
{
"key": f"SERVER_SERIES_{series.pk}_{idx}",
"label": label,
"imageIds": payload["imageIds"],
"seriesInstanceUID": series.series_instance_uid,
"bValue": payload.get("bValue"),
"groupValues": group_values,
}
)
try:
cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
except Exception:
pass
return ordered_groups
class SynMixin(object):
# class Meta:
# abstract = True
@@ -616,21 +841,169 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
study_date = models.DateField(
null=True,
blank=True,
help_text="Optional manually overridden study date for linked case series.",
)
def get_series_info(self):
try:
has_next = hasattr(self, 'next_case') and self.next_case is not None
except Case.DoesNotExist:
has_next = False
if not self.previous_case_id and not has_next:
return {"in_series": False}
# Find the start of the chain (the first case)
first_case = self
visited = {self.pk}
while first_case.previous_case:
if first_case.previous_case.pk in visited:
break
first_case = first_case.previous_case
visited.add(first_case.pk)
# Traverse the chain from the first case to find positions and count
chain = []
current = first_case
visited_forward = set()
while current:
if current.pk in visited_forward:
break
chain.append(current)
visited_forward.add(current.pk)
try:
current = current.next_case if hasattr(current, 'next_case') else None
except Case.DoesNotExist:
current = None
total = len(chain)
try:
position = chain.index(self) + 1
except ValueError:
position = 1
return {
"in_series": True,
"position": position,
"total": total,
}
def get_ordered_series_details(self):
# If the series are already prefetched, use them directly
if hasattr(self, '_prefetched_objects_cache') and 'series' in self._prefetched_objects_cache:
# Get the through model and sort by sort_order
through_model = self.series.through
# Build a mapping from series pk to through instance
through_objs = list(through_model.objects.filter(case=self).select_related('series'))
through_objs.sort(key=lambda x: x.sort_order)
cached = getattr(self, "_ordered_series_details_cache", None)
if cached is not None:
return cached
prefetched = getattr(self, "_prefetched_objects_cache", {})
if "seriesdetail_set" in prefetched:
through_objs = sorted(
list(prefetched["seriesdetail_set"]),
key=lambda item: item.sort_order,
)
self._ordered_series_details_cache = through_objs
return through_objs
# Otherwise, query as before
return self.series.through.objects.filter(case=self).select_related('series').order_by('sort_order')
through_objs = list(
self.series.through.objects
.filter(case=self)
.select_related("series")
.order_by("sort_order")
)
self._ordered_series_details_cache = through_objs
return through_objs
def get_ordered_series(self):
"""Returns the series in the case in order of the SeriesDetail sort_order"""
return [sd.series for sd in self.get_ordered_series_details()]
cached = getattr(self, "_ordered_series_cache", None)
if cached is not None:
return cached
ordered = [sd.series for sd in self.get_ordered_series_details()]
self._ordered_series_cache = ordered
return ordered
def get_case_named_stacks(self):
stacks = []
for series in self.get_ordered_series():
series_images = list(series.get_images())
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
images = [url for _, url in series_images_with_urls]
declared_groups = _build_declared_series_groups(series, series_images_with_urls)
stacks.append(
{
"name": str(series),
"imageIds": images,
"seriesId": series.pk,
"series": declared_groups,
}
)
results = [
{
"caseId": f"CASE-{self.pk}",
"studyId": "Current Case",
"stacks": stacks,
}
]
return json.dumps(results)
@staticmethod
def _parse_dicom_date(value):
if not value:
return None
try:
return datetime.datetime.strptime(str(value), "%Y%m%d").date()
except (TypeError, ValueError):
return None
def _get_series_study_date(self):
for series in self.get_ordered_series():
image = series.images.filter(removed=False).first()
if not image:
continue
tags = getattr(image, "basic_dicom_tags", None) or {}
raw_date = (
tags.get("StudyDate")
or tags.get("AcquisitionDate")
or tags.get("SeriesDate")
)
parsed = self._parse_dicom_date(raw_date)
if parsed:
return parsed
try:
ds = image.get_dicom_data()
except Exception:
ds = None
if not ds:
continue
raw_date = (
getattr(ds, "StudyDate", None)
or getattr(ds, "AcquisitionDate", None)
or getattr(ds, "SeriesDate", None)
)
parsed = self._parse_dicom_date(raw_date)
if parsed:
return parsed
return None
def get_effective_study_date(self):
if self.study_date:
return self.study_date
return self._get_series_study_date()
def save(self, *args, **kwargs):
if self.pk and not self.study_date:
derived_study_date = self._get_series_study_date()
if derived_study_date:
self.study_date = derived_study_date
super().save(*args, **kwargs)
def get_app_name(self):
return "atlas"
@@ -670,6 +1043,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
def get_series_blocks(self):
html = ""
s: Series
for s in self.series.all():
html += s.get_block()
@@ -749,6 +1123,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return prior_cases
def get_all_future_cases(self):
from django.core.exceptions import ObjectDoesNotExist
future_cases = []
current = self
while True:
try:
next_c = current.next_case
except ObjectDoesNotExist:
break
if next_c is None:
break
future_cases.append(next_c)
current = next_c
return future_cases
def get_series(self):
return self.series.all().prefetch_related("images", "examination", "plane")
@@ -814,30 +1203,40 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
else:
return images
def get_case_named_stacks(self):
def build_stacks_for(case_obj, prefix=None):
logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
stacks = []
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append({"name": name, "imageIds": images, "seriesId": series.pk})
return stacks
#def get_case_named_stacks(self):
# def build_stacks_for(case_obj, prefix=None):
# logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
# stacks = []
# for series in case_obj.get_ordered_series():
# series_images = list(series.get_images())
# series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
# images = [url for _, url in series_images_with_urls]
# declared_groups = _build_declared_series_groups(series, series_images_with_urls)
# name = f"{prefix}: {series}" if prefix else str(series)
# stacks.append(
# {
# "name": name,
# "imageIds": images,
# "seriesId": series.pk,
# "series": declared_groups,
# }
# )
# return stacks
results = []
# results = []
logger.debug(f"Building stacks for case {self.pk}")
# logger.debug(f"Building stacks for case {self.pk}")
# main case entry
results.append(
{
"caseId": f"",
"studyId": f"Current Case",
"stacks": build_stacks_for(self, prefix=None),
}
)
# # main case entry
# results.append(
# {
# "caseId": f"",
# "studyId": f"Current Case",
# "stacks": build_stacks_for(self, prefix=None),
# }
# )
return json.dumps(results)
# return json.dumps(results)
class NormalCase(models.Model, AuthorMixin):
@@ -1058,6 +1457,41 @@ class SeriesImage(SeriesImageBase):
# "SOPInstanceUID" : f"1.2.840.1111.{image_index}"
# }}
@receiver(pre_save, sender=SeriesImage)
def _capture_previous_series_for_series_image(sender, instance, **kwargs):
if not getattr(instance, "pk", None):
instance._previous_series_id_for_cache = None
return
previous_series_id = (
sender.objects.filter(pk=instance.pk).values_list("series_id", flat=True).first()
)
instance._previous_series_id_for_cache = previous_series_id
def _invalidate_and_touch_series(series_id):
if not series_id:
return
invalidate_series_declared_groups_cache(series_id)
Series.objects.filter(pk=series_id).update(modified_date=timezone.now())
@receiver(post_save, sender=SeriesImage)
def _invalidate_declared_groups_on_series_image_save(sender, instance, **kwargs):
affected_series_ids = set()
if instance.series_id:
affected_series_ids.add(instance.series_id)
previous_series_id = getattr(instance, "_previous_series_id_for_cache", None)
if previous_series_id:
affected_series_ids.add(previous_series_id)
for series_id in affected_series_ids:
_invalidate_and_touch_series(series_id)
@receiver(post_delete, sender=SeriesImage)
def _invalidate_declared_groups_on_series_image_delete(sender, instance, **kwargs):
_invalidate_and_touch_series(getattr(instance, "series_id", None))
class SeriesFinding(FindingBase):
series = models.ForeignKey(
@@ -1114,6 +1548,7 @@ class Series(SeriesBase):
)
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
source_series_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
@@ -1154,6 +1589,14 @@ class Series(SeriesBase):
self.pk, self.get_examination_full(), self.description, case_id
)
def get_viewer_stack_description(self) -> str:
description = self.description or str(self.examination or "Series")
if self.plane:
description = f"{description} ({self.plane})"
if self.contrast:
description = f"{description} / {self.contrast}"
return description
def get_absolute_url(self):
return reverse("atlas:series_detail", kwargs={"pk": self.pk})
@@ -1192,10 +1635,7 @@ class Series(SeriesBase):
# else:
# instances.append(image.get_image_dicom_json(image_index))
description = f"{self.examination} ({self.plane})"
if self.contrast and self.contrast is not None:
description = f"{description} / {self.contrast}"
series_json["SeriesDescription"] = description
series_json["SeriesDescription"] = self.get_viewer_stack_description()
series_json["instances"] = instances
return series_json
@@ -1513,6 +1953,10 @@ class CaseCollection(ExamOrCollectionGenericBase):
def get_take_url(self):
return reverse("atlas:collection_take_start", kwargs={"pk": self.pk})
def get_take_url_exam_mode(self):
return reverse("atlas:collection_take_start", kwargs={"pk": self.pk})
def __str__(self) -> str:
return self.name
@@ -1754,6 +2198,17 @@ class SeriesDetail(models.Model):
class Meta:
ordering = ("sort_order",)
@receiver(post_save, sender=SeriesDetail)
def _populate_case_study_date_on_series_link(sender, instance, created, **kwargs):
case = instance.case
if case.study_date:
return
derived_study_date = case._get_series_study_date()
if derived_study_date:
Case.objects.filter(pk=case.pk, study_date__isnull=True).update(study_date=derived_study_date)
class CaseDisplaySet(models.Model, AuthorMixin):
"""
This is analogous to a SeriesFinding but for a Case (it has
@@ -1840,6 +2295,29 @@ class CaseDetail(models.Model):
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
)
class SeriesVisibility(models.TextChoices):
ALWAYS = "AL", _("Show all the time")
REVIEW = "RE", _("Show on review")
HIDDEN = "NO", _("Do not show")
series_visibility_config = models.JSONField(
default=dict,
blank=True,
help_text="Per-series visibility map for this case in this collection.",
)
learner_comment = models.TextField(
blank=True,
default="",
help_text="Comment shown to the learner while taking the case and on review.",
)
learner_review_comment = models.TextField(
blank=True,
default="",
help_text="Comment shown to the learner only on review.",
)
class Meta:
ordering = ("sort_order",)
@@ -1893,26 +2371,85 @@ class CaseDetail(models.Model):
return self.override_history
return self.case.history or "No history provided"
def get_case_series_nested(self, include_priors=True):
case_series_images = self.case.get_series_images_nested(as_json=False)
def get_series_visibility_map(self) -> dict[str, str]:
raw_map = self.series_visibility_config or {}
if not isinstance(raw_map, dict):
return {}
valid_values = {
self.SeriesVisibility.ALWAYS,
self.SeriesVisibility.REVIEW,
self.SeriesVisibility.HIDDEN,
}
cleaned = {}
for key, value in raw_map.items():
key_str = str(key)
value_str = str(value)
if value_str in valid_values:
cleaned[key_str] = value_str
return cleaned
def get_series_visibility_for(self, series, review_mode: bool = False) -> str:
visibility = self.get_series_visibility_map().get(
str(series.pk), self.SeriesVisibility.ALWAYS
)
if visibility == self.SeriesVisibility.REVIEW and not review_mode:
return self.SeriesVisibility.HIDDEN
return visibility
def get_visible_case_series(self, review_mode: bool = False):
visible = []
for series in self.case.get_ordered_series():
visibility = self.get_series_visibility_for(series, review_mode=review_mode)
if visibility != self.SeriesVisibility.HIDDEN:
visible.append(series)
return visible
def get_case_series_nested(self, include_priors=True, review_mode: bool = False):
case_series_images = []
for series in self.get_visible_case_series(review_mode=review_mode):
case_series_images.append([
image.image.url
for image in series.images.filter(removed=False)
if image.image
])
if include_priors:
logger.debug(f"Checking for prior cases for case {self.case}")
logger.debug(f"Found {self.case.prior_case.count()} prior cases for case {self.case}")
for prior in self.caseprior_set.all():
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
continue
logger.debug(f"Adding prior case {prior.prior_case} to case {self.case}")
case_series_images.extend(prior.prior_case.get_series_images_nested(as_json=False))
return json.dumps(case_series_images)
def get_case_named_stacks(self, include_priors=True):
def get_case_named_stacks(self, include_priors=True, review_mode: bool = False):
def build_stacks_for(case_obj, prefix=None):
stacks = []
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
if case_obj == self.case:
series_iterable = self.get_visible_case_series(review_mode=review_mode)
else:
series_iterable = case_obj.get_ordered_series()
for series in series_iterable:
series_images = list(series.get_images())
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
images = [url for _, url in series_images_with_urls]
declared_groups = _build_declared_series_groups(series, series_images_with_urls)
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append({"name": name, "imageIds": images, "seriesId": series.pk})
stacks.append(
{
"name": name,
"imageIds": images,
"seriesId": series.pk,
"series": declared_groups,
}
)
return stacks
results = []
@@ -1929,12 +2466,17 @@ class CaseDetail(models.Model):
# include priors as separate entries
if include_priors:
for prior in self.caseprior_set.all():
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
continue
prior_case = prior.prior_case
label = "Prior" if prior.relation_type == CasePrior.RelationType.PRIOR else "Future"
results.append(
{
"caseId": f"CASE-{prior_case.pk}",
"studyId": f"Prior: {prior.relation_text}",
"stacks": build_stacks_for(prior_case, prefix="Prior"),
"studyId": f"{label}: {prior.relation_text}",
"stacks": build_stacks_for(prior_case, prefix=label),
}
)
@@ -1967,6 +2509,17 @@ class CasePrior(models.Model):
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
class RelationType(models.TextChoices):
PRIOR = "PR", _("Prior")
FUTURE = "FU", _("Future")
relation_type = models.CharField(
max_length=2,
choices=RelationType.choices,
default=RelationType.PRIOR,
help_text="Defines whether the related case is a prior or a future exam",
)
class PriorVisibility(models.TextChoices):
NONE = "NO", _("None")
ALWAYS = "AL", _("Always")
@@ -2087,6 +2640,7 @@ class BaseReportAnswer(models.Model):
abstract = True
@reversion.register
class CidReportAnswer(BaseReportAnswer):
cid = models.BigIntegerField(
blank=False,
@@ -2094,6 +2648,7 @@ class CidReportAnswer(BaseReportAnswer):
)
@reversion.register
class UserReportAnswer(BaseReportAnswer):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@@ -2322,6 +2877,7 @@ class UncategorisedDicom(models.Model):
"SliceThickness",
"SeriesNumber",
"StudyDate",
"PatientID",
)
tags = {}
@@ -2468,4 +3024,6 @@ class PrerequisiteRequired(Exception):
def __init__(self, message=None, *, prereq=None):
super().__init__(message or "Prerequisite required")
self.prereq = prereq
self.prereq = prereq
+2
View File
@@ -21,6 +21,7 @@ from .models import (
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.template.defaultfilters import filesizeformat
from easy_thumbnails.files import get_thumbnailer
@@ -159,6 +160,7 @@ class CaseTable(SelectionTable):
return mark_safe(", ".join(links))
return "No collections"
associated_cases = tables.Column(
verbose_name="Associated Cases", accessor="pk", orderable=False
)
+149 -25
View File
@@ -92,6 +92,110 @@ from django.core.cache import cache
import copy
import io
@task(takes_context=True)
def series_downsample_task(context, series_id, user_id, downsample_pct):
"""Downsample a series asynchronously while preserving hash continuity."""
from loguru import logger
from atlas import views as atlas_views
progress_key = f"series_downsample_progress:{context.task_result.id}"
cache.set(
progress_key,
{"current": 0, "total": 0, "message": "Preparing downsample..."},
timeout=60 * 60,
)
series = get_object_or_404(Series, pk=series_id)
user = get_object_or_404(User, pk=user_id)
if not series.check_user_can_edit(user):
raise PermissionError("Permission denied")
images = list(series.get_images())
total_images = len(images)
replacement_series_uid = generate_uid()
source_series_uid = series.series_instance_uid
updated_images = []
cache.set(
progress_key,
{"current": 0, "total": total_images, "message": "Downsample started"},
timeout=60 * 60,
)
for idx, image in enumerate(images, start=1):
ds = atlas_views._read_series_image_dataset(image)
if ds is None:
cache.set(
progress_key,
{"current": idx, "total": total_images, "message": f"Skipping unreadable image ({idx}/{total_images})"},
timeout=60 * 60,
)
continue
try:
original_hash = image.image_blake3_hash
ds_out = atlas_views._downsample_dicom_dataset(
ds,
int(downsample_pct),
series_instance_uid=replacement_series_uid,
source_series_instance_uid=source_series_uid,
series_description=series.description,
)
out_io = io.BytesIO()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
image.image.save(
f"downsample_{downsample_pct}_{image.pk}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
image.image_blake3_hash = original_hash
image.save(update_fields=["image", "image_blake3_hash"])
updated_images.append(image.pk)
except Exception as exc:
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
cache.set(
progress_key,
{
"current": idx,
"total": total_images,
"message": f"Downsampling image {idx}/{total_images}",
},
timeout=60 * 60,
)
if updated_images:
series.modified = Series.SeriesModifiedChocies.RE
series.source_series_instance_uid = source_series_uid
series.series_instance_uid = replacement_series_uid
series.save(update_fields=["modified", "source_series_instance_uid", "series_instance_uid"])
try:
series.get_total_image_size(refresh=True)
except Exception as exc:
logger.warning("Failed to refresh total_image_size for series {}: {}", series.pk, exc)
cache.set(
progress_key,
{
"current": total_images,
"total": total_images,
"message": "Downsample complete",
},
timeout=10 * 60,
)
return {
"series_id": series.pk,
"downsample_pct": int(downsample_pct),
"updated_images": len(updated_images),
"series_instance_uid": replacement_series_uid,
"source_series_instance_uid": source_series_uid,
}
@task
def push_case_to_cimar_task(case_id):
"""Sends an email when the feedback form has been submitted."""
@@ -232,6 +336,32 @@ def series_reconstruct_task(
src_y = source_for_patient_axis[1]
src_z = source_for_patient_axis[2]
# Build patient-axis basis vectors expressed in source/world space.
# This preserves source world-frame orientation (including obliquity/sign)
# for derived series instead of hardcoding cardinal axes.
patient_x_vec = np.asarray(source_axis_dirs[src_x], dtype=float) * float(sign_for_patient_axis[0])
patient_y_vec = np.asarray(source_axis_dirs[src_y], dtype=float) * float(sign_for_patient_axis[1])
patient_z_vec = np.asarray(source_axis_dirs[src_z], dtype=float) * float(sign_for_patient_axis[2])
def _norm_vec(vec):
nrm = float(np.linalg.norm(vec))
if nrm == 0:
return np.asarray(vec, dtype=float)
return np.asarray(vec, dtype=float) / nrm
patient_x_vec = _norm_vec(patient_x_vec)
patient_y_vec = _norm_vec(patient_y_vec)
patient_z_vec = _norm_vec(patient_z_vec)
def _orientation_pair(col_vec, row_vec, desired_normal):
col = _norm_vec(col_vec)
row = _norm_vec(row_vec)
desired = _norm_vec(desired_normal)
normal = np.cross(col, row)
if float(np.dot(normal, desired)) < 0:
row = -row
return [float(col[0]), float(col[1]), float(col[2]), float(row[0]), float(row[1]), float(row[2])]
spacing_x = source_axis_spacings[src_x]
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
@@ -246,7 +376,7 @@ def series_reconstruct_task(
vol_zyx = np.flip(vol_zyx, axis=2)
def source_indices_from_patient_zyx(iz, iy, ix):
src_indices = [0, 0, 0]
src_indices = [0.0, 0.0, 0.0]
z_index = float(iz)
y_index = float(iy)
@@ -274,10 +404,6 @@ def series_reconstruct_task(
)
return [float(pos[0]), float(pos[1]), float(pos[2])]
spacing_x = source_axis_spacings[src_x]
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
def _reduce_slab(slab, reduce_axis=0):
if recon_thickness_mode == "max":
out = np.max(slab, axis=reduce_axis)
@@ -301,23 +427,22 @@ def series_reconstruct_task(
axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing)
end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0
start_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
if start_positions_mm.size == 0:
start_positions_mm = np.array([0.0], dtype=float)
center_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
if center_positions_mm.size == 0:
center_positions_mm = np.array([0.0], dtype=float)
slabs = []
centers_idx = []
for start_mm in start_positions_mm:
end_mm = start_mm + slab_thickness
mask = (axis_positions_mm >= (start_mm - 1e-6)) & (axis_positions_mm < (end_mm - 1e-6))
half_thickness = slab_thickness / 2.0
for center_mm in center_positions_mm:
mask = np.abs(axis_positions_mm - center_mm) <= (half_thickness + 1e-6)
if not mask.any():
nearest = int(np.argmin(np.abs(axis_positions_mm - start_mm)))
nearest = int(np.argmin(np.abs(axis_positions_mm - center_mm)))
idxs = np.array([nearest], dtype=int)
else:
idxs = np.where(mask)[0].astype(int)
slabs.append(idxs)
center_mm = start_mm + (slab_thickness / 2.0)
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
return slabs, centers_idx
@@ -337,8 +462,8 @@ def series_reconstruct_task(
"pixel_spacing_out": [float(spacing_y), float(spacing_x)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
"plane_normal_out": [0.0, 0.0, 1.0],
"image_orientation_out": _orientation_pair(patient_x_vec, patient_y_vec, patient_z_vec),
"plane_normal_out": [float(patient_z_vec[0]), float(patient_z_vec[1]), float(patient_z_vec[2])],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(centers[idx], 0.0, 0.0),
}
elif plane_norm == "coronal":
@@ -349,13 +474,13 @@ def series_reconstruct_task(
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: np.flipud(_reduce_slab(vol_zyx[:, slabs[idx], :], reduce_axis=1)),
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, slabs[idx], :], reduce_axis=1),
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 0.0, -1.0],
"plane_normal_out": [0.0, 1.0, 0.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), centers[idx], 0.0),
"image_orientation_out": _orientation_pair(patient_x_vec, patient_z_vec, patient_y_vec),
"plane_normal_out": [float(patient_y_vec[0]), float(patient_y_vec[1]), float(patient_y_vec[2])],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, centers[idx], 0.0),
}
elif plane_norm == "sagittal":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_x)
@@ -365,13 +490,13 @@ def series_reconstruct_task(
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: np.flipud(_reduce_slab(vol_zyx[:, :, slabs[idx]], reduce_axis=2)),
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[:, :, slabs[idx]], reduce_axis=2),
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [0.0, 1.0, 0.0, 0.0, 0.0, -1.0],
"plane_normal_out": [1.0, 0.0, 0.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), 0.0, centers[idx]),
"image_orientation_out": _orientation_pair(patient_y_vec, patient_z_vec, patient_x_vec),
"plane_normal_out": [float(patient_x_vec[0]), float(patient_x_vec[1]), float(patient_x_vec[2])],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, 0.0, centers[idx]),
}
if not plane_builders:
@@ -389,8 +514,6 @@ def series_reconstruct_task(
for plane_norm, cfg in plane_builders.items():
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
recon_series.series_instance_uid = generate_uid()
recon_series.save(update_fields=["series_instance_uid"])
for idx in range(int(cfg["count"])):
arr2d = cfg["slice_fn"](idx)
@@ -403,6 +526,7 @@ def series_reconstruct_task(
atlas_views._stamp_derived_dataset(
ds_new,
series_instance_uid=recon_series.series_instance_uid,
source_series_instance_uid=series.series_instance_uid,
series_description=recon_series.description or f"Recon {plane_norm.title()}",
derivation_description=(
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
+11 -5
View File
@@ -54,6 +54,9 @@
</li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'atlas:case_admin_overview' %}"><i class="bi bi-speedometer2 me-1" aria-hidden="true"></i>Case Admin Overview</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:cases_by_size' %}"><i class="bi bi-hDD-stack me-1" aria-hidden="true"></i>Large cases</a>
</li>
@@ -139,12 +142,15 @@
{% endif %}
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" title="Additional Features"><i class="bi bi-three-dots me-1" aria-hidden="true"></i>Other</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<ul class="dropdown-menu">
<a class="dropdown-item" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
<a class="dropdown-item" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
</ul>
</li>
</ul>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE HTML>
{% load static %}
{% load django_htmx %}
{% load crispy_forms_tags %}
<html data-bs-theme="dark">
<head>
<title>Create Resource</title>
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'css/widgets.css' %}" type="text/css" media="all" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/bootstrap-icons.css' %}">
<script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
{% block js %}
{% endblock %}
<link rel="stylesheet" href="{% static 'css/forms.css' %}">
</head>
<body class="p-3">
{% block content %}
{% endblock %}
</body>
</html>
@@ -0,0 +1,116 @@
{% extends 'atlas/base.html' %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h3 class="mb-0">Case Admin Overview</h3>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_view' %}">Back to case list</a>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Total cases</div>
<div class="fs-4 fw-semibold">{{ totals.total_cases }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Authors with cases</div>
<div class="fs-4 fw-semibold">{{ totals.total_authors }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Open-access cases</div>
<div class="fs-4 fw-semibold">{{ totals.open_access_cases }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Archived cases</div>
<div class="fs-4 fw-semibold">{{ totals.archived_cases }}</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">Cases per user</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>User</th>
<th class="text-end">Case count</th>
</tr>
</thead>
<tbody>
{% for row in author_rows %}
<tr>
<td>
<a href="{% url 'atlas:case_view' %}?author={{ row.pk }}">
{% if row.get_full_name %}{{ row.get_full_name }}{% else %}{{ row.username }}{% endif %}
</a>
<div class="text-muted small">{{ row.username }}</div>
</td>
<td class="text-end fw-semibold">
<a href="{% url 'atlas:case_view' %}?author={{ row.pk }}">{{ row.case_count }}</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-muted">No case authors found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-7 d-flex flex-column gap-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Recently added cases</span>
<button class="btn btn-sm btn-outline-info"
hx-get="{% url 'atlas:case_admin_recent_cases_partial' %}"
hx-target="#admin-recent-cases"
hx-swap="innerHTML">
Load
</button>
</div>
<div id="admin-recent-cases" class="card-body text-muted small">
Click Load to fetch recently added cases.
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Largest cases</span>
<button class="btn btn-sm btn-outline-warning"
hx-get="{% url 'atlas:case_admin_largest_cases_partial' %}"
hx-target="#admin-largest-cases"
hx-swap="innerHTML">
Load
</button>
</div>
<div id="admin-largest-cases" class="card-body text-muted small">
Click Load to fetch the largest cases by image footprint.
</div>
</div>
</div>
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block css %}
{% endblock %}
@@ -14,13 +14,54 @@
<form method="POST" class="post-form">
{% csrf_token %}
{% crispy form form.helper %}
<div class="card mb-3">
<div class="card-header">
<strong>Series Visibility</strong>
</div>
<div class="card-body">
<p class="small text-muted mb-3">Choose how each series is shown for this case in this collection.</p>
{% if series_visibility_rows %}
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Series</th>
<th>Visibility</th>
</tr>
</thead>
<tbody>
{% for row in series_visibility_rows %}
<tr>
<td>
<div class="fw-semibold">Series {{ forloop.counter }}</div>
<div class="small text-muted">{{ row.series }}</div>
</td>
<td>
<select class="form-select form-select-sm" name="series_visibility_{{ row.series.pk }}">
{% for value, label in visibility_choices %}
<option value="{{ value }}" {% if row.value == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted">No series linked to this case.</div>
{% endif %}
</div>
</div>
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
</form>
{% endblock %}
{% block js %}
<script>
</script>
<style></style>
<script>
</script>
<style></style>
{% endblock %}
@@ -15,14 +15,26 @@
</div>
</div>
<h3 class="mb-3">Available priors</h3>
<h3 class="mb-3">Available Prior Exams</h3>
<div class="row g-3">
{% for case, added, relation, visibility in available_priors %}
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
<div class="row g-3 mb-4">
{% for case, added, relation, visibility, relation_type in available_priors %}
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility relation_type=relation_type collection=collection %}
{% empty %}
<div class="col-12">
<div class="alert alert-info">No available prior cases found for the case.</div>
<div class="alert alert-info">No available prior exams found for the case.</div>
</div>
{% endfor %}
</div>
<h3 class="mb-3">Available Future Exams</h3>
<div class="row g-3 mb-4">
{% for case, added, relation, visibility, relation_type in available_futures %}
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility relation_type=relation_type collection=collection %}
{% empty %}
<div class="col-12">
<div class="alert alert-info">No available future exams found for the case.</div>
</div>
{% endfor %}
</div>
@@ -1,9 +1,18 @@
{% extends 'atlas/base.html' %}
{% load crispy_forms_tags %}
{% load django_htmx %}
{% block content %}
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
{% if viewing_as_user %}
<div class="alert alert-info d-flex align-items-center gap-2 py-2 px-3 mb-3" role="alert">
<i class="bi bi-eye-fill me-1"></i>
<span><strong>Reviewer mode</strong> &mdash; Viewing <strong>{{ target_user.get_full_name|default:target_user.username }}</strong>&rsquo;s answers. This screen is read-only.</span>
<a href="{% url 'atlas:collection_history_user' exam_id=collection.id user_pk=target_user.pk %}" class="btn btn-outline-info btn-sm ms-auto">Back to overview</a>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h4 mb-0">Case {{case_number|add:1}}
@@ -101,53 +110,65 @@
</div>
{% endif %}
{% if casedetail.learner_comment %}
<div class="alert alert-info small">
<strong>Collection note:</strong> {{ casedetail.learner_comment|linebreaks }}
</div>
{% endif %}
{% if question_completed and casedetail.learner_review_comment %}
<div class="alert alert-secondary small">
<strong>Review note:</strong> {{ casedetail.learner_review_comment|linebreaks }}
</div>
{% endif %}
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
<div id="viewer-panel" class="mb-3">
{% if collection.show_built_in_viewer %}
<div class="d-flex align-items-center gap-2 py-1 px-2 mb-1 rounded bg-body-secondary">
<span class="text-muted fw-semibold small me-auto">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-sidebar-btn" title="Move viewer to sidebar">
<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-popout-btn" title="Open viewer in a separate window">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>
</button>
</div>
<div id="viewer-moveable-home">
<div id="viewer-moveable">
<div id="viewer-container" class="viewer-resizable-container">
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
<div id="viewer-panel" class="mb-3">
{% if collection.show_built_in_viewer %}
<div class="d-flex align-items-center gap-2 py-1 px-2 mb-1 rounded bg-body-secondary">
<span class="text-muted fw-semibold small me-auto">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-sidebar-btn" title="Move viewer to sidebar">
<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-popout-btn" title="Open viewer in a separate window">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>
</button>
</div>
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
<div class="viewer-resize-grip-inner"></div>
<div id="viewer-moveable-home">
<div id="viewer-moveable">
<div id="viewer-container" class="viewer-resizable-container">
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{ named_stacks_json }}'
></div>
</div>
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
<div class="viewer-resize-grip-inner"></div>
</div>
</div>
</div>
</div>
{% endif %}
{% if collection.show_ohif_viewer %}
<div{% if collection.show_built_in_viewer %} class="mt-3"{% endif %}>
{% if question_completed %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer_link %}
<div class="mt-2">
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer %}
<div{% if collection.show_built_in_viewer %} class="mt-3"{% endif %}>
{% if question_completed %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer_link %}
<div class="mt-2">
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
@@ -294,6 +315,7 @@
{% endif %}
{% endif %}
{% if not viewing_as_user %}
<div class="mb-2">
{% if previous %}
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
@@ -311,11 +333,24 @@
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
{% endif %}
<div class="mt-3">
<button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div>
{% else %}
{# Read-only navigation for reviewer mode — uses anchor links to avoid mutating POST #}
<div class="mb-2 d-flex gap-2 flex-wrap">
{% if previous %}
<a href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=case_number|add:'-1' user_pk=target_user.pk %}" class="btn btn-outline-secondary btn-sm">Previous</a>
{% endif %}
{% if next %}
<a href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=case_number|add:'1' user_pk=target_user.pk %}" class="btn btn-primary btn-sm">Next</a>
{% endif %}
</div>
<div class="mt-3">
<a href="{% url 'atlas:collection_history_user' exam_id=collection.id user_pk=target_user.pk %}" class="btn btn-outline-secondary btn-sm">Overview</a>
</div>
{% endif %}
</form>
{% if question_completed and collection.case_query_messaging_enabled %}
@@ -351,6 +386,7 @@
</aside>
<aside id="viewerSidebar" class="viewer-sidebar" aria-label="Viewer sidebar">
<div id="viewer-sidebar-resize-handle" class="viewer-sidebar-resize-handle" title="Drag to resize sidebar" aria-hidden="true"></div>
<div class="viewer-sidebar-header">
<span class="fw-semibold">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="viewer-sidebar-close-btn">Close</button>
@@ -545,6 +581,18 @@
overflow: auto;
}
.viewer-sidebar-resize-handle {
position: absolute;
left: 0;
top: 0;
width: 10px;
height: 100%;
cursor: ew-resize;
z-index: 3;
background: linear-gradient(to right, rgba(100,181,246,0.28), rgba(100,181,246,0));
touch-action: none;
}
.viewer-sidebar.open {
transform: translateX(0);
}
@@ -975,207 +1023,244 @@
<script type="text/javascript">
(function () {
function initViewerControls() {
var COLL_PK = '{{ collection.pk }}';
var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK;
var MODE_KEY = 'rad_viewer_mode_' + COLL_PK;
var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
var SIDEBAR_W_KEY = 'rad_viewer_w_' + COLL_PK;
var COLL_PK = '{{ collection.pk }}';
var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK;
var MODE_KEY = 'rad_viewer_mode_' + COLL_PK;
var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
var SIDEBAR_W_KEY = 'rad_viewer_w_' + COLL_PK;
var panel = document.getElementById('viewer-panel');
var moveableHome = document.getElementById('viewer-moveable-home');
var moveable = document.getElementById('viewer-moveable');
var container = document.getElementById('viewer-container');
var resizeHandle = document.getElementById('viewer-resize-handle');
var sidebarEl = document.getElementById('viewerSidebar');
var sidebarBody = document.getElementById('viewerSidebarBody');
var sidebarBtn = document.getElementById('viewer-sidebar-btn');
var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn');
var popoutBtn = document.getElementById('viewer-popout-btn');
var mainViewerEl = document.getElementById('main_viewer');
var rootStyle = document.documentElement.style;
var panel = document.getElementById('viewer-panel');
var moveableHome = document.getElementById('viewer-moveable-home');
var moveable = document.getElementById('viewer-moveable');
var container = document.getElementById('viewer-container');
var resizeHandle = document.getElementById('viewer-resize-handle');
var sidebarEl = document.getElementById('viewerSidebar');
var sidebarBody = document.getElementById('viewerSidebarBody');
var sidebarBtn = document.getElementById('viewer-sidebar-btn');
var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn');
var sidebarResizeHandle = document.getElementById('viewer-sidebar-resize-handle');
var popoutBtn = document.getElementById('viewer-popout-btn');
var mainViewerEl = document.getElementById('main_viewer');
var rootStyle = document.documentElement.style;
if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) {
return;
}
if (panel.dataset.viewerControlsBound === '1') {
return;
}
panel.dataset.viewerControlsBound = '1';
var savedH = localStorage.getItem(HEIGHT_KEY);
if (savedH && !isNaN(parseInt(savedH, 10))) {
container.style.height = parseInt(savedH, 10) + 'px';
}
function applySidebarWidth(px) {
if (!sidebarEl || !px || isNaN(px)) return;
var clamped = Math.max(360, Math.min(window.innerWidth * 0.95, Math.round(px)));
sidebarEl.style.width = clamped + 'px';
rootStyle.setProperty('--viewer-sidebar-width', clamped + 'px');
}
var savedW = localStorage.getItem(SIDEBAR_W_KEY);
if (savedW && !isNaN(parseInt(savedW, 10))) {
applySidebarWidth(parseInt(savedW, 10));
} else if (sidebarEl) {
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
}
if (sidebarEl && 'ResizeObserver' in window) {
var sidebarResizeObserver = new ResizeObserver(function () {
var width = Math.round(sidebarEl.getBoundingClientRect().width);
rootStyle.setProperty('--viewer-sidebar-width', width + 'px');
localStorage.setItem(SIDEBAR_W_KEY, width);
});
sidebarResizeObserver.observe(sidebarEl);
}
var persistHeight = function () {
localStorage.setItem(HEIGHT_KEY, Math.round(container.getBoundingClientRect().height));
};
if ('ResizeObserver' in window) {
var ro = new ResizeObserver(function () {
persistHeight();
});
ro.observe(container);
} else {
window.addEventListener('mouseup', persistHeight);
}
if (resizeHandle) {
resizeHandle.addEventListener('pointerdown', function (e) {
e.preventDefault();
});
}
function openViewerSidebar() {
if (!sidebarEl || !sidebarBody) return;
sidebarBody.appendChild(moveable);
sidebarEl.classList.add('open');
document.body.classList.add('viewer-sidebar-open');
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Close sidebar</span>';
sidebarBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) {
return;
}
localStorage.setItem(MODE_KEY, 'sidebar');
}
function closeViewerSidebar() {
if (!sidebarEl) return;
moveableHome.appendChild(moveable);
sidebarEl.classList.remove('open');
document.body.classList.remove('viewer-sidebar-open');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>';
sidebarBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
if (panel.dataset.viewerControlsBound === '1') {
return;
}
localStorage.removeItem(MODE_KEY);
}
panel.dataset.viewerControlsBound = '1';
if (sidebarBtn) {
sidebarBtn.addEventListener('click', function () {
var savedH = localStorage.getItem(HEIGHT_KEY);
if (savedH && !isNaN(parseInt(savedH, 10))) {
container.style.height = parseInt(savedH, 10) + 'px';
}
function applySidebarWidth(px) {
if (!sidebarEl || !px || isNaN(px)) return;
var clamped = Math.max(360, Math.min(window.innerWidth * 0.95, Math.round(px)));
sidebarEl.style.width = clamped + 'px';
rootStyle.setProperty('--viewer-sidebar-width', clamped + 'px');
}
var savedW = localStorage.getItem(SIDEBAR_W_KEY);
if (savedW && !isNaN(parseInt(savedW, 10))) {
applySidebarWidth(parseInt(savedW, 10));
} else if (sidebarEl) {
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
}
if (sidebarEl && 'ResizeObserver' in window) {
var sidebarResizeObserver = new ResizeObserver(function () {
var width = Math.round(sidebarEl.getBoundingClientRect().width);
rootStyle.setProperty('--viewer-sidebar-width', width + 'px');
localStorage.setItem(SIDEBAR_W_KEY, width);
});
sidebarResizeObserver.observe(sidebarEl);
}
if (sidebarResizeHandle && sidebarEl) {
var sidebarResizing = false;
var sidebarStartX = 0;
var sidebarStartWidth = 0;
var onSidebarPointerMove = function (event) {
if (!sidebarResizing) return;
var delta = sidebarStartX - event.clientX;
var nextWidth = sidebarStartWidth + delta;
applySidebarWidth(nextWidth);
};
var stopSidebarResize = function () {
if (!sidebarResizing) return;
sidebarResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
var width = Math.round(sidebarEl.getBoundingClientRect().width);
localStorage.setItem(SIDEBAR_W_KEY, width);
};
sidebarResizeHandle.addEventListener('pointerdown', function (event) {
event.preventDefault();
sidebarResizing = true;
sidebarStartX = event.clientX;
sidebarStartWidth = sidebarEl.getBoundingClientRect().width;
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
});
window.addEventListener('pointermove', onSidebarPointerMove);
window.addEventListener('pointerup', stopSidebarResize);
window.addEventListener('pointercancel', stopSidebarResize);
}
var persistHeight = function () {
localStorage.setItem(HEIGHT_KEY, Math.round(container.getBoundingClientRect().height));
};
if ('ResizeObserver' in window) {
var ro = new ResizeObserver(function () {
persistHeight();
});
ro.observe(container);
} else {
window.addEventListener('mouseup', persistHeight);
}
if (resizeHandle) {
resizeHandle.addEventListener('pointerdown', function (e) {
e.preventDefault();
});
}
function openViewerSidebar() {
if (!sidebarEl || !sidebarBody) return;
sidebarBody.appendChild(moveable);
sidebarEl.classList.add('open');
document.body.classList.add('viewer-sidebar-open');
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Close sidebar</span>';
sidebarBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
localStorage.setItem(MODE_KEY, 'sidebar');
}
function closeViewerSidebar() {
if (!sidebarEl) return;
moveableHome.appendChild(moveable);
sidebarEl.classList.remove('open');
document.body.classList.remove('viewer-sidebar-open');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>';
sidebarBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
localStorage.removeItem(MODE_KEY);
}
if (sidebarBtn) {
sidebarBtn.addEventListener('click', function () {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
} else {
openViewerSidebar();
}
});
}
if (sidebarCloseBtn) {
sidebarCloseBtn.addEventListener('click', closeViewerSidebar);
}
var bc = null;
try { bc = new BroadcastChannel('viva_load'); } catch (e) {}
function getViewerPayload() {
var namedStacks = mainViewerEl.getAttribute('data-named-stacks');
var images = mainViewerEl.getAttribute('data-images');
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : '';
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : '';
return {
type: 'open',
namedStacks: namedStacks || '',
images: images || '',
viewerstate: viewerState || '',
annotations: annotations || ''
};
}
function broadcastViewerState(payload) {
if (!payload) return;
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
if (bc) {
try { bc.postMessage(payload); } catch (e) {}
}
}
function showPopoutPlaceholder() {
var existing = panel.querySelector('.viewer-popout-placeholder');
if (existing) return;
var placeholder = document.createElement('div');
placeholder.className = 'alert alert-secondary small py-2 mb-0 mt-1 d-flex align-items-center gap-2 viewer-popout-placeholder';
placeholder.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Viewer is open in a separate window. <button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="viewer-popout-bring-back">Bring back</button>';
panel.insertBefore(placeholder, moveableHome);
moveableHome.style.display = 'none';
document.getElementById('viewer-popout-bring-back').addEventListener('click', closePopout);
}
function hidePopoutPlaceholder() {
var placeholder = panel.querySelector('.viewer-popout-placeholder');
if (placeholder) placeholder.remove();
moveableHome.style.display = '';
}
function openPopout() {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
} else {
openViewerSidebar();
}
});
}
if (sidebarCloseBtn) {
sidebarCloseBtn.addEventListener('click', closeViewerSidebar);
}
var bc = null;
try { bc = new BroadcastChannel('viva_load'); } catch (e) {}
function getViewerPayload() {
var namedStacks = mainViewerEl.getAttribute('data-named-stacks');
var images = mainViewerEl.getAttribute('data-images');
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : '';
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : '';
return {
type: 'open',
namedStacks: namedStacks || '',
images: images || '',
viewerstate: viewerState || '',
annotations: annotations || ''
};
}
function broadcastViewerState(payload) {
if (!payload) return;
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
if (bc) {
try { bc.postMessage(payload); } catch (e) {}
var payload = getViewerPayload();
broadcastViewerState(payload);
window.open('{% url "atlas:collection_viewer_local" %}', 'collectionTakeViewer', 'width=1200,height=900,noopener');
setTimeout(function () { broadcastViewerState(payload); }, 700);
localStorage.setItem(MODE_KEY, 'popout');
showPopoutPlaceholder();
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
}
function showPopoutPlaceholder() {
var existing = panel.querySelector('.viewer-popout-placeholder');
if (existing) return;
var placeholder = document.createElement('div');
placeholder.className = 'alert alert-secondary small py-2 mb-0 mt-1 d-flex align-items-center gap-2 viewer-popout-placeholder';
placeholder.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Viewer is open in a separate window. <button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="viewer-popout-bring-back">Bring back</button>';
panel.insertBefore(placeholder, moveableHome);
moveableHome.style.display = 'none';
document.getElementById('viewer-popout-bring-back').addEventListener('click', closePopout);
}
function hidePopoutPlaceholder() {
var placeholder = panel.querySelector('.viewer-popout-placeholder');
if (placeholder) placeholder.remove();
moveableHome.style.display = '';
}
function openPopout() {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
function closePopout() {
hidePopoutPlaceholder();
localStorage.removeItem(MODE_KEY);
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>';
popoutBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
}
var payload = getViewerPayload();
broadcastViewerState(payload);
window.open('{% url "atlas:collection_viewer_local" %}', 'collectionTakeViewer', 'width=1200,height=900,noopener');
setTimeout(function () { broadcastViewerState(payload); }, 700);
localStorage.setItem(MODE_KEY, 'popout');
showPopoutPlaceholder();
if (popoutBtn) {
popoutBtn.addEventListener('click', function () {
if (localStorage.getItem(MODE_KEY) === 'popout') {
closePopout();
} else {
openPopout();
}
});
}
var savedMode = localStorage.getItem(MODE_KEY);
if (savedMode === 'sidebar') {
openViewerSidebar();
} else if (savedMode === 'popout' && popoutBtn) {
showPopoutPlaceholder();
setTimeout(function () { broadcastViewerState(getViewerPayload()); }, 300);
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
function closePopout() {
hidePopoutPlaceholder();
localStorage.removeItem(MODE_KEY);
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>';
popoutBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
}
if (popoutBtn) {
popoutBtn.addEventListener('click', function () {
if (localStorage.getItem(MODE_KEY) === 'popout') {
closePopout();
} else {
openPopout();
}
});
}
var savedMode = localStorage.getItem(MODE_KEY);
if (savedMode === 'sidebar') {
openViewerSidebar();
} else if (savedMode === 'popout' && popoutBtn) {
showPopoutPlaceholder();
setTimeout(function () { broadcastViewerState(getViewerPayload()); }, 300);
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
@@ -4,6 +4,16 @@
{% block content %}
<h2>Case: {{ case.title }}</h2>
{% if casedetail.learner_comment %}
<div class="alert alert-info small">
<strong>Collection note:</strong> {{ casedetail.learner_comment|linebreaks }}
</div>
{% endif %}
{% if casedetail.learner_review_comment %}
<div class="alert alert-secondary small">
<strong>Review note:</strong> {{ casedetail.learner_review_comment|linebreaks }}
</div>
{% endif %}
{{ casedetail.question_schema }}
@@ -61,6 +61,7 @@
<div class="mb-2 w-100 text-md-end">
{% if collection.collection_type == "VIV" %}
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
<a href="{% url 'atlas:collection_viva_local' collection.pk %}" class="btn btn-outline-primary mb-2">Start Viva (Local)</a>
{% else %}
<div class="d-inline-block">
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
+39 -38
View File
@@ -1,45 +1,46 @@
{% if request.user.is_authenticated and collection %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Collection:</strong> {{ collection.name }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collectionNav" aria-controls="collectionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Collection:</strong> {{ collection.name }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collectionNav" aria-controls="collectionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="collectionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_history' collection.pk %}">History</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
</ul>
<div class="collapse navbar-collapse" id="collectionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_history' collection.pk %}">History</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="collectionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="collectionActions">
<li><a class="dropdown-item" href="{% url 'atlas:collection_case_update' collection.pk %}">Edit Cases</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_update' collection.id %}">Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_clone' collection.id %}">Clone</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
{% if request.user.is_superuser %}
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="collectionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="collectionActions">
<li><a class="dropdown-item" href="{% url 'atlas:collection_case_update' collection.pk %}">Edit Cases</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_update' collection.id %}">Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_clone' collection.id %}">Clone</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
{% endif %}
</ul>
</li>
</ul>
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva_local' collection.pk %}">View as viva (local)</a></li>
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
{% endif %}
</ul>
</li>
</ul>
</div>
</div>
</div>
</nav>
</nav>
{% endif %}
@@ -45,7 +45,7 @@
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary"
href="{% url 'atlas:collection_case_view' pk=collection.id case_number=forloop.counter0 %}">
href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=forloop.counter0 user_pk=user.pk %}">
Open Case
</a>
{% if request.user.is_superuser and row.user_answer %}
@@ -89,48 +89,51 @@
</div>
</section>
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
{% partialdef sharing-info inline %}
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared - collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
- you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they do not have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
— you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they don't have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
{% endpartialdef %}
{% else %}
<section class="take-start-card">
<p class="mb-0">
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
@@ -51,6 +52,18 @@
</div>
</div>
{% if post_survey_url %}
<div class="alert alert-info border-info bg-dark bg-opacity-25 my-3 d-flex align-items-center justify-content-between" role="alert">
<div>
<h5 class="alert-heading text-info mb-1"><i class="bi bi-patch-question me-2"></i>Post-Collection Feedback</h5>
<p class="mb-0 small text-muted">Please take a moment to complete the feedback survey for this case collection.</p>
</div>
<a href="{{ post_survey_url }}" class="btn btn-info btn-sm text-dark fw-semibold px-4">
Take Survey <i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
{% endif %}
<div class="list-group mb-3">
{% for question, answer, self_review, review_stats, timing_state in question_answer_tuples %}
<div class="list-group-item {% if review_stats.has_outstanding_feedback %}border-warning border-2{% elif not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
@@ -86,6 +99,19 @@
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2">
{% if request.user.is_staff or request.user.is_superuser or request.user in collection.author.all %}
{% if answer %}
{% if answer.user %}
<a href="{% url 'generic:view_answer_history' app_name='atlas' model_name='UserReportAnswer' pk=answer.pk %}" class="btn btn-sm btn-outline-info">
History
</a>
{% else %}
<a href="{% url 'generic:view_answer_history' app_name='atlas' model_name='CidReportAnswer' pk=answer.pk %}" class="btn btn-sm btn-outline-info">
History
</a>
{% endif %}
{% endif %}
{% endif %}
<a class="btn btn-sm btn-primary"
href="{% if cid is not None %}{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}{% endif %}">
Open Case
@@ -91,49 +91,7 @@
</div>
</section>
{# ---- Who can see your results ---- #}
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
— you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they don't have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
{% include "atlas/collection_review_start.html#sharing-info" %}
{% else %}
<section class="take-start-card">
<p class="mb-3">
+14 -13
View File
@@ -1,22 +1,23 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex align-items-start justify-content-between mb-3">
<h2 class="mb-0">Collections</h2>
{% if request.user.is_staff %}
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
{% endif %}
<div class="container-fluid">
<div class="d-flex align-items-start justify-content-between mb-3">
<h2 class="mb-0">Collections</h2>
{% if request.user.is_staff %}
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
{% endif %}
</div>
<p class="mb-3">Manage and browse case collections. Use filters to narrow results.</p>
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div>
<p class="mb-3">Manage and browse case collections. Use filters to narrow results.</p>
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div>
{% endblock %}
+1 -1
View File
@@ -35,7 +35,7 @@
<a href="{% url 'atlas:case_detail' case.pk %}" style="color: inherit; text-decoration: none;">View Case</a>
</button>
<button class="edit-link-case btn btn-outline-warning btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:case_update' case.pk %}" style="color: inherit; text-decoration: none;">Edit Case</a>
<a href="{% url 'atlas:case_update' case.pk %}?next={{ request.get_full_path|urlencode }}" style="color: inherit; text-decoration: none;">Edit Case</a>
</button>
<br />
<div class="pre-whitespace multi-image-block">
@@ -0,0 +1,449 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<div class="container py-3 viva-local-page">
<div class="card border-secondary-subtle shadow-sm mb-3">
<div class="card-body d-flex flex-wrap gap-2 justify-content-between align-items-center">
<div>
<h1 class="h4 mb-1">Viva Cases (Local Viewer)</h1>
<p class="text-muted mb-0">Load full cases, single series, resources, or display sets into a linked local DV3D window.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<button id="open-viewer-local" class="btn btn-primary">Open Viewer Local</button>
<button id="view-series" class="btn btn-outline-secondary">Toggle series</button>
<button id="toggle-edit-mode" class="btn btn-outline-warning">Enable Edit Mode</button>
</div>
</div>
</div>
<details class="help-text card border-secondary-subtle shadow-sm mb-3">
<summary class="card-header bg-body-secondary d-flex align-items-center gap-2"><i class="bi bi-info-circle"></i> Quick help</summary>
<div class="card-body">
<p class="mb-2">Open the linked viewer first, then use the Load controls on this page. Loading a new case/series replaces the current viewer content.</p>
<p class="mb-0">Use Edit Mode to reveal direct links for case, series, and display-set editing without leaving the viva workflow.</p>
</div>
</details>
<div class="row g-3">
<div class="col-lg-7">
<h2 class="h5 mb-2">Cases</h2>
<ol class="sortable list-unstyled d-flex flex-column gap-3">
{% for casedetail in casedetails %}
{% with case=casedetail.case %}
<li data-question_pk="{{ case.pk }}">
<article class="card border-secondary-subtle shadow-sm case-item"
data-title="{{ case.title }}"
data-case="{{ case.pk }}"
data-casejson="{{ case.viva_details_json|urlencode }}"
data-images="{{ case.series_images_nested_json|urlencode }}"
data-viewerstate="{{ casedetail.default_viewerstate_string|urlencode }}">
<header class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<span class="badge bg-primary me-2">Case {{ forloop.counter }}</span>
<span class="fw-semibold">{{ case.title }}</span>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="open-case-local btn btn-sm btn-success" data-target="{{ case.pk }}" data-type="case">Load Case</button>
<button class="view-link-case btn btn-outline-primary btn-sm" style="display:none;">
<a href="{% url 'atlas:case_detail' case.pk %}" style="color: inherit; text-decoration: none;">View Case</a>
</button>
<button class="edit-link-case btn btn-outline-warning btn-sm" style="display:none;">
<a href="{% url 'atlas:case_update' case.pk %}?next={{ request.get_full_path|urlencode }}" style="color: inherit; text-decoration: none;">Edit Case</a>
</button>
</div>
</header>
<div class="card-body d-flex flex-column gap-3">
<details class="series-detail">
<summary class="fw-semibold">Series</summary>
<div class="mt-2 d-flex flex-column gap-2">
{% for series in case.visible_prefetched_series %}
<div class="border rounded p-2 bg-body-tertiary">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="small">
<div class="fw-semibold">Series {{ forloop.counter }}</div>
<div class="text-muted">
{% if series.modality %}{{ series.modality }}{% endif %}
{% if series.examination %} · {{ series.examination }}{% endif %}
{% if series.plane %} · {{ series.plane }}{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="open-series-local btn btn-sm btn-outline-success"
data-type="series"
data-series="{{ forloop.counter }}"
data-case="{{ case.pk }}"
data-target="{{ series.pk }}">
Load Series
</button>
<button class="edit-link-series btn btn-outline-warning btn-sm" style="display:none;">
<a href="{% url 'atlas:series_update' series.pk %}" style="color: inherit; text-decoration: none;">Edit Series</a>
</button>
</div>
</div>
</div>
{% empty %}
<div class="text-muted small">No series linked.</div>
{% endfor %}
</div>
</details>
{% if case.prefetched_case_resources %}
<div>
<div class="fw-semibold mb-1">Resources</div>
<div class="d-flex flex-column gap-1">
{% for cr in case.prefetched_case_resources %}
{% with res=cr.resource %}
<div class="d-flex flex-wrap align-items-center gap-2 small">
<span>{{ res.name }}</span>
<button class="open-resource btn btn-sm btn-outline-primary" 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.prefetched_display_sets %}
<details class="displayset-detail">
<summary class="fw-semibold">Display Sets</summary>
<div class="mt-2 d-flex flex-column gap-2">
{% for ds in case.prefetched_display_sets %}
<div class="border rounded p-2 bg-body-tertiary d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="small">
<div class="fw-semibold">{{ ds.name }}</div>
{% if ds.description %}<div class="text-muted">{{ ds.description }}</div>{% endif %}
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="open-displayset btn btn-sm btn-outline-info"
data-type="displayset"
data-displayset="{{ ds.pk }}"
data-case="{{ case.pk }}"
data-title="{{ ds.name }}"
data-casejson="{{ case.viva_details_json|urlencode }}"
data-images="{{ case.series_images_nested_json|urlencode }}"
data-viewerstate="{{ ds.viewerstate_string|urlencode }}"
data-annotations="{{ ds.annotations_string|urlencode }}">
Load Display Set
</button>
<button class="edit-link-displayset btn btn-outline-warning btn-sm" style="display:none;">
<a href="{% url 'atlas:case_displaysets' case.pk %}?displayset={{ ds.pk }}&edit=true" style="color: inherit; text-decoration: none;">Edit Display Set</a>
</button>
</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
<div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div>
</div>
</article>
</li>
{% endwith %}
{% endfor %}
</ol>
</div>
<div class="col-lg-5">
<div class="sticky-top pt-2">
<div class="card border-secondary-subtle shadow-sm">
<div class="card-header"><h2 class="h5 mb-0">Current Case</h2></div>
<div class="card-body" id="case-details">
<div id="user-message" class="alert py-2" style="display:none;"></div>
<div id="loading-case" class="alert alert-warning py-2" style="display:none;">Waiting for case to load in linked window.</div>
<div id="current-case-title" class="mb-2">None</div>
<div id="current-case-series" class="mb-2"></div>
<div id="current-case-history" class="mb-2"></div>
<div id="current-case-discussion" class="mb-2"></div>
<div id="current-case-report" class="mb-2"></div>
<div id="current-case-questions" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script type="text/javascript">
var win2 = false;
let editMode = false;
let viewerConnected = false;
$(document).ready(function() {
const showUserMessage = function(message, level = 'warning') {
const $msg = $('#user-message');
$msg
.removeClass('alert-warning alert-danger alert-success alert-info')
.addClass('alert-' + level)
.text(message)
.show();
};
const clearUserMessage = function() {
$('#user-message').hide().text('');
};
const parseEncodedJson = function(raw, label) {
if (!raw) {
return {};
}
try {
return JSON.parse(decodeURIComponent(raw));
} catch (err) {
throw new Error('Unable to parse ' + label + ' payload.');
}
};
const ensureViewerWindowReady = function() {
if (!win2 || win2.closed) {
showUserMessage('Viewer is not open. Click "Open Viewer Local" before loading a case or series.', 'warning');
return false;
}
clearUserMessage();
return true;
};
// Edit mode toggle
$('#toggle-edit-mode').click(function() {
editMode = !editMode;
if (editMode) {
$('.edit-link-case').show();
$('.view-link-case').show();
$('.edit-link-series').show();
$('.edit-link-displayset').show();
$(this).text('Disable Edit Mode').removeClass('btn-warning').addClass('btn-danger');
} else {
$('.edit-link-case').hide();
$('.view-link-case').hide();
$('.edit-link-series').hide();
$('.edit-link-displayset').hide();
$(this).text('Enable Edit Mode').removeClass('btn-danger').addClass('btn-warning');
}
});
const bc = new BroadcastChannel("viva_load");
bc.onmessage = (event) => {
console.log("message received");
console.log(event);
if (event.data.type == "open-complete-local") {
viewerConnected = true;
$("#loading-case").hide();
$("#open-viewer-local").removeClass("flash-button");
clearUserMessage();
//bc.postMessage({"type": "open-complete-local"});
}
};
console.log('ready')
$('.show-collection, .show-case, .show-series').click(function() {
var url = $(this).data('target');
console.log('clicked', url)
$('#case-title').text($(this).data('title'));
$('#viewer').attr('src', url)
});
$('.open-case-local').click(function() {
try {
if (!ensureViewerWindowReady()) {
return;
}
let c = this.closest(".case-item");
console.log('clicked local', c)
console.log('open local', c.dataset.case)
casedetails = parseEncodedJson(c.dataset.casejson, 'case details');
let viewerstate = parseEncodedJson(c.dataset.viewerstate, 'viewer state') || {};
let images = decodeURIComponent(c.dataset.images || '[]');
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+(casedetails['history'] || ''));
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+(casedetails['discussion'] || ''));
$("#current-case-report").html("<span class='title'>Report:</span> "+(casedetails['report'] || ''));
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": images,
"viewerstate": viewerstate
});
// Inject the per-case question snippet into the current case panel
var snippet = $(c).find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.error('Case load failed', e);
showUserMessage('Case load failed: ' + (e.message || 'Unexpected error while parsing case data.'), 'danger');
}
});
$('.open-displayset').click(function() {
try {
if (!ensureViewerWindowReady()) {
return;
}
let c = this;
console.log('clicked local', c)
console.log('open local', c.dataset.case)
casedetails = parseEncodedJson(c.dataset.casejson, 'display set case details');
let viewerstate = parseEncodedJson(c.dataset.viewerstate, 'display set viewer state') || {};
let annotations = parseEncodedJson(c.dataset.annotations, 'display set annotations') || {};
let images = decodeURIComponent(c.dataset.images || '[]');
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+(casedetails['history'] || ''));
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+(casedetails['discussion'] || ''));
$("#current-case-report").html("<span class='title'>Report:</span> "+(casedetails['report'] || ''));
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": images,
"viewerstate": viewerstate,
"annotations": annotations
});
// Inject the per-case question snippet
var caseItem = $(this).closest('.case-item');
var snippet = caseItem.find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.error('Display set load failed', e);
showUserMessage('Display set load failed: ' + (e.message || 'Unexpected error while parsing display set data.'), 'danger');
}
});
$('.open-series-local').click(function() {
try {
if (!ensureViewerWindowReady()) {
return;
}
let c = $(this).closest(".case-item")[0];
let seriesNumber = $(this).data('series');
let seriesPk = $(this).data('target');
let caseDetails = parseEncodedJson(c.dataset.casejson, 'series case details');
let allSeriesImages = parseEncodedJson(c.dataset.images, 'series images');
let images = allSeriesImages[seriesNumber - 1] || [];
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> " + c.dataset.title);
$('#current-case-series').html("<span class='title'>Series:</span> " + seriesNumber);
$("#current-case-history").html("<span class='title'>History:</span> " + (caseDetails['history'] || ''));
$("#current-case-discussion").html("<span class='title'>Discussion:</span> " + (caseDetails['discussion'] || ''));
$("#current-case-report").html("<span class='title'>Report:</span> " + (caseDetails['report'] || ''));
// Send only the images for this series
bc.postMessage({
"type": "open",
"case": c.dataset.case,
"series": seriesPk,
"images": JSON.stringify(images),
});
// Inject the per-case question snippet
var snippet = $(c).find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.error('Series load failed', e);
showUserMessage('Series load failed: ' + (e.message || 'Unexpected error while parsing series data.'), 'danger');
}
});
// Load resource into linked viewer
$('.open-resource').click(function() {
try {
if (!ensureViewerWindowReady()) {
return;
}
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});
} catch (e) {
console.error('Resource load failed', e);
showUserMessage('Resource load failed: ' + (e.message || 'Unexpected error.'), 'danger');
}
});
$('.send-message').click(function() {
var url = $(this).data('target');
bc.postMessage(url);
})
$('#open-viewer-local').click(function() {
$("#open-viewer-local").removeClass("flash-button");
viewerConnected = false;
try {
localStorage.removeItem('rad_local_viewer_popout_state');
} catch (e) {
console.warn('Unable to clear previous viewer state', e);
}
const opened = openSecondaryWindow("{% url 'atlas:collection_viewer_local_progressive' %}");
if (!opened) {
showUserMessage('Unable to open viewer window. Please allow pop-ups for this site and try again.', 'danger');
return;
}
showUserMessage('Viewer window opened. Load a case to begin. If nothing appears, check pop-up blockers and re-open the viewer.', 'info');
//bc.postMessage(url);
})
$('#view-series').click(function() {
$(".series-detail").attr("open", (_, attr) => !attr);
//document.getElementsByClassName("series-detail").toggleAttribute("open");
//bc.postMessage(url);
})
});
function openSecondaryWindow(url) {
//return win2 = window.open(url,'secondary','width=300,height=100');
win2 = window.open(
url,
'secondary',
'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=1200,height=800'
);
return win2;
}
</script>
{{ form.media }}
{% endblock %}
{% block css %}
<style type="text/css">
#loading-case {
display: none;
color: red;
}
@keyframes glowing {
0% {
box-shadow: 0 0 20px #a600ff;
}
50% {
box-shadow: 0 0 40px #52057b;
}
100% {
box-shadow: 0 0 20px #a600ff;
}
}
.flash-button {
animation: glowing 1300ms infinite;
}
#case-details {
padding-bottom: 20px;
}
.title {
font-weight: bold;
}
.edit-link-case, .edit-link-series, .edit-link-displayset {
vertical-align: middle;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
{% endblock css %}
@@ -54,6 +54,20 @@
{% endfor %}
</dd>
{% if can_merge %}
<dt class="col-sm-4">Edit relationships</dt>
<dd class="col-sm-8">
<p>
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#relationshipCollapse" role="button" aria-expanded="false" aria-controls="relationshipCollapse">Show relationship editor</a>
</p>
<div class="collapse" id="relationshipCollapse">
<div class="card card-body">
{% include 'atlas/partials/_condition_relationships.html' %}
</div>
</div>
</dd>
{% endif %}
<dt class="col-sm-4">RCR condition</dt>
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
+46 -10
View File
@@ -38,6 +38,11 @@
<h5>Viva</h5>
<p>A Viva collection is intended for oral examination practice. Cases in this collection can be used to simulate viva-style assessments. These are designed to be presented by the "examiner" and features a number of features to make this as easy as possible.</p>
<h5>Viva (Local Viewer)</h5>
<p>The local viva view opens a linked DV3D viewer in a separate window and lets you load complete cases, individual series, resources, and display sets without leaving the control page.</p>
<p>Use "Open Viewer Local" first, then use the Load buttons in the cases panel. The right-hand current-case panel tracks the active case details and injected question block for discussion.</p>
<p>Use "Enable Edit Mode" in the local viva page when you need quick access to case, series, or display-set edit links during preparation.</p>
<h2>Uploading/creating cases</h2>
<p>If set up, the easiest way to upload cases is to use the uploader tool. This can export, anonnyimize and upload cases from Insignia Insight, however it may need setting up manually. If this is not possible you can upload files directly to the site.</p>
<div class="alert alert-warning" role="alert">
@@ -56,19 +61,50 @@
<p>To do so you will have to first export the files from the PACs system.</p>
<p>Once uploaded the series will be available to be imported into cases
<h2>Cases</h2>
<h3>Ordering dicoms within a series</h3>
<p>Depending on how dicoms are exported images within a stack may appear in the wrong order. This is apparent when viewing the stacks/series as jumping of images.</p>
<p>In most cases this can be fixed by re-ordering based on series metadata (usually slice location).</p>
<p>It is possible to reorder all the stacks in a case at once, or to reorder individual stacks.</p>
<h4>Reorder all stacks in a case</h4>
<p>Go to the case page, click "Manage Series" then "Order dicoms by slice location". This will reorder all the stacks in the case based on slice location.</p>
<h4>Reorder individual stacks</h4>
<p>More ordering options (if the above does not work) are available on the series page.</p>
<p>These can be accessed by opening the "Series info" section and selecting the appropriate button.</p>
<h2>Cases</h2>
<h3>Ordering dicoms within a series</h3>
<p>Depending on how dicoms are exported images within a stack may appear in the wrong order. This is apparent when viewing the stacks/series as jumping of images.</p>
<p>In most cases this can be fixed by re-ordering based on series metadata (usually slice location).</p>
<p>It is possible to reorder all the stacks in a case at once, or to reorder individual stacks.</p>
<h4>Reorder all stacks in a case</h4>
<p>Go to the case page, click "Manage Series" then "Order dicoms by slice location". This will reorder all the stacks in the case based on slice location.</p>
<h4>Reorder individual stacks</h4>
<p>More ordering options (if the above does not work) are available on the series page.</p>
<p>These can be accessed by opening the "Series info" section and selecting the appropriate button.</p>
<h3>Surveys & Feedback</h3>
<p>The platform supports reusable, shareable, and mandatory feedback forms and surveys that can be configured by administrators to assess educational improvement and collect feedback.</p>
<h5>Managing Surveys (Staff/Admins)</h5>
<ul>
<li><strong>Create Surveys:</strong> Navigate to the <em>Survey Dashboard</em> to create a survey, customize its name, description, decide if responses should be anonymous, and specify:
<ul>
<li><em>Before Text:</em> Custom instructions shown to candidates before they begin.</li>
<li><em>After Text:</em> Custom thank-you message shown upon submission.</li>
</ul>
</li>
<li><strong>Question Types & Validation:</strong> You can add questions of multiple types:
<ul>
<li><em>Free Text:</em> For qualitative comments.</li>
<li><em>Multiple Choice:</em> For single-select options (entered one option per line).</li>
<li><em>Rating Scale:</em> 1-to-5 Likert scale ratings.</li>
<li><em>Integer / Number:</em> Enforces numerical input. You can set custom minimum/maximum validation rules.</li>
<li><em>Date:</em> Enforces date input using a calendar picker. You can configure custom minimum/maximum date range limits.</li>
</ul>
</li>
<li><strong>Mandatory & Optional Flows:</strong> You can assign surveys as <em>Pre-take</em> or <em>Post-take</em> surveys in collection/exam edit screens:
<ul>
<li><em>Mandatory:</em> Candidates must fill out the pre-survey to start, and must fill out the post-survey before they can view their results/scores.</li>
<li><em>Optional:</em> Candidates will be prompted to fill out the surveys, but can choose to skip them using the "Skip Survey" option.</li>
</ul>
</li>
<li><strong>Analytics & CSV Export:</strong> The <em>Analytics Dashboard</em> displays rating averages, progress bar breakdowns, integer statistics, and text answers. You can filter results by survey source (Pre-take, Post-take, or Standalone) and target exams/collections. Results can be exported as a filtered CSV.</li>
</ul>
<h5>Taking Standalone/General Surveys</h5>
<p>Surveys can also be distributed as standalone links (e.g. <code>/survey/take/&lt;id&gt;/</code>) for general platform feedback or course evaluation not tied to a specific exam.</p>
{% endblock %}
@@ -1,4 +1,5 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
@@ -15,9 +16,204 @@
{% if direct_next %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_next.pk %}">Next in chain</a>
{% endif %}
{% if can_edit_chain and not show_case_editor %}
<a class="btn btn-outline-info" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_editor=1">Open case series editor</a>
{% endif %}
{% if show_case_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide case series editor</a>
{% endif %}
{% if can_edit_chain and not show_study_dates_editor %}
<a class="btn btn-outline-primary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_dates=1">Open study dates editor</a>
{% endif %}
{% if show_study_dates_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide study dates editor</a>
{% endif %}
{% if can_edit_chain and not show_reorder_editor %}
<a class="btn btn-outline-warning" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_reorder=1">Open case reorder editor</a>
{% endif %}
{% if show_reorder_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide case reorder editor</a>
{% endif %}
</div>
</div>
{% if can_edit_chain and show_study_dates_editor %}
<div class="card border-secondary-subtle shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h5 mb-1">Study Dates</h2>
<p class="text-muted mb-0">Edit each case date directly, or use the copy action to apply one case date to the rest of the linked chain.</p>
</div>
{% if anchor_date %}
<div class="small text-muted">Relative offsets are measured from {{ anchor_date|date:"Y-m-d" }}.</div>
{% endif %}
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="form_action" value="study_dates">
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">Case</th>
<th scope="col" class="text-nowrap">Study date</th>
<th scope="col" class="text-nowrap">Relative</th>
<th scope="col" class="text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{% for row in linked_case_rows %}
<tr class="{% if row.is_current %}table-primary{% endif %}">
<td>
<div class="fw-semibold">{{ row.case.title|default:"Untitled case" }}</div>
<div class="small text-muted">Case {{ row.case.pk }}{% if row.is_current %} · current{% endif %}</div>
</td>
<td style="min-width: 14rem;">
<input
type="date"
class="form-control form-control-sm"
name="study_date_{{ row.case.pk }}"
value="{% if row.study_date %}{{ row.study_date|date:'Y-m-d' }}{% endif %}"
{% if not row.can_edit %}disabled{% endif %}
>
</td>
<td class="text-nowrap">
{% if row.relative_label %}
{{ row.relative_label.text }}
{% else %}
<span class="text-muted">No anchor</span>
{% endif %}
</td>
<td class="text-nowrap">
<button
type="submit"
class="btn btn-sm btn-outline-info"
name="apply_to_all_from"
value="{{ row.case.pk }}"
{% if not row.can_edit %}disabled{% endif %}
>
Copy to others
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2 mt-3">
<button type="submit" class="btn btn-primary">Save study dates</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_edit_chain and show_reorder_editor %}
<div class="card border-warning-subtle shadow-sm mb-4">
<div class="card-body">
<h2 class="h5 mb-1 text-warning-emphasis">Reorder Cases</h2>
<p class="text-muted mb-3">Drag and drop cases to reorder them, or use the Up/Down buttons, then click Save.</p>
<form method="post" id="reorder-cases-form">
{% csrf_token %}
<input type="hidden" name="form_action" value="reorder_cases">
<ul id="cases-reorder-list" class="list-group mb-3">
{% for linked_case in chain_cases %}
<li class="list-group-item d-flex justify-content-between align-items-center {% if linked_case.pk == current_case.pk %}border-primary{% endif %}" data-case-id="{{ linked_case.pk }}">
<span>
<i class="bi bi-grip-vertical me-2 text-muted drag-handle" style="cursor: grab;"></i>
<span class="fw-semibold">Case {{ linked_case.pk }}:</span> {{ linked_case.title|default:"Untitled case" }}
{% if linked_case.pk == current_case.pk %}<span class="badge bg-primary ms-1">Current</span>{% endif %}
</span>
<span>
<button type="button" class="btn btn-sm btn-outline-secondary move-up"></button>
<button type="button" class="btn btn-sm btn-outline-secondary move-down"></button>
</span>
<input type="hidden" name="case_order" value="{{ linked_case.pk }}">
</li>
{% endfor %}
</ul>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-warning">Save Case Order</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if show_case_editor and case_editor_formset %}
<div class="card border-info-subtle shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h5 mb-1">Case Series Editor</h2>
<p class="text-muted mb-0">Edit full case details across this linked chain in one pass, then optionally copy a single field from one case to every other case.</p>
</div>
<div class="small text-muted">
Editable: {{ editable_case_count }}
{% if locked_case_count %} · Read-only: {{ locked_case_count }}{% endif %}
</div>
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="form_action" value="case_series_editor">
{{ case_editor_formset.management_form }}
<div class="row g-2 align-items-end mb-3">
<div class="col-md-4">
<label class="form-label form-label-sm mb-1">Copy field from case</label>
<select class="form-select form-select-sm" name="apply_from_case">
<option value="">No field propagation</option>
{% for row in case_editor_rows %}
<option value="{{ row.case.pk }}" {% if case_editor_selected_source == row.case.pk|stringformat:'s' %}selected{% endif %}>
{{ row.case.pk }} · {{ row.case.title|default:"Untitled case" }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<label class="form-label form-label-sm mb-1">Field to copy to all linked cases</label>
<select class="form-select form-select-sm" name="apply_field">
<option value="">Choose a field</option>
{% for value, label in case_editor_apply_field_choices %}
<option value="{{ value }}" {% if case_editor_selected_field == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<div class="small text-muted">Apply runs after saving any edits you make below.</div>
</div>
</div>
<div class="accordion" id="linked-case-editor-accordion">
{% for row in case_editor_rows %}
<div class="accordion-item mb-2 border-secondary-subtle">
<h2 class="accordion-header" id="editor-heading-{{ row.case.pk }}">
<button class="accordion-button {% if not row.is_current %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#editor-body-{{ row.case.pk }}" aria-expanded="{% if row.is_current %}true{% else %}false{% endif %}" aria-controls="editor-body-{{ row.case.pk }}">
<span class="fw-semibold">Case {{ row.case.pk }} · {{ row.case.title|default:"Untitled case" }}</span>
{% if row.is_current %}<span class="badge text-bg-primary ms-2">Current</span>{% endif %}
</button>
</h2>
<div id="editor-body-{{ row.case.pk }}" class="accordion-collapse collapse {% if row.is_current %}show{% endif %}" aria-labelledby="editor-heading-{{ row.case.pk }}" data-bs-parent="#linked-case-editor-accordion">
<div class="accordion-body">
{{ row.form|crispy }}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-info">Save case series editor changes</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="row g-3 mb-4">
<div class="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100">
@@ -87,7 +283,7 @@
{% if previous_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in previous_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Earlier in the linked chain" is_current=False %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Earlier in the linked chain" relative_label=linked_case.relative_label is_current=False %}
{% endfor %}
</div>
{% else %}
@@ -105,7 +301,7 @@
{% if next_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in next_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Later in the linked chain" is_current=False %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Later in the linked chain" relative_label=linked_case.relative_label is_current=False %}
{% endfor %}
</div>
{% else %}
@@ -116,4 +312,92 @@
</div>
</div>
</div>
{% endblock %}
{% block css %}
<style>
#cases-reorder-list .dragging {
opacity: 0.55;
box-shadow: 0 4px 16px rgba(255, 193, 7, 0.35);
}
#cases-reorder-list .drag-over {
border: 2px dashed #ffc107 !important;
}
</style>
{% endblock %}
{% block js %}
<script>
document.addEventListener("DOMContentLoaded", function() {
// Move up/down logic
document.querySelectorAll("#cases-reorder-list .move-up, #cases-reorder-list .move-down").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.preventDefault();
const li = btn.closest("li");
if (btn.classList.contains("move-up") && li.previousElementSibling) {
li.parentNode.insertBefore(li, li.previousElementSibling);
}
if (btn.classList.contains("move-down") && li.nextElementSibling) {
li.parentNode.insertBefore(li.nextElementSibling, li);
}
});
});
// On submit, update the hidden inputs to match the new order
const reorderCasesForm = document.getElementById("reorder-cases-form");
if (reorderCasesForm) {
reorderCasesForm.addEventListener("submit", function(e) {
const lis = document.querySelectorAll("#cases-reorder-list li");
lis.forEach((li, idx) => {
const input = li.querySelector("input[name='case_order']");
if (input) {
input.setAttribute("name", "case_order_" + idx);
}
});
});
}
// Drag and drop reordering for cases list
const reorderList = document.getElementById("cases-reorder-list");
let draggedItem = null;
if (reorderList) {
reorderList.querySelectorAll("li").forEach(function(li) {
li.setAttribute("draggable", "true");
li.addEventListener("dragstart", function(e) {
draggedItem = li;
li.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
li.addEventListener("dragend", function() {
draggedItem = null;
li.classList.remove("dragging");
reorderList.querySelectorAll("li").forEach(el => el.classList.remove("drag-over"));
});
li.addEventListener("dragover", function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
li.classList.add("drag-over");
});
li.addEventListener("dragleave", function() {
li.classList.remove("drag-over");
});
li.addEventListener("drop", function(e) {
e.preventDefault();
li.classList.remove("drag-over");
if (draggedItem && draggedItem !== li) {
const rect = li.getBoundingClientRect();
const next = (e.clientY - rect.top) / (rect.bottom - rect.top) > 0.5;
li.parentNode.insertBefore(draggedItem, next ? li.nextElementSibling : li);
}
});
});
}
});
</script>
{% endblock %}
@@ -0,0 +1,27 @@
{% if largest_rows %}
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>Case</th>
<th class="text-end">Series</th>
<th class="text-end">Total size</th>
</tr>
</thead>
<tbody>
{% for row in largest_rows %}
<tr>
<td>
<a href="{% url 'atlas:case_detail' row.case.pk %}">{{ row.case.title }}</a>
<div class="text-muted small">#{{ row.case.pk }}</div>
</td>
<td class="text-end">{{ row.series_count }}</td>
<td class="text-end fw-semibold">{{ row.human_total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small">No case-size data available.</div>
{% endif %}
@@ -0,0 +1,33 @@
{% if recent_cases %}
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>Case</th>
<th>Created</th>
<th>Authors</th>
</tr>
</thead>
<tbody>
{% for case in recent_cases %}
<tr>
<td>
<a href="{% url 'atlas:case_detail' case.pk %}">{{ case.title }}</a>
<div class="text-muted small">#{{ case.pk }}</div>
</td>
<td class="small">{{ case.created_date|date:"Y-m-d H:i" }}</td>
<td class="small">
{% for author in case.author.all %}
<a class="badge text-bg-secondary me-1 text-decoration-none" href="{% url 'atlas:case_view' %}?author={{ author.pk }}">{{ author.username }}</a>
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small">No recent cases found.</div>
{% endif %}
@@ -16,6 +16,13 @@
{% for ss in case.subspecialty.all %}
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
{% endfor %}
{% with case.get_series_info as series_info %}
{% if series_info.in_series %}
<span class="badge bg-info text-dark ms-1" title="Linked case series position: {{ series_info.position }} of {{ series_info.total }}">
Series ({{ series_info.position }}/{{ series_info.total }})
</span>
{% endif %}
{% endwith %}
</div>
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
</div>
@@ -0,0 +1,13 @@
<form method="post" class="condition-relationship-form">
{% csrf_token %}
<input type="hidden" name="action" value="update_relationships">
<div class="mb-3">
<label class="form-label" for="id_parents">Parents</label>
{{ relationship_form.parents }}
</div>
<div class="mb-3">
<label class="form-label" for="id_children">Children</label>
{{ relationship_form.children }}
</div>
<button class="btn btn-sm btn-primary" type="submit">Save relationships</button>
</form>
@@ -29,7 +29,11 @@
{% endif %}
</div>
<div class="text-sm-end small text-muted">
{% if linked_case.published_date %}
{% if relative_label %}
<div>Timeline {{ relative_label.text }}</div>
{% elif linked_case.get_effective_study_date %}
<div>Study date {{ linked_case.get_effective_study_date|date:"Y-m-d" }}</div>
{% elif linked_case.published_date %}
<div>Published {{ linked_case.published_date|date:"Y-m-d" }}</div>
{% else %}
<div>Created {{ linked_case.created_date|date:"Y-m-d" }}</div>
@@ -6,7 +6,8 @@
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">{{case.title}}</h5>
<div>
<div class="d-flex gap-1">
<span class="badge {% if relation_type == 'PR' %}bg-info{% else %}bg-warning text-dark{% endif %}">{% if relation_type == 'PR' %}Prior{% else %}Future{% endif %}</span>
{% if added %}
<span class="badge bg-success">Added</span>
{% else %}
@@ -15,7 +16,17 @@
</div>
</div>
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
{% if relation %}
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
{% endif %}
<p class="card-text small text-muted mb-2">
Study date:
{% if case.study_date %}
<span class="fw-medium">{{ case.study_date }}</span>
{% else %}
<span class="fw-medium">Unknown</span>
{% endif %}
</p>
<div class="mb-3 small">{{case.get_series_blocks|safe}}</div>
@@ -37,6 +48,12 @@
<div class="col-12">
<input class="form-control form-control-sm" type="text" name="relation" value="{{relation}}" placeholder="Relation to case" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="use_study_date_relation" value="1" id="prior_use_date_{{case.id}}" />
<label class="form-check-label small" for="prior_use_date_{{case.id}}">Use study dates to generate relation text</label>
</div>
</div>
<div class="col-8">
<select class="form-select form-select-sm" name="prior_visibility" id="prior_visibility_{{case.id}}">
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
@@ -1,6 +1,6 @@
<li class="series-item {% if series_item_classes %}{{ series_item_classes }}{% endif %}">
<div class="series-left">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}"{% if tags.StudyInstanceUID %} data-study-uid="{{ tags.StudyInstanceUID }}"{% elif tags.StudyID %} data-study-uid="{{ tags.StudyID }}"{% endif %}>
<h5 class="series-title">
{{ tags.SeriesDescription|default:series }}
{% if series_is_partial %}
@@ -1,10 +1,10 @@
{% load django_htmx %}
{# Sharing panel for a CidUserExam attempt.
{% comment %} Sharing panel for a CidUserExam attempt.
Context:
cid_user_exam CidUserExam instance
sharing_info dict from cid_user_exam.get_sharing_info()
is_owner bool: current user is the attempt owner
#}
{% endcomment %}
<div id="sharing-panel-{{ cid_user_exam.pk }}" class="card border-secondary mt-3">
<div class="card-header py-2 px-3 d-flex justify-content-between align-items-center">
<span class="fw-semibold small">Result Sharing</span>
@@ -107,7 +107,7 @@
name="q"
autocomplete="off"
hx-get="{% url 'atlas:collection_exam_user_search' cid_user_exam.pk %}"
hx-trigger="input changed delay:300ms, focus"
hx-trigger="input delay:300ms, focus"
hx-target="#sharing-search-results-{{ cid_user_exam.pk }}"
hx-swap="innerHTML">
<div id="sharing-search-results-{{ cid_user_exam.pk }}" class="mt-1"></div>
@@ -0,0 +1,42 @@
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared - collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
- you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they do not have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
@@ -1,9 +1,10 @@
{# HTMX partial: user search results for the sharing panel.
{% comment %}
HTMX partial: user search results for the sharing panel.
Context:
users list of User objects matching the query
cid_user_exam CidUserExam instance
q search query string
#}
{% endcomment %}
{% if users %}
<ul class="list-group list-group-flush small border rounded">
{% for u in users %}
@@ -24,4 +25,6 @@
</ul>
{% elif q|length >= 2 %}
<div class="small text-muted fst-italic">No matching users found.</div>
{% else %}
<div class="small text-muted fst-italic">Please enter at least 2 characters to search.</div>
{% endif %}
@@ -5,40 +5,73 @@
<h5 class="card-title">Your collections</h5>
<h6 class="mt-3">Available to start</h6>
<ul class="list-unstyled">
<ul class="list-group list-group-sm mb-3">
{% for collection in available_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
{% if collection.description %}<small class="text-muted">{{ collection.description }}</small>{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
<div>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
</div>
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
{% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
<input type="hidden" name="object_id" value="{{ collection.pk }}">
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
</button>
</form>
</li>
{% empty %}
<li class="text-muted">No collections available to start.</li>
<li class="list-group-item text-muted">No collections available to start.</li>
{% endfor %}
</ul>
<h6 class="mt-3">In progress</h6>
<ul class="list-unstyled">
<ul class="list-group list-group-sm mb-3">
{% for collection in in_progress_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-warning text-dark ms-2">In progress</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
<div>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-warning text-dark ms-2">In progress</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
</div>
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
{% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
<input type="hidden" name="object_id" value="{{ collection.pk }}">
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
</button>
</form>
</li>
{% empty %}
<li class="text-muted">No in-progress collections.</li>
<li class="list-group-item text-muted">No in-progress collections.</li>
{% endfor %}
</ul>
<h6 class="mt-3">Finished</h6>
<ul class="list-unstyled">
<ul class="list-group list-group-sm">
{% for collection in finished_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-success ms-2">Completed</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
<div>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-success ms-2">Completed</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
</div>
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
{% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
<input type="hidden" name="object_id" value="{{ collection.pk }}">
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
</button>
</form>
</li>
{% empty %}
<li class="text-muted">No finished collections.</li>
<li class="list-group-item text-muted">No finished collections.</li>
{% endfor %}
</ul>
@@ -0,0 +1,21 @@
{% load crispy_forms_tags %}
<form hx-post="{% url 'atlas:case_create_resource_inline' pk=case.pk %}"
hx-encoding="multipart/form-data"
hx-target="#case-resources-list"
hx-swap="innerHTML"
class="bg-dark p-3 rounded border border-secondary text-white my-3">
{% csrf_token %}
<h5>Create New Resource</h5>
{{ form|crispy }}
<div class="form-check mb-3">
<input type="checkbox" name="pre_review" class="form-check-input" id="id_pre_review">
<label class="form-check-label" for="id_pre_review">Pre-review (show before case is taken)</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-sm btn-success">Save and Attach</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
hx-get="{% url 'atlas:resource_search_panel' %}?case_id={{ case.pk }}"
hx-target="#resource-search-panel-placeholder"
hx-swap="innerHTML">Cancel</button>
</div>
</form>
@@ -1,5 +1,13 @@
<div class="case-search-widget">
{% with input_id=input_id|default:'case-search-input' target_id=target_id|default:'case-search-results' %}
<style>
.case-search-loading {
display: none;
}
.case-search-widget.is-loading .case-search-loading {
display: inline-flex;
}
</style>
<label for="{{ input_id }}" class="form-label">Search cases</label>
{% if collection %}
<input type="hidden" id="{{ input_id }}-collection" name="collection" value="{{ collection.pk }}">
@@ -12,9 +20,15 @@
hx-get="{% url 'atlas:case_search' %}"
hx-include="closest .case-search-widget"
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
hx-trigger="input delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="case-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching cases...</span>
</div>
<div id="{{ target_id }}" class="mt-2">
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection show_select_button=show_select_button %}
@@ -27,7 +41,14 @@
try {
var input = document.getElementById('{{ input_id|default:"case-search-input" }}');
if (!input) return;
var widget = input.closest('.case-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
@@ -36,12 +57,13 @@
var url = "{% url 'atlas:case_search' %}?q=" + q + ({% if collection %} "&collection={{ collection.pk }}" {% else %} '' {% endif %}) + ({% if show_select_button %} "&show_select=1" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"case-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"case-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('case search fetch error', e); });
}).catch(function(e){ console.error('case search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
@@ -61,17 +83,29 @@
var targetEl = document.getElementById(targetId);
if (!container || !targetEl) return;
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === inputEl) {
container.classList.add('is-loading');
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === inputEl) {
container.classList.remove('is-loading');
}
});
// Delegate clicks on result items and dispatch a scoped custom event
targetEl.addEventListener('click', function(e){
var item = e.target.closest('.list-group-item');
if (!item || !targetEl.contains(item)) return;
// Explicit select action button should always select the row.
if (e.target.closest('.case-select-btn')) {
var selectBtn = e.target.closest('.case-select-btn');
if (selectBtn) {
e.preventDefault();
} else {
// Ignore other interactive elements inside the item (links, buttons, forms, inputs)
if (e.target.closest('a,button,form,input')) return;
// Ignore all non-select interactions so rows are passive unless
// explicit selection controls are present.
return;
}
var casePk = item.getAttribute('data-case-pk');
@@ -3,6 +3,10 @@
<div id="current-case-meta" data-current-case="{{ case.pk }}" data-current-case-title="{{ case.title|escape }}" style="display:none;"></div>
{% endif %}
{% with input_id=input_id|default:'displayset-search-input' target_id=target_id|default:'displayset-search-results' %}
<style>
.displayset-search-loading { display: none; }
.displayset-search-widget.is-loading .displayset-search-loading { display: inline-flex; }
</style>
<label for="{{ input_id }}" class="form-label">Search display sets</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search display sets..."
@@ -11,8 +15,14 @@
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="displayset-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching display sets...</span>
</div>
<div class="mt-1">
<span id="{{ input_id }}-case-badge" class="badge bg-secondary" style="display: {% if case %}inline-block{% else %}none{% endif %};">
{% if case %}Filtering: {{ case.title|escape }}{% endif %}
@@ -31,7 +41,14 @@
try {
var input = document.getElementById('{{ input_id|default:"displayset-search-input" }}');
if (!input) return;
var widget = input.closest('.displayset-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
@@ -40,16 +57,28 @@
var url = "{% url 'atlas:displayset_search' %}?q=" + q + ({% if case %} "&case={{ case.pk }}" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"displayset-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"displayset-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('displayset search fetch error', e); });
}).catch(function(e){ console.error('displayset search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === input) {
setLoading(true);
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === input) {
setLoading(false);
}
});
} catch(e) { console.error('displayset search widget init error', e); }
})();
</script>
@@ -1,5 +1,9 @@
<div class="finding-search-widget">
{% with input_id=input_id|default:'finding-search-input' target_id=target_id|default:'finding-search-results' %}
<style>
.finding-search-loading { display: none; }
.finding-search-widget.is-loading .finding-search-loading { display: inline-flex; }
</style>
<label for="{{ input_id }}" class="form-label">Search findings</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search findings..."
@@ -8,8 +12,14 @@
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="finding-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching findings...</span>
</div>
<div class="mt-1">
<span id="{{ input_id }}-case-badge" class="badge bg-secondary" style="display: {% if case %}inline-block{% else %}none{% endif %};">
{% if case %}Filtering: {{ case.title|escape }}{% endif %}
@@ -28,8 +38,14 @@
try {
var input = document.getElementById('{{ input_id|default:"finding-search-input" }}');
if (!input) return;
var widget = input.closest('.finding-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
// If server provided a case via inclusion, ensure widget sets dataset and nearby textarea dataset
var meta = document.getElementById('current-case-meta');
var casePk = meta ? meta.getAttribute('data-current-case') : null;
@@ -70,16 +86,28 @@
var url = "{% url 'atlas:finding_search' %}?q=" + q + caseParam;
var target = '#{{ target_id|default:"finding-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"finding-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('finding search fetch error', e); });
}).catch(function(e){ console.error('finding search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === input) {
setLoading(true);
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === input) {
setLoading(false);
}
});
} catch(e) { console.error('finding search widget init error', e); }
})();
</script>
@@ -35,6 +35,14 @@
<label class="form-check-label small" for="resource-open-access-{{ case.pk }}">Open</label>
</div>
</div>
<div class="col-auto ms-auto">
<button type="button" class="btn btn-sm btn-outline-success"
hx-get="{% url 'atlas:case_create_resource_inline' pk=case.pk %}"
hx-target="#resource-search-panel"
hx-swap="outerHTML">
+ New Resource
</button>
</div>
</div>
<div id="resource-search-results" class="mt-2"></div>
@@ -2,7 +2,7 @@
<div class="btn-toolbar" role="toolbar" aria-label="Case actions">
<div class="btn-group me-2 mb-2" role="group" aria-label="Primary actions">
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_update' pk=case.pk %}?next={{ request.get_full_path|urlencode }}" title="Edit the Case">Edit</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' pk=case.pk %}" title="View and edit case display sets">Display sets</a>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "atlas/base.html" %}
{% extends base_template|default:"atlas/base.html" %}
<!-- {% load static from static %} -->
{% load crispy_forms_tags %}
+10
View File
@@ -1,9 +1,19 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %}
<h2>Resources</h2>
<button class="btn btn-primary mb-3">
<a href="{% url 'atlas:resource_create' %}">Create New Resource</a>
</button>
{% comment %} <details>
<summary>Search Resources</summary>
{% include "atlas/partials/resource_search_panel.html" %}
</details>
{% endcomment %}
<form>
{% render_table table %}
</form>
@@ -1,6 +1,7 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block css %}
{% endblock %}
+20 -8
View File
@@ -1,8 +1,20 @@
<br/>
<a href="{% url 'atlas:series_detail' pk=series.pk %}" title="View the Series">View</a>
<a href="{% url 'atlas:series_update' pk=series.pk %}" title="Edit the Series">Edit</a>
<a href="{% url 'atlas:series_delete' pk=series.pk %}" title="Delete the Series">Delete</a>
<a href="{% url 'atlas:series_authors' pk=series.pk %}" title="Update the authors">Authors</a>
{% if request.user.is_superuser %}
<a href="{% url 'admin:atlas_series_change' series.id %}" title="Edit the Series using the admin interface">Admin Edit</a>
{% endif %}
<div class="btn-group mb-2" role="group">
<a href="{% url 'atlas:series_detail' pk=series.pk %}" class="btn btn-outline-secondary btn-sm">View</a>
<a href="{% url 'atlas:series_update' pk=series.pk %}" class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="{% url 'atlas:series_delete' pk=series.pk %}" class="btn btn-outline-danger btn-sm">Delete</a>
<a href="{% url 'atlas:series_authors' pk=series.pk %}" class="btn btn-outline-secondary btn-sm">Authors</a>
{% if request.user.is_superuser %}
<a href="{% url 'admin:atlas_series_change' series.id %}" class="btn btn-outline-dark btn-sm">Admin Edit</a>
{% endif %}
<div class="btn-group" role="group">
<button id="orderbyDropdown" type="button" class="btn btn-outline-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
Order By
</button>
<ul class="dropdown-menu" aria-labelledby="orderbyDropdown">
<li><a class="dropdown-item" href="?orderby=date">Date</a></li>
<li><a class="dropdown-item" href="?orderby=modality">Modality</a></li>
<li><a class="dropdown-item" href="?orderby=images">Image Count</a></li>
<!-- Add more order-by options as needed -->
</ul>
</div>
</div>
@@ -0,0 +1,320 @@
{% extends 'base.html' %}
{% load static %}
{% block css %}
{{ block.super }}
<style>
.recon-advanced-viewer {
width: 100%;
min-height: 62vh;
box-sizing: border-box;
background: #111;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow: hidden;
height: 62vh;
}
.recon-advanced-help {
font-size: 0.9rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="mb-1">Advanced Series Reconstruction</h2>
<p class="text-muted mb-0">Plan reconstruction from interactive DV3D MPR viewports with crosshair-linked navigation.</p>
</div>
<a class="btn btn-outline-secondary btn-sm" href="{{ series.get_absolute_url }}">
<i class="bi bi-arrow-left me-1"></i> Back to series
</a>
</div>
<div class="alert alert-info recon-advanced-help">
<div><strong>Workflow:</strong> the page auto-configures a 3-volume MPR layout on load and attempts to enable crosshair-linked navigation. Adjust crosshairs as needed, then generate reconstructions.</div>
</div>
<div id="advanced_recon_root" class="dicom-viewer-root recon-advanced-viewer" data-images='{{ image_ids_json|safe }}'></div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-body">
<form
id="advanced-recon-form"
class="row g-3"
method="post"
action="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-post="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-target="#advanced-recon-feedback"
hx-swap="innerHTML"
hx-indicator="#advanced-recon-indicator"
>
{% csrf_token %}
<input type="hidden" name="operation" value="reconstruct">
<input type="hidden" name="recon_viewerstate_json" id="id_recon_viewerstate_json">
<div class="col-12 col-lg-4">
<div class="fw-semibold mb-2">Output planes</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-axial" name="recon_planes" value="axial" checked>
<label class="form-check-label" for="adv-recon-axial">Axial</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-coronal" name="recon_planes" value="coronal" checked>
<label class="form-check-label" for="adv-recon-coronal">Coronal</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-sagittal" name="recon_planes" value="sagittal" checked>
<label class="form-check-label" for="adv-recon-sagittal">Sagittal</label>
</div>
</div>
<div class="col-12 col-lg-4">
<label class="form-label" for="adv-recon-thickness">Slab thickness (mm)</label>
<input id="adv-recon-thickness" type="number" step="0.1" min="0.1" name="recon_slice_thickness" class="form-control" value="3.0">
<label class="form-label mt-2" for="adv-recon-spacing">Slice spacing (mm)</label>
<input id="adv-recon-spacing" type="number" step="0.1" min="3" name="recon_slice_spacing" class="form-control" value="3.0">
<small class="text-muted d-block mt-1">Spacing is user-controlled and defaults to 3.0 mm minimum to reduce generated stack size.</small>
</div>
<div class="col-12 col-lg-4">
<label class="form-label" for="adv-recon-mode">Projection mode</label>
<select id="adv-recon-mode" name="recon_thickness_mode" class="form-select">
<option value="mean" selected>Average intensity</option>
<option value="max">Maximum intensity</option>
<option value="min">Minimum intensity</option>
</select>
<div class="d-flex flex-wrap gap-2 mt-3">
<button type="button" id="apply-mpr-layout" class="btn btn-outline-primary btn-sm">Apply MPR layout</button>
<button type="submit" class="btn btn-primary btn-sm">Generate recon series</button>
</div>
</div>
</form>
</div>
<div class="card-footer d-flex align-items-center gap-2">
<span id="advanced-recon-indicator" class="spinner-border spinner-border-sm text-primary htmx-indicator" role="status" aria-hidden="true"></span>
<small class="text-muted">The current viewer state is captured at submit and attached to the reconstruction request.</small>
</div>
</div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h6 class="mb-0">Live Reconstruction Plan</h6>
<small id="advanced-plan-status" class="text-muted">Waiting for viewer telemetry...</small>
</div>
<div class="row g-2 small">
<div class="col-12 col-md-4"><strong>Viewport 1:</strong> <span id="advanced-plan-vp0">-</span></div>
<div class="col-12 col-md-4"><strong>Viewport 2:</strong> <span id="advanced-plan-vp1">-</span></div>
<div class="col-12 col-md-4"><strong>Viewport 3:</strong> <span id="advanced-plan-vp2">-</span></div>
</div>
<div class="row g-2 small mt-1">
<div class="col-12 col-md-6"><strong>Slab thickness:</strong> <span id="advanced-plan-thickness">3.0 mm</span></div>
<div class="col-12 col-md-6"><strong>Slice spacing:</strong> <span id="advanced-plan-spacing">3.0 mm</span></div>
</div>
</div>
</div>
<div id="advanced-recon-feedback" class="small mt-3"></div>
</div>
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer"></script>
<script>
(function () {
var ORIENTATION_LABELS = {
axial: 'Axial',
coronal: 'Coronal',
sagittal: 'Sagittal',
};
function getViewerApi(name) {
return window[name + '_advanced_recon_root'] || window[name + '_advanced_recon'] || window[name + '_root'] || window[name];
}
function parseViewerState(raw) {
if (!raw) {
return null;
}
if (typeof raw === 'string') {
try {
return JSON.parse(raw);
} catch (err) {
return null;
}
}
return raw;
}
function getDisplayOrientation(value) {
if (!value) {
return 'Unknown';
}
return ORIENTATION_LABELS[value] || String(value).charAt(0).toUpperCase() + String(value).slice(1);
}
function ensureMprLayout() {
var exportFn = getViewerApi('exportViewerState');
var importFn = getViewerApi('importViewerState');
if (typeof importFn !== 'function') {
return false;
}
var existing = parseViewerState(typeof exportFn === 'function' ? exportFn() : null) || {};
var stackIdx = Array.isArray(existing.stackIdx) ? existing.stackIdx : [];
var stacks = Array.isArray(existing.stacks) ? existing.stacks : [];
var baseStack = typeof stackIdx[0] === 'number' ? stackIdx[0] : (stacks.length > 0 ? 0 : null);
var nextState = {
grid: { rows: 1, cols: 3 },
modes: ['volume', 'volume', 'volume'],
orientations: ['axial', 'coronal', 'sagittal'],
volumeSliceIndices: [null, null, null],
};
if (stacks.length > 0 && baseStack !== null) {
nextState.stacks = stacks;
nextState.stackIdx = [baseStack, baseStack, baseStack];
}
try {
importFn(nextState);
return true;
} catch (err) {
console.warn('Failed to apply automatic MPR layout', err);
return false;
}
}
function tryEnableCrosshairs() {
var labels = ['Enable Cross Reference Lines', 'Enable Crosshairs'];
var root = document.getElementById('advanced_recon_root');
var scope = root ? root.parentElement || document : document;
var buttons = scope.querySelectorAll('button');
var clicked = false;
buttons.forEach(function (btn) {
if (clicked) {
return;
}
var text = (btn.textContent || '').trim();
if (!text) {
return;
}
for (var i = 0; i < labels.length; i++) {
if (text.indexOf(labels[i]) !== -1) {
btn.click();
clicked = true;
break;
}
}
});
return clicked;
}
function formatViewportPlan(state, idx) {
if (!state) {
return '-';
}
var modes = Array.isArray(state.modes) ? state.modes : [];
var orientations = Array.isArray(state.orientations) ? state.orientations : [];
var slices = Array.isArray(state.volumeSliceIndices) ? state.volumeSliceIndices : [];
var mode = modes[idx] || 'unknown';
var orientation = getDisplayOrientation(orientations[idx]);
var slice = slices[idx];
var sliceText = typeof slice === 'number' ? ('slice ' + slice) : 'slice n/a';
return orientation + ' (' + mode + ', ' + sliceText + ')';
}
function updateLivePlan() {
var exportFn = getViewerApi('exportViewerState');
var thicknessInput = document.getElementById('adv-recon-thickness');
var spacingInput = document.getElementById('adv-recon-spacing');
var state = parseViewerState(typeof exportFn === 'function' ? exportFn() : null);
var statusEl = document.getElementById('advanced-plan-status');
var vp0 = document.getElementById('advanced-plan-vp0');
var vp1 = document.getElementById('advanced-plan-vp1');
var vp2 = document.getElementById('advanced-plan-vp2');
var thicknessEl = document.getElementById('advanced-plan-thickness');
var spacingEl = document.getElementById('advanced-plan-spacing');
if (statusEl) {
statusEl.textContent = state ? 'Viewer telemetry active' : 'Waiting for viewer telemetry...';
}
if (vp0) {
vp0.textContent = formatViewportPlan(state, 0);
}
if (vp1) {
vp1.textContent = formatViewportPlan(state, 1);
}
if (vp2) {
vp2.textContent = formatViewportPlan(state, 2);
}
if (thicknessEl) {
var thicknessValue = thicknessInput && thicknessInput.value ? thicknessInput.value : '3.0';
thicknessEl.textContent = thicknessValue + ' mm';
}
if (spacingEl) {
var spacingValue = spacingInput && spacingInput.value ? spacingInput.value : '3.0';
spacingEl.textContent = spacingValue + ' mm';
}
}
var applyBtn = document.getElementById('apply-mpr-layout');
if (applyBtn) {
applyBtn.addEventListener('click', function () {
ensureMprLayout();
setTimeout(updateLivePlan, 400);
});
}
var form = document.getElementById('advanced-recon-form');
var hidden = document.getElementById('id_recon_viewerstate_json');
if (form && hidden) {
form.addEventListener('submit', function () {
var exportFn = getViewerApi('exportViewerState');
if (typeof exportFn !== 'function') {
hidden.value = '';
return;
}
try {
var state = exportFn();
hidden.value = typeof state === 'string' ? state : JSON.stringify(state || {});
} catch (err) {
hidden.value = '';
console.warn('Failed to capture viewer state', err);
}
});
}
var thicknessInput = document.getElementById('adv-recon-thickness');
var spacingInput = document.getElementById('adv-recon-spacing');
if (thicknessInput) {
thicknessInput.addEventListener('input', updateLivePlan);
}
if (spacingInput) {
spacingInput.addEventListener('input', updateLivePlan);
}
var autoAttempts = 0;
var autoTimer = setInterval(function () {
autoAttempts += 1;
var mprApplied = ensureMprLayout();
var crosshairEnabled = tryEnableCrosshairs();
updateLivePlan();
if ((mprApplied && crosshairEnabled) || autoAttempts >= 12) {
clearInterval(autoTimer);
}
}, 600);
setInterval(updateLivePlan, 700);
setTimeout(updateLivePlan, 1500);
})();
</script>
{% endblock %}
+22 -22
View File
@@ -1,6 +1,7 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %}
@@ -33,42 +34,41 @@
{% render_table table %}
</div>
<script>
document.getElementById('bulk-delete-form').addEventListener('submit', function(e) {
<script>
document.getElementById('bulk-delete-form').addEventListener('submit', function(e) {
// Remove any previously added hidden inputs
document.querySelectorAll('#bulk-delete-form input[name="series_ids"]').forEach(el => el.remove());
document.querySelectorAll('#bulk-delete-form input[name="series_ids"]').forEach(el => el.remove());
// Find all checked checkboxes
const checked = document.querySelectorAll('input[name="selection"]:checked');
if (checked.length === 0) {
alert("Please select at least one series to delete.");
e.preventDefault();
return false;
}
const checked = document.querySelectorAll('input[name="selection"]:checked');
if (checked.length === 0) {
alert("Please select at least one series to delete.");
e.preventDefault();
return false;
}
// Add hidden inputs for each checked series
checked.forEach(cb => {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'series_ids';
hidden.value = cb.value;
this.appendChild(hidden);
});
});
</script>
checked.forEach(cb => {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'series_ids';
hidden.value = cb.value;
this.appendChild(hidden);
});
});
</script>
<div id="exam-options"></div>
{% endblock %}
{% block js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
});
document.addEventListener('DOMContentLoaded', function() {
});
</script>
{% endblock %}
{% block css %}
<style>
</style>
</style>
{% endblock css %}
+44 -9
View File
@@ -32,10 +32,10 @@
<div class="card">
<div class="card-body p-2">
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
<div id="root"
class="dicom-viewer-root w-100 viewer-frame-standard series-viewer-resizable"
style="height: clamp(640px, 78vh, 1200px); min-height: 640px; resize: vertical; overflow: auto;"
data-images="{{ image_url_array_and_count.0 }}"
<div id="root"
class="dicom-viewer-root w-100 viewer-frame-standard series-viewer-resizable"
style="height: clamp(640px, 78vh, 1200px); min-height: 640px; resize: vertical; overflow: auto;"
data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack=false>
</div>
{% endwith %}
@@ -187,14 +187,46 @@
</div>
</div>
<div class="card mb-3">
<div class="card-header">Detected Groups</div>
<div class="card-body">
{% if detected_series_groups %}
<div class="d-flex flex-column gap-2">
{% for group in detected_series_groups %}
<div class="border rounded p-2">
<div class="fw-semibold small">{{ group.label|default:"Series group" }}</div>
<div class="small text-muted">{{ group.imageIds|length }} images</div>
{% if group.groupValues %}
<div class="small mt-1">
{% for key, value in group.groupValues.items %}
<span class="badge bg-secondary-subtle text-body border me-1 mb-1">{{ key }}={{ value }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted small mb-0">No detected grouping values for this series.</p>
{% endif %}
</div>
</div>
<div class="card mb-3">
<div class="card-header">Actions</div>
<div class="card-body d-flex flex-column gap-2">
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
<div class="dropdown">
<button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Order Images
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">By slice location</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">By instance number</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">By SeriesInstanceUID</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">By uploaded filename</a></li>
</ul>
</div>
<div hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-trigger="load"
hx-swap="outerHTML">
@@ -268,7 +300,7 @@
<div class="card-body p-2 d-flex flex-column gap-2">
<div id="truncate_viewer" class="dicom-viewer-root w-100"
style="height: 460px; min-height: 360px; resize: vertical; overflow: auto; background: #222;"
data-images="{{ image_url_array_and_count.0 }}"
data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack="false">
</div>
<div class="small text-muted">Downsample compare preview loads into this viewer as stack options.</div>
@@ -384,6 +416,9 @@
<div class="mt-2">
<button class="btn btn-sm btn-outline-primary" type="submit" name="operation" value="reconstruct">Generate recon series</button>
<a class="btn btn-sm btn-outline-info ms-1" href="{% url 'atlas:series_reconstruct_advanced' series_id=series.pk %}" target="_blank" rel="noopener">
Advanced reconstruction page
</a>
</div>
</div>
</form>
+203 -189
View File
@@ -2,200 +2,214 @@
{% load static %}
{% load django_htmx %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block title %}
Atlas Task Overview
Atlas Task Overview
{% endblock title %}
{% block content %}
<div class="container-fluid py-3 px-3">
<style>
.task-overview-table {
min-width: 1650px;
}
.task-overview-table th,
.task-overview-table td {
white-space: nowrap;
vertical-align: middle;
}
.task-overview-table .task-col,
.task-overview-table .generated-col,
.task-overview-table .error-col,
.task-overview-table .actions-col {
white-space: normal;
}
.task-overview-table .generated-col {
min-width: 220px;
}
</style>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="h4 mb-0">Atlas Task Overview</h2>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="?status=all">All</a>
<a class="btn btn-outline-warning btn-sm" href="?status=active">Active</a>
<a class="btn btn-outline-success btn-sm" href="?status=completed">Completed</a>
<a class="btn btn-outline-danger btn-sm" href="?status=failed">Failed</a>
</div>
</div>
{% if backend_missing %}
<div class="alert alert-warning">
django_tasks_db backend is not available in this environment. Install and migrate it to see task records.
</div>
{% endif %}
<div class="row g-2 mb-3">
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body py-2">
<div class="small text-muted">Active</div>
<div class="h4 mb-0 text-warning">{{ counts.active }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-body py-2">
<div class="small text-muted">Completed</div>
<div class="h4 mb-0 text-success">{{ counts.completed }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-body py-2">
<div class="small text-muted">Failed</div>
<div class="h4 mb-0 text-danger">{{ counts.failed }}</div>
</div>
</div>
</div>
</div>
<form method="post" class="mb-3">
{% csrf_token %}
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<select class="form-select form-select-sm" name="task_action" style="max-width: 320px;">
<option value="">Choose task action...</option>
<option value="reset_failed">Reset FAILED to READY</option>
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
<option value="delete_completed">Delete selected COMPLETED</option>
<option value="delete_failed">Delete selected FAILED</option>
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
<div class="form-check ms-2">
<input class="form-check-input" type="checkbox" id="select-all-tasks">
<label class="form-check-label small" for="select-all-tasks">Select all</label>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle task-overview-table">
<thead>
<tr>
<th style="width: 36px;"></th>
<th class="task-col">Task</th>
<th>Status</th>
<th class="generated-col">Generated Series</th>
<th>Queue</th>
<th>Enqueued</th>
<th>Started</th>
<th>Finished</th>
<th>Worker</th>
<th class="error-col">Error</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>
<input class="form-check-input task-select" type="checkbox" name="task_ids" value="{{ task.id }}">
</td>
<td class="task-col">
<div class="small fw-semibold">{{ task.task_path }}</div>
<div class="small text-muted">{{ task.id }}</div>
</td>
<td>
{% if task.status == "FAILED" %}
<span class="badge bg-danger">{{ task.status }}</span>
{% elif task.status == "SUCCESSFUL" %}
<span class="badge bg-success">{{ task.status }}</span>
{% elif task.status == "RUNNING" or task.status == "READY" %}
<span class="badge bg-warning text-dark">{{ task.status }}</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td class="small generated-col">
{% if task.generated_series %}
{% for series in task.generated_series %}
<a class="d-block" target="_blank" href="{{ series.url }}">{{ series.description }}</a>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ task.queue_name }}</td>
<td class="small">{{ task.enqueued_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.started_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.finished_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.worker_ids }}</td>
<td class="small text-danger error-col">{{ task.exception_class_path|default:"" }}</td>
<td class="small actions-col">
{% if task.traceback %}
<button
class="btn btn-outline-danger btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#traceback-{{ task.id }}"
aria-expanded="false"
aria-controls="traceback-{{ task.id }}"
>
View full traceback
</button>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% if task.traceback %}
<tr class="collapse" id="traceback-{{ task.id }}">
<td></td>
<td colspan="10">
<div class="border rounded bg-dark-subtle p-2">
<div class="small fw-semibold mb-1">Full traceback</div>
<pre class="small mb-0" style="white-space: pre-wrap;">{{ task.traceback }}</pre>
</div>
</td>
</tr>
{% endif %}
{% empty %}
<tr>
<td colspan="11" class="text-muted">No tasks found for this filter.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
<script>
(function () {
const selectAll = document.getElementById('select-all-tasks');
const rows = Array.from(document.querySelectorAll('.task-select'));
if (!selectAll || !rows.length) {
return;
<div class="container-fluid py-3 px-3">
<style>
.task-overview-table {
min-width: 1650px;
}
selectAll.addEventListener('change', function () {
rows.forEach((cb) => {
cb.checked = selectAll.checked;
.task-overview-table th,
.task-overview-table td {
white-space: nowrap;
vertical-align: middle;
}
.task-overview-table .task-col,
.task-overview-table .generated-col,
.task-overview-table .error-col,
.task-overview-table .actions-col {
white-space: normal;
}
.task-overview-table .generated-col {
min-width: 220px;
}
</style>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="h4 mb-0">Atlas Task Overview</h2>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="?status=all">All</a>
<a class="btn btn-outline-warning btn-sm" href="?status=active">Active</a>
<a class="btn btn-outline-success btn-sm" href="?status=completed">Completed</a>
<a class="btn btn-outline-danger btn-sm" href="?status=failed">Failed</a>
</div>
</div>
{% if backend_missing %}
<div class="alert alert-warning">
django_tasks_db backend is not available in this environment. Install and migrate it to see task records.
</div>
{% endif %}
<div class="row g-2 mb-3">
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body py-2">
<div class="small text-muted">Active</div>
<div class="h4 mb-0 text-warning">{{ counts.active }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-body py-2">
<div class="small text-muted">Completed</div>
<div class="h4 mb-0 text-success">{{ counts.completed }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-body py-2">
<div class="small text-muted">Failed</div>
<div class="h4 mb-0 text-danger">{{ counts.failed }}</div>
</div>
</div>
</div>
</div>
<form method="post" class="mb-3">
{% csrf_token %}
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<select class="form-select form-select-sm" name="task_action" style="max-width: 320px;">
<option value="">Choose task action...</option>
<option value="reset_failed">Reset FAILED to READY</option>
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
<option value="reset_stale_running">Reset stale RUNNING to READY</option>
<option value="delete_completed">Delete selected COMPLETED</option>
<option value="delete_failed">Delete selected FAILED</option>
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
</select>
<input
class="form-control form-control-sm"
type="number"
name="stale_minutes"
min="1"
step="1"
value="30"
style="max-width: 150px;"
title="Stale cutoff in minutes for reset stale RUNNING action"
aria-label="Stale minutes"
>
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
<div class="form-check ms-2">
<input class="form-check-input" type="checkbox" id="select-all-tasks">
<label class="form-check-label small" for="select-all-tasks">Select all</label>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle task-overview-table">
<thead>
<tr>
<th style="width: 36px;"></th>
<th class="task-col">Task</th>
<th>Status</th>
<th class="generated-col">Generated Series</th>
<th>Queue</th>
<th>Enqueued</th>
<th>Started</th>
<th>Finished</th>
<th>Worker</th>
<th class="error-col">Error</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>
<input class="form-check-input task-select" type="checkbox" name="task_ids" value="{{ task.id }}">
</td>
<td class="task-col">
<div class="small fw-semibold">{{ task.task_path }}</div>
<div class="small text-muted">{{ task.id }}</div>
</td>
<td>
{% if task.status == "FAILED" %}
<span class="badge bg-danger">{{ task.status }}</span>
{% elif task.status == "SUCCESSFUL" %}
<span class="badge bg-success">{{ task.status }}</span>
{% elif task.status == "RUNNING" or task.status == "READY" %}
<span class="badge bg-warning text-dark">{{ task.status }}</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td class="small generated-col">
{% if task.generated_series %}
{% for series in task.generated_series %}
<a class="d-block" target="_blank" href="{{ series.url }}">{{ series.description }}</a>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ task.queue_name }}</td>
<td class="small">{{ task.enqueued_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.started_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.finished_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.worker_ids }}</td>
<td class="small text-danger error-col">{{ task.exception_class_path|default:"" }}</td>
<td class="small actions-col">
{% if task.traceback %}
<button
class="btn btn-outline-danger btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#traceback-{{ task.id }}"
aria-expanded="false"
aria-controls="traceback-{{ task.id }}"
>
View full traceback
</button>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% if task.traceback %}
<tr class="collapse" id="traceback-{{ task.id }}">
<td></td>
<td colspan="10">
<div class="border rounded bg-dark-subtle p-2">
<div class="small fw-semibold mb-1">Full traceback</div>
<pre class="small mb-0" style="white-space: pre-wrap;">{{ task.traceback }}</pre>
</div>
</td>
</tr>
{% endif %}
{% empty %}
<tr>
<td colspan="11" class="text-muted">No tasks found for this filter.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
<script>
(function () {
const selectAll = document.getElementById('select-all-tasks');
const rows = Array.from(document.querySelectorAll('.task-select'));
if (!selectAll || !rows.length) {
return;
}
selectAll.addEventListener('change', function () {
rows.forEach((cb) => {
cb.checked = selectAll.checked;
});
});
});
})();
</script>
</div>
})();
</script>
</div>
{% endblock content %}
@@ -1,5 +1,7 @@
{% extends 'atlas/base.html' %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %}
<h2>Search Uploaded And Imported DICOMs</h2>
<p class="text-muted">Admin-only tool for searching pending uploads and imported Atlas data by hash, series UID, or study UID.</p>
+276 -8
View File
@@ -1,7 +1,23 @@
{% extends 'atlas/base.html' %}
{% load case_widgets %}
{% load crispy_forms_tags %}
{% block content %}
<script>
(function($) {
if ($ && $.fn && $.fn.select2) {
var originalSelect2 = $.fn.select2;
$.fn.select2 = function(options) {
if (options && typeof options === 'object') {
if (this.closest('#caseSeriesModal').length > 0) {
options.dropdownParent = $('#caseSeriesModal');
}
}
return originalSelect2.apply(this, arguments);
};
}
})(window.jQuery);
</script>
<h2>Uploaded dicoms</h2>
User: {{user}}<br/>
<a href="{% url 'atlas:new_uploads' %}">Upload more dicoms</a><br/>
@@ -16,7 +32,97 @@
{% if series_list %}
<div id="series-list">
{% if grouped_series %}
{% if grouped_patients %}
{% for patient in grouped_patients %}
<div class="patient-block mb-4 p-3 border rounded" style="background: rgba(255,255,255,0.015); border-color: rgba(255,255,255,0.08) !important;">
<div class="patient-header d-flex flex-wrap align-items-center justify-content-between pb-2 mb-3 border-bottom border-secondary">
<span>
<i class="bi bi-person-badge me-2 text-info"></i>
<strong>Patient ID:</strong> <span class="badge bg-secondary font-monospace">{{ patient.patient_id }}</span>
</span>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary select-all-patient-btn" title="Select all series for this patient">Select Patient Series</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-patient-btn" title="Deselect all series for this patient">Deselect all</button>
</div>
</div>
{% for study_uid, study in patient.studies %}
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
<summary>
<strong>{{ study.description|default:"(no description)" }}</strong>
<small class="text-muted"> ({{ study_uid }}) — {{ study.series|length }} series</small>
<span class="ms-2">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" title="Select all in study">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" title="Deselect all in study">Deselect all</button>
</span>
</summary>
{% if study.partial_imported_uids %}
<div class="alert alert-warning py-2 mt-2 mb-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>
<strong>Partial import detected:</strong>
{{ study.partial_imported_count }} series in this study already exist.
The highlighted rows can finish importing the remaining images.
{% if study.matched_existing_series %}
<div class="small mt-1">
Existing series in Atlas:
{% for existing_series in study.matched_existing_series %}
<a href="{% url 'atlas:series_detail' existing_series.pk %}">{{ existing_series }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<button
type="button"
class="btn btn-sm btn-warning import-partial-series-btn"
data-series-uids="{{ study.partial_imported_uids|join:',' }}"
{% if study.preferred_case_id %}data-case-id="{{ study.preferred_case_id }}"{% endif %}
>Finish these imports</button>
</div>
{% endif %}
{% if study.import_suggestions %}
<div class="alert alert-info py-2 mt-2 mb-2">
<strong>Study already imported:</strong>
{% for suggestion in study.import_suggestions %}
<div class="d-flex flex-wrap align-items-center gap-2 mt-1">
<span>
Case:
<a href="{% url 'atlas:case_detail' suggestion.case.pk %}">{{ suggestion.case }}</a>
({{ suggestion.already_imported_count }} series already imported)
</span>
<button
type="button"
class="btn btn-sm btn-outline-primary import-missing-series-btn"
data-case-id="{{ suggestion.case.pk }}"
data-series-uids="{{ suggestion.missing_uids|join:',' }}"
>
Import remaining {{ suggestion.missing_uids|length }} series
</button>
</div>
{% endfor %}
</div>
{% endif %}
<ul class="study-series-list">
{% for series, n, tags, date in study.series %}
{% if series in study.partial_imported_uids %}
{% if case %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=study.preferred_case_id %}
{% endif %}
{% else %}
{% if case %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' %}
{% endif %}
{% endif %}
{% endfor %}
</ul>
</details>
{% endfor %}
</div>
{% endfor %}
{% elif grouped_series %}
{% for study_uid, study in grouped_series %}
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
<summary>
@@ -173,6 +279,7 @@
class="btn btn-primary"
>Import {% if case %}into case{% endif %}</button>
<button id="import-into-case-button" type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#caseSelectModal">Import into case…</button>
<button id="import-case-series-button" type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#caseSeriesModal" disabled>Import as case series…</button>
</div>
<div id="import-status" class="mt-3"></div>
@@ -231,6 +338,34 @@
</div>
</div>
<div class="modal fade" id="caseSeriesModal" aria-hidden="true" data-bs-focus="false">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import as linked case series</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="case-series-form">
{% csrf_token %}
<div class="modal-body">
<div class="alert alert-info small">
This creates one case per selected study, copies the same case details to each case, and links them with <em>previous case</em>.
</div>
{% crispy case_series_form %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<div id="case-series-submit-loading" class="d-none text-muted small me-2">
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
Creating linked cases...
</div>
<button id="case-series-submit-button" type="submit" class="btn btn-success">Create linked cases</button>
</div>
</form>
</div>
</div>
</div>
<script>
const importCaseId = {% if case %}{{ case.pk }}{% else %}null{% endif %};
@@ -238,15 +373,18 @@
function updateSelectionCount() {
const all = Array.from(document.querySelectorAll('input[name="selection"]'));
const selected = all.filter(cb => cb.checked && !cb.disabled).length;
const selectedStudies = new Set(all.filter(cb => cb.checked && !cb.disabled).map(cb => cb.dataset.studyUid).filter(Boolean));
const el = document.getElementById('selected-count');
if (el) el.textContent = selected;
if (el) el.textContent = `${selected} series across ${selectedStudies.size} studies`;
// disable action buttons if none selected
const deleteBtn = document.getElementById('delete-uploads-button');
const importBtn = document.getElementById('import-dicoms-sequential-button');
const importIntoCaseBtn = document.getElementById('import-into-case-button');
const importCaseSeriesBtn = document.getElementById('import-case-series-button');
if (deleteBtn) deleteBtn.disabled = selected === 0;
if (importBtn) importBtn.disabled = selected === 0;
if (importIntoCaseBtn) importIntoCaseBtn.disabled = selected === 0;
if (importCaseSeriesBtn) importCaseSeriesBtn.disabled = selected === 0 || selectedStudies.size < 2;
}
function setImportButtonsDisabled(disabled) {
@@ -304,6 +442,21 @@
return selectedSeries.length ? selectedSeries : selectableSeries;
}
function getSelectedSeriesUids() {
return Array.from(document.querySelectorAll('input[name="selection"]:checked'))
.filter(cb => !cb.disabled)
.map(cb => cb.value);
}
function getSelectedStudyUids() {
return new Set(
Array.from(document.querySelectorAll('input[name="selection"]:checked'))
.filter(cb => !cb.disabled)
.map(cb => cb.dataset.studyUid)
.filter(Boolean)
);
}
async function runImport(selectionList, caseId) {
const statusDiv = document.getElementById('import-status');
if (!statusDiv) return;
@@ -420,7 +573,70 @@
});
}
// Select / Deselect all series within a study block
const caseSeriesForm = document.getElementById('case-series-form');
if (caseSeriesForm) {
caseSeriesForm.addEventListener('submit', async function (event) {
event.preventDefault();
const submitBtn = document.getElementById('case-series-submit-button');
const submitLoading = document.getElementById('case-series-submit-loading');
const selectedSeries = getSelectedSeriesUids();
const selectedStudies = getSelectedStudyUids();
if (!selectedSeries.length || selectedStudies.size < 2) {
alert('Select series from at least two studies before creating a linked case series.');
return;
}
const formData = new FormData(caseSeriesForm);
formData.delete('previous_case');
selectedSeries.forEach((seriesUid) => formData.append('selection', seriesUid));
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
if (orderSeries) {
formData.set('order-series', orderSeries);
}
setImportButtonsDisabled(true);
if (submitBtn) {
submitBtn.classList.add('d-none');
submitBtn.disabled = true;
}
if (submitLoading) {
submitLoading.classList.remove('d-none');
}
try {
const response = await fetch("{% url 'atlas:uploads_import_case_series_htmx' %}", {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
const html = await response.text();
appendImportStatus(html);
if (response.ok) {
selectedSeries.forEach((seriesUid) => markSeriesImported(seriesUid));
const modalEl = document.getElementById('caseSeriesModal');
if (modalEl && window.bootstrap && bootstrap.Modal) {
bootstrap.Modal.getInstance(modalEl)?.hide();
}
}
} catch (error) {
appendImportStatus(`<div class="alert alert-danger mb-0">Linked case import failed. ${error?.message || ''}</div>`);
} finally {
if (submitLoading) {
submitLoading.classList.add('d-none');
}
if (submitBtn) {
submitBtn.classList.remove('d-none');
submitBtn.disabled = false;
}
setImportButtonsDisabled(false);
updateSelectionCount();
}
});
}
// Select / Deselect all series within a study block or patient block
document.addEventListener('click', function (e) {
const target = e.target;
if (target.matches('.select-all-btn')) {
@@ -451,6 +667,34 @@
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
if (target.matches('.select-all-patient-btn')) {
const patientBlock = target.closest('.patient-block');
if (!patientBlock) return;
const inputs = patientBlock.querySelectorAll('input[name="selection"]');
inputs.forEach(cb => {
if (!cb.disabled) {
cb.checked = true;
const li = cb.closest('.series-item');
if (li) li.classList.add('selected');
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
if (target.matches('.deselect-all-patient-btn')) {
const patientBlock = target.closest('.patient-block');
if (!patientBlock) return;
const inputs = patientBlock.querySelectorAll('input[name="selection"]');
inputs.forEach(cb => {
if (!cb.disabled) {
cb.checked = false;
const li = cb.closest('.series-item');
if (li) li.classList.remove('selected');
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
});
// Listen for `case:selected` events dispatched by the case-search widget and perform the import
@@ -525,11 +769,9 @@
}
const casePk = trigger.dataset.caseId || importCaseId;
if (!casePk) {
alert('Could not determine a target case for this partial import. Use an "Import remaining" button linked to a case or open this page in a case context.');
return;
}
const confirmMsg = `Finish importing ${seriesUids.length} series${casePk ? ` into case ${casePk}` : ''}?`;
const confirmMsg = casePk
? `Finish importing ${seriesUids.length} series into case ${casePk}?`
: `Finish importing ${seriesUids.length} series without linking to a case?`;
if (!window.confirm(confirmMsg)) return;
await runImport(seriesUids, casePk);
@@ -753,6 +995,32 @@
/* small helpers */
.text-muted { color: var(--muted) !important; }
#caseSeriesModal .modal-body {
max-height: min(72vh, 760px);
overflow-y: auto;
}
#caseSeriesModal select[multiple] {
min-height: 14rem;
background: #ffffff;
color: #111827;
border: 1px solid #ced4da;
}
#caseSeriesModal select[multiple] option {
background: #ffffff;
color: #111827;
}
#caseSeriesModal select[multiple] option:checked {
background: #0d6efd;
color: #ffffff;
}
.select2-container {
width: 100% !important;
}
#series-list li {
}
#series-list .selected {
+1
View File
@@ -1,6 +1,7 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block css %}
{% endblock %}
@@ -0,0 +1,273 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>Viewer</title>
<style type="text/css">
body, html {
margin: 0; padding: 0; height: 100%; overflow: hidden;
}
#viewer {
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
}
/* Modal styles */
.center-modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100vw; height: 100vh;
background: rgba(20,20,30,0.96);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.center-modal-content {
background: #23232b;
color: #eee;
padding: 2em 2.5em;
border-radius: 12px;
box-shadow: 0 4px 32px rgba(0,0,0,0.4);
max-width: 90vw;
max-height: 80vh;
text-align: center;
font-size: 1.2em;
}
.highlight-action {
display: inline-block;
background: #0d6efd;
color: #fff;
font-weight: bold;
padding: 0.4em 1em;
border-radius: 6px;
margin: 0.7em 0 1.2em 0;
font-size: 1.3em;
box-shadow: 0 2px 8px rgba(13,110,253,0.15);
letter-spacing: 0.03em;
}
.center-modal-content ul {
color: #bdbdbd;
}
.center-modal-content h2 {
color: #fff;
}
</style>
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer"></script>
</head>
<body>
<!-- Centered modal for instructions -->
<div id="awaiting-modal" class="center-modal">
<div class="center-modal-content">
<span class="highlight-action">Share this screen</span>
<h2>Awaiting Load Request</h2>
<p>This page will display a DICOM series when loaded from another page.</p>
<ul style="text-align:left; margin: 1em auto; max-width: 30em;">
<li>Return to the previous page and select a series to view.</li>
<li>This viewer will update automatically when a series is loaded.</li>
<li>Keep this tab open to receive data.</li>
</ul>
</div>
</div>
<div id="root" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100vw; height: 100vh;"
data-auto-cache-stack="false"
></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;
document.addEventListener("DOMContentLoaded", function() {
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
const POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
// 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(); }); })();
// Helper to safely mount the DICOM viewers (waits for bundle and layout)
function waitForMountFn(timeout = 5000) {
if (window.mountDicomViewers) return Promise.resolve(window.mountDicomViewers);
return new Promise((resolve, reject) => {
const start = Date.now();
function poll() {
if (window.mountDicomViewers) return resolve(window.mountDicomViewers);
if (Date.now() - start > timeout) return reject(new Error('mountDicomViewers not available'));
requestAnimationFrame(poll);
}
poll();
});
}
async function mountDicomViewersSafely(timeout = 5000) {
try {
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const mountFn = await waitForMountFn(timeout);
try { mountFn(); } catch (e) { console.warn('mountDicomViewers failed', e); }
requestAnimationFrame(() => { try { window.dispatchEvent(new Event('resize')); } catch (e) {} });
} catch (err) { console.warn('Viewer initialization deferred/fallback:', err); }
}
function applyViewerPayload(payload) {
if (!payload || payload.type !== 'open') {
return;
}
var root = document.getElementById('root');
var awaiting = document.getElementById('awaiting-modal');
if (!root) {
return;
}
if (awaiting) {
awaiting.style.display = 'none';
}
if (payload.namedStacks) {
root.dataset.namedStacks = payload.namedStacks;
} else {
delete root.dataset.namedStacks;
}
if (payload.images) {
root.dataset.images = payload.images;
} else {
delete root.dataset.images;
}
if (payload.viewerstate && Object.keys(payload.viewerstate).length > 0) {
root.dataset.viewerstate = payload.viewerstate;
} else {
delete root.dataset.viewerstate;
}
if (payload.annotations && Object.keys(payload.annotations).length > 0) {
root.dataset.annotationjson = payload.annotations;
} else {
delete root.dataset.annotationjson;
}
mountDicomViewersSafely();
}
function persistViewerPayload(payload) {
try {
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
} catch (e) {
console.warn('Could not persist viewer payload', e);
}
}
function readPersistedViewerPayload() {
try {
var saved = localStorage.getItem(POPOUT_STATE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (e) {
console.warn('Could not read persisted viewer payload', e);
return null;
}
}
// Intentionally do not auto-load persisted payload on startup.
// The local viva launcher should open to an empty state and wait
// for an explicit "open" message from the controller page.
window.addEventListener('storage', function (event) {
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
return;
}
try {
applyViewerPayload(JSON.parse(event.newValue));
} catch (e) {
console.warn('Could not parse updated viewer payload', e);
}
});
bc.onmessage = (event) => {
// Ensure any existing overlay is closed on new load
try { hideResourceLocal(); } catch(_) {}
console.log("message received");
console.log(event);
if (event.data.type == "open") {
// If a resource URL is provided (image/video/embed), show it in overlay
function isMediaUrl(url) {
if (!url) return false;
const lower = url.toLowerCase();
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
return false;
}
function showResource(url) {
const type = isMediaUrl(url);
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
inner.innerHTML = '';
if (!type) return false;
const transformWrap = document.createElement('div');
transformWrap.style.display = 'flex'; transformWrap.style.alignItems = 'center'; transformWrap.style.justifyContent = 'center';
transformWrap.style.width = '100%'; transformWrap.style.height = '100%'; transformWrap.style.overflow = 'hidden'; transformWrap.style.touchAction = 'none';
if (type === 'image') {
const img = document.createElement('img'); img.src = url; img.style.maxWidth = '95%'; img.style.maxHeight = '95%'; img.style.objectFit = 'contain'; img.style.cursor='grab'; img.draggable=false; transformWrap.appendChild(img);
} else if (type === 'video') {
const v = document.createElement('video'); v.controls=true; v.autoplay=false; v.style.maxWidth='95%'; v.style.maxHeight='95%'; v.style.objectFit='contain'; const s=document.createElement('source'); s.src=url; v.appendChild(s); transformWrap.appendChild(v);
} else if (type === 'embed') {
let embed = url; try{ const m=url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); if (m&&m[1]) embed='https://www.youtube.com/embed/'+m[1]; else if (url.indexOf('vimeo.com')!==-1){ const m2=url.match(/vimeo\.com\/(\d+)/); if(m2&&m2[1]) embed='https://player.vimeo.com/video/'+m2[1]; } }catch(e){}
const iframe=document.createElement('iframe'); iframe.src=embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen',''); iframe.style.width='95%'; iframe.style.height='85%'; transformWrap.appendChild(iframe);
}
inner.appendChild(transformWrap);
// pan/zoom
(function enablePanZoom(target){
let scale=1, minScale=0.5, maxScale=6, startX=0, startY=0, translateX=0, translateY=0, dragging=false;
const content=target.firstElementChild;
function update(){ if(content) content.style.transform=`translate(${translateX}px, ${translateY}px) scale(${scale})`; }
target.addEventListener('wheel', function(e){
const dispEl = document.getElementById('resourceDisplay');
if (!dispEl || dispEl.style.display === 'none') return;
e.preventDefault();
const delta=-e.deltaY||e.wheelDelta; const zoom=delta>0?1.12:0.9; const newScale=Math.min(maxScale, Math.max(minScale, scale*zoom)); const rect=content.getBoundingClientRect(); const mx=e.clientX-rect.left, my=e.clientY-rect.top; const ratio=newScale/scale-1; translateX -= (mx-rect.width/2)*ratio; translateY -= (my-rect.height/2)*ratio; scale=newScale; update();
}, {passive:false});
target.addEventListener('pointerdown', function(e){ dragging=true; startX=e.clientX; startY=e.clientY; target.setPointerCapture(e.pointerId); if(content) content.style.cursor='grabbing'; });
target.addEventListener('pointermove', function(e){ if(!dragging) return; e.preventDefault(); const dx=e.clientX-startX, dy=e.clientY-startY; startX=e.clientX; startY=e.clientY; translateX+=dx; translateY+=dy; update(); });
target.addEventListener('pointerup', function(e){ dragging=false; try{target.releasePointerCapture(e.pointerId)}catch(_){} if(content) content.style.cursor='grab'; });
target.addEventListener('dblclick', function(){ scale=1; translateX=0; translateY=0; update(); });
document.addEventListener('keydown', function(ev){ if(ev.key==='Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
})(transformWrap);
disp.style.display='flex'; return true;
}
if (event.data.url && isMediaUrl(event.data.url)) {
// show resource overlay and do not attempt to mount DICOM viewer
if (showResource(event.data.url)) {
persistViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
return;
}
}
persistViewerPayload(event.data);
applyViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
}
};
});
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
# Atlas tests package
+278 -6
View File
@@ -1,15 +1,287 @@
import json
import pytest
import datetime
# from django.contrib.auth.models import User
from django.http import HttpResponse
from django.urls import reverse
import pytest
from atlas.models import Case, CaseCollection, Finding, Condition
#from atlas.views import GenericExamViews
from pytest_django.asserts import assertRedirects, assertContains, assertQuerySetEqual
from django.test import TestCase
from rich.pretty import pprint
from bs4 import BeautifulSoup
#@pytest.mark.django_db
def test_create_condition(db):
abnormality = Condition.objects.create(name="Abnorm")
assert abnormality.name == "Abnorm"
from django.contrib.auth.models import Group
def test_condition_tree_structure(db):
parent = Condition.objects.create(name="parent")
child1 = Condition.objects.create(name="child1")
child2 = Condition.objects.create(name="child2")
parent.child.set([child1, child2])
child1_1 = Condition.objects.create(name="child1_1")
child1_2 = Condition.objects.create(name="child1_2")
child1.child.set([child1_1, child1_2])
assert len(parent.get_children()) == 2
assert len(child1.get_children()) == 2
assert len(child1.get_parents()) == 1
assert parent in child1.get_parents()
assert len(child2.get_parents()) == 1
assert parent in child2.get_parents()
#@pytest.fixture
#def create_basic_user(db, django_user_model):
# return django_user_model.objects.create_superuser("basicuser", "ross@xkjq.uk", "password")
#
#@pytest.fixture
#def create_exam(db):
# return Exam.objects.create(name="test exam", exam_mode=True)
#
#def test_exam_creation(create_exam):
# exams = Exam.objects.filter(name="test exam")
# assert exams.exists()
#
# exam = exams.first()
#
# assert exam.time_limit == 35 * 60
#
#def test_exam_list(rf, admin_user, create_exam):
# request = rf.get('/rapids/exam/')
# # Remember that when using RequestFactory, the request does not pass
# # through middleware. If your view expects fields such as request.user to be
# # set, you need to set them explicitly.
# # The following line sets request.user to an admin user.
# request.user = admin_user
# response = GenericExamViews.exam_list(request)
# #print(response)
# assert response.status_code == 200
#
#def test_exam_active_admin(rf, admin_user, create_exam, client):
# request = rf.get(reverse('rapids:active_exams'))
# request.user = admin_user
# response = GenericExamViews.active_exams(request)
# print(response.content)
# assert response.status_code == 200
#
#def test_exam_active_basic_user(create_basic_user, create_exam, client):
# client.login(username="basicuser", password="password")
# response = client.get(reverse('rapids:active_exams'))
# print(response.content)
# assert response.status_code == 200
def test_create_case(db, create_case):
case = create_case
assert case.title == "Sample Case"
def test_create_series(db, create_series):
series = create_series
assert series
def test_collection(db, create_case_collection, create_case, make_case, client, create_user):
collection: CaseCollection = create_case_collection
# create some cases
case1 = create_case
case2 = make_case()
case3 = make_case(title="Case 3")
case4 = make_case(title="Case 4")
collection.cases.set([case1, case2, case3, case4])
case5 = make_case(title="Case 5")
assert collection.cases.all().count() == 4
collection.add_case(case5)
assert collection.cases.all().count() == 5
assert collection.get_case_by_index(0) == case1
assert collection.get_case_by_index(1) == case2
assert collection.get_case_by_index(2) == case3
assert collection.get_case_by_index(3) == case4
assert collection.get_case_by_index(4) == case5
assert collection.get_absolute_url() == reverse("atlas:collection_detail", kwargs={"pk": collection.pk})
assert collection.get_take_url() == reverse("atlas:collection_take_start", kwargs={"pk": collection.pk})
start_order = collection.get_cases()
new_order = [
case5.pk,
case1.pk,
case2.pk,
case3.pk,
case4.pk
]
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), json=new_order, follow=True)
# Check that sorting the exam works
# First attempt is not logged in
from urllib.parse import quote
expected_next = quote(reverse("atlas:exam_json_edit", kwargs={"pk": collection.pk}))
assertRedirects(response, f"{reverse('login')}?next={expected_next}")
client.force_login(create_user)
# Random user cannot modify order
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), json=new_order, follow=True)
assert response.status_code == 403
assertQuerySetEqual(start_order , collection.get_cases())
# Once the user has been made an author, they can modify the order
collection.add_author(create_user)
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), data={"set_exam_order" : json.dumps(new_order)}, follow=True)
assert response.status_code == 200
assert collection.get_cases().count() == 5
assert collection.get_case_by_index(0) == case5
assert collection.get_case_by_index(1) == case1
@pytest.mark.django_db
def test_linked_cases_overview_can_copy_study_date_to_chain(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response = client.post(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}),
data={
f"study_date_{case1.pk}": "2026-01-10",
f"study_date_{case2.pk}": "2026-01-12",
"apply_to_all_from": str(case1.pk),
},
follow=True,
)
assert response.status_code == 200
case1.refresh_from_db()
case2.refresh_from_db()
assert str(case1.study_date) == "2026-01-10"
assert str(case2.study_date) == "2026-01-10"
@pytest.mark.django_db
def test_case_save_auto_populates_study_date_when_missing(monkeypatch, make_case):
expected_date = datetime.date(2026, 2, 14)
monkeypatch.setattr(Case, "_get_series_study_date", lambda self: expected_date)
case = make_case(title="Autodate Case")
case.study_date = None
case.save(update_fields=["study_date"])
case.refresh_from_db()
assert case.study_date == expected_date
@pytest.mark.django_db
def test_case_save_preserves_manual_study_date(monkeypatch, make_case):
manual_date = datetime.date(2026, 3, 1)
monkeypatch.setattr(Case, "_get_series_study_date", lambda self: datetime.date(2026, 4, 1))
case = make_case(title="Manual Date Case")
case.study_date = manual_date
case.save(update_fields=["study_date"])
case.refresh_from_db()
assert case.study_date == manual_date
@pytest.mark.django_db
def test_linked_cases_editor_is_hidden_until_requested(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response_default = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk})
)
assert response_default.status_code == 200
assertContains(response_default, "Open case series editor")
assert "Case Series Editor" not in response_default.content.decode("utf-8")
response_with_editor = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}) + "?show_editor=1"
)
assert response_with_editor.status_code == 200
assertContains(response_with_editor, "Case Series Editor")
@pytest.mark.django_db
def test_linked_cases_study_dates_editor_is_hidden_until_requested(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response_default = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk})
)
assert response_default.status_code == 200
assertContains(response_default, "Open study dates editor")
assert "Study Dates" not in response_default.content.decode("utf-8")
response_with_editor = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}) + "?show_dates=1"
)
assert response_with_editor.status_code == 200
assertContains(response_with_editor, "Study Dates")
@pytest.mark.django_db
def test_linked_case_card_shows_scaled_relative_timeline_label(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.study_date = datetime.date(2028, 1, 1)
case2.save(update_fields=["previous_case", "study_date"])
case1.study_date = datetime.date(2026, 1, 1)
case1.save(update_fields=["study_date"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk})
)
assert response.status_code == 200
assertContains(response, "Timeline +2 years")
+188
View File
@@ -0,0 +1,188 @@
import pytest
import datetime
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.test import Client
from atlas.models import CaseCollection, Case, CasePrior, CaseDetail
@pytest.mark.django_db
def test_case_future_priors_chain(make_case):
# Create a chain of cases: case1 -> case2 -> case3
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case3 = make_case(title="Case 3")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case3.previous_case = case2
case3.save(update_fields=["previous_case"])
# Test get_all_prior_cases
assert case1.get_all_prior_cases() == []
assert case2.get_all_prior_cases() == [case1]
assert case3.get_all_prior_cases() == [case2, case1]
# Test get_all_future_cases
assert case1.get_all_future_cases() == [case2, case3]
assert case2.get_all_future_cases() == [case3]
assert case3.get_all_future_cases() == []
@pytest.mark.django_db
def test_case_prior_and_future_relation_post(client, make_case):
# Setup user and collection
user = get_user_model().objects.create_user(
username="author_user", password="password", email="author@test.com"
)
collection = CaseCollection.objects.create(name="Test Prior Future Collection")
collection.author.add(user)
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case3 = make_case(title="Case 3")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case3.previous_case = case2
case3.save(update_fields=["previous_case"])
# Add case2 (the index case) to the collection
casedetail = CaseDetail.objects.create(case=case2, collection=collection)
client.force_login(user)
# 1. Post to link case1 (should be auto-detected as a PRIOR relation)
url = reverse(
"atlas:collection_case_priors",
kwargs={"exam_id": collection.pk, "case_number": 0},
)
response = client.post(
url,
data={
"prior_case_id": case1.pk,
"relation": "Baseline exam",
"prior_visibility": "AL",
},
HTTP_HX_REQUEST="true",
)
assert response.status_code == 200
assert "Baseline exam" in response.content.decode("utf-8")
assert "Prior" in response.content.decode("utf-8")
# Verify in DB
p1 = CasePrior.objects.get(casedetail=casedetail, prior_case=case1)
assert p1.relation_type == CasePrior.RelationType.PRIOR
assert p1.relation_text == "Baseline exam"
# 2. Post to link case3 (should be auto-detected as a FUTURE relation)
response = client.post(
url,
data={
"prior_case_id": case3.pk,
"relation": "Follow-up exam",
"prior_visibility": "AL",
},
HTTP_HX_REQUEST="true",
)
assert response.status_code == 200
assert "Follow-up exam" in response.content.decode("utf-8")
assert "Future" in response.content.decode("utf-8")
# Verify in DB
p3 = CasePrior.objects.get(casedetail=casedetail, prior_case=case3)
assert p3.relation_type == CasePrior.RelationType.FUTURE
assert p3.relation_text == "Follow-up exam"
# Verify GET returns both sections and shows the related cases
response_get = client.get(url)
assert response_get.status_code == 200
content = response_get.content.decode("utf-8")
assert "Available Prior Exams" in content
assert "Available Future Exams" in content
assert "Baseline exam" in content
assert "Follow-up exam" in content
# Test named stacks JSON payload
stacks_json = casedetail.get_case_named_stacks()
assert "Prior: Baseline exam" in stacks_json
assert "Future: Follow-up exam" in stacks_json
# Test DICOM JSON endpoint
dicom_url = reverse(
"atlas:collection_case_dicom_json",
kwargs={"exam_id": collection.pk, "case_number": 0},
)
response_dicom = client.get(dicom_url)
assert response_dicom.status_code == 200
dicom_data = response_dicom.json()
descriptions = [study.get("StudyDescription") for study in dicom_data["studies"] if "StudyDescription" in study]
assert "Prior: Baseline exam" in descriptions
assert "Future: Follow-up exam" in descriptions
# 3. Post to remove case3
response_remove = client.post(
url,
data={
"remove": case3.pk,
},
HTTP_HX_REQUEST="true",
)
assert response_remove.status_code == 200
assert not CasePrior.objects.filter(casedetail=casedetail, prior_case=case3).exists()
@pytest.mark.django_db
def test_linked_cases_reordering(client, make_case):
# Setup user
user = get_user_model().objects.create_user(
username="author_user_reorder", password="password", email="author_reorder@test.com"
)
case1 = make_case(title="Case A")
case2 = make_case(title="Case B")
case3 = make_case(title="Case C")
# Author permissions
case1.author.add(user)
case2.author.add(user)
case3.author.add(user)
# Initial order: case1 -> case2 -> case3
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case3.previous_case = case2
case3.save(update_fields=["previous_case"])
client.force_login(user)
url = reverse("atlas:linked_cases_overview", kwargs={"case_id": case2.pk})
# Verify GET works and shows reorder editor when requested
response_get = client.get(url + "?show_reorder=1")
assert response_get.status_code == 200
assert "Reorder Cases" in response_get.content.decode("utf-8")
# Post new order: case3 -> case1 -> case2
response_post = client.post(
url,
data={
"form_action": "reorder_cases",
"case_order_0": case3.pk,
"case_order_1": case1.pk,
"case_order_2": case2.pk,
},
follow=True,
)
assert response_post.status_code == 200
# Refresh cases from database
case1.refresh_from_db()
case2.refresh_from_db()
case3.refresh_from_db()
# Verify new order in DB: case3 -> case1 -> case2
assert case3.previous_case is None
assert case1.previous_case == case3
assert case2.previous_case == case1
@@ -0,0 +1,13 @@
from __future__ import annotations
import pytest
from atlas.models import SeriesImage
@pytest.mark.django_db
def test_series_viewer_stack_description_includes_plane_and_contrast(create_series):
series = create_series
assert series.get_viewer_stack_description() == "Test Examination (Test Plane) / Test Contrast"
assert series.get_series_dicom_json()["SeriesDescription"] == "Test Examination (Test Plane) / Test Contrast"
+121
View File
@@ -0,0 +1,121 @@
import pytest
from django.urls import reverse
from django.contrib.auth.models import Group, User
from django.test import Client
from atlas.models import CaseCollection, Case, Series, Condition, Finding, Structure, Resource
SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405}
# Fixtures for clients
@pytest.fixture
def candidate_client(db):
user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com")
client = Client()
client.force_login(user)
return client
@pytest.fixture
def editor_client(db):
user = User.objects.create_user(username="editor", password="password", email="editor@test.com")
g, _ = Group.objects.get_or_create(name="atlas_editor")
user.groups.add(g)
client = Client()
client.force_login(user)
return client
@pytest.fixture
def anonymous_client(db):
return Client()
# Fixture to prepare smoke test DB records
@pytest.fixture
def smoke_data(db, create_case, create_series, create_condition, create_finding, create_structure, create_user):
# CaseCollection
collection = CaseCollection.objects.create(name="Smoke Collection")
collection.cases.add(create_case)
collection.author.add(create_user)
# Resource
resource = Resource.objects.create(name="Smoke Resource")
resource.author.add(create_user)
# Make sure condition/finding/structure/series are authored/accessible
create_series.author.add(create_user)
return {
"collection": collection,
"case": create_case,
"series": create_series,
"condition": create_condition,
"finding": create_finding,
"structure": create_structure,
"resource": resource,
}
URL_SPECS = [
("atlas:index", None),
("atlas:help", None),
("atlas:case_view", None),
("atlas:case_create", None),
("atlas:case_detail", lambda d: {"pk": d["case"].pk}),
("atlas:case_update", lambda d: {"pk": d["case"].pk}),
("atlas:case_delete", lambda d: {"pk": d["case"].pk}),
("atlas:collection_view", None),
("atlas:exam_create", None),
("atlas:collection_detail", lambda d: {"pk": d["collection"].pk}),
("atlas:exam_update", lambda d: {"pk": d["collection"].pk}),
("atlas:exam_deleted", lambda d: {"pk": d["collection"].pk}),
("atlas:series_view", None),
("atlas:series_create", None),
("atlas:series_detail", lambda d: {"pk": d["series"].pk}),
("atlas:series_update", lambda d: {"pk": d["series"].pk}),
("atlas:series_delete", lambda d: {"pk": d["series"].pk}),
("atlas:resource_view", None),
("atlas:resource_create", None),
("atlas:resource_detail", lambda d: {"pk": d["resource"].pk}),
("atlas:resource_update", lambda d: {"pk": d["resource"].pk}),
("atlas:resource_delete", lambda d: {"pk": d["resource"].pk}),
("atlas:condition_view", None),
("atlas:condition_create", None),
("atlas:condition_detail", lambda d: {"pk": d["condition"].pk}),
("atlas:condition_update", lambda d: {"pk": d["condition"].pk}),
("atlas:condition_delete", lambda d: {"pk": d["condition"].pk}),
("atlas:finding_view", None),
("atlas:finding_create", None),
("atlas:finding_detail", lambda d: {"pk": d["finding"].pk}),
("atlas:finding_update", lambda d: {"pk": d["finding"].pk}),
("atlas:finding_delete", lambda d: {"pk": d["finding"].pk}),
("atlas:structure_view", None),
("atlas:structure_create", None),
("atlas:structure_detail", lambda d: {"pk": d["structure"].pk}),
("atlas:structure_update", lambda d: {"pk": d["structure"].pk}),
("atlas:structure_delete", lambda d: {"pk": d["structure"].pk}),
]
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = anonymous_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous"
assert response.status_code in {200, 302, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous"
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = candidate_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate"
assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate"
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_editor(editor_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = editor_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Editor"
# Editor should get a success status code (200 OK or 302 redirect), never forbidden or not found for these valid resources
assert response.status_code in {200, 301, 302, 303}, f"Unexpected status code {response.status_code} on {url_name} for Editor"
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import pytest
from django.urls import reverse
from atlas.models import SeriesImage, UncategorisedDicom
@pytest.mark.django_db
def test_user_uploads_partial_import_button_prefers_case_for_hash_matched_series(
client,
admin_user,
create_series,
create_case,
make_case,
):
client.force_login(admin_user)
primary_case = create_case
primary_case.author.add(admin_user)
secondary_case = make_case(title="Secondary Case")
secondary_case.author.add(admin_user)
existing_series = create_series
existing_series.series_instance_uid = "atlas-series-1"
existing_series.study_instance_uid = "study-1"
existing_series.save(update_fields=["series_instance_uid", "study_instance_uid"])
primary_case.series.add(existing_series)
secondary_case.series.add(existing_series)
SeriesImage.objects.create(series=existing_series, image_blake3_hash="shared-hash")
UncategorisedDicom.objects.bulk_create(
[
UncategorisedDicom(
image="pending-upload.dcm",
user=admin_user,
series_instance_uid="uploaded-series-1",
image_blake3_hash="shared-hash",
basic_dicom_tags={
"SeriesInstanceUID": "uploaded-series-1",
"StudyInstanceUID": "study-1",
"StudyDescription": "Shared study",
"SeriesDescription": "Uploaded series",
"Modality": "CT",
},
)
]
)
response = client.get(reverse("atlas:user_uploads"))
assert response.status_code == 200
content = response.content.decode("utf-8")
assert 'class="btn btn-sm btn-warning import-partial-series-btn"' in content
assert f'data-case-id="{primary_case.pk}"' in content
@pytest.mark.django_db
def test_user_uploads_patient_grouping_and_select2(client, admin_user):
client.force_login(admin_user)
# Create UncategorisedDicom with PatientID tag using bulk_create to avoid reading non-existent file
UncategorisedDicom.objects.bulk_create(
[
UncategorisedDicom(
image="pending-upload-patient.dcm",
user=admin_user,
series_instance_uid="uploaded-series-patient-1",
basic_dicom_tags={
"SeriesInstanceUID": "uploaded-series-patient-1",
"StudyInstanceUID": "study-patient-1",
"StudyDescription": "Patient Study 1",
"SeriesDescription": "Uploaded series",
"Modality": "CT",
"PatientID": "PAT-999-XYZ",
},
)
]
)
response = client.get(reverse("atlas:user_uploads"))
assert response.status_code == 200
content = response.content.decode("utf-8")
# Verify patient grouping is rendered
assert "PAT-999-XYZ" in content
assert "Patient ID:" in content
assert "select-all-patient-btn" in content
# Verify form autocomplete widgets are in context
form = response.context["case_series_form"]
from dal import autocomplete
assert isinstance(form.fields["condition"].widget, autocomplete.ModelSelect2Multiple)
assert isinstance(form.fields["presentation"].widget, autocomplete.ModelSelect2Multiple)
assert isinstance(form.fields["procedures"].widget, autocomplete.ModelSelect2Multiple)
+41 -1
View File
@@ -2,7 +2,7 @@ from django.urls import path, include
from django.views.generic import RedirectView, TemplateView
from atlas.models import Condition, Finding, Presentation, Structure, Subspecialty, Procedure
from atlas.models import CaseCollection, Condition, Finding, Presentation, Structure, Subspecialty, Procedure, Series
from . import views
app_name = "atlas"
@@ -39,6 +39,17 @@ urlpatterns = [
path("author/<int:pk>/", views.author_detail, name="author_detail"),
path("author/", views.author_list, name="author_list"),
path("case/", views.CaseView.as_view(), name="case_view"),
path("case/admin-overview", views.case_admin_overview, name="case_admin_overview"),
path(
"case/admin-overview/recent",
views.case_admin_recent_cases_partial,
name="case_admin_recent_cases_partial",
),
path(
"case/admin-overview/largest",
views.case_admin_largest_cases_partial,
name="case_admin_largest_cases_partial",
),
path("collection/<int:collection_id>/add_case", views.add_case_to_collection, name="add_case_to_collection"),
path("collection/case_search", views.case_search, name="case_search"),
path("collection/", views.CollectionView.as_view(), name="collection_view"),
@@ -51,6 +62,7 @@ urlpatterns = [
path("uploads/new", views.new_uploads, name="new_uploads"),
path("uploads/import", views.uploads_import_htmx, name="uploads_import_htmx"),
path("uploads/import/case/<int:case_id>", views.uploads_import_htmx, name="uploads_import_case_htmx"),
path("uploads/import/case-series", views.uploads_import_case_series_htmx, name="uploads_import_case_series_htmx"),
path("uploads/case/<int:case_id>", views.user_uploads, name="user_uploads_case"),
path("uploads/<int:user_pk>/user/case/<int:case_id>", views.other_user_uploads, name="other_user_uploads_case"),
path("uploads/series_id/<str:series_instance_uid>", views.user_uploads_series, name="user_uploads_series"),
@@ -85,8 +97,10 @@ urlpatterns = [
),
path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"),
path("collection/viewer_local", TemplateView.as_view(template_name="atlas/viewer_local.html"), name="collection_viewer_local"),
path("collection/viewer_local_progressive", TemplateView.as_view(template_name="atlas/viewer_local_progressive.html"), name="collection_viewer_local_progressive"),
path("collection/<int:pk>", views.collection_detail, name="collection_detail"),
path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"),
path("collection/<int:pk>/viva-local", views.collection_viva_local, name="collection_viva_local"),
path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"),
path("collection/<int:pk>/viva/<int:series_id>/series", views.collection_viva_series, name="collection_viva_series"),
path(
@@ -240,6 +254,7 @@ urlpatterns = [
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"),
path("case/<int:pk>/create-resource-inline/", views.case_create_resource_inline, name="case_create_resource_inline"),
# Backwards-compatible redirect from legacy case_id-style URLs
path(
"collection/<int:exam_id>/case/id/<int:case_id>/details",
@@ -424,6 +439,11 @@ urlpatterns = [
views.redirect_collection_case_view_take_user_answers_by_id,
name="collection_case_view_take_user_answers_legacy",
),
path(
"collection/<int:pk>/<int:case_number>/take/review/<int:user_pk>/",
views.collection_case_view_take_review_user,
name="collection_case_view_take_review_user",
),
path(
"collection/<int:pk>/json_edit",
views.GenericExamViews.exam_json_edit,
@@ -556,6 +576,11 @@ urlpatterns = [
# TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
path(
"series/<int:series_id>/reconstruct/advanced/",
views.series_reconstruct_advanced,
name="series_reconstruct_advanced",
),
path(
"series/<int:series_id>/truncate/<int:start>/<int:end>/",
views.series_truncate_htmx,
@@ -576,6 +601,11 @@ urlpatterns = [
views.series_reconstruct_status_htmx,
name="series_reconstruct_status",
),
path(
"series/<int:series_id>/downsample/status/<str:task_id>/",
views.series_downsample_status_htmx,
name="series_downsample_status",
),
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
@@ -746,11 +776,21 @@ urlpatterns = [
views.ProcedureAutocomplete.as_view(model=Procedure, create_field="name"),
name="procedure-autocomplete",
),
path(
"casecollection-prerequisite-autocomplete",
views.CaseCollectionPrerequisiteAutocomplete.as_view(model=CaseCollection),
name="casecollection-prerequisite-autocomplete",
),
path(
"subspecialty-autocomplete",
views.SubspecialtyAutocomplete.as_view(model=Subspecialty, create_field="name"),
name="subspecialty-autocomplete",
),
path(
"series-autocomplete",
views.SeriesAutocomplete.as_view(model=Series),
name="series-autocomplete",
),
path("presentation/", views.PresentationView.as_view(), name="presentation_view"),
path(
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
+3028 -738
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
import os
import shutil
import pytest
from django.conf import settings
@pytest.fixture(scope="session", autouse=True)
def cleanup_test_media():
"""
Session-scoped fixture to automatically clean up temporary media files
generated during the test run.
"""
yield
# Clean up settings.MEDIA_ROOT
media_root = getattr(settings, "MEDIA_ROOT", None)
if media_root and os.path.exists(media_root) and "media_test" in media_root:
shutil.rmtree(media_root, ignore_errors=True)
# Also clean up any legacy directories created due to missing trailing slash
base_dir = getattr(settings, "BASE_DIR", None)
if base_dir:
legacy_dir = os.path.join(base_dir, "media_testlongs")
if os.path.exists(legacy_dir):
shutil.rmtree(legacy_dir, ignore_errors=True)
+20 -1
View File
@@ -13,6 +13,15 @@ server {
client_max_body_size 100M;
location /rts/ {
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#proxy_set_header X-Forwarded-Proto $scheme;
#proxy_set_header Host $host;
#proxy_redirect off;
#proxy_buffering off;
alias /usr/src/app/rts/;
}
location /static/ {
alias /usr/src/app/static/;
expires 30d;
@@ -23,10 +32,20 @@ server {
alias /usr/src/app/media;
}
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app_server;
}
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://app_server;
+20
View File
@@ -87,6 +87,16 @@ server {
proxy_redirect http://reporter:5129/ /reporter/;
}
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app_server;
}
# Default proxy to the Django/gunicorn upstream
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -124,6 +134,16 @@ server {
location /static/ { alias /usr/src/app/static/; }
location /media { alias /usr/src/app/media; }
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app_server;
}
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+5 -4
View File
@@ -66,7 +66,7 @@ if not DEBUG:
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': 'log.txt',
'filename': 'logs/app.log',
'formatter': 'verbose',
},
},
@@ -89,8 +89,9 @@ CIMAR_PASSWORD = "<REPLACE-WITH-SECRET>"
# Celery settings — when running in docker the redis service is
# reachable at the `redis` hostname provided by docker compose.
CELERY_BROKER_URL = "redis://redis:6379"
CELERY_RESULT_BACKEND = "redis://redis:6379"
REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
CELERY_BROKER_URL = f"redis://{REDIS_HOST}:6379"
CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:6379"
DATABASES = {
"default": {
@@ -127,4 +128,4 @@ CSRF_TRUSTED_ORIGINS = [
"http://localhost:8080/",
]
REMOTE_URL = "http://127.0.0.1:8000"
REMOTE_URL = "http://127.0.0.1:8080"
+4 -3
View File
@@ -33,7 +33,7 @@ if not DEBUG:
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': 'log.txt',
'filename': 'logs/app.log',
'formatter': 'verbose',
},
},
@@ -56,5 +56,6 @@ CIMAR_PASSWORD = "<REPLACE-WITH-SECRET>"
# Celery settings — when running in docker the redis service is
# reachable at the `redis` hostname provided by docker compose.
CELERY_BROKER_URL = "redis://redis:6379"
CELERY_RESULT_BACKEND = "redis://redis:6379"
REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
CELERY_BROKER_URL = f"redis://{REDIS_HOST}:6379"
CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:6379"
+10 -41
View File
@@ -17,6 +17,9 @@ services:
- ../:/usr/src/app:cached
# Mount the app log directory so logs are visible on the host at ./logs
- ../logs:/var/log/rad
- ../../media:/usr/src/app/media
- ../../static:/usr/src/app/static
- /home/ross/rts:/usr/src/app/rts
worker:
env_file:
@@ -24,6 +27,9 @@ services:
volumes:
- ../:/usr/src/app:cached
- ../logs:/var/log/rad
- ../../media:/usr/src/app/media
- ../../static:/usr/src/app/static
- /home/ross/rts:/usr/src/app/rts
entrypoint:
[
"sh",
@@ -39,46 +45,9 @@ services:
volumes:
- ../deploy/nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro
- ../logs:/var/log/rad:rw
- ../../media:/usr/src/app/media:ro
- ../../static:/usr/src/app/static:ro
- /home/ross/rts:/usr/src/app/rts:ro
loki:
image: grafana/loki:2.8.2
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
volumes:
- ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
- ./loki-data:/loki
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
interval: 5s
timeout: 3s
retries: 6
promtail:
image: grafana/promtail:2.8.2
volumes:
- ../logs:/var/log/rad:ro
- ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
command: -config.file=/etc/promtail/promtail-config.yml
depends_on:
- loki
restart: unless-stopped
grafana:
image: grafana/grafana:10.2.0
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
restart: unless-stopped
volumes:
grafana-data:
loki-data:
# No additional volumes required for local dev
+7 -4
View File
@@ -23,8 +23,9 @@ services:
# (Optional) keep explicit mounts for media/static/backups if you need
# to override them separately; otherwise they are covered by the repo mount.
- ../backups:/usr/src/app/backups
- ../media:/usr/src/app/media
- ../static:/usr/src/app/static
- ../../media:/usr/src/app/media
- ../../static:/usr/src/app/static
- /home/ross/rts:/usr/src/app/rts
ports:
- 8000:8000
- 3459:3459
@@ -40,8 +41,8 @@ services:
volumes:
- ../:/usr/src/app
- ../backups:/usr/src/app/backups
- ../media:/usr/src/app/media
- ../static:/usr/src/app/static
- ../../media:/usr/src/app/media
- ../../static:/usr/src/app/static
env_file:
- ./.env.dev.local
depends_on:
@@ -55,6 +56,8 @@ services:
- POSTGRES_USER=django
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=rad
ports:
- "5432:5432"
pgadmin:
image: dpage/pgadmin4:7.2
environment:
+46 -46
View File
@@ -86,58 +86,58 @@ services:
timeout: 10s
retries: 3
# Optional local observability stack (can be enabled on servers that
# should run Loki/Grafana). These services use configs found under
# the repository `docker/` directory so the same config works in dev.
loki:
image: grafana/loki:2.8.2
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
volumes:
- ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
- ../docker/loki-data:/loki
# WAL directory: map a host folder so Loki (running as UID 10001)
# can create the write-ahead log. Host folder created under
# `docker/loki-wal` and owned by UID 10001.
- ../docker/loki-wal:/wal
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
interval: 10s
timeout: 5s
retries: 6
promtail:
image: grafana/promtail:2.8.2
volumes:
# In prod you may want to point this at the host system log directory.
- ../logs:/var/log/rad:ro
- ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
command: -config.file=/etc/promtail/promtail-config.yml
depends_on:
- loki
restart: unless-stopped
grafana:
image: grafana/grafana:10.2.0
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
restart: unless-stopped
# # Optional local observability stack (can be enabled on servers that
# # should run Loki/Grafana). These services use configs found under
# # the repository `docker/` directory so the same config works in dev.
# loki:
# image: grafana/loki:2.8.2
# command: -config.file=/etc/loki/local-config.yaml
# ports:
# - "3100:3100"
# volumes:
# - ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
# - ../docker/loki-data:/loki
# # WAL directory: map a host folder so Loki (running as UID 10001)
# # can create the write-ahead log. Host folder created under
# # `docker/loki-wal` and owned by UID 10001.
# - ../docker/loki-wal:/wal
# restart: unless-stopped
# healthcheck:
# test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
# interval: 10s
# timeout: 5s
# retries: 6
#
# promtail:
# image: grafana/promtail:2.8.2
# volumes:
# # In prod you may want to point this at the host system log directory.
# - ../logs:/var/log/rad:ro
# - ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
# command: -config.file=/etc/promtail/promtail-config.yml
# depends_on:
# - loki
# restart: unless-stopped
#
# grafana:
# image: grafana/grafana:10.2.0
# ports:
# - "3000:3000"
# environment:
# - GF_SECURITY_ADMIN_PASSWORD=admin
# volumes:
# - grafana-data:/var/lib/grafana
# depends_on:
# loki:
# condition: service_healthy
# restart: unless-stopped
volumes:
postgres_data:
redis_data:
static_volume:
media_volume:
grafana-data:
#grafana-data:
networks:
default:
+2
View File
@@ -12,6 +12,8 @@ ingester:
chunk_idle_period: 5m
chunk_retain_period: 30s
max_transfer_retries: 0
wal:
dir: /loki/wal
schema_config:
configs:
+11 -6
View File
@@ -17,9 +17,14 @@ python manage.py migrate --noinput
echo "Collecting static files..."
python manage.py collectstatic --noinput
echo "Starting gunicorn..."
exec gunicorn rad.wsgi:application \
--name rad_gunicorn \
--bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \
--log-level ${GUNICORN_LOGLEVEL:-info}
if [ "${SERVER_TYPE}" = "daphne" ] || [ "${SERVER_TYPE}" = "asgi" ]; then
echo "Starting daphne..."
exec daphne -b 0.0.0.0 -p 8000 rad.asgi:application
else
echo "Starting gunicorn..."
exec gunicorn rad.wsgi:application \
--name rad_gunicorn \
--bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \
--log-level ${GUNICORN_LOGLEVEL:-info}
fi
+10
View File
@@ -144,6 +144,16 @@ class SupervisorFilter(django_filters.FilterSet):
"site": ["exact"],
}
@property
def qs(self):
parent = super().qs
if "active" not in self.request.GET:
return parent.filter(active=True)
return parent
def __init__(
self,
data=None,
+16 -2
View File
@@ -12,7 +12,7 @@ from django.forms import (
DateField,
SplitDateTimeField,
SplitDateTimeWidget,
BooleanField,
)
from django.forms import inlineformset_factory
from atlas.models import CaseCollection
@@ -146,6 +146,10 @@ class ExamFormMixin:
"active",
"publish_results",
"archive",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
Accordion(
AccordionGroup("Date restrictions",
"restrict_to_dates",
@@ -214,7 +218,11 @@ class ExamFormMixin:
"active",
"publish_results",
"archive",
"results_supervisor_visible"
"results_supervisor_visible",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
# "cid_user_groups",
# "user_user_groups",
# "author",
@@ -812,6 +820,12 @@ class UserUserForm(Form):
first_name = CharField(max_length=255, required=True)
last_name = CharField(max_length=255, required=True)
grade = ModelChoiceField(UserGrades.objects.all(), required=False)
create_supervisor = BooleanField(
required=False,
label="Create linked supervisor account",
help_text="If checked, a supervisor profile with this user's name and email will be automatically created.",
initial=False,
)
class TraineeForm(Form):

Some files were not shown because too many files have changed in this diff Show More