Compare commits

..
864 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
Ross 19eb10c089 Add user content management views and templates for content review 2026-02-13 22:26:41 +00:00
Ross 2ab5a8e3b5 Add author parameter to My Questions link in frcr_index.html 2026-02-10 22:19:22 +00:00
Ross c2f12f384a Add questions section with navigation links to frcr_index.html 2026-02-10 22:05:06 +00:00
Ross 33f32dab8b Create frcr_index.html template and update ExamViews to render it 2026-02-10 21:48:11 +00:00
Ross a676d437bb Add badges to FRCR and Atlas sections for better categorization 2026-02-10 21:13:44 +00:00
Ross ba5b11cce2 Update call-to-action section in index.html for improved user engagement 2026-02-10 21:12:49 +00:00
Ross a6c5b99620 Update label for AccordionGroup in AnatomyQuestionForm to improve clarity 2026-02-10 20:53:34 +00:00
Ross 666f49f2c2 Refactor AnatomyQuestionForm layout to arrange modality and related fields in a horizontal flex row 2026-02-10 20:51:49 +00:00
Ross 46608a0758 Update index.html text for clarity in user instructions 2026-02-10 20:48:12 +00:00
Ross cfdf33b9ce Add open access field to AnatomyQuestionForm and update index template layout 2026-02-10 20:42:42 +00:00
Ross bdc97922af Refactor AnatomyQuestionForm layout and update template to use crispy forms 2026-02-10 20:33:18 +00:00
Ross 3626f3918e Enhance image annotation help text and layout in question detail view 2026-02-09 22:50:40 +00:00
Ross 49fbff76e9 Replace FilteredSelectMultiple with custom ExamSearchWidget in AnatomyQuestionForm 2026-02-09 22:39:50 +00:00
Ross 7ec81133d8 Allow any authenticated user to create new Structure entries in autocomplete 2026-02-09 22:33:30 +00:00
Ross c67db4a39e Update hidden input type in open access form to use exam app name 2026-02-09 22:25:43 +00:00
Ross d2f4c21883 Add "View as viva" option to collection actions dropdown 2026-02-09 13:03:17 +00:00
Ross 1f5931aab2 Refactor question search template: rename and move to sbas_search.html 2026-02-09 12:50:31 +00:00
Ross 08c3558861 Add dismissible performance warning for large collections with persistence 2026-02-09 12:45:46 +00:00
Ross add7a1cf38 Add dismissible performance warning for large collections in collection_detail template 2026-02-09 12:44:19 +00:00
Ross 65f08cce6c Add performance warning for large collections in collection_detail template 2026-02-09 12:42:03 +00:00
Ross 3c8238d737 Add user permission checks for series editing and enhance author display method 2026-02-09 12:27:20 +00:00
Ross 23f33ac7ae Optimize collection_viva by aggressively prefetching and annotating related objects to prevent N+1 queries 2026-02-09 12:15:36 +00:00
Ross ab130a08ed Optimize collection_viva by annotating and prefetching related objects to prevent N+1 queries 2026-02-09 12:13:00 +00:00
Ross 45e01a78c1 Refactor case management links and button structure for improved readability and consistency 2026-02-09 11:24:43 +00:00
Ross 25e5ef78ed Update case series count and management links to use collection.pk for consistency 2026-02-09 11:24:33 +00:00
Ross 13dd79b508 Rename parameter 'case_id' to 'case_number' in collection_case_dicom_json_review for consistency 2026-02-09 11:15:57 +00:00
Ross 89986ba2e9 Refactor collection_case_dicom_json to use case_number instead of case_id for improved clarity and error handling 2026-02-09 11:14:53 +00:00
Ross f9e69e1206 Rename parameter 'case_id' to 'case_number' in collection_case_dicom_json function for clarity 2026-02-09 11:13:44 +00:00
Ross e7de6c8970 Set default archive filter to False in CaseCollectionFilter 2026-02-09 11:06:39 +00:00
Ross ea139456a8 Fix URL for editing display set in modal 2026-02-09 10:56:37 +00:00
Ross 035da12e96 Add edit permission check for display set modal and update template 2026-02-09 10:49:28 +00:00
Ross 3b9942054d Increase timeout for loading display set details and remove redundant script from modal 2026-02-09 10:34:20 +00:00
Ross 5761117e68 Enhance case display with modal functionality for findings and display sets 2026-02-09 10:33:06 +00:00
Ross f316dc9bcb Filter out removed images in Case model's image retrieval 2026-02-09 09:46:16 +00:00
Ross bdb7b653b6 Add modal for viewing display set details and update URLs and views 2026-02-08 22:42:14 +00:00
Ross 1eeb0d89d9 Add get_history method to CaseDetail model and update case_list_item template for history modal 2026-02-08 21:42:17 +00:00
Ross f3702db5f6 Refactor case field buttons to use hidden modal content and remove unused endpoint 2026-02-02 22:39:49 +00:00
Ross a6a7d75144 Implement modal for case fields and add partial endpoint for dynamic content retrieval 2026-02-02 22:29:52 +00:00
Ross 14bba93c4d Add quick-view icons for case details in case list item template 2026-02-02 22:22:37 +00:00
Ross f29df840c6 Refactor collection detail template to improve display of exam mode and access information 2026-02-02 22:15:20 +00:00
Ross 10d30cdaa6 Update Django entrypoint to include collectstatic command 2026-02-02 22:08:22 +00:00
Ross dc4cf0c3a2 Comment out conditional block for cid_user_exam in user_score_header template 2026-02-02 17:22:41 +00:00
Ross 99b951b32b Add normalized score to exam template context 2026-02-02 17:15:35 +00:00
Ross c2c8ec5b13 Update question prefetching logic to include 'sbas' app 2026-02-02 17:12:23 +00:00
Ross 6d5303cd0d Update exam scores URL for clarity and remove commented-out paths 2026-02-02 17:11:12 +00:00
Ross f6b990a00a Add user-friendly explanation to 404 error template 2026-02-02 17:04:33 +00:00
Ross 3c0deec140 Improve error messages for exam availability in Http404 responses 2026-02-02 17:02:02 +00:00
Ross e3241b503d Refactor exam take help template for improved readability and structure 2026-02-02 16:54:49 +00:00
Ross 04174484b1 Refactor exam take help template for improved URL handling and readability 2026-02-02 16:54:38 +00:00
Ross 168a4126d3 Refactor exam start template for improved readability and URL handling 2026-02-02 16:51:56 +00:00
Ross a0850c0030 Add conditional rendering for reason in 404 error template 2026-02-02 16:41:11 +00:00
Ross a882a3592b Add debug logging for exam take view to track user actions 2026-02-02 16:38:47 +00:00
Ross 8074b56a80 Fix indentation in 404 error template for improved readability 2026-02-02 16:33:59 +00:00
Ross 2e79df1eae Fix indentation for resolved message in 404 error template 2026-02-02 16:33:52 +00:00
Ross 9bf68cd136 Add condition filters to CaseFilter for enhanced search capabilities 2026-02-02 13:58:21 +00:00
Ross 42163448bf Add free-text search filter for Examination in CaseFilter to improve searchability 2026-02-02 13:56:31 +00:00
Ross 1066a3fb9f Update password reset link text for clarity in login template 2026-02-02 13:35:08 +00:00
Ross 3ade162b28 Remove series truncation endpoint from API 2026-02-02 13:30:06 +00:00
Ross f4186bf923 Fix indentation for confirmation prompt in series truncation logic 2026-02-02 13:24:37 +00:00
Ross 5bb2aea8ef Add HTMX endpoint for truncating series images and corresponding result template 2026-02-02 13:24:27 +00:00
Ross 8911c09c0e Enhance CaseFilter to show distinct examination values for series, improving clarity in selection options 2026-02-02 12:14:16 +00:00
Ross 7342b11b27 Fix button formatting for adding to collection in case display block 2026-02-02 12:09:37 +00:00
Ross 953295cc4d Refactor button styles for collection management to ensure consistent appearance across templates 2026-02-02 12:09:25 +00:00
Ross 374ee17911 Refactor detail templates for condition, finding, and structure to improve layout consistency and readability 2026-02-02 11:59:35 +00:00
Ross 18aef751ca Add display sets section to condition, finding, and structure detail templates; list associated cases for better context 2026-02-02 11:59:28 +00:00
Ross f362be43ef Add inline collection management features; implement search and add functionality for collections in case view 2026-02-02 11:55:43 +00:00
Ross 00680c8b9e Enhance NormalCaseFilter to support flexible age filtering with unit selection; update normals_list template with detailed help text for improved user guidance 2026-02-02 11:26:11 +00:00
Ross eea3433042 Enhance NormalCaseFilter to limit examination and modality choices based on user restrictions; integrate Crispy Forms for improved filter form layout in normals_list template 2026-02-02 11:03:38 +00:00
Ross 8dad4a5a5c Refactor case badges to support multiple conditions, presentations, and subspecialties in search results 2026-02-02 10:59:05 +00:00
Ross 75d23c0ba9 Improve condition, presentation, and subspecialty badge rendering in case search results 2026-02-02 10:56:02 +00:00
Ross ff2b6f4c65 Refactor case search results template for improved readability and structure 2026-02-02 10:52:43 +00:00
Ross 22e5f9a496 Enhance case search results display with additional information and tooltips for condition, presentation, and subspecialty 2026-02-02 10:52:36 +00:00
Ross 16865fab03 Refactor permission checks for marking cases as normal to use can_edit method 2026-02-02 10:23:46 +00:00
Ross 5e14df4836 Refactor case search page layout and improve indentation in search results 2026-02-02 10:21:13 +00:00
Ross 625f6017a6 Add advanced case search page with filtering options and pagination 2026-02-02 10:20:12 +00:00
Ross c45e3c8765 Fix hx-trigger attribute for case search input to improve functionality 2026-02-02 10:08:34 +00:00
Ross ee33f62def . 2026-02-02 09:32:40 +00:00
Ross 3a5fe9a85c Fix indentation in display set list and clean up whitespace in JavaScript section 2026-02-02 09:29:20 +00:00
Ross efc47bb2e8 Delay mounting DICOM viewers to ensure assets and DOM are ready 2026-02-02 09:29:07 +00:00
Ross 38d70f8ff0 Refactor resource search button for improved formatting and readability 2026-02-02 09:25:32 +00:00
Ross 2159b045cd Add resource search panel with HTMX integration for dynamic resource addition 2026-02-02 09:18:16 +00:00
Ross 4628f39a49 Refactor case size template for improved readability and structure 2026-02-02 09:12:56 +00:00
Ross 90ceff1b02 Add admin view for cases ordered by total image size with filtering options 2026-01-28 14:45:01 +00:00
Ross a512c2ba09 Refactor HTML formatting to use mark_safe for safe rendering in various tables and models 2026-01-28 14:28:36 +00:00
Ross a61c8529ae Clean up whitespace in various template files for consistency 2026-01-26 12:49:04 +00:00
Ross a667566db6 Remove unused partials loading from various template files 2026-01-26 12:43:11 +00:00
Ross d070adc326 Add button to view all collections in user related collections template 2026-01-26 11:11:37 +00:00
Ross 048168e87d Add user-related collections feature with HTMX support and timestamps for collections 2026-01-26 11:07:15 +00:00
Ross c881da244b Refactor collections button to use HTMX for dynamic loading of user collections 2026-01-26 10:47:59 +00:00
Ross 797eb9c54a Implement HTMX support for user collections display and refactor rendering logic 2026-01-26 10:44:54 +00:00
Ross 0a1b21cec6 Enhance local-up script with CLI options for no-build and help 2026-01-26 10:29:03 +00:00
Ross d3d3638c1c Refactor score filtering logic in exam scores user template for consistent layout 2026-01-19 12:57:10 +00:00
Ross 81e22dfc88 Refactor answers display in exam scores user template for improved layout and styling 2026-01-19 12:56:20 +00:00
Ross 7ea43a6f44 Refactor score display logic in exam scores user template for accuracy 2026-01-19 12:53:19 +00:00
Ross eb8fe58cf7 Refactor exam scores user template to implement question filtering by score selection 2026-01-19 12:51:02 +00:00
Ross f149a88bd6 Enhance exam scores user template with question filtering and improved score display 2026-01-19 12:49:34 +00:00
Ross d4d747517c Refactor exam scores user template to unify view button class for consistency 2026-01-19 12:47:34 +00:00
Ross 0ee378484b Refactor view button in exam scores user template to remove unnecessary class 2026-01-19 12:45:55 +00:00
Ross ea583744c9 Refactor exam scores user template to improve answers display structure 2026-01-19 12:45:18 +00:00
Ross e0d589f4d0 Refactor exam scores user template to enhance viewer block structure and improve question display functionality 2026-01-19 12:43:41 +00:00
Ross 1c95526ff9 Refactor exam scores user template to simplify question display structure and remove unnecessary wrapper 2026-01-19 12:40:54 +00:00
Ross 662fc4e06e Refactor CSS in exam scores user template to enforce minimum width for sticky viewer on larger viewports 2026-01-19 12:39:36 +00:00
Ross a333ff492d Refactor CSS in exam scores user template for improved layout and styling of sticky viewer 2026-01-19 12:35:01 +00:00
Ross bb9741b786 Refactor exam scores user template to improve viewer card styling and functionality 2026-01-19 12:32:48 +00:00
Ross 75be7c1739 Refactor exam scores user template to enhance question display and viewer functionality 2026-01-19 12:30:49 +00:00
Ross 015a5d18b5 Refactor exam scores user template for improved layout and styling of answers 2026-01-19 12:28:20 +00:00
Ross d16041026f Add links to question stems in exam scores user template for authenticated and unauthenticated users 2026-01-19 12:24:59 +00:00
Ross 774a1b8c8a Refactor question display in exam scores user template to include badges as links for authenticated and unauthenticated users 2026-01-19 12:23:45 +00:00
Ross 8fc42169f3 Add view link for questions in exam scores user template for staff users 2026-01-19 12:22:58 +00:00
Ross 834d91e84d Enhance layout of answer options with improved spacing and conditional category display 2026-01-19 12:21:42 +00:00
Ross ef03a97e9e Refactor exam scores user template for improved layout and clarity 2026-01-19 12:18:28 +00:00
Ross dd7437bc66 Add alignment to feedback display for user answers 2026-01-19 12:16:29 +00:00
Ross 21d3130fa5 Add feedback display for questions based on user answers 2026-01-19 12:13:47 +00:00
Ross 89e7e49e31 Add feedback display for answer options based on exam settings 2026-01-19 12:11:24 +00:00
Ross 8bceb10a13 Refactor CID user overview template for improved layout and clarity 2026-01-19 12:06:08 +00:00
Ross 13a98a1e66 Add exam_mode parameter to exam take URL for enhanced functionality 2026-01-19 11:10:10 +00:00
Ross 9c16f93820 rename cid scores to something more appropriate 2026-01-19 10:28:08 +00:00
Ross 1c27d48a36 Add admin edit link for submitted candidates in the details view 2026-01-19 10:14:00 +00:00
Ross 562ad1c0a4 Refactor submitted candidates fragment to group count and admin link for improved layout 2026-01-19 10:11:09 +00:00
Ross fcd77fd799 Add logic to display Reviewed badges for submitted exams and update exam link parameters 2026-01-19 10:03:18 +00:00
Ross d8f8ef7326 Add --nothreading option to Django runserver command and implement HTMX debug modal for JSON responses 2026-01-12 09:45:48 +00:00
Ross f551d071f0 Remove login requirement for exam_toggle_flag function to allow access without authentication. 2026-01-09 20:39:27 +00:00
Ross 9b6fd31cee Merge branch 'part12026live' into master 2026-01-05 20:55:29 +00:00
Ross 8b7da1554a Refactor exam question fragment to reposition flag button within the button row for improved layout and accessibility during HTMX updates. 2026-01-05 14:53:03 +00:00
Ross d4247adb13 Enhance loading indicators for question fragment during HTMX requests; ensure overview tile text color remains unchanged for flagged questions 2026-01-05 14:50:07 +00:00
Ross b89ca918fa Add loading spinner and blur effect for question fragment during HTMX requests; optimize answered and flagged state queries 2026-01-05 14:48:21 +00:00
Ross 569ddb4343 Enhance question navigation by updating local state on button clicks and ensuring DOM updates after HTMX settle 2026-01-05 14:41:48 +00:00
Ross b040dfcd82 Enhance question menu update logic by adding flag icon management and logging for better debugging 2026-01-05 14:38:03 +00:00
Ross db51ef23be Refactor exam question fragment to move flag button inside the fragment for HTMX updates and enhance local state management for answered and flagged questions. 2026-01-05 14:30:55 +00:00
Ross 8c796c3048 Enhance question menu state management by adding a centralized updater for answered and flagged questions 2026-01-05 14:24:59 +00:00
Ross b837c406cf Refactor exam overview template for improved readability and consistency in HTML structure 2026-01-05 14:22:33 +00:00
Ross 42d209af5a Expose current question index for global listeners and update browser URL to reflect the current question position 2026-01-05 14:19:01 +00:00
Ross 3d1b9a57f9 Enhance exam question fragment by exposing current question index for global listeners and updating question list state locally to reflect answered questions 2026-01-05 14:16:02 +00:00
Ross 8d638cc08b Enhance exam overview and fragment by adding question status indicators for answered and flagged questions 2026-01-05 14:13:52 +00:00
Ross 20a06323d5 Enhance navigation after save by improving URL handling and fallback mechanisms for question transitions 2026-01-05 14:04:30 +00:00
Ross 3928df6d75 Refactor exam question display to include flag button and enhance layout for better usability 2026-01-05 14:01:27 +00:00
Ross bf344f20e1 Add flagging functionality for exam questions with a reusable Flag model and toggle feature 2026-01-05 13:47:45 +00:00
Ross e4cfb1f782 Enhance exam navigation by adding a confirmation modal for unsaved changes when skipping questions. 2026-01-05 12:32:14 +00:00
Ross f44f3bbcfe Enhance exam navigation by adding a skip button for non-saving question transitions and implementing unsaved changes confirmation. 2026-01-05 11:59:23 +00:00
Ross ba0c380f18 Enhance candidate lists by restructuring HTML for improved readability and maintainability. 2026-01-05 11:55:03 +00:00
Ross 459b7f9b93 Enhance candidate lists by adding badge containers for improved visual feedback on submission status. 2026-01-05 11:52:58 +00:00
Ross 378d784435 Enhance submitted candidates display by adding dynamic exam status indicators and improving badge tooltip functionality for better user feedback. 2026-01-05 11:30:53 +00:00
Ross 96e88c05a4 Enhance submission tracking by adding first and last name data attributes for candidates, improving badge tooltip information in the submitted candidates list. 2026-01-05 11:25:10 +00:00
Ross 457fb06dc0 Enhance highlightSubmitted function to add badges for submitted CIDs and users, improving visual feedback in the candidate list. 2026-01-05 11:05:26 +00:00
Ross debe093bc9 Add exam_mode parameter to exam take URL for improved functionality 2026-01-05 10:59:40 +00:00
Ross 086bd8f589 Add permission check for user marker access in mark2 overview 2026-01-05 10:18:41 +00:00
Ross 71dc50b307 Enhance CID and User candidate filtering with clear buttons and indicators for improved usability 2026-01-05 09:51:54 +00:00
Ross c0c12ea9f9 revert last commit 2026-01-04 13:19:36 +00:00
Ross 429adfc376 Optimize exam retrieval by prefetching related data to reduce N+1 queries and enhance access checks for user permissions 2026-01-04 13:15:15 +00:00
Ross 36a809ea40 Update type hints for cid and passcode parameters in ExamViews for improved clarity 2026-01-04 13:11:02 +00:00
Ross 443d93c35f Enhance user marking query to ensure distinct exams and order by name for better organization 2026-01-04 13:02:59 +00:00
Ross d7682ab31d Update exam links in user marking list to use dynamic URL generation based on exam type 2026-01-04 13:01:42 +00:00
Ross e600b8eb91 Fix count annotations in exam collection to use distinct values for accurate user counts 2026-01-04 12:55:53 +00:00
Ross 3e691d774c Refactor exam list annotation to use unfiltered counts for accurate overview matching 2026-01-04 12:50:47 +00:00
Ross c3a70b079d Refactor HTML structure for user marking templates to improve readability and maintainability 2026-01-04 12:40:56 +00:00
Ross 4ffadd3914 add user marknig pages 2026-01-04 12:39:33 +00:00
Ross fa18c33e8a Optimize exam list retrieval by consolidating queries and annotating candidate counts for improved performance 2025-12-29 12:55:15 +00:00
Ross 057c391995 Refactor exam status components to use consistent class names and improve HX target selectors for better maintainability 2025-12-29 12:50:02 +00:00
Ross 94fbd38634 Enhance exam status management with HTMX loading indicators and streamline AJAX handling by removing legacy jQuery code 2025-12-29 12:44:42 +00:00
Ross 4ecc32f837 Implement HTMX lazy-loading for exam collections and optimize queryset annotations for performance 2025-12-29 12:26:56 +00:00
Ross 768e1bf8c5 Enhance ExamCollectionDetail queryset to prefetch related exams and annotate candidate counts for improved performance in templates 2025-12-29 12:18:31 +00:00
Ross 32c4305754 Fix indentation and formatting in exam order editing functionality for improved readability 2025-12-29 12:15:18 +00:00
Ross 465f819973 Improve error handling in state change failure response for better user feedback 2025-12-29 12:15:06 +00:00
Ross 3e1cb4b54f Optimize question loading and user answer retrieval in ExamViews to reduce database queries 2025-12-29 12:09:18 +00:00
Ross b9d86a7639 Remove debug print statement for answers_marks in ExamViews 2025-12-29 12:05:29 +00:00
Ross e5fd99f491 Remove unused exam scores refresh function and related logic for CID user exam scores 2025-12-29 12:04:48 +00:00
Ross efeed2a8b1 Refactor stats and answers display for improved layout and readability 2025-12-29 11:41:16 +00:00
Ross f14ede86d3 Refactor notes display logic in CID user list for improved readability 2025-12-29 11:24:18 +00:00
Ross 517c2ebe06 Add notes display with tooltip support in CID user list 2025-12-29 11:23:52 +00:00
Ross c5f9b830eb Refactor submitted candidates display to use a table format with action buttons and add submission count 2025-12-29 11:18:10 +00:00
Ross 2ab85357a9 Implement HTMX support for CID and User candidates lists, and add submitted candidates summary 2025-12-29 11:14:15 +00:00
Ross 91cf2cab29 Update exam time limit display to show '1Q' for time per question 2025-12-29 10:50:53 +00:00
Ross 34c4c0d830 Add notes display and tooltip support in exam CIDs template 2025-12-29 10:25:27 +00:00
Ross 98cef9ea57 Add notes field to CidUser model and update related forms and templates 2025-12-29 10:17:54 +00:00
Ross df0b26de36 Add user group archiving functionality with HTMX support 2025-12-29 09:53:57 +00:00
Ross 17330681a2 Refactor ExamCollection views to enforce author requirements and improve access control 2025-12-29 09:44:29 +00:00
Ross 112c96dc00 Restrict ExamCollectionList to authenticated users and filter by current user as author 2025-12-29 09:41:45 +00:00
Ross 0bea34d406 Add author requirement to ExamCollectionList view 2025-12-29 09:35:55 +00:00
Ross 06ceb9c2ce Add manual submission flag to CidUserExam and improve logging for exam completion 2025-12-22 12:07:25 +00:00
Ross 8ac292eb0c Add ExamUserStatus logging for exam completion and start events 2025-12-22 11:35:09 +00:00
Ross ff6b8a8a48 Add filtering functionality for CID and User candidates in exam view 2025-12-22 11:23:59 +00:00
Ross 46313cf5bb Refactor exam candidates view for improved layout and user experience 2025-12-22 11:20:39 +00:00
Ross 13a23eee12 Refactor CID candidate list for improved readability and maintainability 2025-12-22 11:14:00 +00:00
Ross a1bc96f99a Add CidUserExam management features: forms, views, and templates for exam records 2025-12-22 11:13:29 +00:00
Ross 9fd5e56356 Enhance exam completion confirmation messages for clarity and user assurance 2025-12-22 10:46:03 +00:00
Ross a02f274a4e Enhance user experience with visual indicators for HTMX actions and streamline button interactions in CID group view 2025-12-22 10:44:50 +00:00
Ross d3d0e865c3 Add conditional preview generation for CID group types in user details 2025-12-22 10:38:45 +00:00
Ross 7163e51335 Add modal for group users table and CSV export functionality 2025-12-22 10:29:47 +00:00
Ross 7b47a75657 Add email preview functionality for candidate details: allow users to preview email content before sending 2025-12-22 10:26:22 +00:00
Ross 54e1cf4ce0 Improve permissions handling in local-up script: only change log and Loki data directory permissions if necessary 2025-12-22 10:06:54 +00:00
Ross c071f38920 Implement custom logging email backend for development: log outgoing emails to Loguru and stdout 2025-12-15 13:14:12 +00:00
Ross 03b23b9178 Configure email backend for local development and update NGINX HTTPS port in environment settings 2025-12-15 12:44:57 +00:00
Ross 00b736d8d6 Implement bulk user management features: add user generation preview and bulk creation functionality in CID group view 2025-12-15 12:23:26 +00:00
Ross 885ab1aad4 Enhance user sorting and email resend functionality in CID group view: add sorting options for user list and improve email resend handling in candidate details view 2025-12-15 12:15:42 +00:00
Ross 77edc17732 Refactor CID group view: enhance user display with improved layout, add help section, and implement user sorting functionality 2025-12-15 12:09:51 +00:00
Ross d98305caff Refactor CID user group form: enhance layout with Bootstrap styling, improve error handling, and update user list presentation 2025-12-15 12:04:42 +00:00
Ross 12bd66d4d8 Refactor bulk user management: update HTMX target attributes to replace wrappers for improved rendering of user lists 2025-12-15 12:01:42 +00:00
Ross 508f8a22a2 Implement bulk user management for exams: add user bulk edit endpoint, update templates for user list rendering, and enhance toggle functionality with HTMX support 2025-12-15 12:01:01 +00:00
Ross 158db133b3 Refactor CID user list rendering: consolidate user list into a single template and enhance toggle feedback with count updates 2025-12-15 11:54:21 +00:00
Ross 83a009e31b Refactor HTMX response handling in ExamViews: streamline rendering of updated CID user list items and ensure consistent response structure 2025-12-15 11:51:49 +00:00
Ross 00dfaf9888 Add bulk edit functionality for CID users: implement endpoint and update templates for bulk add/remove actions 2025-12-15 11:49:06 +00:00
Ross 7e4e66a74f Refactor exam candidate edit script: improve user feedback on add/remove actions and streamline sorting logic 2025-12-15 11:22:47 +00:00
Ross 7a4cf3627d Refactor CID users display: enhance layout with card structure, add sorting functionality, and improve toggle button interactions 2025-12-15 11:21:44 +00:00
Ross 4db1f2ca1a Add toggle functionality for pre-review flag on case resources 2025-12-15 11:15:56 +00:00
Ross cbff1317d0 Enhance resource display: conditionally render resource search panel and update pre/post review badge logic 2025-12-15 10:53:21 +00:00
Ross 0644ed99f4 Refactor resource search panel: simplify input trigger and improve layout for search results 2025-12-15 10:47:42 +00:00
Ross c01000f06e Refactor resource search functionality: replace inline form with a dedicated search panel and improve result handling 2025-12-15 10:42:58 +00:00
Ross 2f014ce1a5 Enhance resource management: refresh case data and notify listeners on resource changes in case attachment/detachment 2025-12-15 10:12:04 +00:00
Ross 654d0ae686 Enhance local viewer: implement centralized hide functionality and improve close handling for resource overlays 2025-12-15 10:08:14 +00:00
Ross 18dbb27a3e Enhance resource handling: add support for loading and displaying non-DICOM resources (images, videos, embeds) in viewer overlays 2025-12-15 10:06:54 +00:00
Ross 4bec329aff Implement resource search functionality and enhance resource management in case details 2025-12-15 09:53:19 +00:00
Ross 1e2300bd70 Add media preview functionality for resources with image or video support 2025-12-15 09:47:07 +00:00
Ross 7674f29124 . 2025-12-08 12:56:39 +00:00
Ross d39879bbca Enhance ExamSearchSelectMultipleWidget: improve button styling and add functionality for adding multiple exams 2025-12-08 12:54:52 +00:00
Ross 012c86340d . 2025-12-08 12:51:17 +00:00
Ross 6f50c53519 . 2025-12-08 12:37:24 +00:00
Ross 53aefa29df . 2025-12-08 12:35:13 +00:00
Ross 4617a643d2 Refactor exam collection form layout for improved readability and structure 2025-12-08 12:34:23 +00:00
Ross d7afab15e1 Refactor templates and views for improved structure and maintainability 2025-12-08 12:31:58 +00:00
Ross ad5e7aeed8 . 2025-12-08 12:17:16 +00:00
Ross b4cf5b9d70 Refactor exam collection templates to improve navigation structure and enhance user experience 2025-12-08 12:12:54 +00:00
Ross f156f4c85e Refactor navbar styles and improve dropdown accessibility for better mobile usability 2025-12-08 12:12:00 +00:00
Ross 4ab2e3a15e Enhance ExamSearch widgets to support customizable default filters and improve metadata display 2025-12-08 12:11:24 +00:00
Ross 6daa643df8 Refactor navigation structure for improved readability and maintainability 2025-12-08 12:07:24 +00:00
Ross f8874a2556 Refactor navigation to use Bootstrap navbar with dropdowns for better organization and accessibility 2025-12-08 12:07:00 +00:00
Ross b4f5992986 . 2025-12-08 11:56:39 +00:00
Ross 9a3a8b932a . 2025-12-08 11:51:36 +00:00
Ross eb06e63f77 . 2025-12-08 11:18:47 +00:00
Ross a4f4ba464a Add advanced search help panel and functionality to ExamSearchSelectMultipleWidget 2025-12-08 11:15:03 +00:00
Ross 6fafccc907 . 2025-12-08 11:10:55 +00:00
Ross d96dd77cdc Add user authentication to index and user collections views; update CSRF trusted origins and internal IPs 2025-12-08 10:57:38 +00:00
Ross 4e47083fcd . 2025-12-08 10:35:58 +00:00
Ross bf895e516e . 2025-12-08 10:32:30 +00:00
Ross 49229b0408 Enhance local-up script to ensure Loki and nginx log directories exist with appropriate permissions 2025-12-08 10:30:00 +00:00
Ross 997b5374c2 . 2025-12-08 10:29:52 +00:00
Ross 9d434ec80d Merge branch 'dockerprod' into master 2025-12-08 10:13:08 +00:00
Ross af616a9f4a Add tooltips for open access settings and improve UI elements in exam overview 2025-12-08 10:05:44 +00:00
Ross d29c23a799 . 2025-12-08 09:54:15 +00:00
Ross 0917947a98 . 2025-12-08 09:52:18 +00:00
Ross 02a9118faf . 2025-12-08 09:29:30 +00:00
Ross 63c6a648d7 Add Loki, Promtail, and Grafana services for observability stack 2025-12-04 20:06:24 +00:00
Ross 517c30ba0e Enhance Docker setup with logging and monitoring features
- Added logging configuration to WSGI for persistent log collection.
- Updated Docker Compose files for local and development environments, including Redis, PostgreSQL, and Grafana services.
- Introduced Promtail configuration for log scraping and integration with Loki.
- Created application log directory in Dockerfile for log persistence.
2025-12-01 16:43:40 +00:00
Ross 590e0b76ed Add series images endpoint and template for HTMX integration 2025-12-01 14:50:11 +00:00
Ross e9a04e823c more docker improvements 2025-12-01 14:01:46 +00:00
Ross 9ed789ba90 more docker updates 2025-12-01 13:31:08 +00:00
Ross fd6e25d55e more ninja fixes 2025-12-01 13:31:02 +00:00
Ross f074e5b086 fix some ninja issuse 2025-12-01 13:30:43 +00:00
Ross 2157b6f781 some fixes? 2025-12-01 11:12:01 +00:00
Ross 359682a0cd start prod docker migration 2025-12-01 10:36:58 +00:00
Ross 78577076c1 Refactor discussion and report sections to use card layout for improved presentation and user interaction 2025-11-24 12:44:55 +00:00
Ross 3635275573 Enhance case view layout and styling for improved readability and user experience 2025-11-24 12:36:18 +00:00
Ross 0c412dfff6 Add prior cases information to collection case view template 2025-11-24 12:19:56 +00:00
Ross 3f00311136 Fix hx-post URL in prior card template for correct case number handling 2025-11-24 12:06:44 +00:00
Ross e8c35efd83 Refactor collection_case_questions function to use case_number instead of case_id for improved clarity and consistency in case retrieval 2025-11-24 12:02:43 +00:00
Ross 0badca4c4f Add default error value to context in collection_case_priors view to prevent variable resolution issues 2025-11-24 11:58:53 +00:00
Ross 25f4e478b3 Add edit permission check for collection case priors view 2025-11-24 11:53:50 +00:00
Ross 33cba3d005 Fix hx-post URL in prior card template for correct case number handling 2025-11-24 11:43:07 +00:00
Ross efca426359 Add case jump functionality with HTMX support and corresponding template 2025-11-24 11:40:52 +00:00
Ross f713632fdd Refactor Condition, Finding, and Structure tables: remove legacy primary field from displayed fields to streamline data representation. 2025-11-24 11:02:51 +00:00
Ross 785638ee23 Add cancel button to ConditionForm for easier navigation 2025-11-24 10:49:13 +00:00
Ross e8020c5d8f Update ConditionForm to use AJAX/autocomplete for canonical field selection 2025-11-24 10:42:38 +00:00
Ross 5ccf755e72 Refactor Condition model: remove legacy synonym and primary fields, introduce canonical field for aliasing. Update related documentation. 2025-11-24 10:31:13 +00:00
Ross 6308fbfcd7 Refactor Finding and Structure models: remove legacy primary and synonym fields, introduce canonical field for aliasing. Update filters and forms accordingly. 2025-11-24 10:18:26 +00:00
Ross 8a871d6332 Replace legacy primary filter with is_canonical and add synonym filter in ConditionFilter 2025-11-23 23:29:39 +00:00
Ross 6eeb4cbb1d Add canonical field and update synonym handling in Condition model
- Introduced a new `canonical` ForeignKey field in the Condition model to manage alias relationships.
- Enhanced synonym retrieval methods to prefer canonical names and handle aliases more effectively.
- Updated condition detail templates to reflect canonical status and improved synonym display.
- Implemented migration scripts to establish canonical relationships based on existing synonym links.
2025-11-20 22:48:03 +00:00
Ross 9d57d03cbb Refactor synonym management: extract synonyms list and add forms for adding synonyms with HTMX support 2025-11-20 21:59:50 +00:00
Ross 21de543313 Enhance ConditionForm: add Crispy Forms integration for consistent submit button rendering 2025-11-17 23:15:21 +00:00
Ross a12a9e38f3 Implement synonym management in condition detail: add forms for adding synonyms and handle POST requests for synonym creation. 2025-11-17 23:08:34 +00:00
Ross 81dc5bf48f Add forms and views for subspecialty, presentation, and pathological process management 2025-11-17 23:01:02 +00:00
Ross 4abb57900d Refactor categories dropdown: enhance layout with additional category options and staff creation links for improved navigation 2025-11-17 22:54:32 +00:00
Ross 08c9138cdb Refactor condition form: streamline JavaScript loading and enhance button layout for improved usability 2025-11-17 22:52:34 +00:00
Ross fb833a2b90 Refactor collection view: create a new template for collections with improved layout and staff action visibility 2025-11-17 22:47:15 +00:00
Ross 2f1e2f6abe Refactor collection_case_details: simplify case retrieval logic and improve parameter naming for clarity 2025-11-17 22:33:10 +00:00
Ross ffa717a9ef Refactor exam overview header: move author display earlier and remove duplicate block for improved clarity 2025-11-17 22:16:08 +00:00
Ross 2dd9f4af32 Refactor exam overview: enhance question display with improved layout and feedback visibility 2025-11-17 22:09:49 +00:00
Ross feb5a85af2 Refactor dropdown menus: enhance action items with icons and improve visual hierarchy for better user experience 2025-11-17 12:20:46 +00:00
Ross 292f81ad74 Refactor question link header: update text for clarity in exam context 2025-11-17 12:16:26 +00:00
Ross 349c46c6db Refactor viewing case template: adjust HTML structure for improved readability and maintainability 2025-11-17 12:14:05 +00:00
Ross 681a7560ba Refactor question form layout: improve indentation and formatting for better readability 2025-11-17 12:12:31 +00:00
Ross aa90ae5e09 Add QuestionForm and related views: implement form for creating questions with user context and exam association 2025-11-17 12:12:06 +00:00
Ross f55bb1b18e Refactor exam navigation: enhance layout and accessibility by replacing links with a responsive navbar structure 2025-11-17 12:02:21 +00:00
Ross 5e78cc4603 Refactor exam overview headers: comment out unused div for improved code clarity 2025-11-17 11:54:35 +00:00
Ross a9cbb1303a Refactor exam navigation: restructure HTML for improved layout and accessibility, adding dropdown menu for exam actions 2025-11-17 11:52:53 +00:00
Ross 24558f92ce Refactor case prior handling: update function to support case_number instead of case_id for improved clarity and maintainability 2025-11-17 11:15:08 +00:00
Ross 0f8bae8a14 Refactor URL patterns: update legacy case_id references to include 'id' for improved clarity and consistency 2025-11-17 11:11:09 +00:00
Ross 6e658aca93 Refactor case handling: update URLs and views to support case_number and case_id interchangeably, enhancing backward compatibility and maintainability 2025-11-17 11:05:13 +00:00
Ross 81cd109449 Refactor dark mode styles: update CSS variables for dark mode theme and set default background and text colors 2025-11-17 10:49:31 +00:00
Ross d86f990be2 Refactor case detail URL references: update template links from 'casedetail' to 'case_detail' for consistency 2025-11-17 10:36:19 +00:00
Ross b6478c44dc Refactor case detail references: standardize variable naming from case_detail to casedetail across templates for consistency 2025-11-17 10:34:55 +00:00
Ross 12b7fa6629 Refactor CaseDetail references to Casedetail across templates and views
- Updated all instances of 'case_detail' to 'casedetail' in templates to maintain consistency.
- Adjusted view functions to use 'casedetail' instead of 'case_detail' for better clarity.
- Renamed the field 'case_detail' to 'casedetail' in the CasePrior model to align with the new naming convention.
- Ensured that all related logic and references in forms and data handling reflect this change.
2025-11-17 10:34:27 +00:00
Ross 7f4570417e Refactor case navigation: consolidate previous and next links into a reusable partial for improved maintainability and consistency across templates 2025-11-17 10:09:00 +00:00
Ross 0098fa5dfc Refactor case prior display: extract prior card markup into a separate partial for improved maintainability and readability 2025-11-17 09:27:02 +00:00
Ross 740d013198 Refactor case prior display: enhance layout with Bootstrap cards for improved readability and user interaction 2025-11-17 09:22:17 +00:00
Ross b47bb141e8 Enhance case search functionality: exclude cases already in the collection from search results to prevent duplication 2025-11-16 23:28:23 +00:00
Ross 712ecec25d Enhance case search results: add data attributes for case identification and implement client-side removal of cases from search results after addition to collection 2025-11-16 23:25:35 +00:00
Ross 0193daf166 Refactor case search widget: update hx-get attribute and include collection in hx-vals for improved search functionality 2025-11-16 23:17:57 +00:00
Ross 279d6344f1 Enhance case search functionality: display recent cases in search results and adjust initial load behavior 2025-11-16 23:16:06 +00:00
Ross 8c8d484dce Enhance exam order editing: hide edit button during editing, improve save button attributes, and clean up controls after saving 2025-11-16 23:06:33 +00:00
Ross 9760be8f27 Enhance case list item links and management actions: update hrefs for dynamic case numbering and improve button structure for better accessibility 2025-11-16 23:03:19 +00:00
Ross 5e0068d490 Implement case search functionality and enhance case addition UI in collection view 2025-11-16 22:55:59 +00:00
Ross 875744a08a Refactor case management links and add case list item partial for improved organization and rendering in collection detail view 2025-11-16 22:51:22 +00:00
Ross c02a46fab1 Refactor collection detail view: enhance layout, improve organization, and add badges for better information display 2025-11-16 22:36:50 +00:00
Ross 6b73006122 Refactor collection detail view: enhance layout and improve organization of case management links and collection information 2025-11-16 22:31:00 +00:00
Ross 7eb478b111 Refactor case management links: enhance layout and improve button accessibility in collection detail view 2025-11-16 22:26:34 +00:00
Ross 10c18bd514 Refactor case management links: move to separate partial for improved template organization and clarity 2025-11-16 22:20:53 +00:00
Ross 1407fe83a2 Fix link for case series display and update edit series action in management links 2025-11-16 22:03:14 +00:00
Ross e9716f3caf Add per-case management links template for collection detail view 2025-11-16 22:01:30 +00:00
Ross 749450b03e Refactor collection detail template: streamline case management links and enhance layout for better usability 2025-11-16 21:57:54 +00:00
Ross 7e72f731b3 Move "Add New Case" link to dropdown for superusers in collection actions 2025-11-16 21:37:03 +00:00
Ross e407326c25 Refactor collection headers: convert to responsive navbar layout with dropdown actions 2025-11-15 23:12:58 +00:00
Ross 0d8177f0b4 Remove exam review link from exams template for cleaner navigation 2025-11-15 23:09:59 +00:00
Ross 3312f3058a Enhance CaseCollectionForm: replace collection_type field with radio buttons and add custom styling 2025-11-15 23:02:12 +00:00
Ross 478dcb7217 Add success URL redirection for CaseCollectionCreate after successful creation 2025-11-15 22:47:37 +00:00
Ross a1aba7836a Fix link to series detail page in series viewer template 2025-11-14 22:11:47 +00:00
Ross 80b1ef3817 Enhance series case addition: update details element for better accessibility and close functionality 2025-11-14 21:56:40 +00:00
Ross 835d0382ba Refactor series cases handling: extract cases list to a partial and enhance HTMX integration for dynamic updates 2025-11-14 21:55:28 +00:00
Ross 8096a36a11 Reorganize finding form placement in series viewer for improved layout 2025-11-14 21:52:37 +00:00
Ross 97d65f0caa Refactor series viewer template for improved layout and usability; enhance findings display and action buttons 2025-11-14 21:48:06 +00:00
Ross b73e89393b . 2025-11-14 21:21:45 +00:00
Ross ec036a6266 Add open_access field to Resource model for access control 2025-11-14 21:16:55 +00:00
Ross 14e6514b90 Update ResourceDelete view to require AuthorOrChecker permissions for deletion 2025-11-14 21:14:58 +00:00
Ross 639e91fc1e Add created_date and modified_date fields to Resource model; update resource detail and list templates for improved display 2025-11-14 21:12:03 +00:00
Ross 74765e70a2 Add created_date and modified_date fields to Resource model; enhance resource navigation with dropdown menu in base template 2025-11-14 21:01:25 +00:00
Ross ca2b7feb5b Add Resource filtering and table display functionality 2025-11-14 20:56:49 +00:00
Ross 453fbf91be Add ManyToMany fields for sites and subspecialty in Resource model 2025-11-14 20:53:31 +00:00
Ross a2e9d5e681 Refactor NormalCase model to use age_days instead of age_years, update forms, filters, and templates accordingly for improved age handling 2025-11-13 22:34:36 +00:00
Ross 8da2dd27da Enhance normals list display and update case state handling in views 2025-11-13 22:17:43 +00:00
Ross 970242e1fb Refactor NormalCaseForm to prevent nested forms and enhance logging in case_normal_form and create_case_normal views 2025-11-13 22:13:46 +00:00
Ross f8be1918f6 Fix indentation for the "Mark as normal" button in the normal toggle template 2025-11-13 22:09:05 +00:00
Ross 5ac998454b Add NormalCase form and related views for managing normal cases 2025-11-13 22:08:55 +00:00
Ross d259d0e8dd Enhance feedback display in exam review by italicizing and reducing opacity for better visual distinction 2025-11-13 22:07:40 +00:00
Ross 5fb3c98941 add a normals feature 2025-11-13 21:54:25 +00:00
Ross a70354c18d Refactor UserUserGroupForm to use a reusable UserSearchSelectMultipleWidget for improved user selection and search functionality 2025-11-13 21:48:58 +00:00
Ross 345977e2ef Add NormalCase model, views, filters, and templates for managing normal cases 2025-11-13 21:44:55 +00:00
Ross 726c7ea401 Enhance user search functionality with advanced search tips and help panel 2025-11-13 21:32:28 +00:00
Ross 7fa08037f0 Remove unnecessary comments from bulk add groups form template 2025-11-12 22:23:50 +00:00
Ross f786e88de9 Refactor bulk add groups form to use HTMX for submission and enhance button functionality 2025-11-12 22:15:10 +00:00
Ross 9441541feb Add grade display to selected users in UserUserGroupForm 2025-11-12 22:11:43 +00:00
Ross 5cca1b079a Fix syntax error in user group form and enhance logging in bulk add groups view 2025-11-12 22:06:09 +00:00
Ross 2e551d411a Add grade information to user search results and enhance user addition functionality 2025-11-12 21:59:20 +00:00
Ross d8055a509c Enhance user search widget with wildcard support and field-specific queries; improve logging in bulk add groups 2025-11-12 21:54:51 +00:00
Ross 208252a858 Add grade filter to user search widget and enhance logging for bulk add operations 2025-11-12 21:48:50 +00:00
Ross ea9dca5804 Implement user search widget with HTMX for dynamic user selection 2025-11-12 21:40:24 +00:00
Ross da04138861 Enhance bulk add groups form with crispy forms for improved styling and usability 2025-11-12 21:23:41 +00:00
Ross ec0245fe7c Add bulk add functionality for user and CID groups to exam collections 2025-11-12 21:19:09 +00:00
Ross c39d30c746 Refactor anatomy question answer form layout and improve styling for better usability 2025-11-11 21:48:01 +00:00
Ross d781508364 Update exam counts to use distinct counts for archived and non-archived exams 2025-11-11 21:31:07 +00:00
Ross 06ea87be14 Add shorts exam count to collection annotations and update display logic 2025-11-11 21:26:05 +00:00
Ross ce1b439681 Refactor exam counts annotation to respect archived status toggle 2025-11-11 21:09:00 +00:00
Ross c8375a28fa Add functionality to filter exam collections by archived status 2025-11-11 21:07:25 +00:00
Ross 6be39ab69d Add archived badge to exam list for better visibility of archived exams 2025-11-11 20:55:57 +00:00
Ross e9d0e402be Implement bulk archive functionality for exams; add HTMX endpoint and update UI controls for improved exam management 2025-11-11 20:52:44 +00:00
Ross dc23dffaf9 Remove instructional text for HTMX marking UI from Mark2 overview page for cleaner interface 2025-11-11 19:51:52 +00:00
Ross 87dde70fd3 Enhance AnatomyQuestion string representation; improve formatting and handle missing attributes for better readability 2025-11-11 14:04:46 +00:00
Ross c69eecc1ec Refactor mark2 overview page layout; enhance question display and button functionality for improved user interaction 2025-11-11 14:02:41 +00:00
Ross 6c149bb2ae Add mark2 overview page and update navigation for improved marking workflow 2025-11-11 13:56:11 +00:00
Ross 1232c3af32 Comment out suggestion section for incorrect answers in the marking template 2025-11-11 13:49:10 +00:00
Ross 05a2b2357c Enhance word suggestion functionality for incorrect answers; improve button layout and toggle behavior for better user experience. 2025-11-11 13:47:12 +00:00
Ross d88a626a76 Implement quick add functionality for incorrect answers; return collapsed partial for quick suggestions in the answers table. 2025-11-11 13:42:34 +00:00
Ross e4bab4a509 Add suggestion management for incorrect answers; implement collapsed view and HTMX support for dynamic updates 2025-11-11 13:36:55 +00:00
Ross 6e7135823a Implement suggestion feature for incorrect answers; add HTMX support for dynamic candidate rendering and inline editing. 2025-11-11 13:21:32 +00:00
Ross a158a6f928 Refactor question detail layout for improved readability; enhance card structure and styling for better user experience. 2025-11-11 13:05:31 +00:00
Ross b79d95cd70 Refactor layout of marked answers section for improved responsiveness; update column classes for better display on various screen sizes. 2025-11-11 13:03:44 +00:00
Ross 796ac3fb84 Implement edit answer functionality with HTMX support; add new endpoint and template for editing stored answers in the marking UI. 2025-11-11 13:01:51 +00:00
Ross 14a54dfa8b Refactor answer click handling to prevent duplicate event listeners on the new mark2 page; streamline state management for answer marking. 2025-11-11 12:49:13 +00:00
Ross ac6a8193a3 Refactor answer list item actions to use right-floating icons for improved accessibility and user interaction; streamline code for marked and unmarked answers. 2025-11-11 12:02:22 +00:00
Ross 6752f03bf2 Enhance marking UI with new navigation buttons and clipboard functionality; improve answer display with tooltips for clarity 2025-11-11 12:00:04 +00:00
Ross 34ef5106f4 Add new endpoints and templates for displaying marked answers in the improved marking UI 2025-11-11 11:53:35 +00:00
Ross 1997d013d8 Implement improved marking UI with new endpoints and templates for enhanced user experience 2025-11-11 11:34:05 +00:00
Ross 9a9ce11ccb . 2025-11-11 11:12:36 +00:00
Ross 9ebcd24938 Refactor image link paths in forms to ensure consistent static file referencing across anatomy, atlas, longs, rapids, and shorts templates. 2025-11-11 11:12:03 +00:00
Ross 70430b2593 Refactor mark view to improve question handling and user answer management, enhancing overall exam experience. 2025-11-10 22:35:24 +00:00
Ross e989a18d31 Enhance create_anatomy_questions management command with optional image generation and improved argument handling 2025-11-10 22:32:30 +00:00
Ross e39539fb99 Add management commands for creating anatomy questions with customizable options 2025-11-10 22:24:44 +00:00
Ross 44ca49899a Refactor button styles for previous and next actions to use secondary color, enhancing visual consistency across the exam interface. 2025-11-10 22:21:43 +00:00
Ross 4246713ee8 Refactor styles for physics finish list and overview question buttons to improve responsiveness and layout on various screen sizes. 2025-11-10 22:09:02 +00:00
Ross 5a4792ea43 Enhance exam take fragment to update global question header with current question number and stem, improving synchronization and user experience. 2025-11-10 22:04:46 +00:00
Ross 94d5771a26 Refactor exam overview to use anchor elements for questions, improving HTML validity and accessibility; enhance styles for better layout and responsiveness. 2025-11-10 22:01:15 +00:00
Ross 200a6e2a1c Enhance exam take functionality to support HX-Redirect for HTMX clients, ensuring full navigation experience 2025-11-10 21:58:12 +00:00
Ross 2ff857621a Refactor exam take functionality to redirect to full overview page instead of returning HTMX fragment 2025-11-10 21:56:41 +00:00
Ross 52c4f27e2b Add HTMX support for exam overview fragment to enhance user experience 2025-11-10 21:52:46 +00:00
Ross c1eff140f8 Add global listener for HTMX save confirmations and trigger event 2025-11-10 21:46:16 +00:00
Ross dce72f9abe Enhance exam take functionality to support HTMX save action and partial HTML responses 2025-11-10 21:43:19 +00:00
Ross 738752c030 Enhance exam take functionality to support HTMX for dynamic question loading and navigation 2025-11-10 21:39:36 +00:00
Ross 41fc7050ad Refactor exam take functionality to support HTMX for dynamic question loading and navigation 2025-11-10 21:30:45 +00:00
Ross 6eec40c43b Style: Enhance overview button appearance and add hover effects for improved user experience 2025-11-10 21:25:47 +00:00
Ross 7782ac55ca Refactor CSS styles for exam layout to improve responsiveness and alignment of question inputs and labels 2025-11-10 21:22:23 +00:00
Ross 9cf7ad6da7 Refactor exam_take function to use a canonical list of questions for consistent indexing and improved error handling for question navigation 2025-11-10 21:18:58 +00:00
Ross 922ca5053f Refactor exam_take function to use a canonical list of questions for consistent indexing and improved error handling for question navigation 2025-11-10 21:01:02 +00:00
Ross 10739f517f Add command to generate sample physics questions with customizable options 2025-11-10 20:56:18 +00:00
Ross eebbe8b759 Enhance question feedback in exam review by adding answers and feedback details for each part 2025-11-10 13:17:19 +00:00
Ross d176288607 Refactor response badges in exam review to use border colors based on correctness percentage for improved visual feedback 2025-11-10 13:12:20 +00:00
Ross bd71c669f0 Refactor exam review response handling to include structured items with optional per-part breakdown for multi-part answers 2025-11-10 13:08:24 +00:00
Ross f3a0a9fd8a Add per-part true/false breakdown to exam review summary for enhanced clarity 2025-11-10 13:05:04 +00:00
Ross 66899b54bd Enhance exam review question fragment to display parts and responses with HTMX support for improved interactivity and user experience 2025-11-10 13:00:47 +00:00
Ross 24c35148b0 Refactor physics exam review scoring to count true/false sub-questions individually and improve accuracy of correct answer calculations 2025-11-10 12:54:29 +00:00
Ross bece7f6ce8 Enhance answer aggregation logic in exam review to handle diverse answer formats and improve robustness 2025-11-10 12:51:55 +00:00
Ross bd81fdcf41 Add exam review question responses and summary views with HTMX support 2025-11-10 12:49:10 +00:00
Ross eaf8a38fe2 Enhance exam review display to show correct answers and update result color descriptions for clarity 2025-11-10 12:41:38 +00:00
Ross 744f2c7600 Add primary answer display to exam review question fragment and ensure safe rendering of question text 2025-11-10 12:37:13 +00:00
Ross e7c15a75a0 Enhance exam review overview page to display per-question summaries with response metrics and color coding for quick triage 2025-11-10 12:26:12 +00:00
Ross cf09449d16 . 2025-11-10 12:23:51 +00:00
Ross 0acc57302f Enhance initAnatomyFragment to prevent double initialization and improve HTMX swap handling 2025-11-10 12:17:53 +00:00
Ross ed88f794f8 Implement initAnatomyFragment function for HTMX fragment swaps to initialize DICOM viewers 2025-11-10 12:15:15 +00:00
Ross 0acc2da374 Add HTMX event handler for DICOM image setup on fragment load 2025-11-10 12:12:49 +00:00
Ross 21a7383c4f Enhance loading indicators for response summary and responses in exam review fragment 2025-11-10 12:11:19 +00:00
Ross 8e038d958b Fix data-url reference in anatomy DICOM image elements for consistent image loading 2025-11-10 12:06:19 +00:00
Ross ad7f8a68e1 Fix element ID reference in getJsonToolStateNoId function for DICOM image handling 2025-11-10 12:02:43 +00:00
Ross 0c5d8bebd6 Refactor setUpDicomLegacy and loadJsonToolStateOnCurrentImage functions for improved annotation handling 2025-11-10 12:00:15 +00:00
Ross 477276c4c5 Fix annotation handling in setUpDicomLegacy function to support multiple annotations 2025-11-10 11:57:27 +00:00
Ross 697191dae9 Add annotation loading in setUpDicomLegacy function 2025-11-10 11:56:04 +00:00
Ross c2ccf02d3e Add annotation data extraction in setUpDicomLegacy function 2025-11-10 11:54:06 +00:00
Ross 1b6757bff2 Update DICOM image element ID for consistency and add CSS styles for anatomy DICOM image 2025-11-10 11:48:01 +00:00
Ross 02284be1a2 Update DICOM image element ID and CSS class for legacy support 2025-11-10 11:46:20 +00:00
Ross 6d03e81e77 Add legacy DICOM viewer setup and update HTML element ID for legacy images 2025-11-10 11:43:31 +00:00
Ross 65a68b6605 Add console log for single DICOM viewer load and update HTML element ID 2025-11-10 11:39:17 +00:00
Ross 2a07843a6b Refactor DICOM 3D setup to clear element before 2D loader fallback and comment out fixed height in CSS 2025-11-10 11:25:08 +00:00
Ross 72c20c13e0 Add fallback to 2D loader in DICOM 3D setup on viewport enable failure 2025-11-10 11:18:20 +00:00
Ross 4d97e9e881 Enhance DICOM 3D loader to fallback to 2D loader for standard web images and handle 'https' URLs 2025-11-10 11:15:52 +00:00
Ross 0be605b41d Enhance DICOM image URL handling to support standard web images without forcing wadouri scheme 2025-11-10 11:11:00 +00:00
Ross b467407e19 Optimize CID lookups to reduce N+1 query pattern in exam review question responses 2025-11-10 11:07:56 +00:00
Ross fdc60bf2e9 Refactor score count variable names for clarity in exam review question summary logic 2025-11-10 11:06:55 +00:00
Ross 486a897e64 Refactor DICOM image div indentation for improved readability in exam review template 2025-11-10 11:00:54 +00:00
Ross fee123741d Add get_image_url method to AnatomyQuestion and update DICOM image URL in exam review template 2025-11-10 10:56:07 +00:00
Ross 367cebb787 Fix DICOM image display by correcting indentation in exam review question fragment 2025-11-10 10:54:38 +00:00
Ross b3b6ce7e6a Refactor exam review question display to show question type instead of question text for improved clarity 2025-11-10 10:53:46 +00:00
Ross 1010f17e3d Enhance exam review responses to include sensible display names for respondents, improving clarity and context in the review process. 2025-11-10 10:52:43 +00:00
Ross 72b7940a8c Refactor show respondents buttons for improved readability and maintainability 2025-11-10 10:50:02 +00:00
Ross cc7a334cb7 Refactor show respondents buttons to improve HTMX integration and add fallback for non-HTMX environments 2025-11-10 10:48:31 +00:00
Ross 9c4eaceda0 Refactor exam review templates to enhance the display of marked answers and top submitted answers with collapsible sections and improved styling 2025-11-10 10:44:39 +00:00
Ross 2aeb1947af Enhance top submitted answers display by adding gap between elements and updating submission count styling 2025-11-10 10:40:18 +00:00
Ross ecf67e3c2b Enhance exam review summary to include dominant score for top submitted answers 2025-11-10 10:38:13 +00:00
Ross d0609e4f8e Remove duplicate question card and associated navigation buttons from exam review template 2025-11-10 10:34:19 +00:00
Ross f4878a58ac Add option to reveal respondent identities in exam review responses 2025-11-10 10:26:38 +00:00
Ross 0d4d4f1dc0 Add option to reveal respondent identities in exam review responses 2025-11-10 10:16:11 +00:00
Ross 3a40a5087d Add management command to normalize Question HTML fields by unwrapping single <p> tags 2025-11-10 10:11:05 +00:00
Ross 921cc53667 Refactor exam review question summary layout for improved alignment and text wrapping 2025-11-10 10:01:58 +00:00
Ross 53ff56edc0 Add clean method to QuestionForm to unwrap single <p> tags from TinyMCE HTML fields 2025-11-10 10:00:42 +00:00
Ross e078b5a98f Refactor exam review templates and views to improve response summary display and handle unanswered responses 2025-11-10 09:58:02 +00:00
Ross 3d6ca8be54 Disable TinyMCE branding badge in editor configuration 2025-11-10 09:50:04 +00:00
Ross e37a7e0b74 Allow superusers to bypass open access check in question detail view 2025-11-10 09:16:10 +00:00
Ross 390f6f098d Enhance exam review question fragment by ensuring safe rendering of question and answers, improving layout for answer feedback, and refining button alignment for better UI consistency. 2025-11-10 09:14:17 +00:00
833 changed files with 142129 additions and 9097 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
+30
View File
@@ -0,0 +1,30 @@
# Django
DEBUG=1
SECRET_KEY=w(s0&(_eb058wvmg@44_repv8)r9@5p8fx*g_@c)1dm&d*ew^u
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
# External DB (you mentioned production uses an external DB)
DB_HOST=db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com
DB_PORT=25060
DB_NAME=testing
DB_USER=django
DB_PASSWORD=AVNS_NG_s4i7SMMobWLO
# Redis
REDIS_URL=redis://redis:6379/0
# Gunicorn tuning
GUNICORN_WORKERS=3
GUNICORN_LOGLEVEL=info
# Nginx host ports for local development (override prod defaults)
NGINX_HTTP_PORT=8080
# Pick a non-privileged HTTPS port to avoid conflicts with host installs
NGINX_HTTPS_PORT=8444
# Use Django's file-based email backend for local development so messages
# are written to files under the mounted `./logs/emails` directory and are
# easy to inspect on the host.
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
#EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
EMAIL_FILE_PATH=/var/log/rad/emails
+20
View File
@@ -0,0 +1,20 @@
# Copy this to .env.prod on the server and fill in production values (do NOT commit secrets).
# Django
SECRET_KEY=replace-me-with-a-secure-random-string
DEBUG=0
ALLOWED_HOSTS=example.com
# External DB (you mentioned production uses an external DB)
DATABASE_HOST=your.db.host
DATABASE_PORT=5432
DATABASE_NAME=rad
DATABASE_USER=django
DATABASE_PASSWORD=strong-db-password
# Redis
REDIS_URL=redis://redis:6379/0
# Gunicorn tuning
GUNICORN_WORKERS=3
GUNICORN_LOGLEVEL=info
+15 -1
View File
@@ -1,4 +1,5 @@
media/
mediafiles/
__pycache__
.vscode/
@@ -9,5 +10,18 @@ venv
.env
*.log
log.txt
temp/
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
+117
View File
@@ -0,0 +1,117 @@
README
======
Quick notes for running this repository (development & observability)
Running locally (development)
- Use the included compose files. To run the dev stack (overrides) set `COMPOSE_ENV=dev` so the correct env file is loaded:
```sh
# run nginx on non-privileged ports (see .env.dev)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
```
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`
Logging & observability (Loki + Promtail + Grafana)
- Promtail is configured to scrape `/var/log/rad/*.log` and push to Loki. Promtail's config is at `docker/promtail-config.yml` and the Loki config is at `docker/loki-config/local-config.yaml`.
- The nginx configs (both `deploy/nginx/prod.conf` and `deploy/nginx/dev.conf`) are configured to write access and error logs to `/var/log/rad/nginx.access.log` and `/var/log/rad/nginx.error.log` respectively.
- The compose files mount a host `logs/` folder into `/var/log/rad` so both nginx (write) and promtail (read) can access the same files:
- `../logs:/var/log/rad:rw` (nginx)
- `../logs:/var/log/rad:ro` (promtail)
Host setup for logs
- Create the host logs folder at the repo root (this repo's `logs/`). In development you can use permissive permissions so containers can write to it quickly:
```sh
mkdir -p logs
# development: make writable by all (change this for production)
sudo chown $USER:$USER logs
chmod 0777 logs
```
- In production prefer setting the folder owner to the nginx worker UID (or run nginx under a specific user) and use `0755` or `0750` as appropriate. Example:
```sh
# run on the host (determine nginx UID as needed)
sudo chown -R 101:101 /srv/rad/logs
sudo chmod 0755 /srv/rad/logs
```
Notes & recommendations
- In development the `logs/` folder is ignored by git (`/.gitignore` updated). Do not commit runtime logs.
- Consider a log rotation/retention policy for production (e.g., `logrotate` or use Loki retention policies).
- If you need nginx to re-resolve the backend service IP without restarting, consider using `resolver` and variable-based upstreams in the nginx config. For many development workflows restarting nginx after web restarts is the simplest approach.
If you want, I can also add a short `docs/OBSERVABILITY.md` with more details and recommended production settings (TLS/auth for Loki, retention, log rotation).
## 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).
+15 -16
View File
@@ -19,51 +19,50 @@ from django.contrib.auth import get_user_model
router = Router()
class CidUserSchema(ModelSchema):
class Config:
class Meta:
model = CidUser
model_fields = ["cid", "name", "email"]
fields = ["cid", "name", "email"]
class UserUserSchema(ModelSchema):
class Config:
class Meta:
model = get_user_model()
model_fields = ["id", "email"]
fields = ["id", "email"]
class CidUserExamSchema(ModelSchema):
cid_user : CidUserSchema | None
user_user : UserUserSchema | None
class Config:
class Meta:
model = CidUserExam
model_fields = ["id"]
fields = ["id"]
class ExamStatusSchema(ModelSchema):
class Config:
class Meta:
model = ExamUserStatus
model_fields = ["id", "datetime", "status", "extra"]
fields = ["id", "datetime", "status", "extra"]
class ExamUserStatusSchema(ModelSchema):
#cid_user : CidUserSchema | None
#user_user : UserUserSchema | None
cid_user_exam : CidUserExamSchema | None
class Config:
class Meta:
model = ExamUserStatus
model_fields = ["id", "datetime", "status", "extra"]
fields = ["id", "datetime", "status", "extra"]
class QuestionSchema(ModelSchema):
class Config:
class Meta:
model = Question
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
model_fields = "__all__"
fields = "__all__"
#model_exclude = ["answers"]
#exclude = ["answers"]
class ExamSchema(ModelSchema):
class Config:
class Meta:
model = Exam
model_fields = ["id", "name", "active", "publish_results"]
fields = ["id", "name", "active", "publish_results"]
@router.get('/')
def list_questions(request):
+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
+51 -3
View File
@@ -34,7 +34,9 @@ from dal import autocomplete
from tinymce.widgets import TinyMCE
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from crispy_forms.layout import Submit, Layout, Div
from crispy_forms.bootstrap import Accordion, AccordionGroup
from generic.widgets import ExamSearchWidget
class AnatomyAnswerForm(ModelForm):
class Meta:
@@ -127,7 +129,38 @@ class AnatomyQuestionForm(ModelForm):
self.fields["exams"] = ModelMultipleChoiceField(
required=False,
queryset=exam_queryset,
widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False),
widget=ExamSearchWidget(exam_model=Exam),
)
# Now that all fields have been created, set the helper layout so
# Crispy can find the referenced field names (including
# `answer_suggest_incorrect`). Placing the layout here ensures the
# collapse/accordion renders server-side.
self.helper.layout = Layout(
"question_type",
"image",
"description",
# Place modality → body_part fields in a horizontal flex row
Div(
Div("modality", css_class="flex-fill me-2"),
Div("region", css_class="flex-fill me-2"),
Div("structure", css_class="flex-fill me-2"),
Div("examination", css_class="flex-fill me-2"),
Div("body_part", css_class="flex-fill"),
css_class="d-flex flex-wrap align-items-start mb-2",
),
"answer_help",
Accordion(
AccordionGroup(
"Suggest incorrect answers",
"answer_suggest_incorrect",
active=False,
)
),
"feedback",
"open_access",
"exams",
Div(Submit("submit", "Submit"), css_class="form-group"),
)
def save(self, commit=True):
@@ -257,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
+1
View File
@@ -0,0 +1 @@
"""Management package for anatomy app."""
+1
View File
@@ -0,0 +1 @@
"""Management commands package for anatomy app."""
@@ -0,0 +1,180 @@
from __future__ import annotations
import os
import io
from uuid import uuid4
from typing import List, Tuple
from django.core.management.base import BaseCommand, CommandError
from django.core.files import File
from django.contrib.auth import get_user_model
from PIL import Image
from anatomy.models import AnatomyQuestion, Exam, ExamQuestionDetail, QuestionType, Structure
from generic.models import Modality as GenericModality # type: ignore
class Command(BaseCommand):
help = (
"Create anatomy questions and assign them to an exam."
"Requires --exam-id and --image-path (path readable inside the container)."
)
def add_arguments(self, parser):
parser.add_argument("--exam-id", type=int, required=False, help="ID of the anatomy.Exam to assign questions to (if omitted a new exam will be created)")
parser.add_argument("--exam-name", type=str, default=None, help="Name to use when creating a new exam if --exam-id omitted")
parser.add_argument("--count", type=int, default=1, help="Number of questions to create")
parser.add_argument("--image-path", type=str, required=False, help="Path to image file to use for each created question")
parser.add_argument("--generate-image", action="store_true", help="Generate a placeholder image automatically if --image-path is not provided or not found")
parser.add_argument("--image-size", type=str, default="800x600", help="Generated image size WIDTHxHEIGHT, e.g. 800x600")
parser.add_argument("--image-color", type=str, default="#777777", help="Background color for generated image (hex) e.g. #ffffff")
parser.add_argument("--description", type=str, default="Generated question {i}", help="Description template; use {i} for index")
parser.add_argument("--question-type-id", type=int, default=None, help="Optional QuestionType id to assign")
parser.add_argument("--modality-id", type=int, default=None, help="Optional Modality id to assign (generic.Modality id)")
parser.add_argument("--structure-id", type=int, default=None, help="Optional Structure id to assign")
parser.add_argument("--author-id", type=int, action="append", help="Author user id; repeatable")
parser.add_argument("--start-sort", type=int, default=1, help="Starting sort_order for the first question")
def handle(self, *args, **options):
exam_id = options.get("exam_id")
count = options["count"]
image_path = options.get("image_path")
generate_image = options.get("generate_image")
image_size_str = options.get("image_size") or "800x600"
image_color = options.get("image_color") or "#777777"
description_template = options["description"]
qtype_id = options.get("question_type_id")
modality_id = options.get("modality_id")
structure_id = options.get("structure_id")
author_ids: List[int] = options.get("author_id") or []
sort = options.get("start_sort") or 1
exam = None
if exam_id:
try:
exam = Exam.objects.get(pk=exam_id)
except Exam.DoesNotExist:
raise CommandError(f"Exam with id {exam_id} does not exist")
else:
# create new exam
exam_name = options.get("exam_name") or f"Auto Anatomy Exam {uuid4().hex[:8]}"
exam = Exam(name=exam_name, active=True, open_access=True)
exam.save()
self.stdout.write(self.style.SUCCESS(f"Created exam id={exam.id} name='{exam.name}'"))
# validate image path if provided
if image_path and not os.path.exists(image_path):
if not generate_image:
raise CommandError(f"Image file not found at path: {image_path}")
else:
image_path = None
# Resolve optional foreign keys
qtype = None
if qtype_id:
qtype = QuestionType.objects.filter(pk=qtype_id).first()
if not qtype:
raise CommandError(f"QuestionType id {qtype_id} not found")
modality = None
if modality_id:
# The project stores Modality in generic.models.Modality
try:
from generic.models import Modality as GModality
modality = GModality.objects.filter(pk=modality_id).first()
if not modality:
raise CommandError(f"Modality id {modality_id} not found in generic.Modality")
except Exception as e:
raise CommandError(str(e))
structure = None
if structure_id:
from atlas.models import Structure as AtlasStructure
structure = AtlasStructure.objects.filter(pk=structure_id).first()
if not structure:
raise CommandError(f"Structure id {structure_id} not found")
User = get_user_model()
authors = []
for uid in author_ids:
u = User.objects.filter(pk=uid).first()
if not u:
raise CommandError(f"Author user id {uid} not found")
authors.append(u)
# parse image size
try:
w, h = image_size_str.lower().split("x")
image_size: Tuple[int, int] = (int(w), int(h))
except Exception:
raise CommandError("Invalid --image-size; expected WIDTHxHEIGHT, e.g. 800x600")
created = []
for i in range(1, count + 1):
desc = description_template.format(i=i)
# determine image source: either provided path or generated image
unique_name = f"{uuid4().hex}_image.jpg"
if image_path:
# use provided file
with open(image_path, "rb") as f:
django_file = File(f, name=unique_name)
img_file_for_save = django_file
# create and save question
q = AnatomyQuestion(
question_type=qtype,
description=desc,
answer_help="",
)
if modality:
q.modality = modality
if structure:
q.structure = structure
q.image.save(unique_name, img_file_for_save, save=False)
q.save()
else:
# generate an image in memory
buf = io.BytesIO()
img = Image.new("RGB", image_size, image_color)
img.save(buf, format="JPEG")
buf.seek(0)
django_file = File(buf, name=unique_name)
q = AnatomyQuestion(
question_type=qtype,
description=desc,
answer_help="",
)
if modality:
q.modality = modality
if structure:
q.structure = structure
q.image.save(unique_name, django_file, save=False)
q.save()
# authors
if authors:
q.author.set(authors)
# add to exam via the many-to-many through relationship
try:
exam.exam_questions.add(q, through_defaults={"sort_order": sort})
except TypeError:
ExamQuestionDetail.objects.create(exam=exam, anatomyquestion=q, sort_order=sort)
self.stdout.write(self.style.SUCCESS(f"Created question id={q.id} and added to exam id={exam.id} with sort_order={sort}"))
created.append(q)
sort += 1
self.stdout.write(self.style.SUCCESS(f"Finished: created {len(created)} question(s)"))
@@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2026-01-26 11:05
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0024_exam_results_supervisor_visible'),
]
operations = [
migrations.AddField(
model_name='exam',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
preserve_default=False,
),
migrations.AddField(
model_name='exam',
name='updated',
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
),
]
@@ -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),
),
]
+103 -69
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 = ()
@@ -132,14 +143,16 @@ class AnatomyQuestion(QuestionBase):
return "anatomy"
def __str__(self):
# Get first answer
return "{}/{}: {} [{}, {}]".format(
self.pk,
self.question_type,
self.get_primary_answer(),
self.modality,
self.structure,
)
# Nicely formatted representation
pk = self.pk or ""
qtype = str(self.question_type) if self.question_type else "N/A"
primary = self.get_primary_answer() or "None"
modality = str(self.modality) if self.modality else "Unknown"
structure = str(self.structure) if self.structure else "Unknown"
title = self.get_title() if hasattr(self, "get_title") else (self.description or "")
if title and len(title) > 60:
title = title[:57] + "..."
return f"{title} (Type: {qtype}) — Answer: {primary} — Modality: {modality}; Structure: {structure}"
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self)
@@ -147,48 +160,50 @@ 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)
exams = ", ".join(e)
return exams
def get_image_url(self):
return "{}/{}".format(settings.REMOTE_URL, self.image.url)
def get_unmarked_user_answer_string(self, exam_pk=None):
unmarked_answers = self.get_unmarked_user_answers(exam_pk)
if not unmarked_answers:
return "No answers to mark"
return format_html(
"<span class='warn'>{} answer unmarked:</span> {}".format(
len(unmarked_answers), ", ".join(unmarked_answers)
)
"<span class='warn'>{} answer unmarked:</span> {}",
len(unmarked_answers),
", ".join(unmarked_answers),
)
def get_unmarked_user_answers(self, exam_pk: int|None = None):
@@ -323,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:
@@ -536,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)
@@ -554,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
@@ -585,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
+357 -177
View File
@@ -16,8 +16,15 @@ $(document).ready(function () {
clearableTextAreaSetup();
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.";
@@ -42,24 +49,27 @@ $(document).ready(function () {
// $(element).append($("<span class='google-link' title='search for answer with google'><a href='https://www.google.com/search?q=" + $(element).text() + "' target='_blank'>G</a></span>"));
//});
$(".answer-list span.answer").each(function (index, element) {
console.log(element);
// Legacy per-element click handler: skip on the new mark2 page which
// manages clicks via a delegated handler (it uses #answer-list). This
// prevents duplicate/colliding handlers when mark2 is rendered.
if (!document.getElementById('answer-list')) {
$(".answer-list span.answer").each(function (index, element) {
console.log(element);
$(element).click(function (e) {
$(element).click(function (e) {
var classes = ['answer correct', 'answer half-correct', 'answer incorrect'];
$(element).each(function () {
let mark = classes[($.inArray(this.className, classes) + 1) % classes.length];
this.className = mark
this.dataset.newmark = mark.split(" ")[1]
});
prepAnswerData();
var classes = ['answer correct', 'answer half-correct', 'answer incorrect'];
$(element).each(function () {
let mark = classes[($.inArray(this.className, classes) + 1) % classes.length];
this.className = mark
this.dataset.newmark = mark.split(" ")[1]
});
prepAnswerData();
});
});
}
prepAnswerData();
if ($(".post-form").length > 0) {
@@ -80,6 +90,14 @@ $(document).ready(function () {
}
}
let dicom_images_legacy = document.getElementsByClassName("dicom-image-legacy");
if (dicom_images_legacy.length) {
for (let element of dicom_images_legacy) {
setUpDicomLegacy(element)
}
}
loadDicomViewer();
@@ -194,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);
@@ -202,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 = () => {
@@ -232,9 +258,16 @@ 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) {
console.log("Load single dicom viewer");
$(single_dicom).empty()
let images = single_dicom.dataset.images;
@@ -307,8 +340,8 @@ function prepAnswerData() {
$("#id_marked_answers").val(JSON.stringify(window.marked_answers));
}
function loadJsonToolStateOnCurrentImage(json) {
let el = document.getElementById("dicom-image");
function loadJsonToolStateOnCurrentImage(element, json) {
let el = element;
const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
@@ -330,160 +363,195 @@ function loadJsonToolStateOnCurrentImage(json) {
}
//async function setUpDicom(element) {
// console.log("setUpDicom (original)", element);
//
// $(element).bind('contextmenu', function (e) {
// return false;
// });
//
// $(element).dblclick((evt) => {
// element.requestFullscreen();
// })
//
// cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
// cornerstoneWebImageLoader.external.cornerstone = cornerstone;
// cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
//
// cornerstoneTools.init();
// const PanTool = cornerstoneTools.PanTool;
// const ZoomTool = cornerstoneTools.ZoomTool;
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
// const WwwcTool = cornerstoneTools.WwwcTool;
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
// const RotateTool = cornerstoneTools.RotateTool;
// const StackScrollTool = cornerstoneTools.StackScrollTool;
// const MagnifyTool = cornerstoneTools.MagnifyTool;
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
//
//
// const images = element.dataset.url.split(",");
//
// //console.log("Dicom - load imageId: ", imageIds);
// //for (let index = 0; index < imageIds.length; index++) {
// // if (imageIds[index].endsWith("dcm")) {
// // imageIds[index] = "wadouri:" + imageIds[index];
// // }
//
// //}
// let imageIds = [];
// for (let i = 0; i < images.length; i++) {
// let data_url = images[i];
// // check stack type
// if (data_url.startsWith("data:image")) {
// let imageId = "base64://" + data_url.split(",")[1];
//
// imageIds.push(imageId);
// } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
//
// imageIds.push(data_url);
//
// // Treat application/octet-stream as if they are dicoms
// } else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
// //stack = stack.split(";")[1];
//
// let dfile = await urltoFile(data_url, "dicom", "application/dicom");
//
// const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
// dfile
// );
//
// loadAnnotation(imageId, annotation);
//
// imageIds.push(imageId);
// //cornerstone.loadImage(imageId).then(function(image) {
// // tempFunction(image);
// //});
// } else {
// let url;
// // This doesn't seem to have any benefit
// //if (data_url.startsWith("http")) {
// // url = data_url;
// //} else {
// // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
// //}
// url = data_url;
//
// if (url.endsWith("dcm")) {
// url = "wadouri:" + url;
// }
//
// // if there is no extension treat it as a dicom
// if (/(?:\/|^)[^.\/]+$/.test(url)) {
// url = "wadouri:" + url;
// }
//
// imageIds.push(url);
//
//
// }
// }
//
// cornerstone.enable(element);
// cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
// cornerstone.displayImage(element, image);
//
// cornerstoneTools.addToolForElement(element, PanTool);
// cornerstoneTools.addToolForElement(element, ZoomTool);
// cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
// cornerstoneTools.addToolForElement(element, WwwcTool);
// cornerstoneTools.addToolForElement(element, WwwcRegionTool);
// cornerstoneTools.addToolForElement(element, RotateTool);
// cornerstoneTools.addToolForElement(element, StackScrollTool);
// cornerstoneTools.addToolForElement(element, MagnifyTool);
//
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
// configuration: {
// getTextCallback: () => {},
// changeTextCallback: () => {},
// allowEmptyLabel: true,
// renderDashed: false,
// drawHandles: false,
// drawHandlesOnHover: true,
// },
// });
//
//
//
// // Enable our tools
// // Avoid incorrect aspect ratio
// cornerstoneTools.setToolActiveForElement(element, "Pan", {
// mouseButtonMask: 1
// });
// cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
// mouseButtonMask: 2
// });
// cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
// mouseButtonMask: 3
// });
// cornerstoneTools.setToolActiveForElement(element, "Zoom", {
// mouseButtonMask: 4
// });
//
// if (element.dataset.edit_annotation == "true") {
// cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
// mouseButtonMask: 2
// });
// } else {
// cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
// }
//
// if (element.dataset.annotations) {
// loadJsonToolStateOnCurrentImage(element.dataset.annotations)
// }
//
// cornerstone.resize(element);
//
//
// }).catch((err, err2) => {
// console.log(err);
// });
//
//}
//console.log("setUpDicom defined");
//
//// keep reference to the existing 2D setup
//window.setUpDicomClassic = setUpDicom;
// Called by HTMX after a fragment swap; initializes any dicom-image-legacy viewers
window.initAnatomyFragment = function(fragmentRoot) {
console.log("initAnatomyFragment", fragmentRoot);
try {
const root = fragmentRoot && fragmentRoot.nodeType ? fragmentRoot : (fragmentRoot && fragmentRoot.detail && fragmentRoot.detail.target) ? fragmentRoot.detail.target : document;
const imgs = (root && root.querySelectorAll) ? root.querySelectorAll('.dicom-image-legacy') : [];
imgs.forEach((el) => {
try {
// Avoid double initialization
if (el.dataset && (el.dataset.dicomInitialised === '1' || el.dataset.dicomInitialised === 'true')) return;
if (typeof window.setUpDicomLegacy === 'function') {
window.setUpDicomLegacy(el);
if (el.dataset) el.dataset.dicomInitialised = '1';
}
} catch (err) {
console.warn('initAnatomyFragment: setUpDicomLegacy failed for element', el, err);
}
});
} catch (err) {
console.warn('initAnatomyFragment error', err);
}
}
// Global fallback: listen for HTMX swaps and initialise any viewers inside
document.addEventListener('htmx:afterSwap', function (e) {
try {
const root = e && e.target ? e.target : (e && e.detail && e.detail.target ? e.detail.target : document);
if (window.initAnatomyFragment) window.initAnatomyFragment(root);
} catch (err) {
console.warn('htmx afterSwap listener error', err);
}
});
async function setUpDicomLegacy(element) {
console.log("setUpDicom (original)", element);
$(element).bind('contextmenu', function (e) {
return false;
});
$(element).dblclick((evt) => {
element.requestFullscreen();
})
cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
cornerstoneWebImageLoader.external.cornerstone = cornerstone;
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneTools.init();
const PanTool = cornerstoneTools.PanTool;
const ZoomTool = cornerstoneTools.ZoomTool;
const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
const WwwcTool = cornerstoneTools.WwwcTool;
const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
const RotateTool = cornerstoneTools.RotateTool;
const StackScrollTool = cornerstoneTools.StackScrollTool;
const MagnifyTool = cornerstoneTools.MagnifyTool;
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
const images = element.dataset.url.split(",");
const annotation = element.dataset.annotations;
//console.log("Dicom - load imageId: ", imageIds);
//for (let index = 0; index < imageIds.length; index++) {
// if (imageIds[index].endsWith("dcm")) {
// imageIds[index] = "wadouri:" + imageIds[index];
// }
//}
let imageIds = [];
for (let i = 0; i < images.length; i++) {
let data_url = images[i];
// check stack type
if (data_url.startsWith("data:image")) {
let imageId = "base64://" + data_url.split(",")[1];
imageIds.push(imageId);
} else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
imageIds.push(data_url);
// Treat application/octet-stream as if they are dicoms
} else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
//stack = stack.split(";")[1];
let dfile = await urltoFile(data_url, "dicom", "application/dicom");
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
dfile
);
loadAnnotation(imageId, annotation);
imageIds.push(imageId);
//cornerstone.loadImage(imageId).then(function(image) {
// tempFunction(image);
//});
} else {
let url;
// This doesn't seem to have any benefit
//if (data_url.startsWith("http")) {
// url = data_url;
//} else {
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
//}
url = data_url;
if (url.endsWith("dcm")) {
url = "wadouri:" + url;
}
// if there is no extension treat it as a dicom
if (/(?:\/|^)[^.\/]+$/.test(url)) {
url = "wadouri:" + url;
}
imageIds.push(url);
}
}
cornerstone.enable(element);
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
cornerstone.displayImage(element, image);
cornerstoneTools.addToolForElement(element, PanTool);
cornerstoneTools.addToolForElement(element, ZoomTool);
cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
cornerstoneTools.addToolForElement(element, WwwcTool);
cornerstoneTools.addToolForElement(element, WwwcRegionTool);
cornerstoneTools.addToolForElement(element, RotateTool);
cornerstoneTools.addToolForElement(element, StackScrollTool);
cornerstoneTools.addToolForElement(element, MagnifyTool);
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
configuration: {
getTextCallback: () => {},
changeTextCallback: () => {},
allowEmptyLabel: true,
renderDashed: false,
drawHandles: false,
drawHandlesOnHover: true,
},
});
// Enable our tools
// Avoid incorrect aspect ratio
cornerstoneTools.setToolActiveForElement(element, "Pan", {
mouseButtonMask: 1
});
cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
mouseButtonMask: 2
});
cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
mouseButtonMask: 3
});
cornerstoneTools.setToolActiveForElement(element, "Zoom", {
mouseButtonMask: 4
});
if (element.dataset.edit_annotation == "true") {
cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
mouseButtonMask: 2
});
} else {
cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
}
if (element.dataset.annotations) {
loadJsonToolStateOnCurrentImage(element, element.dataset.annotations)
}
cornerstone.resize(element);
}).catch((err, err2) => {
console.log(err);
});
}
console.log("setUpDicom defined");
// keep reference to the existing 2D setup
window.setUpDicomLegacy = setUpDicomLegacy;
async function setUpDicom3d(element) {
console.log("setUpDicom3d (cornerstone3d) for", element);
@@ -546,13 +614,28 @@ async function setUpDicom3d(element) {
console.log(renderingEngine)
const viewportInput = {
viewportId,
type: Enums.ViewportType.STACK,
element,
viewportId,
type: Enums.ViewportType.STACK,
element,
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
};
renderingEngine.enableElement(viewportInput);
try {
renderingEngine.enableElement(viewportInput);
} catch (err) {
// Some bundlings / integrations (dv3d wrapper) may reject the provided
// viewport type (e.g. "Viewport is not a valid type"). In that case
// fall back to the 2D cornerstone loader which is more permissive.
console.warn('renderingEngine.enableElement failed, falling back to 2D loader:', err);
try {
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err2) {
console.error('2D fallback after enableElement failure also failed:', err2);
throw err;
}
}
const viewport = renderingEngine.getViewport(viewportId);
@@ -597,10 +680,50 @@ async function setUpDicom3d(element) {
// Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:)
// but avoid forcing wadouri: for standard web images (png/jpg/etc) because
// the dicom parser will fail when given non-DICOM files. For such images
// return the raw URL so the appropriate web-image loader can handle them.
// If any image looks like a plain web image (png/jpg/etc) prefer the
// existing 2D cornerstone loader which handles http(s) image schemes.
// This avoids a situation where the 3D dicom loader has no handler for
// the 'https' scheme and throws "No image loader found for scheme 'https'".
const looksLikeWebImage = (u) => {
if (!u) return false;
try { u = u.trim(); } catch (e) {}
if (u.startsWith('data:image')) return true;
if (u.match(/\.(png|jpe?g|gif|bmp|webp)$/i)) return true;
return false;
};
if (images.some(looksLikeWebImage)) {
// Fallback: use the 2D cornerstone loader which has web-image handling
// and is more tolerant of raw http(s) URLs. We reuse the project's
// dicomViewer helper (it expects a jQuery element).
console.warn('setUpDicom3d: contains web-image(s) — falling back to 2D loader');
try {
// Remove any partially-created dv3d DOM and ensure the element is a
// clean container for the 2D viewer. This ensures the `.canvas-panel`
// appended by `loadCornerstone` overlays exactly the element area.
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
element.style.position = element.style.position || 'relative';
// annotations are not available in this scope in the 3d code path; pass
// undefined for annotations and let the loader handle it.
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
// store a lightweight marker so callers can inspect what happened
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err) {
console.warn('Fallback 2D loader failed; will continue to try 3D loader', err);
// fallthrough to attempt 3D loading below
}
}
const imageIds = images.map((url) => {
url = url.trim();
// if it's already a scheme we pass through; otherwise treat as wadouri
if (/^(wadouri:|base64:|data:)/i.test(url)) return url;
// if it's already a scheme we pass through
if (/^(wadouri:|base64:|data:|http:|https:)/i.test(url)) return url;
// default: treat as wadouri (likely a DICOM file)
return `wadouri:${url}`;
});
@@ -617,7 +740,23 @@ async function setUpDicom3d(element) {
}
}
await viewport.setStack(imageIds, 0);
try {
await viewport.setStack(imageIds, 0);
} catch (err) {
// If the 3D loader fails because it doesn't know how to load 'https'
// URLs, fallback to the 2D cornerstone loader which handles web images.
console.warn('viewport.setStack failed, attempting 2D fallback:', err);
try {
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
element.style.position = element.style.position || 'relative';
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err2) {
console.error('2D fallback also failed:', err2);
throw err; // rethrow original error
}
}
viewport.render();
@@ -738,3 +877,44 @@ function initializeClock(id, endtime) {
window.initializeClock = initializeClock
// Unwrap single-root <p> elements from TinyMCE content before any form submit.
// This prevents a single wrapping <p> from being sent to the server while
// preserving other markup. We use a delegated submit listener so it works
// for dynamically-added forms as well (e.g. admin inlines).
document.addEventListener('submit', function(evt) {
try {
var form = evt.target;
if (!form || !form.querySelector) return;
// Find all tinyMCE textareas inside the form
var areas = form.querySelectorAll('textarea.tinymce');
areas.forEach(function(area) {
var id = area.id;
if (!id) return;
var ed = (typeof tinyMCE !== 'undefined') ? tinyMCE.get(id) : null;
if (!ed) return;
var html = (ed.getContent && typeof ed.getContent === 'function') ? ed.getContent({format: 'html'}) : area.value;
if (!html) return;
// Use a temporary container to inspect top-level elements safely
var tmp = document.createElement('div');
tmp.innerHTML = html.trim();
// If there is exactly one top-level element and it is a <p>, unwrap it
if (tmp.childElementCount === 1 && tmp.firstElementChild && tmp.firstElementChild.tagName.toLowerCase() === 'p') {
var inner = tmp.firstElementChild.innerHTML;
// Update editor content and the underlying textarea value
try {
ed.setContent(inner);
} catch (e) {
// fallback: set textarea value directly
area.value = inner;
}
// Ensure the textarea value matches (some setups sync on save, some don't)
area.value = inner;
}
});
} catch (e) {
// be resilient; don't prevent form submission if something goes wrong here
console.error('TinyMCE submit postprocess error', e);
}
}, true);
+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,23 +228,30 @@
<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>
<p>Supported formats include JPEG, PNG, and GIF. It is also possible to use DICOM images.</p>
{% endhelp %}
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
{{ form|crispy }}
{% crispy form %}
<div id="drop-container" class="drop-target">Drop image here
<div id="drop-filenames"></div>
</div>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
{% endblock %}
@@ -28,32 +28,60 @@
{% if object %}
{% include 'anatomy/question_link_header.html' %}
{% endif %}
<h2>Question Answers</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)</p>
</details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<h3>Answers:</h3>
<div id="form_set">
{% for form in answer_formset %}
<ul class="no-error answer-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form.as_ul }}
</ul>
{% endfor %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="mb-0">Question Answers</h2>
<small class="text-muted">Manage answer keys for auto-marking</small>
</div>
<div class="card-body">
<details class="help-text mb-3">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p class="mb-0">This page can be used to add and modify answers associated with a question. Answers here are used for auto-marking submitted responses; avoid removing entries unless you intend to re-score submissions.</p>
</details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<h3 class="h5 mt-2">Answers</h3>
<div id="form_set" class="mt-3">
{% for form in answer_formset %}
<div class="answer-card mb-3 p-3 border rounded bg-body-secondary">
{{form.non_field_errors}}
{{form.errors}}
<div class="answer-fields">
{{ form.as_ul }}
</div>
</div>
{% endfor %}
</div>
{{ answer_formset.management_form }}
<div class="d-flex gap-2 align-items-center mt-3">
<button type="button" id="add_more" class="btn btn-outline-secondary btn-sm">Add another answer</button>
<input type="submit" class="submit-button btn btn-primary btn-sm ms-auto" value="Save answers" name="submit">
</div>
</form>
<div id="empty_form" class="d-none">
<div class='answer-card p-3 mb-3 border rounded bg-body-secondary'>
{{ answer_formset.empty_form.as_ul }}
</div>
</div>
</div>
{{ answer_formset.management_form }}
<input type="button" value="Add More Answers" id="add_more">
<p>
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
</p>
</form>
<div id="empty_form" style="display:none">
<ul class='no_error answer-formset'>
{{ answer_formset.empty_form.as_ul }}
</ul>
</div>
{% endblock %}
{% block css %}
<style>
/* Answer card layout */
.answer-card label { font-weight: 600; }
.answer-card li { list-style: none; margin-bottom: .5rem; }
.answer-card input[type="text"], .answer-card textarea, .answer-card select { width: 100%; }
.help-text summary { cursor: pointer; font-weight:600; }
/* Make the Add button more prominent on small screens */
@media (max-width: 576px) {
.submit-button { width: 100%; }
}
</style>
{% endblock %}
+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 %}
+152 -68
View File
@@ -1,81 +1,165 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="question-display-block">
<div class="hide-show">HIDE/SHOW</div>
<div class="inner-display-block">
Question <span class="question-number">No question loaded</span><br />
<span id="question-title"></span><br />
<span id="question-question"></span>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
<div class="container anatomy my-3">
{% include 'user_score_header.html' %}
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body p-0">
<div class="p-2 d-flex align-items-center justify-content-between">
<div class="small text-muted">Filter questions by marks</div>
<div>
<select id="filter-score" class="form-select form-select-sm" style="min-width:120px;">
<option value="all">All scores</option>
<option value="2">2 (full)</option>
<option value="1">1</option>
<option value="0">0</option>
</select>
</div>
</div>
<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">
<div class="me-3">
<span class="badge bg-secondary">{{ forloop.counter }}</span>
</div>
<div>
<div class="fw-semibold">Question {{ forloop.counter }}</div>
<div class="small text-muted">Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{ correct_answer }}</span>{% else %}—{% endif %}</div>
<div class="mt-2"><pre class="mb-0">{{ ans }}</pre></div>
{% if exam.publish_results or view_all_results %}
<div class="small mt-2">
Marks:
{% if score == 2 %}
<span class="badge bg-success ms-1">{{ score }}</span>
{% elif score == 1 %}
<span class="badge bg-warning text-dark ms-1">{{ score }}</span>
{% else %}
<span class="badge bg-secondary ms-1">{{ score }}</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="text-end ms-3">
{% if exam.publish_results %}
<button type="button" class="btn btn-sm btn-outline-primary view-question-button" data-qn={{forloop.counter0}}>View</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>View</button>
{% endif %}
{% 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 %}
</ul>
</div>
</div>
{% include 'user_scores_footer.html' %}
</div>
<div class="col-md-4">
{% if exam.publish_results %}
<div class="card sticky-top" style="top:1rem;">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Viewer</h6>
<button class="btn btn-sm btn-outline-secondary" id="hide-viewer">Hide</button>
</div>
<div class="card-body p-0">
<div class="viewer-block">
<div class="viewer-body overflow-auto p-3" style="max-height: calc(100vh - 160px);">
<div>Question <span class="question-number">No question loaded</span></div>
<div id="question-title" class="fw-bold my-2"></div>
<div id="question-question" class="mb-2"></div>
<div id="single-dicom-viewer" class="dicom-viewer mb-2" data-images="" data-annotations=''></div>
<div class="small text-muted">Answers:</div>
<div class="answers mt-1"></div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<div class="answers">Answers: </div>
</div>
</div>
<div class="anatomy">
{% include 'user_score_header.html' %}
<ul class="score-answer-list">
{% for ans, score, correct_answer in answers_and_marks %}
<li class="user-answer-li" data-question-number="{{forloop.counter}}">Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{correct_answer}}</span>{% endif %}
<br/>
<span class="user-answer-score user-answer-score-{{score}}">
<span class="submitted-user-answer">
<pre>{{ans}}</pre>
</span>
{% if exam.publish_results or view_all_results%}
<span class="answer-score">{{score}}</span>
{% endif %}
</span>
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
</li>
{% endfor %}
</ul>
{% include 'user_scores_footer.html' %}
</div>
{% endblock %}
{% block css %}
<style>
/* Ensure the sticky viewer stays within its column and the canvas scales */
.anatomy .card.sticky-top { max-width: 100%; }
/* Keep viewer usable by enforcing a minimum width on larger viewports */
@media (min-width: 768px) {
.anatomy .card.sticky-top { min-width: 600px; }
}
.anatomy .viewer-block .viewer-body { overflow: auto; }
.viewer-block .answers { background: #f8f9fa; padding: .5rem; border-radius: .25rem; color: #212529; }
.viewer-block .answers, .viewer-block .answers * { color: #212529 !important; }
.viewer-block .answers pre { margin: 0; white-space: pre-wrap; }
.dicom-viewer, .dicom-viewer .canvas-panel, .dicom-viewer .cornerstone-element { max-width: 100%; box-sizing: border-box; }
.dicom-viewer canvas { max-width: 100% !important; width: 100% !important; height: auto !important; display: block; }
#single-dicom-viewer { width: 100%; }
/* hide horizontal overflow from the card body so the page doesn't scroll sideways */
.anatomy .card .card-body { overflow-x: hidden; }
</style>
{% endblock %}
{% block js %}
<script>
$(document).ready(function () {
$(".hide-show").click((evt, el) => {
$(".inner-display-block").toggle();
})
{% if exam.publish_results %}
<script>
$(document).ready(function () {
$("#hide-viewer").on('click', function(){ $('.viewer-block .viewer-body').toggle(); });
$(".view-question-link").on("click", function (e) {
console.log("click", e)
question_number = e.currentTarget.dataset.qn;
$("#single-dicom-viewer").empty();
$.ajax({
url: `{% url 'anatomy:question_review' pk=exam.pk %}`,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
question_number: question_number
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
let event = new CustomEvent('loadDicomViewerUrls', {
"detail": { "images": data.images, "annotations": data.annotations }
// Filter questions by score using Bootstrap's d-none for consistent layout
$("#filter-score").on('change', function(){
const val = $(this).val();
$(".list-group-item[data-score]").each(function(){
const s = $(this).data('score');
if(val === 'all' || String(s) === String(val)) $(this).removeClass('d-none'); else $(this).addClass('d-none');
});
});
window.dispatchEvent(event);
$(".question-display-block .answers").empty().append(data.answers.toString());
$("#question-title").empty().append(data.title);
$("#question-question").empty().append(data.question);
n = parseInt(question_number) + 1
$(".question-display-block .question-number").empty().append(n);
$(".inner-display-block").show();
})
.always(function () {
console.log('[Done]');
})
})
$(".view-question-button").on("click", function (e) {
question_number = e.currentTarget.dataset.qn;
$("#single-dicom-viewer").empty();
$.ajax({
url: `{% url 'anatomy:question_review' pk=exam.pk %}`,
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
question_number: question_number
},
type: "POST",
dataType: "json",
})
.done(function (data) {
let event = new CustomEvent('loadDicomViewerUrls', { "detail": { "images": data.images, "annotations": data.annotations } });
window.dispatchEvent(event);
$(".viewer-block .answers").empty().append(data.answers.toString());
$("#question-title").empty().append(data.title);
$("#question-question").empty().append(data.question);
n = parseInt(question_number) + 1
$(".viewer-block .question-number").empty().append(n);
$(".viewer-block .viewer-body").show();
})
.always(function () { console.log('[Done]'); });
});
$(".view-question-link").first().click();
});
</script>
// auto-click first view button to populate viewer
const firstView = $(".view-question-button:visible").first();
if(firstView.length){ firstView.click(); }
});
</script>
{% endif %}
{% endblock %}
+26 -11
View File
@@ -2,15 +2,30 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %}
<a href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a>
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %}
<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>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %}
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %}
+3 -3
View File
@@ -20,8 +20,8 @@
</div>
</details>
{% endif %}
<details>
<summary>Suggest incorrect answers:</summary>
{% comment %} <details>
<summary>Suggest incorrect answers:</summary>
{% for word in words_suggest_incorrect %}
<span>{{word}}<span class="user-select-none" _="on click if #id_answer_suggest_incorrect == null then
@@ -43,7 +43,7 @@
<div class="border border-secondary">
{% include "anatomy/question_detail.html#suggest-incorrect-form" %}
</div>
</details>
</details> {% endcomment %}
+344
View File
@@ -0,0 +1,344 @@
{% extends 'anatomy/exams.html' %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="card-title mb-1">Marking question {{ question_details.current }} of {{ question_details.total }}</h5>
<div class="mb-2">
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a>
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
{% if request.user.is_superuser %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Admin Edit">Admin Edit</a>
{% endif %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:mark' exam.id question_number %}">Classic mark</a>
</div>
<h6 class="mb-1">{{ question.question_type }}</h6>
{% if question.structure %}
<div class="small text-muted">Structure: {{ question.structure }}</div>
{% endif %}
<div class="mt-2">Primary answer: <span id="primary-answer" title="The primary answer of the question">{{ question.get_primary_answer }}</span></div>
</div>
<div class="text-end">
{% if question.answer_help %}
<button class="btn btn-sm btn-outline-info" data-bs-toggle="collapse" data-bs-target="#marking-help">Answer Help</button>
{% endif %}
</div>
</div>
{% if question.answer_help %}
<div class="collapse mt-2" id="marking-help">
<div class="card card-body">
{{ question.answer_help|safe }}
</div>
</div>
{% endif %}
<div class="mt-3">
<form method="POST" class="post-form">{% csrf_token %}
<p class="small text-muted">Click each answer to toggle through marks awarded (as per colour). This UI writes marks immediately.</p>
<div class="d-flex justify-content-between align-items-center mb-2">
<div></div>
<div>
{% if question_details.current > 1 %}
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-1">Previous</button>
{% endif %}
<button type="submit" name="save" class="btn btn-primary btn-sm me-1">Save</button>
{% if question_details.current < question_details.total %}
<button type="submit" name="next" class="btn btn-outline-primary btn-sm me-1">Next</button>
{% endif %}
</div>
</div>
<div class="mb-2">
<button class="btn btn-sm btn-outline-info me-1" hx-get="{% url 'anatomy:mark2_exam_marked' exam.id question_number %}" hx-target="#exam-marked-container" hx-swap="innerHTML">Show exam-submitted answers</button>
<button class="btn btn-sm btn-outline-info" hx-get="{% url 'anatomy:mark2_question_marked' question.pk %}" hx-target="#question-marked-container" hx-swap="innerHTML">Show all stored answers for question</button>
</div>
<div class="row">
<div class="col-12">
<h6>Answers</h6>
<ul id="answer-list" class="list-group answer-list mb-3">
{# Render currently-stored marked answers first, then unmarked submissions #}
{% include 'anatomy/partials/mark2_marked_list_fragment.html' %}
{% include 'anatomy/partials/mark2_unmarked_list_fragment.html' %}
</ul>
<div id="exam-marked-container" class="mt-2"></div>
<div id="question-marked-container" class="mt-2"></div>
</div>
</div>
<div class="mt-3">
<div class="small text-muted key">Key: <span class="key-item correct">2 Marks</span>, <span class="key-item half-correct">1 Mark</span>, <span class="key-item incorrect">0 Marks</span></div>
</div>
<span class="visually-hidden">{{ form.as_p }}</span>
</form>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div id="single-dicom-viewer" class="marking-dicom" data-images="{{ question.get_image_url_array }}" data-annotations='{{ question.get_image_annotations }}'></div>
</div>
</div>
{% endblock %}
{% block css %}
<style>
/* Ensure answer states are visible and clickable in the new layout */
.answer { cursor: pointer; }
/* Use border to indicate mark state instead of background */
.answer.correct { color: #155724; background-color: transparent; }
.answer.half-correct { color: #856404; background-color: transparent; }
.answer.incorrect { color: #721c24; background-color: transparent; }
.answer.not-marked { color: inherit; background-color: transparent; }
.answer-list .list-group-item pre { margin: 0; font-family: monospace; white-space: pre-wrap; }
.mark-controls { display:flex; gap:0.25rem; }
.mark-btn { padding: 0.15rem 0.4rem; font-size: 0.8rem; }
/* Per-state border indicators on the list item */
.marked-item { }
.marked-correct { border-left: 4px solid #2a9d3f; }
.marked-half-correct { border-left: 4px solid #ffb020; }
.marked-incorrect { border-left: 4px solid #d3413a; }
/* Key items use a small left-border swatch to match the list markers */
.key .key-item { display:inline-block; padding-left:8px; margin-left:6px; }
.key .key-item.correct { border-left: 8px solid #2a9d3f; }
.key .key-item.half-correct { border-left: 8px solid #ffb020; }
.key .key-item.incorrect { border-left: 8px solid #d3413a; }
/* Right-floating action icons for answer external links */
.answer-actions { min-width: 6rem; }
.answer-actions .action-icon { margin-left: 0.5rem; color: #6c757d; text-decoration: none; border: none; background: none; }
.answer-actions .action-icon:hover { color: #000; text-decoration: none; }
.answer-actions .copy-to-clipboard { cursor: pointer; }
/* Active state for numeric mark buttons */
.mark-btn.active { background-color: #0d6efd; color: #fff; border-color: #0d6efd; }
</style>
{% endblock %}
{% block js %}
<script>
(function(){
// CSRF helper for fetch
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
// Delegated click handler for answers — only updates local state.
document.addEventListener('click', function(e){
// Ignore clicks on action icons or mark controls
if (e.target.closest('.answer-actions') || e.target.closest('.mark-controls') || e.target.closest('.copy-to-clipboard')) return;
// Prefer a clicked span.answer if present
let spanEl = e.target.closest('span.answer');
// Otherwise fall back to the list-item's span
if (!spanEl) {
const li = e.target.closest('#answer-list li');
if (!li) return;
spanEl = li.querySelector('span.answer');
if (!spanEl) return;
}
// Ensure span has an explicit state class
if (!spanEl.classList.contains('correct') && !spanEl.classList.contains('half-correct') && !spanEl.classList.contains('incorrect') && !spanEl.classList.contains('not-marked')) {
spanEl.classList.add('not-marked');
}
// cycle states: not-marked -> correct -> half-correct -> incorrect -> not-marked
const states = ['not-marked', 'correct', 'half-correct', 'incorrect'];
let currentState = 'not-marked';
for (let p of spanEl.classList) {
if (states.includes(p)) { currentState = p; break; }
}
let idx = (states.indexOf(currentState) + 1) % states.length;
const newState = states[idx];
// map to numeric for setMarkForSpan
let numeric = null;
if (newState === 'correct') numeric = 2;
else if (newState === 'half-correct') numeric = 1;
else if (newState === 'incorrect') numeric = 0;
// delegate to setMarkForSpan to keep behaviour consistent (updates classes, active buttons, hidden field)
setMarkForSpan(spanEl, numeric);
}, false);
function updateMarkedAnswersField() {
const marked = { 'correct': [], 'half-correct': [], 'incorrect': [] };
document.querySelectorAll('#answer-list span.answer').forEach(function(el) {
if (el.classList.contains('correct')) marked.correct.push((el.title || el.textContent).trim());
else if (el.classList.contains('half-correct')) marked['half-correct'].push((el.title || el.textContent).trim());
else if (el.classList.contains('incorrect')) marked.incorrect.push((el.title || el.textContent).trim());
});
// Ensure the hidden input exists
let hidden = document.getElementById('id_marked_answers');
if (!hidden) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'marked_answers';
hidden.id = 'id_marked_answers';
const form = document.querySelector('form.post-form');
if (form) form.appendChild(hidden);
}
hidden.value = JSON.stringify(marked);
}
// Enhance list items with numeric mark controls (2,1,0,Clear)
function attachNumericControls() {
function makeButton(label, cls) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm btn-outline-secondary me-1 mark-btn ' + cls;
b.textContent = label;
return b;
}
document.querySelectorAll('.answer-list li').forEach(function(li) {
if (li.querySelector('.mark-controls')) return; // already attached
const span = li.querySelector('span.answer');
if (!span) return;
// Ensure span has an explicit default state class so logic is consistent
if (!span.classList.contains('correct') && !span.classList.contains('half-correct') && !span.classList.contains('incorrect') && !span.classList.contains('not-marked')) {
span.classList.add('not-marked');
}
const controls = document.createElement('div');
controls.className = 'mark-controls mt-2';
const b2 = makeButton('2', 'mark-2');
const b1 = makeButton('1', 'mark-1');
const b0 = makeButton('0', 'mark-0');
const bClear = makeButton('', 'mark-clear');
controls.appendChild(b2);
controls.appendChild(b1);
controls.appendChild(b0);
controls.appendChild(bClear);
// append after the pre
li.appendChild(controls);
// wire clicks
b2.addEventListener('click', function(){ setMarkForSpan(span, 2); });
b1.addEventListener('click', function(){ setMarkForSpan(span, 1); });
b0.addEventListener('click', function(){ setMarkForSpan(span, 0); });
bClear.addEventListener('click', function(){ setMarkForSpan(span, null); });
// set initial active button based on existing state
(function(){
let state = null;
if (span.classList.contains('correct')) state = 2;
else if (span.classList.contains('half-correct')) state = 1;
else if (span.classList.contains('incorrect')) state = 0;
// clear any pre-existing active flags then set the correct one
[b2, b1, b0, bClear].forEach(b => b.classList.remove('active'));
if (state === 2) b2.classList.add('active');
else if (state === 1) b1.classList.add('active');
else if (state === 0) b0.classList.add('active');
else bClear.classList.add('active');
})();
});
}
function setMarkForSpan(el, numeric) {
// numeric: 2 -> correct, 1 -> half-correct, 0 -> incorrect, null -> not-marked
const li = el.closest('li');
let state = 'not-marked';
if (numeric === 2) state = 'correct';
else if (numeric === 1) state = 'half-correct';
else if (numeric === 0) state = 'incorrect';
el.className = 'answer ' + state;
if (li) {
// clear previous per-state classes
li.classList.remove('marked-correct','marked-half-correct','marked-incorrect','marked-item');
if (state === 'not-marked') {
// nothing
} else if (state === 'correct') {
li.classList.add('marked-item','marked-correct');
} else if (state === 'half-correct') {
li.classList.add('marked-item','marked-half-correct');
} else if (state === 'incorrect') {
li.classList.add('marked-item','marked-incorrect');
}
}
// update active state of numeric controls for this list item
try {
const li = el.closest('li');
if (li) {
const btn2 = li.querySelector('.mark-2');
const btn1 = li.querySelector('.mark-1');
const btn0 = li.querySelector('.mark-0');
const btnClear = li.querySelector('.mark-clear');
[btn2, btn1, btn0, btnClear].forEach(b => { if (b) { b.classList.remove('active'); } });
if (numeric === 2 && btn2) btn2.classList.add('active');
else if (numeric === 1 && btn1) btn1.classList.add('active');
else if (numeric === 0 && btn0) btn0.classList.add('active');
else if (numeric === null && btnClear) btnClear.classList.add('active');
}
} catch (err) {
// ignore
}
updateMarkedAnswersField();
}
// Run once on load to add controls. Also re-run after HTMX swaps so controls
// appear when partials are replaced.
function initMark2() {
attachNumericControls();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMark2);
} else {
initMark2();
}
// If HTMX is used to update parts of the page, re-run attachNumericControls
// after swaps so newly inserted list items get controls.
document.addEventListener('htmx:afterSwap', function(evt) {
initMark2();
});
// Ensure the hidden marked_answers field is updated before any form submit
const postForm = document.querySelector('form.post-form');
if (postForm) {
postForm.addEventListener('submit', function(e){
try { updateMarkedAnswersField(); } catch (err) { /* still submit */ }
});
}
// Copy-to-clipboard handler for dropdown items
document.addEventListener('click', function(e){
const btn = e.target.closest('.copy-to-clipboard');
if (!btn) return;
const text = btn.getAttribute('data-text');
if (!text) return;
try {
navigator.clipboard.writeText(text);
// simple visual feedback: change the button text briefly
const prev = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function(){ btn.textContent = prev; }, 1200);
} catch (err) {
console.warn('copy failed', err);
}
});
})();
</script>
{% endblock %}
@@ -0,0 +1,79 @@
{% extends 'anatomy/exams.html' %}
{% block content %}
<div class="container my-4 anatomy">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
</div>
<div class="text-end">
<div class="btn-group me-2" role="group" aria-label="listing actions">
<button class="btn btn-sm btn-outline-secondary show-all-button">Show all</button>
<button class="btn btn-sm btn-outline-secondary show-unmarked-button">Show unmarked</button>
</div>
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=0 %}" class="btn btn-sm btn-primary">Start mark2</a>
</div>
</div>
<div class="card">
<div class="card-body p-2">
<ul class="list-group list-group-flush" id="question-mark-list">
{% for question, unmarked_count, unmarked_count2 in question_unmarked_map %}
<li class="list-group-item d-flex justify-content-between align-items-start" data-unmarked="{{ unmarked_count }}">
<div class="me-3">
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=forloop.counter0 %}" class="fw-semibold">Question {{ forloop.counter }}:</a>
<div class="small text-truncate" style="max-width:60vw;">{{ question }}</div>
</div>
<div class="text-end">
<div class="small text-muted">Unmarked</div>
{% if unmarked_count > 0 %}
<span class="badge bg-danger">{{ unmarked_count }}</span>
{% else %}
<span class="badge bg-secondary">{{ unmarked_count }}</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item small text-muted">No questions found.</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<script>
(function(){
function $(sel, ctx){ return (ctx||document).querySelector(sel); }
function $all(sel, ctx){ return Array.from((ctx||document).querySelectorAll(sel)); }
var showAllBtn = $('.show-all-button');
var showUnmarkedBtn = $('.show-unmarked-button');
var list = $('#question-mark-list');
if (!list) return;
function showAll(){
$all('#question-mark-list .list-group-item').forEach(function(li){ li.classList.remove('d-none'); });
showAllBtn && showAllBtn.classList.add('active');
showUnmarkedBtn && showUnmarkedBtn.classList.remove('active');
}
function showUnmarked(){
$all('#question-mark-list .list-group-item').forEach(function(li){
var u = parseInt(li.getAttribute('data-unmarked') || '0', 10);
if (u > 0) li.classList.remove('d-none'); else li.classList.add('d-none');
});
showUnmarkedBtn && showUnmarkedBtn.classList.add('active');
showAllBtn && showAllBtn.classList.remove('active');
}
if (showAllBtn) showAllBtn.addEventListener('click', function(e){ e.preventDefault(); showAll(); });
if (showUnmarkedBtn) showUnmarkedBtn.addEventListener('click', function(e){ e.preventDefault(); showUnmarked(); });
// Initialize to show all
showAll();
})();
</script>
<style>
.show-all-button.active, .show-unmarked-button.active { box-shadow: inset 0 -2px 0 rgba(0,0,0,0.08); }
</style>
{% endblock %}
@@ -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>
@@ -1,38 +1,51 @@
<div class="card mb-3">
<div class="card mb-3" hx-on="htmx:afterSwap: initAnatomyFragment(this)">
<div class="card-body">
<div class="mb-2"><span class="badge bg-dark">Anatomy</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p class="lead">{{ question.question_type }}</p>
<div id="anatomy-dicom-image" class="dicom-image-legacy" data-url="{{ question.get_image_url }}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
</div>
<p class="mt-2">
Answer: {{ question.get_primary_answer }}
</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="mt-3">
<h6>Marked answers</h6>
<ul class="list-group">
{% for answer in question.answers.all|dictsortreversed:"status" %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if answer.status == '2' %} list-group-item-success{% elif answer.status == '1' %} list-group-item-warning{% elif answer.status == '0' %} list-group-item-secondary{% endif %}">
<div>
{% if answer.proposed %}
<small class="text-muted me-2">(proposed)</small>
{% endif %}
<span>{{ answer.answer }}</span>
</div>
<div>
{% if answer.status == '2' %}
<span class="badge bg-success">Correct</span>
{% elif answer.status == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif answer.status == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Marked answers</h6>
<div class="d-flex align-items-center gap-2">
<div class="small text-muted">{{ question.answers.all|length }}</div>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#marked-answers-{{ question.pk }}" aria-expanded="false" aria-controls="marked-answers-{{ question.pk }}">Show answers</button>
</div>
</div>
<div class="collapse mt-2" id="marked-answers-{{ question.pk }}">
<ul class="list-group">
{% for answer in question.answers.all|dictsortreversed:"status" %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if answer.status == '2' %} list-group-item-success{% elif answer.status == '1' %} list-group-item-warning{% elif answer.status == '0' %} list-group-item-secondary{% endif %}">
<div>
{% if answer.proposed %}
<small class="text-muted me-2">(proposed)</small>
{% endif %}
<span>{{ answer.answer }}</span>
</div>
<div>
{% if answer.status == '2' %}
<span class="badge bg-success">Correct</span>
{% elif answer.status == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif answer.status == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{# Aggregated summary placeholder — auto-load via HTMX #}
@@ -41,7 +54,13 @@
<div id="anatomy-response-summary"
hx-get="{% url 'anatomy:exam_review_question_summary' exam.pk q_index %}"
hx-trigger="load"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#anatomy-summary-indicator-{{ question.pk }}">
<div id="anatomy-summary-indicator-{{ question.pk }}" style="display:none">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading summary</span>
</div>
<style> #anatomy-summary-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
<div class="small text-muted">Loading summary…</div>
</div>
</div>
@@ -54,10 +73,16 @@
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
hx-target="#anatomy-responses-container"
hx-swap="innerHTML">Show responses</button>
hx-swap="innerHTML"
hx-indicator="#anatomy-responses-indicator-{{ question.pk }}">Show responses</button>
</div>
</div>
<div class="mt-2" id="anatomy-responses-container">
<div id="anatomy-responses-indicator-{{ question.pk }}" style="display:none">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading responses</span>
</div>
<style> #anatomy-responses-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
<div class="text-muted small">Responses are hidden — click “Show responses” to load.</div>
</div>
</div>
@@ -86,36 +111,4 @@
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-info">Anatomy</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
@@ -1,35 +1,92 @@
{# Responses partial for anatomy question within an exam. HTMX-loadable. #}
<div id="anatomy-responses-container">
<h6>Responses in this exam</h6>
<div class="small text-muted mb-2">List of candidate answers recorded for this question in the current exam.</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h6 class="mb-0">Responses in this exam</h6>
<div class="small text-muted">List of candidate answers recorded for this question in the current exam.</div>
</div>
<div>
{% if not reveal_respondents %}
<button type="button" class="btn btn-sm btn-outline-primary show-respondents-btn"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}?reveal=1"
hx-target="#anatomy-responses-container"
hx-swap="outerHTML"
data-url="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}?reveal=1"
>Show respondents</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary show-respondents-btn"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
hx-target="#anatomy-responses-container"
hx-swap="outerHTML"
data-url="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
>Hide respondents</button>
{% endif %}
</div>
</div>
<ul class="list-group">
{% for ua in exam_user_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if ua.user %}
<strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong>
{% else %}
<strong>Anonymous</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif ua.score == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif ua.score == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% for item in exam_user_answers %}
{% with ua=item.ua %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if reveal_respondents %}
{% if item.display_name_raw %}
<strong>{{ item.display_name_raw }}</strong>
{% else %}
<strong>{{ ua.get_candidate_masked }}</strong>
{% endif %}
{% else %}
<strong>Respondent {{ forloop.counter }}</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif ua.score == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif ua.score == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endwith %}
{% empty %}
<li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %}
</ul>
</div>
<script>
// Fallback for environments where HTMX isn't active or the hx-* attributes don't trigger.
// Attach a click handler to buttons with class .show-respondents-btn that will fetch
// the fragment URL (from data-url) and replace the container content.
(function(){
try {
var els = document.querySelectorAll('.show-respondents-btn');
els.forEach(function(btn){
btn.addEventListener('click', function(ev){
// If HTMX is present it will handle the request; only run fallback when not handled.
if (window.htmx) return;
ev.preventDefault();
var url = btn.getAttribute('data-url');
if (!url) return;
fetch(url, {credentials: 'same-origin'})
.then(function(r){ if (!r.ok) throw new Error('Network response was not ok'); return r.text(); })
.then(function(html){
var container = document.querySelector('#anatomy-responses-container');
if (container) container.outerHTML = html;
}).catch(function(err){
console.error('Failed to load responses fragment', err);
});
}, {passive: false});
});
} catch (e) {
// ignore
}
})();
</script>
@@ -21,7 +21,7 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Correct</div>
<div class="text-end"><strong>{{ exam_response_score_counts.2|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.2|default:0 }}%</small></div>
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'2'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'2'|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ exam_response_score_pcts.2|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.2|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -32,7 +32,7 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Half mark</div>
<div class="text-end"><strong>{{ exam_response_score_counts.1|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.1|default:0 }}%</small></div>
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'1'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'1'|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ exam_response_score_pcts.1|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.1|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -43,7 +43,7 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Incorrect</div>
<div class="text-end"><strong>{{ exam_response_score_counts.0|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.0|default:0 }}%</small></div>
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'0'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'0'|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ exam_response_score_pcts.0|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.0|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -54,7 +54,7 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Unmarked</div>
<div class="text-end"><strong>{{ exam_response_score_counts.empty|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.empty|default:0 }}%</small></div>
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'empty'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'empty'|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ exam_response_score_pcts.empty|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.empty|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -63,19 +63,39 @@
</div>
<div>
<h6 class="mb-1">Top submitted answers</h6>
{% if top_answers %}
<ul class="list-group">
{% for a in top_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>{{ a.answer_compare }}</div>
<div class="small text-muted">{{ a.count }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted">No submitted answers yet.</div>
{% endif %}
{# Collapsible Top submitted answers - closed by default. #}
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-1">Top submitted answers</h6>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#top-answers-{{ q_index }}" aria-expanded="false" aria-controls="top-answers-{{ q_index }}">
Show answers
</button>
</div>
<div class="collapse mt-2" id="top-answers-{{ q_index }}">
{% if top_answers %}
<ul class="list-group">
{% for a in top_answers %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1">{{ a.answer_compare }}</div>
<div class="d-flex align-items-center gap-2">
{% if a.dominant_score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif a.dominant_score == '1' %}
<span class="badge bg-warning text-dark">Half</span>
{% elif a.dominant_score == '0' %}
<span class="badge bg-danger">Incorrect</span>
{% else %}
<span class="badge bg-secondary">Unmarked</span>
{% endif %}
<div class="small text-muted badge bg-primary" title="Submission Count">{{ a.count }}</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted">No submitted answers yet.</div>
{% endif %}
</div>
</div>
{% else %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
@@ -0,0 +1,35 @@
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">Edit stored answer</h6>
<form hx-post="{% url 'anatomy:mark2_edit_answer' %}" hx-target="closest .card" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="original" value="{{ original }}">
{% if exam_pk %}
<input type="hidden" name="exam_pk" value="{{ exam_pk }}">
{% endif %}
{% if sk is not None %}
<input type="hidden" name="sk" value="{{ sk }}">
{% endif %}
<div class="mb-2">
<label class="form-label">Original</label>
<pre class="small bg-light p-2">{{ original }}</pre>
</div>
<div class="mb-2">
<label class="form-label">New answer text</label>
<textarea class="form-control" name="new_answer" rows="3">{% if existing %}{{ existing.answer }}{% else %}{{ original }}{% endif %}</textarea>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
{% if exam_pk and sk is not None %}
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'anatomy:mark2_exam_marked' exam_pk sk %}" hx-swap="outerHTML" hx-target="closest .card">Cancel</button>
{% else %}
<button type="button" class="btn btn-outline-secondary btn-sm" _="on click remove .card">Cancel</button>
{% endif %}
</div>
</form>
</div>
</div>
@@ -0,0 +1,82 @@
{% comment %}List of distinct answers submitted within this exam for the question, with counts and mark badges{% endcomment %}
<div class="card mt-2">
<div class="card-body">
<style>
/* Hide edit buttons and mark-action forms by default; show only when card has editing-enabled */
.card .edit-btn { display: none !important; }
.card.editing-enabled .edit-btn { display: inline-block !important; }
.card .mark-actions { display: none !important; }
.card.editing-enabled .mark-actions { display: inline-block !important; }
</style>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="card-title mb-0">Answers submitted in this exam</h6>
<div class="d-flex gap-2 align-items-center">
<div class="btn-group btn-group-sm" role="group" aria-label="Order">
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'count' or not request.GET.order %} active{% endif %}" hx-get="{{ request.path }}?order=count" hx-swap="outerHTML" hx-target="closest .card">By count</button>
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'mark' %} active{% endif %}" hx-get="{{ request.path }}?order=mark" hx-swap="outerHTML" hx-target="closest .card">By score</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="this.closest('.card').classList.toggle('editing-enabled'); this.textContent = this.closest('.card').classList.contains('editing-enabled') ? 'Disable editing' : 'Enable editing'">Enable editing</button>
</div>
</div>
{% if not items %}
<div class="small text-muted">No answers submitted in this exam.</div>
{% else %}
<ul class="list-group">
{% for it in items %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1 me-3"><pre class="mb-0">{{ it.answer }}</pre></div>
<div class="text-end d-flex align-items-center gap-2">
{# Stored mark badge #}
{% if it.mark == 'correct' %}
<span class="badge bg-success me-1" title="Stored answer marked as correct (2)">2</span>
{% elif it.mark == 'half-correct' %}
<span class="badge bg-warning text-dark me-1" title="Stored answer marked as half-correct (1)">1</span>
{% elif it.mark == 'incorrect' %}
<span class="badge bg-danger me-1" title="Stored answer marked as incorrect (0)">0</span>
{% else %}
<span class="badge bg-secondary me-1" title="No stored mark for this answer">-</span>
{% endif %}
{# Submission count badge #}
<span class="badge bg-light text-dark me-2" title="Submitted {{ it.count }} times">{{ it.count }}</span>
<form hx-post="{% url 'anatomy:mark2_toggle_answer' %}" hx-target="closest .card" hx-swap="outerHTML" class="d-inline mark-actions">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="answer" value="{{ it.answer }}">
<input type="hidden" name="exam_pk" value="{{ exam.pk }}">
<input type="hidden" name="sk" value="{{ sk }}">
<button class="btn btn-sm btn-outline-success" name="newmark" value="correct" title="Mark as correct (2)">2</button>
<button class="btn btn-sm btn-outline-warning text-dark" name="newmark" value="half-correct" title="Mark as half-correct (1)">1</button>
<button class="btn btn-sm btn-outline-danger" name="newmark" value="incorrect" title="Mark as incorrect (0)">0</button>
<button class="btn btn-sm btn-outline-secondary" name="newmark" value="clear" title="Clear stored mark"></button>
</form>
<button class="btn btn-sm btn-outline-secondary edit-btn" hx-get="{% url 'anatomy:mark2_edit_answer' %}?question_pk={{ question.pk }}&original={{ it.answer|urlencode }}&exam_pk={{ exam.pk }}&sk={{ sk }}" hx-target="closest .card" hx-swap="outerHTML" title="Edit stored answer">Edit</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<script>
// Keep Edit buttons hidden by default; reveal them when the card receives
// the 'editing-enabled' class (toggled by the Enable editing button).
(function(){
document.querySelectorAll('.card').forEach(function(card){
function update(){
const enable = card.classList.contains('editing-enabled');
card.querySelectorAll('.edit-btn').forEach(function(b){
b.classList.toggle('d-none', !enable);
});
}
// run once in case HTMX inserted the fragment
update();
// observe class changes on the card to re-run update
const obs = new MutationObserver(update);
obs.observe(card, { attributes: true, attributeFilter: ['class'] });
});
})();
</script>
@@ -0,0 +1,43 @@
{% comment %}Renders only the list items for already-marked answers (used as a partial){% endcomment %}
{% for answer in correct_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer correct" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
{% for answer in half_mark_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer half-correct" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
{% for answer in incorrect_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
@@ -0,0 +1,43 @@
{% comment %}All stored Answer choices for this question grouped by status{% endcomment %}
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">All stored answers for this question</h6>
<style>
/* Allow stored answers to wrap long lines and break words so they are visible
in narrow columns. Also ensure the lists stack on small screens. */
.card .list-group-item pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
</style>
<div class="row">
<div class="col-12 col-lg-4">
<h6>2 marks</h6>
<ul class="list-group mb-2">
{% for answer in correct_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-lg-4">
<h6>1 mark</h6>
<ul class="list-group mb-2">
{% for answer in half_mark_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-lg-4">
<h6>0 marks</h6>
<ul class="list-group mb-2">
{% for answer in incorrect_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
@@ -0,0 +1,28 @@
{% comment %}Renders only the list items for unmarked answers (used as a partial){% endcomment %}
{% for answer in user_answers %}
{% with answer_url=answer|urlencode:'' %}
{% if answer in answer_suggest_incorrect %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect suggest" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% else %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer not-marked" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endif %}
{% endwith %}
{% endfor %}
@@ -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>
@@ -0,0 +1,17 @@
<div id="incorrect-answers" class="small text-muted">
<strong>Answer suggest incorrect:</strong>
<div class="mt-2">
{% if question.answer_suggest_incorrect %}
{% for s in question.answer_suggest_incorrect %}
<span class="badge bg-secondary me-1 mb-1">{{ s }}</span>
{% endfor %}
{% else %}
<span class="small text-muted">No suggestions</span>
{% endif %}
</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>
@@ -0,0 +1,48 @@
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">Suggest incorrect answers</h6>
<form hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="outerHTML">
{% csrf_token %}
<div class="mb-2">
<label class="form-label">Current suggestions</label>
<div class="mb-2">
{% for s in question.answer_suggest_incorrect %}
<button type="button" class="btn btn-sm btn-outline-secondary me-1 mb-1" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"remove": "{{ s|escapejs }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-target="closest .card" hx-swap="outerHTML">{{ s }} <span class="text-danger ms-1">&times;</span></button>
{% empty %}
<div class="small text-muted">No suggestions</div>
{% endfor %}
</div>
<div class="d-flex gap-2 align-items-center">
<input id="new-suggest" name="suggestion" class="form-control form-control-sm" placeholder="Add suggestion" />
<button type="button" class="btn btn-sm btn-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-include="#new-suggest" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<button type="submit" name="close" value="1" class="btn btn-primary btn-sm">Close</button>
</div>
</form>
{% if candidates %}
<hr/>
<h6 class="mt-3">Suggested candidates</h6>
<div class="small text-muted mb-2">These are frequent submitted answers that might be suitable to treat as incorrect suggestions.</div>
<ul class="list-group">
{% for c in candidates %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1"><pre class="mb-0">{{ c.answer }}</pre></div>
<div class="text-end">
<span class="badge bg-light text-dark me-2">{{ c.count }}</span>
<button class="btn btn-sm btn-outline-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"suggestion": "{{ c.answer|escapejs }}", "context": "fragment"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<template class="incorrect-answers-original" hidden>
<strong>Answer suggest incorrect:</strong> {{ question.answer_suggest_incorrect }}
<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>
</template>
+297 -113
View File
@@ -1,118 +1,186 @@
{% extends 'anatomy/base.html' %}
{% load partials %}
{% block content %}
{% load static %}
{% include 'anatomy/question_link_header.html' %}
<div id="dicom-image" class="dicom-image" data-url="{{ remote_url }}{{ question.image.url}}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>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. To save the changes click the "Save Annotations" button.</p>
<p>There is no limit to the number of arrows that can be added to the image.</p>
</details>
<button id="save-annotations">Save Annotations</button>
</div>
<div class="question">
<div class="date">
Created: {{ question.created_date|date:"d/m/Y" }}
</div>
<h2>Question type: {{question.question_type}}</h2>
<h3>Primary answer: {{ question.get_primary_answer }}</h3>
<div>
<details>
<summary title="Click to view the question answers">
Answers:
</summary>
<table>
<tr><th>Answer</th><th>Score</th></tr>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
{% endif %}>
<span >
{{ answer }}
</span>
<td>
<td>{{answer.status}}</td>
{% if answer.proposed %}
<td>
<button class="btn btn-sm accept-button" data-aid={{answer.id}}
hx-get="{% url 'anatomy:confirm_answer' answer.id %}"
title="Click to accept the proposed answer">Accept</button>
<button class="btn btn-sm delete-button" data-aid={{answer.id}}
hx-get="{% url 'anatomy:delete_answer' answer.id %}"
title="Click to delete the proposed answer"
>Delete</button>
</td>
{% endif %}
</tr>
{% endfor %}
</table>
</details>
</div>
<div>
Answer help: {{ question.answer_help|safe }}
</div>
<div>
{% partialdef suggest-incorrect-form inline %}
<span id="incorrect-answers">
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
<button id="answer-suggest-incorrect-button" class="btn btn-sm" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}"
hx-target="#incorrect-answers"
hx-swap="innerHTML">Edit suggestions</button>
</span>
{% endpartialdef %}
</div>
<div>
Description: {{ question.description }}
</div>
<div>
Exams: {% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a>
{% endfor %}
<button class="btn btn-sm" 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>
<div>
Modality: {{ question.modality }}
</div>
<div>
Region: {{ question.region }}
</div>
<div>
Structure: {{ question.structure }}
</div>
<div>
Body Part: {{ question.body_part }}
</div>
<div>
Author: {% for author in question.author.all %}
{{ author }},
{% endfor %}
</div>
<div>
Open access: {{ question.open_access }}
</div>
{% include 'question_notes.html' %}
<div>
<details>
<summary>
<span id="annotation-json-toggle">Annotation JSON (+/-):</span> <span id="annotation-json-content">
</summary>
{% if question.image_annotations %}
<pre>{{ question.image_annotations }}</pre>
{% else %}
No saved annotations.
<div class="row g-3">
<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="{{ 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 %}
</span>
</details>
{% 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>
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-1">Question</h5>
<div class="text-muted">Created: {{ question.created_date|date:"d/m/Y" }}</div>
</div>
<div class="text-end">
<h6 class="mb-0">Type: <span class="badge bg-secondary">{{ question.question_type }}</span></h6>
</div>
</div>
<h6 class="mt-2">Primary answer</h6>
{% 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 %}
<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">
<thead>
<tr><th>Answer</th><th>Score</th><th></th></tr>
</thead>
<tbody>
{% for answer in question.answers.all|dictsortreversed:"status" %}
{% 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">
<button type="button" class="btn btn-sm btn-link text-muted p-0 toggle-words-btn me-2" title="Suggest words: show candidate words extracted from this stored answer (click to quick-add)" aria-expanded="false" aria-label="Suggest words"><i class="bi bi-lightbulb-fill"></i></button>
<div class="flex-grow-1">
<pre class="mb-0">{{ answer.answer }}</pre>
<div class="mt-1">
<div class="word-buttons d-none mt-2" aria-hidden="true"></div>
</div>
</div>
</div>
{% else %}
<pre class="mb-0">{{ answer.answer }}</pre>
{% endif %}
</td>
<td>
{% 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>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="small text-muted">No stored answers</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% if question.answer_help %}
<div class="mb-3">
<h6>Answer help</h6>
<div class="small text-muted">{{ question.answer_help|safe }}</div>
</div>
{% endif %}
<div class="mb-2">
{% include 'anatomy/partials/suggest_incorrect_collapsed.html' %}
</div>
<div class="mb-2">
<h6>Description</h6>
<div class="small text-muted">{{ question.description }}</div>
</div>
<div class="row">
<div class="col-12 col-md-6">
<div class="mb-1"><strong>Modality:</strong> {{ question.modality }}</div>
<div class="mb-1"><strong>Region:</strong> {{ question.region }}</div>
<div class="mb-1"><strong>Structure:</strong> {{ question.structure }}</div>
</div>
<div class="col-12 col-md-6">
<div class="mb-1"><strong>Body part:</strong> {{ question.body_part }}</div>
<div class="mb-1"><strong>Open access:</strong> {{ question.open_access }}</div>
<div class="mb-1"><strong>Author:</strong> {% for author in question.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
</div>
</div>
<div class="mt-3">
<h6>Exams</h6>
{% 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' %}
<div class="mt-3">
<details>
<summary class="small">Annotation JSON</summary>
<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>
</div>
</div>
</div>
</div>
<script type="text/javascript">
@@ -154,11 +222,34 @@
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');
});
});
});
function getJsonToolStateNoId() {
const el = document.getElementById("dicom-image");
const el = document.getElementById("anatomy-dicom-image");
const c = cornerstone.getEnabledElement(el);
@@ -174,6 +265,99 @@
}
</script>
<script>
// HTMX quick-add helpers for word buttons inside the answers table
var SUGGEST_URL = "{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}";
var CSRF_TOKEN = "{{ csrf_token }}";
function toggleWords(btn) {
var container = btn.nextElementSibling; // the flex-grow-1 wrapper
if (!container) return;
var wordContainer = container.querySelector('.word-buttons');
if (!wordContainer) return;
// populate if empty
if (!wordContainer.dataset.populated) {
var pre = container.querySelector('pre');
var text = pre ? pre.textContent || pre.innerText : '';
buildWordButtons(wordContainer, text);
wordContainer.dataset.populated = '1';
}
var expanded = !wordContainer.classList.contains('d-none');
if (expanded) {
wordContainer.classList.add('d-none');
btn.setAttribute('aria-expanded', 'false');
wordContainer.setAttribute('aria-hidden', 'true');
} else {
wordContainer.classList.remove('d-none');
btn.setAttribute('aria-expanded', 'true');
wordContainer.setAttribute('aria-hidden', 'false');
}
}
function buildWordButtons(container, text) {
if (!text) return;
var toks = (text.toLowerCase().match(/\w+/g) || []);
var seen = {};
toks.forEach(function(t){
if (t.length < 3) return;
if (seen[t]) return;
seen[t]=true;
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm btn-outline-primary me-1 mb-1';
b.textContent = t;
b.dataset.word = t;
// do not attach inline handlers here; use delegated listener
container.appendChild(b);
});
if (!Object.keys(seen).length) {
var span = document.createElement('div');
span.className = 'small text-muted';
span.textContent = 'No suitable words';
container.appendChild(span);
}
}
// Delegated click handler to keep markup unobtrusive and work for HTMX swaps
document.addEventListener('click', function(e){
var toggle = e.target.closest('.toggle-words-btn');
if (toggle) {
e.preventDefault();
toggleWords(toggle);
return;
}
var wordBtn = e.target.closest('.word-buttons button');
if (wordBtn) {
e.preventDefault();
var word = wordBtn.dataset.word || wordBtn.textContent.trim();
if (word) addQuickSuggestion(word);
return;
}
});
function addQuickSuggestion(word) {
if (!word) return;
if (typeof htmx === 'undefined') {
// Fallback: simple POST via fetch
fetch(SUGGEST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': CSRF_TOKEN },
body: new URLSearchParams({ suggestion: word, quick: '1' })
}).then(function(r){ return r.text(); }).then(function(html){
var wrapper = document.getElementById('incorrect-answers');
if (wrapper) wrapper.innerHTML = html;
});
return;
}
htmx.ajax('POST', SUGGEST_URL, {
values: { suggestion: word, quick: '1' },
headers: { 'X-CSRFToken': CSRF_TOKEN },
target: '#incorrect-answers',
swap: 'innerHTML'
});
}
</script>
{% endblock %}
{% block css %}
@@ -182,9 +366,9 @@
content: "\F505";
font-family: bootstrap-icons;
}
/* Ensure pre-wrapped content inside cards and tables so long answers don't overflow */
.card pre, .table pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
.question div {
margin-bottom: 10px;
}
.card .mb-2, .card .mb-3 { margin-bottom: .75rem; }
</style>
{% endblock css %}
@@ -1,28 +1,98 @@
<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>Question:</strong>
{% if question.stem %}
{{ question.stem|striptags|truncatechars:80 }}
{% else %}
#{{ question.pk }}
{% endif %}
</span>
<div class="floating-header">
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
<a href="{% url 'anatomy:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
<a href="{% url 'anatomy:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a>
<a href="#"
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
Add Note</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{% if request.user.is_superuser %}
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}"
title="Edit the Question using the admin interface">Admin Edit</a>
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
{% endif %}
{% if exam %}
<div>
{% if previous > -1 %}
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
<div class="collapse navbar-collapse" id="questionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if exam %}
<li class="nav-item">
{% if previous > -1 %}
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous</a>
{% endif %}
</li>
<li class="nav-item align-self-center">
Viewing as part of exam: <a class="nav-link d-inline p-0" href="{% url 'anatomy:exam_overview' exam.id %}">{{ exam }}</a>
<span class="navbar-text text-muted ms-2">[{{ pos }}/{{ exam_length }}]</span>
</li>
<li class="nav-item">
{% if next %}
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next</a>
{% endif %}
</li>
{% endif %}
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
{% if next %}
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
{% endif %}
</div>
{% endif %}
</div>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="questionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="questionActions">
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:question_detail' question.id %}">
<i class="bi bi-eye me-2"></i> View
</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
</a>
</li>
<li>
<a class="dropdown-item text-info" href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
<i class="bi bi-chat-left-text me-2"></i> Add Note
</a>
</li>
{% if can_edit %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="{% url 'anatomy:question_delete' pk=question.pk %}">
<i class="bi bi-trash me-2"></i> Delete
</a>
</li>
{% endif %}
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
<i class="bi bi-tools me-2"></i> Admin Edit
</a>
</li>
<li>
<a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
<i class="bi bi-people me-2"></i> User answers
</a>
</li>
{% endif %}
</ul>
</li>
</ul>
</div>
</div>
</nav>
+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"
+18
View File
@@ -33,11 +33,21 @@ 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,
name="question_suggest_incorrect_answers",
),
path(
"question/<int:pk>/suggest_incorrect_collapsed",
views.question_suggest_incorrect_collapsed,
name="question_suggest_incorrect_collapsed",
),
path("question/<int:pk>/answer/", views.answer_question, name="answer_question"),
path(
@@ -102,6 +112,14 @@ urlpatterns = [
views.exam_review_question_summary,
name="exam_review_question_summary",
),
# New improved marking UI (mark2) and small toggle endpoint used by JS/HTMX
path("exam/<int:exam_pk>/<int:sk>/mark2", views.mark2, name="mark2"),
path("exam/<int:pk>/mark2", views.mark2_overview, name="mark2_overview"),
path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"),
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))
+953 -204
View File
File diff suppressed because it is too large Load Diff
+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`
+241
View File
@@ -1,8 +1,10 @@
from django.contrib import admin
from django.contrib import messages
from .models import (
Case,
CaseDetail,
CaseCollection,
NormalCase,
Differential,
Series,
SeriesImage,
@@ -14,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)
@@ -50,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)
@@ -79,6 +93,22 @@ class CaseCollectionAdmin(VersionAdmin):
admin.site.register(CaseCollection, CaseCollectionAdmin)
class NormalCaseAdmin(VersionAdmin):
list_display = ("case", "display_age", "examination", "modality", "added_date")
list_filter = ("examination", "modality")
search_fields = ("case__title", "case__pk")
def display_age(self, obj):
try:
return obj.display_age()
except Exception:
return ""
display_age.short_description = "Age"
admin.site.register(NormalCase, NormalCaseAdmin)
class AtlasAdminForm(ModelForm):
class Meta:
model = Case
@@ -123,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
+387 -78
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,12 +39,78 @@ 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] = []
class Config:
class Meta:
model = Series
model_fields = [
fields = [
"id",
"modality",
"examination",
@@ -48,12 +124,12 @@ class SeriesSchema(ModelSchema):
class CaseSchema(ModelSchema):
class Config:
class Meta:
model = Case
model_fields = ["id", "title"]
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,34 +385,188 @@ def series_remove_duplicate_images(request, series_id: int):
return len(dupes)
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
def series_truncate(request, series_id: int, start: int, end: int):
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return {"status": "permission denied"}
images_removed = []
for n, image in enumerate(series.get_images()):
if n >= start and n <= end:
continue
else:
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
series.get_total_image_size(refresh=True)
return {"status": "success", "images_removed": images_removed}
@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)
@@ -291,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)
@@ -339,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"}
@@ -393,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__
+318 -6
View File
@@ -1,4 +1,8 @@
import django_filters
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Div, Field
from collections import OrderedDict
from .models import (
Case,
@@ -7,13 +11,18 @@ from .models import (
Finding,
PathologicalProcess,
Presentation,
Procedure,
QuestionSchema,
Series,
Structure,
Subspecialty,
NormalCase,
Resource,
Site,
)
from django.contrib.auth.models import User
from django.db.models import Q
from django.http import QueryDict
def get_authors(request):
@@ -78,6 +87,22 @@ class CaseCollectionFilter(django_filters.FilterSet):
user=None,
request=None,
):
# Default the archive filter to False when not provided so collections
# are not archived by default in listings.
if data is None:
data = QueryDict('', mutable=True)
data['archive'] = 'false'
else:
# QueryDict instances need to be copied to be mutable
try:
if 'archive' not in data:
data = data.copy()
data['archive'] = 'false'
except Exception:
# Fallback for non-QueryDict mappings
if isinstance(data, dict) and 'archive' not in data:
data = dict(data)
data['archive'] = 'false'
if not request.user.groups.filter(name="atlas_editor").exists():
queryset = queryset.prefetch_related("author").filter(
author__id=request.user.id
@@ -92,9 +117,22 @@ class CaseFilter(django_filters.FilterSet):
author = django_filters.ModelMultipleChoiceFilter(
queryset=get_authors, null_label="No author"
)
# Show a distinct list of `Series.examination` values instead of raw Series
# entries so examinations that differ only by plane/description appear once.
series = django_filters.ModelMultipleChoiceFilter(
queryset=get_series, null_label="No series"
field_name='series__examination',
queryset=lambda request: __import__('generic.models', fromlist=['Examination']).Examination.objects.filter(atlas_series_examination__isnull=False).distinct(),
label='Examination',
)
# Free-text search to help find the correct examination when there are
# many similarly-named Series/Examination entries.
examination_text = django_filters.CharFilter(method='filter_examination_text', label='Examination (text)')
# Full list of conditions and a text search to find conditions
condition = django_filters.ModelMultipleChoiceFilter(
queryset=Condition.objects.all(),
label='Condition',
)
condition_text = django_filters.CharFilter(method='filter_condition_text', label='Condition (text)')
differential_case = django_filters.ModelMultipleChoiceFilter(
label="Differential",
field_name="differentialcase__condition",
@@ -129,6 +167,58 @@ class CaseFilter(django_filters.FilterSet):
)
pass
def filter_examination_text(self, queryset, name, value):
"""Filter Cases by a text search on related Examination records.
Matches against the Examination string representation and any
common identifying fields (name/code). Uses a case-insensitive
contains lookup and filters Cases which have a Series linked to
a matching Examination.
"""
if not value:
return queryset
from generic.models import Examination
# Only attempt to match PK when the provided value looks like an integer
try:
pk_val = int(value)
except Exception:
pk_val = None
if pk_val is not None:
matches = Examination.objects.filter(
Q(examination__icontains=value) | Q(pk=pk_val)
).distinct()
else:
matches = Examination.objects.filter(
Q(examination__icontains=value)
).distinct()
return queryset.filter(series__examination__in=matches).distinct()
def filter_condition_text(self, queryset, name, value):
"""Filter Cases by a text search on related Condition records.
Matches both direct `condition` M2M and `differential` entries.
"""
if not value:
return queryset
# Only try PK match when numeric
try:
pk_val = int(value)
except Exception:
pk_val = None
if pk_val is not None:
matches = Condition.objects.filter(Q(name__icontains=value) | Q(pk=pk_val)).distinct()
else:
matches = Condition.objects.filter(Q(name__icontains=value)).distinct()
# Match either direct condition M2M or differential (through Differential)
return queryset.filter(Q(condition__in=matches) | Q(differential__in=matches)).distinct()
class SeriesFilter(django_filters.FilterSet):
case = django_filters.ModelMultipleChoiceFilter(
@@ -161,13 +251,16 @@ class SeriesFilter(django_filters.FilterSet):
class ConditionFilter(django_filters.FilterSet):
# Replace legacy `primary` filter with `is_canonical` and provide a
# `synonym` filter which will filter by the canonical group of the
# selected condition.
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Condition
fields = {
"name": ["icontains"],
"primary": ["exact"],
"synonym": ["exact"],
#"parent": ["exact"],
"subspecialty": ["exact"],
"rcr_curriculum": ["exact"],
}
@@ -186,11 +279,32 @@ class ConditionFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
# value is a Condition instance selected in the filter. Return all
# Conditions that belong to the same canonical group as `value`.
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class FindingFilter(django_filters.FilterSet):
# Replace legacy `primary`/`synonym` with canonical-based filters
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Finding
fields = ("name", "primary", "synonym")
fields = {
"name": ["icontains"],
}
def __init__(
self,
@@ -206,11 +320,29 @@ class FindingFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
# value is a Finding instance selected in the filter. Return all
# Findings that belong to the same canonical group as `value`.
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class StructureFilter(django_filters.FilterSet):
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Structure
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
fields = {"name": ["icontains"]}
def __init__(
self,
@@ -226,6 +358,19 @@ class StructureFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class PresentationFilter(django_filters.FilterSet):
class Meta:
@@ -286,6 +431,173 @@ class SubspecialtyFilter(django_filters.FilterSet):
)
pass
class NormalCaseFilter(django_filters.FilterSet):
"""FilterSet for the NormalCase model allowing filtering by
examination, modality and nearest-year age ranges.
"""
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`)
min_value = django_filters.NumberFilter(label="Min", method="filter_min_value")
max_value = django_filters.NumberFilter(label="Max", method="filter_max_value")
AGE_UNIT_CHOICES = (
("days", "Days"),
("weeks", "Weeks"),
("months", "Months"),
("years", "Years"),
)
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES, method="filter_age_unit")
class Meta:
model = NormalCase
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
from generic.models import Examination, Modality
# We'll set the actual querysets for the filters after applying
# any NormalCase queryset restrictions for the current user so
# that the choices only include examinations/modalities that
# actually have NormalCase records available.
# If not an atlas editor restrict to cases authored by the user or open access cases
if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
from django.db.models import Q
if queryset is None:
queryset = NormalCase.objects.select_related("case")
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
# Determine the NormalCase queryset we'll use to build choices
nc_qs = queryset if queryset is not None else NormalCase.objects.all()
# Limit examination and modality choices to those present on the
# available NormalCase records (respecting user restrictions).
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()
self.base_filters.get("modality").queryset = Modality.objects.all()
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
# Attach a Crispy FormHelper to render the filter form as a
# horizontal Bootstrap grid. We set `form_tag=False` because the
# template already provides the <form> element.
try:
helper = FormHelper()
helper.form_method = "get"
helper.form_tag = False
helper.form_class = "row g-2"
helper.layout = Layout(
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(
Div(
Field("min_value", wrapper_class="mb-0 d-flex align-items-center"),
Field("max_value", wrapper_class="mb-0 d-flex align-items-center"),
Field("age_unit", wrapper_class="mb-0 d-flex align-items-center"),
css_class="col-12 d-flex gap-2 align-items-center",
),
css_class="row g-2",
),
)
# Assign helper to the generated form instance and tweak widgets
if hasattr(self, 'form') and self.form is not None:
# Wider numeric inputs and default unit
try:
form = self.form
if "min_value" in form.fields:
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
if "max_value" in form.fields:
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
if "age_unit" in form.fields:
form.fields["age_unit"].initial = "days"
form.fields["age_unit"].widget.attrs.update({"class": "form-select w-auto", "style": "min-width:220px;"})
form.helper = helper
except Exception:
# don't crash if form structure differs
self.form.helper = helper
except Exception:
pass
def _convert_to_days(self, value, unit: str) -> int:
try:
v = float(value)
except Exception:
raise
if unit == "days":
return int(v)
if unit == "weeks":
return int(v * 7)
if unit == "months":
# approximate month length
return int(v * (365.25 / 12.0))
# default: years
return int(v * 365.25)
def filter_min_value(self, queryset, name, value):
try:
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
except Exception:
unit = "days"
try:
days = self._convert_to_days(value, unit)
except Exception:
return queryset
return queryset.filter(age_days__gte=days)
def filter_max_value(self, queryset, name, value):
try:
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
except Exception:
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')
site = django_filters.ModelMultipleChoiceFilter(field_name='sites', queryset=lambda request: Site.objects.all() if True else Site.objects.none())
subspecialty = django_filters.ModelMultipleChoiceFilter(field_name='subspecialty', queryset=Subspecialty.objects.all())
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label='No author')
has_file = django_filters.BooleanFilter(method='filter_has_file', label='Has file')
class Meta:
model = Resource
fields = ('name', 'site', 'subspecialty', 'author')
def filter_has_file(self, queryset, name, value):
if value is True:
return queryset.exclude(file__isnull=True).exclude(file__exact='')
elif value is False:
return queryset.filter(file__isnull=True) | queryset.filter(file__exact='')
return queryset
class QuestionSchemaFilter(django_filters.FilterSet):
class Meta:
model = QuestionSchema
+449 -78
View File
@@ -40,10 +40,15 @@ from atlas.models import (
Condition,
Structure,
Subspecialty,
Presentation,
PathologicalProcess,
Procedure,
UncategorisedDicom,
UserReportAnswer,
CaseDisplaySet,
CaseReviewMessage,
)
from .models import NormalCase
from anatomy.models import Modality
@@ -53,7 +58,8 @@ from generic.models import Examination # , Sign
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
from django.urls import reverse
from crispy_forms.bootstrap import Accordion, AccordionGroup
from tinymce.widgets import TinyMCE
@@ -111,15 +117,34 @@ 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
exclude = ["rcr_curriculum"]
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
# Use an AJAX/autocomplete single-select for canonical so users
# can search for existing canonical Conditions by name.
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
"parent": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
@@ -128,6 +153,86 @@ class ConditionForm(ModelForm):
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Crispy helper to render a submit button consistently
try:
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = True
# Primary submit
self.helper.add_input(Submit("submit", "Submit"))
# Cancel as a button that redirects back to the condition list
try:
cancel_url = reverse("atlas:condition_view")
except Exception:
cancel_url = "#"
self.helper.add_input(
Button(
"cancel",
"Cancel",
css_class="btn btn-secondary ms-2",
onclick=f"window.location='{cancel_url}'",
)
)
except Exception:
# If crispy not available or something goes wrong, don't break form
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:
@@ -141,6 +246,38 @@ class CaseCollectionForm(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
super(CaseCollectionForm, self).__init__(*args, **kwargs)
# Render collection_type as radio buttons and ensure it appears near the top
if "collection_type" in self.fields:
# Replace the model field with an explicit ChoiceField that uses RadioSelect
original = self.fields["collection_type"]
choices = getattr(original, "choices", ())
initial = original.initial if hasattr(original, "initial") else None
required = original.required
label = original.label
widget = RadioSelect(attrs={"class": "btn-check"})
# assign custom template for rendering as button group
#widget.template_name = "widgets/radioselect_buttons.html"
self.fields["collection_type"] = forms.ChoiceField(
choices=choices,
widget=widget,
initial=initial,
required=required,
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")]
@@ -178,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
]
@@ -186,6 +327,7 @@ class CaseCollectionForm(ModelForm):
Fieldset(
"Collection Details",
"name",
Field("collection_type", css_class="mb-2"),
Fieldset(
"Dates",
"restrict_to_dates",
@@ -213,10 +355,26 @@ class CaseCollectionForm(ModelForm):
"exam_mode",
"self_review",
"feedback_once_collection_complete",
"collection_type",
"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(
@@ -236,35 +394,71 @@ class CaseCollectionForm(ModelForm):
Submit("submit", "Save Collection", css_class="btn btn-primary"),
)
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
class NormalCaseForm(ModelForm):
# Keep non-model input fields for age entry so users can provide a
# value + unit (e.g. 6 months) which will be converted to canonical
# days on save. The model now stores only `age_days`.
age_value = forms.IntegerField(required=False, min_value=0)
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
class Meta:
model = NormalCase
fields = ("examination", "modality", "notes")
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
# fragment into a modal that contains the form). Rendering a second
# <form> would nest forms and cause fields to be outside the
# submitted form (HTMX would only send the outer form's fields).
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field("age_value", wrapper_class="col-md-3"),
Field("age_unit", wrapper_class="col-md-3"),
Field("examination", wrapper_class="col-md-3"),
Field("modality", wrapper_class="col-md-3"),
css_class="row",
),
required=False,
help_text="The date the exam is due to start (time is optional)",
)
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
),
required=False,
help_text="The date the exam is due to start (time is optional)",
Field("notes"),
# Submit button rendered in modal footer; avoid duplicate button here
)
def save(self, commit=True):
# Get the unsaved Case instance
instance = ModelForm.save(self, False)
# Respect commit flag. Compute canonical `age_days` from the
# provided age_value/age_unit and set it on the instance.
instance = super().save(commit=False)
age_value = self.cleaned_data.get("age_value")
age_unit = self.cleaned_data.get("age_unit")
if age_value is not None and age_unit:
instance.age_days = NormalCase.to_days(age_value, age_unit)
else:
# If the user left fields blank, clear any previous value.
instance.age_days = None
# Do we need to save all changes now?
# if commit:
instance.save()
self.save_m2m()
if commit:
instance.save()
self.save_m2m()
return instance
@@ -273,9 +467,10 @@ class FindingForm(ModelForm):
class Meta:
model = Finding
exclude = []
# Use the canonical FK in forms (if users want to mark this Finding
# as an alias of another). The old `synonym` M2M has been removed.
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
"canonical": autocomplete.ModelSelect2(
url="atlas:finding-autocomplete"
),
}
@@ -285,14 +480,47 @@ class StructureForm(ModelForm):
class Meta:
model = Structure
exclude = []
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
"canonical": autocomplete.ModelSelect2(
url="atlas:structure-autocomplete"
),
}
class SubspecialtyForm(ModelForm):
class Meta:
model = Subspecialty
exclude = []
class PresentationForm(ModelForm):
class Meta:
model = Presentation
exclude = []
class PathologicalProcessForm(ModelForm):
class Meta:
model = PathologicalProcess
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
@@ -473,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])
@@ -515,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",
),
)
@@ -546,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 = {
@@ -575,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):
@@ -606,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
@@ -644,7 +880,7 @@ CaseDifferentialFormSet = inlineformset_factory(
class ResourceForm(ModelForm):
class Meta:
model = Resource
exclude = ["author"]
exclude = ["author", "created_date"]
widgets = {
"description": Textarea(attrs={"cols": 80, "rows": 5}),
@@ -721,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,
@@ -791,7 +1033,7 @@ class BaseReportAnswerForm(ModelForm):
# "answer": "Write your answer in here."
# }
def __init__(self, *args, case_detail, **kwargs):
def __init__(self, *args, casedetail, **kwargs):
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
self.fields["answer"].required = False
@@ -807,12 +1049,12 @@ class BaseQuestionAnswerForm(ModelForm):
# "answer": "Write your answer in here."
# }
def __init__(self, *args, case_detail, **kwargs):
def __init__(self, *args, casedetail, **kwargs):
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
# self.fields["json_answer"].schema = case_detail.question_schema
if case_detail.question_schema is not None:
# self.fields["json_answer"].schema = casedetail.question_schema
if casedetail.question_schema is not None:
self.fields["json_answer"] = JSONSchemaField(
schema=case_detail.question_schema, options="schema/options.json"
schema=casedetail.question_schema, options="schema/options.json"
)
self.fields["json_answer"].label = ""
@@ -934,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"]
@@ -948,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:
@@ -973,6 +1292,37 @@ class ConditionAutocomplete(ModelAutocomplete):
model = Condition
search_attrs = ["name"]
@classmethod
def get_items_from_keys(cls, keys, context_obj=None):
"""Return items for given keys, ignoring empty strings.
The autocomplete frontend sometimes sends empty string keys which
causes the ORM to raise ValueError when filtering numeric PK fields.
Filter out falsy/blank keys before querying. If no valid keys remain
return an empty queryset.
"""
# normalize and remove empty/blank values
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
if not clean_keys:
return cls.model.objects.none()
# try to convert numeric keys to ints where possible
parsed_keys = []
for k in clean_keys:
try:
parsed_keys.append(int(k))
except Exception:
parsed_keys.append(k)
qs = cls.model.objects.filter(pk__in=parsed_keys)
# Return a list of dicts matching the autocomplete core expectations
# (items subscriptable by keys like 'key' / 'label').
return [
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
for obj in qs
]
class ConditionAutocompleteForm(Form):
condition = ModelChoiceField(
@@ -1027,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):
"""
@@ -1050,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
@@ -1087,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"),
),
)
@@ -1113,32 +1490,26 @@ class PriorCaseForm(Form):
prior_visibility = CharField(max_length=2, required=True)
# def __init__(self, *args, **kwargs):
# self.case_detail = kwargs.pop(
# "case_detail"
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
# super(PriorCaseForm, self).__init__(*args, **kwargs)
# prior_cases = self.case_detail.case.get_all_prior_cases()
# if not prior_cases:
# prior_cases = Case.objects.none()
# self.fields["case"] = ChoiceField(
# required=False,
# # widget=Select(verbose_name="Series", is_stacked=False),
# )
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):
+35
View File
@@ -0,0 +1,35 @@
# Generated by Django 5.2.7 on 2025-11-13 21:43
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0079_casecollection_prerequisites'),
('generic', '0029_add_unique_constraint_supervisor_email'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='NormalCase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('age_years', models.PositiveSmallIntegerField(blank=True, help_text='Age of the patient at scan in years (rounded to nearest year).', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(150)])),
('added_date', models.DateTimeField(default=django.utils.timezone.now)),
('notes', models.TextField(blank=True, null=True)),
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_normals', to=settings.AUTH_USER_MODEL)),
('case', models.OneToOneField(help_text='The Case that is marked as a normal example.', on_delete=django.db.models.deletion.CASCADE, related_name='normal_case', to='atlas.case')),
('examination', models.ForeignKey(blank=True, help_text='Primary examination for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.examination')),
('modality', models.ForeignKey(blank=True, help_text='Primary modality for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.modality')),
],
options={
'ordering': ['-added_date'],
},
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-11-13 22:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0080_normalcase'),
]
operations = [
migrations.RemoveField(
model_name='normalcase',
name='age_years',
),
migrations.AddField(
model_name='normalcase',
name='age_days',
field=models.PositiveIntegerField(blank=True, help_text='Patient age at scan in days (canonical).', null=True),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-11-14 20:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0081_remove_normalcase_age_years_normalcase_age_days'),
('generic', '0029_add_unique_constraint_supervisor_email'),
]
operations = [
migrations.AddField(
model_name='resource',
name='sites',
field=models.ManyToManyField(blank=True, help_text='The site(s) this resource is associated with (if any).', related_name='resources', to='generic.site'),
),
migrations.AddField(
model_name='resource',
name='subspecialty',
field=models.ManyToManyField(blank=True, help_text='Subspecialty or subspecialties this resource relates to.', to='atlas.subspecialty'),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-11-14 21:02
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0082_resource_sites_resource_subspecialty'),
]
operations = [
migrations.AddField(
model_name='resource',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name='resource',
name='modified_date',
field=models.DateTimeField(auto_now=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-15 22:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0083_resource_created_date_resource_modified_date'),
]
operations = [
migrations.AddField(
model_name='resource',
name='open_access',
field=models.BooleanField(default=True, help_text='If true the resource is available to all users, otherwise only in the context of cases.'),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-11-17 10:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0084_resource_open_access'),
]
operations = [
migrations.RenameField(
model_name='caseprior',
old_name='case_detail',
new_name='casedetail',
),
migrations.AlterUniqueTogether(
name='caseprior',
unique_together={('casedetail', 'prior_case')},
),
]
+69
View File
@@ -0,0 +1,69 @@
from django.db import migrations, models
def forwards(apps, schema_editor):
Condition = apps.get_model("atlas", "Condition")
# Build connected components using existing M2M 'synonym' links
visited = set()
for cond in Condition.objects.all():
if cond.pk in visited:
continue
# BFS/DFS to collect component
stack = [cond]
comp = []
while stack:
c = stack.pop()
if c.pk in visited:
continue
visited.add(c.pk)
comp.append(c)
# access existing M2M relations
for s in c.synonym.all():
if s.pk not in visited:
stack.append(s)
# choose canonical: prefer primary==True else lowest pk
canon = None
for c in comp:
if getattr(c, "primary", False):
canon = c
break
if not canon:
canon = min(comp, key=lambda x: x.pk)
# set canonical for all non-canonical members
for c in comp:
if c.pk != canon.pk:
c.canonical_id = canon.pk
c.save()
def backwards(apps, schema_editor):
Condition = apps.get_model("atlas", "Condition")
for c in Condition.objects.all():
if c.canonical_id:
c.canonical_id = None
c.save()
class Migration(migrations.Migration):
dependencies = [
("atlas", "0085_rename_case_detail_caseprior_casedetail_and_more"),
]
operations = [
migrations.AddField(
model_name="condition",
name="canonical",
field=models.ForeignKey(
related_name="aliases",
null=True,
on_delete=models.SET_NULL,
to="atlas.condition",
blank=True,
),
),
migrations.RunPython(forwards, backwards),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-20 22:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0086_add_canonical'),
]
operations = [
migrations.AlterField(
model_name='condition',
name='canonical',
field=models.ForeignKey(blank=True, help_text='If set, this Condition is an alias and points to the canonical Condition.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.condition'),
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.2.7 on 2025-11-24 10:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0087_alter_condition_canonical'),
]
operations = [
migrations.RemoveField(
model_name='finding',
name='primary',
),
migrations.RemoveField(
model_name='finding',
name='synonym',
),
migrations.RemoveField(
model_name='structure',
name='primary',
),
migrations.RemoveField(
model_name='structure',
name='synonym',
),
migrations.AddField(
model_name='finding',
name='canonical',
field=models.ForeignKey(blank=True, help_text='If set, this Finding is an alias and points to the canonical Finding.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.finding'),
),
migrations.AddField(
model_name='structure',
name='canonical',
field=models.ForeignKey(blank=True, help_text='If set, this Structure is an alias and points to the canonical Structure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.structure'),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.2.7 on 2025-11-24 10:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0088_remove_finding_primary_remove_finding_synonym_and_more'),
]
operations = [
migrations.RemoveField(
model_name='condition',
name='primary',
),
migrations.RemoveField(
model_name='condition',
name='synonym',
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2026-01-26 11:05
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0089_remove_condition_primary_remove_condition_synonym'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
preserve_default=False,
),
migrations.AddField(
model_name='casecollection',
name='updated',
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
),
]
@@ -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',
),
]
+1315 -90
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);
+83 -19
View File
@@ -14,10 +14,15 @@ from .models import (
Structure,
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
from easy_thumbnails.exceptions import InvalidImageFormatError
@@ -82,12 +87,14 @@ class CaseTable(SelectionTable):
def get_view_cell(record):
if record.open_access:
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>
<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>
</svg> View""")
return mark_safe(
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">'
'<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>'
'<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>'
'</svg> View'
)
else:
return f'View'
return 'View'
def render_author(self, value):
max_length = 15 # Set the maximum length for truncating individual author names
@@ -107,7 +114,7 @@ class CaseTable(SelectionTable):
)
)
# Join all authors with commas
return format_html(", ".join(authors))
return mark_safe(", ".join(authors))
def render_associated_cases(self, record):
links = []
@@ -129,7 +136,7 @@ class CaseTable(SelectionTable):
except Case.DoesNotExist:
pass
if links:
return format_html(", ".join(links))
return mark_safe(", ".join(links))
return "No associated cases"
def render_collections(self, record):
@@ -150,9 +157,10 @@ class CaseTable(SelectionTable):
)
for collection in collections
]
return format_html(", ".join(links))
return mark_safe(", ".join(links))
return "No collections"
associated_cases = tables.Column(
verbose_name="Associated Cases", accessor="pk", orderable=False
)
@@ -165,7 +173,7 @@ class CaseTable(SelectionTable):
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
)
view = tables.LinkColumn(
"atlas:case_detail", text=get_view_cell, args=[A("pk")], orderable=False
"atlas:case_detail", text=get_view_cell, args=[A("pk")], accessor="pk", orderable=False
)
clone = tables.LinkColumn(
"atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
@@ -198,7 +206,7 @@ class PopupLinkColumn(tables.Column):
obj = value.first()
if obj is None:
return format_html("<span>No image<span>")
return mark_safe("<span>No image</span>")
return obj.get_thumbnail()[0]
@@ -208,7 +216,7 @@ class SeriesTable(SelectionTable):
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
)
view = tables.LinkColumn(
"atlas:series_detail", text="View", args=[A("pk")], orderable=False
"atlas:series_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
)
popup = tables.Column(empty_values=(), orderable=False)
@@ -275,7 +283,7 @@ class ConditionTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Condition
template_name = "django_tables2/bootstrap4.html"
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
sequence = ("name",)
@@ -289,7 +297,7 @@ class ConditionTable(SelectionTable):
)
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
return mark_safe(record.get_synonym_link())
return f"{record.get_synonym_link()}"
@@ -311,12 +319,12 @@ class FindingTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Finding
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
fields = ("name",)
sequence = ("name",)
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
return mark_safe(record.get_synonym_link())
class StructureTable(SelectionTable):
@@ -338,12 +346,12 @@ class StructureTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Structure
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
fields = ("name",)
sequence = ("name",)
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
return mark_safe(record.get_synonym_link())
class PresentationTable(SelectionTable):
@@ -390,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")}),
@@ -411,16 +439,31 @@ class SubspecialtyTable(SelectionTable):
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
return mark_safe(record.get_synonym_link())
return f"{record.get_synonym_link()}"
class ResourceTable(SelectionTable):
name = tables.Column(linkify=("atlas:resource_detail", {"pk": tables.A("pk")}), verbose_name="Resource")
edit = tables.LinkColumn("atlas:resource_update", text="Edit", args=[A("pk")], orderable=False)
delete = tables.LinkColumn("atlas:resource_delete", text="Delete", args=[A("pk")], orderable=False)
class Meta(SelectionTable.Meta):
model = Resource
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "description")
class CaseCollectionTable(SelectionTable):
edit = tables.LinkColumn(
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
)
view = tables.LinkColumn(
"atlas:collection_detail", text="View", args=[A("pk")], orderable=False
"atlas:collection_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
)
created = tables.DateTimeColumn(verbose_name="Created", accessor="created", orderable=True)
updated = tables.DateTimeColumn(verbose_name="Updated", accessor="updated", orderable=True)
#clone = tables.LinkColumn(
# "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
#)
@@ -436,6 +479,8 @@ class CaseCollectionTable(SelectionTable):
template_name = "django_tables2/bootstrap4.html"
fields = (
"name",
"created",
"updated",
"collection_type",
"publish_results",
"active",
@@ -453,6 +498,25 @@ class CaseCollectionTable(SelectionTable):
**kwargs,
)
def render_created(self, value):
if not value:
return ""
# compact date for display, full datetime in tooltip
return format_html(
'<span class="small text-muted" title="{}">{}</span>',
value.strftime("%Y-%m-%d %H:%M"),
value.strftime("%Y-%m-%d"),
)
def render_updated(self, value):
if not value:
return ""
return format_html(
'<span class="small text-muted" title="{}">{}</span>',
value.strftime("%Y-%m-%d %H:%M"),
value.strftime("%Y-%m-%d"),
)
class QuestionSchemaTable(SelectionTable):
name = tables.Column(
linkify=("atlas:question_schema_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,14 +1,11 @@
<span id="add-case-form">
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
{% csrf_token %}
<label for="case">Select Case to add:</label>
<select name="case" id="case">
{% for case in cases %}
<option value="{{ case.id }}">{{ case.title }}</option>
{% endfor %}
</select>
<button type="submit">Add</button>
<button _="on click remove #add-case-form">Cancel</button>
</form>
</span
<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>
{% 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>
</div>
</div>
+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 %}
+84 -11
View File
@@ -46,6 +46,21 @@
<li>
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:case_search_page' %}"><i class="bi bi-search me-1" aria-hidden="true"></i>Search Cases</a>
</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>
{% endif %}
</ul>
</li>
<li class="nav-item dropdown">
@@ -63,21 +78,79 @@
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'atlas:categories_list' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>All categories</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'atlas:condition_view' %}">Conditions</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:finding_view' %}">Findings</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:structure_view' %}">Structures</a></li>
<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>
<li><a class="dropdown-item" href="{% url 'atlas:condition_create' %}">New condition</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:finding_create' %}">New finding</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:structure_create' %}">New structure</a></li>
<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>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{% url 'atlas:resource_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Resources</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:resource_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Resource</a>
</li>
</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>

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