Compare commits

...

493 Commits

Author SHA1 Message Date
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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
Ross 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
316 changed files with 31616 additions and 5365 deletions
+10 -2
View File
@@ -18,5 +18,13 @@ GUNICORN_WORKERS=3
GUNICORN_LOGLEVEL=info
# Nginx host ports for local development (override prod defaults)
NGINX_HTTP_PORT=8000
NGINX_HTTPS_PORT=8443
NGINX_HTTP_PORT=8080
# Pick a non-privileged HTTPS port to avoid conflicts with host installs
NGINX_HTTPS_PORT=8444
# Use Django's file-based email backend for local development so messages
# are written to files under the mounted `./logs/emails` directory and are
# easy to inspect on the host.
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
#EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
EMAIL_FILE_PATH=/var/log/rad/emails
+4 -2
View File
@@ -14,5 +14,7 @@ venv
temp/
# Loki runtime data
/docker/loki-data/
/docker/loki-wal/
docker/loki-data/
docker/loki-wal/
# Local runtime logs collected by Promtail
/logs/
+117
View File
@@ -0,0 +1,117 @@
# 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`).
### 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.
## 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/`.
## 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)
```bash
# Start dev environment
COMPOSE_ENV=dev docker compose -f docker/docker-compose.dev.yml up -d --build
# Run management commands inside the container
docker compose exec web python manage.py migrate
docker compose exec web python manage.py createsuperuser
```
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
+118
View File
@@ -0,0 +1,118 @@
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
```
Viewing logs in Grafana
- Grafana is available on the port exposed by compose (default `3000`).
- The Loki datasource is configured to use `http://loki:3100` (see compose). In Grafana Explore choose the Loki datasource and run queries such as:
- `{job="rad_app"}` (all collected logs)
- `{job="rad_app", filename="/var/log/rad/nginx.access.log"}`
Notes & recommendations
- In development the `logs/` folder is ignored by git (`/.gitignore` updated). Do not commit runtime logs.
- Consider a log rotation/retention policy for production (e.g., `logrotate` or use Loki retention policies).
- If you need nginx to re-resolve the backend service IP without restarting, consider using `resolver` and variable-based upstreams in the nginx config. For many development workflows restarting nginx after web restarts is the simplest approach.
If you want, I can also add a short `docs/OBSERVABILITY.md` with more details and recommended production settings (TLS/auth for Loki, retention, log rotation).
+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).
+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
@@ -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'),
),
]
+37 -27
View File
@@ -116,6 +116,8 @@ class AnatomyQuestion(QuestionBase):
)
structure = models.ForeignKey(
Structure, on_delete=models.SET_NULL, null=True, blank=True
, help_text="The structure that is being asked about in the question, this is used for categorisation and search purposes but is not required to be set for a question to be valid."
)
author = models.ManyToManyField(
@@ -125,6 +127,15 @@ class AnatomyQuestion(QuestionBase):
related_name="anatomy_authored_questions",
)
primary_answer = models.ForeignKey(
'Answer',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='primary_for_questions',
help_text="Optional explicit primary answer for this question",
)
class Meta:
permissions = ()
@@ -149,33 +160,32 @@ class AnatomyQuestion(QuestionBase):
def get_absolute_url(self):
return reverse("anatomy:question_detail", kwargs={"pk": self.pk})
def get_primary_answer(self):
if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer:
ans = self.prefetched_primary_answer[0]
else:
ans = self.answers.filter(
proposed=False, status=Answer.MarkOptions.CORRECT
).first()
# ans = self.answers.first()
def get_primary_answer_obj(self):
"""Return the Answer instance considered the primary answer, or None.
Preference order:
- explicit `primary_answer` FK if set
- `prefetched_primary_answer` if provided by a queryset
- first non-proposed Answer with status CORRECT
"""
if getattr(self, "primary_answer", None):
return self.primary_answer
if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer:
return self.prefetched_primary_answer[0]
return self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).first()
def get_primary_answer(self):
"""Return the primary answer text (string) for templates and display.
Keeps backward compatibility by returning the answer string when
available, or "None yet..." if no primary answer is defined.
"""
ans = self.get_primary_answer_obj()
if ans is None:
return "None yet..."
else:
return ans.answer
if (
self.answers.filter(
proposed=False, status=Answer.MarkOptions.CORRECT
).count()
> 0
):
return (
self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT)
.first()
.answer
)
else:
return "None yet..."
return ans.answer
def get_exams(self):
e = self.exams.all().values_list("name", flat=True)
@@ -191,9 +201,9 @@ class AnatomyQuestion(QuestionBase):
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):
+17
View File
@@ -20,6 +20,11 @@ $(document).ready(function () {
document.body.addEventListener("htmx:responseError", function(e) {
let el = document.getElementById("htmx-error");
if (!el) {
// No global error container available; log and return to avoid JS errors
console.error('htmx:responseError but no #htmx-error element present', e.detail);
return;
}
var error;
if (e.detail.pathInfo.requestPath.includes("cimar") && e.detail.xhr.status == 403) {
error = "Cimar permission error, please login and refresh the page.";
@@ -207,6 +212,12 @@ window.loadDicomViewerEvent = new Event('loadDicomViewerUrls');
window.addEventListener('loadDicomViewerUrls', function (e) {
console.log("LoadDicomViewer unls event", e.detail)
// Skip loading old viewer if using new dv3d viewer (set on new_uploads page)
if (window.skipLoadDicomViewer) {
console.log("Skipping old loadDicomViewer - using dv3d instead");
return;
}
loadDicomViewer(e.detail.images, e.detail.annotations);
}, false);
@@ -245,6 +256,12 @@ function clearableTextAreaSetup() {
}
function loadDicomViewer(images_to_load, annotations_to_load) {
// Skip loading old viewer if using new dv3d viewer (set on new_uploads page)
if (window.skipLoadDicomViewer) {
console.log("Skipping old loadDicomViewer - using dv3d instead");
return;
}
console.log("loadDicomViewer", images_to_load);
let single_dicom = document.getElementById("single-dicom-viewer");
if (single_dicom) {
+3 -5
View File
@@ -1,7 +1,7 @@
import django_tables2 as tables
from django_tables2.utils import A
from generic.tables import SingleImageColumn
from generic.tables import SingleImageColumn, SelectionTable
from .models import AnatomyQuestion, UserAnswer
@@ -13,7 +13,7 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
#from generic.tables import UserAnswerTable
class AnatomyQuestionTable(tables.Table):
class AnatomyQuestionTable(SelectionTable):
edit = tables.LinkColumn(
"anatomy:anatomy_question_update", text="Edit", args=[A("pk")], orderable=False
)
@@ -30,9 +30,7 @@ class AnatomyQuestionTable(tables.Table):
)
exams = tables.ManyToManyColumn(verbose_name="Exams")
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
class Meta:
class Meta(SelectionTable.Meta):
model = AnatomyQuestion
template_name = "django_tables2/bootstrap4.html"
fields = (
@@ -228,11 +228,19 @@
<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 %}
@@ -241,10 +249,9 @@
<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>
{{ 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 %}
+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 %}
+142 -68
View File
@@ -1,81 +1,155 @@
{% extends 'base.html' %}
{% 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">
{% 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 %}
</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%}
Marks: <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 %}
@@ -0,0 +1,24 @@
{% load static %}
<div id="exam-list">
<form id="exam-edit-form">
{% csrf_token %}
{{ form.media }}
<div class="input-group mb-2">
{{ form.exam }}
<button class="btn btn-sm btn-primary" type="button" hx-post="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-include="#id_exam" hx-target="#exam-list" hx-swap="innerHTML">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('exam-list').innerHTML='';">Close</button>
</div>
</form>
<div class="mb-2 small">Current exams:
{% for exam in question.exams.all %}
<form class="d-inline me-1" hx-post="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="exam_id" value="{{ exam.id }}">
<input type="hidden" name="remove" value="true">
<button class="btn btn-sm btn-outline-danger mb-1" type="submit" hx-confirm="Are you sure you want to remove this exam?">{{ exam }}</button>
</form>
{% empty %}
<span class="text-muted">No exams</span>
{% endfor %}
</div>
</div>
@@ -0,0 +1,9 @@
<div id="exam-links" class="small" hx-swap-oob="outerHTML">
{% if question.exams.all %}
{% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
{% endfor %}
{% else %}
No exams
{% endif %}
</div>
@@ -0,0 +1,27 @@
<div id="primary-answer-container" class="position-relative primary-answer-wrap py-1">
<strong>{{ question.get_primary_answer }}</strong>
{% if can_edit %}
<button
class="edit-primary-btn btn btn-sm btn-outline-secondary position-absolute"
style="right:0; top:0;"
hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}"
hx-target="#primary-answer-container"
hx-swap="outerHTML"
aria-label="Edit primary answer"
>
Edit primary answer
</button>
{% endif %}
<style>
/* Scoped styles for primary answer edit control: match outline-secondary look */
.primary-answer-wrap .edit-primary-btn {
opacity: 0.9;
transition: opacity 120ms ease, transform 120ms ease;
}
.primary-answer-wrap:hover .edit-primary-btn {
opacity: 1;
transform: none;
}
</style>
</div>
@@ -0,0 +1,4 @@
<div id="primary-answer-container">
<span class="small text-muted">No primary answer set</span>
<button class="btn btn-sm btn-link" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">Add primary answer</button>
</div>
@@ -0,0 +1,58 @@
{% load crispy_forms_tags %}
<div id="primary-answer-container">
<form hx-post="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">
{% csrf_token %}
<div class="mb-2">
<label for="id_existing_answer" class="form-label">Select existing answer</label>
<select name="existing_answer" id="id_existing_answer" class="form-select">
{% for val, text in form.existing_answer.field.choices %}
<option value="{{ val }}" {% if val == form.initial.existing_answer %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
</div>
<div class="mb-2">
<label for="id_new_answer" class="form-label">Or add a new primary answer</label>
<textarea name="new_answer" id="id_new_answer" class="form-control" rows="3"></textarea>
<script>
(function(){
const sel = document.getElementById('id_existing_answer');
const ta = document.getElementById('id_new_answer');
if(!sel || !ta) return;
function update() {
try {
if (ta.value && ta.value.trim().length > 0) {
sel.disabled = true;
} else {
sel.disabled = false;
}
} catch(e) {
// ignore
}
}
// initial state
update();
// listeners
sel.addEventListener('change', function(){
if(this.value && this.value !== '') {
ta.disabled = true;
} else {
ta.disabled = false;
}
});
ta.addEventListener('input', update);
})();
</script>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" type="submit">Save primary answer</button>
<button class="btn btn-secondary btn-sm" type="button" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}?cancel=1" hx-target="#primary-answer-container" hx-swap="outerHTML">Cancel</button>
</div>
</form>
</div>
@@ -9,8 +9,9 @@
<span class="small text-muted">No suggestions</span>
{% endif %}
</div>
<div class="mt-1">
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
</div>
{% if can_edit %}
<div class="mt-1">
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
</div>
{% endif %}
</div>
+96 -25
View File
@@ -1,5 +1,5 @@
{% extends 'anatomy/base.html' %}
{% load partials %}
{% block content %}
{% load static %}
{% include 'anatomy/question_link_header.html' %}
@@ -8,16 +8,30 @@
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-body">
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=true style="min-height:300px">
</div>
<div class="mt-3 d-flex justify-content-between align-items-center">
<details class="mb-0">
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image 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.</div>
</details>
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation="{{ can_edit|yesno:'true,false' }}" style="min-height:300px">
</div>
{% if not question.image_annotations %}
<div class="alert alert-warning mt-3" role="alert">
<strong>No image annotations defined.</strong>
<div class="small">If important markers or arrows are not burned onto the image, please add annotations using the image annotation tool and click <em>Save Annotations</em>. Please note not all questions required image annotations in which case you can ignore this message.</div>
</div>
{% endif %}
{% if can_edit %}
<div class="mt-3 align-items-start">
<div class="col" style="min-width:0;">
<details class="mb-0">
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image annotation help</summary>
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist. If required use the middle button to zoom and the left button to pan.</div>
</details>
</div>
<div class="col-auto">
<div class="d-flex gap-2">
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
<button id="delete-annotations" class="btn btn-sm btn-outline-danger">Delete Annotations</button>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -36,9 +50,29 @@
</div>
<h6 class="mt-2">Primary answer</h6>
<div class="mb-2"><strong>{{ question.get_primary_answer }}</strong></div>
{% if question.primary_answer %}
{% include 'anatomy/partials/primary_answer_display.html' %}
{% else %}
<div id="primary-answer-container" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-trigger="load" hx-swap="outerHTML"></div>
{% endif %}
<h6>Answers</h6>
<style>
.answer.correct { color: #155724; }
.answer.half-correct { color: #856404; }
.answer.incorrect { color: #721c24; }
.marked-correct { border-left: 4px solid #2a9d3f; }
.marked-half-correct { border-left: 4px solid #ffb020; }
.marked-incorrect { border-left: 4px solid #d3413a; }
</style>
<div class="d-flex justify-content-between align-items-start">
<h6 class="mb-0">Answers</h6>
<div>
{% if can_edit %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">Edit answers</a>
{% endif %}
</div>
</div>
<details class="mb-3">
<summary class="small">Show answers</summary>
<table class="table table-sm mt-2">
@@ -47,7 +81,8 @@
</thead>
<tbody>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
{% comment %}Add visual mark state classes consistent with mark2{% endcomment %}
<tr class="{% if answer.status == '2' %}marked-correct{% elif answer.status == '1' %}marked-half-correct{% elif answer.status == '0' %}marked-incorrect{% endif %}">
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"{% endif %}>
{% if answer.status == "0" %}
<div class="d-flex align-items-start">
@@ -63,9 +98,19 @@
<pre class="mb-0">{{ answer.answer }}</pre>
{% endif %}
</td>
<td>{{ answer.get_status_display|default:answer.status }}</td>
<td>
{% if answer.proposed %}
{% if answer.status == '2' %}
<span class="answer correct">{{ answer.get_status_display|default:'Correct' }}</span>
{% elif answer.status == '1' %}
<span class="answer half-correct">{{ answer.get_status_display|default:'Half mark' }}</span>
{% elif answer.status == '0' %}
<span class="answer incorrect">{{ answer.get_status_display|default:'Incorrect' }}</span>
{% else %}
<span class="answer not-marked">{{ answer.get_status_display|default:'Unmarked' }}</span>
{% endif %}
</td>
<td>
{% if answer.proposed and can_edit %}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success accept-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:confirm_answer' answer.id %}" title="Accept">Accept</button>
<button class="btn btn-outline-danger delete-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:delete_answer' answer.id %}" title="Delete">Delete</button>
@@ -111,15 +156,12 @@
<div class="mt-3">
<h6>Exams</h6>
<div class="small">
{% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
{% empty %}
No exams
{% endfor %}
</div>
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span></div>
{% include 'anatomy/partials/exam_links.html' %}
{% if can_edit %}
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span>
</div>
{% endif %}
</div>
{% include 'question_notes.html' %}
@@ -127,7 +169,13 @@
<div class="mt-3">
<details>
<summary class="small">Annotation JSON</summary>
<div class="mt-2 small">{% if question.image_annotations %}<pre class="mb-0">{{ question.image_annotations }}</pre>{% else %}No saved annotations.{% endif %}</div>
<div class="mt-2 small">
{% if question.image_annotations %}
{% include 'atlas/partials/json_pretty.html' with json_value=question.image_annotations title='Image annotations' %}
{% else %}
No saved annotations.
{% endif %}
</div>
</details>
</div>
@@ -174,6 +222,29 @@
console.log('[Done]');
})
})
$("#delete-annotations").click(function () {
if (!confirm('Clear saved annotations for this question? This cannot be undone.')) return;
$.ajax({
url: "{% url 'anatomy:question_clear_annotation' pk=question.pk %}",
data: {
csrfmiddlewaretoken: "{{ csrf_token }}",
},
type: "POST",
dataType: "json",
})
.done(function (data) {
if (data.status == "success") {
toastr.info('Annotations cleared');
// Optionally reload part of the page or remove the annotation overlay
location.reload();
} else {
toastr.warning('Error clearing annotations');
}
})
.fail(function () {
toastr.warning('Error clearing annotations');
});
});
});
@@ -42,18 +42,19 @@
<i class="bi bi-eye me-2"></i> View
</a>
</li>
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
<i class="bi bi-pencil-square me-2"></i> Edit
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
<i class="bi bi-list-check me-2"></i> Edit Answers
</a>
</li>
{% if can_edit %}
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
<i class="bi bi-pencil-square me-2"></i> Edit
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
<i class="bi bi-list-check me-2"></i> Edit Answers
</a>
</li>
{% endif %}
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_clone' question.id %}">
<i class="bi bi-files me-2"></i> Clone
@@ -66,6 +67,7 @@
</a>
</li>
{% if can_edit %}
<li><hr class="dropdown-divider"></li>
<li>
@@ -73,6 +75,7 @@
<i class="bi bi-trash me-2"></i> Delete
</a>
</li>
{% endif %}
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
+6
View File
@@ -33,6 +33,11 @@ urlpatterns = [
views.question_save_annotation,
name="question_save_annotation",
),
path(
"question/<int:pk>/clear_annotation",
views.question_clear_annotation,
name="question_clear_annotation",
),
path(
"question/<int:pk>/suggest_incorrect_answers",
views.question_suggest_incorrect_answers,
@@ -114,6 +119,7 @@ urlpatterns = [
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
path("question/<int:pk>/primary_answer/", views.primary_answer_htmx, name="primary_answer_htmx"),
]
urlpatterns.extend(generic_view_urls(views.GenericViews))
+166 -137
View File
@@ -30,6 +30,7 @@ from dal import autocomplete
from django.conf import settings
from django.utils.html import format_html_join
from django.utils.safestring import mark_safe
from django.template.loader import render_to_string
from .forms import (
@@ -46,6 +47,7 @@ from .forms import (
AnatomyQuestionForm,
ExamForm,
SuggestIncorrectAnswerForm,
PrimaryAnswerForm,
)
from .models import (
AnatomyQuestion,
@@ -58,6 +60,21 @@ from .models import (
# HalfMarkAnswers,
# IncorrectAnswers,
)
from generic.widgets import ExamSearchWidget
class AddExamForm(forms.Form):
exam = forms.ModelChoiceField(queryset=Exam.objects.none(), required=True, widget=ExamSearchWidget(exam_model=Exam), label="Exam to add")
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
if user is None:
qs = Exam.objects.none()
elif user.groups.filter(name="anatomy_checker").exists():
qs = Exam.objects.all()
else:
qs = Exam.objects.filter(author__id=user.id) | Exam.objects.filter(open_access=True)
self.fields["exam"].queryset = qs.distinct()
from generic.models import CidUser, Examination
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamGroupsUpdateBase, ExamUpdateBase, ExamViews, GenericViewBase, UpdateQuestionMixin
from reversion.views import RevisionMixin
@@ -786,6 +803,9 @@ def mark2_overview(request, pk):
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
exam = get_object_or_404(Exam, pk=pk)
if not GenericExamViews.check_user_marker_access(request.user, pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
@@ -1045,102 +1065,6 @@ def mark2_question_marked(request, pk):
return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context)
#@login_required
#def exam_scores_refresh(request, pk):
# exam = get_object_or_404(Exam, pk=pk)
#
# if not exam.exam_mode:
# raise Http404("Packet not in exam mode")
#
# questions = exam.exam_questions.all() # Don't worry about order here
#
# cids = UserAnswer.objects.filter(question__in=questions, exam__id=pk)
#
# # Force a score update
# for c in cids:
# c.get_answer_score(cached=False)
#
# return render(
# request,
# "anatomy/base.html",
# {
# "simple_content": "Answer scores updated",
# },
# )
def exam_scores_cid_user(request, pk, cid, passcode):
exam = get_object_or_404(Exam, pk=pk)
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
if not exam.check_cid_user(cid, passcode, request.user):
raise Http404("Error accessing exam")
questions = exam.get_questions().prefetch_related("answers")
answers_and_marks = []
answers_marks = []
answers = []
view_all_results = False
if request.user.groups.filter(name="view_all_results").exists():
view_all_results = True
for q in questions:
# Get user answer
user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first()
if not user_answer:
# skip if no answer
answers_marks.append(0)
answers.append("")
answer_score = 0
ans = "Not answered"
else:
ans = user_answer.answer
answer_score = user_answer.get_answer_score()
correct_answer = q.get_primary_answer()
if not exam.publish_results and not view_all_results:
correct_answer = "*****"
answer_score = 0
answers.append(ans)
answers_marks.append(answer_score)
answers_and_marks.append((ans, answer_score, correct_answer))
if "unmarked" in answers_marks:
answered = [i for i in answers_marks if type(i) == int]
total_score = sum(answered)
unmarked_number = len(answers_marks) - len(answered)
total_score = "{} ({} unmarked)".format(total_score, unmarked_number)
else:
total_score = sum(answers_marks)
max_score = len(questions) * 2
return render(
request,
"anatomy/exam_scores_user.html",
{
"exam": exam,
"cid": cid,
"passcode": passcode,
"questions": questions,
"answers": answers,
"answers_marks": answers_marks,
"total_score": total_score,
"max_score": max_score,
"answers_and_marks": answers_and_marks,
"view_all_results": view_all_results,
},
)
class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
model = AnatomyQuestion
form_class = AnatomyQuestionForm
@@ -1318,10 +1242,16 @@ def create_body_part(request):
)
@csrf_exempt
@login_required
def get_body_part_id(request):
if request.accepts("application/json")():
body_part_name = request.GET["body_part_name"]
"""Return the primary key for a BodyPart looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up a BodyPart by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
body_part_name = request.GET.get("body_part_name", "")
body_part_id = BodyPart.objects.get(name=body_part_name).id
data = {
"body_part_id": body_part_id,
@@ -1344,10 +1274,16 @@ def create_examination(request):
)
@csrf_exempt
@login_required
def get_examination_id(request):
if request.accepts("application/json")():
examination_name = request.GET["examination_name"]
"""Return the primary key for an Examination looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up an Examination by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
examination_name = request.GET.get("examination_name", "")
examination_id = Examination.objects.get(name=examination_name).id
data = {
"examination_id": examination_id,
@@ -1358,10 +1294,16 @@ def get_examination_id(request):
@csrf_exempt
@login_required
def get_structure_id(request):
if request.accepts("application/json")():
structure_name = request.GET["structure_name"]
"""Return the primary key for a Structure looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up a Structure by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
structure_name = request.GET.get("structure_name", "")
structure_id = Structure.objects.get(name=structure_name).id
data = {
"structure_id": structure_id,
@@ -1596,6 +1538,17 @@ def question_save_annotation(request, pk):
return JsonResponse(data, status=400)
@user_is_author_or_anatomy_checker
def question_clear_annotation(request, pk):
"""Clear the stored image annotations for a question."""
if request.method == "POST":
question = get_object_or_404(AnatomyQuestion, pk=pk)
question.image_annotations = ""
question.save(update_fields=["image_annotations"])
return JsonResponse({"status": "success"}, status=200)
return JsonResponse({"status": "error"}, status=400)
GenericExamViews = ExamViews(
Exam, AnatomyQuestion, Answer, UserAnswer, "anatomy", "anatomy"
)
@@ -1708,44 +1661,120 @@ def question_add_exam(request, question_id: int):
return HttpResponse("Invalid permissions", status=403)
if request.POST:
# POST: handle add/remove; if exam_id provided use existing behaviour
if request.method == "POST":
exam_id = request.POST.get("exam_id", None)
if exam_id is None:
return HttpResponse("No exam id provided", status=400)
if exam_id:
exam = get_object_or_404(Exam, pk=exam_id)
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
else:
question.exams.add(exam)
exam = get_object_or_404(Exam, pk=exam_id)
# return out-of-band swap: update exam links and remove the editor
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
return HttpResponse(mark_safe(links_html + oob_remove))
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
return HttpResponse("Question removed from exam.")
# else try to process from the search form or widget-provided values
# The ExamSearchWidget may submit a list; accept first value if present.
exam_vals = request.POST.getlist('exam') if hasattr(request.POST, 'getlist') else []
if not exam_vals:
# also accept single 'exam' param
single = request.POST.get('exam')
if single:
exam_vals = [single]
if exam_vals:
try:
exam = get_object_or_404(Exam, pk=int(exam_vals[0]))
except (ValueError, TypeError):
exam = None
if exam:
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
else:
question.exams.add(exam)
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
return HttpResponse(mark_safe(links_html + oob_remove))
# else try to process via bound form (fallback)
form = AddExamForm(request.POST, user=request.user)
if form.is_valid():
exam = form.cleaned_data.get("exam")
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
else:
question.exams.add(exam)
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
return HttpResponse(mark_safe(links_html + oob_remove))
else:
question.exams.add(exam)
# render form with errors
return render(request, "anatomy/partials/exam_edit_fragment.html", {"form": form, "question": question})
return HttpResponse("Question added to exam.")
# GET: render the fragment with the add form and current exams
form = AddExamForm(user=request.user)
return render(request, "anatomy/partials/exam_edit_fragment.html", {"form": form, "question": question})
raise PermissionDenied()
@login_required
def primary_answer_htmx(request, pk: int):
"""HTMX endpoint to render or update a question's primary answer.
GET: render the form for selecting/creating primary answer.
POST: create/select answer and set `question.primary_answer`, then
return the display fragment.
"""
if not request.htmx:
raise PermissionDenied()
question = get_object_or_404(AnatomyQuestion, pk=pk)
if not question.can_edit(request.user):
return HttpResponse("Invalid permissions", status=403)
if request.method == "POST":
form = PrimaryAnswerForm(request.POST, question=question)
if form.is_valid():
new_text = form.cleaned_data.get("new_answer", "").strip()
existing = form.cleaned_data.get("existing_answer", "")
if new_text:
# create a new Answer and mark it CORRECT
ans = Answer(question=question, answer=new_text, proposed=False, status=Answer.MarkOptions.CORRECT)
ans.save()
question.primary_answer = ans
question.save(update_fields=["primary_answer"])
elif existing:
try:
aid = int(existing)
ans = Answer.objects.get(pk=aid, question=question)
question.primary_answer = ans
question.save(update_fields=["primary_answer"])
except (ValueError, Answer.DoesNotExist):
return HttpResponse("Invalid answer choice", status=400)
# return display fragment
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question, "can_edit": question.can_edit(request.user)})
else:
# Return a list of exams that we can add to the question
return HttpResponse("Invalid form", status=400)
exams = Exam.objects.filter(author=request.user)
exams = exams.difference(question.exams.all())
# Handle cancel request to restore original state
if request.method == "GET" and request.GET.get("cancel"):
if question.primary_answer:
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question, "can_edit": question.can_edit(request.user)})
else:
return render(request, "anatomy/partials/primary_answer_empty.html", {"question": question, "can_edit": question.can_edit(request.user)})
if not exams:
return HttpResponse("No exams available to add")
html = "Exams to add to question: <br>"
for exam in exams:
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><button hx-post=\"{request.path}\""
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<br>Exams to remove from question: <br>"
for exam in question.exams.all():
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><input name='remove' value='true' type='hidden'><button hx-post=\"{request.path}\""
f"hx-confirm='Are you sure you want to remove this exam from the question?'"
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<button _='on click remove #htmx-exam-list'>Cancel</button>"
return HttpResponse(mark_safe(html))
raise PermissionDenied()
# GET - render form
form = PrimaryAnswerForm(question=question)
return render(request, "anatomy/partials/primary_answer_form.html", {"form": form, "question": question, "can_edit": question.can_edit(request.user)})
+255
View File
@@ -0,0 +1,255 @@
# Atlas Widgets
This document describes the reusable search widgets provided by Atlas, how they work, and how to use them safely.
## Scope
These widgets are Django inclusion tags implemented in `atlas/templatetags/case_widgets.py` and rendered through partial templates in `atlas/templates/atlas/partials/`.
Current widgets:
- `case_search_widget`
- `finding_search_widget`
- `displayset_search_widget`
They are HTMX-first widgets with JavaScript fallback to `fetch`.
## Load The Tags
In any template that uses these widgets:
```django
{% load case_widgets %}
```
## 1) case_search_widget
### Tag Signature
```django
{% case_search_widget
collection=None
cases=None
recent_cases=None
input_id='case-search-input'
target_id='case-search-results'
selection_mode='single'
action_url=None
show_select_button=False
%}
```
### Parameters
- `collection`:
Optional collection object. When set, search requests include `collection=<pk>` and rows render an `Add` action.
- `cases`:
Optional initial case queryset/list for first render.
- `recent_cases`:
Optional initial recent list. If omitted, widget auto-populates with up to 5 recent cases authored by the current user.
- `input_id`:
DOM id for the search input. Must be unique per page.
- `target_id`:
DOM id for results container. Must be unique per page.
- `selection_mode`:
Present in tag API but currently not used by rendering logic.
- `action_url`:
Present in tag API but currently not used by rendering logic.
- `show_select_button`:
When `True`, rows include a `Select` button and clicking rows emits selection events.
### How It Works
- Input triggers search on: `keyup changed delay:400ms`.
- HTMX call target: `atlas:case_search` (`/atlas/collection/case_search`).
- The widget includes all local hidden state via:
- `hx-include="closest .case-search-widget"`
- Search results are swapped into the configured `target_id`.
- If JavaScript cannot use HTMX, fallback `fetch` performs the same GET and swaps `innerHTML`.
### Events Emitted
The widget dispatches a bubbling custom event from the widget container:
- Event name: `case:selected`
- Detail payload:
- `casePk` (string)
- `caseTitle` (string)
Example listener:
```javascript
document.body.addEventListener('case:selected', function (e) {
const pk = e.detail && e.detail.casePk;
const title = e.detail && e.detail.caseTitle;
if (!pk) return;
// consume selection
});
```
### Result Row Contract
Rows are rendered with:
- `data-case-pk`
- visible title in `h6`
Actions per mode:
- `collection` provided: `Add` form + `View`
- no collection, `show_select_button=True`: `Select` + `View`
- no collection, `show_select_button=False`: `View`
### Common Usage Patterns
#### A. Case picker behavior (select destination/import target)
```django
{% case_search_widget
collection=None
input_id='move-series-case-search-input'
target_id='move-series-case-search-results'
show_select_button=True
%}
```
Then consume `case:selected` and write `casePk` to your hidden form field.
#### B. Add to collection flow
```django
{% case_search_widget
collection=collection
cases=cases
recent_cases=recent_cases
input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s'
target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s'
%}
```
No event handling is required; rows provide server-posted `Add` action.
### Endpoint Notes
The widget uses `atlas:case_search` for HTMX/fetch updates. That view supports:
- `q` search text
- `collection` optional collection id
- `show_select` optional flag to preserve select-button rendering on refresh
## 2) finding_search_widget
### Tag Signature
```django
{% finding_search_widget
case=None
findings=None
input_id='finding-search-input'
target_id='finding-search-results'
%}
```
### Parameters
- `case`:
Optional case instance; when present, search is filtered to findings linked to series in that case.
- `findings`:
Optional initial findings list.
- `input_id`, `target_id`:
DOM ids. Must be unique per page.
### How It Works
- HTMX endpoint: `atlas:finding_search_partial` / `atlas:finding_search`.
- Search query is sent as `q`.
- Case filter (if set) is sent as `case=<pk>`.
- Widget shows a case filter badge and a clear (`x`) button.
- Script mirrors active case into dataset fields used by markup insertion workflows:
- `input.dataset.currentCase`
- nearby `textarea.dataset.currentCase`
### Result Row Contract
Rows are rendered with:
- `data-finding-pk`
- `data-seriesfinding-pk`
- `data-label`
These attributes are consumed by `atlas/static/atlas/js/markup_inserter.js` to insert tokens like:
- `[[seriesfinding:<pk>|<label>]]`
- `[[finding:<pk>|<label>]]`
## 3) displayset_search_widget
### Tag Signature
```django
{% displayset_search_widget
case=None
displaysets=None
input_id='displayset-search-input'
target_id='displayset-search-results'
%}
```
### Parameters
- `case`:
Optional case instance; when present, search is filtered to display sets in that case.
- `displaysets`:
Optional initial list.
- `input_id`, `target_id`:
DOM ids. Must be unique per page.
### How It Works
- HTMX endpoint: `atlas:displayset_search_partial` / `atlas:displayset_search`.
- Query sent as `q`; case scoping sent as `case=<pk>`.
- Includes case badge + clear button behavior similar to finding widget.
- Also mirrors case context into input/textarea datasets for markup insertion workflows.
### Result Row Contract
Rows are rendered with:
- `data-ds-pk`
- `data-label`
Used by `atlas/static/atlas/js/markup_inserter.js` to insert:
- `[[displayset:<pk>|<label>]]`
## Integration Guidelines
- Keep each widget instance ids unique:
- unique `input_id`
- unique `target_id`
- Prefer one event listener per feature container or page for `case:selected`.
- Do not depend on button text (`Select`, `View`) in JS. Use data attributes and events.
- If your workflow needs selection, set `show_select_button=True` on `case_search_widget`.
- If your workflow is add-to-collection, pass `collection` and rely on row `Add` forms.
## Known Constraints
- `selection_mode` and `action_url` are currently API placeholders for `case_search_widget`; they are not applied by templates/views yet.
- Widget scripts are inline with each widget include. If many instances are rendered, listeners are instance-scoped but still duplicated per include.
## Quick Troubleshooting
- No results update:
- verify `input_id` and `target_id` are unique and present in DOM.
- verify endpoint routes exist and user is authenticated.
- Selection not received:
- ensure `show_select_button=True` and listener is bound (for example on `document.body`).
- inspect `case:selected` payload in DevTools.
- Case scoping not applied in finding/displayset widgets:
- ensure `case` is passed to the tag.
- verify `case=<pk>` appears in network requests.
## Source Files
- `atlas/templatetags/case_widgets.py`
- `atlas/templates/atlas/partials/case_search_widget.html`
- `atlas/templates/atlas/partials/case_search_results.html`
- `atlas/templates/atlas/partials/_case_search_item.html`
- `atlas/templates/atlas/partials/finding_search_widget.html`
- `atlas/templates/atlas/partials/_finding_search_results.html`
- `atlas/templates/atlas/partials/displayset_search_widget.html`
- `atlas/templates/atlas/partials/_displayset_search_results.html`
- `atlas/views.py` (`case_search`, `finding_search`, `displayset_search`)
- `atlas/static/atlas/js/markup_inserter.js`
+203 -1
View File
@@ -1,4 +1,5 @@
from django.contrib import admin
from django.contrib import messages
from .models import (
Case,
CaseDetail,
@@ -15,24 +16,33 @@ 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 tinymce.widgets import TinyMCE
# from generic.models import Examination, Sign, Site, Condition
from generic.models import Examination
try:
from django_tasks_db.models import DBTaskResult
except ImportError:
DBTaskResult = None
# Register your models here.
# admin.site.register(Examination)
# admin.site.register(Site)
@@ -51,6 +61,7 @@ admin.site.register(UncategorisedDicom)
admin.site.register(SeriesDetail)
admin.site.register(Resource)
admin.site.register(CaseDisplaySet)
admin.site.register(Procedure)
admin.site.register(UserReportAnswer)
admin.site.register(CidReportAnswer)
@@ -82,7 +93,7 @@ admin.site.register(CaseCollection, CaseCollectionAdmin)
class NormalCaseAdmin(VersionAdmin):
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
list_display = ("case", "display_age", "examination", "modality", "added_date")
list_filter = ("examination", "modality")
search_fields = ("case__title", "case__pk")
@@ -140,3 +151,194 @@ 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="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",
"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.delete_selected_completed = delete_selected_completed
admin_cls.delete_selected_failed = delete_selected_failed
admin_cls.has_add_permission = has_add_permission
+383 -74
View File
@@ -2,6 +2,8 @@ from collections import defaultdict
from typing import List, Tuple
from django.shortcuts import get_object_or_404
from django.urls import reverse
import json
from django.http import JsonResponse, HttpResponse
from ninja import ModelSchema, Router, Schema, Field
from django.db import transaction, IntegrityError
@@ -14,14 +16,22 @@ from pydicom.errors import InvalidDicomError
from generic.decorators import check_user_in_group
from generic.constants import Group
from ninja.security import django_auth
from ninja.security import django_auth, HttpBearer
from ninja.errors import HttpError
from ninja import NinjaAPI, File
from ninja.files import UploadedFile
import hashlib
import secrets
from django.utils import timezone
from django.contrib.auth import authenticate
from django.views.decorators.csrf import csrf_exempt
from django.middleware.csrf import CsrfViewMiddleware
from generic.models import Examination, Modality
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom, APIToken
from atlas.helpers import get_cases_available_to_user
from loguru import logger
@@ -29,6 +39,72 @@ from loguru import logger
router = Router()
class BearerAuth(HttpBearer):
"""Ninja HttpBearer auth that validates APIToken entries.
Returns the token owner user on success or raises HttpError(401) on failure.
"""
def authenticate(self, request, token: str):
if not token:
raise HttpError(status_code=401)
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
api_token = APIToken.objects.get(token_hash=token_hash)
except APIToken.DoesNotExist:
raise HttpError(status_code=401)
if api_token.revoked:
raise HttpError(status_code=401)
if api_token.expires and api_token.expires < timezone.now():
raise HttpError(status_code=401)
try:
api_token.mark_used()
except Exception:
pass
# Attach for downstream handlers
request.api_token = api_token
# Also set `request.user` so code that expects Django's user works
try:
request.user = api_token.user
# Some middleware caches the user on `_cached_user`
setattr(request, "_cached_user", api_token.user)
except Exception:
pass
return api_token.user
class SessionOrBearer:
"""Composite auth: prefer Django session (request.user), fall back to bearer token.
Use as `auth=SessionOrBearer()` on Ninja router endpoints.
"""
def __call__(self, request):
# Session-backed auth already populated by Django middleware
if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
return request.user
# Try bearer token from header
auth = request.META.get("HTTP_AUTHORIZATION")
if not auth:
raise HttpError(status_code=401)
parts = auth.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HttpError(status_code=401)
token = parts[1]
# reuse BearerAuth logic
bearer = BearerAuth()
return bearer.authenticate(request, token)
# Backwards-compatible alias used in existing decorators
TokenAuth = SessionOrBearer
class SeriesSchema(ModelSchema):
case_id: List[int] = []
@@ -53,7 +129,7 @@ class CaseSchema(ModelSchema):
fields = ["id", "title"]
@router.post("/upload_dicom", auth=django_auth)
@router.post("/upload_dicom", auth=TokenAuth())
def upload_dicom(request, files: List[UploadedFile] = File(...)):
uploaded = []
duplicate = []
@@ -70,11 +146,8 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
except DuplicateDicom as e:
duplicate.append((file.name, ud.image_blake3_hash))
if type(e.duplicate) == SeriesImage:
if isinstance(e.duplicate, SeriesImage) and e.duplicate.series is not None:
duplicate_series.add(e.duplicate.series.get_absolute_url())
pass
except InvalidDicomError:
failed.append(file.name)
@@ -86,7 +159,7 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
}
@router.post("/generate_image_hash", auth=django_auth)
@router.post("/generate_image_hash", auth=TokenAuth())
def generate_image_hash(request, id: int):
s = SeriesImage.objects.get(pk=id)
s.generate_hashes()
@@ -98,7 +171,7 @@ def generate_image_hash(request, id: int):
}
@router.post("/clear_dicoms", auth=django_auth)
@router.post("/clear_dicoms", auth=TokenAuth())
def clear_dicoms(request):
if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(
@@ -113,7 +186,7 @@ def clear_dicoms(request):
return True
@router.get("/uncategorised_dicoms", auth=django_auth)
@router.get("/uncategorised_dicoms", auth=TokenAuth())
def uncategorised_dicoms(request):
dicoms = UncategorisedDicom.objects.filter(user=request.user)
@@ -127,13 +200,15 @@ def uncategorised_dicoms(request):
return data
def import_dicoms_helper(request, case_id: int | None = None):
if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection")
)
else:
dicoms = UncategorisedDicom.objects.filter(user=request.user)
def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
# Allow callers to pass an explicit queryset of UncategorisedDicom objects
if dicoms is None:
if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection")
)
else:
dicoms = UncategorisedDicom.objects.filter(user=request.user)
if "order-series" in request.POST:
order = request.POST["order-series"]
@@ -145,6 +220,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
tags = d.basic_dicom_tags
series_uid = tags["SeriesInstanceUID"]
study_uid = tags.get("StudyInstanceUID")
if series_uid not in series_map:
# Get or create Series and related objects as needed
@@ -161,6 +237,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
defaults={
"modality": modality,
"description": tags.get("SeriesDescription", ""),
"study_instance_uid": study_uid,
}
)
if created and tags.get("StudyDescription"):
@@ -175,7 +252,12 @@ def import_dicoms_helper(request, case_id: int | None = None):
except IntegrityError:
series = Series.objects.get(series_instance_uid=series_uid)
if study_uid and not series.study_instance_uid:
series.study_instance_uid = study_uid
series.save(update_fields=["study_instance_uid"])
# Add author and case if needed
# Attach author to the series
series.author.add(request.user)
if case_id is not None:
case_object = get_object_or_404(Case, pk=case_id)
@@ -212,7 +294,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
@router.post(
"/import_dicoms", auth=django_auth, response=List[Tuple[SeriesSchema, str]]
"/import_dicoms", auth=TokenAuth(), response=List[Tuple[SeriesSchema, str]]
)
def import_dicoms(request):
return import_dicoms_helper(request)
@@ -220,19 +302,73 @@ def import_dicoms(request):
@router.post(
"/import_dicoms/{case_id}",
auth=django_auth,
auth=TokenAuth(),
response=List[Tuple[SeriesSchema, str]],
)
def import_dicoms_case(request, case_id: int):
return import_dicoms_helper(request, case_id=case_id)
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
@router.post("/upload_dicom_case/{case_id}", auth=TokenAuth())
def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(...)):
"""Upload DICOM files and immediately import them into the given case.
This saves each uploaded file as an `UncategorisedDicom` owned by the user,
then calls the existing import helper which will create Series/SeriesImage
objects and attach them to the specified case.
"""
uploaded = []
duplicate = []
failed = []
duplicate_series = set()
created_ids = []
for file in files:
try:
ud = UncategorisedDicom(image=file, user=request.user)
ud.save()
uploaded.append((file.name, ud.image_blake3_hash))
created_ids.append(ud.pk)
except DuplicateDicom as e:
duplicate.append((file.name, getattr(ud, "image_blake3_hash", None)))
dup_obj = getattr(e, "duplicate", None)
if isinstance(dup_obj, SeriesImage):
try:
if dup_obj.series is not None:
duplicate_series.add(dup_obj.series.get_absolute_url())
except Exception:
pass
continue
except InvalidDicomError:
failed.append(file.name)
# Import into the target case (consume only the uploaded UncategorisedDicom objects)
if created_ids:
try:
dicom_qs = UncategorisedDicom.objects.filter(pk__in=created_ids)
import_dicoms_helper(request, case_id=case_id, dicoms=dicom_qs)
except Exception:
# don't let import errors prevent returning upload status
logger.error("Error importing dicoms after upload", exc_info=True)
return {
"uploaded": uploaded,
"duplicates": duplicate,
"failed": failed,
"duplicate_series": list(duplicate_series),
}
@router.get("/orphan_series", auth=TokenAuth(), response=List[SeriesSchema])
def orphan_series(request):
return request.user.series.filter(case=None)
@router.get("/series_remove_duplicate_images", auth=django_auth)
@router.get("/series_remove_duplicate_images", auth=TokenAuth())
def series_remove_duplicate_images(request, series_id: int):
series = get_object_or_404(Series, pk=series_id)
@@ -249,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)
+198 -11
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,6 +11,7 @@ from .models import (
Finding,
PathologicalProcess,
Presentation,
Procedure,
QuestionSchema,
Series,
Structure,
@@ -17,6 +22,7 @@ from .models import (
)
from django.contrib.auth.models import User
from django.db.models import Q
from django.http import QueryDict
def get_authors(request):
@@ -81,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
@@ -95,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",
@@ -132,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(
@@ -351,22 +438,32 @@ class NormalCaseFilter(django_filters.FilterSet):
"""
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
procedures = django_filters.ModelMultipleChoiceFilter(field_name="case__procedures", queryset=Procedure.objects.all(), label="Procedure")
# Accept min/max as years in the UI but filter against the canonical
# `age_days` field by converting years -> days.
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
# 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", "age_days")
fields = ("examination", "modality", "procedures", "age_days")
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
# Use the global Examination and Modality querysets from the Series model
from generic.models import Examination, Modality
# Set up the actual querysets for the filters
self.base_filters.get("examination").queryset = Examination.objects.all()
self.base_filters.get("modality").queryset = Modality.objects.all()
# 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():
@@ -376,22 +473,112 @@ class NormalCaseFilter(django_filters.FilterSet):
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)
def filter_min_age(self, queryset, name, value):
# 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:
days = int(float(value) * 365.25)
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_age(self, queryset, name, value):
def filter_max_value(self, queryset, name, value):
try:
days = int(float(value) * 365.25)
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')
+176 -3
View File
@@ -42,9 +42,11 @@ from atlas.models import (
Subspecialty,
Presentation,
PathologicalProcess,
Procedure,
UncategorisedDicom,
UserReportAnswer,
CaseDisplaySet,
CaseReviewMessage,
)
from .models import NormalCase
@@ -115,6 +117,26 @@ class CaseSelect(Select):
pass
class MoveSelectedSeriesForm(Form):
destination_case = ModelChoiceField(
queryset=Case.objects.none(),
widget=HiddenInput(),
label="Move selected series to case",
required=True,
)
def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
source_case = kwargs.pop("source_case", None)
super().__init__(*args, **kwargs)
available_cases = get_cases_available_to_user(user)
if source_case is not None:
available_cases = available_cases.exclude(pk=source_case.pk)
self.fields["destination_case"].queryset = available_cases.order_by("pk")
class ConditionForm(ModelForm):
class Meta:
model = Condition
@@ -226,6 +248,10 @@ class CaseCollectionForm(ModelForm):
"show_description_post",
"show_discussion_post",
"show_report_post",
"show_case_link_post",
"show_findings_post",
"show_displaysets_post",
"show_differentials_post",
]
if f in self.fields
]
@@ -262,9 +288,22 @@ class CaseCollectionForm(ModelForm):
"exam_mode",
"self_review",
"feedback_once_collection_complete",
"case_query_messaging_enabled",
"show_results_sharing_information",
"viewer_mode",
"question_time_limit",
"answer_entry_grace_period",
"prerequisites",
Fieldset(
"Custom Start Screen",
"start_screen_content",
"start_screen_resources",
),
Fieldset(
"Custom End Overview",
"end_overview_content",
"end_overview_resources",
),
),
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
Div(
@@ -298,6 +337,22 @@ class NormalCaseForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# If editing an existing NormalCase, prepopulate age_value/age_unit
try:
instance = kwargs.get('instance')
except Exception:
instance = None
if instance is None:
# also support self.instance when ModelForm sets it
instance = getattr(self, 'instance', None)
if instance is not None and getattr(instance, 'age_days', None) is not None:
try:
val, unit = NormalCase.days_to_value_unit(instance.age_days)
if val is not None and unit is not None:
self.fields['age_value'].initial = val
self.fields['age_unit'].initial = unit
except Exception:
pass
self.helper = FormHelper()
# Do not render a <form> tag from crispy here because the template
# already provides the surrounding form element (we inject this
@@ -315,7 +370,7 @@ class NormalCaseForm(ModelForm):
css_class="row",
),
Field("notes"),
Submit("submit", "Save", css_class="btn btn-primary"),
# Submit button rendered in modal footer; avoid duplicate button here
)
def save(self, commit=True):
@@ -379,6 +434,22 @@ class PathologicalProcessForm(ModelForm):
exclude = []
class ProcedureForm(ModelForm):
class Meta:
model = Procedure
exclude = []
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
try:
self.helper = FormHelper()
self.helper.form_method = "post"
# Prevent crispy from rendering its own <form> tag so our
# template-level form wrapper contains the submit buttons.
self.helper.form_tag = False
except Exception:
pass
class SeriesFindingForm(ModelForm):
class Meta:
model = SeriesFinding
@@ -601,6 +672,7 @@ class CaseForm(ModelForm):
"presentation",
"discussion",
"report",
"procedures",
"condition",
"pathological_process",
"open_access",
@@ -632,6 +704,7 @@ class CaseForm(ModelForm):
"history",
"presentation",
"discussion",
"procedures",
"condition",
"pathological_process",
"report",
@@ -661,6 +734,9 @@ class CaseForm(ModelForm):
"subspecialty": CheckboxSelectMultiple(),
"pathological_process": CheckboxSelectMultiple(),
"previous_case": CaseSelect(),
"procedures": autocomplete.ModelSelect2Multiple(
url="atlas:procedure-autocomplete"
),
}
def clean_cimar_uuid(self):
@@ -1020,6 +1096,14 @@ class AddCollectionToCaseForm(Form):
class SelfReviewForm(ModelForm):
SCORE_CHOICES = [
(1, "1 - Poor"),
(2, "2 - Limited"),
(3, "3 - Adequate"),
(4, "4 - Good"),
(5, "5 - Excellent"),
]
class Meta:
model = SelfReview
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
@@ -1034,6 +1118,75 @@ class SelfReviewForm(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
super(SelfReviewForm, self).__init__(*args, **kwargs)
# Render scoring fields as explicit 1-5 selector choices for cleaner UX.
self.fields["findings"] = forms.TypedChoiceField(
choices=self.SCORE_CHOICES,
coerce=int,
empty_value=None,
required=False,
widget=RadioSelect,
label="Findings",
help_text="How well did you identify key findings?",
)
self.fields["interpretation"] = forms.TypedChoiceField(
choices=self.SCORE_CHOICES,
coerce=int,
empty_value=None,
required=False,
widget=RadioSelect,
label="Interpretation",
help_text="How strong was your overall interpretation?",
)
self.fields["comments"].widget.attrs.update(
{
"rows": 5,
"placeholder": "What went well? What would you do differently next time?",
}
)
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Field("user_exam"),
Field("case"),
Field("comments"),
Field("findings"),
Field("interpretation"),
)
class CaseReviewMessageForm(ModelForm):
class Meta:
model = CaseReviewMessage
fields = ["message", "requires_acknowledgement"]
def __init__(self, *args, allow_ack=False, message_placeholder=None, **kwargs):
super(CaseReviewMessageForm, self).__init__(*args, **kwargs)
self.fields["message"].widget.attrs.update(
{
"rows": 3,
"placeholder": message_placeholder or "Write a message...",
"class": "form-control",
}
)
self.fields["requires_acknowledgement"].widget.attrs.update(
{
"class": "form-check-input",
}
)
if not allow_ack:
self.fields["requires_acknowledgement"].widget = HiddenInput()
self.fields["requires_acknowledgement"].required = False
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
class UncategorisedDicomForm(ModelForm):
class Meta:
@@ -1144,7 +1297,12 @@ class CaseQuestionForm(ModelForm):
class CaseDetailForm(ModelForm):
class Meta:
model = CaseDetail
fields = ["redact_history", "override_history"]
fields = [
"redact_history",
"override_history",
"question_time_limit_override",
"answer_entry_grace_period_override",
]
def __init__(self, *args, case_history: str = None, **kwargs):
"""
@@ -1167,6 +1325,16 @@ class CaseDetailForm(ModelForm):
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
if "question_time_limit_override" in self.fields:
self.fields["question_time_limit_override"].help_text = (
"Leave blank to use the collection default answer time limit."
)
if "answer_entry_grace_period_override" in self.fields:
self.fields["answer_entry_grace_period_override"].help_text = (
"Leave blank to use the collection default answer-only grace period."
)
# Build crispy helper/layout embedding the original history and buttons tied to override_history
self.helper = FormHelper()
self.helper.form_tag = False
@@ -1204,7 +1372,12 @@ class CaseDetailForm(ModelForm):
self.helper.layout = Layout(
HTML(history_block),
Field("override_history"),
Field("redact_history")
Field("redact_history"),
Fieldset(
"Case Timing Overrides",
Field("question_time_limit_override"),
Field("answer_entry_grace_period_override"),
),
)
@@ -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),
),
]
+352 -18
View File
@@ -230,6 +230,44 @@ class SynMixin(object):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class APIToken(models.Model):
"""Personal Access Token for API access.
Stores only a hash of the token. The raw token is returned once at
creation and never stored in plaintext.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens")
name = models.CharField(max_length=200, blank=True, help_text="Friendly name for this token")
token_hash = models.CharField(max_length=64, db_index=True)
scopes = models.CharField(max_length=255, blank=True, help_text="Space-separated scopes")
created = models.DateTimeField(auto_now_add=True)
expires = models.DateTimeField(null=True, blank=True)
revoked = models.BooleanField(default=False)
last_used = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = "API token"
verbose_name_plural = "API tokens"
def __str__(self):
return f"APIToken(user={self.user}, name={self.name})"
@classmethod
def create_token(cls, user, name="", scopes="", expires=None):
import secrets, hashlib
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
obj = cls.objects.create(user=user, name=name, token_hash=token_hash, scopes=scopes, expires=expires)
return token, obj
def mark_used(self):
from django.utils import timezone
self.last_used = timezone.now()
self.save(update_fields=["last_used"])
class Finding(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
# New canonical/alias field: if set, this Finding is an alias and points
@@ -396,6 +434,35 @@ class PathologicalProcess(models.Model):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class Procedure(SynMixin, models.Model):
"""A procedure or operation performed on the patient (attachable to Cases).
This mirrors the lightweight structure used by `Presentation` / `PathologicalProcess`.
"""
name = models.CharField(max_length=255, unique=True)
# Optional canonical/alias pointer for future synonym handling
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Procedure is an alias and points to the canonical Procedure.",
)
subspecialty = models.ManyToManyField("subspecialty", blank=True)
def __str__(self) -> str:
return self.name
def get_absolute_url(self):
return reverse("atlas:procedure_detail", kwargs={"pk": self.pk})
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
class Differential(models.Model):
condition = models.ForeignKey(Condition, on_delete=models.CASCADE)
case = models.ForeignKey(
@@ -484,6 +551,13 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
help_text="The presentation(s) the case is associated with.",
)
# Procedures performed on the patient (e.g. operations, interventions)
procedures = models.ManyToManyField(
"Procedure",
blank=True,
help_text="Procedure(s) or operations the patient has undergone.",
)
pathological_process = models.ManyToManyField(PathologicalProcess, blank=True)
differential = models.ManyToManyField(
@@ -720,11 +794,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
series_qs = self.series.all()
if exclude_series_ids is not None:
series_qs = self.series.exclude(id__in=exclude_series_ids)
images = [
[image.image.url for image in series.images.all()]
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images')
]
series_qs = series_qs.exclude(id__in=exclude_series_ids)
# Ensure series are ordered by the through model sort_order
series_list = list(series_qs.order_by('seriesdetail__sort_order').prefetch_related('images'))
images = []
for series in series_list:
# If images were prefetched, use the cached list and filter in Python
prefetched = getattr(series, '_prefetched_objects_cache', {}).get('images')
if prefetched is not None:
imgs = [img.image.url for img in prefetched if not getattr(img, 'removed', False)]
else:
imgs = [image.image.url for image in series.images.filter(removed=False)]
images.append(imgs)
if as_json:
return json.dumps(images)
@@ -738,7 +821,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append({"name": name, "imageIds": images})
stacks.append({"name": name, "imageIds": images, "seriesId": series.pk})
return stacks
results = []
@@ -757,7 +840,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return json.dumps(results)
class NormalCase(models.Model):
class NormalCase(models.Model, AuthorMixin):
"""Marks a Case as a 'normal' example used in the Atlas normals section.
This version stores a canonical `age_days` integer (patient age in days).
@@ -810,18 +893,18 @@ class NormalCase(models.Model):
help_text="Primary modality for this normal case (optional).",
)
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="added_normals",
)
added_date = models.DateTimeField(default=timezone.now)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="normal_cases",
)
notes = models.TextField(null=True, blank=True)
class Meta:
ordering = ["-added_date"]
@@ -1031,6 +1114,7 @@ class Series(SeriesBase):
)
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
def __str__(self) -> str:
@@ -1038,6 +1122,27 @@ class Series(SeriesBase):
return self.description
else:
return f"{self.examination} ({self.plane})"
def user_can_edit(self, user, allow_if_can_edit_cases=True) -> bool:
"""Check if the user can edit the series.
Args:
user (User): The user to check.
Returns:
bool: True if the user can edit the series, False otherwise.
"""
if user.is_superuser:
return True
if self.author.filter(id=user.id).exists():
return True
if allow_if_can_edit_cases:
# Check if user can edit any of the cases this series is in
for case in self.case.all():
if case.check_user_can_edit(user):
return True
return False
def get_full_str(self):
if self.case:
@@ -1152,6 +1257,7 @@ class Series(SeriesBase):
class CaseCollection(ExamOrCollectionGenericBase):
app_name = "atlas"
cases = models.ManyToManyField(Case, through="CaseDetail")
@@ -1188,6 +1294,21 @@ class CaseCollection(ExamOrCollectionGenericBase):
default=False, help_text="Show the case report (post exam)"
)
# New post-exam display options
show_case_link_post = models.BooleanField(
default=False, help_text="Show a link to the case (post exam)"
)
show_findings_post = models.BooleanField(
default=False, help_text="Show findings related to the case (post exam)"
)
show_displaysets_post = models.BooleanField(
default=False, help_text="Show display sets for the case (post exam)"
)
show_differentials_post = models.BooleanField(
default=False, help_text="Show differentials for the case (post exam)"
)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
@@ -1236,6 +1357,14 @@ class CaseCollection(ExamOrCollectionGenericBase):
cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="atlas_exams")
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
case_query_messaging_enabled = models.BooleanField(
default=True,
help_text="Enable per-case query and messaging for this collection.",
)
show_results_sharing_information = models.BooleanField(
default=True,
help_text="If true, show result-sharing information on candidate start and overview screens.",
)
question_time_limit = models.PositiveIntegerField(
blank=True,
@@ -1243,6 +1372,39 @@ class CaseCollection(ExamOrCollectionGenericBase):
help_text="Time limit for answering questions in seconds."
)
answer_entry_grace_period = models.PositiveIntegerField(
blank=True,
null=True,
default=0,
help_text="Additional seconds after the case view is locked during which answers can still be entered."
)
start_screen_content = models.TextField(
blank=True,
null=True,
help_text="Optional HTML content shown on the collection start screen (for example embedded resources).",
)
start_screen_resources = models.ManyToManyField(
"Resource",
blank=True,
related_name="start_screen_collections",
help_text="Optional resources shown on the collection start screen.",
)
end_overview_content = models.TextField(
blank=True,
null=True,
help_text="Optional HTML content shown on the collection end overview screen.",
)
end_overview_resources = models.ManyToManyField(
"Resource",
blank=True,
related_name="end_overview_collections",
help_text="Optional resources shown on the collection end overview screen.",
)
# Collections that must be completed before this collection can be taken
prerequisites = models.ManyToManyField(
"self",
@@ -1296,6 +1458,47 @@ class CaseCollection(ExamOrCollectionGenericBase):
default=VIEWER_MODE_CHOICES.BUILT_IN,
)
# -----------------------------------------------------------------------
# Marking configuration
# These fields can be defined here so that the same CaseCollection can be
# reused in multiple research studies or standalone with consistent marking.
# -----------------------------------------------------------------------
marking_scheme_name = models.CharField(
max_length=255,
blank=True,
help_text="Human-readable mark scheme name shown to markers and in exports.",
)
marking_guidance = models.TextField(
blank=True,
help_text="Plain-language marking guidance displayed to markers.",
)
case_question_max_score = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Maximum score per case used for normalisation. Defaults to 10 when not set.",
)
# -----------------------------------------------------------------------
# Case question template
# When set, this JSON schema is used as a default for all CaseDetail entries
# in the collection that do not have their own question_schema.
# -----------------------------------------------------------------------
case_question_template = models.JSONField(
null=True,
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."
),
)
auto_apply_question_template = 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."
),
)
def get_app_name(self):
return "atlas"
@@ -1356,6 +1559,16 @@ class CaseCollection(ExamOrCollectionGenericBase):
"""Returns the cases in the collection in order of the CaseDetail sort_order"""
return self.cases.all().order_by("casedetail__sort_order")
def get_effective_case_time_limit(self, casedetail):
if casedetail.question_time_limit_override is not None:
return casedetail.question_time_limit_override
return self.question_time_limit
def get_effective_answer_entry_grace_period(self, casedetail):
if casedetail.answer_entry_grace_period_override is not None:
return casedetail.answer_entry_grace_period_override
return self.answer_entry_grace_period or 0
def get_next_case(self, case):
cases = list(self.get_cases())
@@ -1374,6 +1587,38 @@ class CaseCollection(ExamOrCollectionGenericBase):
else:
return cases[new_index]
def apply_question_template_to_cases(self, overwrite_existing=False):
"""
Apply case_question_template to all CaseDetail entries in this collection.
Args:
overwrite_existing: If True, overwrite existing question_schema on CaseDetails.
If False (default), only apply to cases without a schema.
Returns:
int: Number of CaseDetail entries updated.
"""
if not self.case_question_template:
return 0
casedetails = self.casedetail_set.all()
updated = 0
for cd in casedetails:
if overwrite_existing or not cd.question_schema:
cd.question_schema = self.case_question_template
cd.save(update_fields=["question_schema"])
updated += 1
return updated
def get_effective_question_schema(self, casedetail):
"""
Return the effective question schema for a CaseDetail.
Uses the individual CaseDetail schema if set, otherwise falls back to the collection template.
"""
if casedetail.question_schema:
return casedetail.question_schema
return self.case_question_template
def get_index_of_case(self, case, case_count=False):
cases = list(self.get_cases())
@@ -1414,7 +1659,9 @@ class CaseCollection(ExamOrCollectionGenericBase):
Extend base check_user_can_take to also require completion of any
prerequisite collections.
"""
# Perform the normal access checks first
logger.error("Checking if user can take collection with prerequisites")
logger.error(f"CID: {cid}, user: {user}, active_only: {active_only}")
#` Perform the normal access checks first
super().check_user_can_take(cid, passcode, user=user, active_only=active_only)
# If there are prerequisites, the user (or CID) must have completed them
@@ -1581,6 +1828,18 @@ class CaseDetail(models.Model):
help_text="This will override the case history for the purpose of the exam/collection."
)
question_time_limit_override = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Optional per-case override for the answer time limit in seconds.",
)
answer_entry_grace_period_override = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
)
class Meta:
ordering = ("sort_order",)
@@ -1627,6 +1886,13 @@ class CaseDetail(models.Model):
return self.case.history or "No history provided"
return ""
def get_history(self):
if self.redact_history:
return "[Redacted]"
if self.override_history and self.override_history != "":
return self.override_history
return self.case.history or "No history provided"
def get_case_series_nested(self, include_priors=True):
case_series_images = self.case.get_series_images_nested(as_json=False)
@@ -1646,7 +1912,7 @@ class CaseDetail(models.Model):
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append({"name": name, "imageIds": images})
stacks.append({"name": name, "imageIds": images, "seriesId": series.pk})
return stacks
results = []
@@ -1895,6 +2161,71 @@ class SelfReview(models.Model):
)
class CaseReviewMessage(models.Model):
"""Conversation message for one learner attempt on one case.
Supervisors/collection authors use this for per-case feedback and replies.
Learners use it to raise questions/queries. Supervisor messages may require
acknowledgement so they continue to flag on the learner-facing pages until
explicitly acknowledged.
"""
class MessageType(models.TextChoices):
FEEDBACK = "feedback", _("Feedback")
QUERY = "query", _("Query")
REPLY = "reply", _("Reply")
cid_user_exam = models.ForeignKey(
CidUserExam,
on_delete=models.CASCADE,
related_name="case_review_messages",
)
case = models.ForeignKey(
Case,
on_delete=models.CASCADE,
related_name="case_review_messages",
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="case_review_messages_authored",
null=True,
blank=True,
)
author_label = models.CharField(max_length=255, blank=True)
message_type = models.CharField(
max_length=20,
choices=MessageType.choices,
default=MessageType.QUERY,
)
message = models.TextField()
requires_acknowledgement = models.BooleanField(default=False)
acknowledged_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("created_at",)
def __str__(self) -> str:
return f"{self.get_message_type_display()} for {self.case}"
@property
def is_acknowledged(self) -> bool:
return self.acknowledged_at is not None
def get_author_display(self) -> str:
if self.author is not None:
full_name = self.author.get_full_name().strip()
return full_name or self.author.username
return self.author_label or "Learner"
def acknowledge(self):
if self.acknowledged_at is None:
self.acknowledged_at = timezone.now()
self.save(update_fields=["acknowledged_at"])
class UncategorisedDicom(models.Model):
# We use image to maintain consitency across apps
image = models.FileField(upload_to=uncategorised_dicom_directory_path)
@@ -1915,7 +2246,7 @@ class UncategorisedDicom(models.Model):
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
image_blake3_hash = models.CharField(max_length=64, null=True, blank=True)
image_blake3_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)
created_date = models.DateTimeField(default=timezone.now)
@@ -1981,6 +2312,7 @@ class UncategorisedDicom(models.Model):
if dataset is None:
dataset = pydicom.dcmread(self.image)
to_include = (
"StudyInstanceUID",
"StudyDescription",
"Modality",
"ProtocolName",
@@ -1988,6 +2320,8 @@ class UncategorisedDicom(models.Model):
"SeriesInstanceUID",
"BodyPartExamined",
"SliceThickness",
"SeriesNumber",
"StudyDate",
)
tags = {}
+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);
+65 -16
View File
@@ -15,10 +15,13 @@ from .models import (
Finding,
Subspecialty,
Resource,
Procedure,
)
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from easy_thumbnails.files import get_thumbnailer
from easy_thumbnails.exceptions import InvalidImageFormatError
@@ -83,12 +86,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
@@ -108,7 +113,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 = []
@@ -130,7 +135,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):
@@ -151,7 +156,7 @@ class CaseTable(SelectionTable):
)
for collection in collections
]
return format_html(", ".join(links))
return mark_safe(", ".join(links))
return "No collections"
associated_cases = tables.Column(
@@ -166,7 +171,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
@@ -199,7 +204,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]
@@ -209,7 +214,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)
@@ -290,7 +295,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()}"
@@ -317,7 +322,7 @@ class FindingTable(SelectionTable):
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
return mark_safe(record.get_synonym_link())
class StructureTable(SelectionTable):
@@ -344,7 +349,7 @@ class StructureTable(SelectionTable):
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
return mark_safe(record.get_synonym_link())
class PresentationTable(SelectionTable):
@@ -391,6 +396,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")}),
@@ -412,7 +437,7 @@ 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()}"
@@ -432,8 +457,11 @@ class CaseCollectionTable(SelectionTable):
"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
#)
@@ -449,6 +477,8 @@ class CaseCollectionTable(SelectionTable):
template_name = "django_tables2/bootstrap4.html"
fields = (
"name",
"created",
"updated",
"collection_type",
"publish_results",
"active",
@@ -466,6 +496,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")}),
+436 -4
View File
@@ -2,14 +2,97 @@ 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
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 +132,353 @@ 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]
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]
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])]
spacing_x = source_axis_spacings[src_x]
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
def _reduce_slab(slab, reduce_axis=0):
if recon_thickness_mode == "max":
out = np.max(slab, axis=reduce_axis)
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
start_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
if start_positions_mm.size == 0:
start_positions_mm = np.array([0.0], dtype=float)
slabs = []
centers_idx = []
for start_mm in start_positions_mm:
end_mm = start_mm + slab_thickness
mask = (axis_positions_mm >= (start_mm - 1e-6)) & (axis_positions_mm < (end_mm - 1e-6))
if not mask.any():
nearest = int(np.argmin(np.abs(axis_positions_mm - start_mm)))
idxs = np.array([nearest], dtype=int)
else:
idxs = np.where(mask)[0].astype(int)
slabs.append(idxs)
center_mm = start_mm + (slab_thickness / 2.0)
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
return slabs, centers_idx
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": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
"plane_normal_out": [0.0, 0.0, 1.0],
"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: np.flipud(_reduce_slab(vol_zyx[:, slabs[idx], :], reduce_axis=1)),
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 0.0, -1.0],
"plane_normal_out": [0.0, 1.0, 0.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), centers[idx], 0.0),
}
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: np.flipud(_reduce_slab(vol_zyx[:, :, slabs[idx]], reduce_axis=2)),
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [0.0, 1.0, 0.0, 0.0, 0.0, -1.0],
"plane_normal_out": [1.0, 0.0, 0.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), 0.0, centers[idx]),
}
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)")
recon_series.series_instance_uid = generate_uid()
recon_series.save(update_fields=["series_instance_uid"])
for idx in range(int(cfg["count"])):
arr2d = cfg["slice_fn"](idx)
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,
series_description=recon_series.description or f"Recon {plane_norm.title()}",
derivation_description=(
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
),
image_type=["DERIVED", "SECONDARY", "MPR"],
)
ds_new.PixelData = arr2d.tobytes()
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
ipp = cfg["position_fn"](idx)
ds_new.ImagePositionPatient = ipp
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
try:
normal = np.asarray(cfg["plane_normal_out"], dtype=float)
ds_new.SliceLocation = float(np.dot(np.asarray(ipp, dtype=float), normal))
except Exception:
pass
out_io = io.BytesIO()
ds_new.save_as(out_io, write_like_original=False)
out_io.seek(0)
recon_image = SeriesImage(
series=recon_series,
position=idx + 1,
upload_filename=f"recon_{plane_norm}_{idx + 1}.dcm",
)
recon_image.image.save(
f"recon_{plane_norm}_{recon_series.pk}_{idx + 1}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
recon_image.save()
processed += 1
cache.set(
progress_key,
{
"current": processed,
"total": total_slices,
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
},
timeout=60 * 60,
)
created_series.append(
{
"id": recon_series.pk,
"url": recon_series.get_absolute_url(),
"description": recon_series.description or str(recon_series.pk),
}
)
logger.info(
"Reconstruction task complete for series {} with {} outputs",
series.pk,
len(created_series),
)
cache.set(
progress_key,
{
"current": total_slices,
"total": total_slices,
"message": "Reconstruction complete",
},
timeout=10 * 60,
)
return {
"series_id": series.pk,
"created_series": created_series,
"target_spacing": float(requested_slice_spacing) if requested_slice_spacing is not None else None,
"slab_thickness": float(requested_slab_thickness) if requested_slab_thickness is not None else None,
"mode": recon_thickness_mode,
"source_plane": source_plane,
}
@@ -18,6 +18,8 @@
<a href="{% url 'atlas:subspecialty_detail' item.pk %}">{{ item.name }}</a>
{% elif group_name == 'presentations' %}
<a href="{% url 'atlas:presentation_detail' item.pk %}">{{ item.name }}</a>
{% elif group_name == 'procedures' %}
<a href="{% url 'atlas:procedure_detail' item.pk %}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
@@ -27,7 +29,7 @@
{% endif %}
{% endfor %}
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists %}
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists and not results.procedures.exists %}
<p class="text-muted">No matches found.</p>
{% endif %}
</div>
@@ -1,7 +1,9 @@
<div id="add-case-form" class="p-2 border rounded">
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
{% include 'atlas/partials/case_search_widget.html' %}
{% load case_widgets %}
{% url 'atlas:add_case_to_collection' collection.pk as add_case_url %}
{% case_search_widget collection=collection cases=cases recent_cases=recent_cases selection_mode='multiple' action_url=add_case_url input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s' target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s' %}
<div class="mt-2">
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
+64
View File
@@ -0,0 +1,64 @@
{% extends 'base.html' %}
{% block content %}
<div class="container my-4">
<h3>API Tokens</h3>
<div class="card mb-3">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="row g-2">
<div class="col-md-4">
<input name="name" class="form-control" placeholder="Name (e.g. CLI)" />
</div>
<div class="col-md-4">
<input name="scopes" class="form-control" placeholder="Scopes (space separated)" />
</div>
<div class="col-md-2">
<input name="expires_days" type="number" min="0" class="form-control" placeholder="Days" />
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" type="submit" name="action" value="create">Create</button>
</div>
</div>
</form>
</div>
</div>
{% if created_token %}
<div class="alert alert-info">Created token. Save it now — it won't be shown again.
<pre class="mt-2">{{ created_token }}</pre>
</div>
{% endif %}
<div class="card">
<div class="card-header">Your tokens</div>
<div class="card-body">
<table class="table table-sm">
<thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Expires</th><th>Revoked</th><th></th></tr></thead>
<tbody>
{% for t in tokens %}
<tr>
<td>{{ t.name }}</td>
<td>{{ t.scopes }}</td>
<td>{{ t.created }}</td>
<td>{{ t.expires }}</td>
<td>{{ t.revoked }}</td>
<td>
{% if not t.revoked %}
<form method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="revoke_id" value="{{ t.id }}" />
<button class="btn btn-sm btn-outline-danger" type="submit" name="action" value="revoke">Revoke</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-muted">No tokens</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+31 -3
View File
@@ -49,6 +49,15 @@
<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: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">
@@ -81,6 +90,7 @@
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:procedure_view' %}">Procedures</a></li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item-text small text-muted px-3">Create</li>
@@ -90,6 +100,7 @@
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:procedure_create' %}">New procedure</a></li>
{% endif %}
</ul>
</li>
@@ -107,9 +118,26 @@
</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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
<div class="case-displayset-modal">
<div class="mb-3">
<h5>{{ ds.name }}</h5>
{% if can_edit %}
<a href="{% url 'atlas:case_displaysets' ds.case.pk %}?displayset={{ ds.pk }}&edit=true" class="btn btn-sm btn-outline-secondary ms-2">Edit display set</a>
{% endif %}
{% if ds.description %}
<div class="text-muted">{{ ds.description }}</div>
{% endif %}
</div>
{# Viewer area for the modal - mounted by rad/static/dv3d bundle #}
<div class="mb-3">
<div id="modal_viewer_{{ ds.pk }}" class="dicom-viewer-root" style="height:420px;" data-auto-cache-stack="false" data-annotationjson="{{ ds.annotations }}" data-viewerstate="{{ ds.viewerstate }}"></div>
</div>
{% if ds.viewerstate and ds.annotations %}
<div class="mb-3">
<button
type="button"
class="btn btn-primary"
onclick='(window.importAnnotations_modal_viewer_{{ ds.pk }}||window.importAnnotations_modal_viewer||window.importAnnotations)({{ ds.annotations|safe }}); (window.importViewerState_modal_viewer_{{ ds.pk }}||window.importViewerState_modal_viewer||window.importViewerState)({{ ds.viewerstate|safe }});'
>Reload display set</button>
</div>
{% endif %}
<div class="mt-2">
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul>
{% for finding in ds.findings.all %}
<li>{{ finding }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul>
{% for structure in ds.structures.all %}
<li>{{ structure }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul>
{% for condition in ds.conditions.all %}
<li>{{ condition }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div class="mt-3">
<details>
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
<div>
{% include 'atlas/partials/json_pretty.html' with json_value=ds.viewerstate title='Viewer State' %}
</div>
<div>
{% include 'atlas/partials/json_pretty.html' with json_value=ds.annotations title='Annotations' %}
</div>
</details>
</div>
</div>
+41 -12
View File
@@ -27,7 +27,7 @@
<ul class="list-group mb-4" id="display-set-list">
{% for ds in case.display_sets.all %}
{% include 'atlas/partials/displayset_row.html' %}
{% include 'atlas/partials/displayset_row.html' %}
{% empty %}
<li class="list-group-item text-muted">No display sets for this case.</li>
{% endfor %}
@@ -38,20 +38,49 @@
{% block js %}
<script>
document.addEventListener("DOMContentLoaded", function () {
window.mountDicomViewers();
// Helper: wait for mount function and mount the viewers safely
function waitForMountFn(timeout = 5000) {
if (window.mountDicomViewers) return Promise.resolve(window.mountDicomViewers);
return new Promise((resolve, reject) => {
const start = Date.now();
function poll() {
if (window.mountDicomViewers) return resolve(window.mountDicomViewers);
if (Date.now() - start > timeout) return reject(new Error('mountDicomViewers not available'));
requestAnimationFrame(poll);
}
poll();
});
}
async function mountDicomViewersSafely(timeout = 5000) {
try {
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const mountFn = await waitForMountFn(timeout);
try { mountFn(); } catch (e) { console.warn('mountDicomViewers failed', e); }
requestAnimationFrame(() => { try { window.dispatchEvent(new Event('resize')); } catch (e) {} });
} catch (err) {
console.warn('Viewer initialization deferred/fallback:', err);
}
}
// Initialize viewers deterministically
mountDicomViewersSafely();
{% if selected_displayset %}
setTimeout(function() {
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
// Highlight the selected displayset row
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
if (selectedRow) {
selectedRow.style.border = "2px solid #ffc107";
}
}, 1000);
(async function(){
try {
await mountDicomViewersSafely();
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
// Highlight the selected displayset row
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
if (selectedRow) {
selectedRow.style.border = "2px solid #ffc107";
}
} catch (e) { console.warn('failed to import selected displayset state', e); }
})();
{% if edit %}
var editButton = document.getElementById("displayset-{{ selected_displayset.pk }}-edit-btn");
if (editButton) {
+53 -46
View File
@@ -146,58 +146,58 @@
<!-- Differential Collapsible Panel -->
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
<h3 class="d-inline">Differential</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="differentialPanel">
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="casedifferential_formset" class="list_formset">
<ol>
{% for form in casedifferential_formset %}
<li class="no-error casedifferential_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
<h3 class="d-inline">Differential</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="differentialPanel">
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="casedifferential_formset" class="list_formset">
<ol>
{% for form in casedifferential_formset %}
<li class="no-error casedifferential_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ casedifferential_formset.management_form | crispy }}
</div>
</div>
{{ casedifferential_formset.management_form | crispy }}
</div>
</div>
</div>
<!-- Resource Collapsible Panel -->
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
<h3 class="d-inline">Resource</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="resourcePanel">
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
<li class="no-error caseresource_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
<h3 class="d-inline">Resource</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="resourcePanel">
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
<li class="no-error caseresource_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ caseresource_formset.management_form | crispy}}
</div>
</div>
{{ caseresource_formset.management_form | crispy}}
</div>
</div>
</div>
<br/>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
@@ -216,4 +216,11 @@
{{ caseresource_formset.empty_form | crispy}}
</li>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
if(window.markupInserter && typeof markupInserter.initForNames === 'function'){
markupInserter.initForNames(['description','history','discussion','report','notes']);
}
});
</script>
{% endblock %}
@@ -0,0 +1,67 @@
{% extends 'atlas/base.html' %}
{% block content %}
<div class="container">
<h2>Search Cases</h2>
<form method="get" class="row g-2 mb-3" id="case-search-form">
<div class="col-md-6">
<input type="search" name="q" value="{{ q }}" class="form-control" placeholder="Search cases...">
</div>
<div class="col-md-2">
<select name="field" class="form-select">
<option value="all" {% if field == 'all' %}selected{% endif %}>All fields</option>
<option value="title" {% if field == 'title' %}selected{% endif %}>Title</option>
<option value="description" {% if field == 'description' %}selected{% endif %}>Description</option>
<option value="history" {% if field == 'history' %}selected{% endif %}>History</option>
<option value="author" {% if field == 'author' %}selected{% endif %}>Author</option>
</select>
</div>
<div class="col-md-2">
<select name="subspecialty" class="form-select">
<option value="">Any subspecialty</option>
{% for s in subspecialty_queryset %}
<option value="{{ s.pk }}" {% if selected_subspecialty and selected_subspecialty|stringformat:"s" == s.pk|stringformat:"s" %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<label class="form-check-label me-2"><input type="checkbox" name="my" value="1" class="form-check-input" {% if my %}checked{% endif %}> My cases</label>
<label class="form-check-label me-2"><input type="checkbox" name="open" value="1" class="form-check-input" {% if open %}checked{% endif %}> Open access</label>
<label class="form-check-label"><input type="checkbox" name="has_images" value="1" class="form-check-input" {% if has_images %}checked{% endif %}> Has images</label>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Search</button>
<a href="{% url 'atlas:case_search_page' %}" class="btn btn-outline-secondary ms-2">Clear</a>
</div>
</form>
<div id="case-search-results">
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=None q=q %}
{# pagination controls #}
{% if cases.has_other_pages %}
<nav aria-label="Case results pagination">
<ul class="pagination mt-3">
{% if cases.has_previous %}
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&amp;{% endif %}{% if field %}field={{ field }}&amp;{% endif %}{% if my %}my=1&amp;{% endif %}{% if open %}open=1&amp;{% endif %}{% if has_images %}has_images=1&amp;{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&amp;{% endif %}page={{ cases.previous_page_number }}">Previous</a></li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ cases.number }} of {{ cases.paginator.num_pages }}</span></li>
{% if cases.has_next %}
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&amp;{% endif %}{% if field %}field={{ field }}&amp;{% endif %}{% if my %}my=1&amp;{% endif %}{% if open %}open=1&amp;{% endif %}{% if has_images %}has_images=1&amp;{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&amp;{% endif %}page={{ cases.next_page_number }}">Next</a></li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
{% endblock %}
@@ -72,29 +72,6 @@
{% csrf_token %}
{% crispy form form.helper %}
{% comment %} <h3>Cases:</h3>
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
<input type="button" value="Add More Cases" id="add_more_case">
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
<div id="case_formset" class="sortable list_formset">
<ol>
{% for form in case_formset %}
<li class="no-error case-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form }}
</li>
{% endfor %}
</ol>
</div>
{{ case_formset.management_form }}
</form>
<div id="empty_case_form" style="display:none">
<li class='no_error case-formset'>
{{ case_formset.empty_form }}
</li>
</div> {% endcomment %}
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
</form>
<script>
+104 -94
View File
@@ -1,8 +1,8 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h1>Categories</h1>
<p>The platform has numberous different ways to tag and categorise content. These help with organsing cases and series, given the ability to find cases by a particuarly property (such as a finding) or find similar cases.</p><p>These can be browsed below.</p>
<h1>Categories</h1>
<p>The platform has numberous different ways to tag and categorise content. These help with organsing cases and series, given the ability to find cases by a particuarly property (such as a finding) or find similar cases.</p><p>These can be browsed below.</p>
<ul>
<li><a href="{% url 'atlas:condition_view' %}">Conditions</a></li>
<li><a href="{% url 'atlas:finding_view' %}">Findings</a></li>
@@ -10,113 +10,123 @@
<li><a href="{% url 'atlas:subspecialty_view' %}">Subspecialties</li></a>
<li><a href="{% url 'atlas:presentation_view' %}">Presentations</li></a>
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
<li><a href="{% url 'atlas:procedure_view' %}">Procedures</a></li>
</ul>
<div class="mt-4 mb-3">
<div class="mt-4 mb-3">
{# HTMX loading indicator styles: hide by default, show when HTMX adds the .htmx-request class #}
<style>
<style>
#category-search-loading { display: none; }
#category-search-loading.htmx-request { display: inline-block; }
</style>
</style>
<p>Alternatively you can use the search function below</p>
<p>Alternatively you can use the search function below</p>
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
<div class="d-flex align-items-center">
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
<div class="d-flex align-items-center">
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-trigger="keyup delay:500ms"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</div>
<button class="btn btn-primary ms-2" type="submit">Search</button>
</div>
{# Collapsed advanced filters block - defaults to collapsed #}
<div class="mt-2">
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
Advanced filters ▾
</button>
<div class="collapse mt-2" id="category-filters">
<div class="card card-body">
<div class="d-flex flex-wrap gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-conditions">Conditions</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-findings">Findings</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-structures">Structures</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-presentations">Presentations</label>
</div>
</div>
</div>
</div>
</div>
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</div>
<button class="btn btn-primary ms-2" type="submit">Search</button>
</div>
{# Collapsed advanced filters block - defaults to collapsed #}
<div class="mt-2">
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
Advanced filters ▾
</button>
<div class="collapse mt-2" id="category-filters">
<div class="card card-body">
<div class="d-flex flex-wrap gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-conditions">Conditions</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-findings">Findings</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-structures">Structures</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-presentations">Presentations</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-procedures" name="types" value="procedures" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-procedures">Procedures</label>
</div>
</div>
</div>
</div>
</div>
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</div>
{% comment %} <button class="btn btn-primary ms-2" type="submit">Search</button> {% endcomment %}
</form>
</div>
</form>
</div>
<div id="category-search-results">
{% if results is not None %}
{% include 'atlas/_categories_search_results.html' %}
{% endif %}
</div>
{% if request.user.is_staff %}
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
</p>
<div id="category-search-results">
{% if results is not None %}
{% include 'atlas/_categories_search_results.html' %}
{% endif %}
</div>
{% if request.user.is_staff %}
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
</p>
{% endif %}
{% endblock %}
@@ -6,8 +6,7 @@
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_displaysetup" %}
<h2>Setup default display for case</h2>
<div id="root" class="dicom-viewer-root" data-images="{{casedetail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
<div id="root" class="dicom-viewer-root viewer-frame-setup" data-images="{{casedetail.case.get_series_images_nested}}"
data-auto-cache-stack="true"
{% if casedetail.default_viewerstate %}
data-viewerstate={{ casedetail.default_viewerstate }}
File diff suppressed because it is too large Load Diff
+304 -127
View File
@@ -1,157 +1,334 @@
{% extends 'atlas/exams.html' %}
{% load partials %}
{% partialdef casedetails-management-links %}
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
{% include 'atlas/partials/casedetails_management_links.html' %}
</div>
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
{% include 'atlas/partials/casedetails_management_links.html' %}
</div>
{% endpartialdef %}
{% block content %}
<div class="container">
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<h2 class="card-title mb-2">{{ collection.name }}</h2>
<div class="container">
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<h2 class="card-title mb-2">{{ collection.name }}</h2>
{% include 'exam_notes.html' %}
<p class="mb-1">
<span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
<small class="text-muted">(open access: {{ collection.exam_open_access }})</small>
</p>
<p class="mb-2">
{% include "generic/partials/exams/exam_status.html#publish-results" %}
{% include "generic/partials/exams/exam_status.html#exam-active" %}
</p>
<p class="mb-1">
<span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
<span class="badge bg-light text-dark me-2">Self review: {{ collection.self_review }}</span>
{% if collection.open_access %}
<span class="badge bg-success">Open</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% include 'exam_notes.html' %}
{% if casesdetails|length > 20 %}
<div id="collection-warning-{{ collection.pk }}" class="alert alert-warning alert-dismissible fade show" role="alert">
<strong>Performance warning:</strong> This collection contains {{ casesdetails|length }} cases — in some situations this may adversely affect performance, all features will still work but page loading may be slower. Consider splitting into smaller collections if performance is an issue.
<button id="collection-warning-close-{{ collection.pk }}" type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
</p>
{% if collection.prerequisites.exists %}
<div class="mt-3 mb-2">
<strong>Prerequisites</strong>
<ul class="mb-0">
{% for prereq in collection.prerequisites.all %}
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<p class="mb-1">
</p>
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
<div class="mb-2 w-100 text-md-end">
{% if collection.collection_type == "VIV" %}
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
{% else %}
<div class="d-inline-block">
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
<button
_="on click
writeText(#take-url-link's href) on navigator.clipboard
put 'copied url!' into me
wait 1s
put 'copy link' into me"
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
<p class="mb-2">
{% include "generic/partials/exams/exam_status.html#publish-results" %} {% include "generic/partials/exams/exam_status.html#exam-active" %}
</p>
<p class="mb-1">
Collection type: <span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
Self review: <span class="badge bg-light text-dark me-2">{{ collection.self_review }}</span>
Open access:
{% if collection.open_access %}
<span class="badge bg-success">Open</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
Exam mode: <span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
</p>
{% if collection.prerequisites.exists %}
<div class="mt-3 mb-2">
<strong>Prerequisites</strong>
<ul class="mb-0">
{% for prereq in collection.prerequisites.all %}
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if collection.prerequisites.exists %}
<div class="w-100 text-md-end">
<button
title="Sync users from all prerequisite collections into this collection"
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
hx-swap="outerHTML"
class="btn btn-sm btn-secondary"
>Sync prerequisite users</button>
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
<div class="mb-2 w-100 text-md-end">
{% if collection.collection_type == "VIV" %}
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
{% else %}
<div class="d-inline-block">
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
<button
_="on click
writeText(#take-url-link's href) on navigator.clipboard
put 'copied url!' into me
wait 1s
put 'copy link' into me"
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
</div>
{% endif %}
</div>
{% endif %}
{% if collection.prerequisites.exists %}
<div class="w-100 text-md-end">
<button
title="Sync users from all prerequisite collections into this collection"
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
hx-swap="outerHTML"
class="btn btn-sm btn-secondary"
>Sync prerequisite users</button>
</div>
{% endif %}
<div class="card border-secondary w-100 mt-3">
<div class="card-body py-2 px-3 text-start text-md-end">
{% if collection.case_query_messaging_enabled %}
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} pending acknowledgement
</div>
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Inbox</a>
</div>
{% if user_message_rows %}
<div class="mt-2 small text-muted">Users</div>
<div class="d-flex flex-column gap-1">
{% for row in user_message_rows|slice:":5" %}
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_user_messages' collection.pk row.user_user.pk %}">
{{ row.get_user_name }}
{% if row.outstanding_message_count %}
({{ row.outstanding_message_count }} outstanding)
{% else %}
({{ row.total_review_message_count }} history)
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if cid_message_rows %}
<div class="mt-2 small text-muted">CID users</div>
<div class="d-flex flex-column gap-1">
{% for row in cid_message_rows|slice:":5" %}
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_cid_messages' collection.pk row.cid_user.cid %}">
{{ row.cid_user }}
{% if row.outstanding_message_count %}
({{ row.outstanding_message_count }} outstanding)
{% else %}
({{ row.total_review_message_count }} history)
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="small text-muted">Case query/messaging</div>
<div class="fw-semibold text-muted">Disabled for this collection</div>
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<h3 class="mb-3">Cases</h3>
<ol id="full-question-list" class="sortable list-group list-group-numbered">
{% for casedetail in casesdetails %}
{% include 'atlas/partials/case_list_item.html' %}
{% endfor %}
</ol>
</div>
</div>
{% if can_edit %}
<div class="row mt-4">
<div class="col-12 d-flex gap-2">
<button id='button-edit-order'
title='click and drag questions to change order'
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
class="btn btn-sm btn-outline-secondary">
Edit case order / Remove cases
</button>
<button id='button-add-case'
title='click to add a case to this collection'
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
class="btn btn-sm btn-secondary">
Add case
</button>
<div id='case-list'></div>
</div>
</div>
<div class="row mt-3">
<div class="row">
<div class="col-12">
<details>
<summary>Answer management</summary>
<h3 class="mb-3">Cases</h3>
<div class="alert alert-danger mt-3">
<p class="mb-1">Manage answers for this collection.</p>
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
</div>
<div class="d-flex gap-2">
<button title="This will clear all user answers and attempts"
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
class="btn btn-danger btn-sm"
>Reset all answers</button>
<button title="This will specific user answers and attempts"
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
hx-swap="innerHTML"
hx-target="#user-list"
class="btn btn-warning btn-sm">
Reset user answers</button>
</div>
<div id="user-list" class="mt-3"></div>
</details>
<ol id="full-question-list" class="sortable list-group list-group-numbered">
{% for casedetail in casesdetails %}
{% include 'atlas/partials/case_list_item.html' %}
{% endfor %}
</ol>
</div>
</div>
{% endif %}
{% include 'exam_overview_js.html' %}
</div>
{% if can_edit %}
<div class="row mt-4">
<div class="col-12 d-flex gap-2">
<button id='button-edit-order'
title='click and drag questions to change order'
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
class="btn btn-sm btn-outline-secondary">
Edit case order / Remove cases
</button>
<button id='button-add-case'
title='click to add a case to this collection'
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
class="btn btn-sm btn-secondary">
Add case
</button>
<div id='case-list'></div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<details>
<summary>Answer management</summary>
<div class="alert alert-danger mt-3">
<p class="mb-1">Manage answers for this collection.</p>
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
</div>
<div class="d-flex gap-2">
<button title="This will clear all user answers and attempts"
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
class="btn btn-danger btn-sm"
>Reset all answers</button>
<button title="This will specific user answers and attempts"
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
hx-swap="innerHTML"
hx-target="#user-list"
class="btn btn-warning btn-sm">
Reset user answers</button>
</div>
<div id="user-list" class="mt-3"></div>
</details>
</div>
</div>
{% endif %}
{% include 'exam_overview_js.html' %}
<!-- Modal for case fields (history / discussion / report) -->
<div id="case-field-modal" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content"></div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
try {
var pk = '{{ collection.pk }}';
var key = 'collection_warning_dismissed_' + pk;
var alertEl = document.getElementById('collection-warning-' + pk);
if (!alertEl) return;
if (localStorage.getItem(key) === '1') {
alertEl.remove();
return;
}
var btn = document.getElementById('collection-warning-close-' + pk);
if (btn) {
btn.addEventListener('click', function() {
try { localStorage.setItem(key, '1'); } catch (e) { /* ignore */ }
});
}
} catch (e) { console.warn('collection warning persistence error', e); }
});
</script>
<script>
// Listen for case selection events from a case-search-widget included in this page/modal
document.body.addEventListener('case:selected', async function(e) {
try {
const detail = e.detail || {};
const casePk = detail.casePk;
const caseTitle = detail.caseTitle || ('#' + casePk);
if (!casePk) return;
if (!window.confirm(`Add case ${caseTitle} to collection {{ collection.name }}?`)) return;
const url = "{% url 'atlas:add_case_to_collection' collection.pk %}";
const form = new URLSearchParams();
form.append('case', casePk);
const csrfToken = '{{ csrf_token }}';
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: form.toString(),
credentials: 'same-origin'
});
const html = await resp.text();
// Append the returned case item into the list
const list = document.getElementById('full-question-list');
if (list) list.insertAdjacentHTML('beforeend', html);
} catch (err) {
console.error('Add case to collection failed', err);
alert('Failed to add case to collection — check console.');
}
});
</script>
<script>
// Global handler for markup links inserted by render_markup
document.body.addEventListener('click', function(ev){
var el = ev.target.closest && ev.target.closest('.markup-link');
if(!el) return;
ev.preventDefault();
try{
var typ = el.getAttribute('data-type');
var args = el.getAttribute('data-args');
var href = el.getAttribute('href') || '#';
if(typ === 'case'){
// open case — give option to open in new tab
if(window.confirm('Open case in new tab?')){
window.open(href, '_blank');
} else {
window.location = href;
}
return;
}
// For finding/displayset open modal with basic options — endpoints can be wired later
var modal = document.getElementById('case-field-modal');
var content = modal && modal.querySelector('.modal-content');
var parsedArgs = [];
try{ parsedArgs = JSON.parse(args || '[]'); }catch(e){ parsedArgs = []; }
if(typ === 'finding'){
var findingId = parsedArgs[0] || '';
var html = '<div class="modal-header"><h5 class="modal-title">Finding '+findingId+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
html += '<div class="modal-body">';
html += '<p>This will open the finding viewer for ID '+findingId+'.</p>';
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/finding/'+findingId+'" target="_blank">Open in new tab</a> ';
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
html += '</div>';
if(content) content.innerHTML = html;
var bs = new bootstrap.Modal(modal);
bs.show();
return;
}
if(typ === 'displayset'){
var caseId = parsedArgs[0] || '';
var setName = parsedArgs[1] || '';
var html = '<div class="modal-header"><h5 class="modal-title">Display set '+setName+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
html += '<div class="modal-body">';
html += '<p>Display set <strong>'+setName+'</strong> from case #'+caseId+'.</p>';
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/collection/'+caseId+'" target="_blank">Open case in new tab</a> ';
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
html += '</div>';
if(content) content.innerHTML = html;
var bs2 = new bootstrap.Modal(modal);
bs2.show();
return;
}
}catch(err){ console.error('markup link handler error', err); }
}, false);
</script>
{% endblock %}
@@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">
{% if cid is not None %}
CID: <strong>{{ cid }}</strong>
{% else %}
Feedback hub
{% endif %}
</div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Feedback Summary</div>
<div class="fw-semibold">{{ total_outstanding_feedback }} outstanding feedback item{{ total_outstanding_feedback|pluralize }}</div>
<div class="small text-muted mt-1">Across {{ outstanding_case_count }} case{{ outstanding_case_count|pluralize }}</div>
<div class="mt-2">
<a class="btn btn-sm btn-outline-secondary"
href="{% if cid is not None %}{% url 'atlas:collection_take_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_take_overview_user' pk=collection.id %}{% endif %}">
Back to Overview
</a>
</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3">Outstanding Feedback</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
<div class="card-header d-flex justify-content-between align-items-center gap-2">
<div>
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_feedback_count }} awaiting acknowledgement</span>
</div>
<div class="card-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success">No outstanding feedback needs acknowledgement.</div>
{% endif %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3">Previous Conversations</h3>
<div class="accordion" id="feedbackHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item" id="case-feedback-{{ row.casedetail.case.id }}">
<h2 class="accordion-header" id="heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} message{{ row.stats.message_count|pluralize }}</span>
</button>
</h2>
<div id="collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ row.casedetail.case.id }}" data-bs-parent="#feedbackHistoryAccordion">
<div class="accordion-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open to load conversation history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary">
<div class="card-body text-muted small">No case conversations have been started yet.</div>
</div>
{% endif %}
{% endblock %}
@@ -28,6 +28,8 @@
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
+158 -35
View File
@@ -1,54 +1,177 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>{{collection.name}}</h2>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Collection attempt history and message activity</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.pk %}">Collection Detail</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Messages Inbox</a>
{% endif %}
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Cases</div>
<div class="h4 mb-0">{{ total_cases }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Attempts</div>
<div class="h4 mb-0">{{ total_attempt_count }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Completed Attempts</div>
<div class="h4 mb-0">{{ completed_attempt_count }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100 {% if total_outstanding_messages %}border-danger-subtle{% endif %}">
<div class="card-body py-3">
{% if collection.case_query_messaging_enabled %}
<div class="small text-muted">Outstanding Messages</div>
<div class="h4 mb-0 {% if total_outstanding_messages %}text-danger{% endif %}">{{ total_outstanding_messages }}</div>
{% else %}
<div class="small text-muted">Case Messaging</div>
<div class="h6 mb-0 text-muted">Disabled</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if userexams %}
<h3>Users</h3>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h5 mb-0">Users</h3>
<div class="small text-muted">{{ userexams|length }} user attempt{{ userexams|length|pluralize }}</div>
</div>
<ul>
<div class="d-flex flex-column gap-3 mb-4">
{% for userexam in userexams %}
<li id="user-history-{{ userexam.user_user.pk }}">
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
Completed: {{userexam.completed}}<br/>
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
<button
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
hx-target="#user-history-{{ userexam.user_user.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</li>
<div id="user-history-{{ userexam.user_user.pk }}" class="card {% if userexam.outstanding_feedback_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">
<a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{ userexam.get_user_name }}</a>
{% if userexam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% else %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">In progress</span>
{% endif %}
</div>
<div class="small text-muted mt-1">Started: {{ userexam.start_time|default:"-" }}</div>
<div class="small text-muted">Ended: {{ userexam.end_time|default:"-" }}</div>
<div class="mt-2 d-flex flex-wrap gap-2">
{% if collection.case_query_messaging_enabled %}
{% if userexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ userexam.outstanding_feedback_count }} outstanding message{{ userexam.outstanding_feedback_count|pluralize }}</span>
{% endif %}
{% if userexam.total_review_message_count %}
<span class="badge bg-secondary">{{ userexam.total_review_message_count }} conversation message{{ userexam.total_review_message_count|pluralize }}</span>
{% else %}
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">Open Attempt</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_user_messages' collection.pk userexam.user_user.pk %}">Messages</a>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
hx-target="#user-history-{{ userexam.user_user.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</ul>
</div>
{% endif %}
{% if cidexams %}
<h3>CID Users</h3>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h5 mb-0">CID Users</h3>
<div class="small text-muted">{{ cidexams|length }} CID attempt{{ cidexams|length|pluralize }}</div>
</div>
<ul>
<div class="d-flex flex-column gap-3">
{% for cidexam in cidexams %}
<li id="cid-history-{{ cidexam.cid_user.pk }}">
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{cidexam.cid_user}}</a><b><br/>
Completed: {{cidexam.completed}}<br/>
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this CID user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</li>
<div id="cid-history-{{ cidexam.cid_user.cid }}" class="card {% if cidexam.outstanding_feedback_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">
<a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{ cidexam.cid_user }}</a>
{% if cidexam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% else %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">In progress</span>
{% endif %}
</div>
<div class="small text-muted mt-1">Started: {{ cidexam.start_time|default:"-" }}</div>
<div class="small text-muted">Ended: {{ cidexam.end_time|default:"-" }}</div>
<div class="mt-2 d-flex flex-wrap gap-2">
{% if collection.case_query_messaging_enabled %}
{% if cidexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ cidexam.outstanding_feedback_count }} outstanding message{{ cidexam.outstanding_feedback_count|pluralize }}</span>
{% endif %}
{% if cidexam.total_review_message_count %}
<span class="badge bg-secondary">{{ cidexam.total_review_message_count }} conversation message{{ cidexam.total_review_message_count|pluralize }}</span>
{% else %}
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">Open Attempt</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_cid_messages' collection.pk cidexam.cid_user.cid %}">Messages</a>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this CID user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</ul>
</div>
{% endif %}
{% if not userexams and not cidexams %}
<div class="card border-secondary">
<div class="card-body text-muted small">No attempts have been recorded for this collection yet.</div>
</div>
{% endif %}
{% endblock %}
@@ -1,101 +1,165 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>Collection: {{collection.name}}</h2>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Learner: <strong>{{ user }}</strong></div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Attempt Progress</div>
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
<div class="progress progress-compact mt-2">
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
</div>
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
</div>
</div>
</div>
<h3>User: {{user}}</h3>
<ul>
{% for casedetail, user_answer in user_answers %}
<li class="case">
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
Question started: {{user_answer.started_at}} - Answer submitted: {{user_answer.submitted_at}}
{% if request.user.is_superuser and user_answer %}
&nbsp;(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
{% endif %}
<br/>
<br/>
{% if not user_answer %}
<span class="case-not-answered">Case not answered.</span>
{% else %}
<div class="answer-block">
User answer:
<div class="user-answer">
{% if user_answer.answer %}
{{user_answer.answer}}
{% else %}
{{user_answer.json_answer}}
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
{% if not user_answer %}
Not answered
{% else %}
<div class="{% if answer_is_correct %}
correct
{% else %}
incorrect
{% endif %}
{% if automark %}
automark
{% endif %}
">
<h5>{{value.title}}</h5>
{{value.description}}
<div
>
Answer : {{user_answer}}
{% if not answer_is_correct %}
<br/>Correct answer: {{correct_answer}}
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
<div class="list-group mb-4">
{% for row in history_rows %}
<div id="case-history-{{ row.casedetail.case.id }}" class="list-group-item {% if not row.user_answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% if row.user_answer and row.user_answer.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif row.user_answer %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
{% endif %}
{% if row.self_reviews %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">{{ row.self_reviews|length }} self review{{ row.self_reviews|length|pluralize }}</span>
{% endif %}
</div>
<div class="small text-muted">
Started: {{ row.user_answer.started_at|default:'-' }}
{% if row.user_answer %}
· Submitted: {{ row.user_answer.submitted_at|default:'-' }}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary"
href="{% url 'atlas:collection_case_view' pk=collection.id case_number=forloop.counter0 %}">
Open Case
</a>
{% if request.user.is_superuser and row.user_answer %}
{% if row.user_answer.user_id %}
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'admin:atlas_userreportanswer_change' row.user_answer.pk %}"
target="_blank">
Edit in admin
</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'admin:atlas_cidreportanswer_change' row.user_answer.pk %}"
target="_blank">
Edit in admin
</a>
{% endif %}
{% endif %}
{% if row.self_reviews %}
{% for review in row.self_reviews %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
View Self Review {{ forloop.counter }}
</a>
{% endfor %}
{% endif %}
</div>
</div>
{% if not row.user_answer %}
<div class="small text-muted">This case has not been answered yet.</div>
{% else %}
<details class="mb-3" open>
<summary class="small fw-semibold">Answer / Report</summary>
<div class="small mt-2 p-3 rounded bg-body-tertiary border">
{% if row.user_answer.answer %}
{{ row.user_answer.answer|linebreaksbr }}
{% else %}
<div class="mb-2">{{ row.user_answer.json_answer }}</div>
{% for value, single_answer, correct_answer, answer_is_correct, automark in row.user_answer.get_correct_json_answers %}
<div class="mb-2 p-2 border rounded {% if answer_is_correct %}border-success-subtle{% else %}border-danger-subtle{% endif %}">
<div class="fw-semibold">
{{ value.title }}
{% if answer_is_correct %}
<span class="text-success"></span>
{% elif automark %}
<span class="text-danger"></span>
{% else %}
<span class="text-warning"></span>
{% endif %}
</div>
<div class="small text-muted">{{ value.description }}</div>
<div class="small mt-1">Answer: {{ single_answer|default:'Not answered' }}</div>
{% if not answer_is_correct and correct_answer %}
<div class="small text-muted">Correct answer: {{ correct_answer }}</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</details>
{% endif %}
</li>
{% if row.self_reviews %}
<details class="mb-3">
<summary class="small fw-semibold">Self Reviews</summary>
<div class="d-flex flex-column gap-2 mt-2">
{% for review in row.self_reviews %}
<div class="border rounded p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<div class="small text-muted">Updated: {{ review.review_update_date|date:"Y-m-d H:i" }}</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
View
</a>
<a class="btn btn-sm btn-outline-warning"
href="{% url 'atlas:self_review_update' cid_user_exam.id row.casedetail.case.id review.id %}">
Edit
</a>
</div>
</div>
<div class="small mb-1"><strong>Findings:</strong> {{ review.findings|default:'-' }}/5</div>
<div class="small mb-2"><strong>Interpretation:</strong> {{ review.interpretation|default:'-' }}/5</div>
<div class="small text-body-secondary">{{ review.comments|default:'No comments added.'|linebreaksbr }}</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if collection.case_query_messaging_enabled %}
<details class="mt-3" {% if row.case_review_stats.has_outstanding_any %}open{% endif %}>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case conversation</span>
{% if row.case_review_stats.has_outstanding_any %}
<span class="badge bg-danger">{{ row.case_review_stats.outstanding_total_count }} unacknowledged message{{ row.case_review_stats.outstanding_total_count|pluralize }}</span>
{% elif row.case_review_stats.has_messages %}
<span class="badge bg-secondary">{{ row.case_review_stats.message_count }} message{{ row.case_review_stats.message_count|pluralize }}</span>
{% else %}
<span class="text-muted">No messages yet</span>
{% endif %}
</summary>
<div class="mt-2"
hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Loading case conversation...</div>
</div>
</details>
{% endif %}
</div>
{% endfor %}
</ul>
</div>
{% endblock %}
{% block css %}
{{ block.super }}
<style>
.correct {
}
.correct h5::after {
content: '✓';
color: green;
}
.incorrect h5::after {
content: '✗';
color: orange;
}
.incorrect.automark h5::after {
content: '✗';
color: red;
}
.answer-block>div {
padding-bottom: 10px;
}
.case:has(.case-not-answered) {
opacity: 0.5;
}
</style>
{% endblock %}
@@ -1,28 +1,143 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Collection: {{exam}}</h2>
<style>
.take-start-hero {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, rgba(13, 110, 253, 0.22), rgba(25, 135, 84, 0.2));
border-radius: .75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
{% comment %} <ul>
{% for case in collection.cases.all %}
<li><a href="{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}">Case {{forloop.counter}}</a></li>
{% endfor %}
</ul> {% endcomment %}
.take-start-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border-radius: .75rem;
padding: 1rem;
}
User: {{request.user}}<br/>
.take-start-meta {
display: grid;
grid-template-columns: 9rem 1fr;
gap: .35rem .75rem;
}
{% if cid_exam %}
Started: {{cid_exam.start_time}} <br/>
{% if cid_exam.completed %}
Ended: {{cid_exam.end_time}} <br/>
{% endif %}
.take-start-meta dt {
font-weight: 600;
color: var(--bs-secondary-color);
margin: 0;
}
.take-start-meta dd {
margin: 0;
}
</style>
<section class="take-start-hero">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h2 class="mb-0">Review: {{ collection.name }}</h2>
<span class="badge bg-light text-dark">Review Collection</span>
</div>
<p class="mb-0 mt-2 text-body-secondary">Open the collection and browse completed cases.</p>
</section>
{% if collection.start_screen_content %}
<section class="take-start-card mb-3">
{{ collection.start_screen_content|safe }}
</section>
{% endif %}
<a href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}
<button>Review</button>
{% else %}
<button>Start</button>
{% if start_screen_resources %}
<section class="take-start-card mb-3">
<h6 class="mb-2">Resources</h6>
<div class="d-grid gap-2">
{% for resource in start_screen_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if request.user.is_authenticated and valid_user %}
<section class="take-start-card">
<dl class="take-start-meta mb-3">
<dt>User</dt>
<dd>{{ request.user }}</dd>
{% if cid_exam %}
<dt>Started</dt>
<dd>{{ cid_exam.start_time }}</dd>
{% if cid_exam.completed %}
<dt>Ended</dt>
<dd>{{ cid_exam.end_time }}</dd>
{% endif %}
{% endif %}
</dl>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary"
href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
Browse
</a>
{% if cid_exam.completed %}
<a class="btn btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}">Overview</a>
{% endif %}
</div>
</section>
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared - collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
- you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they do not have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
</a>
{% else %}
<section class="take-start-card">
<p class="mb-0">
Please sign in to access this review collection.
<a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">Log in here</a>.
</p>
</section>
{% endif %}
{% endblock %}
@@ -1,78 +1,195 @@
{% extends 'base.html' %}
{% block content %}
{% if cid is not None %}
CID: {{cid}}
{% endif %}
<h2>Collection: {{collection.name}}
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="stamp-white">REVIEW</span>
{% endif %}
</h2>
{% if not collection.review_only %}
{{answer_count}} out of {{collection_length}} cases answered. Click to go to case.
{% endif %}
<div class="sba-finish-list">
<ul>
{% for question, answer, self_review in question_answer_tuples %}
<li> <span class="{% if not collection.review_only and not answer %}unanswered{% endif %}">
<a href="
{% if cid is not None %}
{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}
{% else %}
{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}
{% endif %}
">
Case: {{forloop.counter}}
{% if answer.completed or cid_user_exam.completed %}
<span title="You have completed this case"><i class="bi bi-check-square-fill"></i>
</span>
{% endif %}
{% if self_review %} <span class="self-review-complete" title="You have reviewed this case."> <i class="bi-card-checklist"></i></span>{% endif %}
</a>
<br/>
{% if not collection.review_only %}
{% if not answer %}
No answer
{% else %}
<details><summary>Answered</summary>
{% if aswer.answer %}
{{answer.answer}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">
{{ collection.name }}
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="badge bg-light text-dark ms-2">Review</span>
{% endif %}
{% if collection.case_query_messaging_enabled and total_outstanding_feedback %}
<span class="badge bg-danger ms-2">{{ total_outstanding_feedback }} outstanding feedback</span>
{% endif %}
</h2>
<div class="text-muted small">
{% if cid is not None %}
CID: <strong>{{ cid }}</strong>
{% else %}
<h5>Overview</h5>
{% endif %}
</div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Progress</div>
{% if not collection.review_only %}
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
<div class="progress progress-compact mt-2">
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
</div>
{% else %}
<div class="fw-semibold">Review-only collection</div>
{% endif %}
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
{% if cid_user_exam.completed %}
<div class="mt-1 small">End time: {{ cid_user_exam.end_time|default:"-" }}</div>
{% endif %}
{% if collection.case_query_messaging_enabled and collection.in_review_mode or collection.case_query_messaging_enabled and cid_user_exam.completed %}
<div class="mt-2">
<a class="btn btn-sm {% if total_outstanding_feedback %}btn-danger{% else %}btn-outline-secondary{% endif %}"
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}">
{% if total_outstanding_feedback %}
Review Feedback Hub
{% else %}
{{answer.json_answer}}
Open Feedback Hub
{% endif %}
</details>
{% endif %}
{% endif %}
</span>
</li>
{% endfor %}
</ul>
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="list-group mb-3">
{% for question, answer, self_review, review_stats, timing_state in question_answer_tuples %}
<div class="list-group-item {% if review_stats.has_outstanding_feedback %}border-warning border-2{% elif not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}{% if collection.show_title_post and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}: {{ question.case.title|default:question.case.pk|truncatechars:70 }}{% endif %}
{% if answer and answer.completed or cid_user_exam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif answer %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
{% endif %}
{% if self_review %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
{% endif %}
{% if timing_state.effective_time_limit is not None and timing_state.case_view_locked %}
<span class="badge bg-dark ms-1">Time expired</span>
{% endif %}
{% if collection.case_query_messaging_enabled %}
{% if review_stats.has_outstanding_feedback %}
<span class="badge bg-danger ms-1">{{ review_stats.outstanding_feedback_count }} feedback item{{ review_stats.outstanding_feedback_count|pluralize }} to review</span>
{% elif review_stats.has_messages %}
<span class="badge bg-secondary ms-1">{{ review_stats.message_count }} conversation message{{ review_stats.message_count|pluralize }}</span>
{% endif %}
{% endif %}
</div>
{% if collection.show_title_post and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}
<div class="small text-muted">
{{ question.case }}
</div>
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary"
href="{% if cid is not None %}{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}{% endif %}">
Open Case
</a>
{% if collection.in_review_mode or cid_user_exam.completed %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:add_self_review' cid_user_exam.id question.case.id %}">
{% if self_review %}Add New Self Review{% else %}Add Self Review{% endif %}
</a>
{% if collection.case_query_messaging_enabled and review_stats.has_messages %}
<a class="btn btn-sm {% if review_stats.has_outstanding_feedback %}btn-danger{% else %}btn-outline-dark{% endif %}"
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}#case-feedback-{{ question.case.id }}">
View Conversation
</a>
{% endif %}
{% endif %}
</div>
</div>
Start time: {{cid_user_exam.start_time}}
{% if self_review %}
<div class="mt-2 pt-2 border-top">
<div class="small fw-semibold mb-1">Your self reviews</div>
<div class="d-flex flex-wrap gap-2">
{% for review in self_review %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id question.case.id review.id %}">
View review {{ forloop.counter }}
</a>
<a class="btn btn-sm btn-outline-warning"
href="{% url 'atlas:self_review_update' cid_user_exam.id question.case.id review.id %}">
Edit review {{ forloop.counter }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if not collection.review_only %}
{% if answer %}
<details class="mt-2">
<summary class="small text-muted">Preview saved answer/report</summary>
<div class="small mt-2 p-2 bg-body-tertiary rounded">
{% if answer.answer %}
{{ answer.answer|linebreaksbr }}
{% else %}
{{ answer.json_answer }}
{% endif %}
</div>
</details>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% if not collection.review_only and not cid_user_exam.completed %}
<p>Completed: <span id="completed-state">{{cid_user_exam.completed}}</span></p>
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
>
<button type="submit" name="finish" value="finish">Finish</button>
</form>
<div class="card border-warning-subtle">
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div class="fw-semibold">Ready to submit this session?</div>
<div class="small text-muted">You can continue to review and edit your answers until you finish the session.</div>
<div id="completed-state" class="mt-2 small text-danger"></div>
</div>
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
class="m-0">
<button type="submit" name="finish" value="finish" class="btn btn-warning">Finish Session</button>
</form>
</div>
</div>
{% endif %}
{% if collection.end_overview_content %}
<div class="card mt-3">
<div class="card-body">
{{ collection.end_overview_content|safe }}
</div>
</div>
{% endif %}
{% if end_overview_resources %}
<div class="card mt-3">
<div class="card-body">
<h6 class="mb-2">Further Resources</h6>
<div class="d-grid gap-2">
{% for resource in end_overview_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Sharing panel only for registered-user attempts (not CID candidates) #}
{% if collection.show_results_sharing_information and cid_user_exam.user_user_id %}
<div hx-get="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-muted small mt-3">Loading sharing settings…</div>
</div>
{% endif %}
+163 -44
View File
@@ -1,69 +1,188 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Start: {{collection.name}}{% if cid_exam.completed %} <span class="stamp-white">REVIEW</span>{% endif %}</h2>
<style>
.take-start-hero {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, rgba(13, 110, 253, 0.22), rgba(25, 135, 84, 0.2));
border-radius: .75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.take-start-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border-radius: .75rem;
padding: 1rem;
}
.take-start-meta {
display: grid;
grid-template-columns: 9rem 1fr;
gap: .35rem .75rem;
}
.take-start-meta dt {
font-weight: 600;
color: var(--bs-secondary-color);
margin: 0;
}
.take-start-meta dd {
margin: 0;
}
</style>
<section class="take-start-hero">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h2 class="mb-0">Start: {{ collection.name }}</h2>
{% if cid_exam.completed %}
<span class="badge bg-light text-dark">Review Mode</span>
{% endif %}
</div>
<p class="mb-0 mt-2 text-body-secondary">Open the collection and begin or resume your session.</p>
</section>
{% if collection.start_screen_content %}
<section class="take-start-card mb-3">
{{ collection.start_screen_content|safe }}
</section>
{% endif %}
{% if start_screen_resources %}
<section class="take-start-card mb-3">
<h6 class="mb-2">Resources</h6>
<div class="d-grid gap-2">
{% for resource in start_screen_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if request.user.is_authenticated and valid_user %}
User: {{request.user}}<br/>
<section class="take-start-card">
<dl class="take-start-meta mb-3">
<dt>User</dt>
<dd>{{ request.user }}</dd>
{% if cid_exam %}
<dt>Started</dt>
<dd>{{ cid_exam.start_time }}</dd>
{% if cid_exam %}
Started: {{cid_exam.start_time}} <br/>
{% if cid_exam.completed %}
<dt>Ended</dt>
<dd>{{ cid_exam.end_time }}</dd>
{% endif %}
{% endif %}
</dl>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary"
href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}Review{% else %}Start{% endif %}
</a>
{% if cid_exam.completed %}
Ended: {{cid_exam.end_time}} <br/>
{% endif %}
{% if cid_exam.completed %}
<a class="btn btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}">Overview</a>
{% endif %}
</div>
</section>
{# ---- Who can see your results ---- #}
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
— you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they don't have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
<a href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}
<button>Review</button>
{% else %}
<button>Start</button>
{% endif %}
</a>
{% if cid_exam.completed %}
<a href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}"><button>Overview</button></a>
{% endif %}
{% else %}
Enter your CID and passcode in the below boxes. (Registered user should login <a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">here</a>)<br />
<p><input id="cid-box" type="text" value="Candidate ID"></p>
<p><input id="passcode-box" type="text" value="Passcode"></p>
<section class="take-start-card">
<p class="mb-3">
Enter your CID and passcode below.
Registered users can
<a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">log in here</a>.
</p>
<button>Start</button>
<div class="row g-2 align-items-end">
<div class="col-12 col-md-5">
<label class="form-label" for="cid-box">Candidate ID</label>
<input id="cid-box" class="form-control" type="text" value="Candidate ID">
</div>
<div class="col-12 col-md-5">
<label class="form-label" for="passcode-box">Passcode</label>
<input id="passcode-box" class="form-control" type="text" value="Passcode">
</div>
<div class="col-12 col-md-2 d-grid">
<button id="start-candidate-btn" class="btn btn-primary" type="button">Start</button>
</div>
</div>
</section>
<script type="text/javascript">
$(document).ready(function () {
window.location.search.substr(1).split("&").forEach((item) =>
{
s = item.split("=");
if (s[0] == "cid") {
$("#cid-box").val(s[1]);
}
if (s[0] == "passcode") {
$("#passcode-box").val(s[1]);
}
});
$("#cid-box, #passcode-box").keypress(function(e) {
// Enter pressed?
console.log(e)
if(e.which == 10 || e.which == 13) {
$("button").click();
window.location.search.substr(1).split("&").forEach((item) => {
const s = item.split("=");
if (s[0] === "cid") {
$("#cid-box").val(s[1]);
}
if (s[0] === "passcode") {
$("#passcode-box").val(s[1]);
}
});
$("button").click(() => {
let cid = $("#cid-box").val();
let passcode = $("#passcode-box").val();
$("#cid-box, #passcode-box").keypress(function (e) {
if (e.which === 10 || e.which === 13) {
$("#start-candidate-btn").click();
}
});
$("#start-candidate-btn").click(() => {
const cid = $("#cid-box").val();
const passcode = $("#passcode-box").val();
if (Number.isInteger(parseInt(cid))) {
window.location.replace("{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=0 cid='0000000' passcode='ZZZZZZ' %}".replace("0000000", cid).replace("ZZZZZZ", passcode));
} else {
alert("Please enter a valid Candidate ID (CID).")
alert("Please enter a valid Candidate ID (CID).");
}
});
});
@@ -0,0 +1,121 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Message hub for <strong>{{ target_label }}</strong></div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} awaiting acknowledgement
</div>
<div class="mt-2 d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Back to Collection History</a>
<a class="btn btn-sm btn-outline-secondary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.pk target_user.pk %}{% else %}{% url 'atlas:collection_history_ciduser' collection.pk target_cid %}{% endif %}">
Open Full Attempt Page
</a>
</div>
<div class="mt-2">
{% if show_all %}
<a class="btn btn-sm btn-outline-primary" href="?">Show outstanding only</a>
{% else %}
<a class="btn btn-sm btn-primary" href="?show=all">Load all conversation history</a>
{% endif %}
</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3">Outstanding Messages</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:90 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_total_count }} outstanding</span>
</div>
<div class="card-body pt-0">
<div class="d-flex flex-wrap gap-2 mt-2 mb-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.id %}">
Collection
</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_case_view' pk=collection.id case_number=row.casedetail.sort_order %}">
Case In Collection
</a>
<a class="btn btn-sm btn-outline-primary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.id target_user.id %}{% else %}{% url 'atlas:collection_history_ciduser' collection.id target_cid %}{% endif %}#case-history-{{ row.casedetail.case.id }}">
User Answer In Context
</a>
</div>
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success">No outstanding messages for this user.</div>
{% endif %}
{% if show_all %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3">Conversation History</h3>
<div class="accordion" id="userMessageHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item" id="history-case-{{ row.casedetail.case.id }}">
<h2 class="accordion-header" id="history-heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#history-collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="history-collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:90 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} messages</span>
</button>
</h2>
<div id="history-collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="history-heading-{{ row.casedetail.case.id }}" data-bs-parent="#userMessageHistoryAccordion">
<div class="accordion-body pt-0">
<div class="d-flex flex-wrap gap-2 mt-2 mb-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.id %}">
Collection
</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_case_view' pk=collection.id case_number=row.casedetail.sort_order %}">
Case In Collection
</a>
<a class="btn btn-sm btn-outline-primary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.id target_user.id %}{% else %}{% url 'atlas:collection_history_ciduser' collection.id target_cid %}{% endif %}#case-history-{{ row.casedetail.case.id }}">
User Answer In Context
</a>
</div>
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open section to load history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary">
<div class="card-body text-muted small">No conversation history for this user yet.</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
@@ -71,6 +71,22 @@
<div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div>
{# Attached resources for this case: allow loading into the shared viewer #}
{% if case.caseresource_set.exists %}
<div class="mt-2">
<strong>Resources:</strong>
<div class="d-flex flex-column gap-1">
{% for cr in case.caseresource_set.all %}
{% with res=cr.resource %}
<div>
<span class="me-2">{{ res.name }}</span>
<button class="open-resource btn btn-sm btn-outline-primary ms-1" data-src="{% if res.file %}{{ res.file.url }}{% else %}{{ res.url }}{% endif %}" data-title="{{ res.name|escapejs }}">Load</button>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
{% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary>
{% for ds in case.display_sets.all %}
@@ -335,6 +351,19 @@
//}
//win2 = openSecondaryWindow(url);
})
// Load resource into linked viewer
$('.open-resource').click(function() {
var src = $(this).data('src');
var title = $(this).data('title') || 'Resource';
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Resource:</span> "+title);
$('#current-case-history').html('');
$('#current-case-discussion').html('');
$('#current-case-report').html('');
bc.postMessage({"type": "open", "url": src});
});
$('.send-message').click(function() {
var url = $(this).data('target');
bc.postMessage(url);
+109 -95
View File
@@ -17,127 +17,141 @@
<section class="mb-3">
<h5 class="mb-2">Details</h5>
<dl class="row">
<dt class="col-sm-4">Primary name</dt>
<dd class="col-sm-8">{% if condition.canonical %}No (alias of <a href="{{ condition.canonical.get_absolute_url }}">{{ condition.canonical.name }}</a>){% else %}Yes{% endif %}</dd>
<dt class="col-sm-4">Primary name</dt>
<dd class="col-sm-8">{% if condition.canonical %}No (alias of <a href="{{ condition.canonical.get_absolute_url }}">{{ condition.canonical.name }}</a>){% else %}Yes{% endif %}</dd>
<dt class="col-sm-4">Subspecialty</dt>
<dt class="col-sm-4">Subspecialty</dt>
<dd class="col-sm-8">
{% for sub in condition.subspecialty.all %}
<a class="badge bg-dark text-white me-1 text-decoration-none" href="{{ sub.get_absolute_url }}">{{ sub }}</a>
{% empty %}
&mdash;
{% endfor %}
</dd>
<dt class="col-sm-4">Synonyms</dt>
<dd class="col-sm-8">
<div id="synonyms-container">
{% include 'atlas/partials/_synonyms_list.html' %}
</div>
</dd>
<dt class="col-sm-4">Parent</dt>
<dd class="col-sm-8">
{% for parent in condition.parent.all %}
<a class="badge bg-primary text-white text-decoration-none me-1" href="{{ parent.get_absolute_url }}">{{ parent }}</a>
{% empty %}
&mdash;
{% endfor %}
</dd>
<dt class="col-sm-4">Children</dt>
<dd class="col-sm-8">
{% for child in condition.child.all %}
<a class="badge bg-info text-dark text-decoration-none me-1" href="{{ child.get_absolute_url }}">{{ child }}</a>
{% empty %}
&mdash;
{% endfor %}
</dd>
<dt class="col-sm-4">RCR condition</dt>
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
{% if not condition.rcr_curriculum %}
<dt class="col-sm-4">RCR condition map</dt>
<dd class="col-sm-8">
{% for sub in condition.subspecialty.all %}
<a class="badge bg-dark text-white me-1 text-decoration-none" href="{{ sub.get_absolute_url }}">{{ sub }}</a>
{% for map in condition.rcr_curriculum_map.all %}
<a class="badge bg-success text-white text-decoration-none me-1" href="{{ map.get_absolute_url }}">{{ map }}</a>
{% empty %}
&mdash;
{% endfor %}
</dd>
<dt class="col-sm-4">Synonyms</dt>
<dd class="col-sm-8">
<div id="synonyms-container">
{% include 'atlas/partials/_synonyms_list.html' %}
</div>
</dd>
<dt class="col-sm-4">Parent</dt>
<dd class="col-sm-8">
{% for parent in condition.parent.all %}
<a class="badge bg-primary text-white text-decoration-none me-1" href="{{ parent.get_absolute_url }}">{{ parent }}</a>
{% empty %}
&mdash;
{% endfor %}
</dd>
<dt class="col-sm-4">Children</dt>
<dd class="col-sm-8">
{% for child in condition.child.all %}
<a class="badge bg-info text-dark text-decoration-none me-1" href="{{ child.get_absolute_url }}">{{ child }}</a>
{% empty %}
&mdash;
{% endfor %}
</dd>
<dt class="col-sm-4">RCR condition</dt>
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
{% if not condition.rcr_curriculum %}
<dt class="col-sm-4">RCR condition map</dt>
<dd class="col-sm-8">
{% for map in condition.rcr_curriculum_map.all %}
<a class="badge bg-success text-white text-decoration-none me-1" href="{{ map.get_absolute_url }}">{{ map }}</a>
{% empty %}
&mdash;
{% endfor %}
</dd>
{% endif %}
</dl>
{% endif %}
</dl>
</section>
{% if condition.case_set.exists %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Associated Cases</h5>
<ul class="list-unstyled mb-0">
{% for case in condition.case_set.all %}
<li class="mb-1">{{ case.get_link }}</li>
{% endfor %}
</ul>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Associated Cases</h5>
<ul class="list-unstyled mb-0">
{% for case in condition.case_set.all %}
<li class="mb-1">{{ case.get_link }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{# Also include cases that reference this condition via CaseDisplaySet #}
{% if condition.casedisplayset_set.exists %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Cases via Display Sets</h5>
<ul class="list-unstyled mb-0">
{% for ds in condition.casedisplayset_set.all %}
<li class="mb-1">{{ ds.case.get_link }} <small class="text-muted">[Display set: {{ ds.name }}]</small></li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% if condition.differential_set.exists %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Associated Case Differentials</h5>
<ul class="list-unstyled mb-0">
{% for diff in condition.differential_set.all %}
<li class="mb-1">{{ diff.case.get_link }} <small class="text-muted">[{{ diff.text }}]</small></li>
{% endfor %}
</ul>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Associated Case Differentials</h5>
<ul class="list-unstyled mb-0">
{% for diff in condition.differential_set.all %}
<li class="mb-1">{{ diff.case.get_link }} <small class="text-muted">[{{ diff.text }}]</small></li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% if condition.sbas_questions.exists %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Related SBA Questions</h5>
<ul class="list-unstyled mb-0">
{% for q in condition.sbas_questions.all %}
<li class="mb-1"><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
{% endfor %}
</ul>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Related SBA Questions</h5>
<ul class="list-unstyled mb-0">
{% for q in condition.sbas_questions.all %}
<li class="mb-1"><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
{% if not condition.rcr_curriculum and can_merge %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Merge</h5>
<p class="small text-muted">Merge this condition into another. This will transfer associated cases and differentials but will not transfer synonyms, parents or children.</p>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Merge</h5>
<p class="small text-muted">Merge this condition into another. This will transfer associated cases and differentials but will not transfer synonyms, parents or children.</p>
<p>
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#mergeCollapse" role="button" aria-expanded="false" aria-controls="mergeCollapse">Show merge form</a>
</p>
<p>
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#mergeCollapse" role="button" aria-expanded="false" aria-controls="mergeCollapse">Show merge form</a>
</p>
<div class="collapse" id="mergeCollapse">
<div class="card card-body">
<form method="POST" id="condition-merge-form">
{% csrf_token %}
<div class="mb-3">
{{ form.as_p }}
</div>
<button class="btn btn-danger"
hx-post='{% url "atlas:condition_merge" condition.pk %}'
hx-target="#merge-options" hx-swap="innerHTML"
hx-confirm="This will merge {{condition.name}} into the selected condition, this cannot be undone. Continue?">
Merge
</button>
</form>
<div id="merge-options" class="mt-3"></div>
<div class="collapse" id="mergeCollapse">
<div class="card card-body">
<form method="POST" id="condition-merge-form">
{% csrf_token %}
<div class="mb-3">
{{ form.as_p }}
</div>
<button class="btn btn-danger"
hx-post='{% url "atlas:condition_merge" condition.pk %}'
hx-target="#merge-options" hx-swap="innerHTML"
hx-confirm="This will merge {{condition.name}} into the selected condition, this cannot be undone. Continue?">
Merge
</button>
</form>
<div id="merge-options" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
+12 -2
View File
@@ -12,8 +12,8 @@
</div>
<div>
<h3>Name: {{finding.name}}</h3>
Primary name: {% if finding.canonical %}No (alias of <a href="{{ finding.canonical.get_absolute_url }}">{{ finding.canonical.name }}</a>){% else %}Yes{% endif %}<br />
Synonyms: {{ finding.get_synonym_link|safe }}<br />
Primary name: {% if finding.canonical %}No (alias of <a href="{{ finding.canonical.get_absolute_url }}">{{ finding.canonical.name }}</a>){% else %}Yes{% endif %}<br />
Synonyms: {{ finding.get_synonym_link|safe }}<br />
</div>
{% if finding.seriesfinding_set.all %}
<h4>Associated Cases</h4>
@@ -27,6 +27,16 @@
</ul>
{% endif %}
{# Also include cases referenced via CaseDisplaySet that list this finding #}
{% if finding.casedisplayset_set.all %}
<h4>Cases via Display Sets</h4>
<ul>
{% for ds in finding.casedisplayset_set.all %}
<li>Case: {{ ds.case.get_link }} (Display set: {{ ds.name }})</li>
{% endfor %}
</ul>
{% endif %}
{% if finding.sbas_questions.all %}
<h4>Related SBA Questions</h4>
<ul>
+76 -60
View File
@@ -1,76 +1,92 @@
{% extends 'atlas/base.html' %}
{% block content %}
<div class="container my-4">
<div class="row">
<div class="col-12">
<h2 class="mb-3">Atlas</h2>
</div>
</div>
<div class="row">
<div class="col-12 col-md-8">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Collections</h5>
<p class="card-text">Quick access to collections you can view or take.</p>
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">My cases / collections</h5>
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
<p>
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
</a>
</p>
</div>
<div class="container my-4">
<div class="row">
<div class="col-12">
<h2 class="mb-3">Atlas</h2>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
<ul class="list-unstyled mb-0">
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
</ul>
<div class="row">
<div class="col-12 col-md-8">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Collections</h5>
<p class="card-text">Quick access to collections you can view or take.</p>
<button class="btn btn-primary"
hx-get="{% url 'atlas:user_collections' %}"
hx-target="#user-collections-placeholder"
hx-swap="innerHTML">
Collections to view / take
</button>
<button class="btn btn-outline-primary ms-2"
hx-get="{% url 'atlas:user_related_collections' %}"
hx-target="#user-related-collections-placeholder"
hx-swap="innerHTML">
My collections
</button>
<div id="user-collections-placeholder" class="mt-3"></div>
<div id="user-related-collections-placeholder" class="mt-3"></div>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">My cases / collections</h5>
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a> or <a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">my collections</a>.</p>
<p>
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
</a>
</p>
</div>
</div>
<!-- user collections will load into the Collections card when requested -->
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Search cases</h5>
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="case-search-input" name="q" type="search"
placeholder="Search cases by title or description"
hx-get="{% url 'atlas:case_search_partial' %}"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#case-search-results"
hx-indicator="#case-search-indicator" />
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading</span>
</span>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
<ul class="list-unstyled mb-0">
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
<li><a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">My collections</a></li>
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
</ul>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Search cases</h5>
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="case-search-input" name="q" type="search"
placeholder="Search cases by title or description"
hx-get="{% url 'atlas:case_search_partial' %}"
hx-trigger="input delay:500ms, keyup[key=='Enter']"
hx-target="#case-search-results"
hx-indicator="#case-search-indicator" />
<a class="btn btn-outline-secondary ms-2" href="{% url 'atlas:case_search_page' %}" title="Open advanced case search">Advanced</a>
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading</span>
</span>
</div>
</div>
<style>
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
#case-search-indicator { display: none; }
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
</style>
<div id="case-search-results">
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
</div>
</div>
<style>
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
#case-search-indicator { display: none; }
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
</style>
<div id="case-search-results">
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+113 -33
View File
@@ -1,39 +1,119 @@
<!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html -->
{% extends "base.html" %}
{% load partials %}
{% extends "atlas/base.html" %}
{% block content %}
<h1>Linked Cases Overview</h1>
<h2>Current Case: {{ current_case.title }}</h2>
<div class="container py-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="h2 mb-1">Linked Cases Overview</h1>
<p class="text-muted mb-0">Review the full case chain around this study, with quick access to related cases and their linked series.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-outline-secondary" href="{{ current_case.get_absolute_url }}">Open current case</a>
{% if direct_previous %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_previous.pk %}">Previous in chain</a>
{% endif %}
{% if direct_next %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_next.pk %}">Next in chain</a>
{% endif %}
</div>
</div>
<h3>Previous Cases</h3>
{% if previous_cases %}
<ul>
{% for case in previous_cases %}
<li>
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
<div class="row g-3 mb-4">
<div class="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<span class="badge bg-primary">Current case</span>
<span class="badge bg-dark-subtle text-dark-emphasis">{{ current_index }} of {{ chain_total }}</span>
{% if previous_cases %}
<span class="badge text-bg-light border">{{ previous_cases|length }} previous</span>
{% endif %}
{% if next_cases %}
<span class="badge text-bg-light border">{{ next_cases|length }} follow-up</span>
{% endif %}
</div>
<h2 class="h4 mb-1">{{ current_case.title|default:"Untitled case" }}</h2>
<div class="small text-muted mb-3">Case {{ current_case.pk }}</div>
{% if current_case.description %}
<p class="mb-3">{{ current_case.description|striptags|truncatechars:280 }}</p>
{% elif current_case.history %}
<p class="mb-3">{{ current_case.history|striptags|truncatechars:280 }}</p>
{% endif %}
</li>
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
<p>No previous cases.</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 small">
{% if current_case.open_access %}
<span class="badge bg-success">Open access</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
{% if current_case.verified %}
<span class="badge bg-info text-dark">Verified</span>
{% endif %}
{% if current_case.archive %}
<span class="badge bg-dark">Archived</span>
{% endif %}
{% if current_case.diagnostic_certainty %}
<span class="badge text-bg-light border">{{ current_case.get_diagnostic_certainty_display }}</span>
{% endif %}
<span class="badge text-bg-light border">{{ current_case.get_ordered_series|length }} series</span>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary-subtle shadow-sm h-100">
<div class="card-body">
<h2 class="h5 mb-3">Chain Summary</h2>
<div class="list-group list-group-flush small">
{% for linked_case in chain_cases %}
<a href="{% url 'atlas:linked_cases_overview' linked_case.pk %}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if linked_case.pk == current_case.pk %}active{% endif %}">
<span class="text-truncate pe-3">{{ forloop.counter }}. {{ linked_case.title|default:"Untitled case" }}</span>
<span class="badge {% if linked_case.pk == current_case.pk %}text-bg-light{% else %}text-bg-secondary{% endif %}">{{ linked_case.pk }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<h3>Next Cases</h3>
{% if next_cases %}
<ul>
{% for case in next_cases %}
<li>
<a href="{{ next_case.get_absolute_url }}">{{ case.title }}</a>
</li>
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
<p>No next cases.</p>
{% endif %}
<div class="row g-4">
<div class="col-xl-6">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Previous Cases</h2>
<span class="badge text-bg-light border">{{ previous_cases|length }}</span>
</div>
{% if previous_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in previous_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Earlier in the linked chain" is_current=False %}
{% endfor %}
</div>
{% else %}
<div class="card border-secondary-subtle shadow-sm">
<div class="card-body text-muted">No previous linked cases.</div>
</div>
{% endif %}
</div>
<div class="col-xl-6">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Next Cases</h2>
<span class="badge text-bg-light border">{{ next_cases|length }}</span>
</div>
{% if next_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in next_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Later in the linked chain" is_current=False %}
{% endfor %}
</div>
{% else %}
<div class="card border-secondary-subtle shadow-sm">
<div class="card-body text-muted">No follow-up linked cases.</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+57 -23
View File
@@ -1,21 +1,31 @@
{% extends "atlas/base.html" %}
{% load static %}
{% load crispy_forms_tags %}
{% load help_tags %}
{% block content %}
<div class="container">
<h1>Normals</h1>
{% help "Help" %}
<p>This page lists all normal cases available in the atlas.</p>
<p>You can use the filter form to narrow down the list based on criteria such as age, modality, or examination type.</p>
<p>Click on a case title to view the case.</p>
<p>See a case that isn't normal? Please add feedback to the case so it can be reviewed.</p>
{% endhelp%}
<div class="row mb-3">
<div class="col-md-4">
<div class="col-12">
<form method="get" class="form-inline">
{% for field in filter.form.visible_fields %}
<div class="mb-2">
{{ field.label_tag }}
{{ field }}
</div>
{% endfor %}
<div class="mb-2">
{% crispy filter.form %}
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
<label class="mb-0" for="id_sort">Sort by</label>
<select class="form-select w-auto" id="id_sort" name="sort">
{% for value, label in sort_choices %}
<option value="{{ value }}" {% if selected_sort == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">Filter</button>
<a class="btn btn-link" href="?">Clear</a>
</div>
@@ -24,22 +34,44 @@
</div>
{% if page_obj.object_list %}
<div class="mb-2">
<span class="text-muted me-2">Quick sort:</span>
{% for value, label in sort_choices %}
{% if selected_sort == value %}
<span class="badge text-bg-primary">{{ label }}</span>
{% else %}
<a class="badge text-bg-light text-decoration-none" href="?{% if querystring_without_page_sort %}{{ querystring_without_page_sort }}&amp;{% endif %}sort={{ value }}">{{ label }}</a>
{% endif %}
{% endfor %}
</div>
<div class="list-group">
{% for normal in page_obj.object_list %}
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ normal.case.title }}</h5>
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
<div class="list-group-item d-flex justify-content-between align-items-start">
<a class="flex-grow-1 text-decoration-none text-reset" href="{% url 'atlas:case_detail' normal.case.pk %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ normal.case.title }}</h5>
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
</div>
<p class="mb-1">
Age: {{ normal.display_age }}
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
</p>
{% if normal.notes %}
<p class="mb-0 text-muted">{{ normal.notes }}</p>
{% endif %}
</a>
<div class="ms-2">
{% if request.user.is_superuser or request.user in normal.author.all %}
<button class="btn btn-sm btn-outline-secondary"
hx-get="{% url 'atlas:case_normal_edit' normal.pk %}"
hx-target="body"
hx-swap="beforeend">
Edit
</button>
{% endif %}
</div>
<p class="mb-1">
Age: {{ normal.display_age }}
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
</p>
{% if normal.notes %}
<p class="mb-0 text-muted">{{ normal.notes }}</p>
{% endif %}
</a>
</div>
{% endfor %}
</div>
{% else %}
@@ -49,13 +81,15 @@
<nav aria-label="Page navigation">
<ul class="pagination mt-3">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
<li class="page-item"><a class="page-link" href="?{% if querystring_without_page %}{{ querystring_without_page }}&amp;{% endif %}page={{ page_obj.previous_page_number }}">Previous</a></li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
<li class="page-item"><a class="page-link" href="?{% if querystring_without_page %}{{ querystring_without_page }}&amp;{% endif %}page={{ page_obj.next_page_number }}">Next</a></li>
{% endif %}
</ul>
</nav>
</div>
{% endblock %}
{% block css %}{% endblock %}
@@ -0,0 +1,13 @@
{# Partial: Case Differentials #}
<div>
<details open>
<summary><b>Differentials</b></summary>
<ul>
{% for diff in case.differentialcase.all %}
<li>{{diff.condition.get_link}} - {{diff.text}}</li>
{% empty %}
<span class="text-muted">No differentials added for this case.</span>
{% endfor %}
</ul>
</details>
</div>
@@ -0,0 +1,271 @@
{# Partial: Case Display Sets (displaysets) #}
{# Buttons adapt at runtime: if an in-page viewer is present (importAnnotations_main_viewer), #}
{# "View" loads the display set into it; otherwise falls back to the modal viewer. #}
{# An Edit button is shown separately when can_edit is truthy. #}
<div>
<h5 class="mt-3">Case Display Sets</h5>
<ul class="list-group">
{% for ds in case.display_sets.all %}
<li class="list-group-item" id="displayset-row-{{ ds.pk }}">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<b>{{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted ms-1">({{ ds.description }})</span>
{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0">
{% if ds.viewerstate or ds.annotations %}
<button type="button"
class="btn btn-primary btn-sm ds-view-btn"
data-ds-id="{{ ds.pk }}"
data-modal-url="{% url 'atlas:case_displaysets_modal' ds.pk %}"
data-annotations='{{ ds.annotations|safe }}'
data-viewerstate='{{ ds.viewerstate|safe }}'>
<i class="bi bi-display"></i> View Display Set
</button>
{% endif %}
<button type="button"
class="btn btn-secondary btn-sm view-displayset-modal"
data-ds-id="{{ ds.pk }}"
data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">
<i class="bi bi-eye"></i> View in Modal
</button>
{% if can_edit %}
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}?edit=1"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Edit
</a>
{% endif %}
</div>
</div>
<div class="mt-2">
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul class="mb-1">
{% for finding in ds.findings.all %}
<li>{{ finding.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul class="mb-1">
{% for structure in ds.structures.all %}
<li>{{ structure.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul class="mb-1">
{% for condition in ds.conditions.all %}
<li>{{ condition.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item text-muted">
No display sets for this case.
{% if can_edit %}
<a href="{% url 'atlas:case_displaysets' case.pk %}">Add one now</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% include 'atlas/partials/_displayset_modals.html' %}
<script>
(function () {
if (!window.__displaysetPartialInitDone) {
window.__displaysetPartialInitDone = true;
if (typeof window.mountDisplaysetViewersSafely !== 'function') {
window.mountDisplaysetViewersSafely = async function () {
try {
await new Promise(function (resolve) {
requestAnimationFrame(function () {
requestAnimationFrame(resolve);
});
});
if (typeof window.mountDicomViewersSafely === 'function') {
await window.mountDicomViewersSafely();
} else if (typeof window.mountDicomViewers === 'function') {
window.mountDicomViewers();
}
requestAnimationFrame(function () {
try { window.dispatchEvent(new Event('resize')); } catch (e) {}
if (typeof window.resizeDicomViewers === 'function') {
try { window.resizeDicomViewers(); } catch (e) {}
}
});
} catch (e) {
console.warn('displayset viewer mount failed', e);
}
};
}
if (typeof window.loadDisplaysetModal !== 'function') {
window.loadDisplaysetModal = async function (url, dsId) {
if (!url && dsId) url = '/atlas/' + dsId + '/display_sets/modal';
if (!url) return;
var modalEl = document.getElementById('displaysetModal');
var modalBody = document.getElementById('displaysetModalBody');
if (!modalEl || !modalBody) return;
modalBody.innerHTML = '<p>Loading...</p>';
try {
var resp = await fetch(url, { credentials: 'same-origin' });
var html = await resp.text();
modalBody.innerHTML = html;
var fsBtn = modalEl.querySelector('.displayset-fullscreen-btn');
if (fsBtn) {
fsBtn.dataset.url = url;
if (dsId) fsBtn.dataset.dsId = dsId;
}
bootstrap.Modal.getOrCreateInstance(modalEl).show();
await window.mountDisplaysetViewersSafely();
} catch (error) {
console.error('Error loading display set details:', error);
modalBody.innerHTML = '<p>Failed to load display set.</p>';
}
};
}
if (typeof window.loadDisplaysetFullscreen !== 'function') {
window.loadDisplaysetFullscreen = async function (url, dsId) {
if (!url && dsId) url = '/atlas/' + dsId + '/display_sets/modal';
if (!url) return;
var leftEl = document.getElementById('displaysetFullscreenLeft');
var rightEl = document.getElementById('displaysetFullscreenRight');
var fullModalEl = document.getElementById('displaysetFullscreenModal');
if (!leftEl || !rightEl || !fullModalEl) return;
leftEl.innerHTML = '<p style="color:#fff; padding:1rem;">Loading viewer...</p>';
rightEl.innerHTML = '<p>Loading details...</p>';
try {
var resp = await fetch(url, { credentials: 'same-origin' });
var html = await resp.text();
var tmp = document.createElement('div');
tmp.innerHTML = html;
var viewer = tmp.querySelector('.dicom-viewer-root');
if (viewer) {
leftEl.innerHTML = '';
leftEl.appendChild(viewer);
try {
viewer.style.height = '100%';
viewer.style.minHeight = '100%';
viewer.style.width = '100%';
viewer.style.display = 'block';
viewer.style.flex = '1 1 auto';
} catch (e) {}
} else {
leftEl.innerHTML = '<p style="color:#fff; padding:1rem;">No viewer available</p>';
}
var viewerInTmp = tmp.querySelector('.dicom-viewer-root');
if (viewerInTmp) viewerInTmp.remove();
rightEl.innerHTML = tmp.innerHTML;
bootstrap.Modal.getOrCreateInstance(fullModalEl).show();
await window.mountDisplaysetViewersSafely();
} catch (error) {
console.error('Error loading fullscreen display set:', error);
leftEl.innerHTML = '<p style="color:#fff; padding:1rem;">Failed to load viewer</p>';
rightEl.innerHTML = '<p>Failed to load details.</p>';
}
};
}
document.addEventListener('click', function (e) {
var modalBtn = e.target.closest('.view-displayset-modal');
if (modalBtn) {
e.preventDefault();
var modalUrl = modalBtn.getAttribute('data-url');
var modalDsId = modalBtn.getAttribute('data-ds-id');
window.loadDisplaysetModal(modalUrl, modalDsId);
return;
}
var fsBtn = e.target.closest('.displayset-fullscreen-btn');
if (fsBtn) {
e.preventDefault();
var fsUrl = fsBtn.dataset.url;
var fsDsId = fsBtn.dataset.dsId || null;
if (typeof window.loadDisplaysetFullscreen === 'function') {
window.loadDisplaysetFullscreen(fsUrl, fsDsId);
}
}
});
}
/**
* For each "View Display Set" button:
* - If the page has an in-page viewer (importAnnotations_main_viewer available),
* load the display set's state into it and scroll the viewer into view.
* - Otherwise, fall back to the existing modal loader (loadDisplaysetModal).
*/
document.querySelectorAll('.ds-view-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var dsId = btn.dataset.dsId;
var modalUrl = btn.dataset.modalUrl;
var annotations = btn.dataset.annotations;
var viewerstate = btn.dataset.viewerstate;
var hasInPageViewer = (
typeof window.importAnnotations_main_viewer === 'function' &&
typeof window.importViewerState_main_viewer === 'function'
);
if (hasInPageViewer) {
// Ensure viewer UI is visible before applying display set state.
var viewerDetails = document.getElementById('dicom-viewer-details');
if (viewerDetails && viewerDetails.tagName === 'DETAILS') {
viewerDetails.open = true;
}
try { window.importAnnotations_main_viewer(JSON.parse(annotations || '{}')); } catch (e) { console.warn('displayset annotations import error', e); }
try { window.importViewerState_main_viewer(JSON.parse(viewerstate || '{}')); } catch (e) { console.warn('displayset viewerstate import error', e); }
try { window.dispatchEvent(new Event('resize')); } catch (e) {}
if (typeof window.resizeDicomViewers === 'function') {
try { window.resizeDicomViewers(); } catch (e) { console.warn('displayset viewer resize error', e); }
}
// Scroll the viewer into view
var viewer = document.getElementById('main_viewer') || document.querySelector('.dicom-viewer-root');
if (viewer) { viewer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
} else if (typeof window.loadDisplaysetModal === 'function') {
window.loadDisplaysetModal(modalUrl, dsId);
} else {
// Ultimate fallback: navigate to the detail page
window.location.href = modalUrl;
}
});
});
}());
</script>
@@ -0,0 +1,114 @@
<div id="{{ thread_dom_id }}" class="case-review-thread-root card border-secondary-subtle mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Case Conversation</strong>
{% if unacknowledged_count %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">{{ unacknowledged_count }} awaiting acknowledgement</span>
{% endif %}
</div>
<span class="small text-muted">Case {{ case.pk }}</span>
</div>
<div class="card-body">
{% if messages %}
<div class="d-flex flex-column gap-2 mb-3">
{% for message in messages %}
<div class="border rounded p-2 {% if message.message_type == 'feedback' %}border-warning-subtle bg-warning-subtle{% elif message.message_type == 'query' %}border-info-subtle bg-info-subtle{% else %}border-secondary-subtle bg-body-tertiary{% endif %}">
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
<div>
<span class="fw-semibold">{{ message.get_author_display }}</span>
<span class="badge text-bg-dark ms-1">{{ message.get_message_type_display }}</span>
{% if message.requires_acknowledgement %}
{% if message.is_acknowledged %}
<span class="badge bg-success-subtle text-success-emphasis ms-1">Acknowledged</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-1">Awaiting acknowledgement</span>
{% endif %}
{% endif %}
</div>
<div class="small text-muted">{{ message.created_at|date:"Y-m-d H:i" }}</div>
</div>
<div class="small">{{ message.message|linebreaksbr }}</div>
{% if message.requires_acknowledgement and not message.is_acknowledged %}
{% if can_acknowledge_query and message.message_type == 'query' %}
<form class="mt-2"
hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="action" value="acknowledge">
<input type="hidden" name="message_id" value="{{ message.id }}">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<button type="submit" class="btn btn-sm btn-outline-success">Acknowledge learner query</button>
</form>
{% elif can_acknowledge_feedback and message.message_type != 'query' %}
<form class="mt-2"
hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="action" value="acknowledge">
<input type="hidden" name="message_id" value="{{ message.id }}">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<button type="submit" class="btn btn-sm btn-outline-success">Acknowledge feedback</button>
</form>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small mb-3">No messages yet for this case.</div>
{% endif %}
{% if can_feedback %}
<form hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML"
class="border rounded p-3 mb-2 bg-body-tertiary">
{% csrf_token %}
<input type="hidden" name="action" value="add_message">
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label small fw-semibold">Supervisor response</label>
{{ supervisor_form.message }}
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold">Type</label>
<select name="message_type" class="form-select form-select-sm">
<option value="feedback">Feedback</option>
<option value="reply">Reply</option>
</select>
</div>
<div class="col-md-2">
<div class="form-check mt-4 pt-2">
{{ supervisor_form.requires_acknowledgement }}
<label for="{{ supervisor_form.requires_acknowledgement.id_for_label }}" class="form-check-label small">Needs acknowledgement</label>
</div>
</div>
</div>
<div class="mt-2">
<button type="submit" class="btn btn-sm btn-primary">Send feedback</button>
</div>
</form>
{% endif %}
{% if can_query %}
<form hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML"
class="border rounded p-3 bg-body-tertiary">
{% csrf_token %}
<input type="hidden" name="action" value="add_message">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<label class="form-label small fw-semibold">Ask a question / raise a query</label>
{{ query_form.message }}
<div class="mt-2">
<button type="submit" class="btn btn-sm btn-outline-info">Send query</button>
</div>
</form>
{% endif %}
</div>
</div>
@@ -0,0 +1,39 @@
{# Unified single case item used by search results and recent-created lists #}
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
<div class="flex-fill me-3">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ case.title }}</h6>
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
</div>
<div class="mb-1">
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
{% for cond in case.condition.all %}
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
{% endfor %}
{% for pres in case.presentation.all %}
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
{% endfor %}
{% for ss in case.subspecialty.all %}
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
{% endfor %}
</div>
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
</div>
<div class="ms-2 d-flex align-items-center">
{% if collection %}
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
{% csrf_token %}
<input type="hidden" name="case" value="{{ case.pk }}">
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
</form>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'atlas:case_detail' case.pk %}" target="_blank" rel="noopener">View</a>
{% else %}
{% if show_select_button %}
<button type="button" class="btn btn-sm btn-outline-primary case-select-btn" data-case-select="{{ case.pk }}">Select</button>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'atlas:case_detail' case.pk %}">View</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_detail' case.pk %}">View</a>
{% endif %}
{% endif %}
</div>
</div>
@@ -1,35 +1,56 @@
{# partial: list of cases for HTMX search results #}
<div class="list-group">
{% if cases %}
{% for case in cases %}
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ case.title }}</h6>
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
</div>
<div class="mb-1">
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
{% if case.condition %}<span class="badge bg-secondary me-1">{{ case.condition.name }}</span>{% endif %}
{% if case.presentation %}<span class="badge bg-secondary me-1">{{ case.presentation.name }}</span>{% endif %}
{% if case.subspecialty %}<span class="badge bg-secondary">{{ case.subspecialty.name }}</span>{% endif %}
</div>
{% if cases %}
{% for case in cases %}
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}" data-label="{{ case.title|escape }}">
<div class="flex-fill me-3">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ case.title }}</h6>
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
</div>
<div class="mb-1">
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
{% for cond in case.condition.all %}
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
{% endfor %}
{% for pres in case.presentation.all %}
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
{% endfor %}
{% for ss in case.subspecialty.all %}
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
{% endfor %}
</div>
<div class="mb-1">
{# show up to three series modalities/examinations if present #}
{% for s in case.series.all|slice:":3" %}
<span class="badge bg-light text-dark me-1">
{% if s.modality %}{{ s.modality.modality }}{% endif %}
{% if s.examination %} / {{ s.examination.examination }}{% endif %}
</span>
{% empty %}
<small class="text-muted">No series</small>
{% endfor %}
</div>
<div class="mb-1">
{# show up to three series modalities/examinations if present #}
{% for s in case.series.all|slice:":3" %}
{% if s.modality and s.modality.modality or s.examination and s.examination.examination %}
<span class="badge bg-light text-dark me-1" title="Series: {% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %} / {{ s.examination.examination }}{% endif %}">
{% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %}{% if s.modality and s.modality.modality %} / {% endif %}{{ s.examination.examination }}{% endif %}
</span>
{% endif %}
{% empty %}
<small class="text-muted">No series</small>
{% endfor %}
</div>
<p class="mb-1 text-truncate">{{ case.description }}</p>
</a>
{% endfor %}
{% else %}
<div class="list-group-item">No cases found</div>
{% endif %}
<p class="mb-1 text-truncate">{{ case.description }}</p>
</div>
<div class="ms-2 d-flex align-items-center">
{% if collection %}
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
{% csrf_token %}
<input type="hidden" name="case" value="{{ case.pk }}">
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
</form>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'atlas:case_detail' case.pk %}" target="_blank" rel="noopener">View</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' case.pk %}">View</a>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="list-group-item">No cases found</div>
{% endif %}
</div>
@@ -1,11 +1,20 @@
{% for cd in casedetails %}
{% with idx=forloop.counter0 %}
{% if cid %}
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=idx cid=cid passcode=passcode %}">
{% else %}
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=idx %}">
{% endif %}
Case {{ forloop.counter }}: {{ cd.case.title|default:cd.case.pk|truncatechars:60 }}
</a>
{% endwith %}
{% for item in jump_items %}
<div class="dropdown-item-text {% if not item.has_answer %}bg-danger-subtle border border-danger-subtle rounded{% elif item.idx == current %}bg-primary-subtle rounded{% endif %} py-2 px-2 mb-1">
<div class="d-flex justify-content-between align-items-start gap-2">
<a class="text-decoration-none small fw-semibold"
href="{{ item.case_url }}">
Case {{ item.idx|add:1 }}
</a>
<div class="d-flex gap-1">
{% if item.is_time_expired %}
<span class="badge bg-dark">Time expired</span>
{% endif %}
{% if item.has_self_review %}
<span class="badge bg-info-subtle text-info-emphasis">Self review</span>
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="dropdown-item-text text-muted small px-2 py-2">No cases available.</div>
{% endfor %}
@@ -0,0 +1,47 @@
<div class="modal fade" id="displaysetModal" tabindex="-1" aria-labelledby="displaysetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="displaysetModalLabel">Display Set Details</h5>
<button type="button" class="btn btn-sm btn-outline-primary ms-2 displayset-fullscreen-btn" title="Open fullscreen"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="displaysetModalBody">
<p>Loading...</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Fullscreen displayset modal: viewer left, details right -->
<div class="modal fade" id="displaysetFullscreenModal" tabindex="-1" aria-labelledby="displaysetFullscreenModalLabel" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content" style="height:100vh;">
<div class="modal-header">
<h5 class="modal-title" id="displaysetFullscreenModalLabel">Display Set — Fullscreen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0" id="displaysetFullscreenBody">
<div class="container-fluid h-100">
<div class="row g-0 h-100">
<div class="col-8" id="displaysetFullscreenLeft" style="height:100%; overflow:auto; background:#000; display:flex;">
</div>
<div class="col-4" id="displaysetFullscreenRight" style="height:100%; overflow:auto; padding:1rem;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
/* Ensure fullscreen modal and viewer stretch vertically */
#displaysetFullscreenModal .modal-content { height: 100vh; }
#displaysetFullscreenModal .modal-body { height: calc(100vh - 56px); padding: 0; }
#displaysetFullscreenModal .modal-content .container-fluid, #displaysetFullscreenModal .modal-content .row { height: 100%; }
#displaysetFullscreenLeft { display:flex; align-items:stretch; }
#displaysetFullscreenLeft .dicom-viewer-root { height:100% !important; min-height:100% !important; width:100% !important; flex:1 1 auto; }
</style>
@@ -0,0 +1,12 @@
<div class="displayset-preview p-2">
<h5 class="mb-1">Display Set</h5>
<div class="small text-muted mb-2">Case: <a href="{% url 'atlas:case_displaysets' ds.case.pk %}">{{ ds.case.title }}</a></div>
<div class="mb-2">Name: {{ ds.name|default:'(unnamed)' }}</div>
{% if ds.description %}
<div class="mb-2">{{ ds.description }}</div>
{% endif %}
<div class="d-flex gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:case_displaysets_detail' ds.pk %}">Open full</a>
<button class="btn btn-sm btn-outline-secondary view-displayset-modal" data-ds-id="{{ ds.pk }}" data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">View in modal</button>
</div>
</div>
@@ -0,0 +1,25 @@
<div class="list-group">
{% if displaysets %}
{% for ds in displaysets %}
<div class="list-group-item d-flex justify-content-between align-items-center" data-ds-pk="{{ ds.pk }}" data-label="{{ ds.name|escape }}">
<div class="flex-fill">
<div class="d-flex w-100 justify-content-between">
<div>
<strong class="mb-1">{{ ds.name }}</strong>
{% if ds.case %}
<div class="small text-muted">Case: <a href="{% url 'atlas:case_detail' ds.case.pk %}">{{ ds.case.title }}</a> (#{% if ds.case.pk %}{{ ds.case.pk }}{% endif %})</div>
{% endif %}
</div>
<small class="text-muted">{{ ds.pk }}</small>
</div>
<p class="mb-1 text-truncate">{{ ds.description }}</p>
</div>
<div class="ms-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View</a>
</div>
</div>
{% endfor %}
{% else %}
<div class="list-group-item">No display sets found</div>
{% endif %}
</div>
@@ -0,0 +1,17 @@
<div class="modal fade" id="findingModal" tabindex="-1" aria-labelledby="findingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="findingModalLabel">Finding Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="findingModalBody">
<!-- Finding details will be loaded here dynamically -->
<p>Loading...</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,29 @@
<div class="list-group">
{% if findings %}
{% for f in findings %}
<div class="list-group-item d-flex justify-content-between align-items-center" data-finding-pk="{{ f.pk }}" data-seriesfinding-pk="{{ f.pk }}" data-label="{% if f.findings.all %}{% for sub in f.findings.all %}{{ sub.name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% elif f.description %}{{ f.description|truncatechars:80|escape }}{% elif f.series %}Series {{ f.series.id }}{% else %}Finding {{ f.pk }}{% endif %}">
<div class="flex-fill">
<div class="d-flex w-100 justify-content-between">
<strong class="mb-1">{% if f.series %}{{ f.series.id }}{% else %}Series{% endif %}{% if f.description %}: {{ f.description|truncatechars:80 }}{% endif %}</strong>
<small class="text-muted">{{ f.pk }}</small>
</div>
<p class="mb-1 text-truncate">
{% if f.findings.all %}
{% for sub in f.findings.all %}
<span class="badge bg-light text-dark me-1">{{ sub.name }}</span>
{% endfor %}
{% endif %}
{{ f.description }}
</p>
</div>
<div class="ms-2">
{% if f.series %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:series_detail' pk=f.series.pk %}">View series</a>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="list-group-item">No findings found</div>
{% endif %}
</div>
@@ -0,0 +1,116 @@
<article class="card h-100 border-secondary-subtle shadow-sm">
<div class="card-body d-flex flex-column gap-3">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="badge bg-secondary">Case {{ linked_case.pk }}</span>
{% if is_current %}
<span class="badge bg-primary">Current</span>
{% endif %}
{% if linked_case.open_access %}
<span class="badge bg-success">Open access</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
{% if linked_case.verified %}
<span class="badge bg-info text-dark">Verified</span>
{% endif %}
{% if linked_case.archive %}
<span class="badge bg-dark">Archived</span>
{% endif %}
</div>
<h3 class="h5 mb-1">
<a class="link-body-emphasis text-decoration-none" href="{{ linked_case.get_absolute_url }}">
{{ linked_case.title|default:"Untitled case" }}
</a>
</h3>
{% if relation_label %}
<div class="small text-muted">{{ relation_label }}</div>
{% endif %}
</div>
<div class="text-sm-end small text-muted">
{% if linked_case.published_date %}
<div>Published {{ linked_case.published_date|date:"Y-m-d" }}</div>
{% else %}
<div>Created {{ linked_case.created_date|date:"Y-m-d" }}</div>
{% endif %}
{% if linked_case.diagnostic_certainty %}
<div>Certainty: {{ linked_case.get_diagnostic_certainty_display }}</div>
{% endif %}
</div>
</div>
{% if linked_case.description %}
<p class="mb-0 text-body-secondary">{{ linked_case.description|striptags|truncatechars:220 }}</p>
{% elif linked_case.history %}
<p class="mb-0 text-body-secondary">{{ linked_case.history|striptags|truncatechars:220 }}</p>
{% endif %}
<div class="d-flex flex-column gap-2 small">
{% if linked_case.subspecialty.all %}
<div>
<span class="text-muted me-2">Subspecialty</span>
{% for item in linked_case.subspecialty.all %}
<span class="badge text-bg-light border">{{ item }}</span>
{% empty %}{% endfor %}
</div>
{% endif %}
{% if linked_case.condition.all %}
<div>
<span class="text-muted me-2">Condition</span>
{% for item in linked_case.condition.all|slice:":4" %}
<span class="badge text-bg-light border">{{ item }}</span>
{% endfor %}
{% if linked_case.condition.count > 4 %}
<span class="text-muted">+{{ linked_case.condition.count|add:-4 }} more</span>
{% endif %}
</div>
{% endif %}
{% if linked_case.presentation.all %}
<div>
<span class="text-muted me-2">Presentation</span>
{% for item in linked_case.presentation.all|slice:":4" %}
<span class="badge text-bg-light border">{{ item }}</span>
{% endfor %}
{% if linked_case.presentation.count > 4 %}
<span class="text-muted">+{{ linked_case.presentation.count|add:-4 }} more</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="border rounded p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<strong class="small">Series</strong>
<span class="badge bg-dark-subtle text-dark-emphasis">{{ linked_case.get_ordered_series|length }} linked</span>
</div>
{% with ordered_series=linked_case.get_ordered_series %}
{% if ordered_series %}
<div class="d-flex flex-column gap-2 small">
{% for series in ordered_series|slice:":4" %}
<div class="d-flex justify-content-between align-items-start gap-2 border rounded px-2 py-1 bg-body">
<div>
<a class="text-decoration-none" href="{{ series.get_absolute_url }}">
{{ series.description|default:series.examination }}
</a>
<div class="text-muted">{{ series.modality }}{% if series.examination %} · {{ series.examination }}{% endif %}{% if series.plane %} · {{ series.plane }}{% endif %}</div>
</div>
<span class="badge text-bg-light border">#{{ forloop.counter }}</span>
</div>
{% endfor %}
</div>
{% if ordered_series|length > 4 %}
<div class="small text-muted mt-2">Showing 4 of {{ ordered_series|length }} linked series.</div>
{% endif %}
{% else %}
<div class="small text-muted">No series linked to this case.</div>
{% endif %}
{% endwith %}
</div>
<div class="d-flex flex-wrap gap-2 mt-auto">
<a class="btn btn-sm btn-primary" href="{{ linked_case.get_absolute_url }}">Open case</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' linked_case.pk %}">Re-centre overview</a>
</div>
</div>
</article>
@@ -1,10 +1,10 @@
<div id="normal-modal-{{ case.pk }}" class="modal modal-blur fade" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<form method="post" hx-post="{% url 'atlas:case_create_normal' case.pk %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
<form method="post" hx-post="{% if normal %}{% url 'atlas:case_normal_edit' normal.pk %}{% else %}{% url 'atlas:case_create_normal' case.pk %}{% endif %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Mark case {{ case.pk }} as Normal</h5>
<h5 class="modal-title">{% if normal %}Edit normal for case {{ case.pk }}{% else %}Mark case {{ case.pk }} as Normal{% endif %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -4,17 +4,26 @@
{% if case.normal_case %}
<span class="badge bg-success">Normal</span>
<small class="text-muted">{{ case.normal_case.display_age }}</small>
{% if can_mark_normal %}
<button class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
hx-target="#normal-toggle-block"
hx-swap="outerHTML"
hx-confirm="Unmark this case as normal?">
Unmark normal
</button>
{% if can_edit %}
<span>
<button class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
hx-target="#normal-toggle-block"
hx-swap="outerHTML"
hx-confirm="Unmark this case as normal?">
Unmark normal
</button>
<button class="btn btn-sm btn-outline-secondary ms-2"
hx-get="{% url 'atlas:case_normal_edit' case.normal_case.pk %}"
hx-target="body"
hx-swap="beforeend">
Edit Normal
</button>
</span>
{% endif %}
{% else %}
{% if can_mark_normal %}
{% if can_edit %}
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
hx-target="body"
@@ -22,7 +31,7 @@
Mark as normal
</button>
{% else %}
<span class="text-muted">Normal status only editable by atlas editors or case authors</span>
<span class="badge bg-secondary">Not normal</span>
{% endif %}
{% endif %}
</div>
@@ -0,0 +1,72 @@
<div class="self-review-actions mb-3 js-self-review-panel" data-refresh-key="{{ panel_key|default:'default' }}">
<div class="btn-group btn-group-sm mb-2" role="group" aria-label="Self review actions">
<a class="btn btn-primary" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}">
<i class="bi bi-card-checklist"></i> Add Self Review
</a>
<button type="button"
class="btn btn-outline-info"
onclick="openSelfReviewPopup('{% url 'atlas:add_self_review' cid_user_exam.id case.id %}')">
Popup
</button>
<button type="button"
class="btn btn-outline-secondary"
onclick="openSelfReviewSidebar('{% url 'atlas:add_self_review' cid_user_exam.id case.id %}')">
Sidebar
</button>
</div>
{% if self_review %}
<h4>Self Feedback</h4>
<div class="list-group">
{% for review in self_review %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="small text-muted">
Updated: {{ review.review_update_date|date:"Y-m-d H:i" }}
</div>
<div class="d-flex flex-wrap gap-1" role="group" aria-label="Self review actions">
<a class="btn btn-outline-primary btn-sm"
href="{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}">
View
</a>
<button type="button" class="btn btn-outline-info btn-sm"
onclick="openSelfReviewPopup('{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}')">
View popup
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="openSelfReviewSidebar('{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}')">
View sidebar
</button>
<a class="btn btn-outline-warning btn-sm"
href="{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}">
Edit
</a>
<button type="button" class="btn btn-outline-warning btn-sm"
onclick="openSelfReviewPopup('{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}')">
Edit popup
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
onclick="openSelfReviewSidebar('{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}')">
Edit sidebar
</button>
</div>
</div>
<div class="small mb-1">
<strong>Findings:</strong>
<span class="badge bg-info-subtle text-info-emphasis">{{ review.findings|default:"-" }}/5</span>
</div>
<div class="small mb-2">
<strong>Interpretation:</strong>
<span class="badge bg-warning-subtle text-warning-emphasis">{{ review.interpretation|default:"-" }}/5</span>
</div>
{% if review.comments %}
<div class="small text-body-secondary">{{ review.comments|linebreaksbr }}</div>
{% else %}
<div class="small text-muted">No comments added.</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
@@ -0,0 +1,52 @@
<div class="p-3">
<div class="card border-secondary shadow-sm mb-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h6 mb-0">Self Review</h2>
<span class="badge bg-primary-subtle text-primary-emphasis">{{ user_exam.exam }}</span>
</div>
<div class="card-body">
<div class="small text-muted mb-2">Case: <strong>{{ case }}</strong></div>
<div class="row g-2 mb-3">
<div class="col-6">
<div class="p-2 border rounded">
<div class="small text-muted">Findings</div>
<div class="fw-semibold">{{ review.findings|default:"-" }}/5</div>
</div>
</div>
<div class="col-6">
<div class="p-2 border rounded">
<div class="small text-muted">Interpretation</div>
<div class="fw-semibold">{{ review.interpretation|default:"-" }}/5</div>
</div>
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Comments</div>
<div class="p-2 border rounded bg-body-tertiary">
{% if review.comments %}
{{ review.comments|linebreaksbr }}
{% else %}
<span class="text-muted">No comments added.</span>
{% endif %}
</div>
</div>
<div class="small text-muted mb-3">
Updated: {{ review.review_update_date }}
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-warning btn-sm"
href="{% url 'atlas:self_review_update' user_exam.id case.id review.id %}?embed=1">
Edit in sidebar
</a>
<a class="btn btn-outline-secondary btn-sm"
href="{% url 'atlas:self_review_detail' user_exam.id case.id review.id %}">
Open full page
</a>
</div>
</div>
</div>
</div>
@@ -0,0 +1,83 @@
{% load crispy_forms_tags %}
<div class="p-3">
<div class="card border-secondary shadow-sm mb-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h6 mb-0">Self Review</h2>
<span class="badge bg-primary-subtle text-primary-emphasis">{{ user_exam.exam }}</span>
</div>
<div class="card-body">
<p class="text-muted small mb-2">
Record a quick reflection while reviewing this case.
</p>
<div class="small text-muted mb-3">
Case: <strong>{{ case }}</strong>
</div>
<form method="post" class="self-review-embed-form" action="">
{% csrf_token %}
{{ form.non_field_errors }}
{{ form.user_exam }}
{{ form.case }}
<div class="mb-3">
{{ form.comments|as_crispy_field }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold d-block">{{ form.findings.label }}</label>
<div class="btn-group" role="group" aria-label="Findings self rating">
{% for value, label in form.fields.findings.choices %}
<input
type="radio"
class="btn-check"
name="{{ form.findings.html_name }}"
id="id_embed_findings_{{ forloop.counter0 }}"
value="{{ value }}"
{% if form.findings.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
>
<label class="btn btn-outline-info" for="id_embed_findings_{{ forloop.counter0 }}">{{ label }}</label>
{% endfor %}
</div>
{% if form.findings.help_text %}
<div class="form-text">{{ form.findings.help_text }}</div>
{% endif %}
{% if form.findings.errors %}
<div class="text-danger small mt-1">{{ form.findings.errors|striptags }}</div>
{% endif %}
</div>
<div class="mb-4">
<label class="form-label fw-semibold d-block">{{ form.interpretation.label }}</label>
<div class="btn-group" role="group" aria-label="Interpretation self rating">
{% for value, label in form.fields.interpretation.choices %}
<input
type="radio"
class="btn-check"
name="{{ form.interpretation.html_name }}"
id="id_embed_interpretation_{{ forloop.counter0 }}"
value="{{ value }}"
{% if form.interpretation.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
>
<label class="btn btn-outline-warning" for="id_embed_interpretation_{{ forloop.counter0 }}">{{ label }}</label>
{% endfor %}
</div>
{% if form.interpretation.help_text %}
<div class="form-text">{{ form.interpretation.help_text }}</div>
{% endif %}
{% if form.interpretation.errors %}
<div class="text-danger small mt-1">{{ form.interpretation.errors|striptags }}</div>
{% endif %}
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary btn-sm">Save Self Review</button>
{% if review %}
<a href="{% url 'atlas:self_review_delete' user_exam.id case.id review.id %}" class="btn btn-outline-danger btn-sm">Delete</a>
{% endif %}
</div>
</form>
<div class="self-review-embed-message mt-2"></div>
</div>
</div>
</div>
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Self Review Saved</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #111;
color: #f0f0f0;
}
.wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.card {
max-width: 460px;
width: 100%;
border: 1px solid #3a3a3a;
border-radius: 10px;
background: #1a1a1a;
padding: 1rem 1.2rem;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35);
}
h1 {
margin-top: 0;
font-size: 1.15rem;
}
p {
margin-bottom: 0;
color: #d0d0d0;
}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Self review saved</h1>
<p>This window will close shortly and refresh the case page.</p>
</div>
</div>
<script>
(function () {
try { window.alert('Self review saved. This popup will close and refresh the case page.'); } catch (e) {}
try {
if (window.opener && !window.opener.closed) {
try {
window.opener.postMessage({ type: 'self_review_saved' }, '*');
} catch (e) {
window.opener.location.reload();
}
}
} catch (e) {}
setTimeout(function () {
window.close();
}, 700);
})();
</script>
</body>
</html>
@@ -0,0 +1,59 @@
{# Partial: Series Findings for a Case #}
<div>
<details open>
<summary><b>Findings</b></summary>
{% if can_edit %}
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Findings are display sets that can be added to series. Once added they will appear here on linked cases.</p>
<p>To add a finding to a series, select the series and then click the "Add finding" button.</p>
</details>
{% endif %}
<!-- Series Findings -->
<h5>Series Findings</h5>
{% for series in case.get_ordered_series %}
{% for finding in series.findings.all %}
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
<span>
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
<button class="btn btn-primary btn-sm">View</button>
</a>
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
View in Modal
</button>
</span>
{% if finding.findings %}
<span>
Findings: {% for f in finding.findings.all %}
{{f.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.conditions %}
<span>
Conditions: {% for c in finding.conditions.all %}
{{c.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.structures %}
<span>
Structure: {% for s in finding.structures.all %}
{{s.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.description %}
<span>
Description: {{finding.description}}
</span>
{% endif %}
</div>
{% endfor %}
{% empty %}
No series associated with case.
{% endfor %}
</details>
</div>
@@ -0,0 +1,13 @@
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="small text-muted">Total images size</div>
<div class="fw-bold">{{ size_human }}</div>
<div class="text-muted small">({{ size_bytes }} bytes)</div>
</div>
<div class="text-end">
<button class="btn btn-sm btn-outline-secondary"
hx-get="{% url 'atlas:series_image_size' series.pk %}?refresh=1"
hx-target="#series-image-size"
hx-swap="innerHTML">Recalculate</button>
</div>
</div>
@@ -0,0 +1,46 @@
<li class="series-item {% if series_item_classes %}{{ series_item_classes }}{% endif %}">
<div class="series-left">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
<h5 class="series-title">
{{ tags.SeriesDescription|default:series }}
{% if series_is_partial %}
<span class="badge bg-warning text-dark ms-2">Part imported</span>
{% endif %}
</h5>
<div class="series-meta small text-muted">
<div>UID: <code class="series-uid">{{ series }}</code></div>
{% if tags.SeriesNumber %}<div>Series #: <span class="series-number">{{ tags.SeriesNumber }}</span></div>{% endif %}
<div>Modality: <span class="series-modality">{{ tags.Modality }}</span></div>
<div>Images: <span class="series-images">{{ n }}</span></div>
{% if tags.StudyDate %}<div>Study date: <span class="series-study-date">{{ tags.StudyDate }}</span></div>{% endif %}
<div>Uploaded: <span class="series-date">{{ date }}</span></div>
</div>
</div>
<div class="series-right">
{% if tags.Modality %}
<span class="modality-badge">{{ tags.Modality }}</span>
{% endif %}
<span class="series-block-popup-link">
<a href="#" onclick="return window.create_popup_window('/atlas/uploads/series_id/{{ series }}', 'Series')">Popup</a>
{% if show_tags_link %}
&nbsp;|&nbsp;
<a
hx-get="{% url 'atlas:series_tags_partial' series %}"
hx-target="#seriesTagsModal .modal-body"
hx-swap="innerHTML"
>View tags</a>
{% endif %}
</span>
{% if series_is_partial %}
<button
type="button"
class="btn btn-sm btn-warning mt-2 import-partial-series-btn"
data-series-uids="{{ series }}"
{% if import_case_id %}data-case-id="{{ import_case_id }}"{% endif %}
>Finish import</button>
{% endif %}
</div>
<div class="w-100 mt-2">
<div id="series-tags-{{ series }}" class="series-tags-placeholder"></div>
</div>
</li>
@@ -0,0 +1,45 @@
{% load django_htmx %}
<div id="series-split-by-tag-panel" class="d-flex flex-column gap-2">
<div class="d-flex flex-wrap gap-2">
<button type="button"
class="btn btn-outline-warning btn-sm"
hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1"
hx-target="#series-split-by-tag-panel"
hx-swap="outerHTML"
hx-indicator="#series-split-analysis-indicator">
Analyze split options
</button>
<span id="series-split-analysis-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
</div>
{% if analyze_requested %}
{% if split_options %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#split-by-tag-result"
hx-swap="innerHTML"
hx-indicator="#series-split-submit-indicator">
{% csrf_token %}
<div class="input-group input-group-sm">
<select name="dicom_tag" class="form-select form-select-sm">
{% for option in split_options %}
<option value="{{ option.tag_value }}">{{ option.tag_label }} ({{ option.split_count }} series)</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</div>
</form>
<span id="series-split-submit-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
<p class="small text-muted mb-0">Only tags that can produce more than one output series are shown.</p>
{% else %}
<div class="alert alert-secondary mb-0 small">
No common split tags were found that can divide this series into multiple groups.
</div>
{% endif %}
{% else %}
<p class="small text-muted mb-0">Run analysis to detect valid split tags and estimated output series count.</p>
{% endif %}
<div id="split-by-tag-result"></div>
</div>
@@ -0,0 +1,200 @@
{% load django_htmx %}
<div id="series-tag-consistency-panel">
<div class="modal-header">
<h5 class="modal-title">Series Tag Consistency</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-flex flex-column gap-3">
<div class="d-flex flex-wrap gap-2 align-items-center">
<button type="button"
class="btn btn-outline-info btn-sm"
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-panel"
hx-swap="outerHTML"
hx-indicator="#series-tag-consistency-indicator">
Re-run analysis
</button>
<span id="series-tag-consistency-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
{% if analyze_requested %}
<span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span>
{% endif %}
</div>
<div id="tag-consistency-split-result"></div>
{% if analyze_requested %}
<div class="small d-flex flex-wrap gap-2">
<span class="badge text-bg-success">Same: {{ same_tags|length }}</span>
<span class="badge text-bg-warning">Different: {{ different_tags|length }}</span>
<span class="badge text-bg-secondary">Missing in some: {{ partial_tags|length }}</span>
</div>
<div>
<label for="tag-consistency-filter-{{ series.pk }}" class="form-label form-label-sm mb-1">Filter tags</label>
<input id="tag-consistency-filter-{{ series.pk }}" type="text" class="form-control form-control-sm" placeholder="Type to filter by tag name or value">
</div>
<details class="styled-detail" open>
<summary>Different tags (highlighted)</summary>
<div class="table-responsive mt-2">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Tag</th>
<th>Unique values</th>
<th>Sample values</th>
<th>Split</th>
</tr>
</thead>
<tbody>
{% for row in different_tags %}
<tr class="table-warning" data-filter-row="1">
<td>{{ row.tag_name }}</td>
<td>{{ row.unique_count }}</td>
<td>{{ row.sample_values|join:", " }}</td>
<td>
{% if can_split %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</form>
{% else %}
<span class="text-muted small">No split access</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-muted">No tags vary across all images.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
<details class="styled-detail">
<summary>Same tags</summary>
<div class="table-responsive mt-2">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Tag</th>
<th>Value</th>
<th>Split</th>
</tr>
</thead>
<tbody>
{% for row in same_tags %}
<tr class="table-success" data-filter-row="1">
<td>{{ row.tag_name }}</td>
<td>{{ row.sample_values.0 }}</td>
<td>
{% if can_split %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</form>
{% else %}
<span class="text-muted small">No split access</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-muted">No uniform tags found across all images.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
<details class="styled-detail">
<summary>Missing in some images</summary>
<div class="table-responsive mt-2">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Tag</th>
<th>Present in</th>
<th>Unique values</th>
<th>Split</th>
</tr>
</thead>
<tbody>
{% for row in partial_tags %}
<tr class="table-secondary" data-filter-row="1">
<td>{{ row.tag_name }}</td>
<td>{{ row.present_count }}/{{ image_count }}</td>
<td>{{ row.unique_count }}</td>
<td>
{% if can_split %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</form>
{% else %}
<span class="text-muted small">No split access</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-muted">All scanned tags were present in every image.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% else %}
<p class="small text-muted mb-0">Run analysis to quickly highlight tags that are the same or different across the series.</p>
{% endif %}
</div>
<div class="modal-footer">
<span id="tag-consistency-split-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator me-auto" role="status" aria-hidden="true"></span>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
<script>
(function() {
const filterInput = document.getElementById("tag-consistency-filter-{{ series.pk }}");
if (!filterInput) {
return;
}
const rows = document.querySelectorAll("#series-tag-consistency-panel tr[data-filter-row='1']");
function applyFilter() {
const query = filterInput.value.trim().toLowerCase();
rows.forEach((row) => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(query) ? "" : "none";
});
}
filterInput.addEventListener("input", applyFilter);
})();
</script>

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