Compare commits

..
422 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
Ross 93e24bf855 feat(tasks): Add seriesId to stacks in Case model and enhance series viewer with navigation buttons 2026-05-18 08:33:46 +01:00
Ross 1c76e0696c feat(tasks): Refactor series_reconstruct_task and add _stamp_derived_dataset for improved dataset handling 2026-05-18 07:48:24 +01:00
Ross ff00efecf7 feat(tasks): Enhance series_reconstruct_task with additional metadata and improved slice handling 2026-05-17 22:29:23 +01:00
Ross a9946961f1 fix(tasks): Update _reduce_slab function to accept reduce_axis parameter for flexibility in reconstruction 2026-05-17 22:20:22 +01:00
Ross d5e473d692 style(tasks): Improve task overview page layout and styling for better readability 2026-05-17 22:08:07 +01:00
Ross 75e3bca503 feat(tasks): Implement task action functionality in task overview page 2026-05-17 21:53:30 +01:00
Ross 1775b11e40 fix(tasks): Correct ImagePositionPatient assignment in series_reconstruct_task 2026-05-17 21:44:09 +01:00
Ross d77c5149f0 feat(worker): Enhance db_worker script for better error handling and add systemd service example 2026-05-17 21:36:46 +01:00
Ross c2d8376e22 feat(worker): Add db_worker service with automatic restart and logging
feat(task_overview): Display generated series in task overview
docs(README): Update documentation for background task workers and troubleshooting
2026-05-17 21:13:40 +01:00
Ross ab9847e22e feat(tasks): Add task overview page with active, completed, and failed task summaries 2026-05-17 14:30:38 +01:00
Ross bbf7542451 feat(settings): Update task backend to use django-tasks-db and add QUEUES configuration 2026-05-17 14:00:04 +01:00
Ross e6a20bb851 feat(tasks): Integrate django-tasks for task management and progress tracking 2026-05-17 12:41:17 +01:00
Ross 7ed0a8f378 feat(reconstruction): Add asynchronous series reconstruction task with progress tracking 2026-05-17 11:32:48 +01:00
Ross c8d05818a4 feat(reconstruction): Enhance volume reconstruction with geometry extraction and thickness merge options 2026-05-17 11:19:18 +01:00
Ross b6716c9ae1 feat(viewer): Refactor truncate viewer API integration and update modal handling 2026-05-17 11:04:18 +01:00
Ross e368d0c7d1 feat(series): Add functions for inferring native spacings and resampling volumes for optimization 2026-05-16 22:42:49 +01:00
Ross b9cbfd20c1 feat(optimization): Implement series optimization features including downsampling, empty DICOM removal, and reconstruction options 2026-05-16 22:30:55 +01:00
Ross 5ce337c7c4 feat(series): Enhance series truncation with options for removing empty DICOMs and downsampling pixel data 2026-05-16 22:02:50 +01:00
Ross f4d990676a feat(viewer): Enhance sidebar functionality with adjustable width and responsive behavior 2026-05-15 20:28:05 +01:00
Ross 4f61e43b8c feat(viewer): Refactor DICOM viewer structure and update responsive styles 2026-05-15 20:19:12 +01:00
Ross c947cba5a7 feat(viewer): Enhance viewer functionality with sidebar and popout support 2026-05-15 20:11:31 +01:00
Ross 0cf655325c feat: Enhance collection review and overview templates with improved styling and conditional content display 2026-05-15 20:04:13 +01:00
Ross 89e89cef73 feat: Remove slug field from ResearchStudy model and update related references 2026-05-12 21:51:44 +01:00
Ross c94d2fb1a7 feat(research): add research study management functionality
- Implemented models for ResearchStudy, ResearchStudyArm, and ResearchParticipant.
- Created views for listing, creating, updating, and exporting research studies.
- Added bulk participant generation feature with CSV and JSON support.
- Developed templates for study management, participant entry, and bulk generation.
- Introduced URL routing for research study operations.
- Added utility functions for randomisation and participant assignment.
- Implemented participant intake process with demographic data collection.
- Ensured proper handling of participant CIDs and assignment methods.
2026-05-12 21:37:57 +01:00
Ross e28bf641b7 fix: Correct indentation for viewer div elements in collection_case_view_take.html and series_viewer.html 2026-05-11 13:31:50 +01:00
Ross 59956670df feat: Implement responsive design enhancements with new utility classes and improved layout for various components 2026-05-11 13:31:25 +01:00
Ross d2a4ee9f70 feat: Refactor navigation bar for consistent structure and improved user experience across templates 2026-05-11 13:26:45 +01:00
Ross c7fd99f6f3 feat: Update navbar identifiers for better accessibility and mobile responsiveness 2026-05-11 13:25:28 +01:00
Ross cf4cb32c5e feat: Enhance media cleanup functionality with loading indicator and detailed results 2026-05-11 13:10:05 +01:00
Ross f4c2c4bdaf feat: Update index.html layout and improve user authentication flow 2026-05-11 13:01:45 +01:00
Ross 8da8f27433 feat: Add media cleanup functionality with UI and backend support for unused media management 2026-05-11 12:58:41 +01:00
Ross c3131564e6 fix: Correct logical condition for better OCR candidate selection 2026-05-11 12:50:11 +01:00
Ross 0bda5e2c41 feat: Enhance OCR processing with increased sample counts and improved candidate selection logic 2026-05-11 12:49:22 +01:00
Ross 6b9c7be633 feat: Implement OCR debug panel visibility sync and enhance OCR processing with improved canvas handling 2026-05-11 12:42:00 +01:00
Ross 35887fbcaf refactor: Clean up OCR debug table structure and remove redundant regex checks 2026-05-11 12:32:54 +01:00
Ross f9a9b9bb38 feat: Add OCR debug panel with detailed status and results for image processing 2026-05-11 12:30:19 +01:00
Ross 916ac3b69c feat: Improve OCR sample selection and detection of clinical identifiers 2026-05-11 12:27:28 +01:00
Ross 7ac8517062 feat: Enhance duplicate series handling with deselect functionality and UI updates 2026-05-11 12:20:51 +01:00
Ross 22c5576fc9 feat: Add anonymisation issue summary rendering for flagged series in upload process 2026-05-11 12:17:43 +01:00
Ross 52e6e79c1b fix: Remove unnecessary field reference from series in check_images_hashes function 2026-05-11 12:11:59 +01:00
Ross 3455dc3855 fix: Update field reference from series__pk to series__id in check_images_hashes function 2026-05-11 12:10:12 +01:00
Ross 2b9f3e3b68 feat: Optimize image hash checks with bulk queries and add database indexing for performance 2026-05-11 12:07:55 +01:00
Ross e55a3bf36a fix: Correct duplicate check logic in upload process 2026-05-11 12:00:38 +01:00
Ross b24eac862b feat: Implement duplicate check functionality during DICOM uploads and update viewer element ID 2026-05-11 11:53:18 +01:00
Ross 84b032addb Refactor code structure for improved readability and maintainability 2026-05-11 11:31:04 +01:00
Ross d046b7a151 Refactor code structure for improved readability and maintainability 2026-05-11 11:06:51 +01:00
Ross aaa3ee6313 feat: Add support for study instance UID in series and enhance DICOM import functionality 2026-05-11 10:05:39 +01:00
Ross c1e351c7db feat: Enhance user uploads with support for partial imports and improved UI feedback 2026-05-11 09:25:41 +01:00
Ross 863d65b4b3 feat: Refactor smoke tests to focus on standard named routes and improve route filtering 2026-05-11 09:09:22 +01:00
RossandCopilot 26a73d1d3b feat: Update test configurations and add smoke tests for named view routes
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:35:11 +01:00
RossandCopilot d12933e62a feat: Enhance user uploads with import suggestions for already uploaded DICOM series
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:20:09 +01:00
RossandCopilot 771abf62e2 feat: Refactor series tag consistency modal with improved UI and analysis functionality
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:08:26 +01:00
RossandCopilot 859042bf96 feat: Add DICOM tag consistency analysis with dynamic loading in series viewer
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:01:54 +01:00
RossandCopilot 6574c29a70 feat: Enhance series splitting functionality with DICOM tag analysis and improved user feedback
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:57:19 +01:00
RossandCopilot 9edac288f5 feat: Add transaction import to views for improved database handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:51:17 +01:00
RossandCopilot ab7dcaed76 feat: Implement series splitting by DICOM tag with HTMX support
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:46:36 +01:00
RossandCopilot 2aff9516ac feat: Enhance security by adding @login_required to multiple views and replacing print statements with logger.debug
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:35:08 +01:00
Ross 5d37cad48d feat: Refactor resource, description, history, and viewer sections for improved readability 2026-04-30 20:32:14 +01:00
RossandCopilot 6575c50507 feat: Enhance case collection and detail forms with timing overrides and custom screens
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 20:31:16 +01:00
RossandCopilot 74cdf14af3 feat: Add option to include template debug noise in logs view
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:47:43 +01:00
Ross 9ef4b2dac1 feat: Add button to view raw log file in logs viewer 2026-04-29 22:43:45 +01:00
RossandCopilot 2e35fa7036 feat: Add raw log file viewing option in logs view
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:42:02 +01:00
RossandCopilot 6be7398b42 feat: Enhance logs view with level filtering and improved entry display
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:40:08 +01:00
RossandCopilot 2478de6b98 feat: Enhance logs view with support for displaying all entries and improved pagination
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:37:13 +01:00
RossandCopilot 7479b44f53 feat: Add logs view for superusers to display and search production logs
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:32:18 +01:00
RossandCopilot 89c8cd565f feat: Implement supervisor trainee management features, including search, add, and remove functionalities
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:27:09 +01:00
RossandCopilot f63f3b3687 feat: Enhance supervision page with collapsible group sections for better organization
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:50:45 +01:00
RossandCopilot 8201f91697 fix: Improve sorting and normalization of study dates in user uploads
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:36:20 +01:00
RossandCopilot 082a260757 feat: Consolidate shared attempts functionality into supervision page and remove legacy shared-with-me page
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:28:57 +01:00
RossandCopilot 5bd1338b5e feat: Add supervision page to display collection attempts shared with the user
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:18:43 +01:00
RossandCopilot 7062723433 feat: Implement sharing functionality for CidUserExam attempts, including user management and sharing settings
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:09:06 +01:00
RossandCopilot cc100dba89 fix: Update case messaging conditionals for clarity and correctness
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 21:36:30 +01:00
RossandCopilot 64aa2974ee feat: Add admin edit links for superusers in case history view
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 21:34:46 +01:00
Ross 47cfa79083 try to fix thumbnails in d3vd 2026-04-28 21:13:02 +01:00
RossandCopilot 5c217266c4 feat: Add safe mounting for display set viewers to enhance viewer initialization
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:56:43 +01:00
RossandCopilot 68dfcdfa7e feat: Implement dynamic loading for display set modals and fullscreen viewer
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:48:20 +01:00
RossandCopilot e894e02445 feat: Refactor collection take start template for improved layout and user experience
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:11:19 +01:00
Ross f7cc51c221 feat: Add documentation for Atlas widgets including case_search_widget, finding_search_widget, and displayset_search_widget 2026-04-28 14:04:48 +01:00
Ross 56a644c5c1 fix: Correct indentation for hx-include attribute in case search widget 2026-04-27 23:11:57 +01:00
Ross 4a787aa3eb feat: Add show select button option to case search widget and related templates for improved case selection 2026-04-27 23:10:20 +01:00
Ross 7031942198 feat: Enhance case selection functionality with dedicated select button and improved event handling 2026-04-27 23:07:06 +01:00
Ross 585a19013a feat: Enhance case:selected event handling with logging for better debugging 2026-04-27 22:58:20 +01:00
Ross 61a83ed836 feat: Refactor move series case selection logic with improved event handling and state synchronization 2026-04-27 22:55:23 +01:00
Ross bf12204c51 feat: Refactor move series functionality by simplifying event handling and removing redundant code 2026-04-27 22:49:52 +01:00
Ross 29165811d1 feat: Enhance linked cases overview with detailed case information and navigation, and add linked case card partial 2026-04-27 22:41:16 +01:00
Ross 02ad8c61e5 feat: Implement per-case query and messaging functionality with UI updates and tests 2026-04-27 22:28:45 +01:00
Ross 70ea3788ca feat: Add functionality to move selected series between cases with a new form and UI enhancements 2026-04-27 22:16:06 +01:00
Ross 3772846e55 feat: Update user messages inbox and collection user messages with enhanced acknowledgment statistics and navigation links 2026-04-27 22:05:30 +01:00
Ross 1876a7f845 feat: Enhance collection history page with detailed user and CID exam statistics 2026-04-27 21:49:59 +01:00
Ross d4f25de9d3 feat: Enhance case conversation display with detailed message stats and loading indicator 2026-04-27 21:45:27 +01:00
Ross 0b1f842e31 feat: Update case conversation details to reflect total unacknowledged messages 2026-04-27 21:40:58 +01:00
Ross 29f9b724b0 feat: Add guidance for session submission and clarify editing options 2026-04-27 21:37:06 +01:00
Ross 1f1b324cfe feat: Update collection overview header and remove completed state display 2026-04-27 21:35:29 +01:00
Ross 474ad205f6 feat: Add feedback overview button visibility based on review mode and exam completion status 2026-04-27 21:34:30 +01:00
Ross 1f806d2e60 feat: Update self-review and conversation visibility based on review mode and exam completion status 2026-04-27 21:33:34 +01:00
Ross 20987274af feat: Update self-review button visibility based on question completion status 2026-04-27 21:30:34 +01:00
Ross d82551674b feat: Conditionally load case conversation based on question completion status 2026-04-27 21:28:57 +01:00
Ross 0d1318564c feat: Enhance series item display with additional metadata and modularize template 2026-04-27 21:25:31 +01:00
Ross 9a8a1a1b57 feat: Add navigation buttons for collection overview and user answers in messages inbox 2026-04-27 13:29:42 +01:00
Ross 9df74edc8c feat: Add moderator action buttons in user messages inbox for enhanced navigation 2026-04-27 13:17:06 +01:00
Ross 3db2c1a768 feat: Implement user messages inbox with message acknowledgment tracking and UI updates 2026-04-27 13:09:19 +01:00
Ross dc479cafd0 feat: Add case review messaging system with feedback and query capabilities
- Introduced CaseReviewMessage model to handle feedback and queries for cases.
- Implemented views for displaying user and CID messages, including outstanding feedback.
- Created templates for collection feedback overview and user messages.
- Added functionality for acknowledging messages and sending feedback or queries.
- Enhanced collection detail view to show outstanding messages and user message statistics.
- Added tests for handling duplicate DICOM uploads without series.
2026-04-27 12:55:27 +01:00
Ross b02120d5e7 update dv3d 2026-04-27 12:50:10 +01:00
Ross be02269af6 Fix duplicate handling in DICOM upload: ensure series is not None before adding to duplicate series 2026-04-27 12:48:13 +01:00
Ross 5843547a05 Add end time display for completed user exams in collection overview 2026-04-27 12:06:45 +01:00
Ross 36a77981c8 Enhance self-review functionality with new detail views, improved UI, and dynamic case management 2026-04-27 11:43:15 +01:00
Ross 95793c78c9 Refactor self-review functionality: replace inline actions with dedicated partials and enhance dynamic panel updates 2026-04-27 11:16:48 +01:00
Ross 37e1bf29a3 Add self-review popup functionality with success message and auto-refresh 2026-04-27 11:12:44 +01:00
Ross b864f3d3fd Implement self-review functionality with enhanced UI and dynamic scoring options 2026-04-27 11:07:09 +01:00
Ross e82b353ad9 Enhance case display sets management with HTMX integration and improved UI elements 2026-04-27 10:48:02 +01:00
Ross 854f265623 Add agent guidelines documentation for RAD project architecture, models, views, templates, testing, and deployment 2026-04-27 10:04:56 +01:00
Ross bebac18625 Refactor user_scores.html to replace Case Collections section with Atlas Collections, implementing HTMX for dynamic loading of user-specific collections. 2026-04-27 09:58:00 +01:00
Ross 068dd3df80 Refactor normal case toggle buttons for improved readability and structure 2026-04-27 09:46:39 +01:00
Ross 9bae94a595 Refactor collection case view documentation and enhance management view with edit permissions 2026-04-27 09:42:46 +01:00
Ross df7f56f34c Update dv3d 2026-04-27 09:37:57 +01:00
Ross f47f408cce Add empty case handling for differentials in partial template 2026-04-13 14:32:33 +01:00
Ross c0870a8765 Fix loop to use correct method for retrieving ordered series in findings partial 2026-04-13 14:30:48 +01:00
Ross 113424d1e4 Fix indentation for included partials in case display block template 2026-04-13 14:28:23 +01:00
Ross e007e35f4f Add show_differentials_post field and update templates for case differentials display 2026-04-13 14:27:14 +01:00
Ross e162e60f44 Refactor case display logic by creating partials for findings and display sets to enhance maintainability and readability 2026-04-13 14:15:04 +01:00
Ross 5c50ee75da Add post-exam display options to CaseCollection model and update template 2026-04-13 14:07:54 +01:00
Ross 90c8f0b60d Enhance logging and error handling in collection access methods and WSGI configuration 2026-04-13 13:44:25 +01:00
Ross 463a741185 Refactor exam_user_bulk_edit method to support both generic and app-specific exam IDs for improved flexibility 2026-04-13 10:00:03 +01:00
Ross f7b6ec1447 Refactor exam_users_edit.html to improve formatting and maintainability of the user addition panel 2026-04-13 09:57:25 +01:00
Ross 3e03413047 Add user search widget to exam_users_edit.html for adding arbitrary users 2026-04-13 09:54:18 +01:00
Ross 126d186ad1 Add disabled nav link for 'Candidate Management' in exam_candidate_headers.html for improved navigation clarity 2026-04-13 09:53:41 +01:00
Ross a749373a21 Refactor exam_candidate_headers.html to wrap navigation links in a structured nav and ul for improved semantics and styling 2026-04-13 09:48:24 +01:00
Ross d9331fe279 Refactor exam_groups_edit.html to use crispy forms for improved form rendering and styling 2026-04-13 09:45:58 +01:00
Ross 3e936e494a Update exam-related URL patterns to use 'collection' prefix and add bulk edit routes 2026-04-13 09:38:41 +01:00
Ross 5a050fb36d Add custom template filter 'has_group' and update question_link_header for improved user permission checks 2026-03-25 22:12:33 +00:00
Ross 2d48a6bad6 Enhance row selection toggle functionality in SelectionTable to manage user selection state and improve user experience 2026-03-25 22:10:15 +00:00
Ross d4f0c05341 Enhance row selection functionality in SelectionTable to support shift-range selection and improve checkbox handling 2026-03-25 22:07:56 +00:00
Ross 7b52cab180 Refactor SelectionTable to use per-table initializers for row selection controls and improve JavaScript handling in templates 2026-03-25 22:05:19 +00:00
Ross ffb730e9c8 Remove exam_mode filter for superusers in generic_exam_htmx to display all non-archived exams 2026-03-25 21:37:56 +00:00
Ross 1b199150e5 Refactor row selection handling in SelectionTable to server-side rendering and per-table initializers for improved maintainability 2026-03-25 21:37:43 +00:00
Ross bf7f88ac6e Render row selection controls server-side in SelectionTable for improved usability 2026-03-25 21:34:38 +00:00
Ross 1d19afa72d Refactor tables to inherit from SelectionTable for consistent behavior across Anatomy and Question tables 2026-03-25 21:26:32 +00:00
Ross bd0c71140c Update button elements in selection controls to ensure proper type attribute for improved accessibility 2026-03-25 21:21:50 +00:00
Ross d6487eae88 Enhance migration process for rapids to shorts; skip normal rapids and provide user feedback on migration status 2026-03-25 21:14:33 +00:00
Ross 06fe73e2a3 Add migration actions for Rapid questions to Shorts in admin and templates 2026-03-25 21:12:30 +00:00
Ross ec0bad02c6 Implement modal opening for tokens in markup; replace popup actions with modal invocations for display sets and findings 2026-03-23 16:51:25 +00:00
Ross de5b6c36f3 Refactor modal handling for display sets and findings; extract modals into partials for improved maintainability 2026-03-23 16:13:56 +00:00
Ross 90e22435a5 Add preview templates and update URLs for display sets and series findings 2026-03-23 15:39:38 +00:00
Ross a82e797bae Enhance markup rendering to support interactive tokens with styled spans and data attributes for client-side upgrades 2026-03-23 15:34:57 +00:00
Ross 97db951c79 Enhance markup inserter and search result templates with custom label prompts for cases, display sets, and findings 2026-03-23 14:35:59 +00:00
Ross f70ecb4648 Enhance finding search functionality to support series findings and improve result display with badges 2026-03-23 14:30:07 +00:00
Ross a792f0c7cd Enhance finding and display set widgets with explicit case filtering and badge indicators 2026-03-23 14:18:25 +00:00
Ross 13d73020ee Enhance finding and display set pickers with dynamic case filtering and badge indicators 2026-03-23 13:59:28 +00:00
Ross 4d6d535ca7 Enhance display set and finding pickers with case detection and filtering 2026-03-23 13:54:44 +00:00
Ross fbb2a2cc18 Allow standard fetch/ajax requests in finding and displayset search endpoints 2026-03-23 13:41:37 +00:00
Ross 20b4d6b6af Add finding and displayset search functionality with corresponding widgets and endpoints 2026-03-23 13:34:28 +00:00
Ross d7ed8881d8 Implement case picker modal for enhanced case linking in markup inserter 2026-03-23 12:26:19 +00:00
Ross 3ae6ce53bc Enhance markup inserter with logging and auto-attach functionality for textareas 2026-03-23 12:15:49 +00:00
Ross 5a5eaa04a2 Add markup inserter functionality and render markup filter for enhanced text editing 2026-03-23 11:46:21 +00:00
Ross d75d2be547 Implement feature X to enhance user experience and fix bug Y in module Z 2026-03-23 11:08:34 +00:00
Ross f65e78b959 Add new URL patterns for user and submitted CID lists in exam collections 2026-03-23 11:04:20 +00:00
Ross 7526f6a902 Refactor DICOM viewer initialization to use safe mounting helper for improved reliability and error handling 2026-03-23 10:46:28 +00:00
Ross b17774aa7c Refactor modal reopening logic to streamline URL parameter handling and improve code clarity 2026-03-23 10:35:07 +00:00
Ross 077bd3ed87 Refactor display set modal loading functions for improved readability and error handling 2026-03-23 10:34:03 +00:00
Ross 73a364fd44 Enhance modal functionality: add URL parameter handling for displaysets and findings, and reopen modals based on URL params 2026-03-23 10:27:13 +00:00
Ross 8733f59882 Add fullscreen display set modal and button for enhanced viewing experience 2026-03-23 10:24:07 +00:00
Ross 64d4958744 Fix link in case search item template to direct to case detail page 2026-03-23 09:58:49 +00:00
Ross 9e0e66bcd9 update dv3d 2026-03-23 09:44:51 +00:00
Ross ec4dcdba2b Add empty state display for cases without display sets and link to add new ones 2026-03-23 09:43:27 +00:00
Ross 4512b799a6 Add get_full_url method to SeriesImageBase and update DICOM template to use it 2026-03-23 09:35:28 +00:00
Ross d313cd083c Add admin hash search link and enhance uploads hash search functionality 2026-03-18 20:57:29 +00:00
Ross 8bbb6b741d Remove truncation from image hash display in series images template 2026-03-18 19:56:26 +00:00
Ross d00a7f9cf6 Add admin feature to search uploads by pixel hash and corresponding template 2026-03-18 19:38:20 +00:00
Ross 70602e6f71 Refactor code structure for improved readability and maintainability 2026-03-16 14:09:29 +00:00
Ross b35057d059 Fix indentation for download link in case display block 2026-03-16 12:28:31 +00:00
Ross 1cadbab742 Enhance JSON display by integrating pretty JSON formatter in various templates 2026-03-16 12:08:34 +00:00
Ross 3ea51f976d Add pretty JSON filter and enhance JSON display in viewer state and annotations 2026-03-16 11:56:03 +00:00
Ross d0686dbe17 update dv3d 2026-03-16 11:05:50 +00:00
Ross e391f4f943 Add download functionality for case DICOMs and metadata as ZIP 2026-03-16 11:03:37 +00:00
Ross 65208ae7ad Refactor presentation display by moving it to the metadata column for a tighter layout 2026-03-09 12:24:06 +00:00
Ross 82bea5ff05 Refactor series actions layout by removing unnecessary div and improving structure 2026-03-09 12:18:03 +00:00
Ross 71c5cc6c24 Enhance series actions layout with improved flexbox styling and structure 2026-03-09 12:09:46 +00:00
Ross 6c7a37ca4a Refactor series actions layout and enhance button styles for improved usability 2026-03-09 12:06:24 +00:00
Ross 4788d56dec Add series block styling with dark theme support and enhance highlight effects 2026-03-09 12:02:41 +00:00
Ross 42bffc0c07 Enhance case description and presentation styles with dark mode support 2026-03-09 11:40:50 +00:00
Ross 996ef4877c Refactor case metadata layout and improve quick-edit button integration 2026-03-09 10:44:48 +00:00
Ross b87db68329 Enhance quick-edit button functionality and layout in case metadata display 2026-03-09 10:44:06 +00:00
Ross b87c0a776c Implement feature X to enhance user experience and optimize performance 2026-03-09 10:18:43 +00:00
Ross af9bfabeed Update subspecialty and pathological process display to use 'None' badge for empty states 2026-03-09 09:48:42 +00:00
Ross 2c482216c1 Add case viewing buttons and improve layout in case display block 2026-03-09 09:46:25 +00:00
Ross 61c768b80d Fix selection toggle logic for series items in user uploads 2026-03-02 13:14:18 +00:00
Ross 5d0ff3373d Enhance series selection functionality with select/deselect all buttons and improve selection toggle logic 2026-03-02 13:14:05 +00:00
Ross c14ad2dcf7 Refactor case import functionality and enhance logging in views 2026-03-02 13:04:14 +00:00
Ross b503558ee4 Add case selection handling and import functionality in user uploads 2026-03-02 12:48:55 +00:00
Ross c177d21688 Fix indentation for quick edit buttons and form in case inline field template 2026-03-02 12:45:26 +00:00
Ross 2a1db54a17 Enhance case inline editing and search functionality with improved UI and new subspecialty handling 2026-03-02 12:41:25 +00:00
Ross b1ec4b173e Refactor case inline editing and search widget for improved functionality and layout 2026-03-02 12:31:06 +00:00
Ross 4c1e0c3d2a Add case inline editing functionality and search widget improvements 2026-03-02 12:23:51 +00:00
Ross 4e6f72e045 Add case selection modal for importing series into existing cases 2026-03-02 11:42:25 +00:00
Ross ed3bd3433b Add quick sort options to normals list for improved user navigation 2026-03-02 11:41:32 +00:00
Ross 2c6d0f2613 Add sorting functionality to normal cases list with user-selected options 2026-03-02 11:38:17 +00:00
Ross 7cac5aa60f Refactor user uploads template to enhance selection functionality and update count display 2026-03-02 11:32:19 +00:00
Ross a5a4084994 Add modality badge to series display for improved information visibility 2026-03-02 11:20:52 +00:00
Ross 7caaa425ed Refactor series item layout to use responsive grid for improved display and interaction 2026-03-02 11:19:45 +00:00
Ross 904c023bf1 Refactor series list display for improved layout and styling 2026-03-02 11:18:00 +00:00
Ross e2e0e374e6 Refactor series tags modal for improved structure and loading indication 2026-03-02 11:15:23 +00:00
Ross af2dcd95b2 Add modal for viewing series DICOM tags with HTMX integration and improved styling 2026-03-02 11:14:28 +00:00
Ross 6ff8706fa4 Refactor logging setup to use Loguru in WSGI and views for improved logging consistency and debugging 2026-03-02 11:09:05 +00:00
Ross 12e3d47600 Enhance logging configuration in WSGI setup for better debugging and error handling 2026-03-02 11:04:51 +00:00
Ross b3f5684e69 Add global HTMX error handler to prevent JS errors when error container is missing 2026-03-02 10:39:46 +00:00
Ross 166b0a0282 Refactor CSS formatting in user uploads template for improved readability 2026-03-02 10:37:02 +00:00
Ross ed6418efad Add select/deselect all buttons for series in user uploads view and enhance styles for better visibility 2026-03-02 10:36:21 +00:00
Ross 0c6d33e26a Enhance user uploads view by grouping series by StudyInstanceUID and adding a partial template for displaying common DICOM tags 2026-03-02 10:31:04 +00:00
Ross b43d441da1 Refactor authentication for DICOM endpoints to use TokenAuth and enhance error handling in upload process 2026-03-02 10:02:17 +00:00
Ross b7cf022622 Refactor import_dicoms_helper to accept explicit queryset and enhance upload_dicom_case with detailed upload status reporting 2026-02-23 13:16:32 +00:00
Ross 419063fdeb Enhance BearerAuth to set request.user from api_token and improve series author assignment in import_dicoms_helper 2026-02-23 12:52:55 +00:00
Ross a6205c95cb Update authentication method to BearerAuth for DICOM endpoints 2026-02-23 12:48:58 +00:00
Ross c00e08d63e Refactor token authentication: streamline bearer token validation and improve error handling 2026-02-23 12:41:58 +00:00
Ross a3ac91437f Implement API token authentication: add BearerAuth and SessionOrBearer classes, create endpoints for token generation and validation 2026-02-23 12:35:29 +00:00
Ross d7f3ebf950 Add API token management: implement APIToken model, views, and templates for creating and revoking tokens 2026-02-23 11:57:57 +00:00
Ross 83d1b48213 Add DICOM upload and case retrieval endpoints: implement upload_dicom_case and get_cases_available functions 2026-02-23 10:45:34 +00:00
Ross 65db1220d8 Fix button formatting in series image size partial template 2026-02-23 10:34:18 +00:00
Ross 3cdfba0acf Add image size display to series viewer: implement HTMX endpoint and partial template for total image size 2026-02-23 09:55:35 +00:00
Ross d899d0d86a Enhance ProcedureForm and ProcedureCreate: add form helper initialization, update procedure form action URL, and implement logging in dispatch method 2026-02-16 14:24:07 +00:00
Ross 44d23801b2 Add Procedure model integration: create views, forms, and templates; update URLs and search functionality 2026-02-16 14:15:28 +00:00
Ross 6e3ab7de41 Add procedures filter to NormalCaseFilter; update fields and queryset handling 2026-02-16 14:08:37 +00:00
Ross a51a5c8d54 Add Procedure model and integrate into CaseForm; update views and templates for procedure handling 2026-02-16 14:04:37 +00:00
Ross c61f92ae9e Refactor exam search widget: streamline filtering logic and enhance queryset efficiency 2026-02-16 13:49:17 +00:00
Ross 6e30fcc19f Enhance exam search widget: refactor query handling to support wildcard searches and improve filter options rendering 2026-02-16 13:46:09 +00:00
Ross 36da045d71 Enhance NormalCase functionality: prepopulate age fields on edit, update author handling in create case normal view, and refine template logic for editing permissions 2026-02-16 13:45:10 +00:00
Ross 0b6661c018 Refactor NormalCase model: remove 'added_by' field, add 'author' ManyToManyField; update related views and templates for editing functionality 2026-02-16 13:32:06 +00:00
Ross 64103cac1e Enhance exam search widget: improve filter handling and parsing in JavaScript 2026-02-16 13:17:08 +00:00
Ross 990f4534bd Update case display block: replace plain text with links for findings, structures, and conditions 2026-02-16 12:12:18 +00:00
Ross 4382c6070e Enhance exam search widget: add error handling for model retrieval and improve query parsing 2026-02-16 12:10:00 +00:00
Ross 8ba9543c89 Refactor exam search widget: simplify model retrieval and remove redundant code 2026-02-16 12:03:57 +00:00
Ross f2eaf6197e Refactor exam management: update exam edit form and add exam links partial 2026-02-16 11:47:49 +00:00
Ross b19eefa60d Change button text from "Add" to "Save" in exam edit form 2026-02-16 11:27:20 +00:00
Ross 0718ea82b2 Add AddExamForm and update exam_edit_fragment for exam management 2026-02-16 11:21:51 +00:00
Ross fb9952e7aa Replace FilteredSelectMultiple with ExamSearchWidget for exams field in multiple forms 2026-02-16 10:54:39 +00:00
Ross ed1a33ac89 Add conditional rendering for edit buttons in question detail and suggest incorrect templates 2026-02-16 10:51:42 +00:00
Ross eddfb85d7b Enhance navbar tap targets for touch devices in mobile view 2026-02-16 10:48:43 +00:00
Ross f133fc3fd0 Add viewport meta tag for responsive design 2026-02-16 10:47:26 +00:00
Ross 2f90a21abe Reduce popup window width for improved content display 2026-02-16 10:44:05 +00:00
Ross 98ce450f11 Increase popup window width for better content display 2026-02-16 10:42:01 +00:00
Ross 6734036477 Add conditional rendering for delete option in question actions dropdown 2026-02-16 10:41:08 +00:00
Ross 5243100d7e Add conditional rendering for edit options in question actions dropdown 2026-02-16 10:40:30 +00:00
Ross fa82d81a96 Update data-edit_annotation attribute to use conditional rendering based on edit permissions 2026-02-16 10:38:48 +00:00
Ross 726057b51e Refactor image annotation help section for improved readability and conditional rendering 2026-02-16 10:35:28 +00:00
Ross 647081a7d6 Add conditional rendering for save annotations button based on edit permissions 2026-02-16 10:31:16 +00:00
Ross 57b9c712c2 Add delete functionality for image annotations in question detail view 2026-02-16 10:28:32 +00:00
Ross 4d78a6b63a Add visual indicators for answer statuses and enhance warning message for missing image annotations 2026-02-16 10:01:29 +00:00
Ross 941c599d24 Add warning alert for missing image annotations in question detail view 2026-02-16 09:54:45 +00:00
Ross 2df789735c Refactor button and style indentation in primary answer display for improved readability 2026-02-16 09:51:05 +00:00
Ross 37812921b8 Enhance primary answer display with edit functionality and conditional rendering based on user permissions 2026-02-16 09:50:52 +00:00
Ross 4cdddcf288 Refactor HTML structure and JavaScript indentation in primary answer templates for improved readability 2026-02-16 09:47:03 +00:00
Ross 17d3b4206a Implement primary answer management with HTMX support, including form for selecting or creating answers and display templates 2026-02-16 09:46:57 +00:00
Ross ec03def164 Alter primary_answer and structure fields in AnatomyQuestion model for improved clarity and categorization 2026-02-16 09:36:06 +00:00
Ross f72ec82525 Add primary_answer field to AnatomyQuestion model and create migration to backfill existing values 2026-02-16 09:32:25 +00:00
Ross 90c96333a1 Add help text for AnatomyQuestion structure field and enhance form instructions 2026-02-16 09:22:00 +00:00
670 changed files with 123858 additions and 5960 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
+160
View File
@@ -0,0 +1,160 @@
# RAD Project — Agent Guidelines
Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL + Redis + Celery stack.
## Architecture
### Apps
`generic` · `anatomy` · `physics` · `rapids` · `shorts` · `longs` · `sbas` · `wally` · `atlas` · `rcr` · `rota` · `oef`
- `generic/` — shared base models, views, forms, mixins, decorators, widgets. **Always check here before adding new base classes or utilities.**
- `atlas/` — CaseCollection-based learning (DICOM cases, findings, display sets, differentials)
- `rad/` — project config (`settings.py`, `urls.py`, `celery.py`, root API)
### Request/Response Pattern
- HTMX-first: `if request.htmx:` returns a partial, else returns a full page.
- Full pages extend `base.html`. HTMX partials live in `{app}/templates/{app}/partials/`.
- 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).
## Models
- **Auto-timestamp**: use `created_at = models.DateTimeField(auto_now_add=True)` — match the convention already on the model you are editing.
- **Primary keys**: default Django `AutoField` (integer). Do not switch to UUID without discussion.
- **Audit trail**: wrap models that need history with `@reversion.register()`.
- **Base classes** in `generic/models.py`:
- `QuestionBase`, `ExamBase`, `UserAnswerBase` — exam content
- `AuthorMixin` — M2M authors field
- `CidUser`, `CidUserGroup` — cohort-based access
- After any model change: run `python manage.py makemigrations && python manage.py migrate`.
## Views
- Mix of FBVs and CBVs. Prefer the pattern already used in the app you are editing.
- **Login**: `@login_required` (FBV) or `LoginRequiredMixin` (CBV).
- **Permission decorators** in `generic/decorators.py`: `@user_is_cid_user_manager`, `@check_user_in_group`.
- **Key CBV mixins** in `generic/views.py`: `CidManagerRequiredMixin`, `AuthorRequiredMixin`, `CheckCanEditMixin`.
- Do not expose `can_edit=True` to candidate-facing views. Management and candidate views are intentionally separate.
- When editing views please update docstrings to include their scope and functionality.
## Templates
- **Inheritance**: all full pages use `{% extends "base.html" %}`.
- **Partials path**: `{app}/templates/{app}/partials/_fragment_name.html` (prefix with `_`).
- **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, 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
- **Crispy Forms** with `crispy_bootstrap5`. All forms should use a `FormHelper`.
- `Layout`, `Fieldset`, `Div`, `Row`, `Column` from `crispy_forms.layout` for structure.
- Custom widgets in `generic/widgets.py` — check before creating new widgets.
- Autocomplete fields use `django-autocomplete-light` (`dal`).
## Testing
- **Runner**: `pytest` (config in `pytest.ini`; `--nomigrations` is on by default).
- **Files**: `{app}/tests/test_*.py` or `{app}/tests.py`.
- **Fixtures**: shared fixtures in `{app}/tests/conftest.py`. Check `generic/tests/conftest.py` for common fixtures (`create_user`, `create_examination`, etc.).
- 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`.
- Broker and result backend: Redis at `redis://redis:6379` (Docker service name).
- Import the shared Celery app: `from rad.celery import app as celery_app`.
## Static & Media
- Static: `python manage.py collectstatic --noinput`. App static dirs at `{app}/static/`.
- Media: custom `HashedFilenameFileSystemStorage`. Do not assume filenames are original.
- DICOM thumbnails via `easy_thumbnails` + `helpers.images.pil_dicom_image`.
## Settings & Secrets
- **Never hardcode secrets**. Use `settings_local.py` (gitignored) or environment variables.
- Env vars: `SECRET_KEY`, `DB_NAME/USER/PASSWORD/HOST/PORT`, `MEMCACHE_HOST`, `DEBUG`.
- Local overrides: `rad/settings_local.py` (imported at the bottom of `settings.py`).
## Docker (Dev)
### Start Dev Environment
Start the development server with local configurations:
```bash
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml up -d --build
```
### 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).
## Key Conventions
| Convention | Detail |
|---|---|
| HTMX partial detection | `if request.htmx: return render(...)` |
| Partial naming | `_fragment_name.html` with leading underscore |
| Permission checks | Decorators/mixins from `generic/`; never inline `if user.is_staff` in templates |
| Model timestamps | `created_at` (auto_now_add) preferred |
| API schemas | Django Ninja `ModelSchema`; explicit field lists |
| Audit trail | `@reversion.register()` on audited models |
| Logging | `loguru` (`from loguru import logger`) |
| Candidate vs management views | Keep separate; `can_edit` must not leak to candidate views |
## Before Opening a PR
1. `pytest` — all tests pass
2. `python manage.py makemigrations --check` — no missing migrations
3. `python manage.py check` — no system check errors
4. No hardcoded secrets or debug prints
+68 -6
View File
@@ -11,6 +11,69 @@ Running locally (development)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
```
Background task workers (automatic)
- The compose stack now includes a dedicated `worker` service which runs:
```sh
python manage.py db_worker
```
- This means workers start automatically with `docker compose up` and restart on failure.
- To inspect worker logs:
```sh
docker compose logs -f worker
```
- To scale workers (for higher throughput):
```sh
docker compose up -d --scale worker=2
```
Troubleshooting `SIGKILL` when running manually
- A `SIGKILL` on `db_worker` is usually the kernel OOM killer (out-of-memory), not a Django exception.
- Check kernel OOM events:
```sh
dmesg -T | grep -i -E "killed process|out of memory|oom"
```
- If OOM is confirmed, prefer running worker inside compose (managed restart + container limits), reduce concurrency/parallel services, or add memory/swap.
Manual worker startup (non-Docker production)
- If you are not yet running production in Docker, start the worker using the restart wrapper script:
```sh
source .venv/bin/activate
DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh
```
- This script restarts `db_worker` if it exits unexpectedly.
- To run it in the background and keep logs:
```sh
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
```
- In fish shell, follow with `disown` so the shell does not keep the job attached:
```sh
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
disown
```
- For server reliability, prefer running the same script under `systemd` (auto-start on reboot, restart-on-failure).
- A starter unit file is provided at `scripts/db-worker.service.example`.
- To verify it is still running:
```sh
ps aux | grep "manage.py db_worker" | grep -v grep
tail -f logs/db_worker.log
```
- By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are:
- `NGINX_HTTP_PORT=8080`
- `NGINX_HTTPS_PORT=8444`
@@ -40,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.
@@ -53,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).
+231
View File
@@ -0,0 +1,231 @@
# Security Audit Report
**Date:** 2026-04-30
**Scope:** All `views.py` files and `generic/decorators.py` across the entire project.
---
## Issues Fixed
### 1. Missing `@login_required` on `exam_collection_exams_partial` (generic/views.py)
**Severity:** High
**File:** `generic/views.py`
The `exam_collection_exams_partial` view was publicly accessible with no authentication. It exposed exam names, candidate counts, and author information from any collection to anonymous users.
**Fix:** Added `@login_required` decorator.
---
### 2. Unauthenticated `get_*_id` popup-helper views (multiple files)
**Severity:** High
**Files:** `generic/views.py`, `anatomy/views.py`, `rapids/views.py`, `longs/views.py`, `atlas/views.py`
All of the following views were decorated with `@csrf_exempt` but had **no authentication**. They allowed unauthenticated users to enumerate internal database records (Examinations, BodyParts, Structures, Abnormalities, Regions) by name via GET requests.
Additionally, all of these views contained a bug: `request.accepts("application/json")()` calls the boolean result of `accepts()` as a function, which raises `TypeError: 'bool' object is not callable`. This made them effectively non-functional (always returning `HttpResponse("/")`).
| File | View |
|---|---|
| `generic/views.py` | `get_examination_id` |
| `anatomy/views.py` | `get_body_part_id`, `get_examination_id`, `get_structure_id` |
| `rapids/views.py` | `get_abnormality_id`, `get_examination_id`, `get_region_id` |
| `longs/views.py` | `get_examination_id` |
| `atlas/views.py` | `get_examination_id` |
**Fix:**
- Removed `@csrf_exempt` (GET-only views do not need CSRF exemption).
- Added `@login_required`.
- Fixed `request.accepts("application/json")()``request.accepts("application/json")`.
- Replaced `request.GET["key"]` dict access (raises `KeyError` on missing key) with `request.GET.get("key", "")`.
- Added docstrings documenting scope and functionality.
---
### 3. Unauthenticated `answer_suggestion_submit` with CSRF exemption (rad/views.py)
**Severity:** High
**File:** `rad/views.py` (line ~781)
This view accepted anonymous POST requests with no CSRF protection to create new `Answer` records (marked `proposed=True`) on any existing question. This allowed anyone on the internet to submit arbitrary proposed answers to anatomy or rapids questions, polluting the moderation queue.
**Fix:**
- Removed `@csrf_exempt`.
- Added `@login_required`.
- Added `if request.method != "POST": return ...` guard.
- Fixed a minor typo in the response message (`"submited"``"submitted"`).
- Added docstring documenting scope and functionality.
---
### 4. Debug `print()` in `check_user_in_group` decorator (generic/decorators.py)
**Severity:** Medium
**File:** `generic/decorators.py`
The `check_user_in_group` decorator printed `request.user` to stdout on every invocation, leaking usernames/email addresses to application logs.
**Fix:** Removed the `print(request.user)` statement.
---
### 5. Debug `print()` statements leaking sensitive data (multiple files)
**Severity:** Medium
**Files:** `generic/views.py`, `atlas/views.py`, `longs/views.py`, `sbas/views.py`, `physics/views.py`, `shorts/views.py`, `rcr/views.py`, `oef/views.py`
Numerous active (non-commented) `print()` calls throughout view code leaked sensitive data to stdout/production logs, including:
- User objects and usernames
- CID numbers and passcodes
- Email address lists being processed
- Exam and queryset data
**Fix:** All active `print()` statements replaced with `logger.debug()` (using the project-standard `loguru` logger) or removed where they provided no diagnostic value. Commented-out print statements were left in place.
---
### 6. `@csrf_exempt` on `collection_case_displaysetup` (atlas/views.py)
**Severity:** Low
**File:** `atlas/views.py`
The view had `@csrf_exempt` applied alongside a comment saying "Only if you have CSRF issues; otherwise, keep CSRF protection". The view already has proper authentication via `@user_is_collection_author_or_atlas_editor`, so the CSRF exemption was unnecessary and weakened security.
**Fix:** Removed `@csrf_exempt`.
---
### 7. Missing `@login_required` on `toggle_share_with_supervisor` (generic/views.py)
**Severity:** Medium
**File:** `generic/views.py`
This HTMX view had no authentication check. An anonymous user with a known `CidUserExam` PK could toggle the `share_with_supervisor` flag (although the `request.user != cid_user_exam.user_user` check would prevent the toggle from succeeding, it still leaks whether the record exists).
**Fix:** Added `@login_required` and updated docstring.
---
### 8. Missing `@login_required` on `active_exams` in sbas/views.py
**Severity:** Medium
**File:** `sbas/views.py`
The `active_exams` view had no authentication. It returned a list of all active SBA exams to anonymous users.
**Fix:** Added `@login_required`. Updated docstring.
---
### 9. Unreachable dead code in `rcr/views.py`
**Severity:** Low
**File:** `rcr/views.py`
In the `get_object` method of an `UpdateView` subclass, two lines appeared after a `return obj` statement:
```python
return obj
print("test") # unreachable
return super().get_object() # unreachable
```
**Fix:** Removed the unreachable `print("test")` and `return super().get_object()` lines.
---
## Issues Requiring Clarification (Not Fixed)
### A. `@csrf_exempt` on `exam_submit` (rad/views.py)
**File:** `rad/views.py`
This view accepts POST submissions of exam answers without CSRF protection and without `@login_required`. It appears to be a legacy mobile/external client endpoint that dispatches to `post_exam_answers()` on individual exam view classes, which may perform their own CID/passcode authentication.
**Risk:** Without authentication at this layer, the endpoint could be abused to submit bogus exam answers for real users if a valid CID/passcode combination is known.
**Recommendation:** Clarify whether this endpoint is still in active use by a mobile client. If so, implement token-based authentication (e.g. Django REST Framework token auth) or require CID/passcode in the request body and validate before dispatching. If not in use, remove it.
---
### B. Unauthenticated views in `rcr/views.py`
**File:** `rcr/views.py`
The following views have no authentication decorators:
- `radiology_gap` — renders a full RCR gap analysis page with all curriculum items
- `radiology_results` — renders all RCR assessment results
**Risk:** If these pages are intended to be internal-only (for assessors), they should require authentication. If they are intentionally public (e.g., public-facing curriculum overview), this is acceptable.
**Recommendation:** Confirm intended visibility. If internal, add `@login_required` (or a more specific decorator).
---
### C. Unauthenticated class-based views in `oef/views.py`
**File:** `oef/views.py`
The following CBVs lack `LoginRequiredMixin`:
- `EntryUpdateView` — allows updating OEF entry fields
- `EntryTableView` — lists all entries
- `FormatsCreateView` — creates new Formats objects
- `FormatsUpdateView` — updates Formats objects
The update views in particular are a concern if this is not an intentionally public tool.
**Recommendation:** Add `LoginRequiredMixin` (and appropriate permission checks) if this app is not intended to be publicly editable.
---
### D. Commented-out `@login_required` on `exam_toggle_flag` in physics/views.py
**File:** `physics/views.py`
The `exam_toggle_flag` view has `#@login_required` commented out. The view falls back to `exam.check_user_can_take(cid, passcode, request.user)` for access control, suggesting this is intentionally accessible to CID users (non-Django-auth users). However, the intent should be confirmed.
**Recommendation:** If the CID/passcode check is the intended authentication mechanism, document this clearly. If the view should also support Django-authenticated users, restore the decorator appropriately.
---
### E. `exam_submit` potential `AttributeError` (rad/views.py)
**File:** `rad/views.py`
```python
exam_type, exam_id = request.POST.get("eid").split("/")
```
If `eid` is missing from the POST body, `request.POST.get("eid")` returns `None`, and `.split("/")` raises `AttributeError`. This would return a 500 response rather than a clean error.
**Recommendation:** Guard with:
```python
eid = request.POST.get("eid", "")
if "/" not in eid:
return JsonResponse({"success": False, "error": "Invalid eid"})
exam_type, exam_id = eid.split("/", 1)
```
---
### F. Open redirect risk in `RedirectMixin` and `UpdateUserProfileView` (generic/views.py, rad/views.py)
**File:** `generic/views.py`, `rad/views.py`
Both `RedirectMixin.get_success_url` and `UpdateUserProfileView.get_success_url` use `self.request.GET["redirect"]` / `self.request.GET.get("redirect")` as the redirect target without validation. A malicious actor could craft a URL like `?redirect=https://evil.com` to redirect authenticated users to a phishing site after a successful form submission.
**Recommendation:** Validate the redirect URL with Django's `url_has_allowed_host_and_scheme`:
```python
from django.utils.http import url_has_allowed_host_and_scheme
redirect_url = self.request.GET.get("redirect", "")
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts={self.request.get_host()}):
return redirect_url
return super().get_success_url()
```
---
### G. Missing login check on `sbas/views.py` `#@login_required` commented-out views
**File:** `sbas/views.py`
Two views have `@login_required` commented out:
- `question_view` (around line 222) — currently commented out entirely
- `question_detail` (around line 226) — currently commented out entirely
These are fully commented-out functions; no action needed unless they are re-enabled.
---
## Notes on Existing `@csrf_exempt` Usage
The following `@csrf_exempt` usages remain after fixes. All are on GET-only admin popup helpers that are now also `@login_required`. CSRF protection is not relevant for idempotent GET requests, but the decorators are harmless:
> All `@csrf_exempt` instances have been removed from the codebase as part of this audit. The pattern `@csrf_exempt` on GET-only views is redundant since CSRF protection only applies to state-changing requests (POST/PUT/PATCH/DELETE).
+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
+16 -1
View File
@@ -290,4 +290,19 @@ class ExamGroupsForm(ExamGroupsFormMixin):
class SuggestIncorrectAnswerForm(ModelForm):
class Meta:
model = AnatomyQuestion
fields = ["answer_suggest_incorrect"]
fields = ["answer_suggest_incorrect"]
class PrimaryAnswerForm(Form):
existing_answer = ChoiceField(required=False, choices=[], label="Use existing answer")
new_answer = CharField(required=False, widget=Textarea(attrs={"rows":3}), label="Or create new primary answer")
def __init__(self, *args, **kwargs):
question = kwargs.pop("question", None)
super().__init__(*args, **kwargs)
choices = [("", "-- choose existing --")]
if question is not None:
# Only include existing non-proposed CORRECT answers, ordered by answer text
for a in question.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).order_by('answer'):
choices.append((str(a.pk), a.answer))
self.fields["existing_answer"].choices = choices
@@ -0,0 +1,43 @@
"""Add primary_answer FK to AnatomyQuestion and backfill existing values.
This migration adds a nullable FK `primary_answer` on AnatomyQuestion
pointing to Answer, then backfills it using the current logic of
selecting the first non-proposed CORRECT answer (status '2').
"""
from django.db import migrations, models
import django.db.models.deletion
def forwards(apps, schema_editor):
AnatomyQuestion = apps.get_model('anatomy', 'AnatomyQuestion')
Answer = apps.get_model('anatomy', 'Answer')
# Select first non-proposed CORRECT answer (status '2') per question
for q in AnatomyQuestion.objects.all():
ans = Answer.objects.filter(question_id=q.pk, proposed=False, status='2').first()
if ans:
q.primary_answer_id = ans.pk
q.save(update_fields=['primary_answer'])
def backwards(apps, schema_editor):
AnatomyQuestion = apps.get_model('anatomy', 'AnatomyQuestion')
for q in AnatomyQuestion.objects.all():
q.primary_answer = None
q.save(update_fields=['primary_answer'])
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0025_exam_created_exam_updated'),
]
operations = [
migrations.AddField(
model_name='anatomyquestion',
name='primary_answer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for_questions', to='anatomy.answer'),
),
migrations.RunPython(forwards, backwards),
]
@@ -0,0 +1,25 @@
# Generated by Django 6.0.1 on 2026-02-16 09:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0026_add_primary_answer'),
('atlas', '0090_casecollection_created_casecollection_updated'),
]
operations = [
migrations.AlterField(
model_name='anatomyquestion',
name='primary_answer',
field=models.ForeignKey(blank=True, help_text='Optional explicit primary answer for this question', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for_questions', to='anatomy.answer'),
),
migrations.AlterField(
model_name='anatomyquestion',
name='structure',
field=models.ForeignKey(blank=True, help_text='The structure that is being asked about in the question, this is used for categorisation and search purposes but is not required to be set for a question to be valid.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='atlas.structure'),
),
]
@@ -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),
),
]
+87 -58
View File
@@ -116,6 +116,8 @@ class AnatomyQuestion(QuestionBase):
)
structure = models.ForeignKey(
Structure, on_delete=models.SET_NULL, null=True, blank=True
, help_text="The structure that is being asked about in the question, this is used for categorisation and search purposes but is not required to be set for a question to be valid."
)
author = models.ManyToManyField(
@@ -125,6 +127,15 @@ class AnatomyQuestion(QuestionBase):
related_name="anatomy_authored_questions",
)
primary_answer = models.ForeignKey(
'Answer',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='primary_for_questions',
help_text="Optional explicit primary answer for this question",
)
class Meta:
permissions = ()
@@ -149,33 +160,32 @@ class AnatomyQuestion(QuestionBase):
def get_absolute_url(self):
return reverse("anatomy:question_detail", kwargs={"pk": self.pk})
def get_primary_answer(self):
if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer:
ans = self.prefetched_primary_answer[0]
else:
ans = self.answers.filter(
proposed=False, status=Answer.MarkOptions.CORRECT
).first()
# ans = self.answers.first()
def get_primary_answer_obj(self):
"""Return the Answer instance considered the primary answer, or None.
Preference order:
- explicit `primary_answer` FK if set
- `prefetched_primary_answer` if provided by a queryset
- first non-proposed Answer with status CORRECT
"""
if getattr(self, "primary_answer", None):
return self.primary_answer
if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer:
return self.prefetched_primary_answer[0]
return self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).first()
def get_primary_answer(self):
"""Return the primary answer text (string) for templates and display.
Keeps backward compatibility by returning the answer string when
available, or "None yet..." if no primary answer is defined.
"""
ans = self.get_primary_answer_obj()
if ans is None:
return "None yet..."
else:
return ans.answer
if (
self.answers.filter(
proposed=False, status=Answer.MarkOptions.CORRECT
).count()
> 0
):
return (
self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT)
.first()
.answer
)
else:
return "None yet..."
return ans.answer
def get_exams(self):
e = self.exams.all().values_list("name", flat=True)
@@ -328,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:
@@ -541,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)
@@ -559,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
@@ -590,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
+19
View File
@@ -20,6 +20,11 @@ $(document).ready(function () {
document.body.addEventListener("htmx:responseError", function(e) {
let el = document.getElementById("htmx-error");
if (!el) {
// No global error container available; log and return to avoid JS errors
console.error('htmx:responseError but no #htmx-error element present', e.detail);
return;
}
var error;
if (e.detail.pathInfo.requestPath.includes("cimar") && e.detail.xhr.status == 403) {
error = "Cimar permission error, please login and refresh the page.";
@@ -207,6 +212,12 @@ window.loadDicomViewerEvent = new Event('loadDicomViewerUrls');
window.addEventListener('loadDicomViewerUrls', function (e) {
console.log("LoadDicomViewer unls event", e.detail)
// Skip loading old viewer if using new dv3d viewer (set on new_uploads page)
if (window.skipLoadDicomViewer) {
console.log("Skipping old loadDicomViewer - using dv3d instead");
return;
}
loadDicomViewer(e.detail.images, e.detail.annotations);
}, false);
@@ -215,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 = () => {
@@ -245,6 +258,12 @@ function clearableTextAreaSetup() {
}
function loadDicomViewer(images_to_load, annotations_to_load) {
// Skip loading old viewer if using new dv3d viewer (set on new_uploads page)
if (window.skipLoadDicomViewer) {
console.log("Skipping old loadDicomViewer - using dv3d instead");
return;
}
console.log("loadDicomViewer", images_to_load);
let single_dicom = document.getElementById("single-dicom-viewer");
if (single_dicom) {
+3 -5
View File
@@ -1,7 +1,7 @@
import django_tables2 as tables
from django_tables2.utils import A
from generic.tables import SingleImageColumn
from generic.tables import SingleImageColumn, SelectionTable
from .models import AnatomyQuestion, UserAnswer
@@ -13,7 +13,7 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
#from generic.tables import UserAnswerTable
class AnatomyQuestionTable(tables.Table):
class AnatomyQuestionTable(SelectionTable):
edit = tables.LinkColumn(
"anatomy:anatomy_question_update", text="Edit", args=[A("pk")], orderable=False
)
@@ -30,9 +30,7 @@ class AnatomyQuestionTable(tables.Table):
)
exams = tables.ManyToManyColumn(verbose_name="Exams")
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
class Meta:
class Meta(SelectionTable.Meta):
model = AnatomyQuestion
template_name = "django_tables2/bootstrap4.html"
fields = (
@@ -228,10 +228,14 @@
<h2>{{object|yesno:"Update,Create"}} Question</h2>
{% help "Help" %}
<p>This form can be used to create an Anatomy question.</p>
<p>This form can be used to create an Anatomy question. Once created, the question can be edited or deleted as needed.</p>
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
<p>Answers can be defined once the question has been created (and labelled).</p>
<p>Not all fields are required.</p>
<p>Correct and incorrect answers can be defined once the question has been created.</p>
<p>Images can be selected either directly via the browse button or by dragging and dropping them into the designated area below.</p>
+29 -29
View File
@@ -6,39 +6,39 @@
{% block navigation %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#anatomyNavbarNav" aria-controls="anatomyNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="anatomyNavbarNav">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
</li>
{% endif %}
{% endif %}
{% endif %}
</ul>
</ul>
</div>
</div>
</div>
</nav>
</nav>
{% endblock %}
{% block content %}
{% if simple_content %}
+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 %}
@@ -0,0 +1,24 @@
{% load static %}
<div id="exam-list">
<form id="exam-edit-form">
{% csrf_token %}
{{ form.media }}
<div class="input-group mb-2">
{{ form.exam }}
<button class="btn btn-sm btn-primary" type="button" hx-post="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-include="#id_exam" hx-target="#exam-list" hx-swap="innerHTML">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('exam-list').innerHTML='';">Close</button>
</div>
</form>
<div class="mb-2 small">Current exams:
{% for exam in question.exams.all %}
<form class="d-inline me-1" hx-post="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="exam_id" value="{{ exam.id }}">
<input type="hidden" name="remove" value="true">
<button class="btn btn-sm btn-outline-danger mb-1" type="submit" hx-confirm="Are you sure you want to remove this exam?">{{ exam }}</button>
</form>
{% empty %}
<span class="text-muted">No exams</span>
{% endfor %}
</div>
</div>
@@ -0,0 +1,9 @@
<div id="exam-links" class="small" hx-swap-oob="outerHTML">
{% if question.exams.all %}
{% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
{% endfor %}
{% else %}
No exams
{% endif %}
</div>
@@ -0,0 +1,27 @@
<div id="primary-answer-container" class="position-relative primary-answer-wrap py-1">
<strong>{{ question.get_primary_answer }}</strong>
{% if can_edit %}
<button
class="edit-primary-btn btn btn-sm btn-outline-secondary position-absolute"
style="right:0; top:0;"
hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}"
hx-target="#primary-answer-container"
hx-swap="outerHTML"
aria-label="Edit primary answer"
>
Edit primary answer
</button>
{% endif %}
<style>
/* Scoped styles for primary answer edit control: match outline-secondary look */
.primary-answer-wrap .edit-primary-btn {
opacity: 0.9;
transition: opacity 120ms ease, transform 120ms ease;
}
.primary-answer-wrap:hover .edit-primary-btn {
opacity: 1;
transform: none;
}
</style>
</div>
@@ -0,0 +1,4 @@
<div id="primary-answer-container">
<span class="small text-muted">No primary answer set</span>
<button class="btn btn-sm btn-link" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">Add primary answer</button>
</div>
@@ -0,0 +1,58 @@
{% load crispy_forms_tags %}
<div id="primary-answer-container">
<form hx-post="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">
{% csrf_token %}
<div class="mb-2">
<label for="id_existing_answer" class="form-label">Select existing answer</label>
<select name="existing_answer" id="id_existing_answer" class="form-select">
{% for val, text in form.existing_answer.field.choices %}
<option value="{{ val }}" {% if val == form.initial.existing_answer %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="mb-2">
<label for="id_new_answer" class="form-label">Or add a new primary answer</label>
<textarea name="new_answer" id="id_new_answer" class="form-control" rows="3"></textarea>
<script>
(function(){
const sel = document.getElementById('id_existing_answer');
const ta = document.getElementById('id_new_answer');
if(!sel || !ta) return;
function update() {
try {
if (ta.value && ta.value.trim().length > 0) {
sel.disabled = true;
} else {
sel.disabled = false;
}
} catch(e) {
// ignore
}
}
// initial state
update();
// listeners
sel.addEventListener('change', function(){
if(this.value && this.value !== '') {
ta.disabled = true;
} else {
ta.disabled = false;
}
});
ta.addEventListener('input', update);
})();
</script>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" type="submit">Save primary answer</button>
<button class="btn btn-secondary btn-sm" type="button" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}?cancel=1" hx-target="#primary-answer-container" hx-swap="outerHTML">Cancel</button>
</div>
</form>
</div>
@@ -9,8 +9,9 @@
<span class="small text-muted">No suggestions</span>
{% endif %}
</div>
<div class="mt-1">
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
</div>
{% if can_edit %}
<div class="mt-1">
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
</div>
{% endif %}
</div>
+95 -24
View File
@@ -8,16 +8,30 @@
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-body">
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=true style="min-height:300px">
</div>
<div class="mt-3 d-flex justify-content-between align-items-start">
<details class="mb-0 flex-grow-1 me-2">
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image annotation help</summary>
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist. If required use the middle button to zoom and the left button to pan.</div>
</details>
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation="{{ can_edit|yesno:'true,false' }}" style="min-height:300px">
</div>
{% if not question.image_annotations %}
<div class="alert alert-warning mt-3" role="alert">
<strong>No image annotations defined.</strong>
<div class="small">If important markers or arrows are not burned onto the image, please add annotations using the image annotation tool and click <em>Save Annotations</em>. Please note not all questions required image annotations in which case you can ignore this message.</div>
</div>
{% endif %}
{% if can_edit %}
<div class="mt-3 align-items-start">
<div class="col" style="min-width:0;">
<details class="mb-0">
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image annotation help</summary>
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist. If required use the middle button to zoom and the left button to pan.</div>
</details>
</div>
<div class="col-auto">
<div class="d-flex gap-2">
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
<button id="delete-annotations" class="btn btn-sm btn-outline-danger">Delete Annotations</button>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -36,9 +50,29 @@
</div>
<h6 class="mt-2">Primary answer</h6>
<div class="mb-2"><strong>{{ question.get_primary_answer }}</strong></div>
{% if question.primary_answer %}
{% include 'anatomy/partials/primary_answer_display.html' %}
{% else %}
<div id="primary-answer-container" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-trigger="load" hx-swap="outerHTML"></div>
{% endif %}
<h6>Answers</h6>
<style>
.answer.correct { color: #155724; }
.answer.half-correct { color: #856404; }
.answer.incorrect { color: #721c24; }
.marked-correct { border-left: 4px solid #2a9d3f; }
.marked-half-correct { border-left: 4px solid #ffb020; }
.marked-incorrect { border-left: 4px solid #d3413a; }
</style>
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-0">Answers</h6>
<div>
{% if can_edit %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">Edit answers</a>
{% endif %}
</div>
</div>
<details class="mb-3">
<summary class="small">Show answers</summary>
<table class="table table-sm mt-2">
@@ -47,7 +81,8 @@
</thead>
<tbody>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
{% comment %}Add visual mark state classes consistent with mark2{% endcomment %}
<tr class="{% if answer.status == '2' %}marked-correct{% elif answer.status == '1' %}marked-half-correct{% elif answer.status == '0' %}marked-incorrect{% endif %}">
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"{% endif %}>
{% if answer.status == "0" %}
<div class="d-flex align-items-start">
@@ -63,9 +98,19 @@
<pre class="mb-0">{{ answer.answer }}</pre>
{% endif %}
</td>
<td>{{ answer.get_status_display|default:answer.status }}</td>
<td>
{% if answer.proposed %}
{% if answer.status == '2' %}
<span class="answer correct">{{ answer.get_status_display|default:'Correct' }}</span>
{% elif answer.status == '1' %}
<span class="answer half-correct">{{ answer.get_status_display|default:'Half mark' }}</span>
{% elif answer.status == '0' %}
<span class="answer incorrect">{{ answer.get_status_display|default:'Incorrect' }}</span>
{% else %}
<span class="answer not-marked">{{ answer.get_status_display|default:'Unmarked' }}</span>
{% endif %}
</td>
<td>
{% if answer.proposed and can_edit %}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success accept-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:confirm_answer' answer.id %}" title="Accept">Accept</button>
<button class="btn btn-outline-danger delete-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:delete_answer' answer.id %}" title="Delete">Delete</button>
@@ -111,15 +156,12 @@
<div class="mt-3">
<h6>Exams</h6>
<div class="small">
{% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
{% empty %}
No exams
{% endfor %}
</div>
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span></div>
{% include 'anatomy/partials/exam_links.html' %}
{% if can_edit %}
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span>
</div>
{% endif %}
</div>
{% include 'question_notes.html' %}
@@ -127,7 +169,13 @@
<div class="mt-3">
<details>
<summary class="small">Annotation JSON</summary>
<div class="mt-2 small">{% if question.image_annotations %}<pre class="mb-0">{{ question.image_annotations }}</pre>{% else %}No saved annotations.{% endif %}</div>
<div class="mt-2 small">
{% if question.image_annotations %}
{% include 'atlas/partials/json_pretty.html' with json_value=question.image_annotations title='Image annotations' %}
{% else %}
No saved annotations.
{% endif %}
</div>
</details>
</div>
@@ -174,6 +222,29 @@
console.log('[Done]');
})
})
$("#delete-annotations").click(function () {
if (!confirm('Clear saved annotations for this question? This cannot be undone.')) return;
$.ajax({
url: "{% url 'anatomy:question_clear_annotation' pk=question.pk %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
},
type: "POST",
dataType: "json",
})
.done(function (data) {
if (data.status == "success") {
toastr.info('Annotations cleared');
// Optionally reload part of the page or remove the annotation overlay
location.reload();
} else {
toastr.warning('Error clearing annotations');
}
})
.fail(function () {
toastr.warning('Error clearing annotations');
});
});
});
@@ -42,18 +42,19 @@
<i class="bi bi-eye me-2"></i> View
</a>
</li>
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
<i class="bi bi-pencil-square me-2"></i> Edit
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
<i class="bi bi-list-check me-2"></i> Edit Answers
</a>
</li>
{% if can_edit %}
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
<i class="bi bi-pencil-square me-2"></i> Edit
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
<i class="bi bi-list-check me-2"></i> Edit Answers
</a>
</li>
{% endif %}
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_clone' question.id %}">
<i class="bi bi-files me-2"></i> Clone
@@ -66,6 +67,7 @@
</a>
</li>
{% if can_edit %}
<li><hr class="dropdown-divider"></li>
<li>
@@ -73,6 +75,7 @@
<i class="bi bi-trash me-2"></i> Delete
</a>
</li>
{% endif %}
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
+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"
+6
View File
@@ -33,6 +33,11 @@ urlpatterns = [
views.question_save_annotation,
name="question_save_annotation",
),
path(
"question/<int:pk>/clear_annotation",
views.question_clear_annotation,
name="question_clear_annotation",
),
path(
"question/<int:pk>/suggest_incorrect_answers",
views.question_suggest_incorrect_answers,
@@ -114,6 +119,7 @@ urlpatterns = [
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
path("question/<int:pk>/primary_answer/", views.primary_answer_htmx, name="primary_answer_htmx"),
]
urlpatterns.extend(generic_view_urls(views.GenericViews))
+165 -41
View File
@@ -30,6 +30,7 @@ from dal import autocomplete
from django.conf import settings
from django.utils.html import format_html_join
from django.utils.safestring import mark_safe
from django.template.loader import render_to_string
from .forms import (
@@ -46,6 +47,7 @@ from .forms import (
AnatomyQuestionForm,
ExamForm,
SuggestIncorrectAnswerForm,
PrimaryAnswerForm,
)
from .models import (
AnatomyQuestion,
@@ -58,6 +60,21 @@ from .models import (
# HalfMarkAnswers,
# IncorrectAnswers,
)
from generic.widgets import ExamSearchWidget
class AddExamForm(forms.Form):
exam = forms.ModelChoiceField(queryset=Exam.objects.none(), required=True, widget=ExamSearchWidget(exam_model=Exam), label="Exam to add")
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
if user is None:
qs = Exam.objects.none()
elif user.groups.filter(name="anatomy_checker").exists():
qs = Exam.objects.all()
else:
qs = Exam.objects.filter(author__id=user.id) | Exam.objects.filter(open_access=True)
self.fields["exam"].queryset = qs.distinct()
from generic.models import CidUser, Examination
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamGroupsUpdateBase, ExamUpdateBase, ExamViews, GenericViewBase, UpdateQuestionMixin
from reversion.views import RevisionMixin
@@ -87,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():
@@ -1225,10 +1244,16 @@ def create_body_part(request):
)
@csrf_exempt
@login_required
def get_body_part_id(request):
if request.accepts("application/json")():
body_part_name = request.GET["body_part_name"]
"""Return the primary key for a BodyPart looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up a BodyPart by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
body_part_name = request.GET.get("body_part_name", "")
body_part_id = BodyPart.objects.get(name=body_part_name).id
data = {
"body_part_id": body_part_id,
@@ -1251,10 +1276,16 @@ def create_examination(request):
)
@csrf_exempt
@login_required
def get_examination_id(request):
if request.accepts("application/json")():
examination_name = request.GET["examination_name"]
"""Return the primary key for an Examination looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up an Examination by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
examination_name = request.GET.get("examination_name", "")
examination_id = Examination.objects.get(name=examination_name).id
data = {
"examination_id": examination_id,
@@ -1265,10 +1296,16 @@ def get_examination_id(request):
@csrf_exempt
@login_required
def get_structure_id(request):
if request.accepts("application/json")():
structure_name = request.GET["structure_name"]
"""Return the primary key for a Structure looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up a Structure by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
structure_name = request.GET.get("structure_name", "")
structure_id = Structure.objects.get(name=structure_name).id
data = {
"structure_id": structure_id,
@@ -1503,6 +1540,17 @@ def question_save_annotation(request, pk):
return JsonResponse(data, status=400)
@user_is_author_or_anatomy_checker
def question_clear_annotation(request, pk):
"""Clear the stored image annotations for a question."""
if request.method == "POST":
question = get_object_or_404(AnatomyQuestion, pk=pk)
question.image_annotations = ""
question.save(update_fields=["image_annotations"])
return JsonResponse({"status": "success"}, status=200)
return JsonResponse({"status": "error"}, status=400)
GenericExamViews = ExamViews(
Exam, AnatomyQuestion, Answer, UserAnswer, "anatomy", "anatomy"
)
@@ -1615,44 +1663,120 @@ def question_add_exam(request, question_id: int):
return HttpResponse("Invalid permissions", status=403)
if request.POST:
# POST: handle add/remove; if exam_id provided use existing behaviour
if request.method == "POST":
exam_id = request.POST.get("exam_id", None)
if exam_id is None:
return HttpResponse("No exam id provided", status=400)
if exam_id:
exam = get_object_or_404(Exam, pk=exam_id)
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
else:
question.exams.add(exam)
exam = get_object_or_404(Exam, pk=exam_id)
# return out-of-band swap: update exam links and remove the editor
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
return HttpResponse(mark_safe(links_html + oob_remove))
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
return HttpResponse("Question removed from exam.")
# else try to process from the search form or widget-provided values
# The ExamSearchWidget may submit a list; accept first value if present.
exam_vals = request.POST.getlist('exam') if hasattr(request.POST, 'getlist') else []
if not exam_vals:
# also accept single 'exam' param
single = request.POST.get('exam')
if single:
exam_vals = [single]
if exam_vals:
try:
exam = get_object_or_404(Exam, pk=int(exam_vals[0]))
except (ValueError, TypeError):
exam = None
if exam:
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
else:
question.exams.add(exam)
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
return HttpResponse(mark_safe(links_html + oob_remove))
# else try to process via bound form (fallback)
form = AddExamForm(request.POST, user=request.user)
if form.is_valid():
exam = form.cleaned_data.get("exam")
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
else:
question.exams.add(exam)
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
return HttpResponse(mark_safe(links_html + oob_remove))
else:
question.exams.add(exam)
# render form with errors
return render(request, "anatomy/partials/exam_edit_fragment.html", {"form": form, "question": question})
return HttpResponse("Question added to exam.")
# GET: render the fragment with the add form and current exams
form = AddExamForm(user=request.user)
return render(request, "anatomy/partials/exam_edit_fragment.html", {"form": form, "question": question})
raise PermissionDenied()
@login_required
def primary_answer_htmx(request, pk: int):
"""HTMX endpoint to render or update a question's primary answer.
GET: render the form for selecting/creating primary answer.
POST: create/select answer and set `question.primary_answer`, then
return the display fragment.
"""
if not request.htmx:
raise PermissionDenied()
question = get_object_or_404(AnatomyQuestion, pk=pk)
if not question.can_edit(request.user):
return HttpResponse("Invalid permissions", status=403)
if request.method == "POST":
form = PrimaryAnswerForm(request.POST, question=question)
if form.is_valid():
new_text = form.cleaned_data.get("new_answer", "").strip()
existing = form.cleaned_data.get("existing_answer", "")
if new_text:
# create a new Answer and mark it CORRECT
ans = Answer(question=question, answer=new_text, proposed=False, status=Answer.MarkOptions.CORRECT)
ans.save()
question.primary_answer = ans
question.save(update_fields=["primary_answer"])
elif existing:
try:
aid = int(existing)
ans = Answer.objects.get(pk=aid, question=question)
question.primary_answer = ans
question.save(update_fields=["primary_answer"])
except (ValueError, Answer.DoesNotExist):
return HttpResponse("Invalid answer choice", status=400)
# return display fragment
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question, "can_edit": question.can_edit(request.user)})
else:
# Return a list of exams that we can add to the question
return HttpResponse("Invalid form", status=400)
exams = Exam.objects.filter(author=request.user)
exams = exams.difference(question.exams.all())
# Handle cancel request to restore original state
if request.method == "GET" and request.GET.get("cancel"):
if question.primary_answer:
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question, "can_edit": question.can_edit(request.user)})
else:
return render(request, "anatomy/partials/primary_answer_empty.html", {"question": question, "can_edit": question.can_edit(request.user)})
if not exams:
return HttpResponse("No exams available to add")
html = "Exams to add to question: <br>"
for exam in exams:
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><button hx-post=\"{request.path}\""
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<br>Exams to remove from question: <br>"
for exam in question.exams.all():
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><input name='remove' value='true' type='hidden'><button hx-post=\"{request.path}\""
f"hx-confirm='Are you sure you want to remove this exam from the question?'"
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<button _='on click remove #htmx-exam-list'>Cancel</button>"
return HttpResponse(mark_safe(html))
raise PermissionDenied()
# GET - render form
form = PrimaryAnswerForm(question=question)
return render(request, "anatomy/partials/primary_answer_form.html", {"form": form, "question": question, "can_edit": question.can_edit(request.user)})
+255
View File
@@ -0,0 +1,255 @@
# Atlas Widgets
This document describes the reusable search widgets provided by Atlas, how they work, and how to use them safely.
## Scope
These widgets are Django inclusion tags implemented in `atlas/templatetags/case_widgets.py` and rendered through partial templates in `atlas/templates/atlas/partials/`.
Current widgets:
- `case_search_widget`
- `finding_search_widget`
- `displayset_search_widget`
They are HTMX-first widgets with JavaScript fallback to `fetch`.
## Load The Tags
In any template that uses these widgets:
```django
{% load case_widgets %}
```
## 1) case_search_widget
### Tag Signature
```django
{% case_search_widget
collection=None
cases=None
recent_cases=None
input_id='case-search-input'
target_id='case-search-results'
selection_mode='single'
action_url=None
show_select_button=False
%}
```
### Parameters
- `collection`:
Optional collection object. When set, search requests include `collection=<pk>` and rows render an `Add` action.
- `cases`:
Optional initial case queryset/list for first render.
- `recent_cases`:
Optional initial recent list. If omitted, widget auto-populates with up to 5 recent cases authored by the current user.
- `input_id`:
DOM id for the search input. Must be unique per page.
- `target_id`:
DOM id for results container. Must be unique per page.
- `selection_mode`:
Present in tag API but currently not used by rendering logic.
- `action_url`:
Present in tag API but currently not used by rendering logic.
- `show_select_button`:
When `True`, rows include a `Select` button and clicking rows emits selection events.
### How It Works
- Input triggers search on: `keyup changed delay:400ms`.
- HTMX call target: `atlas:case_search` (`/atlas/collection/case_search`).
- The widget includes all local hidden state via:
- `hx-include="closest .case-search-widget"`
- Search results are swapped into the configured `target_id`.
- If JavaScript cannot use HTMX, fallback `fetch` performs the same GET and swaps `innerHTML`.
### Events Emitted
The widget dispatches a bubbling custom event from the widget container:
- Event name: `case:selected`
- Detail payload:
- `casePk` (string)
- `caseTitle` (string)
Example listener:
```javascript
document.body.addEventListener('case:selected', function (e) {
const pk = e.detail && e.detail.casePk;
const title = e.detail && e.detail.caseTitle;
if (!pk) return;
// consume selection
});
```
### Result Row Contract
Rows are rendered with:
- `data-case-pk`
- visible title in `h6`
Actions per mode:
- `collection` provided: `Add` form + `View`
- no collection, `show_select_button=True`: `Select` + `View`
- no collection, `show_select_button=False`: `View`
### Common Usage Patterns
#### A. Case picker behavior (select destination/import target)
```django
{% case_search_widget
collection=None
input_id='move-series-case-search-input'
target_id='move-series-case-search-results'
show_select_button=True
%}
```
Then consume `case:selected` and write `casePk` to your hidden form field.
#### B. Add to collection flow
```django
{% case_search_widget
collection=collection
cases=cases
recent_cases=recent_cases
input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s'
target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s'
%}
```
No event handling is required; rows provide server-posted `Add` action.
### Endpoint Notes
The widget uses `atlas:case_search` for HTMX/fetch updates. That view supports:
- `q` search text
- `collection` optional collection id
- `show_select` optional flag to preserve select-button rendering on refresh
## 2) finding_search_widget
### Tag Signature
```django
{% finding_search_widget
case=None
findings=None
input_id='finding-search-input'
target_id='finding-search-results'
%}
```
### Parameters
- `case`:
Optional case instance; when present, search is filtered to findings linked to series in that case.
- `findings`:
Optional initial findings list.
- `input_id`, `target_id`:
DOM ids. Must be unique per page.
### How It Works
- HTMX endpoint: `atlas:finding_search_partial` / `atlas:finding_search`.
- Search query is sent as `q`.
- Case filter (if set) is sent as `case=<pk>`.
- Widget shows a case filter badge and a clear (`x`) button.
- Script mirrors active case into dataset fields used by markup insertion workflows:
- `input.dataset.currentCase`
- nearby `textarea.dataset.currentCase`
### Result Row Contract
Rows are rendered with:
- `data-finding-pk`
- `data-seriesfinding-pk`
- `data-label`
These attributes are consumed by `atlas/static/atlas/js/markup_inserter.js` to insert tokens like:
- `[[seriesfinding:<pk>|<label>]]`
- `[[finding:<pk>|<label>]]`
## 3) displayset_search_widget
### Tag Signature
```django
{% displayset_search_widget
case=None
displaysets=None
input_id='displayset-search-input'
target_id='displayset-search-results'
%}
```
### Parameters
- `case`:
Optional case instance; when present, search is filtered to display sets in that case.
- `displaysets`:
Optional initial list.
- `input_id`, `target_id`:
DOM ids. Must be unique per page.
### How It Works
- HTMX endpoint: `atlas:displayset_search_partial` / `atlas:displayset_search`.
- Query sent as `q`; case scoping sent as `case=<pk>`.
- Includes case badge + clear button behavior similar to finding widget.
- Also mirrors case context into input/textarea datasets for markup insertion workflows.
### Result Row Contract
Rows are rendered with:
- `data-ds-pk`
- `data-label`
Used by `atlas/static/atlas/js/markup_inserter.js` to insert:
- `[[displayset:<pk>|<label>]]`
## Integration Guidelines
- Keep each widget instance ids unique:
- unique `input_id`
- unique `target_id`
- Prefer one event listener per feature container or page for `case:selected`.
- Do not depend on button text (`Select`, `View`) in JS. Use data attributes and events.
- If your workflow needs selection, set `show_select_button=True` on `case_search_widget`.
- If your workflow is add-to-collection, pass `collection` and rely on row `Add` forms.
## Known Constraints
- `selection_mode` and `action_url` are currently API placeholders for `case_search_widget`; they are not applied by templates/views yet.
- Widget scripts are inline with each widget include. If many instances are rendered, listeners are instance-scoped but still duplicated per include.
## Quick Troubleshooting
- No results update:
- verify `input_id` and `target_id` are unique and present in DOM.
- verify endpoint routes exist and user is authenticated.
- Selection not received:
- ensure `show_select_button=True` and listener is bound (for example on `document.body`).
- inspect `case:selected` payload in DevTools.
- Case scoping not applied in finding/displayset widgets:
- ensure `case` is passed to the tag.
- verify `case=<pk>` appears in network requests.
## Source Files
- `atlas/templatetags/case_widgets.py`
- `atlas/templates/atlas/partials/case_search_widget.html`
- `atlas/templates/atlas/partials/case_search_results.html`
- `atlas/templates/atlas/partials/_case_search_item.html`
- `atlas/templates/atlas/partials/finding_search_widget.html`
- `atlas/templates/atlas/partials/_finding_search_results.html`
- `atlas/templates/atlas/partials/displayset_search_widget.html`
- `atlas/templates/atlas/partials/_displayset_search_results.html`
- `atlas/views.py` (`case_search`, `finding_search`, `displayset_search`)
- `atlas/static/atlas/js/markup_inserter.js`
+225 -1
View File
@@ -1,4 +1,5 @@
from django.contrib import admin
from django.contrib import messages
from .models import (
Case,
CaseDetail,
@@ -15,24 +16,35 @@ from .models import (
Structure,
PathologicalProcess,
Presentation,
Procedure,
UncategorisedDicom,
SeriesDetail,
Resource,
CaseDisplaySet,
UserReportAnswer,
CidReportAnswer,
APIToken,
)
from django.forms import ModelForm
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
# from generic.models import Examination, Sign, Site, Condition
from generic.models import Examination
try:
from django_tasks_db.models import DBTaskResult
except ImportError:
DBTaskResult = None
# Register your models here.
# admin.site.register(Examination)
# admin.site.register(Site)
@@ -51,6 +63,7 @@ admin.site.register(UncategorisedDicom)
admin.site.register(SeriesDetail)
admin.site.register(Resource)
admin.site.register(CaseDisplaySet)
admin.site.register(Procedure)
admin.site.register(UserReportAnswer)
admin.site.register(CidReportAnswer)
@@ -82,7 +95,7 @@ admin.site.register(CaseCollection, CaseCollectionAdmin)
class NormalCaseAdmin(VersionAdmin):
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
list_display = ("case", "display_age", "examination", "modality", "added_date")
list_filter = ("examination", "modality")
search_fields = ("case__title", "case__pk")
@@ -140,3 +153,214 @@ class SeriesAdmin(VersionAdmin):
admin.site.register(Series, SeriesAdmin)
class APITokenAdmin(admin.ModelAdmin):
list_display = ("user", "name", "revoked", "created", "expires", "last_used")
search_fields = ("user__username", "name")
readonly_fields = ("token_hash", "created", "last_used")
admin.site.register(APIToken, APITokenAdmin)
if DBTaskResult is not None:
try:
# Ensure package admin registration has run before we customize it.
import django_tasks_db.admin # noqa: F401
except Exception:
pass
def traceback_link(self, obj):
if not obj.traceback:
return "-"
url = reverse(
f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
args=[obj.pk],
)
return format_html('<a href="{}">View full traceback</a>', url)
traceback_link.short_description = "Traceback"
def traceback_pretty(self, obj):
if not obj.traceback:
return ""
return format_html(
'<pre style="white-space: pre-wrap; margin: 0;">{}</pre>',
obj.traceback,
)
traceback_pretty.short_description = "Traceback"
@admin.action(description="Reset FAILED tasks to READY")
def reset_failed_to_ready(self, request, queryset):
failed_qs = queryset.filter(status="FAILED")
updated = failed_qs.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} failed task(s) to READY.",
level=messages.SUCCESS,
)
@admin.action(description="Reset selected tasks to READY (except RUNNING)")
def reset_selected_to_ready(self, request, queryset):
blocked = queryset.filter(status="RUNNING").count()
reset_qs = queryset.exclude(status="RUNNING")
updated = reset_qs.update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
msg = f"Reset {updated} task(s) to READY."
if blocked:
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")
deleted, _ = completed_qs.delete()
self.message_user(
request,
f"Deleted {deleted} completed task record(s).",
level=messages.SUCCESS,
)
@admin.action(description="Delete selected FAILED tasks")
def delete_selected_failed(self, request, queryset):
failed_qs = queryset.filter(status="FAILED")
deleted, _ = failed_qs.delete()
self.message_user(
request,
f"Deleted {deleted} failed task record(s).",
level=messages.SUCCESS,
)
def has_add_permission(self, request):
return False
task_admin = admin.site._registry.get(DBTaskResult)
if task_admin is not None:
admin_cls = task_admin.__class__
admin_cls.list_display = (
"id",
"task_path",
"status",
"queue_name",
"priority",
"enqueued_at",
"started_at",
"finished_at",
"worker_ids",
"exception_class_path",
"traceback_link",
)
admin_cls.list_filter = ("status", "queue_name", "task_path", "backend_name")
admin_cls.search_fields = (
"id",
"task_path",
"queue_name",
"worker_ids",
"exception_class_path",
"traceback",
)
admin_cls.ordering = ("-enqueued_at",)
admin_cls.actions = (
"reset_failed_to_ready",
"reset_selected_to_ready",
"reset_stale_running_to_ready",
"delete_selected_completed",
"delete_selected_failed",
)
admin_cls.readonly_fields = (
"id",
"status",
"enqueued_at",
"started_at",
"finished_at",
"args_kwargs",
"priority",
"task_path",
"worker_ids",
"queue_name",
"backend_name",
"run_after",
"return_value",
"exception_class_path",
"traceback_pretty",
)
admin_cls.fieldsets = (
(
"Task",
{
"fields": (
"id",
"task_path",
"status",
"queue_name",
"priority",
"backend_name",
)
},
),
(
"Timing",
{
"fields": (
"enqueued_at",
"run_after",
"started_at",
"finished_at",
"worker_ids",
)
},
),
(
"Payload",
{"fields": ("args_kwargs", "return_value")},
),
(
"Error",
{
"fields": ("exception_class_path", "traceback_pretty"),
},
),
)
admin_cls.traceback_link = traceback_link
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
+383 -52
View File
@@ -2,6 +2,8 @@ from collections import defaultdict
from typing import List, Tuple
from django.shortcuts import get_object_or_404
from django.urls import reverse
import json
from django.http import JsonResponse, HttpResponse
from ninja import ModelSchema, Router, Schema, Field
from django.db import transaction, IntegrityError
@@ -14,14 +16,22 @@ from pydicom.errors import InvalidDicomError
from generic.decorators import check_user_in_group
from generic.constants import Group
from ninja.security import django_auth
from ninja.security import django_auth, HttpBearer
from ninja.errors import HttpError
from ninja import NinjaAPI, File
from ninja.files import UploadedFile
import hashlib
import secrets
from django.utils import timezone
from django.contrib.auth import authenticate
from django.views.decorators.csrf import csrf_exempt
from django.middleware.csrf import CsrfViewMiddleware
from generic.models import Examination, Modality
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom, APIToken
from atlas.helpers import get_cases_available_to_user
from loguru import logger
@@ -29,6 +39,72 @@ from loguru import logger
router = Router()
class BearerAuth(HttpBearer):
"""Ninja HttpBearer auth that validates APIToken entries.
Returns the token owner user on success or raises HttpError(401) on failure.
"""
def authenticate(self, request, token: str):
if not token:
raise HttpError(status_code=401)
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
api_token = APIToken.objects.get(token_hash=token_hash)
except APIToken.DoesNotExist:
raise HttpError(status_code=401)
if api_token.revoked:
raise HttpError(status_code=401)
if api_token.expires and api_token.expires < timezone.now():
raise HttpError(status_code=401)
try:
api_token.mark_used()
except Exception:
pass
# Attach for downstream handlers
request.api_token = api_token
# Also set `request.user` so code that expects Django's user works
try:
request.user = api_token.user
# Some middleware caches the user on `_cached_user`
setattr(request, "_cached_user", api_token.user)
except Exception:
pass
return api_token.user
class SessionOrBearer:
"""Composite auth: prefer Django session (request.user), fall back to bearer token.
Use as `auth=SessionOrBearer()` on Ninja router endpoints.
"""
def __call__(self, request):
# Session-backed auth already populated by Django middleware
if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
return request.user
# Try bearer token from header
auth = request.META.get("HTTP_AUTHORIZATION")
if not auth:
raise HttpError(status_code=401)
parts = auth.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HttpError(status_code=401)
token = parts[1]
# reuse BearerAuth logic
bearer = BearerAuth()
return bearer.authenticate(request, token)
# Backwards-compatible alias used in existing decorators
TokenAuth = SessionOrBearer
class SeriesSchema(ModelSchema):
case_id: List[int] = []
@@ -53,7 +129,7 @@ class CaseSchema(ModelSchema):
fields = ["id", "title"]
@router.post("/upload_dicom", auth=django_auth)
@router.post("/upload_dicom", auth=TokenAuth())
def upload_dicom(request, files: List[UploadedFile] = File(...)):
uploaded = []
duplicate = []
@@ -70,11 +146,8 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
except DuplicateDicom as e:
duplicate.append((file.name, ud.image_blake3_hash))
if type(e.duplicate) == SeriesImage:
if isinstance(e.duplicate, SeriesImage) and e.duplicate.series is not None:
duplicate_series.add(e.duplicate.series.get_absolute_url())
pass
except InvalidDicomError:
failed.append(file.name)
@@ -86,7 +159,7 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
}
@router.post("/generate_image_hash", auth=django_auth)
@router.post("/generate_image_hash", auth=TokenAuth())
def generate_image_hash(request, id: int):
s = SeriesImage.objects.get(pk=id)
s.generate_hashes()
@@ -98,7 +171,7 @@ def generate_image_hash(request, id: int):
}
@router.post("/clear_dicoms", auth=django_auth)
@router.post("/clear_dicoms", auth=TokenAuth())
def clear_dicoms(request):
if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(
@@ -113,7 +186,7 @@ def clear_dicoms(request):
return True
@router.get("/uncategorised_dicoms", auth=django_auth)
@router.get("/uncategorised_dicoms", auth=TokenAuth())
def uncategorised_dicoms(request):
dicoms = UncategorisedDicom.objects.filter(user=request.user)
@@ -127,13 +200,15 @@ def uncategorised_dicoms(request):
return data
def import_dicoms_helper(request, case_id: int | None = None):
if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection")
)
else:
dicoms = UncategorisedDicom.objects.filter(user=request.user)
def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
# Allow callers to pass an explicit queryset of UncategorisedDicom objects
if dicoms is None:
if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection")
)
else:
dicoms = UncategorisedDicom.objects.filter(user=request.user)
if "order-series" in request.POST:
order = request.POST["order-series"]
@@ -145,6 +220,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
tags = d.basic_dicom_tags
series_uid = tags["SeriesInstanceUID"]
study_uid = tags.get("StudyInstanceUID")
if series_uid not in series_map:
# Get or create Series and related objects as needed
@@ -161,6 +237,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
defaults={
"modality": modality,
"description": tags.get("SeriesDescription", ""),
"study_instance_uid": study_uid,
}
)
if created and tags.get("StudyDescription"):
@@ -175,7 +252,12 @@ def import_dicoms_helper(request, case_id: int | None = None):
except IntegrityError:
series = Series.objects.get(series_instance_uid=series_uid)
if study_uid and not series.study_instance_uid:
series.study_instance_uid = study_uid
series.save(update_fields=["study_instance_uid"])
# Add author and case if needed
# Attach author to the series
series.author.add(request.user)
if case_id is not None:
case_object = get_object_or_404(Case, pk=case_id)
@@ -212,7 +294,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
@router.post(
"/import_dicoms", auth=django_auth, response=List[Tuple[SeriesSchema, str]]
"/import_dicoms", auth=TokenAuth(), response=List[Tuple[SeriesSchema, str]]
)
def import_dicoms(request):
return import_dicoms_helper(request)
@@ -220,19 +302,73 @@ def import_dicoms(request):
@router.post(
"/import_dicoms/{case_id}",
auth=django_auth,
auth=TokenAuth(),
response=List[Tuple[SeriesSchema, str]],
)
def import_dicoms_case(request, case_id: int):
return import_dicoms_helper(request, case_id=case_id)
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
@router.post("/upload_dicom_case/{case_id}", auth=TokenAuth())
def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(...)):
"""Upload DICOM files and immediately import them into the given case.
This saves each uploaded file as an `UncategorisedDicom` owned by the user,
then calls the existing import helper which will create Series/SeriesImage
objects and attach them to the specified case.
"""
uploaded = []
duplicate = []
failed = []
duplicate_series = set()
created_ids = []
for file in files:
try:
ud = UncategorisedDicom(image=file, user=request.user)
ud.save()
uploaded.append((file.name, ud.image_blake3_hash))
created_ids.append(ud.pk)
except DuplicateDicom as e:
duplicate.append((file.name, getattr(ud, "image_blake3_hash", None)))
dup_obj = getattr(e, "duplicate", None)
if isinstance(dup_obj, SeriesImage):
try:
if dup_obj.series is not None:
duplicate_series.add(dup_obj.series.get_absolute_url())
except Exception:
pass
continue
except InvalidDicomError:
failed.append(file.name)
# Import into the target case (consume only the uploaded UncategorisedDicom objects)
if created_ids:
try:
dicom_qs = UncategorisedDicom.objects.filter(pk__in=created_ids)
import_dicoms_helper(request, case_id=case_id, dicoms=dicom_qs)
except Exception:
# don't let import errors prevent returning upload status
logger.error("Error importing dicoms after upload", exc_info=True)
return {
"uploaded": uploaded,
"duplicates": duplicate,
"failed": failed,
"duplicate_series": list(duplicate_series),
}
@router.get("/orphan_series", auth=TokenAuth(), response=List[SeriesSchema])
def orphan_series(request):
return request.user.series.filter(case=None)
@router.get("/series_remove_duplicate_images", auth=django_auth)
@router.get("/series_remove_duplicate_images", auth=TokenAuth())
def series_remove_duplicate_images(request, series_id: int):
series = get_object_or_404(Series, pk=series_id)
@@ -249,12 +385,188 @@ def series_remove_duplicate_images(request, series_id: int):
return len(dupes)
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
@router.get("/get_cases_user", auth=TokenAuth(), response=List[CaseSchema])
def get_cases_user(request):
return Case.objects.filter(author=request.user)
@router.get("/check_image_hash/{hash}", auth=django_auth)
@router.get("/get_cases_available", auth=TokenAuth(), response=List[CaseSchema])
def get_cases_available(request):
"""Return cases available to the authenticated user (via get_cases_available_to_user)."""
logger.error(f"Getting cases available to user {request.user}")
qs = get_cases_available_to_user(request.user)
return qs.order_by('-created_date')[:200]
class APITokenOut(Schema):
id: int
name: str
scopes: str = ""
created: str
expires: str | None = None
revoked: bool = False
last_used: str | None = None
@router.get("/api_tokens", auth=TokenAuth(), response=List[APITokenOut])
def list_api_tokens(request):
tokens = APIToken.objects.filter(user=request.user).order_by("-created")
out = []
for t in tokens:
out.append(
{
"id": t.id,
"name": t.name,
"scopes": t.scopes,
"created": t.created.isoformat(),
"expires": t.expires.isoformat() if t.expires else None,
"revoked": t.revoked,
"last_used": t.last_used.isoformat() if t.last_used else None,
}
)
return out
class CreateTokenIn(Schema):
name: str = Field("", description="Friendly name for token")
scopes: str = Field("", description="Space-separated scopes")
expires_days: int | None = Field(None, description="Expire after N days")
class TokenAuthIn(Schema):
username: str
password: str
@csrf_exempt
@router.post("/create_api_token")
def token_auth(request, payload: TokenAuthIn):
"""Authenticate username/password and return a new API token.
Returns JSON: {"token": "<raw-token>", "id": <token_id>}
"""
user = authenticate(request, username=payload.username, password=payload.password)
if user is None:
return HttpResponse(status=401, content=json.dumps({"detail": "Invalid credentials"}), content_type="application/json")
# Create a new token for this login (no scopes by default)
token, obj = APIToken.create_token(user, name=f"login-{timezone.now().isoformat()}")
return {"token": token, "id": obj.id, "username": user.username}
@csrf_exempt
@router.post("/token_check")
def token_check(request):
"""Check whether a token is valid.
Accepts either `Authorization: Bearer <token>` header or POST JSON
body `{ "token": "<token>" }`.
Returns JSON with keys: `valid` (bool), and when valid: `id`, `username`,
`scopes`, `expires`, `revoked`.
"""
auth = request.META.get("HTTP_AUTHORIZATION")
token_obj = None
# If header present, validate bearer token explicitly
if auth:
parts = auth.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return JsonResponse({"valid": False}, status=401)
token = parts[1]
try:
bearer = BearerAuth()
user = bearer.authenticate(request, token)
token_obj = getattr(request, "api_token", None)
except HttpError:
return JsonResponse({"valid": False}, status=401)
else:
# try JSON body
try:
data = json.loads(request.body.decode("utf-8")) if request.body else {}
except Exception:
data = {}
token = data.get("token")
if token:
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
t = APIToken.objects.get(token_hash=token_hash)
token_obj = t
except APIToken.DoesNotExist:
token_obj = None
if not token_obj:
return JsonResponse({"valid": False}, status=401)
# Validate token state
valid = True
if token_obj.revoked:
valid = False
if token_obj.expires and token_obj.expires < timezone.now():
valid = False
if not valid:
return JsonResponse({"valid": False, "revoked": token_obj.revoked}, status=401)
return JsonResponse(
{
"valid": True,
"id": token_obj.id,
"username": token_obj.user.username,
"scopes": token_obj.scopes,
"expires": token_obj.expires.isoformat() if token_obj.expires else None,
"revoked": token_obj.revoked,
},
status=200,
)
#@csrf_exempt
#@router.post("/create_api_token")
#def create_api_token(request, payload: CreateTokenIn):
# """Create an API token.
#
# This endpoint accepts either normal session auth (`request.user`) or
# a bearer token. If no authenticated user is found a 403 is returned.
# """
# # If caller is not using Authorization header, enforce CSRF via middleware
# if not request.META.get("HTTP_AUTHORIZATION"):
# mw = CsrfViewMiddleware()
# resp = mw.process_view(request, create_api_token, (), {})
# if resp is not None:
# return resp
# # Determine authenticated user: prefer session auth, fall back to token auth
# user = None
# if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
# user = request.user
# else:
# user = TokenAuth()(request)
#
# if not user:
# raise PermissionDenied()
#
# expires = None
# if payload.expires_days:
# expires = timezone.now() + timezone.timedelta(days=payload.expires_days)
#
# token, obj = APIToken.create_token(user, name=payload.name, scopes=payload.scopes, expires=expires)
# # Return raw token once — callers must store it
# return {"token": token, "id": obj.id}
@router.post("/api_tokens/{token_id}/revoke", auth=TokenAuth())
def revoke_api_token(request, token_id: int):
try:
t = APIToken.objects.get(pk=token_id, user=request.user)
except APIToken.DoesNotExist:
return {"status": "not found"}
t.revoked = True
t.save(update_fields=["revoked"])
return {"status": "revoked"}
@router.get("/check_image_hash/{hash}", auth=TokenAuth())
def check_image_hash(request, hash: str):
try:
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
@@ -269,41 +581,61 @@ def check_image_hash(request, hash: str):
return data
@router.post("/check_image_hashes/", auth=django_auth)
@router.post("/check_image_hashes/", auth=TokenAuth())
def check_images_hashes(request, hashes: List[str]):
"""Checks a list of image hashes and returns the series id / url if found
"""Checks a list of image hashes and returns the series id / url if found.
Uses two bulk queries (one for SeriesImage, one for UncategorisedDicom) to
handle large batches efficiently regardless of batch size.
Return format
{ "hash_id": {"id": "series_id|false", "url": "series_url|false"}, ...}
"""
hash_status = {}
try:
hash_status: dict = {h: {"id": False, "url": False} for h in hashes}
for hash in hashes:
try:
# TOOD also check against uncategorised dicoms?
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
data = {
"id": series_image.pk,
"url": series_image.series.get_absolute_url(),
"type": "series",
}
except SeriesImage.DoesNotExist:
# Single bulk query for all SeriesImage matches
series_images = (
SeriesImage.objects.filter(image_blake3_hash__in=hashes)
.select_related("series")
.only("pk", "image_blake3_hash", "series__id")
)
matched_hashes: set = set()
for si in series_images:
if si.image_blake3_hash in matched_hashes:
continue # keep first match per hash
try:
uncategorised_dicom = UncategorisedDicom.objects.get(
image_blake3_hash=hash
)
data = {
"id": uncategorised_dicom.pk,
"url": reverse("atlas:user_uploads"),
"type": "uncategorised",
}
except UncategorisedDicom.DoesNotExist:
data = {"id": False, "url": False}
url = si.series.get_absolute_url()
except Exception:
url = None
hash_status[si.image_blake3_hash] = {"id": si.pk, "url": url, "type": "series"}
matched_hashes.add(si.image_blake3_hash)
hash_status[hash] = data
# Single bulk query for remaining hashes in UncategorisedDicom
remaining = [h for h in hashes if h not in matched_hashes]
if remaining:
try:
uploads_url = reverse("atlas:user_uploads")
except Exception:
uploads_url = None
uncategorised_qs = (
UncategorisedDicom.objects.filter(image_blake3_hash__in=remaining)
.only("pk", "image_blake3_hash")
)
for uc in uncategorised_qs:
if uc.image_blake3_hash not in matched_hashes:
hash_status[uc.image_blake3_hash] = {
"id": uc.pk,
"url": uploads_url,
"type": "uncategorised",
}
matched_hashes.add(uc.image_blake3_hash)
return hash_status
return hash_status
except Exception:
logger.exception("check_images_hashes failed")
return JsonResponse({"detail": "Internal server error"}, status=500)
# @router.get("/generate_image_hash/{id}", auth=django_auth)
@@ -317,16 +649,15 @@ def check_images_hashes(request, hashes: List[str]):
# print(series_image)
@router.get("/view_dicom_tags/{hash}", auth=django_auth)
@router.get("/view_dicom_tags/{hash}", auth=TokenAuth())
def view_dicom_tags(request, hash: str):
item = SeriesImage.objects.get(image_blake3_hash=hash)
return item.get_dicom_json()
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
def series_split_by_tag(request, series_id: int, dicom_tag: str):
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return {"status": "permission denied"}
@@ -371,7 +702,7 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
return new_series
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
def series_order_by_tag(request, series_id: int, dicom_tag: str):
series = get_object_or_404(Series, pk=series_id)
+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__
+16 -6
View File
@@ -11,6 +11,7 @@ from .models import (
Finding,
PathologicalProcess,
Presentation,
Procedure,
QuestionSchema,
Series,
Structure,
@@ -437,6 +438,7 @@ class NormalCaseFilter(django_filters.FilterSet):
"""
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
procedures = django_filters.ModelMultipleChoiceFilter(field_name="case__procedures", queryset=Procedure.objects.all(), label="Procedure")
# Accept min/max as years in the UI but filter against the canonical
# `age_days` field by converting years -> days.
# Numeric min/max values entered by the user (interpreted via `age_unit`)
@@ -448,11 +450,11 @@ class NormalCaseFilter(django_filters.FilterSet):
("months", "Months"),
("years", "Years"),
)
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES)
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES, method="filter_age_unit")
class Meta:
model = NormalCase
fields = ("examination", "modality", "age_days")
fields = ("examination", "modality", "procedures", "age_days")
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
# Use the global Examination and Modality querysets from the Series model
@@ -479,8 +481,10 @@ class NormalCaseFilter(django_filters.FilterSet):
try:
exam_ids = nc_qs.values_list("examination_id", flat=True)
mod_ids = nc_qs.values_list("modality_id", flat=True)
proc_ids = nc_qs.values_list("case__procedures", flat=True)
self.base_filters.get("examination").queryset = Examination.objects.filter(id__in=exam_ids).distinct()
self.base_filters.get("modality").queryset = Modality.objects.filter(id__in=mod_ids).distinct()
self.base_filters.get("procedures").queryset = Procedure.objects.filter(id__in=proc_ids).distinct()
except Exception:
# Fallback to all if something goes wrong
self.base_filters.get("examination").queryset = Examination.objects.all()
@@ -500,6 +504,7 @@ class NormalCaseFilter(django_filters.FilterSet):
Div(
Div("examination", css_class="col-md-3"),
Div("modality", css_class="col-md-3"),
Div("procedures", css_class="col-md-3"),
css_class="row g-2",
),
Div(
@@ -549,9 +554,9 @@ class NormalCaseFilter(django_filters.FilterSet):
def filter_min_value(self, queryset, name, value):
try:
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "years")
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
except Exception:
unit = "years"
unit = "days"
try:
days = self._convert_to_days(value, unit)
except Exception:
@@ -560,15 +565,20 @@ class NormalCaseFilter(django_filters.FilterSet):
def filter_max_value(self, queryset, name, value):
try:
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "years")
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
except Exception:
unit = "years"
unit = "days"
try:
days = self._convert_to_days(value, unit)
except Exception:
return queryset
return queryset.filter(age_days__lte=days)
def filter_age_unit(self, queryset, name, value):
# `age_unit` is a helper input used by min/max conversion and does not
# map to a database field on NormalCase.
return queryset
class ResourceFilter(django_filters.FilterSet):
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
+293 -22
View File
@@ -42,9 +42,11 @@ from atlas.models import (
Subspecialty,
Presentation,
PathologicalProcess,
Procedure,
UncategorisedDicom,
UserReportAnswer,
CaseDisplaySet,
CaseReviewMessage,
)
from .models import NormalCase
@@ -115,6 +117,26 @@ class CaseSelect(Select):
pass
class MoveSelectedSeriesForm(Form):
destination_case = ModelChoiceField(
queryset=Case.objects.none(),
widget=HiddenInput(),
label="Move selected series to case",
required=True,
)
def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
source_case = kwargs.pop("source_case", None)
super().__init__(*args, **kwargs)
available_cases = get_cases_available_to_user(user)
if source_case is not None:
available_cases = available_cases.exclude(pk=source_case.pk)
self.fields["destination_case"].queryset = available_cases.order_by("pk")
class ConditionForm(ModelForm):
class Meta:
model = Condition
@@ -158,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
@@ -189,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")]
@@ -226,6 +315,10 @@ class CaseCollectionForm(ModelForm):
"show_description_post",
"show_discussion_post",
"show_report_post",
"show_case_link_post",
"show_findings_post",
"show_displaysets_post",
"show_differentials_post",
]
if f in self.fields
]
@@ -262,9 +355,26 @@ class CaseCollectionForm(ModelForm):
"exam_mode",
"self_review",
"feedback_once_collection_complete",
"case_query_messaging_enabled",
"show_results_sharing_information",
"viewer_mode",
"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",
"start_screen_resources",
),
Fieldset(
"Custom End Overview",
"end_overview_content",
"end_overview_resources",
),
),
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
Div(
@@ -298,6 +408,22 @@ class NormalCaseForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# If editing an existing NormalCase, prepopulate age_value/age_unit
try:
instance = kwargs.get('instance')
except Exception:
instance = None
if instance is None:
# also support self.instance when ModelForm sets it
instance = getattr(self, 'instance', None)
if instance is not None and getattr(instance, 'age_days', None) is not None:
try:
val, unit = NormalCase.days_to_value_unit(instance.age_days)
if val is not None and unit is not None:
self.fields['age_value'].initial = val
self.fields['age_unit'].initial = unit
except Exception:
pass
self.helper = FormHelper()
# Do not render a <form> tag from crispy here because the template
# already provides the surrounding form element (we inject this
@@ -315,7 +441,7 @@ class NormalCaseForm(ModelForm):
css_class="row",
),
Field("notes"),
Submit("submit", "Save", css_class="btn btn-primary"),
# Submit button rendered in modal footer; avoid duplicate button here
)
def save(self, commit=True):
@@ -379,6 +505,22 @@ class PathologicalProcessForm(ModelForm):
exclude = []
class ProcedureForm(ModelForm):
class Meta:
model = Procedure
exclude = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
try:
self.helper = FormHelper()
self.helper.form_method = "post"
# Prevent crispy from rendering its own <form> tag so our
# template-level form wrapper contains the submit buttons.
self.helper.form_tag = False
except Exception:
pass
class SeriesFindingForm(ModelForm):
class Meta:
model = SeriesFinding
@@ -559,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])
@@ -601,12 +744,14 @@ class CaseForm(ModelForm):
"presentation",
"discussion",
"report",
"procedures",
"condition",
"pathological_process",
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
#cimar_uuid",
"study_date",
),
)
@@ -632,13 +777,15 @@ class CaseForm(ModelForm):
"history",
"presentation",
"discussion",
"procedures",
"condition",
"pathological_process",
"report",
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
#cimar_uuid",
"study_date",
]
# fields = ['question', 'findings', 'subspecialty', 'references']
widgets = {
@@ -661,6 +808,9 @@ class CaseForm(ModelForm):
"subspecialty": CheckboxSelectMultiple(),
"pathological_process": CheckboxSelectMultiple(),
"previous_case": CaseSelect(),
"procedures": autocomplete.ModelSelect2Multiple(
url="atlas:procedure-autocomplete"
),
}
def clean_cimar_uuid(self):
@@ -692,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
@@ -807,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,
@@ -1020,6 +1176,14 @@ class AddCollectionToCaseForm(Form):
class SelfReviewForm(ModelForm):
SCORE_CHOICES = [
(1, "1 - Poor"),
(2, "2 - Limited"),
(3, "3 - Adequate"),
(4, "4 - Good"),
(5, "5 - Excellent"),
]
class Meta:
model = SelfReview
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
@@ -1034,6 +1198,75 @@ class SelfReviewForm(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
super(SelfReviewForm, self).__init__(*args, **kwargs)
# Render scoring fields as explicit 1-5 selector choices for cleaner UX.
self.fields["findings"] = forms.TypedChoiceField(
choices=self.SCORE_CHOICES,
coerce=int,
empty_value=None,
required=False,
widget=RadioSelect,
label="Findings",
help_text="How well did you identify key findings?",
)
self.fields["interpretation"] = forms.TypedChoiceField(
choices=self.SCORE_CHOICES,
coerce=int,
empty_value=None,
required=False,
widget=RadioSelect,
label="Interpretation",
help_text="How strong was your overall interpretation?",
)
self.fields["comments"].widget.attrs.update(
{
"rows": 5,
"placeholder": "What went well? What would you do differently next time?",
}
)
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Field("user_exam"),
Field("case"),
Field("comments"),
Field("findings"),
Field("interpretation"),
)
class CaseReviewMessageForm(ModelForm):
class Meta:
model = CaseReviewMessage
fields = ["message", "requires_acknowledgement"]
def __init__(self, *args, allow_ack=False, message_placeholder=None, **kwargs):
super(CaseReviewMessageForm, self).__init__(*args, **kwargs)
self.fields["message"].widget.attrs.update(
{
"rows": 3,
"placeholder": message_placeholder or "Write a message...",
"class": "form-control",
}
)
self.fields["requires_acknowledgement"].widget.attrs.update(
{
"class": "form-check-input",
}
)
if not allow_ack:
self.fields["requires_acknowledgement"].widget = HiddenInput()
self.fields["requires_acknowledgement"].required = False
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
class UncategorisedDicomForm(ModelForm):
class Meta:
@@ -1144,7 +1377,14 @@ class CaseQuestionForm(ModelForm):
class CaseDetailForm(ModelForm):
class Meta:
model = CaseDetail
fields = ["redact_history", "override_history"]
fields = [
"redact_history",
"override_history",
"learner_comment",
"learner_review_comment",
"question_time_limit_override",
"answer_entry_grace_period_override",
]
def __init__(self, *args, case_history: str = None, **kwargs):
"""
@@ -1167,6 +1407,16 @@ class CaseDetailForm(ModelForm):
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
if "question_time_limit_override" in self.fields:
self.fields["question_time_limit_override"].help_text = (
"Leave blank to use the collection default answer time limit."
)
if "answer_entry_grace_period_override" in self.fields:
self.fields["answer_entry_grace_period_override"].help_text = (
"Leave blank to use the collection default answer-only grace period."
)
# Build crispy helper/layout embedding the original history and buttons tied to override_history
self.helper = FormHelper()
self.helper.form_tag = False
@@ -1204,7 +1454,17 @@ class CaseDetailForm(ModelForm):
self.helper.layout = Layout(
HTML(history_block),
Field("override_history"),
Field("redact_history")
Field("redact_history"),
Fieldset(
"Learner Comments",
Field("learner_comment"),
Field("learner_review_comment"),
),
Fieldset(
"Case Timing Overrides",
Field("question_time_limit_override"),
Field("answer_entry_grace_period_override"),
),
)
@@ -1235,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,17 @@
# Generated by Django 6.0.1 on 2026-02-16 13:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0090_casecollection_created_casecollection_updated'),
]
operations = [
migrations.RemoveField(
model_name='normalcase',
name='added_by',
),
]
@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-02-16 13:29
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0091_remove_normalcase_added_by'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='normalcase',
name='author',
field=models.ManyToManyField(blank=True, related_name='normal_cases', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,30 @@
# Generated by Django 6.0.1 on 2026-02-16 14:01
import atlas.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0092_normalcase_author'),
]
operations = [
migrations.CreateModel(
name='Procedure',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('canonical', models.ForeignKey(blank=True, help_text='If set, this Procedure is an alias and points to the canonical Procedure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.procedure')),
('subspecialty', models.ManyToManyField(blank=True, to='atlas.subspecialty')),
],
bases=(atlas.models.SynMixin, models.Model),
),
migrations.AddField(
model_name='case',
name='procedures',
field=models.ManyToManyField(blank=True, help_text='Procedure(s) or operations the patient has undergone.', to='atlas.procedure'),
),
]
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 6.0.1 on 2026-02-23 11:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0093_procedure_case_procedures'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, help_text='Friendly name for this token', max_length=200)),
('token_hash', models.CharField(db_index=True, max_length=64)),
('scopes', models.CharField(blank=True, help_text='Space-separated scopes', max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
('expires', models.DateTimeField(blank=True, null=True)),
('revoked', models.BooleanField(default=False)),
('last_used', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API token',
'verbose_name_plural': 'API tokens',
},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-04-13 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0094_apitoken'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='show_case_link_post',
field=models.BooleanField(default=False, help_text='Show a link to the case (post exam)'),
),
migrations.AddField(
model_name='casecollection',
name='show_displaysets_post',
field=models.BooleanField(default=False, help_text='Show display sets for the case (post exam)'),
),
migrations.AddField(
model_name='casecollection',
name='show_findings_post',
field=models.BooleanField(default=False, help_text='Show findings related to the case (post exam)'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-04-13 13:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0095_casecollection_show_case_link_post_and_more'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='show_differentials_post',
field=models.BooleanField(default=False, help_text='Show differentials for the case (post exam)'),
),
]
@@ -0,0 +1,33 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("generic", "0031_flag"),
("atlas", "0096_casecollection_show_differentials_post"),
]
operations = [
migrations.CreateModel(
name="CaseReviewMessage",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("message_type", models.CharField(choices=[("feedback", "Feedback"), ("query", "Query"), ("reply", "Reply")], default="query", max_length=20)),
("message", models.TextField()),
("author_label", models.CharField(blank=True, max_length=255)),
("requires_acknowledgement", models.BooleanField(default=False)),
("acknowledged_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("author", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages_authored", to=settings.AUTH_USER_MODEL)),
("case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="atlas.case")),
("cid_user_exam", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="generic.ciduserexam")),
],
options={
"ordering": ("created_at",),
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-04-27 21:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0097_casereviewmessage'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='case_query_messaging_enabled',
field=models.BooleanField(default=True, help_text='Enable per-case query and messaging for this collection.'),
),
]
@@ -0,0 +1,77 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("atlas", "0098_casecollection_case_query_messaging_enabled"),
]
operations = [
migrations.AddField(
model_name="casedetail",
name="answer_entry_grace_period_override",
field=models.PositiveIntegerField(
blank=True,
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
null=True,
),
),
migrations.AddField(
model_name="casedetail",
name="question_time_limit_override",
field=models.PositiveIntegerField(
blank=True,
help_text="Optional per-case override for the answer time limit in seconds.",
null=True,
),
),
migrations.AddField(
model_name="casecollection",
name="answer_entry_grace_period",
field=models.PositiveIntegerField(
blank=True,
default=0,
help_text="Additional seconds after the case view is locked during which answers can still be entered.",
null=True,
),
),
migrations.AddField(
model_name="casecollection",
name="end_overview_content",
field=models.TextField(
blank=True,
help_text="Optional HTML content shown on the collection end overview screen.",
null=True,
),
),
migrations.AddField(
model_name="casecollection",
name="end_overview_resources",
field=models.ManyToManyField(
blank=True,
help_text="Optional resources shown on the collection end overview screen.",
related_name="end_overview_collections",
to="atlas.resource",
),
),
migrations.AddField(
model_name="casecollection",
name="start_screen_content",
field=models.TextField(
blank=True,
help_text="Optional HTML content shown on the collection start screen (for example embedded resources).",
null=True,
),
),
migrations.AddField(
model_name="casecollection",
name="start_screen_resources",
field=models.ManyToManyField(
blank=True,
help_text="Optional resources shown on the collection start screen.",
related_name="start_screen_collections",
to="atlas.resource",
),
),
]
@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("atlas", "0099_casecollection_workflow_customisation"),
]
operations = [
migrations.AddField(
model_name="casecollection",
name="show_results_sharing_information",
field=models.BooleanField(
default=True,
help_text="If true, show result-sharing information on candidate start and overview screens.",
),
),
]
@@ -0,0 +1,65 @@
# Generated by Django 5.2.7 on 2026-05-11 08:56
from django.db import migrations, models
import pydicom
def backfill_series_study_instance_uid(apps, schema_editor):
Series = apps.get_model('atlas', 'Series')
SeriesImage = apps.get_model('atlas', 'SeriesImage')
series_qs = Series.objects.filter(study_instance_uid__isnull=True)
for series in series_qs.iterator():
image_obj = (
SeriesImage.objects.filter(series_id=series.pk, removed=False)
.exclude(image='')
.order_by('pk')
.first()
)
if image_obj is None:
image_obj = (
SeriesImage.objects.filter(series_id=series.pk)
.exclude(image='')
.order_by('pk')
.first()
)
if image_obj is None or not image_obj.image:
continue
try:
with image_obj.image.open('rb') as fp:
ds = pydicom.dcmread(fp, stop_before_pixels=True, force=True)
value = ds.get('StudyInstanceUID')
if value is None:
continue
if hasattr(value, 'value'):
value = value.value
study_uid = str(value).strip()
if not study_uid:
continue
Series.objects.filter(pk=series.pk).update(study_instance_uid=study_uid)
except Exception:
continue
def noop_reverse(apps, schema_editor):
return
class Migration(migrations.Migration):
dependencies = [
('atlas', '0100_casecollection_show_results_sharing_information'),
]
operations = [
migrations.AddField(
model_name='series',
name='study_instance_uid',
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
),
migrations.RunPython(backfill_series_study_instance_uid, noop_reverse),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-05-11 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0101_series_study_instance_uid'),
]
operations = [
migrations.AlterField(
model_name='seriesimage',
name='image_blake3_hash',
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
),
migrations.AlterField(
model_name='uncategoriseddicom',
name='image_blake3_hash',
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 6.0.1 on 2026-05-12 20:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0102_alter_seriesimage_image_blake3_hash_and_more'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='auto_apply_question_template',
field=models.BooleanField(default=False, help_text='If enabled, the case_question_template is automatically applied to new cases added to this collection that do not have an existing question_schema.'),
),
migrations.AddField(
model_name='casecollection',
name='case_question_max_score',
field=models.PositiveIntegerField(blank=True, help_text='Maximum score per case used for normalisation. Defaults to 10 when not set.', null=True),
),
migrations.AddField(
model_name='casecollection',
name='case_question_template',
field=models.JSONField(blank=True, help_text='JSON schema applied as a default question template for all cases in this collection. Individual cases can override via CaseDetail.question_schema.', null=True),
),
migrations.AddField(
model_name='casecollection',
name='marking_guidance',
field=models.TextField(blank=True, help_text='Plain-language marking guidance displayed to markers.'),
),
migrations.AddField(
model_name='casecollection',
name='marking_scheme_name',
field=models.CharField(blank=True, help_text='Human-readable mark scheme name shown to markers and in exports.', max_length=255),
),
]
@@ -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',
),
]
+909 -54
View File
File diff suppressed because it is too large Load Diff
+483
View File
@@ -0,0 +1,483 @@
// Simple markup inserter module
(function(global){
console.log('markup_inserter.js loaded');
function insertAtCursor(el, text){
if(!el) return;
const start = el.selectionStart || 0;
const end = el.selectionEnd || 0;
const val = el.value || '';
el.value = val.slice(0, start) + text + val.slice(end);
el.selectionStart = el.selectionEnd = start + text.length;
el.focus();
}
function createToolbar(){
const wrap = document.createElement('div');
wrap.className = 'd-flex gap-2 mb-2 markup-inserter-toolbar';
const group = document.createElement('div');
group.className = 'btn-group';
const makeBtn = function(type, text){
const b = document.createElement('button');
b.type = 'button'; b.className = 'btn btn-sm btn-outline-secondary markup-insert-button';
b.dataset.type = type; b.textContent = text; return b;
};
group.appendChild(makeBtn('case','Insert case'));
group.appendChild(makeBtn('finding','Insert finding'));
group.appendChild(makeBtn('displayset','Insert displayset'));
const hint = document.createElement('div'); hint.className='flex-fill text-muted small align-self-center'; hint.textContent='Use the buttons to insert markup like [[case:123]]';
wrap.appendChild(group); wrap.appendChild(hint);
return wrap;
}
function attachToolbarToTextarea(textarea){
if(!textarea) return null;
// avoid double attaching
if(textarea._markupToolbarAttached) return null;
console.log('Attaching markup toolbar to textarea', textarea);
const toolbar = createToolbar();
textarea.parentNode.insertBefore(toolbar, textarea);
toolbar.querySelectorAll('.markup-insert-button').forEach(function(btn){
btn.addEventListener('click', function(){
const t = btn.dataset.type;
// Respect explicit dataset provided by the widget or page meta only
var detectedCase = textarea && textarea.dataset && textarea.dataset.currentCase ? textarea.dataset.currentCase : (document.getElementById('current-case-meta') ? document.getElementById('current-case-meta').getAttribute('data-current-case') : null);
if(t === 'case'){
openCasePicker(textarea);
} else if(t === 'finding'){
openFindingPicker(textarea, detectedCase);
} else if(t === 'displayset'){
openDisplaysetPicker(textarea, detectedCase);
}
});
});
textarea._markupToolbarAttached = true;
return toolbar;
}
function initForNames(names){
if(!Array.isArray(names)) return;
document.addEventListener('DOMContentLoaded', function(){
console.log('markupInserter.initForNames DOMContentLoaded handler running for', names);
names.forEach(function(name){
const el = document.querySelector('textarea[name="'+name+'"]');
if(el) attachToolbarToTextarea(el);
});
});
}
function attachToSelector(selector){
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll(selector).forEach(function(el){
if(el.tagName === 'TEXTAREA') attachToolbarToTextarea(el);
});
});
}
// Auto-attach to placeholders rendered in HTMX responses or server-side includes.
document.addEventListener('DOMContentLoaded', function(){
console.log('markupInserter DOMContentLoaded auto-attach running');
// Attach to explicit placeholders
document.querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
let ta = ph.nextElementSibling;
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
});
// Also auto-init for common case textarea names to cover full-page forms
try{ initForNames(['description','history','discussion','report','notes']); }catch(e){}
});
document.addEventListener('htmx:afterSwap', function(evt){
console.log('markupInserter htmx:afterSwap event', evt && evt.detail && evt.detail.target);
try{
const target = evt.detail && evt.detail.target ? evt.detail.target : document;
(target.querySelectorAll ? target : document).querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
let ta = ph.nextElementSibling;
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
});
// Also attach to any textareas within the swapped target that match our common names
var ctx = evt.detail && evt.detail.target ? evt.detail.target : document;
['description','history','discussion','report','notes'].forEach(function(name){
ctx.querySelectorAll && ctx.querySelectorAll('textarea[name="'+name+'"]') && ctx.querySelectorAll('textarea[name="'+name+'"]').forEach(function(ta){ attachToolbarToTextarea(ta); });
});
}catch(e){/* ignore */}
});
// Fallback: attach toolbar when a textarea receives focus (covers dynamically created editors)
document.addEventListener('focusin', function(e){
console.log('markupInserter focusin event for', e && e.target);
try{
var el = e.target;
if(!el || el.tagName !== 'TEXTAREA') return;
// Attach for common names or if a placeholder is present
var name = el.getAttribute('name') || '';
if(['description','history','discussion','report','notes'].includes(name) || (el.previousElementSibling && el.previousElementSibling.classList && el.previousElementSibling.classList.contains('markup-toolbar-placeholder'))){
attachToolbarToTextarea(el);
}
}catch(err){/* ignore */}
}, true);
// Expose API
global.markupInserter = {
attachToolbarToTextarea: attachToolbarToTextarea,
initForNames: initForNames,
attachToSelector: attachToSelector
};
// --- Case picker modal ---
function openCasePicker(textarea){
try{
console.log('Opening case picker for', textarea);
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'markup-case-picker-overlay';
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
const dialog = document.createElement('div');
dialog.className = 'markup-case-picker-dialog card';
Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
header.innerHTML = '<strong>Select case</strong>';
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close');
closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); });
header.appendChild(closeBtn);
const body = document.createElement('div'); body.className='card-body';
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search cases...';
const results = document.createElement('div'); results.className='case-search-results';
body.appendChild(input); body.appendChild(results);
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog);
document.body.appendChild(overlay);
input.focus();
var timer = null;
input.addEventListener('input', function(){
if(timer) clearTimeout(timer);
timer = setTimeout(function(){
var q = encodeURIComponent(input.value || '');
// Fetch server-side partial that renders result list-group
var url = window.location.origin + '/atlas/search/cases/?q=' + q;
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){
results.innerHTML = html;
}).catch(function(e){ console.error('case picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; });
}, 300);
});
// Delegate click on result items
results.addEventListener('click', function(e){
var item = e.target.closest('.list-group-item');
if(!item) return;
var pk = item.getAttribute('data-case-pk');
if(!pk){
var a = item.querySelector('a');
if(a){ var m = a.getAttribute('href').match(/case\/(\d+)\/?/); if(m) pk = m[1]; }
}
if(!pk) return;
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('h6') ? item.querySelector('h6').textContent.trim() : '');
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
var token = '[[' + 'case:' + pk + (labelToUse ? '|' + labelToUse : '') + ']]';
insertAtCursor(textarea, token);
// remove modal
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
});
// Allow ESC to close
overlay.addEventListener('keydown', function(ev){ if(ev.key === 'Escape'){ if(overlay.parentNode) overlay.parentNode.removeChild(overlay); } });
// make overlay focusable so keydown works
overlay.tabIndex = -1; overlay.focus();
// load initial recent results (empty query)
fetch(window.location.origin + '/atlas/search/cases/').then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){});
}catch(err){ console.error('openCasePicker error', err); }
}
function openFindingPicker(textarea, caseId){
try{
console.log('Opening finding picker for', textarea);
const overlay = document.createElement('div');
overlay.className = 'markup-case-picker-overlay';
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center'; header.innerHTML = '<strong>Select finding</strong>';
// Determine case filter from explicit dataset on the textarea or provided caseId
var detectedF = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
var currentCaseF = caseId || detectedF.id || null;
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
const body = document.createElement('div'); body.className='card-body';
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search findings...';
const results = document.createElement('div'); results.className='finding-search-results'; body.appendChild(input); body.appendChild(results);
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
var timer = null;
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/findings/?q=' + q + (currentCaseF ? '&case=' + currentCaseF : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('finding picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
results.addEventListener('click', function(e){
var item = e.target.closest('.list-group-item');
if(!item) return;
var sfpk = item.getAttribute('data-seriesfinding-pk');
var fpk = item.getAttribute('data-finding-pk');
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
if(sfpk){
var token = '[['+'seriesfinding:'+sfpk + (labelToUse ? '|' + labelToUse : '') + ']]';
insertAtCursor(textarea, token);
} else if(fpk){
var token = '[['+'finding:'+fpk + (labelToUse ? '|' + labelToUse : '') + ']]';
insertAtCursor(textarea, token);
} else {
return;
}
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
});
overlay.tabIndex = -1; overlay.focus();
function fetchInitialFindings(){ var initFindingsUrl = window.location.origin + '/atlas/search/findings/' + (currentCaseF ? '?case=' + currentCaseF : ''); fetch(initFindingsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
fetchInitialFindings();
}catch(err){ console.error('openFindingPicker error', err); }
}
function openDisplaysetPicker(textarea, caseId){
try{
console.log('Opening displayset picker for', textarea);
const overlay = document.createElement('div');
overlay.className = 'markup-case-picker-overlay';
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
header.innerHTML = '<strong>Select display set</strong>';
// Determine case filter from explicit dataset on the textarea or provided caseId
var detectedD = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
var currentCaseD = caseId || detectedD.id || null;
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
const body = document.createElement('div'); body.className='card-body';
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search display sets...';
const results = document.createElement('div'); results.className='displayset-search-results'; body.appendChild(input); body.appendChild(results);
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
var timer = null;
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/displaysets/?q=' + q + (currentCaseD ? '&case=' + currentCaseD : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('displayset picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
results.addEventListener('click', function(e){
var item = e.target.closest('.list-group-item');
if(!item) return;
var pk = item.getAttribute('data-ds-pk');
if(!pk) return;
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
var token = '[['+'displayset:'+pk + (labelToUse ? '|' + labelToUse : '') + ']]';
insertAtCursor(textarea, token);
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
});
overlay.tabIndex = -1; overlay.focus();
function fetchInitialDisplaysets(){ var initDisplaysetsUrl = window.location.origin + '/atlas/search/displaysets/' + (currentCaseD ? '?case=' + currentCaseD : ''); fetch(initDisplaysetsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
fetchInitialDisplaysets();
}catch(err){ console.error('openDisplaysetPicker error', err); }
}
// Simplified detection: rely on explicit dataset attributes set by the widget
// When a textarea includes `data-current-case` and optionally `data-current-case-title`, that will be used as the default filter.
// --- Render inserted markup tokens as styled items with popup/modal actions ---
function injectMarkupStyles(){
if(document.getElementById('markup-inserter-styles')) return;
var s = document.createElement('style'); s.id = 'markup-inserter-styles';
s.textContent = '\n.markup-token{cursor:pointer;display:inline-block;margin:0 .25rem .25rem 0;padding:.25rem .5rem;border-radius:.375rem;font-size:0.85em;}\n.markup-popup{position:fixed;z-index:1060;background:#fff;border:1px solid rgba(0,0,0,.12);box-shadow:0 6px 18px rgba(0,0,0,.12);padding:.5rem;border-radius:.25rem;}\n.markup-modal-backdrop{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(0,0,0,.45);z-index:1080;display:flex;align-items:flex-start;justify-content:center;padding-top:4vh;}\n.markup-modal{background:#fff;border-radius:.375rem;max-width:95%;max-height:85vh;overflow:auto;}\n';
document.head.appendChild(s);
}
function getDetailUrl(type, pk){
if(!type || !pk) return null;
if(type === 'seriesfinding') return window.location.origin + '/atlas/series_finding/' + pk + '/preview/';
if(type === 'finding') return window.location.origin + '/atlas/finding/' + pk;
if(type === 'displayset') return window.location.origin + '/atlas/displayset/' + pk + '/preview/';
if(type === 'case') return window.location.origin + '/atlas/case/' + pk + '/';
return null;
}
// Reusable modal element
var __markup_modal = null;
function ensureModal(){
if(__markup_modal) return __markup_modal;
var backdrop = document.createElement('div'); backdrop.className = 'markup-modal-backdrop'; backdrop.style.display='none';
var modal = document.createElement('div'); modal.className = 'markup-modal card'; modal.style.marginTop='1rem'; modal.style.maxHeight='80vh'; modal.style.overflow='auto';
var header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
var h = document.createElement('strong'); h.textContent = 'Detail';
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.addEventListener('click', function(){ backdrop.style.display='none'; modal.querySelector('.card-body') && (modal.querySelector('.card-body').innerHTML=''); });
header.appendChild(h); header.appendChild(closeBtn);
var body = document.createElement('div'); body.className='card-body'; body.style.maxHeight='70vh'; body.style.overflow='auto';
modal.appendChild(header); modal.appendChild(body); backdrop.appendChild(modal); document.body.appendChild(backdrop);
__markup_modal = { backdrop: backdrop, modal: modal, body: body };
return __markup_modal;
}
function showModalForUrl(url, title){
var m = ensureModal();
m.backdrop.style.display = 'flex';
if(title){ var h = m.modal.querySelector('strong'); if(h) h.textContent = title; }
m.body.innerHTML = '<div class="p-3 text-muted">Loading…</div>';
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ m.body.innerHTML = html; }).catch(function(e){ m.body.innerHTML = '<div class="p-3 text-danger">Failed to load</div>'; console.error('modal fetch error', e); });
}
function showPopupFor(element){
// remove existing
var existing = document.getElementById('markup-popup'); if(existing) existing.remove();
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
var popup = document.createElement('div'); popup.id = 'markup-popup'; popup.className = 'markup-popup';
popup.innerHTML = '<div style="font-weight:600;margin-bottom:.25rem;">'+ (label||type+':'+pk) +'</div>';
var openBtn = document.createElement('button'); openBtn.type='button'; openBtn.className='btn btn-sm btn-outline-primary me-2'; openBtn.textContent='Open';
openBtn.addEventListener('click', function(){
var url = getDetailUrl(type, pk);
try{
// Prefer page-specific modal loaders if present so we reuse existing Bootstrap modals/viewers
if(type === 'displayset'){
if(typeof window.loadDisplaysetModal === 'function'){
window.loadDisplaysetModal(url, pk);
popup.remove();
return;
}
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
if(dsBtn){ dsBtn.click(); popup.remove(); return; }
}
if(type === 'seriesfinding' || type === 'finding'){
if(typeof window.loadFindingModal === 'function'){
window.loadFindingModal(pk, url);
popup.remove();
return;
}
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
if(fBtn){ fBtn.click(); popup.remove(); return; }
}
}catch(e){ console.warn('error invoking page modal loader', e); }
if(url) showModalForUrl(url, label);
popup.remove();
});
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn btn-sm btn-outline-secondary'; closeBtn.textContent='Close'; closeBtn.addEventListener('click', function(){ popup.remove(); });
popup.appendChild(openBtn); popup.appendChild(closeBtn);
document.body.appendChild(popup);
// position near element
try{
var r = element.getBoundingClientRect();
popup.style.top = (window.scrollY + r.bottom + 6) + 'px';
popup.style.left = Math.min(window.innerWidth - 240, window.scrollX + r.left) + 'px';
popup.style.minWidth = '160px';
}catch(e){}
// clicking outside closes it
setTimeout(function(){
var onDoc = function(ev){ if(!popup.contains(ev.target) && ev.target !== element) { popup.remove(); document.removeEventListener('click', onDoc); } };
document.addEventListener('click', onDoc);
}, 10);
}
// Try to open a modal for a token (prefer page-specific modal loaders)
function openModalForToken(element){
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
var url = getDetailUrl(type, pk);
try{
if(type === 'displayset'){
if(typeof window.loadDisplaysetModal === 'function'){
window.loadDisplaysetModal(url, pk); return;
}
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
if(dsBtn){ dsBtn.click(); return; }
}
if(type === 'seriesfinding' || type === 'finding'){
if(typeof window.loadFindingModal === 'function'){
window.loadFindingModal(pk, url); return;
}
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
if(fBtn){ fBtn.click(); return; }
}
}catch(e){ console.warn('error invoking page modal loader', e); }
if(url) showModalForUrl(url, label);
}
// Replace tokens in text nodes with styled spans
function renderMarkupTokens(root){
injectMarkupStyles();
root = root || document.body;
// Convert server-rendered anchor tokens into our interactive spans
try{
if(root.querySelectorAll){
root.querySelectorAll('a.markup-link').forEach(function(a){
try{
var type = a.getAttribute('data-type') || '';
var args = [];
try{ args = JSON.parse(a.getAttribute('data-args') || '[]'); }catch(e){ args = []; }
var pk = (args && args.length) ? (args[0] + '') : (a.getAttribute('data-pk') || '');
var label = a.textContent ? a.textContent.trim() : null;
var span = document.createElement('span');
span.className = 'markup-token';
if(type) span.setAttribute('data-type', type);
if(pk) span.setAttribute('data-pk', pk);
if(label) span.setAttribute('data-label', label);
if(type === 'case') span.className += ' badge bg-primary text-white';
else if(type === 'displayset') span.className += ' badge bg-success text-white';
else span.className += ' badge bg-info text-dark';
span.textContent = label ? label : (type + ':' + pk);
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
a.parentNode.replaceChild(span, a);
}catch(e){}
});
}
}catch(e){}
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
var nodes = [];
while(walker.nextNode()) nodes.push(walker.currentNode);
var re = /\[\[(seriesfinding|finding|displayset|case):(\d+)(?:\|([^\]]+))?\]\]/gi;
nodes.forEach(function(textNode){
if(!textNode.nodeValue || !re.test(textNode.nodeValue)) return;
var parentTag = textNode.parentElement && textNode.parentElement.tagName ? textNode.parentElement.tagName.toLowerCase() : '';
if(['script','style','textarea','code','pre','a','button'].includes(parentTag)) return;
// rebuild the fragment
var frag = document.createDocumentFragment();
var txt = textNode.nodeValue;
var lastIndex = 0; var m;
re.lastIndex = 0;
while((m = re.exec(txt)) !== null){
var before = txt.slice(lastIndex, m.index);
if(before) frag.appendChild(document.createTextNode(before));
var type = m[1].toLowerCase(); var pk = m[2]; var label = m[3] ? m[3] : null;
var span = document.createElement('span'); span.className = 'markup-token'; span.setAttribute('data-type', type); span.setAttribute('data-pk', pk); if(label) span.setAttribute('data-label', label);
// style by type
if(type === 'case') span.className += ' badge bg-primary text-white';
else if(type === 'displayset') span.className += ' badge bg-success text-white';
else span.className += ' badge bg-info text-dark';
span.textContent = label ? label : (type + ':' + pk);
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
frag.appendChild(span);
lastIndex = re.lastIndex;
}
var rest = txt.slice(lastIndex); if(rest) frag.appendChild(document.createTextNode(rest));
textNode.parentNode.replaceChild(frag, textNode);
});
// Ensure any server-rendered span.markup-token elements have click handlers
try{
if(root.querySelectorAll){
root.querySelectorAll('span.markup-token').forEach(function(span){
try{
if(span._markupBound) return;
span._markupBound = true;
// ensure data attributes are present
var type = span.getAttribute('data-type') || '';
var pk = span.getAttribute('data-pk') || '';
var label = span.getAttribute('data-label') || span.textContent || '';
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
}catch(e){}
});
}
}catch(e){}
}
// Run rendering on DOMContentLoaded and after HTMX swaps
document.addEventListener('DOMContentLoaded', function(){ try{ renderMarkupTokens(document.body); }catch(e){console.error(e);} });
document.addEventListener('htmx:afterSwap', function(evt){ try{ var target = evt.detail && evt.detail.target ? evt.detail.target : document; renderMarkupTokens(target); }catch(e){/*ignore*/} });
})(window);
+23
View File
@@ -15,11 +15,13 @@ from .models import (
Finding,
Subspecialty,
Resource,
Procedure,
)
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
@@ -158,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
)
@@ -395,6 +398,26 @@ class PathologicalProcessTable(SelectionTable):
sequence = ("name",)
class ProcedureTable(SelectionTable):
name = tables.Column(
linkify=("atlas:procedure_detail", {"pk": tables.A("pk")}),
verbose_name="Procedure",
)
edit = tables.LinkColumn(
"atlas:procedure_update", text="Edit", args=[A("pk")], orderable=False
)
delete = tables.LinkColumn(
"atlas:procedure_delete", text="Delete", args=[A("pk")], orderable=False
)
class Meta(SelectionTable.Meta):
model = Procedure
template_name = "django_tables2/bootstrap4.html"
fields = ("name",)
sequence = ("name",)
class SubspecialtyTable(SelectionTable):
name = tables.Column(
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
+560 -4
View File
@@ -2,14 +2,201 @@ from time import sleep
from django.core.mail import send_mail
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from celery import shared_task
from atlas.models import Case
from enum import Enum
try:
from django_tasks import task
HAS_DJANGO_TASKS = True
except ImportError:
from celery import shared_task
from celery.result import AsyncResult
HAS_DJANGO_TASKS = False
class _CompatTaskResultStatus(Enum):
READY = "READY"
RUNNING = "RUNNING"
FAILED = "FAILED"
SUCCESSFUL = "SUCCESSFUL"
class _CompatError:
def __init__(self, traceback):
self.traceback = traceback
class _CompatTaskResult:
def __init__(self, async_result):
self._async_result = async_result
@property
def id(self):
return self._async_result.id
@property
def status(self):
state = self._async_result.state
if state in ("PENDING",):
return _CompatTaskResultStatus.READY
if state in ("STARTED", "RETRY"):
return _CompatTaskResultStatus.RUNNING
if state == "SUCCESS":
return _CompatTaskResultStatus.SUCCESSFUL
if state == "FAILURE":
return _CompatTaskResultStatus.FAILED
return _CompatTaskResultStatus.RUNNING
def refresh(self):
return self
@property
def return_value(self):
return self._async_result.result
@property
def errors(self):
if self._async_result.state == "FAILURE" and self._async_result.traceback:
return [_CompatError(self._async_result.traceback)]
return []
def task(func=None, *, takes_context=False, **kwargs):
def decorator(inner_func):
if takes_context:
@shared_task(bind=True)
def wrapped(self, *args, **inner_kwargs):
class _Ctx:
class _TaskResultRef:
id = self.request.id
task_result = _TaskResultRef()
attempt = 1
return inner_func(_Ctx(), *args, **inner_kwargs)
else:
wrapped = shared_task(inner_func)
wrapped.enqueue = wrapped.delay
wrapped.get_result = lambda result_id: _CompatTaskResult(AsyncResult(result_id))
return wrapped
if func is not None:
return decorator(func)
return decorator
from atlas.models import Case, Series, SeriesImage
from generic.models import CimarCase
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
from helpers.cimar import CimarAPI, NotFoundError
from pydicom.uid import generate_uid
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.core.cache import cache
import copy
import io
@shared_task()
@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."""
case = get_object_or_404(Case, pk=case_id)
@@ -49,4 +236,373 @@ def push_case_to_cimar_task(case_id):
cimar_case.refresh_study()
return 10
return 10
@task(takes_context=True)
def series_reconstruct_task(
context,
series_id,
user_id,
recon_planes,
slice_thickness_val=None,
slice_spacing_val=None,
recon_thickness_mode="mean",
):
"""Generate reconstructions asynchronously for a series with progress updates."""
import numpy as np
from loguru import logger
from atlas import views as atlas_views
progress_key = f"series_reconstruct_progress:{context.task_result.id}"
cache.set(
progress_key,
{"current": 0, "total": 0, "message": "Preparing reconstruction..."},
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())
dicom_items = []
for image in images:
ds = atlas_views._read_series_image_dataset(image)
if ds is None:
continue
try:
arr = ds.pixel_array
if arr.ndim != 2:
continue
dicom_items.append((image, ds, arr))
except Exception:
continue
if len(dicom_items) < 2:
raise ValueError("Need at least 2 valid DICOM images in series for reconstruction")
base_shape = dicom_items[0][2].shape
dicom_items = [item for item in dicom_items if item[2].shape == base_shape]
if len(dicom_items) < 2:
raise ValueError("Not enough consistently-sized slices for reconstruction")
geom = atlas_views._extract_recon_geometry(dicom_items)
sorted_items = geom["sorted_items"]
volume = np.stack([item[2] for item in sorted_items], axis=0)
template_ds = sorted_items[0][1]
origin_ipp = geom["origin_ipp"]
row_dir = geom["row_dir"]
col_dir = geom["col_dir"]
normal_dir = geom["normal_dir"]
native_row_spacing = geom["row_spacing"]
native_col_spacing = geom["col_spacing"]
native_z_spacing = geom["native_z_spacing"]
requested_slice_spacing = float(slice_spacing_val) if slice_spacing_val is not None else None
requested_slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else None
# Determine source acquisition plane from normal vector.
dominant_normal_axis = int(np.argmax(np.abs(normal_dir)))
source_plane = {0: "sagittal", 1: "coronal", 2: "axial"}.get(dominant_normal_axis, "unknown")
# Reorient the reconstructed volume into patient axes order [z, y, x] so
# generated plane names are anatomically correct regardless of source plane.
# NOTE: DICOM stores ImageOrientationPatient as:
# - first triplet: direction across columns (x-axis in pixel grid)
# - second triplet: direction across rows (y-axis in pixel grid)
# In _extract_recon_geometry names are historically row_dir/col_dir, so we
# map axis spacing/indexing explicitly to avoid directional mixups.
source_axis_dirs = [normal_dir, col_dir, row_dir]
source_axis_spacings = [float(native_z_spacing), float(native_row_spacing), float(native_col_spacing)]
source_axis_sizes = [int(volume.shape[0]), int(volume.shape[1]), int(volume.shape[2])]
source_for_patient_axis = {}
sign_for_patient_axis = {}
patient_axes_claimed = set()
for src_axis, vec in enumerate(source_axis_dirs):
patient_axis = int(np.argmax(np.abs(vec)))
if patient_axis in patient_axes_claimed:
raise ValueError("Could not infer unique source orientation axes for reconstruction")
patient_axes_claimed.add(patient_axis)
source_for_patient_axis[patient_axis] = src_axis
sign_for_patient_axis[patient_axis] = 1 if float(vec[patient_axis]) >= 0 else -1
src_x = source_for_patient_axis[0]
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]
vol_zyx = np.transpose(volume, (src_z, src_y, src_x))
if sign_for_patient_axis[2] < 0:
vol_zyx = np.flip(vol_zyx, axis=0)
if sign_for_patient_axis[1] < 0:
vol_zyx = np.flip(vol_zyx, axis=1)
if sign_for_patient_axis[0] < 0:
vol_zyx = np.flip(vol_zyx, axis=2)
def source_indices_from_patient_zyx(iz, iy, ix):
src_indices = [0.0, 0.0, 0.0]
z_index = float(iz)
y_index = float(iy)
x_index = float(ix)
if sign_for_patient_axis[2] < 0:
z_index = source_axis_sizes[src_z] - 1 - z_index
if sign_for_patient_axis[1] < 0:
y_index = source_axis_sizes[src_y] - 1 - y_index
if sign_for_patient_axis[0] < 0:
x_index = source_axis_sizes[src_x] - 1 - x_index
src_indices[src_z] = z_index
src_indices[src_y] = y_index
src_indices[src_x] = x_index
return src_indices
def position_from_patient_zyx(iz, iy, ix):
src_k, src_r, src_c = source_indices_from_patient_zyx(iz, iy, ix)
pos = (
np.asarray(origin_ipp, dtype=float)
+ (np.asarray(normal_dir, dtype=float) * (float(src_k) * float(native_z_spacing)))
+ (np.asarray(col_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
+ (np.asarray(row_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
)
return [float(pos[0]), float(pos[1]), float(pos[2])]
def _reduce_slab(slab, reduce_axis=0):
if recon_thickness_mode == "max":
out = np.max(slab, axis=reduce_axis)
elif recon_thickness_mode == "min":
out = np.min(slab, axis=reduce_axis)
else:
out = np.mean(slab, axis=reduce_axis)
if np.issubdtype(volume.dtype, np.integer):
info = np.iinfo(volume.dtype)
out = np.clip(np.rint(out), info.min, info.max).astype(volume.dtype)
else:
out = out.astype(volume.dtype, copy=False)
return out
def _slice_indices_for_axis(axis_length, axis_spacing, output_spacing, slab_thickness):
if output_spacing <= 0:
raise ValueError("Slice spacing must be greater than 0")
if slab_thickness <= 0:
raise ValueError("Slice thickness must be greater than 0")
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
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 = []
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 - center_mm)))
idxs = np.array([nearest], dtype=int)
else:
idxs = np.where(mask)[0].astype(int)
slabs.append(idxs)
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
return slabs, centers_idx
plane_builders = {}
for plane in recon_planes:
plane_norm = plane.lower()
if plane_norm == "axial":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_z)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[0]), float(spacing_z), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[slabs[idx], :, :]),
"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": _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":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_y)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[1]), float(spacing_y), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"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": _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)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[2]), float(spacing_x), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"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": _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:
raise ValueError("No valid reconstruction planes selected")
total_slices = sum(v["count"] for v in plane_builders.values())
cache.set(
progress_key,
{"current": 0, "total": total_slices, "message": "Reconstruction started"},
timeout=60 * 60,
)
processed = 0
created_series = []
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)")
for idx in range(int(cfg["count"])):
arr2d = cfg["slice_fn"](idx)
ds_new = copy.deepcopy(template_ds)
arr2d = np.asarray(arr2d, dtype=dicom_items[0][2].dtype)
ds_new.Rows = int(arr2d.shape[0])
ds_new.Columns = int(arr2d.shape[1])
ds_new.InstanceNumber = idx + 1
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}; "
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
),
image_type=["DERIVED", "SECONDARY", "MPR"],
)
ds_new.PixelData = arr2d.tobytes()
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
ipp = cfg["position_fn"](idx)
ds_new.ImagePositionPatient = ipp
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
try:
normal = np.asarray(cfg["plane_normal_out"], dtype=float)
ds_new.SliceLocation = float(np.dot(np.asarray(ipp, dtype=float), normal))
except Exception:
pass
out_io = io.BytesIO()
ds_new.save_as(out_io, write_like_original=False)
out_io.seek(0)
recon_image = SeriesImage(
series=recon_series,
position=idx + 1,
upload_filename=f"recon_{plane_norm}_{idx + 1}.dcm",
)
recon_image.image.save(
f"recon_{plane_norm}_{recon_series.pk}_{idx + 1}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
recon_image.save()
processed += 1
cache.set(
progress_key,
{
"current": processed,
"total": total_slices,
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
},
timeout=60 * 60,
)
created_series.append(
{
"id": recon_series.pk,
"url": recon_series.get_absolute_url(),
"description": recon_series.description or str(recon_series.pk),
}
)
logger.info(
"Reconstruction task complete for series {} with {} outputs",
series.pk,
len(created_series),
)
cache.set(
progress_key,
{
"current": total_slices,
"total": total_slices,
"message": "Reconstruction complete",
},
timeout=10 * 60,
)
return {
"series_id": series.pk,
"created_series": created_series,
"target_spacing": float(requested_slice_spacing) if requested_slice_spacing is not None else None,
"slab_thickness": float(requested_slab_thickness) if requested_slab_thickness is not None else None,
"mode": recon_thickness_mode,
"source_plane": source_plane,
}
@@ -18,6 +18,8 @@
<a href="{% url 'atlas:subspecialty_detail' item.pk %}">{{ item.name }}</a>
{% elif group_name == 'presentations' %}
<a href="{% url 'atlas:presentation_detail' item.pk %}">{{ item.name }}</a>
{% elif group_name == 'procedures' %}
<a href="{% url 'atlas:procedure_detail' item.pk %}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
@@ -27,7 +29,7 @@
{% endif %}
{% endfor %}
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists %}
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists and not results.procedures.exists %}
<p class="text-muted">No matches found.</p>
{% endif %}
</div>
@@ -1,7 +1,9 @@
<div id="add-case-form" class="p-2 border rounded">
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
{% include 'atlas/partials/case_search_widget.html' %}
{% load case_widgets %}
{% url 'atlas:add_case_to_collection' collection.pk as add_case_url %}
{% case_search_widget collection=collection cases=cases recent_cases=recent_cases selection_mode='multiple' action_url=add_case_url input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s' target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s' %}
<div class="mt-2">
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
+64
View File
@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block content %}
<div class="container my-4">
<h3>API Tokens</h3>
<div class="card mb-3">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="row g-2">
<div class="col-md-4">
<input name="name" class="form-control" placeholder="Name (e.g. CLI)" />
</div>
<div class="col-md-4">
<input name="scopes" class="form-control" placeholder="Scopes (space separated)" />
</div>
<div class="col-md-2">
<input name="expires_days" type="number" min="0" class="form-control" placeholder="Days" />
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" type="submit" name="action" value="create">Create</button>
</div>
</div>
</form>
</div>
</div>
{% if created_token %}
<div class="alert alert-info">Created token. Save it now — it won't be shown again.
<pre class="mt-2">{{ created_token }}</pre>
</div>
{% endif %}
<div class="card">
<div class="card-header">Your tokens</div>
<div class="card-body">
<table class="table table-sm">
<thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Expires</th><th>Revoked</th><th></th></tr></thead>
<tbody>
{% for t in tokens %}
<tr>
<td>{{ t.name }}</td>
<td>{{ t.scopes }}</td>
<td>{{ t.created }}</td>
<td>{{ t.expires }}</td>
<td>{{ t.revoked }}</td>
<td>
{% if not t.revoked %}
<form method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="revoke_id" value="{{ t.id }}" />
<button class="btn btn-sm btn-outline-danger" type="submit" name="action" value="revoke">Revoke</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-muted">No tokens</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+32 -7
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>
@@ -90,6 +93,7 @@
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:procedure_view' %}">Procedures</a></li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item-text small text-muted px-3">Create</li>
@@ -99,6 +103,7 @@
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:procedure_create' %}">New procedure</a></li>
{% endif %}
</ul>
</li>
@@ -117,15 +122,35 @@
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Uploads Menu</span>
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{% url 'atlas:user_uploads' %}"><i class="bi bi-hourglass-split me-1" aria-hidden="true"></i>Awaiting Import</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:new_uploads' %}"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Web Upload</a>
</li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'atlas:uploads_hash_search' %}" title="Search uploads and imported images by hash, series UID or study UID"><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Upload Search</a>
</li>
{% 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
@@ -67,12 +67,10 @@
<details>
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
<div>
<strong>Viewer State:</strong>
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
{% include 'atlas/partials/json_pretty.html' with json_value=ds.viewerstate title='Viewer State' %}
</div>
<div>
<strong>Annotations:</strong>
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
{% include 'atlas/partials/json_pretty.html' with json_value=ds.annotations title='Annotations' %}
</div>
</details>
</div>
+39 -11
View File
@@ -38,20 +38,48 @@
{% block js %}
<script>
document.addEventListener("DOMContentLoaded", function () {
// Delay mounting slightly to ensure viewer assets and DOM are ready
setTimeout(function() { window.mountDicomViewers(); }, 500);
// Helper: wait for mount function and mount the viewers safely
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);
}
}
// Initialize viewers deterministically
mountDicomViewersSafely();
{% if selected_displayset %}
setTimeout(function() {
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
// Highlight the selected displayset row
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
if (selectedRow) {
selectedRow.style.border = "2px solid #ffc107";
}
}, 1000);
(async function(){
try {
await mountDicomViewersSafely();
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
// Highlight the selected displayset row
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
if (selectedRow) {
selectedRow.style.border = "2px solid #ffc107";
}
} catch (e) { console.warn('failed to import selected displayset state', e); }
})();
{% if edit %}
var editButton = document.getElementById("displayset-{{ selected_displayset.pk }}-edit-btn");
+53 -46
View File
@@ -146,58 +146,58 @@
<!-- Differential Collapsible Panel -->
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
<h3 class="d-inline">Differential</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="differentialPanel">
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="casedifferential_formset" class="list_formset">
<ol>
{% for form in casedifferential_formset %}
<li class="no-error casedifferential_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
<h3 class="d-inline">Differential</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="differentialPanel">
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="casedifferential_formset" class="list_formset">
<ol>
{% for form in casedifferential_formset %}
<li class="no-error casedifferential_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ casedifferential_formset.management_form | crispy }}
</div>
</div>
{{ casedifferential_formset.management_form | crispy }}
</div>
</div>
</div>
<!-- Resource Collapsible Panel -->
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
<h3 class="d-inline">Resource</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="resourcePanel">
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
<li class="no-error caseresource_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
<h3 class="d-inline">Resource</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="resourcePanel">
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
<li class="no-error caseresource_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ caseresource_formset.management_form | crispy}}
</div>
</div>
{{ caseresource_formset.management_form | crispy}}
</div>
</div>
</div>
<br/>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
@@ -216,4 +216,11 @@
{{ caseresource_formset.empty_form | crispy}}
</li>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
if(window.markupInserter && typeof markupInserter.initForNames === 'function'){
markupInserter.initForNames(['description','history','discussion','report','notes']);
}
});
</script>
{% endblock %}
+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 %}
+104 -94
View File
@@ -1,8 +1,8 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h1>Categories</h1>
<p>The platform has numberous different ways to tag and categorise content. These help with organsing cases and series, given the ability to find cases by a particuarly property (such as a finding) or find similar cases.</p><p>These can be browsed below.</p>
<h1>Categories</h1>
<p>The platform has numberous different ways to tag and categorise content. These help with organsing cases and series, given the ability to find cases by a particuarly property (such as a finding) or find similar cases.</p><p>These can be browsed below.</p>
<ul>
<li><a href="{% url 'atlas:condition_view' %}">Conditions</a></li>
<li><a href="{% url 'atlas:finding_view' %}">Findings</a></li>
@@ -10,113 +10,123 @@
<li><a href="{% url 'atlas:subspecialty_view' %}">Subspecialties</li></a>
<li><a href="{% url 'atlas:presentation_view' %}">Presentations</li></a>
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
<li><a href="{% url 'atlas:procedure_view' %}">Procedures</a></li>
</ul>
<div class="mt-4 mb-3">
<div class="mt-4 mb-3">
{# HTMX loading indicator styles: hide by default, show when HTMX adds the .htmx-request class #}
<style>
<style>
#category-search-loading { display: none; }
#category-search-loading.htmx-request { display: inline-block; }
</style>
</style>
<p>Alternatively you can use the search function below</p>
<p>Alternatively you can use the search function below</p>
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
<div class="d-flex align-items-center">
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-trigger="keyup delay:500ms"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
<div class="d-flex align-items-center">
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-trigger="keyup delay:500ms"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</div>
<button class="btn btn-primary ms-2" type="submit">Search</button>
</div>
{# Collapsed advanced filters block - defaults to collapsed #}
<div class="mt-2">
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
Advanced filters ▾
</button>
<div class="collapse mt-2" id="category-filters">
<div class="card card-body">
<div class="d-flex flex-wrap gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-conditions">Conditions</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-findings">Findings</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-structures">Structures</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-presentations">Presentations</label>
</div>
</div>
</div>
</div>
</div>
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</div>
<button class="btn btn-primary ms-2" type="submit">Search</button>
</div>
{# Collapsed advanced filters block - defaults to collapsed #}
<div class="mt-2">
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
Advanced filters ▾
</button>
<div class="collapse mt-2" id="category-filters">
<div class="card card-body">
<div class="d-flex flex-wrap gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-conditions">Conditions</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-findings">Findings</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-structures">Structures</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-presentations">Presentations</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-procedures" name="types" value="procedures" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-procedures">Procedures</label>
</div>
</div>
</div>
</div>
</div>
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</div>
{% comment %} <button class="btn btn-primary ms-2" type="submit">Search</button> {% endcomment %}
</form>
</div>
</form>
</div>
<div id="category-search-results">
{% if results is not None %}
{% include 'atlas/_categories_search_results.html' %}
{% endif %}
</div>
{% if request.user.is_staff %}
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
</p>
<div id="category-search-results">
{% if results is not None %}
{% include 'atlas/_categories_search_results.html' %}
{% endif %}
</div>
{% if request.user.is_staff %}
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
</p>
{% endif %}
{% 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 %}
@@ -6,8 +6,7 @@
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_displaysetup" %}
<h2>Setup default display for case</h2>
<div id="root" class="dicom-viewer-root" data-images="{{casedetail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
<div id="root" class="dicom-viewer-root viewer-frame-setup" data-images="{{casedetail.case.get_series_images_nested}}"
data-auto-cache-stack="true"
{% if casedetail.default_viewerstate %}
data-viewerstate={{ casedetail.default_viewerstate }}
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -85,6 +86,59 @@
>Sync prerequisite users</button>
</div>
{% endif %}
<div class="card border-secondary w-100 mt-3">
<div class="card-body py-2 px-3 text-start text-md-end">
{% if collection.case_query_messaging_enabled %}
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} pending acknowledgement
</div>
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Inbox</a>
</div>
{% if user_message_rows %}
<div class="mt-2 small text-muted">Users</div>
<div class="d-flex flex-column gap-1">
{% for row in user_message_rows|slice:":5" %}
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_user_messages' collection.pk row.user_user.pk %}">
{{ row.get_user_name }}
{% if row.outstanding_message_count %}
({{ row.outstanding_message_count }} outstanding)
{% else %}
({{ row.total_review_message_count }} history)
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if cid_message_rows %}
<div class="mt-2 small text-muted">CID users</div>
<div class="d-flex flex-column gap-1">
{% for row in cid_message_rows|slice:":5" %}
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_cid_messages' collection.pk row.cid_user.cid %}">
{{ row.cid_user }}
{% if row.outstanding_message_count %}
({{ row.outstanding_message_count }} outstanding)
{% else %}
({{ row.total_review_message_count }} history)
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="small text-muted">Case query/messaging</div>
<div class="fw-semibold text-muted">Disabled for this collection</div>
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
@@ -185,4 +239,97 @@
} catch (e) { console.warn('collection warning persistence error', e); }
});
</script>
<script>
// Listen for case selection events from a case-search-widget included in this page/modal
document.body.addEventListener('case:selected', async function(e) {
try {
const detail = e.detail || {};
const casePk = detail.casePk;
const caseTitle = detail.caseTitle || ('#' + casePk);
if (!casePk) return;
if (!window.confirm(`Add case ${caseTitle} to collection {{ collection.name }}?`)) return;
const url = "{% url 'atlas:add_case_to_collection' collection.pk %}";
const form = new URLSearchParams();
form.append('case', casePk);
const csrfToken = '{{ csrf_token }}';
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: form.toString(),
credentials: 'same-origin'
});
const html = await resp.text();
// Append the returned case item into the list
const list = document.getElementById('full-question-list');
if (list) list.insertAdjacentHTML('beforeend', html);
} catch (err) {
console.error('Add case to collection failed', err);
alert('Failed to add case to collection — check console.');
}
});
</script>
<script>
// Global handler for markup links inserted by render_markup
document.body.addEventListener('click', function(ev){
var el = ev.target.closest && ev.target.closest('.markup-link');
if(!el) return;
ev.preventDefault();
try{
var typ = el.getAttribute('data-type');
var args = el.getAttribute('data-args');
var href = el.getAttribute('href') || '#';
if(typ === 'case'){
// open case — give option to open in new tab
if(window.confirm('Open case in new tab?')){
window.open(href, '_blank');
} else {
window.location = href;
}
return;
}
// For finding/displayset open modal with basic options — endpoints can be wired later
var modal = document.getElementById('case-field-modal');
var content = modal && modal.querySelector('.modal-content');
var parsedArgs = [];
try{ parsedArgs = JSON.parse(args || '[]'); }catch(e){ parsedArgs = []; }
if(typ === 'finding'){
var findingId = parsedArgs[0] || '';
var html = '<div class="modal-header"><h5 class="modal-title">Finding '+findingId+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
html += '<div class="modal-body">';
html += '<p>This will open the finding viewer for ID '+findingId+'.</p>';
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/finding/'+findingId+'" target="_blank">Open in new tab</a> ';
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
html += '</div>';
if(content) content.innerHTML = html;
var bs = new bootstrap.Modal(modal);
bs.show();
return;
}
if(typ === 'displayset'){
var caseId = parsedArgs[0] || '';
var setName = parsedArgs[1] || '';
var html = '<div class="modal-header"><h5 class="modal-title">Display set '+setName+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
html += '<div class="modal-body">';
html += '<p>Display set <strong>'+setName+'</strong> from case #'+caseId+'.</p>';
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/collection/'+caseId+'" target="_blank">Open case in new tab</a> ';
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
html += '</div>';
if(content) content.innerHTML = html;
var bs2 = new bootstrap.Modal(modal);
bs2.show();
return;
}
}catch(err){ console.error('markup link handler error', err); }
}, false);
</script>
{% endblock %}
@@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">
{% if cid is not None %}
CID: <strong>{{ cid }}</strong>
{% else %}
Feedback hub
{% endif %}
</div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Feedback Summary</div>
<div class="fw-semibold">{{ total_outstanding_feedback }} outstanding feedback item{{ total_outstanding_feedback|pluralize }}</div>
<div class="small text-muted mt-1">Across {{ outstanding_case_count }} case{{ outstanding_case_count|pluralize }}</div>
<div class="mt-2">
<a class="btn btn-sm btn-outline-secondary"
href="{% if cid is not None %}{% url 'atlas:collection_take_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_take_overview_user' pk=collection.id %}{% endif %}">
Back to Overview
</a>
</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3">Outstanding Feedback</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
<div class="card-header d-flex justify-content-between align-items-center gap-2">
<div>
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_feedback_count }} awaiting acknowledgement</span>
</div>
<div class="card-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success">No outstanding feedback needs acknowledgement.</div>
{% endif %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3">Previous Conversations</h3>
<div class="accordion" id="feedbackHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item" id="case-feedback-{{ row.casedetail.case.id }}">
<h2 class="accordion-header" id="heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} message{{ row.stats.message_count|pluralize }}</span>
</button>
</h2>
<div id="collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ row.casedetail.case.id }}" data-bs-parent="#feedbackHistoryAccordion">
<div class="accordion-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open to load conversation history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary">
<div class="card-body text-muted small">No case conversations have been started yet.</div>
</div>
{% endif %}
{% endblock %}
+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 %}
+158 -35
View File
@@ -1,54 +1,177 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>{{collection.name}}</h2>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Collection attempt history and message activity</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.pk %}">Collection Detail</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Messages Inbox</a>
{% endif %}
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Cases</div>
<div class="h4 mb-0">{{ total_cases }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Attempts</div>
<div class="h4 mb-0">{{ total_attempt_count }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Completed Attempts</div>
<div class="h4 mb-0">{{ completed_attempt_count }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100 {% if total_outstanding_messages %}border-danger-subtle{% endif %}">
<div class="card-body py-3">
{% if collection.case_query_messaging_enabled %}
<div class="small text-muted">Outstanding Messages</div>
<div class="h4 mb-0 {% if total_outstanding_messages %}text-danger{% endif %}">{{ total_outstanding_messages }}</div>
{% else %}
<div class="small text-muted">Case Messaging</div>
<div class="h6 mb-0 text-muted">Disabled</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if userexams %}
<h3>Users</h3>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h5 mb-0">Users</h3>
<div class="small text-muted">{{ userexams|length }} user attempt{{ userexams|length|pluralize }}</div>
</div>
<ul>
<div class="d-flex flex-column gap-3 mb-4">
{% for userexam in userexams %}
<li id="user-history-{{ userexam.user_user.pk }}">
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
Completed: {{userexam.completed}}<br/>
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
<button
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
hx-target="#user-history-{{ userexam.user_user.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</li>
<div id="user-history-{{ userexam.user_user.pk }}" class="card {% if userexam.outstanding_feedback_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">
<a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{ userexam.get_user_name }}</a>
{% if userexam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% else %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">In progress</span>
{% endif %}
</div>
<div class="small text-muted mt-1">Started: {{ userexam.start_time|default:"-" }}</div>
<div class="small text-muted">Ended: {{ userexam.end_time|default:"-" }}</div>
<div class="mt-2 d-flex flex-wrap gap-2">
{% if collection.case_query_messaging_enabled %}
{% if userexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ userexam.outstanding_feedback_count }} outstanding message{{ userexam.outstanding_feedback_count|pluralize }}</span>
{% endif %}
{% if userexam.total_review_message_count %}
<span class="badge bg-secondary">{{ userexam.total_review_message_count }} conversation message{{ userexam.total_review_message_count|pluralize }}</span>
{% else %}
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">Open Attempt</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_user_messages' collection.pk userexam.user_user.pk %}">Messages</a>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
hx-target="#user-history-{{ userexam.user_user.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</ul>
</div>
{% endif %}
{% if cidexams %}
<h3>CID Users</h3>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h5 mb-0">CID Users</h3>
<div class="small text-muted">{{ cidexams|length }} CID attempt{{ cidexams|length|pluralize }}</div>
</div>
<ul>
<div class="d-flex flex-column gap-3">
{% for cidexam in cidexams %}
<li id="cid-history-{{ cidexam.cid_user.pk }}">
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{cidexam.cid_user}}</a><b><br/>
Completed: {{cidexam.completed}}<br/>
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this CID user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</li>
<div id="cid-history-{{ cidexam.cid_user.cid }}" class="card {% if cidexam.outstanding_feedback_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">
<a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{ cidexam.cid_user }}</a>
{% if cidexam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% else %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">In progress</span>
{% endif %}
</div>
<div class="small text-muted mt-1">Started: {{ cidexam.start_time|default:"-" }}</div>
<div class="small text-muted">Ended: {{ cidexam.end_time|default:"-" }}</div>
<div class="mt-2 d-flex flex-wrap gap-2">
{% if collection.case_query_messaging_enabled %}
{% if cidexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ cidexam.outstanding_feedback_count }} outstanding message{{ cidexam.outstanding_feedback_count|pluralize }}</span>
{% endif %}
{% if cidexam.total_review_message_count %}
<span class="badge bg-secondary">{{ cidexam.total_review_message_count }} conversation message{{ cidexam.total_review_message_count|pluralize }}</span>
{% else %}
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">Open Attempt</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_cid_messages' collection.pk cidexam.cid_user.cid %}">Messages</a>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this CID user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</ul>
</div>
{% endif %}
{% if not userexams and not cidexams %}
<div class="card border-secondary">
<div class="card-body text-muted small">No attempts have been recorded for this collection yet.</div>
</div>
{% endif %}
{% endblock %}
@@ -1,101 +1,165 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>Collection: {{collection.name}}</h2>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Learner: <strong>{{ user }}</strong></div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Attempt Progress</div>
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
<div class="progress progress-compact mt-2">
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
</div>
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
</div>
</div>
</div>
<h3>User: {{user}}</h3>
<ul>
{% for casedetail, user_answer in user_answers %}
<li class="case">
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
Question started: {{user_answer.started_at}} - Answer submitted: {{user_answer.submitted_at}}
{% if request.user.is_superuser and user_answer %}
&nbsp;(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
{% endif %}
<br/>
<br/>
{% if not user_answer %}
<span class="case-not-answered">Case not answered.</span>
{% else %}
<div class="answer-block">
User answer:
<div class="user-answer">
{% if user_answer.answer %}
{{user_answer.answer}}
{% else %}
{{user_answer.json_answer}}
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
{% if not user_answer %}
Not answered
{% else %}
<div class="{% if answer_is_correct %}
correct
{% else %}
incorrect
{% endif %}
{% if automark %}
automark
{% endif %}
">
<h5>{{value.title}}</h5>
{{value.description}}
<div
>
Answer : {{user_answer}}
{% if not answer_is_correct %}
<br/>Correct answer: {{correct_answer}}
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
<div class="list-group mb-4">
{% for row in history_rows %}
<div id="case-history-{{ row.casedetail.case.id }}" class="list-group-item {% if not row.user_answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% if row.user_answer and row.user_answer.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif row.user_answer %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
{% endif %}
{% if row.self_reviews %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">{{ row.self_reviews|length }} self review{{ row.self_reviews|length|pluralize }}</span>
{% endif %}
</div>
<div class="small text-muted">
Started: {{ row.user_answer.started_at|default:'-' }}
{% if row.user_answer %}
· Submitted: {{ row.user_answer.submitted_at|default:'-' }}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary"
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 %}
{% if row.user_answer.user_id %}
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'admin:atlas_userreportanswer_change' row.user_answer.pk %}"
target="_blank">
Edit in admin
</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'admin:atlas_cidreportanswer_change' row.user_answer.pk %}"
target="_blank">
Edit in admin
</a>
{% endif %}
{% endif %}
{% if row.self_reviews %}
{% for review in row.self_reviews %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
View Self Review {{ forloop.counter }}
</a>
{% endfor %}
{% endif %}
</div>
</div>
{% if not row.user_answer %}
<div class="small text-muted">This case has not been answered yet.</div>
{% else %}
<details class="mb-3" open>
<summary class="small fw-semibold">Answer / Report</summary>
<div class="small mt-2 p-3 rounded bg-body-tertiary border">
{% if row.user_answer.answer %}
{{ row.user_answer.answer|linebreaksbr }}
{% else %}
<div class="mb-2">{{ row.user_answer.json_answer }}</div>
{% for value, single_answer, correct_answer, answer_is_correct, automark in row.user_answer.get_correct_json_answers %}
<div class="mb-2 p-2 border rounded {% if answer_is_correct %}border-success-subtle{% else %}border-danger-subtle{% endif %}">
<div class="fw-semibold">
{{ value.title }}
{% if answer_is_correct %}
<span class="text-success"></span>
{% elif automark %}
<span class="text-danger"></span>
{% else %}
<span class="text-warning"></span>
{% endif %}
</div>
<div class="small text-muted">{{ value.description }}</div>
<div class="small mt-1">Answer: {{ single_answer|default:'Not answered' }}</div>
{% if not answer_is_correct and correct_answer %}
<div class="small text-muted">Correct answer: {{ correct_answer }}</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</details>
{% endif %}
</li>
{% if row.self_reviews %}
<details class="mb-3">
<summary class="small fw-semibold">Self Reviews</summary>
<div class="d-flex flex-column gap-2 mt-2">
{% for review in row.self_reviews %}
<div class="border rounded p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<div class="small text-muted">Updated: {{ review.review_update_date|date:"Y-m-d H:i" }}</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
View
</a>
<a class="btn btn-sm btn-outline-warning"
href="{% url 'atlas:self_review_update' cid_user_exam.id row.casedetail.case.id review.id %}">
Edit
</a>
</div>
</div>
<div class="small mb-1"><strong>Findings:</strong> {{ review.findings|default:'-' }}/5</div>
<div class="small mb-2"><strong>Interpretation:</strong> {{ review.interpretation|default:'-' }}/5</div>
<div class="small text-body-secondary">{{ review.comments|default:'No comments added.'|linebreaksbr }}</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if collection.case_query_messaging_enabled %}
<details class="mt-3" {% if row.case_review_stats.has_outstanding_any %}open{% endif %}>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case conversation</span>
{% if row.case_review_stats.has_outstanding_any %}
<span class="badge bg-danger">{{ row.case_review_stats.outstanding_total_count }} unacknowledged message{{ row.case_review_stats.outstanding_total_count|pluralize }}</span>
{% elif row.case_review_stats.has_messages %}
<span class="badge bg-secondary">{{ row.case_review_stats.message_count }} message{{ row.case_review_stats.message_count|pluralize }}</span>
{% else %}
<span class="text-muted">No messages yet</span>
{% endif %}
</summary>
<div class="mt-2"
hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Loading case conversation...</div>
</div>
</details>
{% endif %}
</div>
{% endfor %}
</ul>
</div>
{% endblock %}
{% block css %}
{{ block.super }}
<style>
.correct {
}
.correct h5::after {
content: '✓';
color: green;
}
.incorrect h5::after {
content: '✗';
color: orange;
}
.incorrect.automark h5::after {
content: '✗';
color: red;
}
.answer-block>div {
padding-bottom: 10px;
}
.case:has(.case-not-answered) {
opacity: 0.5;
}
</style>
{% endblock %}
@@ -1,28 +1,146 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Collection: {{exam}}</h2>
<style>
.take-start-hero {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, rgba(13, 110, 253, 0.22), rgba(25, 135, 84, 0.2));
border-radius: .75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
{% comment %} <ul>
{% for case in collection.cases.all %}
<li><a href="{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}">Case {{forloop.counter}}</a></li>
{% endfor %}
</ul> {% endcomment %}
.take-start-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border-radius: .75rem;
padding: 1rem;
}
User: {{request.user}}<br/>
.take-start-meta {
display: grid;
grid-template-columns: 9rem 1fr;
gap: .35rem .75rem;
}
{% if cid_exam %}
Started: {{cid_exam.start_time}} <br/>
{% if cid_exam.completed %}
Ended: {{cid_exam.end_time}} <br/>
{% endif %}
.take-start-meta dt {
font-weight: 600;
color: var(--bs-secondary-color);
margin: 0;
}
.take-start-meta dd {
margin: 0;
}
</style>
<section class="take-start-hero">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h2 class="mb-0">Review: {{ collection.name }}</h2>
<span class="badge bg-light text-dark">Review Collection</span>
</div>
<p class="mb-0 mt-2 text-body-secondary">Open the collection and browse completed cases.</p>
</section>
{% if collection.start_screen_content %}
<section class="take-start-card mb-3">
{{ collection.start_screen_content|safe }}
</section>
{% endif %}
{% if start_screen_resources %}
<section class="take-start-card mb-3">
<h6 class="mb-2">Resources</h6>
<div class="d-grid gap-2">
{% for resource in start_screen_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if request.user.is_authenticated and valid_user %}
<section class="take-start-card">
<dl class="take-start-meta mb-3">
<dt>User</dt>
<dd>{{ request.user }}</dd>
{% if cid_exam %}
<dt>Started</dt>
<dd>{{ cid_exam.start_time }}</dd>
{% if cid_exam.completed %}
<dt>Ended</dt>
<dd>{{ cid_exam.end_time }}</dd>
{% endif %}
{% endif %}
</dl>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary"
href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
Browse
</a>
{% if cid_exam.completed %}
<a class="btn btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}">Overview</a>
{% endif %}
</div>
</section>
{% 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">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 %}
{% endpartialdef %}
{% else %}
<section class="take-start-card">
<p class="mb-0">
Please sign in to access this review collection.
<a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">Log in here</a>.
</p>
</section>
{% endif %}
<a href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}
<button>Review</button>
{% else %}
<button>Start</button>
{% endif %}
</a>
{% endblock %}
@@ -1,78 +1,221 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
{% if cid is not None %}
CID: {{cid}}
{% endif %}
<h2>Collection: {{collection.name}}
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="stamp-white">REVIEW</span>
{% endif %}
</h2>
{% if not collection.review_only %}
{{answer_count}} out of {{collection_length}} cases answered. Click to go to case.
{% endif %}
<div class="sba-finish-list">
<ul>
{% for question, answer, self_review in question_answer_tuples %}
<li> <span class="{% if not collection.review_only and not answer %}unanswered{% endif %}">
<a 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 %}
">
Case: {{forloop.counter}}
{% if answer.completed or cid_user_exam.completed %}
<span title="You have completed this case"><i class="bi bi-check-square-fill"></i>
</span>
{% endif %}
{% if self_review %} <span class="self-review-complete" title="You have reviewed this case."> <i class="bi-card-checklist"></i></span>{% endif %}
</a>
<br/>
{% if not collection.review_only %}
{% if not answer %}
No answer
{% else %}
<details><summary>Answered</summary>
{% if aswer.answer %}
{{answer.answer}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">
{{ collection.name }}
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="badge bg-light text-dark ms-2">Review</span>
{% endif %}
{% if collection.case_query_messaging_enabled and total_outstanding_feedback %}
<span class="badge bg-danger ms-2">{{ total_outstanding_feedback }} outstanding feedback</span>
{% endif %}
</h2>
<div class="text-muted small">
{% if cid is not None %}
CID: <strong>{{ cid }}</strong>
{% else %}
<h5>Overview</h5>
{% endif %}
</div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Progress</div>
{% if not collection.review_only %}
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
<div class="progress progress-compact mt-2">
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
</div>
{% else %}
<div class="fw-semibold">Review-only collection</div>
{% endif %}
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
{% if cid_user_exam.completed %}
<div class="mt-1 small">End time: {{ cid_user_exam.end_time|default:"-" }}</div>
{% endif %}
{% if collection.case_query_messaging_enabled and collection.in_review_mode or collection.case_query_messaging_enabled and cid_user_exam.completed %}
<div class="mt-2">
<a class="btn btn-sm {% if total_outstanding_feedback %}btn-danger{% else %}btn-outline-secondary{% endif %}"
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}">
{% if total_outstanding_feedback %}
Review Feedback Hub
{% else %}
{{answer.json_answer}}
Open Feedback Hub
{% endif %}
</details>
{% endif %}
{% endif %}
</span>
</li>
{% endfor %}
</ul>
</a>
</div>
{% endif %}
</div>
</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 %}
Start time: {{cid_user_exam.start_time}}
<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 %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}{% if collection.show_title_post and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}: {{ question.case.title|default:question.case.pk|truncatechars:70 }}{% endif %}
{% if answer and answer.completed or cid_user_exam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif answer %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
{% endif %}
{% if self_review %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
{% endif %}
{% if timing_state.effective_time_limit is not None and timing_state.case_view_locked %}
<span class="badge bg-dark ms-1">Time expired</span>
{% endif %}
{% if collection.case_query_messaging_enabled %}
{% if review_stats.has_outstanding_feedback %}
<span class="badge bg-danger ms-1">{{ review_stats.outstanding_feedback_count }} feedback item{{ review_stats.outstanding_feedback_count|pluralize }} to review</span>
{% elif review_stats.has_messages %}
<span class="badge bg-secondary ms-1">{{ review_stats.message_count }} conversation message{{ review_stats.message_count|pluralize }}</span>
{% endif %}
{% endif %}
</div>
{% if collection.show_title_post and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}
<div class="small text-muted">
{{ question.case }}
</div>
{% 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
</a>
{% if collection.in_review_mode or cid_user_exam.completed %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:add_self_review' cid_user_exam.id question.case.id %}">
{% if self_review %}Add New Self Review{% else %}Add Self Review{% endif %}
</a>
{% if collection.case_query_messaging_enabled and review_stats.has_messages %}
<a class="btn btn-sm {% if review_stats.has_outstanding_feedback %}btn-danger{% else %}btn-outline-dark{% endif %}"
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}#case-feedback-{{ question.case.id }}">
View Conversation
</a>
{% endif %}
{% endif %}
</div>
</div>
{% if self_review %}
<div class="mt-2 pt-2 border-top">
<div class="small fw-semibold mb-1">Your self reviews</div>
<div class="d-flex flex-wrap gap-2">
{% for review in self_review %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id question.case.id review.id %}">
View review {{ forloop.counter }}
</a>
<a class="btn btn-sm btn-outline-warning"
href="{% url 'atlas:self_review_update' cid_user_exam.id question.case.id review.id %}">
Edit review {{ forloop.counter }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if not collection.review_only %}
{% if answer %}
<details class="mt-2">
<summary class="small text-muted">Preview saved answer/report</summary>
<div class="small mt-2 p-2 bg-body-tertiary rounded">
{% if answer.answer %}
{{ answer.answer|linebreaksbr }}
{% else %}
{{ answer.json_answer }}
{% endif %}
</div>
</details>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% if not collection.review_only and not cid_user_exam.completed %}
<p>Completed: <span id="completed-state">{{cid_user_exam.completed}}</span></p>
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
>
<button type="submit" name="finish" value="finish">Finish</button>
</form>
<div class="card border-warning-subtle">
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div class="fw-semibold">Ready to submit this session?</div>
<div class="small text-muted">You can continue to review and edit your answers until you finish the session.</div>
<div id="completed-state" class="mt-2 small text-danger"></div>
</div>
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
class="m-0">
<button type="submit" name="finish" value="finish" class="btn btn-warning">Finish Session</button>
</form>
</div>
</div>
{% endif %}
{% if collection.end_overview_content %}
<div class="card mt-3">
<div class="card-body">
{{ collection.end_overview_content|safe }}
</div>
</div>
{% endif %}
{% if end_overview_resources %}
<div class="card mt-3">
<div class="card-body">
<h6 class="mb-2">Further Resources</h6>
<div class="d-grid gap-2">
{% for resource in end_overview_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Sharing panel only for registered-user attempts (not CID candidates) #}
{% if collection.show_results_sharing_information and cid_user_exam.user_user_id %}
<div hx-get="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-muted small mt-3">Loading sharing settings…</div>
</div>
{% endif %}
+121 -44
View File
@@ -1,69 +1,146 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Start: {{collection.name}}{% if cid_exam.completed %} <span class="stamp-white">REVIEW</span>{% endif %}</h2>
<style>
.take-start-hero {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, rgba(13, 110, 253, 0.22), rgba(25, 135, 84, 0.2));
border-radius: .75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.take-start-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border-radius: .75rem;
padding: 1rem;
}
.take-start-meta {
display: grid;
grid-template-columns: 9rem 1fr;
gap: .35rem .75rem;
}
.take-start-meta dt {
font-weight: 600;
color: var(--bs-secondary-color);
margin: 0;
}
.take-start-meta dd {
margin: 0;
}
</style>
<section class="take-start-hero">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h2 class="mb-0">Start: {{ collection.name }}</h2>
{% if cid_exam.completed %}
<span class="badge bg-light text-dark">Review Mode</span>
{% endif %}
</div>
<p class="mb-0 mt-2 text-body-secondary">Open the collection and begin or resume your session.</p>
</section>
{% if collection.start_screen_content %}
<section class="take-start-card mb-3">
{{ collection.start_screen_content|safe }}
</section>
{% endif %}
{% if start_screen_resources %}
<section class="take-start-card mb-3">
<h6 class="mb-2">Resources</h6>
<div class="d-grid gap-2">
{% for resource in start_screen_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if request.user.is_authenticated and valid_user %}
User: {{request.user}}<br/>
<section class="take-start-card">
<dl class="take-start-meta mb-3">
<dt>User</dt>
<dd>{{ request.user }}</dd>
{% if cid_exam %}
<dt>Started</dt>
<dd>{{ cid_exam.start_time }}</dd>
{% if cid_exam %}
Started: {{cid_exam.start_time}} <br/>
{% if cid_exam.completed %}
<dt>Ended</dt>
<dd>{{ cid_exam.end_time }}</dd>
{% endif %}
{% endif %}
</dl>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary"
href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}Review{% else %}Start{% endif %}
</a>
{% if cid_exam.completed %}
Ended: {{cid_exam.end_time}} <br/>
{% endif %}
{% endif %}
{% if cid_exam.completed %}
<a class="btn btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}">Overview</a>
{% endif %}
</div>
</section>
<a href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}
<button>Review</button>
{% else %}
<button>Start</button>
{% endif %}
</a>
{% if cid_exam.completed %}
<a href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}"><button>Overview</button></a>
{% endif %}
{% include "atlas/collection_review_start.html#sharing-info" %}
{% else %}
Enter your CID and passcode in the below boxes. (Registered user should login <a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">here</a>)<br />
<p><input id="cid-box" type="text" value="Candidate ID"></p>
<p><input id="passcode-box" type="text" value="Passcode"></p>
<section class="take-start-card">
<p class="mb-3">
Enter your CID and passcode below.
Registered users can
<a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">log in here</a>.
</p>
<button>Start</button>
<div class="row g-2 align-items-end">
<div class="col-12 col-md-5">
<label class="form-label" for="cid-box">Candidate ID</label>
<input id="cid-box" class="form-control" type="text" value="Candidate ID">
</div>
<div class="col-12 col-md-5">
<label class="form-label" for="passcode-box">Passcode</label>
<input id="passcode-box" class="form-control" type="text" value="Passcode">
</div>
<div class="col-12 col-md-2 d-grid">
<button id="start-candidate-btn" class="btn btn-primary" type="button">Start</button>
</div>
</div>
</section>
<script type="text/javascript">
$(document).ready(function () {
window.location.search.substr(1).split("&").forEach((item) =>
{
s = item.split("=");
if (s[0] == "cid") {
$("#cid-box").val(s[1]);
}
if (s[0] == "passcode") {
$("#passcode-box").val(s[1]);
}
});
$("#cid-box, #passcode-box").keypress(function(e) {
// Enter pressed?
console.log(e)
if(e.which == 10 || e.which == 13) {
$("button").click();
window.location.search.substr(1).split("&").forEach((item) => {
const s = item.split("=");
if (s[0] === "cid") {
$("#cid-box").val(s[1]);
}
if (s[0] === "passcode") {
$("#passcode-box").val(s[1]);
}
});
$("button").click(() => {
let cid = $("#cid-box").val();
let passcode = $("#passcode-box").val();
$("#cid-box, #passcode-box").keypress(function (e) {
if (e.which === 10 || e.which === 13) {
$("#start-candidate-btn").click();
}
});
$("#start-candidate-btn").click(() => {
const cid = $("#cid-box").val();
const passcode = $("#passcode-box").val();
if (Number.isInteger(parseInt(cid))) {
window.location.replace("{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=0 cid='0000000' passcode='ZZZZZZ' %}".replace("0000000", cid).replace("ZZZZZZ", passcode));
} else {
alert("Please enter a valid Candidate ID (CID).")
alert("Please enter a valid Candidate ID (CID).");
}
});
});
@@ -0,0 +1,121 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Message hub for <strong>{{ target_label }}</strong></div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} awaiting acknowledgement
</div>
<div class="mt-2 d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Back to Collection History</a>
<a class="btn btn-sm btn-outline-secondary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.pk target_user.pk %}{% else %}{% url 'atlas:collection_history_ciduser' collection.pk target_cid %}{% endif %}">
Open Full Attempt Page
</a>
</div>
<div class="mt-2">
{% if show_all %}
<a class="btn btn-sm btn-outline-primary" href="?">Show outstanding only</a>
{% else %}
<a class="btn btn-sm btn-primary" href="?show=all">Load all conversation history</a>
{% endif %}
</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3">Outstanding Messages</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:90 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_total_count }} outstanding</span>
</div>
<div class="card-body pt-0">
<div class="d-flex flex-wrap gap-2 mt-2 mb-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.id %}">
Collection
</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_case_view' pk=collection.id case_number=row.casedetail.sort_order %}">
Case In Collection
</a>
<a class="btn btn-sm btn-outline-primary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.id target_user.id %}{% else %}{% url 'atlas:collection_history_ciduser' collection.id target_cid %}{% endif %}#case-history-{{ row.casedetail.case.id }}">
User Answer In Context
</a>
</div>
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success">No outstanding messages for this user.</div>
{% endif %}
{% if show_all %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3">Conversation History</h3>
<div class="accordion" id="userMessageHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item" id="history-case-{{ row.casedetail.case.id }}">
<h2 class="accordion-header" id="history-heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#history-collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="history-collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:90 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} messages</span>
</button>
</h2>
<div id="history-collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="history-heading-{{ row.casedetail.case.id }}" data-bs-parent="#userMessageHistoryAccordion">
<div class="accordion-body pt-0">
<div class="d-flex flex-wrap gap-2 mt-2 mb-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.id %}">
Collection
</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_case_view' pk=collection.id case_number=row.casedetail.sort_order %}">
Case In Collection
</a>
<a class="btn btn-sm btn-outline-primary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.id target_user.id %}{% else %}{% url 'atlas:collection_history_ciduser' collection.id target_cid %}{% endif %}#case-history-{{ row.casedetail.case.id }}">
User Answer In Context
</a>
</div>
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open section to load history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary">
<div class="card-body text-muted small">No conversation history for this user yet.</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
+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 %}
+397 -32
View File
@@ -1,38 +1,403 @@
<!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html -->
{% extends "base.html" %}
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h1>Linked Cases Overview</h1>
<h2>Current Case: {{ current_case.title }}</h2>
<div class="container py-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="h2 mb-1">Linked Cases Overview</h1>
<p class="text-muted mb-0">Review the full case chain around this study, with quick access to related cases and their linked series.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-outline-secondary" href="{{ current_case.get_absolute_url }}">Open current case</a>
{% if direct_previous %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_previous.pk %}">Previous in chain</a>
{% endif %}
{% 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>
<h3>Previous Cases</h3>
{% if previous_cases %}
<ul>
{% for case in previous_cases %}
<li>
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
{% 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>
</li>
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
<p>No previous cases.</p>
{% endif %}
<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 %}
<h3>Next Cases</h3>
{% if next_cases %}
<ul>
{% for case in next_cases %}
<li>
<a href="{{ next_case.get_absolute_url }}">{{ case.title }}</a>
</li>
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
<p>No next cases.</p>
{% 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">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<span class="badge bg-primary">Current case</span>
<span class="badge bg-dark-subtle text-dark-emphasis">{{ current_index }} of {{ chain_total }}</span>
{% if previous_cases %}
<span class="badge text-bg-light border">{{ previous_cases|length }} previous</span>
{% endif %}
{% if next_cases %}
<span class="badge text-bg-light border">{{ next_cases|length }} follow-up</span>
{% endif %}
</div>
<h2 class="h4 mb-1">{{ current_case.title|default:"Untitled case" }}</h2>
<div class="small text-muted mb-3">Case {{ current_case.pk }}</div>
{% if current_case.description %}
<p class="mb-3">{{ current_case.description|striptags|truncatechars:280 }}</p>
{% elif current_case.history %}
<p class="mb-3">{{ current_case.history|striptags|truncatechars:280 }}</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 small">
{% if current_case.open_access %}
<span class="badge bg-success">Open access</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
{% if current_case.verified %}
<span class="badge bg-info text-dark">Verified</span>
{% endif %}
{% if current_case.archive %}
<span class="badge bg-dark">Archived</span>
{% endif %}
{% if current_case.diagnostic_certainty %}
<span class="badge text-bg-light border">{{ current_case.get_diagnostic_certainty_display }}</span>
{% endif %}
<span class="badge text-bg-light border">{{ current_case.get_ordered_series|length }} series</span>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary-subtle shadow-sm h-100">
<div class="card-body">
<h2 class="h5 mb-3">Chain Summary</h2>
<div class="list-group list-group-flush small">
{% for linked_case in chain_cases %}
<a href="{% url 'atlas:linked_cases_overview' linked_case.pk %}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if linked_case.pk == current_case.pk %}active{% endif %}">
<span class="text-truncate pe-3">{{ forloop.counter }}. {{ linked_case.title|default:"Untitled case" }}</span>
<span class="badge {% if linked_case.pk == current_case.pk %}text-bg-light{% else %}text-bg-secondary{% endif %}">{{ linked_case.pk }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-xl-6">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Previous Cases</h2>
<span class="badge text-bg-light border">{{ previous_cases|length }}</span>
</div>
{% 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" relative_label=linked_case.relative_label is_current=False %}
{% endfor %}
</div>
{% else %}
<div class="card border-secondary-subtle shadow-sm">
<div class="card-body text-muted">No previous linked cases.</div>
</div>
{% endif %}
</div>
<div class="col-xl-6">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Next Cases</h2>
<span class="badge text-bg-light border">{{ next_cases|length }}</span>
</div>
{% 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" relative_label=linked_case.relative_label is_current=False %}
{% endfor %}
</div>
{% else %}
<div class="card border-secondary-subtle shadow-sm">
<div class="card-body text-muted">No follow-up linked cases.</div>
</div>
{% endif %}
</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 %}
File diff suppressed because it is too large Load Diff
+44 -16
View File
@@ -19,7 +19,13 @@
<div class="col-12">
<form method="get" class="form-inline">
{% crispy filter.form %}
<div class="mb-2">
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
<label class="mb-0" for="id_sort">Sort by</label>
<select class="form-select w-auto" id="id_sort" name="sort">
{% for value, label in sort_choices %}
<option value="{{ value }}" {% if selected_sort == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">Filter</button>
<a class="btn btn-link" href="?">Clear</a>
</div>
@@ -28,22 +34,44 @@
</div>
{% if page_obj.object_list %}
<div class="mb-2">
<span class="text-muted me-2">Quick sort:</span>
{% for value, label in sort_choices %}
{% if selected_sort == value %}
<span class="badge text-bg-primary">{{ label }}</span>
{% else %}
<a class="badge text-bg-light text-decoration-none" href="?{% if querystring_without_page_sort %}{{ querystring_without_page_sort }}&amp;{% endif %}sort={{ value }}">{{ label }}</a>
{% endif %}
{% endfor %}
</div>
<div class="list-group">
{% for normal in page_obj.object_list %}
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ normal.case.title }}</h5>
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
<div class="list-group-item d-flex justify-content-between align-items-start">
<a class="flex-grow-1 text-decoration-none text-reset" href="{% url 'atlas:case_detail' normal.case.pk %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ normal.case.title }}</h5>
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
</div>
<p class="mb-1">
Age: {{ normal.display_age }}
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
</p>
{% if normal.notes %}
<p class="mb-0 text-muted">{{ normal.notes }}</p>
{% endif %}
</a>
<div class="ms-2">
{% if request.user.is_superuser or request.user in normal.author.all %}
<button class="btn btn-sm btn-outline-secondary"
hx-get="{% url 'atlas:case_normal_edit' normal.pk %}"
hx-target="body"
hx-swap="beforeend">
Edit
</button>
{% endif %}
</div>
<p class="mb-1">
Age: {{ normal.display_age }}
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
</p>
{% if normal.notes %}
<p class="mb-0 text-muted">{{ normal.notes }}</p>
{% endif %}
</a>
</div>
{% endfor %}
</div>
{% else %}
@@ -53,11 +81,11 @@
<nav aria-label="Page navigation">
<ul class="pagination mt-3">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
<li class="page-item"><a class="page-link" href="?{% if querystring_without_page %}{{ querystring_without_page }}&amp;{% endif %}page={{ page_obj.previous_page_number }}">Previous</a></li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
<li class="page-item"><a class="page-link" href="?{% if querystring_without_page %}{{ querystring_without_page }}&amp;{% endif %}page={{ page_obj.next_page_number }}">Next</a></li>
{% endif %}
</ul>
</nav>
@@ -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 %}
@@ -0,0 +1,13 @@
{# Partial: Case Differentials #}
<div>
<details open>
<summary><b>Differentials</b></summary>
<ul>
{% for diff in case.differentialcase.all %}
<li>{{diff.condition.get_link}} - {{diff.text}}</li>
{% empty %}
<span class="text-muted">No differentials added for this case.</span>
{% endfor %}
</ul>
</details>
</div>

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