Compare commits

..
248 Commits
Author SHA1 Message Date
Ross 02a9118faf . 2025-12-08 09:29:30 +00:00
Ross 63c6a648d7 Add Loki, Promtail, and Grafana services for observability stack 2025-12-04 20:06:24 +00:00
Ross 517c30ba0e Enhance Docker setup with logging and monitoring features
- Added logging configuration to WSGI for persistent log collection.
- Updated Docker Compose files for local and development environments, including Redis, PostgreSQL, and Grafana services.
- Introduced Promtail configuration for log scraping and integration with Loki.
- Created application log directory in Dockerfile for log persistence.
2025-12-01 16:43:40 +00:00
Ross 590e0b76ed Add series images endpoint and template for HTMX integration 2025-12-01 14:50:11 +00:00
Ross e9a04e823c more docker improvements 2025-12-01 14:01:46 +00:00
Ross 9ed789ba90 more docker updates 2025-12-01 13:31:08 +00:00
Ross fd6e25d55e more ninja fixes 2025-12-01 13:31:02 +00:00
Ross f074e5b086 fix some ninja issuse 2025-12-01 13:30:43 +00:00
Ross 2157b6f781 some fixes? 2025-12-01 11:12:01 +00:00
Ross 359682a0cd start prod docker migration 2025-12-01 10:36:58 +00:00
Ross 78577076c1 Refactor discussion and report sections to use card layout for improved presentation and user interaction 2025-11-24 12:44:55 +00:00
Ross 3635275573 Enhance case view layout and styling for improved readability and user experience 2025-11-24 12:36:18 +00:00
Ross 0c412dfff6 Add prior cases information to collection case view template 2025-11-24 12:19:56 +00:00
Ross 3f00311136 Fix hx-post URL in prior card template for correct case number handling 2025-11-24 12:06:44 +00:00
Ross e8c35efd83 Refactor collection_case_questions function to use case_number instead of case_id for improved clarity and consistency in case retrieval 2025-11-24 12:02:43 +00:00
Ross 0badca4c4f Add default error value to context in collection_case_priors view to prevent variable resolution issues 2025-11-24 11:58:53 +00:00
Ross 25f4e478b3 Add edit permission check for collection case priors view 2025-11-24 11:53:50 +00:00
Ross 33cba3d005 Fix hx-post URL in prior card template for correct case number handling 2025-11-24 11:43:07 +00:00
Ross efca426359 Add case jump functionality with HTMX support and corresponding template 2025-11-24 11:40:52 +00:00
Ross f713632fdd Refactor Condition, Finding, and Structure tables: remove legacy primary field from displayed fields to streamline data representation. 2025-11-24 11:02:51 +00:00
Ross 785638ee23 Add cancel button to ConditionForm for easier navigation 2025-11-24 10:49:13 +00:00
Ross e8020c5d8f Update ConditionForm to use AJAX/autocomplete for canonical field selection 2025-11-24 10:42:38 +00:00
Ross 5ccf755e72 Refactor Condition model: remove legacy synonym and primary fields, introduce canonical field for aliasing. Update related documentation. 2025-11-24 10:31:13 +00:00
Ross 6308fbfcd7 Refactor Finding and Structure models: remove legacy primary and synonym fields, introduce canonical field for aliasing. Update filters and forms accordingly. 2025-11-24 10:18:26 +00:00
Ross 8a871d6332 Replace legacy primary filter with is_canonical and add synonym filter in ConditionFilter 2025-11-23 23:29:39 +00:00
Ross 6eeb4cbb1d Add canonical field and update synonym handling in Condition model
- Introduced a new `canonical` ForeignKey field in the Condition model to manage alias relationships.
- Enhanced synonym retrieval methods to prefer canonical names and handle aliases more effectively.
- Updated condition detail templates to reflect canonical status and improved synonym display.
- Implemented migration scripts to establish canonical relationships based on existing synonym links.
2025-11-20 22:48:03 +00:00
Ross 9d57d03cbb Refactor synonym management: extract synonyms list and add forms for adding synonyms with HTMX support 2025-11-20 21:59:50 +00:00
Ross 21de543313 Enhance ConditionForm: add Crispy Forms integration for consistent submit button rendering 2025-11-17 23:15:21 +00:00
Ross a12a9e38f3 Implement synonym management in condition detail: add forms for adding synonyms and handle POST requests for synonym creation. 2025-11-17 23:08:34 +00:00
Ross 81dc5bf48f Add forms and views for subspecialty, presentation, and pathological process management 2025-11-17 23:01:02 +00:00
Ross 4abb57900d Refactor categories dropdown: enhance layout with additional category options and staff creation links for improved navigation 2025-11-17 22:54:32 +00:00
Ross 08c9138cdb Refactor condition form: streamline JavaScript loading and enhance button layout for improved usability 2025-11-17 22:52:34 +00:00
Ross fb833a2b90 Refactor collection view: create a new template for collections with improved layout and staff action visibility 2025-11-17 22:47:15 +00:00
Ross 2f1e2f6abe Refactor collection_case_details: simplify case retrieval logic and improve parameter naming for clarity 2025-11-17 22:33:10 +00:00
Ross ffa717a9ef Refactor exam overview header: move author display earlier and remove duplicate block for improved clarity 2025-11-17 22:16:08 +00:00
Ross 2dd9f4af32 Refactor exam overview: enhance question display with improved layout and feedback visibility 2025-11-17 22:09:49 +00:00
Ross feb5a85af2 Refactor dropdown menus: enhance action items with icons and improve visual hierarchy for better user experience 2025-11-17 12:20:46 +00:00
Ross 292f81ad74 Refactor question link header: update text for clarity in exam context 2025-11-17 12:16:26 +00:00
Ross 349c46c6db Refactor viewing case template: adjust HTML structure for improved readability and maintainability 2025-11-17 12:14:05 +00:00
Ross 681a7560ba Refactor question form layout: improve indentation and formatting for better readability 2025-11-17 12:12:31 +00:00
Ross aa90ae5e09 Add QuestionForm and related views: implement form for creating questions with user context and exam association 2025-11-17 12:12:06 +00:00
Ross f55bb1b18e Refactor exam navigation: enhance layout and accessibility by replacing links with a responsive navbar structure 2025-11-17 12:02:21 +00:00
Ross 5e78cc4603 Refactor exam overview headers: comment out unused div for improved code clarity 2025-11-17 11:54:35 +00:00
Ross a9cbb1303a Refactor exam navigation: restructure HTML for improved layout and accessibility, adding dropdown menu for exam actions 2025-11-17 11:52:53 +00:00
Ross 24558f92ce Refactor case prior handling: update function to support case_number instead of case_id for improved clarity and maintainability 2025-11-17 11:15:08 +00:00
Ross 0f8bae8a14 Refactor URL patterns: update legacy case_id references to include 'id' for improved clarity and consistency 2025-11-17 11:11:09 +00:00
Ross 6e658aca93 Refactor case handling: update URLs and views to support case_number and case_id interchangeably, enhancing backward compatibility and maintainability 2025-11-17 11:05:13 +00:00
Ross 81cd109449 Refactor dark mode styles: update CSS variables for dark mode theme and set default background and text colors 2025-11-17 10:49:31 +00:00
Ross d86f990be2 Refactor case detail URL references: update template links from 'casedetail' to 'case_detail' for consistency 2025-11-17 10:36:19 +00:00
Ross b6478c44dc Refactor case detail references: standardize variable naming from case_detail to casedetail across templates for consistency 2025-11-17 10:34:55 +00:00
Ross 12b7fa6629 Refactor CaseDetail references to Casedetail across templates and views
- Updated all instances of 'case_detail' to 'casedetail' in templates to maintain consistency.
- Adjusted view functions to use 'casedetail' instead of 'case_detail' for better clarity.
- Renamed the field 'case_detail' to 'casedetail' in the CasePrior model to align with the new naming convention.
- Ensured that all related logic and references in forms and data handling reflect this change.
2025-11-17 10:34:27 +00:00
Ross 7f4570417e Refactor case navigation: consolidate previous and next links into a reusable partial for improved maintainability and consistency across templates 2025-11-17 10:09:00 +00:00
Ross 0098fa5dfc Refactor case prior display: extract prior card markup into a separate partial for improved maintainability and readability 2025-11-17 09:27:02 +00:00
Ross 740d013198 Refactor case prior display: enhance layout with Bootstrap cards for improved readability and user interaction 2025-11-17 09:22:17 +00:00
Ross b47bb141e8 Enhance case search functionality: exclude cases already in the collection from search results to prevent duplication 2025-11-16 23:28:23 +00:00
Ross 712ecec25d Enhance case search results: add data attributes for case identification and implement client-side removal of cases from search results after addition to collection 2025-11-16 23:25:35 +00:00
Ross 0193daf166 Refactor case search widget: update hx-get attribute and include collection in hx-vals for improved search functionality 2025-11-16 23:17:57 +00:00
Ross 279d6344f1 Enhance case search functionality: display recent cases in search results and adjust initial load behavior 2025-11-16 23:16:06 +00:00
Ross 8c8d484dce Enhance exam order editing: hide edit button during editing, improve save button attributes, and clean up controls after saving 2025-11-16 23:06:33 +00:00
Ross 9760be8f27 Enhance case list item links and management actions: update hrefs for dynamic case numbering and improve button structure for better accessibility 2025-11-16 23:03:19 +00:00
Ross 5e0068d490 Implement case search functionality and enhance case addition UI in collection view 2025-11-16 22:55:59 +00:00
Ross 875744a08a Refactor case management links and add case list item partial for improved organization and rendering in collection detail view 2025-11-16 22:51:22 +00:00
Ross c02a46fab1 Refactor collection detail view: enhance layout, improve organization, and add badges for better information display 2025-11-16 22:36:50 +00:00
Ross 6b73006122 Refactor collection detail view: enhance layout and improve organization of case management links and collection information 2025-11-16 22:31:00 +00:00
Ross 7eb478b111 Refactor case management links: enhance layout and improve button accessibility in collection detail view 2025-11-16 22:26:34 +00:00
Ross 10c18bd514 Refactor case management links: move to separate partial for improved template organization and clarity 2025-11-16 22:20:53 +00:00
Ross 1407fe83a2 Fix link for case series display and update edit series action in management links 2025-11-16 22:03:14 +00:00
Ross e9716f3caf Add per-case management links template for collection detail view 2025-11-16 22:01:30 +00:00
Ross 749450b03e Refactor collection detail template: streamline case management links and enhance layout for better usability 2025-11-16 21:57:54 +00:00
Ross 7e72f731b3 Move "Add New Case" link to dropdown for superusers in collection actions 2025-11-16 21:37:03 +00:00
Ross e407326c25 Refactor collection headers: convert to responsive navbar layout with dropdown actions 2025-11-15 23:12:58 +00:00
Ross 0d8177f0b4 Remove exam review link from exams template for cleaner navigation 2025-11-15 23:09:59 +00:00
Ross 3312f3058a Enhance CaseCollectionForm: replace collection_type field with radio buttons and add custom styling 2025-11-15 23:02:12 +00:00
Ross 478dcb7217 Add success URL redirection for CaseCollectionCreate after successful creation 2025-11-15 22:47:37 +00:00
Ross a1aba7836a Fix link to series detail page in series viewer template 2025-11-14 22:11:47 +00:00
Ross 80b1ef3817 Enhance series case addition: update details element for better accessibility and close functionality 2025-11-14 21:56:40 +00:00
Ross 835d0382ba Refactor series cases handling: extract cases list to a partial and enhance HTMX integration for dynamic updates 2025-11-14 21:55:28 +00:00
Ross 8096a36a11 Reorganize finding form placement in series viewer for improved layout 2025-11-14 21:52:37 +00:00
Ross 97d65f0caa Refactor series viewer template for improved layout and usability; enhance findings display and action buttons 2025-11-14 21:48:06 +00:00
Ross b73e89393b . 2025-11-14 21:21:45 +00:00
Ross ec036a6266 Add open_access field to Resource model for access control 2025-11-14 21:16:55 +00:00
Ross 14e6514b90 Update ResourceDelete view to require AuthorOrChecker permissions for deletion 2025-11-14 21:14:58 +00:00
Ross 639e91fc1e Add created_date and modified_date fields to Resource model; update resource detail and list templates for improved display 2025-11-14 21:12:03 +00:00
Ross 74765e70a2 Add created_date and modified_date fields to Resource model; enhance resource navigation with dropdown menu in base template 2025-11-14 21:01:25 +00:00
Ross ca2b7feb5b Add Resource filtering and table display functionality 2025-11-14 20:56:49 +00:00
Ross 453fbf91be Add ManyToMany fields for sites and subspecialty in Resource model 2025-11-14 20:53:31 +00:00
Ross a2e9d5e681 Refactor NormalCase model to use age_days instead of age_years, update forms, filters, and templates accordingly for improved age handling 2025-11-13 22:34:36 +00:00
Ross 8da2dd27da Enhance normals list display and update case state handling in views 2025-11-13 22:17:43 +00:00
Ross 970242e1fb Refactor NormalCaseForm to prevent nested forms and enhance logging in case_normal_form and create_case_normal views 2025-11-13 22:13:46 +00:00
Ross f8be1918f6 Fix indentation for the "Mark as normal" button in the normal toggle template 2025-11-13 22:09:05 +00:00
Ross 5ac998454b Add NormalCase form and related views for managing normal cases 2025-11-13 22:08:55 +00:00
Ross d259d0e8dd Enhance feedback display in exam review by italicizing and reducing opacity for better visual distinction 2025-11-13 22:07:40 +00:00
Ross 5fb3c98941 add a normals feature 2025-11-13 21:54:25 +00:00
Ross a70354c18d Refactor UserUserGroupForm to use a reusable UserSearchSelectMultipleWidget for improved user selection and search functionality 2025-11-13 21:48:58 +00:00
Ross 345977e2ef Add NormalCase model, views, filters, and templates for managing normal cases 2025-11-13 21:44:55 +00:00
Ross 726c7ea401 Enhance user search functionality with advanced search tips and help panel 2025-11-13 21:32:28 +00:00
Ross 7fa08037f0 Remove unnecessary comments from bulk add groups form template 2025-11-12 22:23:50 +00:00
Ross f786e88de9 Refactor bulk add groups form to use HTMX for submission and enhance button functionality 2025-11-12 22:15:10 +00:00
Ross 9441541feb Add grade display to selected users in UserUserGroupForm 2025-11-12 22:11:43 +00:00
Ross 5cca1b079a Fix syntax error in user group form and enhance logging in bulk add groups view 2025-11-12 22:06:09 +00:00
Ross 2e551d411a Add grade information to user search results and enhance user addition functionality 2025-11-12 21:59:20 +00:00
Ross d8055a509c Enhance user search widget with wildcard support and field-specific queries; improve logging in bulk add groups 2025-11-12 21:54:51 +00:00
Ross 208252a858 Add grade filter to user search widget and enhance logging for bulk add operations 2025-11-12 21:48:50 +00:00
Ross ea9dca5804 Implement user search widget with HTMX for dynamic user selection 2025-11-12 21:40:24 +00:00
Ross da04138861 Enhance bulk add groups form with crispy forms for improved styling and usability 2025-11-12 21:23:41 +00:00
Ross ec0245fe7c Add bulk add functionality for user and CID groups to exam collections 2025-11-12 21:19:09 +00:00
Ross c39d30c746 Refactor anatomy question answer form layout and improve styling for better usability 2025-11-11 21:48:01 +00:00
Ross d781508364 Update exam counts to use distinct counts for archived and non-archived exams 2025-11-11 21:31:07 +00:00
Ross 06ea87be14 Add shorts exam count to collection annotations and update display logic 2025-11-11 21:26:05 +00:00
Ross ce1b439681 Refactor exam counts annotation to respect archived status toggle 2025-11-11 21:09:00 +00:00
Ross c8375a28fa Add functionality to filter exam collections by archived status 2025-11-11 21:07:25 +00:00
Ross 6be39ab69d Add archived badge to exam list for better visibility of archived exams 2025-11-11 20:55:57 +00:00
Ross e9d0e402be Implement bulk archive functionality for exams; add HTMX endpoint and update UI controls for improved exam management 2025-11-11 20:52:44 +00:00
Ross dc23dffaf9 Remove instructional text for HTMX marking UI from Mark2 overview page for cleaner interface 2025-11-11 19:51:52 +00:00
Ross 87dde70fd3 Enhance AnatomyQuestion string representation; improve formatting and handle missing attributes for better readability 2025-11-11 14:04:46 +00:00
Ross c69eecc1ec Refactor mark2 overview page layout; enhance question display and button functionality for improved user interaction 2025-11-11 14:02:41 +00:00
Ross 6c149bb2ae Add mark2 overview page and update navigation for improved marking workflow 2025-11-11 13:56:11 +00:00
Ross 1232c3af32 Comment out suggestion section for incorrect answers in the marking template 2025-11-11 13:49:10 +00:00
Ross 05a2b2357c Enhance word suggestion functionality for incorrect answers; improve button layout and toggle behavior for better user experience. 2025-11-11 13:47:12 +00:00
Ross d88a626a76 Implement quick add functionality for incorrect answers; return collapsed partial for quick suggestions in the answers table. 2025-11-11 13:42:34 +00:00
Ross e4bab4a509 Add suggestion management for incorrect answers; implement collapsed view and HTMX support for dynamic updates 2025-11-11 13:36:55 +00:00
Ross 6e7135823a Implement suggestion feature for incorrect answers; add HTMX support for dynamic candidate rendering and inline editing. 2025-11-11 13:21:32 +00:00
Ross a158a6f928 Refactor question detail layout for improved readability; enhance card structure and styling for better user experience. 2025-11-11 13:05:31 +00:00
Ross b79d95cd70 Refactor layout of marked answers section for improved responsiveness; update column classes for better display on various screen sizes. 2025-11-11 13:03:44 +00:00
Ross 796ac3fb84 Implement edit answer functionality with HTMX support; add new endpoint and template for editing stored answers in the marking UI. 2025-11-11 13:01:51 +00:00
Ross 14a54dfa8b Refactor answer click handling to prevent duplicate event listeners on the new mark2 page; streamline state management for answer marking. 2025-11-11 12:49:13 +00:00
Ross ac6a8193a3 Refactor answer list item actions to use right-floating icons for improved accessibility and user interaction; streamline code for marked and unmarked answers. 2025-11-11 12:02:22 +00:00
Ross 6752f03bf2 Enhance marking UI with new navigation buttons and clipboard functionality; improve answer display with tooltips for clarity 2025-11-11 12:00:04 +00:00
Ross 34ef5106f4 Add new endpoints and templates for displaying marked answers in the improved marking UI 2025-11-11 11:53:35 +00:00
Ross 1997d013d8 Implement improved marking UI with new endpoints and templates for enhanced user experience 2025-11-11 11:34:05 +00:00
Ross 9a9ce11ccb . 2025-11-11 11:12:36 +00:00
Ross 9ebcd24938 Refactor image link paths in forms to ensure consistent static file referencing across anatomy, atlas, longs, rapids, and shorts templates. 2025-11-11 11:12:03 +00:00
Ross 70430b2593 Refactor mark view to improve question handling and user answer management, enhancing overall exam experience. 2025-11-10 22:35:24 +00:00
Ross e989a18d31 Enhance create_anatomy_questions management command with optional image generation and improved argument handling 2025-11-10 22:32:30 +00:00
Ross e39539fb99 Add management commands for creating anatomy questions with customizable options 2025-11-10 22:24:44 +00:00
Ross 44ca49899a Refactor button styles for previous and next actions to use secondary color, enhancing visual consistency across the exam interface. 2025-11-10 22:21:43 +00:00
Ross 4246713ee8 Refactor styles for physics finish list and overview question buttons to improve responsiveness and layout on various screen sizes. 2025-11-10 22:09:02 +00:00
Ross 5a4792ea43 Enhance exam take fragment to update global question header with current question number and stem, improving synchronization and user experience. 2025-11-10 22:04:46 +00:00
Ross 94d5771a26 Refactor exam overview to use anchor elements for questions, improving HTML validity and accessibility; enhance styles for better layout and responsiveness. 2025-11-10 22:01:15 +00:00
Ross 200a6e2a1c Enhance exam take functionality to support HX-Redirect for HTMX clients, ensuring full navigation experience 2025-11-10 21:58:12 +00:00
Ross 2ff857621a Refactor exam take functionality to redirect to full overview page instead of returning HTMX fragment 2025-11-10 21:56:41 +00:00
Ross 52c4f27e2b Add HTMX support for exam overview fragment to enhance user experience 2025-11-10 21:52:46 +00:00
Ross c1eff140f8 Add global listener for HTMX save confirmations and trigger event 2025-11-10 21:46:16 +00:00
Ross dce72f9abe Enhance exam take functionality to support HTMX save action and partial HTML responses 2025-11-10 21:43:19 +00:00
Ross 738752c030 Enhance exam take functionality to support HTMX for dynamic question loading and navigation 2025-11-10 21:39:36 +00:00
Ross 41fc7050ad Refactor exam take functionality to support HTMX for dynamic question loading and navigation 2025-11-10 21:30:45 +00:00
Ross 6eec40c43b Style: Enhance overview button appearance and add hover effects for improved user experience 2025-11-10 21:25:47 +00:00
Ross 7782ac55ca Refactor CSS styles for exam layout to improve responsiveness and alignment of question inputs and labels 2025-11-10 21:22:23 +00:00
Ross 9cf7ad6da7 Refactor exam_take function to use a canonical list of questions for consistent indexing and improved error handling for question navigation 2025-11-10 21:18:58 +00:00
Ross 922ca5053f Refactor exam_take function to use a canonical list of questions for consistent indexing and improved error handling for question navigation 2025-11-10 21:01:02 +00:00
Ross 10739f517f Add command to generate sample physics questions with customizable options 2025-11-10 20:56:18 +00:00
Ross eebbe8b759 Enhance question feedback in exam review by adding answers and feedback details for each part 2025-11-10 13:17:19 +00:00
Ross d176288607 Refactor response badges in exam review to use border colors based on correctness percentage for improved visual feedback 2025-11-10 13:12:20 +00:00
Ross bd71c669f0 Refactor exam review response handling to include structured items with optional per-part breakdown for multi-part answers 2025-11-10 13:08:24 +00:00
Ross f3a0a9fd8a Add per-part true/false breakdown to exam review summary for enhanced clarity 2025-11-10 13:05:04 +00:00
Ross 66899b54bd Enhance exam review question fragment to display parts and responses with HTMX support for improved interactivity and user experience 2025-11-10 13:00:47 +00:00
Ross 24c35148b0 Refactor physics exam review scoring to count true/false sub-questions individually and improve accuracy of correct answer calculations 2025-11-10 12:54:29 +00:00
Ross bece7f6ce8 Enhance answer aggregation logic in exam review to handle diverse answer formats and improve robustness 2025-11-10 12:51:55 +00:00
Ross bd81fdcf41 Add exam review question responses and summary views with HTMX support 2025-11-10 12:49:10 +00:00
Ross eaf8a38fe2 Enhance exam review display to show correct answers and update result color descriptions for clarity 2025-11-10 12:41:38 +00:00
Ross 744f2c7600 Add primary answer display to exam review question fragment and ensure safe rendering of question text 2025-11-10 12:37:13 +00:00
Ross e7c15a75a0 Enhance exam review overview page to display per-question summaries with response metrics and color coding for quick triage 2025-11-10 12:26:12 +00:00
Ross cf09449d16 . 2025-11-10 12:23:51 +00:00
Ross 0acc57302f Enhance initAnatomyFragment to prevent double initialization and improve HTMX swap handling 2025-11-10 12:17:53 +00:00
Ross ed88f794f8 Implement initAnatomyFragment function for HTMX fragment swaps to initialize DICOM viewers 2025-11-10 12:15:15 +00:00
Ross 0acc2da374 Add HTMX event handler for DICOM image setup on fragment load 2025-11-10 12:12:49 +00:00
Ross 21a7383c4f Enhance loading indicators for response summary and responses in exam review fragment 2025-11-10 12:11:19 +00:00
Ross 8e038d958b Fix data-url reference in anatomy DICOM image elements for consistent image loading 2025-11-10 12:06:19 +00:00
Ross ad7f8a68e1 Fix element ID reference in getJsonToolStateNoId function for DICOM image handling 2025-11-10 12:02:43 +00:00
Ross 0c5d8bebd6 Refactor setUpDicomLegacy and loadJsonToolStateOnCurrentImage functions for improved annotation handling 2025-11-10 12:00:15 +00:00
Ross 477276c4c5 Fix annotation handling in setUpDicomLegacy function to support multiple annotations 2025-11-10 11:57:27 +00:00
Ross 697191dae9 Add annotation loading in setUpDicomLegacy function 2025-11-10 11:56:04 +00:00
Ross c2ccf02d3e Add annotation data extraction in setUpDicomLegacy function 2025-11-10 11:54:06 +00:00
Ross 1b6757bff2 Update DICOM image element ID for consistency and add CSS styles for anatomy DICOM image 2025-11-10 11:48:01 +00:00
Ross 02284be1a2 Update DICOM image element ID and CSS class for legacy support 2025-11-10 11:46:20 +00:00
Ross 6d03e81e77 Add legacy DICOM viewer setup and update HTML element ID for legacy images 2025-11-10 11:43:31 +00:00
Ross 65a68b6605 Add console log for single DICOM viewer load and update HTML element ID 2025-11-10 11:39:17 +00:00
Ross 2a07843a6b Refactor DICOM 3D setup to clear element before 2D loader fallback and comment out fixed height in CSS 2025-11-10 11:25:08 +00:00
Ross 72c20c13e0 Add fallback to 2D loader in DICOM 3D setup on viewport enable failure 2025-11-10 11:18:20 +00:00
Ross 4d97e9e881 Enhance DICOM 3D loader to fallback to 2D loader for standard web images and handle 'https' URLs 2025-11-10 11:15:52 +00:00
Ross 0be605b41d Enhance DICOM image URL handling to support standard web images without forcing wadouri scheme 2025-11-10 11:11:00 +00:00
Ross b467407e19 Optimize CID lookups to reduce N+1 query pattern in exam review question responses 2025-11-10 11:07:56 +00:00
Ross fdc60bf2e9 Refactor score count variable names for clarity in exam review question summary logic 2025-11-10 11:06:55 +00:00
Ross 486a897e64 Refactor DICOM image div indentation for improved readability in exam review template 2025-11-10 11:00:54 +00:00
Ross fee123741d Add get_image_url method to AnatomyQuestion and update DICOM image URL in exam review template 2025-11-10 10:56:07 +00:00
Ross 367cebb787 Fix DICOM image display by correcting indentation in exam review question fragment 2025-11-10 10:54:38 +00:00
Ross b3b6ce7e6a Refactor exam review question display to show question type instead of question text for improved clarity 2025-11-10 10:53:46 +00:00
Ross 1010f17e3d Enhance exam review responses to include sensible display names for respondents, improving clarity and context in the review process. 2025-11-10 10:52:43 +00:00
Ross 72b7940a8c Refactor show respondents buttons for improved readability and maintainability 2025-11-10 10:50:02 +00:00
Ross cc7a334cb7 Refactor show respondents buttons to improve HTMX integration and add fallback for non-HTMX environments 2025-11-10 10:48:31 +00:00
Ross 9c4eaceda0 Refactor exam review templates to enhance the display of marked answers and top submitted answers with collapsible sections and improved styling 2025-11-10 10:44:39 +00:00
Ross 2aeb1947af Enhance top submitted answers display by adding gap between elements and updating submission count styling 2025-11-10 10:40:18 +00:00
Ross ecf67e3c2b Enhance exam review summary to include dominant score for top submitted answers 2025-11-10 10:38:13 +00:00
Ross d0609e4f8e Remove duplicate question card and associated navigation buttons from exam review template 2025-11-10 10:34:19 +00:00
Ross f4878a58ac Add option to reveal respondent identities in exam review responses 2025-11-10 10:26:38 +00:00
Ross 0d4d4f1dc0 Add option to reveal respondent identities in exam review responses 2025-11-10 10:16:11 +00:00
Ross 3a40a5087d Add management command to normalize Question HTML fields by unwrapping single <p> tags 2025-11-10 10:11:05 +00:00
Ross 921cc53667 Refactor exam review question summary layout for improved alignment and text wrapping 2025-11-10 10:01:58 +00:00
Ross 53ff56edc0 Add clean method to QuestionForm to unwrap single <p> tags from TinyMCE HTML fields 2025-11-10 10:00:42 +00:00
Ross e078b5a98f Refactor exam review templates and views to improve response summary display and handle unanswered responses 2025-11-10 09:58:02 +00:00
Ross 3d6ca8be54 Disable TinyMCE branding badge in editor configuration 2025-11-10 09:50:04 +00:00
Ross e37a7e0b74 Allow superusers to bypass open access check in question detail view 2025-11-10 09:16:10 +00:00
Ross 390f6f098d Enhance exam review question fragment by ensuring safe rendering of question and answers, improving layout for answer feedback, and refining button alignment for better UI consistency. 2025-11-10 09:14:17 +00:00
Ross 3c31246636 Update progress bar colors to use bg-danger for incorrect answers in exam review summary 2025-11-09 22:27:28 +00:00
Ross dd55f999f7 Remove unnecessary check for defined exam_user_answers in responses fragment 2025-11-09 22:23:33 +00:00
Ross a6f5ed4c88 Enhance exam review UI with detailed response summaries and progress bars for answer choices 2025-11-09 22:21:08 +00:00
Ross d482b77430 Add HTMX-based response summary and user responses display for exam questions 2025-11-09 22:12:18 +00:00
Ross 3e9a96d99f Add HTMX-based aggregated response summary for exam questions 2025-11-09 22:09:23 +00:00
Ross 092c4a1be6 Add aggregated response summary and user answer filtering in exam review 2025-11-09 22:06:56 +00:00
Ross ae720662f7 Implement HTMX-based loading of exam question responses and add corresponding view and URL routing 2025-11-09 22:01:57 +00:00
Ross c89c14b89e Add display of user responses with correctness indication in exam review 2025-11-09 21:54:51 +00:00
Ross e6a01cb1b5 Enhance exam review question fragment to display detailed answers and feedback with correct answer indication 2025-11-09 21:52:41 +00:00
Ross fd8edd3cc3 Refactor exam review fragment inclusion to support app-specific templates and improve HTMX handling 2025-11-09 21:41:02 +00:00
Ross 2142749db6 Refactor exam review URLs and streamline HTMX request handling in views 2025-11-09 21:36:22 +00:00
Ross 1e1abb2e7f Refactor HTMX request detection to use request.htmx for improved compatibility 2025-11-09 21:18:31 +00:00
Ross 133a63cecc Improve HTMX request detection by using request.headers and falling back to META for compatibility 2025-11-08 22:15:03 +00:00
Ross 8ad47ffc91 Change hx-swap attribute from outerHTML to innerHTML for navigation buttons in exam review question fragment 2025-11-08 22:13:11 +00:00
Ross bf240af678 Change hx-swap attribute from innerHTML to outerHTML for navigation buttons in exam review template 2025-11-08 22:12:07 +00:00
Ross 0f571d8f5c Refactor exam review question template to support HTMX navigation and create a partial for question details 2025-11-08 22:10:21 +00:00
Ross 321c7ef895 Add user access check for editing/reviewing exams in GenericViewBase 2025-11-08 22:07:28 +00:00
Ross d26cac04a7 Add exam review links to navigation in multiple templates 2025-11-08 22:05:52 +00:00
Ross 5613bf6b9f Add exam review functionality with navigation for questions 2025-11-08 21:56:08 +00:00
Ross 1a45f11fc2 test updating view 2025-11-08 21:48:27 +00:00
Ross 4d830bb8e7 . 2025-11-08 21:16:23 +00:00
Ross b424defd57 Optimize exam collection queries by annotating exam counts and prefetching authors to improve template performance 2025-11-07 22:16:24 +00:00
Ross 00a675b715 Refactor exam collections list to use Bootstrap cards for improved layout and add total exam count display 2025-11-07 22:13:09 +00:00
Ross bd0859cce0 Add HTMX indicator for import form to enhance user feedback during submission 2025-11-07 21:53:03 +00:00
Ross 2bf74ea297 Fix event listener closure in LLM prompt view for proper feedback handling 2025-11-07 21:51:55 +00:00
Ross 71e9b47c6b Enhance button labels for clarity in exam collection detail view 2025-11-07 21:27:08 +00:00
Ross 9f8b4ff36a Add candidate count method to ExamOrCollectionGenericBase and update exam list template 2025-11-07 21:10:52 +00:00
Ross 9b8e6f1398 Convert date inputs to ISO format on form submit for correct Django parsing 2025-11-06 22:20:07 +00:00
Ross 0ff60da006 Add date range filtering to exams with jQuery UI datepicker support 2025-11-06 22:18:50 +00:00
Ross 53e18e296e Improve exam display layout by adding badges for questions and candidates in the exam list 2025-11-06 22:09:31 +00:00
Ross 8f2ca66bbb Enhance bulk selection functionality in exam index template with visual cues and row click toggling 2025-11-06 22:03:48 +00:00
Ross ebad9c6bd9 Refactor exam date handling to use start_date instead of date in bulk update functionality 2025-11-06 22:01:57 +00:00
Ross 54afa6e6bd Implement bulk update and delete functionality for exams with HTMX support 2025-11-06 21:53:35 +00:00
Ross 17541b8187 Remove filter details section from exam index template 2025-11-06 21:44:21 +00:00
Ross 04ce90049a Apply date from original collection to cloned exams in ExamCollectionClone 2025-11-06 21:41:53 +00:00
Ross 2d963259ca Add exam collection link to the navigation menu and clean up dropdown toggle 2025-11-06 21:30:14 +00:00
Ross ccb27af697 Add search questions link to the navigation menu 2025-11-05 22:20:43 +00:00
Ross dfdba4cce6 Add question search functionality with HTMX support for dynamic results 2025-11-05 22:19:24 +00:00
Ross b12a45275c Enhance case search results display to include series modalities and examinations 2025-11-05 22:08:54 +00:00
Ross 4f6e9fae9d Enhance case search functionality with loading indicator for improved user experience 2025-11-05 22:03:02 +00:00
Ross 22299b9298 Refactor case search functionality to include additional fields for improved search accuracy 2025-11-05 21:58:43 +00:00
Ross 06df2c4188 Enhance case search functionality to include additional fields and related names for improved search results 2025-11-05 21:54:28 +00:00
Ross 8c1c7b75e7 Refactor case search input and results handling for improved functionality and clarity 2025-11-05 21:52:53 +00:00
Ross 03a24615ab Add case search functionality with HTMX support and results partial 2025-11-05 21:37:57 +00:00
Ross 89a767034c Update image retrieval in Case model to use get_images method for improved clarity 2025-11-05 21:18:15 +00:00
187 changed files with 13425 additions and 2543 deletions
+22
View File
@@ -0,0 +1,22 @@
# Django
DEBUG=1
SECRET_KEY=w(s0&(_eb058wvmg@44_repv8)r9@5p8fx*g_@c)1dm&d*ew^u
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
# External DB (you mentioned production uses an external DB)
DB_HOST=db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com
DB_PORT=25060
DB_NAME=testing
DB_USER=django
DB_PASSWORD=AVNS_NG_s4i7SMMobWLO
# Redis
REDIS_URL=redis://redis:6379/0
# Gunicorn tuning
GUNICORN_WORKERS=3
GUNICORN_LOGLEVEL=info
# Nginx host ports for local development (override prod defaults)
NGINX_HTTP_PORT=8000
NGINX_HTTPS_PORT=8443
+20
View File
@@ -0,0 +1,20 @@
# Copy this to .env.prod on the server and fill in production values (do NOT commit secrets).
# Django
SECRET_KEY=replace-me-with-a-secure-random-string
DEBUG=0
ALLOWED_HOSTS=example.com
# External DB (you mentioned production uses an external DB)
DATABASE_HOST=your.db.host
DATABASE_PORT=5432
DATABASE_NAME=rad
DATABASE_USER=django
DATABASE_PASSWORD=strong-db-password
# Redis
REDIS_URL=redis://redis:6379/0
# Gunicorn tuning
GUNICORN_WORKERS=3
GUNICORN_LOGLEVEL=info
+6 -1
View File
@@ -1,4 +1,5 @@
media/
mediafiles/
__pycache__
.vscode/
@@ -10,4 +11,8 @@ venv
*.log
temp/
temp/
# Loki runtime data
/docker/loki-data/
/docker/loki-wal/
+15 -16
View File
@@ -19,51 +19,50 @@ from django.contrib.auth import get_user_model
router = Router()
class CidUserSchema(ModelSchema):
class Config:
class Meta:
model = CidUser
model_fields = ["cid", "name", "email"]
fields = ["cid", "name", "email"]
class UserUserSchema(ModelSchema):
class Config:
class Meta:
model = get_user_model()
model_fields = ["id", "email"]
fields = ["id", "email"]
class CidUserExamSchema(ModelSchema):
cid_user : CidUserSchema | None
user_user : UserUserSchema | None
class Config:
class Meta:
model = CidUserExam
model_fields = ["id"]
fields = ["id"]
class ExamStatusSchema(ModelSchema):
class Config:
class Meta:
model = ExamUserStatus
model_fields = ["id", "datetime", "status", "extra"]
fields = ["id", "datetime", "status", "extra"]
class ExamUserStatusSchema(ModelSchema):
#cid_user : CidUserSchema | None
#user_user : UserUserSchema | None
cid_user_exam : CidUserExamSchema | None
class Config:
class Meta:
model = ExamUserStatus
model_fields = ["id", "datetime", "status", "extra"]
fields = ["id", "datetime", "status", "extra"]
class QuestionSchema(ModelSchema):
class Config:
class Meta:
model = Question
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
model_fields = "__all__"
fields = "__all__"
#model_exclude = ["answers"]
#exclude = ["answers"]
class ExamSchema(ModelSchema):
class Config:
class Meta:
model = Exam
model_fields = ["id", "name", "active", "publish_results"]
fields = ["id", "name", "active", "publish_results"]
@router.get('/')
def list_questions(request):
+1
View File
@@ -0,0 +1 @@
"""Management package for anatomy app."""
+1
View File
@@ -0,0 +1 @@
"""Management commands package for anatomy app."""
@@ -0,0 +1,180 @@
from __future__ import annotations
import os
import io
from uuid import uuid4
from typing import List, Tuple
from django.core.management.base import BaseCommand, CommandError
from django.core.files import File
from django.contrib.auth import get_user_model
from PIL import Image
from anatomy.models import AnatomyQuestion, Exam, ExamQuestionDetail, QuestionType, Structure
from generic.models import Modality as GenericModality # type: ignore
class Command(BaseCommand):
help = (
"Create anatomy questions and assign them to an exam."
"Requires --exam-id and --image-path (path readable inside the container)."
)
def add_arguments(self, parser):
parser.add_argument("--exam-id", type=int, required=False, help="ID of the anatomy.Exam to assign questions to (if omitted a new exam will be created)")
parser.add_argument("--exam-name", type=str, default=None, help="Name to use when creating a new exam if --exam-id omitted")
parser.add_argument("--count", type=int, default=1, help="Number of questions to create")
parser.add_argument("--image-path", type=str, required=False, help="Path to image file to use for each created question")
parser.add_argument("--generate-image", action="store_true", help="Generate a placeholder image automatically if --image-path is not provided or not found")
parser.add_argument("--image-size", type=str, default="800x600", help="Generated image size WIDTHxHEIGHT, e.g. 800x600")
parser.add_argument("--image-color", type=str, default="#777777", help="Background color for generated image (hex) e.g. #ffffff")
parser.add_argument("--description", type=str, default="Generated question {i}", help="Description template; use {i} for index")
parser.add_argument("--question-type-id", type=int, default=None, help="Optional QuestionType id to assign")
parser.add_argument("--modality-id", type=int, default=None, help="Optional Modality id to assign (generic.Modality id)")
parser.add_argument("--structure-id", type=int, default=None, help="Optional Structure id to assign")
parser.add_argument("--author-id", type=int, action="append", help="Author user id; repeatable")
parser.add_argument("--start-sort", type=int, default=1, help="Starting sort_order for the first question")
def handle(self, *args, **options):
exam_id = options.get("exam_id")
count = options["count"]
image_path = options.get("image_path")
generate_image = options.get("generate_image")
image_size_str = options.get("image_size") or "800x600"
image_color = options.get("image_color") or "#777777"
description_template = options["description"]
qtype_id = options.get("question_type_id")
modality_id = options.get("modality_id")
structure_id = options.get("structure_id")
author_ids: List[int] = options.get("author_id") or []
sort = options.get("start_sort") or 1
exam = None
if exam_id:
try:
exam = Exam.objects.get(pk=exam_id)
except Exam.DoesNotExist:
raise CommandError(f"Exam with id {exam_id} does not exist")
else:
# create new exam
exam_name = options.get("exam_name") or f"Auto Anatomy Exam {uuid4().hex[:8]}"
exam = Exam(name=exam_name, active=True, open_access=True)
exam.save()
self.stdout.write(self.style.SUCCESS(f"Created exam id={exam.id} name='{exam.name}'"))
# validate image path if provided
if image_path and not os.path.exists(image_path):
if not generate_image:
raise CommandError(f"Image file not found at path: {image_path}")
else:
image_path = None
# Resolve optional foreign keys
qtype = None
if qtype_id:
qtype = QuestionType.objects.filter(pk=qtype_id).first()
if not qtype:
raise CommandError(f"QuestionType id {qtype_id} not found")
modality = None
if modality_id:
# The project stores Modality in generic.models.Modality
try:
from generic.models import Modality as GModality
modality = GModality.objects.filter(pk=modality_id).first()
if not modality:
raise CommandError(f"Modality id {modality_id} not found in generic.Modality")
except Exception as e:
raise CommandError(str(e))
structure = None
if structure_id:
from atlas.models import Structure as AtlasStructure
structure = AtlasStructure.objects.filter(pk=structure_id).first()
if not structure:
raise CommandError(f"Structure id {structure_id} not found")
User = get_user_model()
authors = []
for uid in author_ids:
u = User.objects.filter(pk=uid).first()
if not u:
raise CommandError(f"Author user id {uid} not found")
authors.append(u)
# parse image size
try:
w, h = image_size_str.lower().split("x")
image_size: Tuple[int, int] = (int(w), int(h))
except Exception:
raise CommandError("Invalid --image-size; expected WIDTHxHEIGHT, e.g. 800x600")
created = []
for i in range(1, count + 1):
desc = description_template.format(i=i)
# determine image source: either provided path or generated image
unique_name = f"{uuid4().hex}_image.jpg"
if image_path:
# use provided file
with open(image_path, "rb") as f:
django_file = File(f, name=unique_name)
img_file_for_save = django_file
# create and save question
q = AnatomyQuestion(
question_type=qtype,
description=desc,
answer_help="",
)
if modality:
q.modality = modality
if structure:
q.structure = structure
q.image.save(unique_name, img_file_for_save, save=False)
q.save()
else:
# generate an image in memory
buf = io.BytesIO()
img = Image.new("RGB", image_size, image_color)
img.save(buf, format="JPEG")
buf.seek(0)
django_file = File(buf, name=unique_name)
q = AnatomyQuestion(
question_type=qtype,
description=desc,
answer_help="",
)
if modality:
q.modality = modality
if structure:
q.structure = structure
q.image.save(unique_name, django_file, save=False)
q.save()
# authors
if authors:
q.author.set(authors)
# add to exam via the many-to-many through relationship
try:
exam.exam_questions.add(q, through_defaults={"sort_order": sort})
except TypeError:
ExamQuestionDetail.objects.create(exam=exam, anatomyquestion=q, sort_order=sort)
self.stdout.write(self.style.SUCCESS(f"Created question id={q.id} and added to exam id={exam.id} with sort_order={sort}"))
created.append(q)
sort += 1
self.stdout.write(self.style.SUCCESS(f"Finished: created {len(created)} question(s)"))
+13 -8
View File
@@ -132,14 +132,16 @@ class AnatomyQuestion(QuestionBase):
return "anatomy"
def __str__(self):
# Get first answer
return "{}/{}: {} [{}, {}]".format(
self.pk,
self.question_type,
self.get_primary_answer(),
self.modality,
self.structure,
)
# Nicely formatted representation
pk = self.pk or ""
qtype = str(self.question_type) if self.question_type else "N/A"
primary = self.get_primary_answer() or "None"
modality = str(self.modality) if self.modality else "Unknown"
structure = str(self.structure) if self.structure else "Unknown"
title = self.get_title() if hasattr(self, "get_title") else (self.description or "")
if title and len(title) > 60:
title = title[:57] + "..."
return f"{title} (Type: {qtype}) — Answer: {primary} — Modality: {modality}; Structure: {structure}"
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self)
@@ -180,6 +182,9 @@ class AnatomyQuestion(QuestionBase):
exams = ", ".join(e)
return exams
def get_image_url(self):
return "{}/{}".format(settings.REMOTE_URL, self.image.url)
def get_unmarked_user_answer_string(self, exam_pk=None):
unmarked_answers = self.get_unmarked_user_answers(exam_pk)
+338 -177
View File
@@ -16,6 +16,8 @@ $(document).ready(function () {
clearableTextAreaSetup();
document.body.addEventListener("htmx:responseError", function(e) {
let el = document.getElementById("htmx-error");
var error;
@@ -42,24 +44,27 @@ $(document).ready(function () {
// $(element).append($("<span class='google-link' title='search for answer with google'><a href='https://www.google.com/search?q=" + $(element).text() + "' target='_blank'>G</a></span>"));
//});
$(".answer-list span.answer").each(function (index, element) {
console.log(element);
// Legacy per-element click handler: skip on the new mark2 page which
// manages clicks via a delegated handler (it uses #answer-list). This
// prevents duplicate/colliding handlers when mark2 is rendered.
if (!document.getElementById('answer-list')) {
$(".answer-list span.answer").each(function (index, element) {
console.log(element);
$(element).click(function (e) {
$(element).click(function (e) {
var classes = ['answer correct', 'answer half-correct', 'answer incorrect'];
$(element).each(function () {
let mark = classes[($.inArray(this.className, classes) + 1) % classes.length];
this.className = mark
this.dataset.newmark = mark.split(" ")[1]
});
prepAnswerData();
var classes = ['answer correct', 'answer half-correct', 'answer incorrect'];
$(element).each(function () {
let mark = classes[($.inArray(this.className, classes) + 1) % classes.length];
this.className = mark
this.dataset.newmark = mark.split(" ")[1]
});
prepAnswerData();
});
});
}
prepAnswerData();
if ($(".post-form").length > 0) {
@@ -80,6 +85,14 @@ $(document).ready(function () {
}
}
let dicom_images_legacy = document.getElementsByClassName("dicom-image-legacy");
if (dicom_images_legacy.length) {
for (let element of dicom_images_legacy) {
setUpDicomLegacy(element)
}
}
loadDicomViewer();
@@ -235,6 +248,7 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
console.log("loadDicomViewer", images_to_load);
let single_dicom = document.getElementById("single-dicom-viewer");
if (single_dicom) {
console.log("Load single dicom viewer");
$(single_dicom).empty()
let images = single_dicom.dataset.images;
@@ -307,8 +321,8 @@ function prepAnswerData() {
$("#id_marked_answers").val(JSON.stringify(window.marked_answers));
}
function loadJsonToolStateOnCurrentImage(json) {
let el = document.getElementById("dicom-image");
function loadJsonToolStateOnCurrentImage(element, json) {
let el = element;
const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager;
@@ -330,160 +344,195 @@ function loadJsonToolStateOnCurrentImage(json) {
}
//async function setUpDicom(element) {
// console.log("setUpDicom (original)", element);
//
// $(element).bind('contextmenu', function (e) {
// return false;
// });
//
// $(element).dblclick((evt) => {
// element.requestFullscreen();
// })
//
// cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
// cornerstoneWebImageLoader.external.cornerstone = cornerstone;
// cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
//
// cornerstoneTools.init();
// const PanTool = cornerstoneTools.PanTool;
// const ZoomTool = cornerstoneTools.ZoomTool;
// const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
// const WwwcTool = cornerstoneTools.WwwcTool;
// const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
// const RotateTool = cornerstoneTools.RotateTool;
// const StackScrollTool = cornerstoneTools.StackScrollTool;
// const MagnifyTool = cornerstoneTools.MagnifyTool;
// const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
//
//
// const images = element.dataset.url.split(",");
//
// //console.log("Dicom - load imageId: ", imageIds);
// //for (let index = 0; index < imageIds.length; index++) {
// // if (imageIds[index].endsWith("dcm")) {
// // imageIds[index] = "wadouri:" + imageIds[index];
// // }
//
// //}
// let imageIds = [];
// for (let i = 0; i < images.length; i++) {
// let data_url = images[i];
// // check stack type
// if (data_url.startsWith("data:image")) {
// let imageId = "base64://" + data_url.split(",")[1];
//
// imageIds.push(imageId);
// } else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
//
// imageIds.push(data_url);
//
// // Treat application/octet-stream as if they are dicoms
// } else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
// //stack = stack.split(";")[1];
//
// let dfile = await urltoFile(data_url, "dicom", "application/dicom");
//
// const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
// dfile
// );
//
// loadAnnotation(imageId, annotation);
//
// imageIds.push(imageId);
// //cornerstone.loadImage(imageId).then(function(image) {
// // tempFunction(image);
// //});
// } else {
// let url;
// // This doesn't seem to have any benefit
// //if (data_url.startsWith("http")) {
// // url = data_url;
// //} else {
// // url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
// //}
// url = data_url;
//
// if (url.endsWith("dcm")) {
// url = "wadouri:" + url;
// }
//
// // if there is no extension treat it as a dicom
// if (/(?:\/|^)[^.\/]+$/.test(url)) {
// url = "wadouri:" + url;
// }
//
// imageIds.push(url);
//
//
// }
// }
//
// cornerstone.enable(element);
// cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
// cornerstone.displayImage(element, image);
//
// cornerstoneTools.addToolForElement(element, PanTool);
// cornerstoneTools.addToolForElement(element, ZoomTool);
// cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
// cornerstoneTools.addToolForElement(element, WwwcTool);
// cornerstoneTools.addToolForElement(element, WwwcRegionTool);
// cornerstoneTools.addToolForElement(element, RotateTool);
// cornerstoneTools.addToolForElement(element, StackScrollTool);
// cornerstoneTools.addToolForElement(element, MagnifyTool);
//
// cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
// configuration: {
// getTextCallback: () => {},
// changeTextCallback: () => {},
// allowEmptyLabel: true,
// renderDashed: false,
// drawHandles: false,
// drawHandlesOnHover: true,
// },
// });
//
//
//
// // Enable our tools
// // Avoid incorrect aspect ratio
// cornerstoneTools.setToolActiveForElement(element, "Pan", {
// mouseButtonMask: 1
// });
// cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
// mouseButtonMask: 2
// });
// cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
// mouseButtonMask: 3
// });
// cornerstoneTools.setToolActiveForElement(element, "Zoom", {
// mouseButtonMask: 4
// });
//
// if (element.dataset.edit_annotation == "true") {
// cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
// mouseButtonMask: 2
// });
// } else {
// cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
// }
//
// if (element.dataset.annotations) {
// loadJsonToolStateOnCurrentImage(element.dataset.annotations)
// }
//
// cornerstone.resize(element);
//
//
// }).catch((err, err2) => {
// console.log(err);
// });
//
//}
//console.log("setUpDicom defined");
//
//// keep reference to the existing 2D setup
//window.setUpDicomClassic = setUpDicom;
// Called by HTMX after a fragment swap; initializes any dicom-image-legacy viewers
window.initAnatomyFragment = function(fragmentRoot) {
console.log("initAnatomyFragment", fragmentRoot);
try {
const root = fragmentRoot && fragmentRoot.nodeType ? fragmentRoot : (fragmentRoot && fragmentRoot.detail && fragmentRoot.detail.target) ? fragmentRoot.detail.target : document;
const imgs = (root && root.querySelectorAll) ? root.querySelectorAll('.dicom-image-legacy') : [];
imgs.forEach((el) => {
try {
// Avoid double initialization
if (el.dataset && (el.dataset.dicomInitialised === '1' || el.dataset.dicomInitialised === 'true')) return;
if (typeof window.setUpDicomLegacy === 'function') {
window.setUpDicomLegacy(el);
if (el.dataset) el.dataset.dicomInitialised = '1';
}
} catch (err) {
console.warn('initAnatomyFragment: setUpDicomLegacy failed for element', el, err);
}
});
} catch (err) {
console.warn('initAnatomyFragment error', err);
}
}
// Global fallback: listen for HTMX swaps and initialise any viewers inside
document.addEventListener('htmx:afterSwap', function (e) {
try {
const root = e && e.target ? e.target : (e && e.detail && e.detail.target ? e.detail.target : document);
if (window.initAnatomyFragment) window.initAnatomyFragment(root);
} catch (err) {
console.warn('htmx afterSwap listener error', err);
}
});
async function setUpDicomLegacy(element) {
console.log("setUpDicom (original)", element);
$(element).bind('contextmenu', function (e) {
return false;
});
$(element).dblclick((evt) => {
element.requestFullscreen();
})
cornerstoneBase64ImageLoader.external.cornerstone = cornerstone;
cornerstoneWebImageLoader.external.cornerstone = cornerstone;
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneTools.init();
const PanTool = cornerstoneTools.PanTool;
const ZoomTool = cornerstoneTools.ZoomTool;
const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool;
const WwwcTool = cornerstoneTools.WwwcTool;
const WwwcRegionTool = cornerstoneTools.WwwcRegionTool;
const RotateTool = cornerstoneTools.RotateTool;
const StackScrollTool = cornerstoneTools.StackScrollTool;
const MagnifyTool = cornerstoneTools.MagnifyTool;
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
const images = element.dataset.url.split(",");
const annotation = element.dataset.annotations;
//console.log("Dicom - load imageId: ", imageIds);
//for (let index = 0; index < imageIds.length; index++) {
// if (imageIds[index].endsWith("dcm")) {
// imageIds[index] = "wadouri:" + imageIds[index];
// }
//}
let imageIds = [];
for (let i = 0; i < images.length; i++) {
let data_url = images[i];
// check stack type
if (data_url.startsWith("data:image")) {
let imageId = "base64://" + data_url.split(",")[1];
imageIds.push(imageId);
} else if (data_url.startsWith("base64://") || data_url.startsWith("wadouri:")) {
imageIds.push(data_url);
// Treat application/octet-stream as if they are dicoms
} else if (data_url.startsWith("data:application/dicom") || data_url.startsWith("data:application/octet-stream")) {
//stack = stack.split(";")[1];
let dfile = await urltoFile(data_url, "dicom", "application/dicom");
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
dfile
);
loadAnnotation(imageId, annotation);
imageIds.push(imageId);
//cornerstone.loadImage(imageId).then(function(image) {
// tempFunction(image);
//});
} else {
let url;
// This doesn't seem to have any benefit
//if (data_url.startsWith("http")) {
// url = data_url;
//} else {
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
//}
url = data_url;
if (url.endsWith("dcm")) {
url = "wadouri:" + url;
}
// if there is no extension treat it as a dicom
if (/(?:\/|^)[^.\/]+$/.test(url)) {
url = "wadouri:" + url;
}
imageIds.push(url);
}
}
cornerstone.enable(element);
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
cornerstone.displayImage(element, image);
cornerstoneTools.addToolForElement(element, PanTool);
cornerstoneTools.addToolForElement(element, ZoomTool);
cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool);
cornerstoneTools.addToolForElement(element, WwwcTool);
cornerstoneTools.addToolForElement(element, WwwcRegionTool);
cornerstoneTools.addToolForElement(element, RotateTool);
cornerstoneTools.addToolForElement(element, StackScrollTool);
cornerstoneTools.addToolForElement(element, MagnifyTool);
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
configuration: {
getTextCallback: () => {},
changeTextCallback: () => {},
allowEmptyLabel: true,
renderDashed: false,
drawHandles: false,
drawHandlesOnHover: true,
},
});
// Enable our tools
// Avoid incorrect aspect ratio
cornerstoneTools.setToolActiveForElement(element, "Pan", {
mouseButtonMask: 1
});
cornerstoneTools.setToolActiveForElement(element, "Wwwc", {
mouseButtonMask: 2
});
cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", {
mouseButtonMask: 3
});
cornerstoneTools.setToolActiveForElement(element, "Zoom", {
mouseButtonMask: 4
});
if (element.dataset.edit_annotation == "true") {
cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", {
mouseButtonMask: 2
});
} else {
cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate");
}
if (element.dataset.annotations) {
loadJsonToolStateOnCurrentImage(element, element.dataset.annotations)
}
cornerstone.resize(element);
}).catch((err, err2) => {
console.log(err);
});
}
console.log("setUpDicom defined");
// keep reference to the existing 2D setup
window.setUpDicomLegacy = setUpDicomLegacy;
async function setUpDicom3d(element) {
console.log("setUpDicom3d (cornerstone3d) for", element);
@@ -546,13 +595,28 @@ async function setUpDicom3d(element) {
console.log(renderingEngine)
const viewportInput = {
viewportId,
type: Enums.ViewportType.STACK,
element,
viewportId,
type: Enums.ViewportType.STACK,
element,
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
};
renderingEngine.enableElement(viewportInput);
try {
renderingEngine.enableElement(viewportInput);
} catch (err) {
// Some bundlings / integrations (dv3d wrapper) may reject the provided
// viewport type (e.g. "Viewport is not a valid type"). In that case
// fall back to the 2D cornerstone loader which is more permissive.
console.warn('renderingEngine.enableElement failed, falling back to 2D loader:', err);
try {
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err2) {
console.error('2D fallback after enableElement failure also failed:', err2);
throw err;
}
}
const viewport = renderingEngine.getViewport(viewportId);
@@ -597,10 +661,50 @@ async function setUpDicom3d(element) {
// Prepare imageIds: ensure remote URLs are usable by dicom-image-loader (wadouri:)
// but avoid forcing wadouri: for standard web images (png/jpg/etc) because
// the dicom parser will fail when given non-DICOM files. For such images
// return the raw URL so the appropriate web-image loader can handle them.
// If any image looks like a plain web image (png/jpg/etc) prefer the
// existing 2D cornerstone loader which handles http(s) image schemes.
// This avoids a situation where the 3D dicom loader has no handler for
// the 'https' scheme and throws "No image loader found for scheme 'https'".
const looksLikeWebImage = (u) => {
if (!u) return false;
try { u = u.trim(); } catch (e) {}
if (u.startsWith('data:image')) return true;
if (u.match(/\.(png|jpe?g|gif|bmp|webp)$/i)) return true;
return false;
};
if (images.some(looksLikeWebImage)) {
// Fallback: use the 2D cornerstone loader which has web-image handling
// and is more tolerant of raw http(s) URLs. We reuse the project's
// dicomViewer helper (it expects a jQuery element).
console.warn('setUpDicom3d: contains web-image(s) — falling back to 2D loader');
try {
// Remove any partially-created dv3d DOM and ensure the element is a
// clean container for the 2D viewer. This ensures the `.canvas-panel`
// appended by `loadCornerstone` overlays exactly the element area.
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
element.style.position = element.style.position || 'relative';
// annotations are not available in this scope in the 3d code path; pass
// undefined for annotations and let the loader handle it.
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
// store a lightweight marker so callers can inspect what happened
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err) {
console.warn('Fallback 2D loader failed; will continue to try 3D loader', err);
// fallthrough to attempt 3D loading below
}
}
const imageIds = images.map((url) => {
url = url.trim();
// if it's already a scheme we pass through; otherwise treat as wadouri
if (/^(wadouri:|base64:|data:)/i.test(url)) return url;
// if it's already a scheme we pass through
if (/^(wadouri:|base64:|data:|http:|https:)/i.test(url)) return url;
// default: treat as wadouri (likely a DICOM file)
return `wadouri:${url}`;
});
@@ -617,7 +721,23 @@ async function setUpDicom3d(element) {
}
}
await viewport.setStack(imageIds, 0);
try {
await viewport.setStack(imageIds, 0);
} catch (err) {
// If the 3D loader fails because it doesn't know how to load 'https'
// URLs, fallback to the 2D cornerstone loader which handles web images.
console.warn('viewport.setStack failed, attempting 2D fallback:', err);
try {
try { element.innerHTML = ""; } catch (e) { $(element).empty(); }
element.style.position = element.style.position || 'relative';
dicomViewer.loadCornerstone($(element), null, images, undefined, images.length > 5);
element._cornerstone3d = { fallback: 'cornerstone2d', imageCount: images.length };
return element._cornerstone3d;
} catch (err2) {
console.error('2D fallback also failed:', err2);
throw err; // rethrow original error
}
}
viewport.render();
@@ -738,3 +858,44 @@ function initializeClock(id, endtime) {
window.initializeClock = initializeClock
// Unwrap single-root <p> elements from TinyMCE content before any form submit.
// This prevents a single wrapping <p> from being sent to the server while
// preserving other markup. We use a delegated submit listener so it works
// for dynamically-added forms as well (e.g. admin inlines).
document.addEventListener('submit', function(evt) {
try {
var form = evt.target;
if (!form || !form.querySelector) return;
// Find all tinyMCE textareas inside the form
var areas = form.querySelectorAll('textarea.tinymce');
areas.forEach(function(area) {
var id = area.id;
if (!id) return;
var ed = (typeof tinyMCE !== 'undefined') ? tinyMCE.get(id) : null;
if (!ed) return;
var html = (ed.getContent && typeof ed.getContent === 'function') ? ed.getContent({format: 'html'}) : area.value;
if (!html) return;
// Use a temporary container to inspect top-level elements safely
var tmp = document.createElement('div');
tmp.innerHTML = html.trim();
// If there is exactly one top-level element and it is a <p>, unwrap it
if (tmp.childElementCount === 1 && tmp.firstElementChild && tmp.firstElementChild.tagName.toLowerCase() === 'p') {
var inner = tmp.firstElementChild.innerHTML;
// Update editor content and the underlying textarea value
try {
ed.setContent(inner);
} catch (e) {
// fallback: set textarea value directly
area.value = inner;
}
// Ensure the textarea value matches (some setups sync on save, some don't)
area.value = inner;
}
});
} catch (e) {
// be resilient; don't prevent form submission if something goes wrong here
console.error('TinyMCE submit postprocess error', e);
}
}, true);
@@ -237,9 +237,9 @@
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
{{ form|crispy }}
<div id="drop-container" class="drop-target">Drop image here
@@ -28,32 +28,60 @@
{% if object %}
{% include 'anatomy/question_link_header.html' %}
{% endif %}
<h2>Question Answers</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)</p>
</details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<h3>Answers:</h3>
<div id="form_set">
{% for form in answer_formset %}
<ul class="no-error answer-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form.as_ul }}
</ul>
{% endfor %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="mb-0">Question Answers</h2>
<small class="text-muted">Manage answer keys for auto-marking</small>
</div>
<div class="card-body">
<details class="help-text mb-3">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p class="mb-0">This page can be used to add and modify answers associated with a question. Answers here are used for auto-marking submitted responses; avoid removing entries unless you intend to re-score submissions.</p>
</details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<h3 class="h5 mt-2">Answers</h3>
<div id="form_set" class="mt-3">
{% for form in answer_formset %}
<div class="answer-card mb-3 p-3 border rounded bg-body-secondary">
{{form.non_field_errors}}
{{form.errors}}
<div class="answer-fields">
{{ form.as_ul }}
</div>
</div>
{% endfor %}
</div>
{{ answer_formset.management_form }}
<div class="d-flex gap-2 align-items-center mt-3">
<button type="button" id="add_more" class="btn btn-outline-secondary btn-sm">Add another answer</button>
<input type="submit" class="submit-button btn btn-primary btn-sm ms-auto" value="Save answers" name="submit">
</div>
</form>
<div id="empty_form" class="d-none">
<div class='answer-card p-3 mb-3 border rounded bg-body-secondary'>
{{ answer_formset.empty_form.as_ul }}
</div>
</div>
</div>
{{ answer_formset.management_form }}
<input type="button" value="Add More Answers" id="add_more">
<p>
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
</p>
</form>
<div id="empty_form" style="display:none">
<ul class='no_error answer-formset'>
{{ answer_formset.empty_form.as_ul }}
</ul>
</div>
{% endblock %}
{% block css %}
<style>
/* Answer card layout */
.answer-card label { font-weight: 600; }
.answer-card li { list-style: none; margin-bottom: .5rem; }
.answer-card input[type="text"], .answer-card textarea, .answer-card select { width: 100%; }
.help-text summary { cursor: pointer; font-weight:600; }
/* Make the Add button more prominent on small screens */
@media (max-width: 576px) {
.submit-button { width: 100%; }
}
</style>
{% endblock %}
@@ -23,7 +23,7 @@
<pre>{{ans}}</pre>
</span>
{% if exam.publish_results or view_all_results%}
<span class="answer-score">{{score}}</span>
Marks: <span class="answer-score">{{score}}</span>
{% endif %}
</span>
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
+26 -10
View File
@@ -2,14 +2,30 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
{% if exam.exam_mode %}
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %}
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %}
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %}
+3 -3
View File
@@ -20,8 +20,8 @@
</div>
</details>
{% endif %}
<details>
<summary>Suggest incorrect answers:</summary>
{% comment %} <details>
<summary>Suggest incorrect answers:</summary>
{% for word in words_suggest_incorrect %}
<span>{{word}}<span class="user-select-none" _="on click if #id_answer_suggest_incorrect == null then
@@ -43,7 +43,7 @@
<div class="border border-secondary">
{% include "anatomy/question_detail.html#suggest-incorrect-form" %}
</div>
</details>
</details> {% endcomment %}
+344
View File
@@ -0,0 +1,344 @@
{% extends 'anatomy/exams.html' %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="card-title mb-1">Marking question {{ question_details.current }} of {{ question_details.total }}</h5>
<div class="mb-2">
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a>
<a class="btn btn-sm btn-outline-secondary me-1" href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
{% if request.user.is_superuser %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}" title="Admin Edit">Admin Edit</a>
{% endif %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:mark' exam.id question_number %}">Classic mark</a>
</div>
<h6 class="mb-1">{{ question.question_type }}</h6>
{% if question.structure %}
<div class="small text-muted">Structure: {{ question.structure }}</div>
{% endif %}
<div class="mt-2">Primary answer: <span id="primary-answer" title="The primary answer of the question">{{ question.get_primary_answer }}</span></div>
</div>
<div class="text-end">
{% if question.answer_help %}
<button class="btn btn-sm btn-outline-info" data-bs-toggle="collapse" data-bs-target="#marking-help">Answer Help</button>
{% endif %}
</div>
</div>
{% if question.answer_help %}
<div class="collapse mt-2" id="marking-help">
<div class="card card-body">
{{ question.answer_help|safe }}
</div>
</div>
{% endif %}
<div class="mt-3">
<form method="POST" class="post-form">{% csrf_token %}
<p class="small text-muted">Click each answer to toggle through marks awarded (as per colour). This UI writes marks immediately.</p>
<div class="d-flex justify-content-between align-items-center mb-2">
<div></div>
<div>
{% if question_details.current > 1 %}
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-1">Previous</button>
{% endif %}
<button type="submit" name="save" class="btn btn-primary btn-sm me-1">Save</button>
{% if question_details.current < question_details.total %}
<button type="submit" name="next" class="btn btn-outline-primary btn-sm me-1">Next</button>
{% endif %}
</div>
</div>
<div class="mb-2">
<button class="btn btn-sm btn-outline-info me-1" hx-get="{% url 'anatomy:mark2_exam_marked' exam.id question_number %}" hx-target="#exam-marked-container" hx-swap="innerHTML">Show exam-submitted answers</button>
<button class="btn btn-sm btn-outline-info" hx-get="{% url 'anatomy:mark2_question_marked' question.pk %}" hx-target="#question-marked-container" hx-swap="innerHTML">Show all stored answers for question</button>
</div>
<div class="row">
<div class="col-12">
<h6>Answers</h6>
<ul id="answer-list" class="list-group answer-list mb-3">
{# Render currently-stored marked answers first, then unmarked submissions #}
{% include 'anatomy/partials/mark2_marked_list_fragment.html' %}
{% include 'anatomy/partials/mark2_unmarked_list_fragment.html' %}
</ul>
<div id="exam-marked-container" class="mt-2"></div>
<div id="question-marked-container" class="mt-2"></div>
</div>
</div>
<div class="mt-3">
<div class="small text-muted key">Key: <span class="key-item correct">2 Marks</span>, <span class="key-item half-correct">1 Mark</span>, <span class="key-item incorrect">0 Marks</span></div>
</div>
<span class="visually-hidden">{{ form.as_p }}</span>
</form>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div id="single-dicom-viewer" class="marking-dicom" data-images="{{ question.get_image_url_array }}" data-annotations='{{ question.get_image_annotations }}'></div>
</div>
</div>
{% endblock %}
{% block css %}
<style>
/* Ensure answer states are visible and clickable in the new layout */
.answer { cursor: pointer; }
/* Use border to indicate mark state instead of background */
.answer.correct { color: #155724; background-color: transparent; }
.answer.half-correct { color: #856404; background-color: transparent; }
.answer.incorrect { color: #721c24; background-color: transparent; }
.answer.not-marked { color: inherit; background-color: transparent; }
.answer-list .list-group-item pre { margin: 0; font-family: monospace; white-space: pre-wrap; }
.mark-controls { display:flex; gap:0.25rem; }
.mark-btn { padding: 0.15rem 0.4rem; font-size: 0.8rem; }
/* Per-state border indicators on the list item */
.marked-item { }
.marked-correct { border-left: 4px solid #2a9d3f; }
.marked-half-correct { border-left: 4px solid #ffb020; }
.marked-incorrect { border-left: 4px solid #d3413a; }
/* Key items use a small left-border swatch to match the list markers */
.key .key-item { display:inline-block; padding-left:8px; margin-left:6px; }
.key .key-item.correct { border-left: 8px solid #2a9d3f; }
.key .key-item.half-correct { border-left: 8px solid #ffb020; }
.key .key-item.incorrect { border-left: 8px solid #d3413a; }
/* Right-floating action icons for answer external links */
.answer-actions { min-width: 6rem; }
.answer-actions .action-icon { margin-left: 0.5rem; color: #6c757d; text-decoration: none; border: none; background: none; }
.answer-actions .action-icon:hover { color: #000; text-decoration: none; }
.answer-actions .copy-to-clipboard { cursor: pointer; }
/* Active state for numeric mark buttons */
.mark-btn.active { background-color: #0d6efd; color: #fff; border-color: #0d6efd; }
</style>
{% endblock %}
{% block js %}
<script>
(function(){
// CSRF helper for fetch
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
// Delegated click handler for answers — only updates local state.
document.addEventListener('click', function(e){
// Ignore clicks on action icons or mark controls
if (e.target.closest('.answer-actions') || e.target.closest('.mark-controls') || e.target.closest('.copy-to-clipboard')) return;
// Prefer a clicked span.answer if present
let spanEl = e.target.closest('span.answer');
// Otherwise fall back to the list-item's span
if (!spanEl) {
const li = e.target.closest('#answer-list li');
if (!li) return;
spanEl = li.querySelector('span.answer');
if (!spanEl) return;
}
// Ensure span has an explicit state class
if (!spanEl.classList.contains('correct') && !spanEl.classList.contains('half-correct') && !spanEl.classList.contains('incorrect') && !spanEl.classList.contains('not-marked')) {
spanEl.classList.add('not-marked');
}
// cycle states: not-marked -> correct -> half-correct -> incorrect -> not-marked
const states = ['not-marked', 'correct', 'half-correct', 'incorrect'];
let currentState = 'not-marked';
for (let p of spanEl.classList) {
if (states.includes(p)) { currentState = p; break; }
}
let idx = (states.indexOf(currentState) + 1) % states.length;
const newState = states[idx];
// map to numeric for setMarkForSpan
let numeric = null;
if (newState === 'correct') numeric = 2;
else if (newState === 'half-correct') numeric = 1;
else if (newState === 'incorrect') numeric = 0;
// delegate to setMarkForSpan to keep behaviour consistent (updates classes, active buttons, hidden field)
setMarkForSpan(spanEl, numeric);
}, false);
function updateMarkedAnswersField() {
const marked = { 'correct': [], 'half-correct': [], 'incorrect': [] };
document.querySelectorAll('#answer-list span.answer').forEach(function(el) {
if (el.classList.contains('correct')) marked.correct.push((el.title || el.textContent).trim());
else if (el.classList.contains('half-correct')) marked['half-correct'].push((el.title || el.textContent).trim());
else if (el.classList.contains('incorrect')) marked.incorrect.push((el.title || el.textContent).trim());
});
// Ensure the hidden input exists
let hidden = document.getElementById('id_marked_answers');
if (!hidden) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'marked_answers';
hidden.id = 'id_marked_answers';
const form = document.querySelector('form.post-form');
if (form) form.appendChild(hidden);
}
hidden.value = JSON.stringify(marked);
}
// Enhance list items with numeric mark controls (2,1,0,Clear)
function attachNumericControls() {
function makeButton(label, cls) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm btn-outline-secondary me-1 mark-btn ' + cls;
b.textContent = label;
return b;
}
document.querySelectorAll('.answer-list li').forEach(function(li) {
if (li.querySelector('.mark-controls')) return; // already attached
const span = li.querySelector('span.answer');
if (!span) return;
// Ensure span has an explicit default state class so logic is consistent
if (!span.classList.contains('correct') && !span.classList.contains('half-correct') && !span.classList.contains('incorrect') && !span.classList.contains('not-marked')) {
span.classList.add('not-marked');
}
const controls = document.createElement('div');
controls.className = 'mark-controls mt-2';
const b2 = makeButton('2', 'mark-2');
const b1 = makeButton('1', 'mark-1');
const b0 = makeButton('0', 'mark-0');
const bClear = makeButton('', 'mark-clear');
controls.appendChild(b2);
controls.appendChild(b1);
controls.appendChild(b0);
controls.appendChild(bClear);
// append after the pre
li.appendChild(controls);
// wire clicks
b2.addEventListener('click', function(){ setMarkForSpan(span, 2); });
b1.addEventListener('click', function(){ setMarkForSpan(span, 1); });
b0.addEventListener('click', function(){ setMarkForSpan(span, 0); });
bClear.addEventListener('click', function(){ setMarkForSpan(span, null); });
// set initial active button based on existing state
(function(){
let state = null;
if (span.classList.contains('correct')) state = 2;
else if (span.classList.contains('half-correct')) state = 1;
else if (span.classList.contains('incorrect')) state = 0;
// clear any pre-existing active flags then set the correct one
[b2, b1, b0, bClear].forEach(b => b.classList.remove('active'));
if (state === 2) b2.classList.add('active');
else if (state === 1) b1.classList.add('active');
else if (state === 0) b0.classList.add('active');
else bClear.classList.add('active');
})();
});
}
function setMarkForSpan(el, numeric) {
// numeric: 2 -> correct, 1 -> half-correct, 0 -> incorrect, null -> not-marked
const li = el.closest('li');
let state = 'not-marked';
if (numeric === 2) state = 'correct';
else if (numeric === 1) state = 'half-correct';
else if (numeric === 0) state = 'incorrect';
el.className = 'answer ' + state;
if (li) {
// clear previous per-state classes
li.classList.remove('marked-correct','marked-half-correct','marked-incorrect','marked-item');
if (state === 'not-marked') {
// nothing
} else if (state === 'correct') {
li.classList.add('marked-item','marked-correct');
} else if (state === 'half-correct') {
li.classList.add('marked-item','marked-half-correct');
} else if (state === 'incorrect') {
li.classList.add('marked-item','marked-incorrect');
}
}
// update active state of numeric controls for this list item
try {
const li = el.closest('li');
if (li) {
const btn2 = li.querySelector('.mark-2');
const btn1 = li.querySelector('.mark-1');
const btn0 = li.querySelector('.mark-0');
const btnClear = li.querySelector('.mark-clear');
[btn2, btn1, btn0, btnClear].forEach(b => { if (b) { b.classList.remove('active'); } });
if (numeric === 2 && btn2) btn2.classList.add('active');
else if (numeric === 1 && btn1) btn1.classList.add('active');
else if (numeric === 0 && btn0) btn0.classList.add('active');
else if (numeric === null && btnClear) btnClear.classList.add('active');
}
} catch (err) {
// ignore
}
updateMarkedAnswersField();
}
// Run once on load to add controls. Also re-run after HTMX swaps so controls
// appear when partials are replaced.
function initMark2() {
attachNumericControls();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMark2);
} else {
initMark2();
}
// If HTMX is used to update parts of the page, re-run attachNumericControls
// after swaps so newly inserted list items get controls.
document.addEventListener('htmx:afterSwap', function(evt) {
initMark2();
});
// Ensure the hidden marked_answers field is updated before any form submit
const postForm = document.querySelector('form.post-form');
if (postForm) {
postForm.addEventListener('submit', function(e){
try { updateMarkedAnswersField(); } catch (err) { /* still submit */ }
});
}
// Copy-to-clipboard handler for dropdown items
document.addEventListener('click', function(e){
const btn = e.target.closest('.copy-to-clipboard');
if (!btn) return;
const text = btn.getAttribute('data-text');
if (!text) return;
try {
navigator.clipboard.writeText(text);
// simple visual feedback: change the button text briefly
const prev = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function(){ btn.textContent = prev; }, 1200);
} catch (err) {
console.warn('copy failed', err);
}
});
})();
</script>
{% endblock %}
@@ -0,0 +1,79 @@
{% extends 'anatomy/exams.html' %}
{% block content %}
<div class="container my-4 anatomy">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
</div>
<div class="text-end">
<div class="btn-group me-2" role="group" aria-label="listing actions">
<button class="btn btn-sm btn-outline-secondary show-all-button">Show all</button>
<button class="btn btn-sm btn-outline-secondary show-unmarked-button">Show unmarked</button>
</div>
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=0 %}" class="btn btn-sm btn-primary">Start mark2</a>
</div>
</div>
<div class="card">
<div class="card-body p-2">
<ul class="list-group list-group-flush" id="question-mark-list">
{% for question, unmarked_count, unmarked_count2 in question_unmarked_map %}
<li class="list-group-item d-flex justify-content-between align-items-start" data-unmarked="{{ unmarked_count }}">
<div class="me-3">
<a href="{% url 'anatomy:mark2' exam_pk=exam.pk sk=forloop.counter0 %}" class="fw-semibold">Question {{ forloop.counter }}:</a>
<div class="small text-truncate" style="max-width:60vw;">{{ question }}</div>
</div>
<div class="text-end">
<div class="small text-muted">Unmarked</div>
{% if unmarked_count > 0 %}
<span class="badge bg-danger">{{ unmarked_count }}</span>
{% else %}
<span class="badge bg-secondary">{{ unmarked_count }}</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item small text-muted">No questions found.</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<script>
(function(){
function $(sel, ctx){ return (ctx||document).querySelector(sel); }
function $all(sel, ctx){ return Array.from((ctx||document).querySelectorAll(sel)); }
var showAllBtn = $('.show-all-button');
var showUnmarkedBtn = $('.show-unmarked-button');
var list = $('#question-mark-list');
if (!list) return;
function showAll(){
$all('#question-mark-list .list-group-item').forEach(function(li){ li.classList.remove('d-none'); });
showAllBtn && showAllBtn.classList.add('active');
showUnmarkedBtn && showUnmarkedBtn.classList.remove('active');
}
function showUnmarked(){
$all('#question-mark-list .list-group-item').forEach(function(li){
var u = parseInt(li.getAttribute('data-unmarked') || '0', 10);
if (u > 0) li.classList.remove('d-none'); else li.classList.add('d-none');
});
showUnmarkedBtn && showUnmarkedBtn.classList.add('active');
showAllBtn && showAllBtn.classList.remove('active');
}
if (showAllBtn) showAllBtn.addEventListener('click', function(e){ e.preventDefault(); showAll(); });
if (showUnmarkedBtn) showUnmarkedBtn.addEventListener('click', function(e){ e.preventDefault(); showUnmarked(); });
// Initialize to show all
showAll();
})();
</script>
<style>
.show-all-button.active, .show-unmarked-button.active { box-shadow: inset 0 -2px 0 rgba(0,0,0,0.08); }
</style>
{% endblock %}
@@ -0,0 +1,114 @@
<div class="card mb-3" hx-on="htmx:afterSwap: initAnatomyFragment(this)">
<div class="card-body">
<p class="lead">{{ question.question_type }}</p>
<div id="anatomy-dicom-image" class="dicom-image-legacy" data-url="{{ question.get_image_url }}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
</div>
<p class="mt-2">
Answer: {{ question.get_primary_answer }}
</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Marked answers</h6>
<div class="d-flex align-items-center gap-2">
<div class="small text-muted">{{ question.answers.all|length }}</div>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#marked-answers-{{ question.pk }}" aria-expanded="false" aria-controls="marked-answers-{{ question.pk }}">Show answers</button>
</div>
</div>
<div class="collapse mt-2" id="marked-answers-{{ question.pk }}">
<ul class="list-group">
{% for answer in question.answers.all|dictsortreversed:"status" %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if answer.status == '2' %} list-group-item-success{% elif answer.status == '1' %} list-group-item-warning{% elif answer.status == '0' %} list-group-item-secondary{% endif %}">
<div>
{% if answer.proposed %}
<small class="text-muted me-2">(proposed)</small>
{% endif %}
<span>{{ answer.answer }}</span>
</div>
<div>
{% if answer.status == '2' %}
<span class="badge bg-success">Correct</span>
{% elif answer.status == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif answer.status == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{# Aggregated summary placeholder — auto-load via HTMX #}
<div class="mt-3">
<h6>Response summary</h6>
<div id="anatomy-response-summary"
hx-get="{% url 'anatomy:exam_review_question_summary' exam.pk q_index %}"
hx-trigger="load"
hx-swap="innerHTML"
hx-indicator="#anatomy-summary-indicator-{{ question.pk }}">
<div id="anatomy-summary-indicator-{{ question.pk }}" style="display:none">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading summary</span>
</div>
<style> #anatomy-summary-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
<div class="small text-muted">Loading summary…</div>
</div>
</div>
{# Responses: load on demand #}
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Responses in this exam</h6>
<div>
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
hx-target="#anatomy-responses-container"
hx-swap="innerHTML"
hx-indicator="#anatomy-responses-indicator-{{ question.pk }}">Show responses</button>
</div>
</div>
<div class="mt-2" id="anatomy-responses-container">
<div id="anatomy-responses-indicator-{{ question.pk }}" style="display:none">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading responses</span>
</div>
<style> #anatomy-responses-indicator-{{ question.pk }}.htmx-request{display:inline-block !important;} </style>
<div class="text-muted small">Responses are hidden — click “Show responses” to load.</div>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,92 @@
{# Responses partial for anatomy question within an exam. HTMX-loadable. #}
<div id="anatomy-responses-container">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h6 class="mb-0">Responses in this exam</h6>
<div class="small text-muted">List of candidate answers recorded for this question in the current exam.</div>
</div>
<div>
{% if not reveal_respondents %}
<button type="button" class="btn btn-sm btn-outline-primary show-respondents-btn"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}?reveal=1"
hx-target="#anatomy-responses-container"
hx-swap="outerHTML"
data-url="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}?reveal=1"
>Show respondents</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary show-respondents-btn"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
hx-target="#anatomy-responses-container"
hx-swap="outerHTML"
data-url="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
>Hide respondents</button>
{% endif %}
</div>
</div>
<ul class="list-group">
{% for item in exam_user_answers %}
{% with ua=item.ua %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if reveal_respondents %}
{% if item.display_name_raw %}
<strong>{{ item.display_name_raw }}</strong>
{% else %}
<strong>{{ ua.get_candidate_masked }}</strong>
{% endif %}
{% else %}
<strong>Respondent {{ forloop.counter }}</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif ua.score == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif ua.score == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endwith %}
{% empty %}
<li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %}
</ul>
</div>
<script>
// Fallback for environments where HTMX isn't active or the hx-* attributes don't trigger.
// Attach a click handler to buttons with class .show-respondents-btn that will fetch
// the fragment URL (from data-url) and replace the container content.
(function(){
try {
var els = document.querySelectorAll('.show-respondents-btn');
els.forEach(function(btn){
btn.addEventListener('click', function(ev){
// If HTMX is present it will handle the request; only run fallback when not handled.
if (window.htmx) return;
ev.preventDefault();
var url = btn.getAttribute('data-url');
if (!url) return;
fetch(url, {credentials: 'same-origin'})
.then(function(r){ if (!r.ok) throw new Error('Network response was not ok'); return r.text(); })
.then(function(html){
var container = document.querySelector('#anatomy-responses-container');
if (container) container.outerHTML = html;
}).catch(function(err){
console.error('Failed to load responses fragment', err);
});
}, {passive: false});
});
} catch (e) {
// ignore
}
})();
</script>
@@ -0,0 +1,103 @@
{# Aggregated response summary partial for anatomy questions (HTMX-loadable) #}
<div id="anatomy-response-summary-content">
{% if exam_response_total %}
<div class="small text-muted mb-2">Total responses: {{ exam_response_total }}</div>
<div class="row mb-2">
<div class="col-auto">Correct:</div>
<div class="col">
{% if exam_response_correct_count is not None %}
<strong>{{ exam_response_correct_count }}</strong>
{% if exam_response_correct_pct is not None %}
<span class="text-muted">({{ exam_response_correct_pct }}%)</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</div>
</div>
<div class="list-group mb-2">
{# Correct (2) #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Correct</div>
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'2'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'2'|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ exam_response_score_pcts.2|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.2|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Half mark (1) #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Half mark</div>
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'1'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'1'|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ exam_response_score_pcts.1|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.1|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Incorrect (0) #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Incorrect</div>
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'0'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'0'|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ exam_response_score_pcts.0|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.0|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Unmarked (empty) #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Unmarked</div>
<div class="text-end"><strong>{{ exam_response_score_counts|get_item:'empty'|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts|get_item:'empty'|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ exam_response_score_pcts.empty|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.empty|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
<div>
{# Collapsible Top submitted answers - closed by default. #}
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-1">Top submitted answers</h6>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#top-answers-{{ q_index }}" aria-expanded="false" aria-controls="top-answers-{{ q_index }}">
Show answers
</button>
</div>
<div class="collapse mt-2" id="top-answers-{{ q_index }}">
{% if top_answers %}
<ul class="list-group">
{% for a in top_answers %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1">{{ a.answer_compare }}</div>
<div class="d-flex align-items-center gap-2">
{% if a.dominant_score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif a.dominant_score == '1' %}
<span class="badge bg-warning text-dark">Half</span>
{% elif a.dominant_score == '0' %}
<span class="badge bg-danger">Incorrect</span>
{% else %}
<span class="badge bg-secondary">Unmarked</span>
{% endif %}
<div class="small text-muted badge bg-primary" title="Submission Count">{{ a.count }}</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted">No submitted answers yet.</div>
{% endif %}
</div>
</div>
{% else %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
{% endif %}
</div>
@@ -0,0 +1,35 @@
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">Edit stored answer</h6>
<form hx-post="{% url 'anatomy:mark2_edit_answer' %}" hx-target="closest .card" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="original" value="{{ original }}">
{% if exam_pk %}
<input type="hidden" name="exam_pk" value="{{ exam_pk }}">
{% endif %}
{% if sk is not None %}
<input type="hidden" name="sk" value="{{ sk }}">
{% endif %}
<div class="mb-2">
<label class="form-label">Original</label>
<pre class="small bg-light p-2">{{ original }}</pre>
</div>
<div class="mb-2">
<label class="form-label">New answer text</label>
<textarea class="form-control" name="new_answer" rows="3">{% if existing %}{{ existing.answer }}{% else %}{{ original }}{% endif %}</textarea>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
{% if exam_pk and sk is not None %}
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'anatomy:mark2_exam_marked' exam_pk sk %}" hx-swap="outerHTML" hx-target="closest .card">Cancel</button>
{% else %}
<button type="button" class="btn btn-outline-secondary btn-sm" _="on click remove .card">Cancel</button>
{% endif %}
</div>
</form>
</div>
</div>
@@ -0,0 +1,82 @@
{% comment %}List of distinct answers submitted within this exam for the question, with counts and mark badges{% endcomment %}
<div class="card mt-2">
<div class="card-body">
<style>
/* Hide edit buttons and mark-action forms by default; show only when card has editing-enabled */
.card .edit-btn { display: none !important; }
.card.editing-enabled .edit-btn { display: inline-block !important; }
.card .mark-actions { display: none !important; }
.card.editing-enabled .mark-actions { display: inline-block !important; }
</style>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="card-title mb-0">Answers submitted in this exam</h6>
<div class="d-flex gap-2 align-items-center">
<div class="btn-group btn-group-sm" role="group" aria-label="Order">
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'count' or not request.GET.order %} active{% endif %}" hx-get="{{ request.path }}?order=count" hx-swap="outerHTML" hx-target="closest .card">By count</button>
<button class="btn btn-sm btn-outline-primary{% if request.GET.order == 'mark' %} active{% endif %}" hx-get="{{ request.path }}?order=mark" hx-swap="outerHTML" hx-target="closest .card">By score</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="this.closest('.card').classList.toggle('editing-enabled'); this.textContent = this.closest('.card').classList.contains('editing-enabled') ? 'Disable editing' : 'Enable editing'">Enable editing</button>
</div>
</div>
{% if not items %}
<div class="small text-muted">No answers submitted in this exam.</div>
{% else %}
<ul class="list-group">
{% for it in items %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1 me-3"><pre class="mb-0">{{ it.answer }}</pre></div>
<div class="text-end d-flex align-items-center gap-2">
{# Stored mark badge #}
{% if it.mark == 'correct' %}
<span class="badge bg-success me-1" title="Stored answer marked as correct (2)">2</span>
{% elif it.mark == 'half-correct' %}
<span class="badge bg-warning text-dark me-1" title="Stored answer marked as half-correct (1)">1</span>
{% elif it.mark == 'incorrect' %}
<span class="badge bg-danger me-1" title="Stored answer marked as incorrect (0)">0</span>
{% else %}
<span class="badge bg-secondary me-1" title="No stored mark for this answer">-</span>
{% endif %}
{# Submission count badge #}
<span class="badge bg-light text-dark me-2" title="Submitted {{ it.count }} times">{{ it.count }}</span>
<form hx-post="{% url 'anatomy:mark2_toggle_answer' %}" hx-target="closest .card" hx-swap="outerHTML" class="d-inline mark-actions">
{% csrf_token %}
<input type="hidden" name="question_pk" value="{{ question.pk }}">
<input type="hidden" name="answer" value="{{ it.answer }}">
<input type="hidden" name="exam_pk" value="{{ exam.pk }}">
<input type="hidden" name="sk" value="{{ sk }}">
<button class="btn btn-sm btn-outline-success" name="newmark" value="correct" title="Mark as correct (2)">2</button>
<button class="btn btn-sm btn-outline-warning text-dark" name="newmark" value="half-correct" title="Mark as half-correct (1)">1</button>
<button class="btn btn-sm btn-outline-danger" name="newmark" value="incorrect" title="Mark as incorrect (0)">0</button>
<button class="btn btn-sm btn-outline-secondary" name="newmark" value="clear" title="Clear stored mark"></button>
</form>
<button class="btn btn-sm btn-outline-secondary edit-btn" hx-get="{% url 'anatomy:mark2_edit_answer' %}?question_pk={{ question.pk }}&original={{ it.answer|urlencode }}&exam_pk={{ exam.pk }}&sk={{ sk }}" hx-target="closest .card" hx-swap="outerHTML" title="Edit stored answer">Edit</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<script>
// Keep Edit buttons hidden by default; reveal them when the card receives
// the 'editing-enabled' class (toggled by the Enable editing button).
(function(){
document.querySelectorAll('.card').forEach(function(card){
function update(){
const enable = card.classList.contains('editing-enabled');
card.querySelectorAll('.edit-btn').forEach(function(b){
b.classList.toggle('d-none', !enable);
});
}
// run once in case HTMX inserted the fragment
update();
// observe class changes on the card to re-run update
const obs = new MutationObserver(update);
obs.observe(card, { attributes: true, attributeFilter: ['class'] });
});
})();
</script>
@@ -0,0 +1,43 @@
{% comment %}Renders only the list items for already-marked answers (used as a partial){% endcomment %}
{% for answer in correct_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer correct" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
{% for answer in half_mark_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer half-correct" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
{% for answer in incorrect_answers %}
{% with answer_url=answer|urlencode:'' %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
@@ -0,0 +1,43 @@
{% comment %}All stored Answer choices for this question grouped by status{% endcomment %}
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">All stored answers for this question</h6>
<style>
/* Allow stored answers to wrap long lines and break words so they are visible
in narrow columns. Also ensure the lists stack on small screens. */
.card .list-group-item pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
</style>
<div class="row">
<div class="col-12 col-lg-4">
<h6>2 marks</h6>
<ul class="list-group mb-2">
{% for answer in correct_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-lg-4">
<h6>1 mark</h6>
<ul class="list-group mb-2">
{% for answer in half_mark_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
<div class="col-12 col-lg-4">
<h6>0 marks</h6>
<ul class="list-group mb-2">
{% for answer in incorrect_answers %}
<li class="list-group-item"><pre class="mb-0">{{ answer.answer }}</pre></li>
{% empty %}
<li class="list-group-item small text-muted">None</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
@@ -0,0 +1,28 @@
{% comment %}Renders only the list items for unmarked answers (used as a partial){% endcomment %}
{% for answer in user_answers %}
{% with answer_url=answer|urlencode:'' %}
{% if answer in answer_suggest_incorrect %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer incorrect suggest" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% else %}
<li class="list-group-item d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<pre class="mb-0 me-3 flex-grow-1"><span class="answer not-marked" title="{{ answer }}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}">{{ answer }}</span></pre>
<div class="answer-actions text-end">
<a class="action-icon" href="https://www.google.com/search?q={{ answer_url }}" target="_blank" title="Search Google">🔍</a>
<button type="button" class="action-icon btn btn-link p-0 copy-to-clipboard" data-text="{{ answer|escapejs }}" title="Copy to clipboard">📋</button>
<a class="action-icon" href="{% url 'anatomy:question_user_answers_by_compare' question.pk answer_url %}" target="_blank" title="View answers">🔗</a>
</div>
</div>
</li>
{% endif %}
{% endwith %}
{% endfor %}
@@ -0,0 +1,16 @@
<div id="incorrect-answers" class="small text-muted">
<strong>Answer suggest incorrect:</strong>
<div class="mt-2">
{% if question.answer_suggest_incorrect %}
{% for s in question.answer_suggest_incorrect %}
<span class="badge bg-secondary me-1 mb-1">{{ s }}</span>
{% endfor %}
{% else %}
<span class="small text-muted">No suggestions</span>
{% endif %}
</div>
<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>
</div>
@@ -0,0 +1,48 @@
<div class="card mt-2">
<div class="card-body">
<h6 class="card-title">Suggest incorrect answers</h6>
<form hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="outerHTML">
{% csrf_token %}
<div class="mb-2">
<label class="form-label">Current suggestions</label>
<div class="mb-2">
{% for s in question.answer_suggest_incorrect %}
<button type="button" class="btn btn-sm btn-outline-secondary me-1 mb-1" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"remove": "{{ s|escapejs }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-target="closest .card" hx-swap="outerHTML">{{ s }} <span class="text-danger ms-1">&times;</span></button>
{% empty %}
<div class="small text-muted">No suggestions</div>
{% endfor %}
</div>
<div class="d-flex gap-2 align-items-center">
<input id="new-suggest" name="suggestion" class="form-control form-control-sm" placeholder="Add suggestion" />
<button type="button" class="btn btn-sm btn-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-include="#new-suggest" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<button type="submit" name="close" value="1" class="btn btn-primary btn-sm">Close</button>
</div>
</form>
{% if candidates %}
<hr/>
<h6 class="mt-3">Suggested candidates</h6>
<div class="small text-muted mb-2">These are frequent submitted answers that might be suitable to treat as incorrect suggestions.</div>
<ul class="list-group">
{% for c in candidates %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="flex-grow-1"><pre class="mb-0">{{ c.answer }}</pre></div>
<div class="text-end">
<span class="badge bg-light text-dark me-2">{{ c.count }}</span>
<button class="btn btn-sm btn-outline-primary" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-vals='{"suggestion": "{{ c.answer|escapejs }}", "context": "fragment"}' hx-target="closest .card" hx-swap="outerHTML">Add</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<template class="incorrect-answers-original" hidden>
<strong>Answer suggest incorrect:</strong> {{ question.answer_suggest_incorrect }}
<div class="mt-1"><button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button></div>
</template>
+221 -108
View File
@@ -3,116 +3,136 @@
{% block content %}
{% load static %}
{% include 'anatomy/question_link_header.html' %}
<div id="dicom-image" class="dicom-image" data-url="{{ remote_url }}{{ question.image.url}}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. To save the changes click the "Save Annotations" button.</p>
<p>There is no limit to the number of arrows that can be added to the image.</p>
</details>
<button id="save-annotations">Save Annotations</button>
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-body">
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=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>
</div>
<div class="question">
<div class="date">
Created: {{ question.created_date|date:"d/m/Y" }}
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
</div>
</div>
</div>
</div>
<h2>Question type: {{question.question_type}}</h2>
<h3>Primary answer: {{ question.get_primary_answer }}</h3>
<div>
<details>
<summary title="Click to view the question answers">
Answers:
</summary>
<table>
<tr><th>Answer</th><th>Score</th></tr>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
{% endif %}>
<span >
{{ answer }}
</span>
<td>
<td>{{answer.status}}</td>
{% if answer.proposed %}
<td>
<button class="btn btn-sm accept-button" data-aid={{answer.id}}
hx-get="{% url 'anatomy:confirm_answer' answer.id %}"
title="Click to accept the proposed answer">Accept</button>
<button class="btn btn-sm delete-button" data-aid={{answer.id}}
hx-get="{% url 'anatomy:delete_answer' answer.id %}"
title="Click to delete the proposed answer"
>Delete</button>
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-1">Question</h5>
<div class="text-muted">Created: {{ question.created_date|date:"d/m/Y" }}</div>
</div>
<div class="text-end">
<h6 class="mb-0">Type: <span class="badge bg-secondary">{{ question.question_type }}</span></h6>
</div>
</div>
<h6 class="mt-2">Primary answer</h6>
<div class="mb-2"><strong>{{ question.get_primary_answer }}</strong></div>
<h6>Answers</h6>
<details class="mb-3">
<summary class="small">Show answers</summary>
<table class="table table-sm mt-2">
<thead>
<tr><th>Answer</th><th>Score</th><th></th></tr>
</thead>
<tbody>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"{% endif %}>
{% if answer.status == "0" %}
<div class="d-flex align-items-start">
<button type="button" class="btn btn-sm btn-link text-muted p-0 toggle-words-btn me-2" title="Suggest words: show candidate words extracted from this stored answer (click to quick-add)" aria-expanded="false" aria-label="Suggest words"><i class="bi bi-lightbulb-fill"></i></button>
<div class="flex-grow-1">
<pre class="mb-0">{{ answer.answer }}</pre>
<div class="mt-1">
<div class="word-buttons d-none mt-2" aria-hidden="true"></div>
</div>
</div>
</div>
{% else %}
<pre class="mb-0">{{ answer.answer }}</pre>
{% endif %}
</td>
{% endif %}
<td>{{ answer.get_status_display|default:answer.status }}</td>
<td>
{% if answer.proposed %}
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success accept-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:confirm_answer' answer.id %}" title="Accept">Accept</button>
<button class="btn btn-outline-danger delete-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:delete_answer' answer.id %}" title="Delete">Delete</button>
</div>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="small text-muted">No stored answers</td></tr>
{% endfor %}
</tbody>
</table>
</details>
</tr>
{% endfor %}
</table>
</details>
</div>
<div>
Answer help: {{ question.answer_help|safe }}
</div>
<div>
{% partialdef suggest-incorrect-form inline %}
<span id="incorrect-answers">
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
<button id="answer-suggest-incorrect-button" class="btn btn-sm" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}"
hx-target="#incorrect-answers"
hx-swap="innerHTML">Edit suggestions</button>
</span>
{% endpartialdef %}
</div>
<div>
Description: {{ question.description }}
</div>
<div>
Exams: {% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a>
{% endfor %}
<button class="btn btn-sm" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}"
hx-target="#exam-list"
hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span>
</div>
<div>
Modality: {{ question.modality }}
</div>
<div>
Region: {{ question.region }}
</div>
<div>
Structure: {{ question.structure }}
</div>
<div>
Body Part: {{ question.body_part }}
</div>
<div>
Author: {% for author in question.author.all %}
{{ author }},
{% endfor %}
</div>
<div>
Open access: {{ question.open_access }}
</div>
{% include 'question_notes.html' %}
<div>
<details>
<summary>
<span id="annotation-json-toggle">Annotation JSON (+/-):</span> <span id="annotation-json-content">
</summary>
{% if question.image_annotations %}
<pre>{{ question.image_annotations }}</pre>
{% else %}
No saved annotations.
{% if question.answer_help %}
<div class="mb-3">
<h6>Answer help</h6>
<div class="small text-muted">{{ question.answer_help|safe }}</div>
</div>
{% endif %}
</span>
</details>
<div class="mb-2">
{% include 'anatomy/partials/suggest_incorrect_collapsed.html' %}
</div>
<div class="mb-2">
<h6>Description</h6>
<div class="small text-muted">{{ question.description }}</div>
</div>
<div class="row">
<div class="col-12 col-md-6">
<div class="mb-1"><strong>Modality:</strong> {{ question.modality }}</div>
<div class="mb-1"><strong>Region:</strong> {{ question.region }}</div>
<div class="mb-1"><strong>Structure:</strong> {{ question.structure }}</div>
</div>
<div class="col-12 col-md-6">
<div class="mb-1"><strong>Body part:</strong> {{ question.body_part }}</div>
<div class="mb-1"><strong>Open access:</strong> {{ question.open_access }}</div>
<div class="mb-1"><strong>Author:</strong> {% for author in question.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
</div>
</div>
<div class="mt-3">
<h6>Exams</h6>
<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>
</div>
{% include 'question_notes.html' %}
<div class="mt-3">
<details>
<summary class="small">Annotation JSON</summary>
<div class="mt-2 small">{% if question.image_annotations %}<pre class="mb-0">{{ question.image_annotations }}</pre>{% else %}No saved annotations.{% endif %}</div>
</details>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
@@ -158,7 +178,7 @@
function getJsonToolStateNoId() {
const el = document.getElementById("dicom-image");
const el = document.getElementById("anatomy-dicom-image");
const c = cornerstone.getEnabledElement(el);
@@ -174,6 +194,99 @@
}
</script>
<script>
// HTMX quick-add helpers for word buttons inside the answers table
var SUGGEST_URL = "{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}";
var CSRF_TOKEN = "{{ csrf_token }}";
function toggleWords(btn) {
var container = btn.nextElementSibling; // the flex-grow-1 wrapper
if (!container) return;
var wordContainer = container.querySelector('.word-buttons');
if (!wordContainer) return;
// populate if empty
if (!wordContainer.dataset.populated) {
var pre = container.querySelector('pre');
var text = pre ? pre.textContent || pre.innerText : '';
buildWordButtons(wordContainer, text);
wordContainer.dataset.populated = '1';
}
var expanded = !wordContainer.classList.contains('d-none');
if (expanded) {
wordContainer.classList.add('d-none');
btn.setAttribute('aria-expanded', 'false');
wordContainer.setAttribute('aria-hidden', 'true');
} else {
wordContainer.classList.remove('d-none');
btn.setAttribute('aria-expanded', 'true');
wordContainer.setAttribute('aria-hidden', 'false');
}
}
function buildWordButtons(container, text) {
if (!text) return;
var toks = (text.toLowerCase().match(/\w+/g) || []);
var seen = {};
toks.forEach(function(t){
if (t.length < 3) return;
if (seen[t]) return;
seen[t]=true;
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-sm btn-outline-primary me-1 mb-1';
b.textContent = t;
b.dataset.word = t;
// do not attach inline handlers here; use delegated listener
container.appendChild(b);
});
if (!Object.keys(seen).length) {
var span = document.createElement('div');
span.className = 'small text-muted';
span.textContent = 'No suitable words';
container.appendChild(span);
}
}
// Delegated click handler to keep markup unobtrusive and work for HTMX swaps
document.addEventListener('click', function(e){
var toggle = e.target.closest('.toggle-words-btn');
if (toggle) {
e.preventDefault();
toggleWords(toggle);
return;
}
var wordBtn = e.target.closest('.word-buttons button');
if (wordBtn) {
e.preventDefault();
var word = wordBtn.dataset.word || wordBtn.textContent.trim();
if (word) addQuickSuggestion(word);
return;
}
});
function addQuickSuggestion(word) {
if (!word) return;
if (typeof htmx === 'undefined') {
// Fallback: simple POST via fetch
fetch(SUGGEST_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': CSRF_TOKEN },
body: new URLSearchParams({ suggestion: word, quick: '1' })
}).then(function(r){ return r.text(); }).then(function(html){
var wrapper = document.getElementById('incorrect-answers');
if (wrapper) wrapper.innerHTML = html;
});
return;
}
htmx.ajax('POST', SUGGEST_URL, {
values: { suggestion: word, quick: '1' },
headers: { 'X-CSRFToken': CSRF_TOKEN },
target: '#incorrect-answers',
swap: 'innerHTML'
});
}
</script>
{% endblock %}
{% block css %}
@@ -182,9 +295,9 @@
content: "\F505";
font-family: bootstrap-icons;
}
/* Ensure pre-wrapped content inside cards and tables so long answers don't overflow */
.card pre, .table pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
.question div {
margin-bottom: 10px;
}
.card .mb-2, .card .mb-3 { margin-bottom: .75rem; }
</style>
{% endblock css %}
@@ -1,28 +1,95 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Question:</strong>
{% if question.stem %}
{{ question.stem|striptags|truncatechars:80 }}
{% else %}
#{{ question.pk }}
{% endif %}
</span>
<div class="floating-header">
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
<a href="{% url 'anatomy:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
<a href="{% url 'anatomy:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a>
<a href="#"
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
Add Note</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{% if request.user.is_superuser %}
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}"
title="Edit the Question using the admin interface">Admin Edit</a>
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
{% endif %}
{% if exam %}
<div>
{% if previous > -1 %}
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
<div class="collapse navbar-collapse" id="questionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if exam %}
<li class="nav-item">
{% if previous > -1 %}
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous</a>
{% endif %}
</li>
<li class="nav-item align-self-center">
Viewing as part of exam: <a class="nav-link d-inline p-0" href="{% url 'anatomy:exam_overview' exam.id %}">{{ exam }}</a>
<span class="navbar-text text-muted ms-2">[{{ pos }}/{{ exam_length }}]</span>
</li>
<li class="nav-item">
{% if next %}
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next</a>
{% endif %}
</li>
{% endif %}
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
{% if next %}
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
{% endif %}
</div>
{% endif %}
</div>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="questionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="questionActions">
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:question_detail' question.id %}">
<i class="bi bi-eye me-2"></i> View
</a>
</li>
<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>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_clone' question.id %}">
<i class="bi bi-files me-2"></i> Clone
</a>
</li>
<li>
<a class="dropdown-item text-info" href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
<i class="bi bi-chat-left-text me-2"></i> Add Note
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="{% url 'anatomy:question_delete' pk=question.pk %}">
<i class="bi bi-trash me-2"></i> Delete
</a>
</li>
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
<i class="bi bi-tools me-2"></i> Admin Edit
</a>
</li>
<li>
<a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
<i class="bi bi-people me-2"></i> User answers
</a>
</li>
{% endif %}
</ul>
</li>
</ul>
</div>
</div>
</nav>
+22
View File
@@ -38,6 +38,11 @@ urlpatterns = [
views.question_suggest_incorrect_answers,
name="question_suggest_incorrect_answers",
),
path(
"question/<int:pk>/suggest_incorrect_collapsed",
views.question_suggest_incorrect_collapsed,
name="question_suggest_incorrect_collapsed",
),
path("question/<int:pk>/answer/", views.answer_question, name="answer_question"),
path(
@@ -92,6 +97,23 @@ urlpatterns = [
views.UserAnswerDelete.as_view(),
name="user_answer_delete",
),
path(
"exam/<int:pk>/review/<int:q_index>/responses",
views.exam_review_question_responses,
name="exam_review_question_responses",
),
path(
"exam/<int:pk>/review/<int:q_index>/summary",
views.exam_review_question_summary,
name="exam_review_question_summary",
),
# New improved marking UI (mark2) and small toggle endpoint used by JS/HTMX
path("exam/<int:exam_pk>/<int:sk>/mark2", views.mark2, name="mark2"),
path("exam/<int:pk>/mark2", views.mark2_overview, name="mark2_overview"),
path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"),
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
]
urlpatterns.extend(generic_view_urls(views.GenericViews))
+808 -2
View File
@@ -15,6 +15,7 @@ from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic import ListView
from django.db.models.functions import Lower
from django.db.models import Prefetch
from django.core.cache import cache
@@ -80,6 +81,7 @@ from helpers.images import image_as_base64
from django.template.defaulttags import register
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from django.db.models import Count
class AuthorOrCheckerRequiredMixin(object):
@@ -94,9 +96,43 @@ class AuthorOrCheckerRequiredMixin(object):
@register.filter
def get_item(dictionary, key, default=None):
"""Safe template filter to fetch an item by key/index from various containers.
Supports dict-like objects (uses .get), lists/tuples (indexing by integer
keys), and other indexable objects. Returns ``default`` when the lookup
fails or the input is None.
"""
if dictionary is None:
return default
return dictionary.get(key)
# If it's dict-like, prefer .get
try:
if hasattr(dictionary, "get") and callable(getattr(dictionary, "get")):
# Allow .get default behaviour
try:
return dictionary.get(key, default)
except Exception:
# Fall through to other strategies
pass
# If it's a list/tuple and key is integer-like, return by index
if isinstance(dictionary, (list, tuple)):
try:
idx = int(key)
except Exception:
return default
try:
return dictionary[idx]
except Exception:
return default
# Generic attempt: try __getitem__ with key (handles QueryDict, lists with int keys, etc.)
try:
return dictionary[key]
except Exception:
return default
except Exception:
return default
def user_is_admin(user):
@@ -412,6 +448,604 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
)
@login_required
def exam_review_question_responses(request, pk: int, q_index: int):
"""Return the responses partial for a given anatomy exam question (HTMX-friendly)."""
exam = get_object_or_404(Exam, pk=pk)
questions = exam.get_questions()
try:
question = questions[q_index]
except Exception:
raise Http404("Question not found in exam")
try:
exam_user_answers_qs = question.cid_user_answers.filter(exam=exam).select_related("user")
except Exception:
exam_user_answers_qs = question.cid_user_answers.all()
# Allow an HTMX-driven reveal of respondent names. Default: anonymised.
reveal = request.GET.get("reveal", "0")
reveal_respondents = str(reveal).lower() in ("1", "true", "yes", "on")
# Build a small list that includes a sensible display name for each respondent.
# If the answer is from a regular user use their full name/username. If it's a CID
# candidate try to resolve the CidUser and show CID + name if available. If neither
# is present fall back to empty/anonymous.
# Optimize CID lookups: collect distinct CID values and fetch all CidUser rows
# in a single query to avoid an N+1 query pattern.
cids_qs = exam_user_answers_qs.values_list("cid", flat=True).distinct()
cids = [c for c in cids_qs if c is not None and str(c).strip() != ""]
cid_map = {}
if cids:
for cu in CidUser.objects.filter(cid__in=cids):
cid_map[cu.cid] = cu
exam_user_answers = []
for ua in exam_user_answers_qs:
# default display
display = None
is_cid = False
try:
if getattr(ua, "user", None):
user = ua.user
# prefer full name where available
full = getattr(user, "get_full_name", None)
if full:
name = user.get_full_name() or user.username
else:
name = getattr(user, "username", str(user))
display = name
elif getattr(ua, "cid", None) is not None:
cid_val = ua.cid
cu = cid_map.get(cid_val)
if cu is not None and cu.name:
display = f"CID{cid_val} ({cu.name})"
else:
display = f"CID{cid_val}"
is_cid = True
except Exception:
display = None
exam_user_answers.append({"ua": ua, "display_name_raw": display, "is_cid": is_cid})
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "anatomy",
"exam_user_answers": exam_user_answers,
"reveal_respondents": reveal_respondents,
}
return render(request, "anatomy/partials/exam_review_question_responses_fragment.html", context)
@login_required
def exam_review_question_summary(request, pk: int, q_index: int):
"""Return aggregated response summary partial for anatomy question (HTMX-friendly)."""
exam = get_object_or_404(Exam, pk=pk)
questions = exam.get_questions()
try:
question = questions[q_index]
except Exception:
raise Http404("Question not found in exam")
ua_qs = question.cid_user_answers.filter(exam=exam)
total_responses = ua_qs.count()
# counts by score values ('2','1','0','')
score_counts = {}
for s, c in ua_qs.values_list("score").annotate(count=Count("id")) if False else []:
pass
# simpler: compute via values and normalise unmarked -> 'empty'
def _norm_score_key(v):
# Treat None or empty-string scores as the 'empty' bucket
if v is None:
return "empty"
if isinstance(v, str) and v.strip() == "":
return "empty"
return str(v)
score_counts = {_norm_score_key(row["score"]): row["count"] for row in ua_qs.values("score").annotate(count=Count("id"))}
# top submitted answers (by answer_compare) with dominant score per answer
# First, get counts grouped by answer_compare and score
grouped = ua_qs.values("answer_compare", "score").annotate(count=Count("id"))
# Aggregate totals and score breakdown per answer_compare
answer_map = {}
for row in grouped:
ans = row["answer_compare"]
sc = _norm_score_key(row["score"])
cnt = row["count"]
if ans not in answer_map:
answer_map[ans] = {"total": 0, "score_counts": {}}
answer_map[ans]["total"] += cnt
answer_map[ans]["score_counts"][sc] = answer_map[ans]["score_counts"].get(sc, 0) + cnt
# Build list sorted by total count desc, and choose a dominant score per answer
top_answers = []
for ans, meta in answer_map.items():
# pick dominant score by highest count; tie-break prefer higher numeric score
ans_score_counts = meta["score_counts"]
# sort by (count desc, score desc) where 'empty' maps to -1
def score_key(item):
s, c = item
# Map 'empty' (or any non-numeric key) to a low value so numeric scores win ties
if s == "empty":
s_val = -1
else:
try:
s_val = int(s)
except Exception:
s_val = -1
return (c, s_val)
dominant = max(ans_score_counts.items(), key=score_key)[0]
top_answers.append({"answer_compare": ans, "count": meta["total"], "dominant_score": dominant})
top_answers.sort(key=lambda x: x["count"], reverse=True)
top_answers = top_answers[:10]
correct_count = None
correct_pct = None
# compute correct based on matching marked answers if available
if total_responses:
# count scores equal to '2' as correct
correct_count = score_counts.get("2", 0)
correct_pct = round(100.0 * correct_count / total_responses, 1) if total_responses else None
# compute percentage distribution for score buckets for the summary bar chart
score_keys = ["2", "1", "0", "empty"]
score_pcts = {}
if total_responses:
for k in score_keys:
score_pcts[k] = round(100.0 * score_counts.get(k, 0) / total_responses, 1)
else:
for k in score_keys:
score_pcts[k] = 0.0
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "anatomy",
"exam_response_total": total_responses,
"exam_response_score_counts": score_counts,
"exam_response_score_pcts": score_pcts,
"exam_response_correct_count": correct_count,
"exam_response_correct_pct": correct_pct,
"top_answers": top_answers,
}
return render(request, "anatomy/partials/exam_review_question_summary_fragment.html", context)
@login_required
def mark2(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
"""Improved marking UI (mark2).
Same core behaviour as `mark` but renders a modern, HTMX-friendly template
and exposes a small toggle endpoint for quick per-answer marking.
"""
exam = get_object_or_404(Exam, pk=exam_pk)
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
mark_url = "anatomy:mark2"
if review:
mark_url = "anatomy:mark2"
questions = exam.get_questions().prefetch_related("answers")
n = sk
question_details = {
"total": len(questions),
"current": n + 1,
}
try:
question: AnatomyQuestion = questions[sk]
except IndexError:
raise Http404("Exam question does not exist")
# For consistency with the existing mark view we accept a standard POST form
# but the preferred workflow is immediate HTMX/ajax toggles via mark2_toggle_answer.
if request.method == "POST":
form = MarkAnatomyQuestionForm(request.POST)
if form.is_valid():
if "skip" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
cd = form.cleaned_data
marked_answers = json.loads(cd.get("marked_answers"))
to_run = (
("correct", Answer.MarkOptions.CORRECT),
("half-correct", Answer.MarkOptions.HALF_MARK),
("incorrect", Answer.MarkOptions.INCORRECT),
)
for status_string, status in to_run:
for ans in marked_answers[status_string]:
ans = ans.strip()
if ans == "":
continue
a = Answer.objects.filter(answer__iexact=ans, question_id=question.pk).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = ans
a.status = status
a.save()
if "next" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
elif "previous" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n - 1)
else:
form = MarkAnatomyQuestionForm()
# Reuse existing collections for display
correct_answers = []
half_mark_answers = []
incorrect_answers = []
review_user_answers = []
unmarked_answers_bool = False
unmarked_user_answers = []
if review:
unmarked_user_answers = question.get_user_answers(exam_pk=exam_pk, include_normal=False)
elif unmarked_exam_answers_only:
unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk)
else:
unmarked_user_answers = question.get_unmarked_user_answers()
if not unmarked_exam_answers_only:
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
answer_suggest_incorrect: list = []
if review:
for ans in unmarked_user_answers:
marked_ans = question.answers.filter(answer_compare__iexact=ans).first()
mark = "unmarked"
if marked_ans is not None:
if marked_ans.status == Answer.MarkOptions.CORRECT:
mark = "correct"
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
mark = "half-correct"
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
mark = "incorrect"
if mark == "unmarked":
unmarked_answers_bool = True
review_user_answers.append((ans, mark))
else:
for ans in unmarked_user_answers:
for suggest_incorrect in question.answer_suggest_incorrect:
if suggest_incorrect in ans:
answer_suggest_incorrect.append(ans)
break
words_present: set = set()
words_present_in_correct: set = set()
for ans in correct_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_present_in_correct.update(answer_words)
for ans in half_mark_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_present_in_correct.update(answer_words)
for ans in incorrect_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_suggest_incorrect = words_present - words_present_in_correct - set(question.answer_suggest_incorrect)
context = {
"exam": exam,
"form": form,
"review": review,
"question": question,
"question_number": n,
"question_details": question_details,
"unmarked_answers_bool": unmarked_answers_bool,
"user_answers": unmarked_user_answers,
"review_user_answers": review_user_answers,
"correct_answers": correct_answers,
"half_mark_answers": half_mark_answers,
"incorrect_answers": incorrect_answers,
"unmarked_exam_answers_only": unmarked_exam_answers_only,
"answer_suggest_incorrect": answer_suggest_incorrect,
"words_suggest_incorrect": words_suggest_incorrect,
}
return render(request, "anatomy/mark2.html", context)
@login_required
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 exam.exam_mode:
raise Http404("Packet not in exam mode")
# Only allow authors or designated markers
if request.user not in exam.author.all():
if not GenericExamViews.check_user_marker_access(request.user, pk):
raise PermissionDenied
# For anatomy prefer prefetch similar to generic.mark_overview
questions = (
exam.exam_questions.select_related(
"modality",
"structure",
"body_part",
"region",
"examination",
"question_type",
)
.all()
.prefetch_related(
Prefetch(
"answers",
queryset=Answer.objects.filter(proposed=False, status=Answer.MarkOptions.CORRECT),
to_attr="prefetched_primary_answer",
),
)
.order_by("examquestiondetail__sort_order")
)
# Handle double marking which needs per-marker counts
try:
if exam.double_mark:
question_unmarked_map = []
for q in questions:
question_unmarked_map.append(
(
q,
int(q.get_unmarked_user_answer_count(exam_pk=exam.pk)),
int(q.get_unmarked_user_answer_count(exam_pk=exam.pk, marker=request.user)),
)
)
return render(request, "anatomy/mark2_overview.html", {"exam": exam, "question_unmarked_map": question_unmarked_map})
except AttributeError:
pass
question_unmarked_map = []
for q in questions:
count = int(q.get_unmarked_user_answer_count(exam_pk=exam.pk))
question_unmarked_map.append((q, count, count))
return render(request, "anatomy/mark2_overview.html", {"exam": exam, "question_unmarked_map": question_unmarked_map})
@login_required
def mark2_toggle_answer(request):
"""Endpoint to toggle/set a single answer mark quickly.
Expects POST with form fields: question_pk, answer (text), newmark ("correct"|"half-correct"|"incorrect").
Returns JSON status.
"""
if request.method != "POST":
return JsonResponse({"status": "error", "message": "POST required"}, status=400)
question_pk = request.POST.get("question_pk") or request.POST.get("question")
answer_text = request.POST.get("answer")
newmark = request.POST.get("newmark")
if not question_pk or not answer_text or not newmark:
return JsonResponse({"status": "error", "message": "missing parameters"}, status=400)
try:
question = AnatomyQuestion.objects.get(pk=question_pk)
except AnatomyQuestion.DoesNotExist:
return JsonResponse({"status": "error", "message": "question not found"}, status=404)
# permission checks: ensure user can mark the exam(s) this question belongs to
# We can't easily infer exam_pk here, but ask GenericExamViews.check_user_marker_access
# using any exam that contains this question is adequate if present.
exams = list(question.exams.all())
if exams:
exam_pk = exams[0].pk
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
# Map mark strings to model constants
mark_map = {
"correct": Answer.MarkOptions.CORRECT,
"half-correct": Answer.MarkOptions.HALF_MARK,
"incorrect": Answer.MarkOptions.INCORRECT,
}
# Support clearing a stored answer (delete) via 'clear'
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if newmark == 'clear':
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is not None:
# permission: ensure user can edit this answer
if not a.can_edit(request.user):
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
a.delete()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
if newmark not in mark_map:
return JsonResponse({"status": "error", "message": "invalid mark"}, status=400)
status_val = mark_map[newmark]
# Find existing Answer by answer_compare or create
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = answer_text
a.status = status_val
a.save()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
@login_required
def mark2_exam_marked(request, exam_pk, sk):
"""Return a partial listing of answers submitted for this question within the given exam.
Shows distinct submitted answers with counts and any existing mark/status.
"""
exam = get_object_or_404(Exam, pk=exam_pk)
questions = exam.get_questions()
try:
question = questions[sk]
except Exception:
raise Http404("Question not found in exam")
ua_qs = question.cid_user_answers.filter(exam=exam)
# Allow client to request ordering by 'count' (default) or by 'mark' (badge)
order = request.GET.get('order', 'count')
# Group distinct submitted answers and count occurrences
grouped = ua_qs.values('answer_compare').annotate(count=Count('id'))
items = []
for row in grouped:
ans = row['answer_compare']
cnt = row['count']
a = question.answers.filter(answer_compare__iexact=ans).first()
mark = None
if a is not None:
if a.status == Answer.MarkOptions.CORRECT:
mark = 'correct'
elif a.status == Answer.MarkOptions.HALF_MARK:
mark = 'half-correct'
elif a.status == Answer.MarkOptions.INCORRECT:
mark = 'incorrect'
items.append({'answer': ans, 'count': cnt, 'mark': mark})
# Sort items according to requested ordering
if order == 'mark':
weight = {'correct': 3, 'half-correct': 2, 'incorrect': 1, None: 0}
items.sort(key=lambda x: (weight.get(x.get('mark')), x.get('count', 0)), reverse=True)
else:
items.sort(key=lambda x: x.get('count', 0), reverse=True)
context = {'exam': exam, 'question': question, 'items': items, 'sk': sk}
return render(request, 'anatomy/partials/mark2_exam_marked_fragment.html', context)
@login_required
def mark2_edit_answer(request):
"""HTMX-friendly endpoint to edit/create a stored Answer for a question.
GET: returns a small form fragment.
POST: updates/creates the Answer and, if exam_pk/sk provided, returns the
refreshed exam-marked fragment so the UI updates in-place.
"""
if request.method == 'GET' and request.htmx:
question_pk = request.GET.get('question_pk')
original = request.GET.get('original', '')
exam_pk = request.GET.get('exam_pk')
sk = request.GET.get('sk')
question = get_object_or_404(AnatomyQuestion, pk=question_pk)
# Try to find an existing Answer by compare
existing = question.answers.filter(answer_compare__iexact=original).first()
context = {
'question': question,
'original': original,
'existing': existing,
'exam_pk': exam_pk,
'sk': sk,
}
return render(request, 'anatomy/partials/mark2_edit_answer_fragment.html', context)
if request.method == 'POST' and request.htmx:
question_pk = request.POST.get('question_pk')
original = request.POST.get('original', '')
new_text = request.POST.get('new_answer', '').strip()
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if not question_pk or not new_text:
return HttpResponse('Missing parameters', status=400)
question = get_object_or_404(AnatomyQuestion, pk=question_pk)
a = question.answers.filter(answer_compare__iexact=original).first()
if a is None:
a = Answer()
a.question = question
a.answer = new_text
else:
a.answer = new_text
a.save()
# If we have exam context, refresh the exam-marked fragment for that question
if exam_pk and sk is not None:
# delegate to mark2_exam_marked to re-render the card
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return HttpResponse('OK')
return HttpResponse('OK')
# Fallback: forbidden for non-HTMX or other methods
raise PermissionDenied()
@login_required
def mark2_question_marked(request, pk):
"""Return a partial listing of all stored Answer choices for this question (grouped by status)."""
question = get_object_or_404(AnatomyQuestion, pk=pk)
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
context = {
'question': question,
'correct_answers': correct_answers,
'half_mark_answers': half_mark_answers,
'incorrect_answers': incorrect_answers,
}
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)
@@ -758,23 +1392,195 @@ class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView):
@user_is_author_or_anatomy_checker
def question_suggest_incorrect_answers(request, pk):
question = get_object_or_404(AnatomyQuestion, pk=pk)
# helper: build candidate suggestions (n-grams + prefixes)
def build_candidates(question_obj):
from django.db.models import Count
import re
def tokenize(s: str):
return re.findall(r"\w+", (s or "").lower())
candidates_qs = (
question_obj.cid_user_answers.values("answer_compare")
.annotate(count=Count("id"))
.order_by("-count")
)
existing = set([s for s in question_obj.answer_suggest_incorrect if s])
marked_texts = [m.lower() for m in question_obj.get_marked_answers()]
ngram_map = {}
ngram_weight = {}
prefix_map = {}
prefix_weight = {}
for row in candidates_qs:
ans = (row.get("answer_compare") or "").strip()
cnt = int(row.get("count") or 0)
if not ans:
continue
tokens = tokenize(ans)
max_n = min(4, len(tokens))
for n in range(1, max_n + 1):
for i in range(len(tokens) - n + 1):
ngram = " ".join(tokens[i : i + n])
ngram_map.setdefault(ngram, set()).add(ans)
ngram_weight[ngram] = ngram_weight.get(ngram, 0) + cnt
for tok in tokens:
tlen = len(tok)
for plen in range(3, min(6, tlen) + 1):
pref = tok[:plen]
prefix_map.setdefault(pref, set()).add(ans)
prefix_weight[pref] = prefix_weight.get(pref, 0) + cnt
raw_candidates = []
for ngram, ans_set in ngram_map.items():
if ngram in existing:
continue
excluded = False
for mt in marked_texts:
if ngram in mt:
excluded = True
break
if excluded:
continue
coverage = len(ans_set)
if coverage < 1:
continue
raw_candidates.append(
{
"text": ngram,
"coverage": coverage,
"count": ngram_weight.get(ngram, 0),
"words": len(ngram.split()),
"type": "ngram",
}
)
for pref, ans_set in prefix_map.items():
if pref in existing:
continue
excluded = False
for mt in marked_texts:
if pref in mt:
excluded = True
break
if excluded:
continue
coverage = len(ans_set)
if coverage < 1:
continue
raw_candidates.append(
{
"text": pref,
"coverage": coverage,
"count": prefix_weight.get(pref, 0),
"words": 1,
"type": "prefix",
}
)
raw_candidates.sort(key=lambda x: (x["words"], -x["coverage"], -x["count"]))
selected = []
selected_texts = []
for c in raw_candidates:
t = c["text"]
skip = False
for s in selected_texts:
if s in t:
skip = True
break
if not skip:
selected.append({"answer": t, "count": c["count"], "type": c.get("type", "ngram")})
selected_texts.append(t)
return selected
# Support HTMX partial rendering with suggestion candidates and inline add
if request.method == "POST":
# Support remove action from the edit fragment
if request.htmx and request.POST.get("remove"):
to_remove = request.POST.get("remove").strip()
if to_remove:
existing = [s for s in question.answer_suggest_incorrect if s]
if to_remove in existing:
existing = [s for s in existing if s != to_remove]
question.answer_suggest_incorrect = existing
question.save()
# Recompute candidates and render the edit fragment
form = SuggestIncorrectAnswerForm(instance=question)
candidates = build_candidates(question)
context = {"form": form, "question": question, "candidates": candidates}
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
# Support single-candidate add via HTMX (button posts 'suggestion').
# Edits should only be performed via the edit fragment; when a
# suggestion is posted we re-render the edit fragment so the
# user remains in edit mode and sees the updated candidate list.
if request.htmx and request.POST.get("suggestion"):
suggestion = request.POST.get("suggestion").strip()
if suggestion:
existing = [s for s in question.answer_suggest_incorrect if s]
if suggestion not in existing:
existing.append(suggestion)
question.answer_suggest_incorrect = existing
question.save()
# If this is a quick add (from the answers table) return the collapsed partial
if request.POST.get("quick"):
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", {"question": question})
# Recompute candidates and render the edit fragment
form = SuggestIncorrectAnswerForm(instance=question)
candidates = build_candidates(question)
context = {"form": form, "question": question, "candidates": candidates}
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
# Otherwise treat as the full form submission
form = SuggestIncorrectAnswerForm(request.POST, instance=question)
if form.is_valid():
obj = form.save()
obj.save()
# If HTMX requested, return the fragment or collapsed partial
if request.htmx:
# If the close button was used, return the collapsed small partial
if request.POST.get("close"):
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", {"question": question})
# Otherwise re-render the edit fragment so the user stays in edit mode
form = SuggestIncorrectAnswerForm(instance=question)
candidates = build_candidates(question)
context = {"form": form, "question": question, "candidates": candidates}
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
return render(request, "anatomy/question_detail.html#suggest-incorrect-form", {"question": question})
return HttpResponse("Invalid")
else:
form = SuggestIncorrectAnswerForm(instance=question)
candidates = build_candidates(question)
context = {"form": form, "question": question, "candidates": candidates}
return render(request, "anatomy/suggest_incorrect.html", {"form": form, "question": question})
if request.htmx:
return render(request, "anatomy/partials/suggest_incorrect_fragment.html", context)
return render(request, "anatomy/suggest_incorrect.html", context)
return HttpResponse("Error", status=400)
@user_is_author_or_anatomy_checker
def question_suggest_incorrect_collapsed(request, pk):
"""Return the small collapsed 'Edit suggestions' box as a partial."""
question = get_object_or_404(AnatomyQuestion, pk=pk)
context = {"question": question}
return render(request, "anatomy/partials/suggest_incorrect_collapsed.html", context)
@user_is_author_or_anatomy_checker
def question_save_annotation(request, pk):
if request.method == "POST":
+17
View File
@@ -3,6 +3,7 @@ from .models import (
Case,
CaseDetail,
CaseCollection,
NormalCase,
Differential,
Series,
SeriesImage,
@@ -79,6 +80,22 @@ class CaseCollectionAdmin(VersionAdmin):
admin.site.register(CaseCollection, CaseCollectionAdmin)
class NormalCaseAdmin(VersionAdmin):
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
list_filter = ("examination", "modality")
search_fields = ("case__title", "case__pk")
def display_age(self, obj):
try:
return obj.display_age()
except Exception:
return ""
display_age.short_description = "Age"
admin.site.register(NormalCase, NormalCaseAdmin)
class AtlasAdminForm(ModelForm):
class Meta:
model = Case
+4 -4
View File
@@ -32,9 +32,9 @@ router = Router()
class SeriesSchema(ModelSchema):
case_id: List[int] = []
class Config:
class Meta:
model = Series
model_fields = [
fields = [
"id",
"modality",
"examination",
@@ -48,9 +48,9 @@ class SeriesSchema(ModelSchema):
class CaseSchema(ModelSchema):
class Config:
class Meta:
model = Case
model_fields = ["id", "title"]
fields = ["id", "title"]
@router.post("/upload_dicom", auth=django_auth)
+130 -5
View File
@@ -11,6 +11,9 @@ from .models import (
Series,
Structure,
Subspecialty,
NormalCase,
Resource,
Site,
)
from django.contrib.auth.models import User
from django.db.models import Q
@@ -161,13 +164,16 @@ class SeriesFilter(django_filters.FilterSet):
class ConditionFilter(django_filters.FilterSet):
# Replace legacy `primary` filter with `is_canonical` and provide a
# `synonym` filter which will filter by the canonical group of the
# selected condition.
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Condition
fields = {
"name": ["icontains"],
"primary": ["exact"],
"synonym": ["exact"],
#"parent": ["exact"],
"subspecialty": ["exact"],
"rcr_curriculum": ["exact"],
}
@@ -186,11 +192,32 @@ class ConditionFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
# value is a Condition instance selected in the filter. Return all
# Conditions that belong to the same canonical group as `value`.
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class FindingFilter(django_filters.FilterSet):
# Replace legacy `primary`/`synonym` with canonical-based filters
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Finding
fields = ("name", "primary", "synonym")
fields = {
"name": ["icontains"],
}
def __init__(
self,
@@ -206,11 +233,29 @@ class FindingFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
# value is a Finding instance selected in the filter. Return all
# Findings that belong to the same canonical group as `value`.
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class StructureFilter(django_filters.FilterSet):
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Structure
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
fields = {"name": ["icontains"]}
def __init__(
self,
@@ -226,6 +271,19 @@ class StructureFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class PresentationFilter(django_filters.FilterSet):
class Meta:
@@ -286,6 +344,73 @@ class SubspecialtyFilter(django_filters.FilterSet):
)
pass
class NormalCaseFilter(django_filters.FilterSet):
"""FilterSet for the NormalCase model allowing filtering by
examination, modality and nearest-year age ranges.
"""
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
# 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")
class Meta:
model = NormalCase
fields = ("examination", "modality", "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()
# If not an atlas editor restrict to cases authored by the user or open access cases
if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
from django.db.models import Q
if queryset is None:
queryset = NormalCase.objects.select_related("case")
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
def filter_min_age(self, queryset, name, value):
try:
days = int(float(value) * 365.25)
except Exception:
return queryset
return queryset.filter(age_days__gte=days)
def filter_max_age(self, queryset, name, value):
try:
days = int(float(value) * 365.25)
except Exception:
return queryset
return queryset.filter(age_days__lte=days)
class ResourceFilter(django_filters.FilterSet):
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
site = django_filters.ModelMultipleChoiceFilter(field_name='sites', queryset=lambda request: Site.objects.all() if True else Site.objects.none())
subspecialty = django_filters.ModelMultipleChoiceFilter(field_name='subspecialty', queryset=Subspecialty.objects.all())
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label='No author')
has_file = django_filters.BooleanFilter(method='filter_has_file', label='Has file')
class Meta:
model = Resource
fields = ('name', 'site', 'subspecialty', 'author')
def filter_has_file(self, queryset, name, value):
if value is True:
return queryset.exclude(file__isnull=True).exclude(file__exact='')
elif value is False:
return queryset.filter(file__isnull=True) | queryset.filter(file__exact='')
return queryset
class QuestionSchemaFilter(django_filters.FilterSet):
class Meta:
model = QuestionSchema
+157 -57
View File
@@ -40,10 +40,13 @@ from atlas.models import (
Condition,
Structure,
Subspecialty,
Presentation,
PathologicalProcess,
UncategorisedDicom,
UserReportAnswer,
CaseDisplaySet,
)
from .models import NormalCase
from anatomy.models import Modality
@@ -53,7 +56,8 @@ from generic.models import Examination # , Sign
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
from django.urls import reverse
from crispy_forms.bootstrap import Accordion, AccordionGroup
from tinymce.widgets import TinyMCE
@@ -115,11 +119,10 @@ class ConditionForm(ModelForm):
class Meta:
model = Condition
exclude = ["rcr_curriculum"]
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
# Use an AJAX/autocomplete single-select for canonical so users
# can search for existing canonical Conditions by name.
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
"parent": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
@@ -128,6 +131,32 @@ class ConditionForm(ModelForm):
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Crispy helper to render a submit button consistently
try:
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = True
# Primary submit
self.helper.add_input(Submit("submit", "Submit"))
# Cancel as a button that redirects back to the condition list
try:
cancel_url = reverse("atlas:condition_view")
except Exception:
cancel_url = "#"
self.helper.add_input(
Button(
"cancel",
"Cancel",
css_class="btn btn-secondary ms-2",
onclick=f"window.location='{cancel_url}'",
)
)
except Exception:
# If crispy not available or something goes wrong, don't break form
pass
class CaseCollectionForm(ModelForm):
class Meta:
@@ -141,6 +170,25 @@ class CaseCollectionForm(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
super(CaseCollectionForm, self).__init__(*args, **kwargs)
# Render collection_type as radio buttons and ensure it appears near the top
if "collection_type" in self.fields:
# Replace the model field with an explicit ChoiceField that uses RadioSelect
original = self.fields["collection_type"]
choices = getattr(original, "choices", ())
initial = original.initial if hasattr(original, "initial") else None
required = original.required
label = original.label
widget = RadioSelect(attrs={"class": "btn-check"})
# assign custom template for rendering as button group
#widget.template_name = "widgets/radioselect_buttons.html"
self.fields["collection_type"] = forms.ChoiceField(
choices=choices,
widget=widget,
initial=initial,
required=required,
label=label,
)
# Identify show_ fields
show_fields = [f for f in self.fields if f.startswith("show_")]
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
@@ -186,6 +234,7 @@ class CaseCollectionForm(ModelForm):
Fieldset(
"Collection Details",
"name",
Field("collection_type", css_class="mb-2"),
Fieldset(
"Dates",
"restrict_to_dates",
@@ -213,7 +262,6 @@ class CaseCollectionForm(ModelForm):
"exam_mode",
"self_review",
"feedback_once_collection_complete",
"collection_type",
"viewer_mode",
"question_time_limit",
"prerequisites",
@@ -236,35 +284,55 @@ class CaseCollectionForm(ModelForm):
Submit("submit", "Save Collection", css_class="btn btn-primary"),
)
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
class NormalCaseForm(ModelForm):
# Keep non-model input fields for age entry so users can provide a
# value + unit (e.g. 6 months) which will be converted to canonical
# days on save. The model now stores only `age_days`.
age_value = forms.IntegerField(required=False, min_value=0)
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
class Meta:
model = NormalCase
fields = ("examination", "modality", "notes")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
# Do not render a <form> tag from crispy here because the template
# already provides the surrounding form element (we inject this
# fragment into a modal that contains the form). Rendering a second
# <form> would nest forms and cause fields to be outside the
# submitted form (HTMX would only send the outer form's fields).
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field("age_value", wrapper_class="col-md-3"),
Field("age_unit", wrapper_class="col-md-3"),
Field("examination", wrapper_class="col-md-3"),
Field("modality", wrapper_class="col-md-3"),
css_class="row",
),
required=False,
help_text="The date the exam is due to start (time is optional)",
)
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
),
required=False,
help_text="The date the exam is due to start (time is optional)",
Field("notes"),
Submit("submit", "Save", css_class="btn btn-primary"),
)
def save(self, commit=True):
# Get the unsaved Case instance
instance = ModelForm.save(self, False)
# Respect commit flag. Compute canonical `age_days` from the
# provided age_value/age_unit and set it on the instance.
instance = super().save(commit=False)
age_value = self.cleaned_data.get("age_value")
age_unit = self.cleaned_data.get("age_unit")
if age_value is not None and age_unit:
instance.age_days = NormalCase.to_days(age_value, age_unit)
else:
# If the user left fields blank, clear any previous value.
instance.age_days = None
# Do we need to save all changes now?
# if commit:
instance.save()
self.save_m2m()
if commit:
instance.save()
self.save_m2m()
return instance
@@ -273,9 +341,10 @@ class FindingForm(ModelForm):
class Meta:
model = Finding
exclude = []
# Use the canonical FK in forms (if users want to mark this Finding
# as an alias of another). The old `synonym` M2M has been removed.
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
"canonical": autocomplete.ModelSelect2(
url="atlas:finding-autocomplete"
),
}
@@ -285,14 +354,31 @@ class StructureForm(ModelForm):
class Meta:
model = Structure
exclude = []
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
"canonical": autocomplete.ModelSelect2(
url="atlas:structure-autocomplete"
),
}
class SubspecialtyForm(ModelForm):
class Meta:
model = Subspecialty
exclude = []
class PresentationForm(ModelForm):
class Meta:
model = Presentation
exclude = []
class PathologicalProcessForm(ModelForm):
class Meta:
model = PathologicalProcess
exclude = []
class SeriesFindingForm(ModelForm):
class Meta:
model = SeriesFinding
@@ -644,7 +730,7 @@ CaseDifferentialFormSet = inlineformset_factory(
class ResourceForm(ModelForm):
class Meta:
model = Resource
exclude = ["author"]
exclude = ["author", "created_date"]
widgets = {
"description": Textarea(attrs={"cols": 80, "rows": 5}),
@@ -791,7 +877,7 @@ class BaseReportAnswerForm(ModelForm):
# "answer": "Write your answer in here."
# }
def __init__(self, *args, case_detail, **kwargs):
def __init__(self, *args, casedetail, **kwargs):
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
self.fields["answer"].required = False
@@ -807,12 +893,12 @@ class BaseQuestionAnswerForm(ModelForm):
# "answer": "Write your answer in here."
# }
def __init__(self, *args, case_detail, **kwargs):
def __init__(self, *args, casedetail, **kwargs):
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
# self.fields["json_answer"].schema = case_detail.question_schema
if case_detail.question_schema is not None:
# self.fields["json_answer"].schema = casedetail.question_schema
if casedetail.question_schema is not None:
self.fields["json_answer"] = JSONSchemaField(
schema=case_detail.question_schema, options="schema/options.json"
schema=casedetail.question_schema, options="schema/options.json"
)
self.fields["json_answer"].label = ""
@@ -973,6 +1059,37 @@ class ConditionAutocomplete(ModelAutocomplete):
model = Condition
search_attrs = ["name"]
@classmethod
def get_items_from_keys(cls, keys, context_obj=None):
"""Return items for given keys, ignoring empty strings.
The autocomplete frontend sometimes sends empty string keys which
causes the ORM to raise ValueError when filtering numeric PK fields.
Filter out falsy/blank keys before querying. If no valid keys remain
return an empty queryset.
"""
# normalize and remove empty/blank values
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
if not clean_keys:
return cls.model.objects.none()
# try to convert numeric keys to ints where possible
parsed_keys = []
for k in clean_keys:
try:
parsed_keys.append(int(k))
except Exception:
parsed_keys.append(k)
qs = cls.model.objects.filter(pk__in=parsed_keys)
# Return a list of dicts matching the autocomplete core expectations
# (items subscriptable by keys like 'key' / 'label').
return [
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
for obj in qs
]
class ConditionAutocompleteForm(Form):
condition = ModelChoiceField(
@@ -1113,23 +1230,6 @@ class PriorCaseForm(Form):
prior_visibility = CharField(max_length=2, required=True)
# def __init__(self, *args, **kwargs):
# self.case_detail = kwargs.pop(
# "case_detail"
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
# super(PriorCaseForm, self).__init__(*args, **kwargs)
# prior_cases = self.case_detail.case.get_all_prior_cases()
# if not prior_cases:
# prior_cases = Case.objects.none()
# self.fields["case"] = ChoiceField(
# required=False,
# # widget=Select(verbose_name="Series", is_stacked=False),
# )
class CaseSeriesForm(forms.ModelForm):
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
+35
View File
@@ -0,0 +1,35 @@
# Generated by Django 5.2.7 on 2025-11-13 21:43
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0079_casecollection_prerequisites'),
('generic', '0029_add_unique_constraint_supervisor_email'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='NormalCase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('age_years', models.PositiveSmallIntegerField(blank=True, help_text='Age of the patient at scan in years (rounded to nearest year).', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(150)])),
('added_date', models.DateTimeField(default=django.utils.timezone.now)),
('notes', models.TextField(blank=True, null=True)),
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_normals', to=settings.AUTH_USER_MODEL)),
('case', models.OneToOneField(help_text='The Case that is marked as a normal example.', on_delete=django.db.models.deletion.CASCADE, related_name='normal_case', to='atlas.case')),
('examination', models.ForeignKey(blank=True, help_text='Primary examination for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.examination')),
('modality', models.ForeignKey(blank=True, help_text='Primary modality for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.modality')),
],
options={
'ordering': ['-added_date'],
},
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-11-13 22:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0080_normalcase'),
]
operations = [
migrations.RemoveField(
model_name='normalcase',
name='age_years',
),
migrations.AddField(
model_name='normalcase',
name='age_days',
field=models.PositiveIntegerField(blank=True, help_text='Patient age at scan in days (canonical).', null=True),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-11-14 20:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0081_remove_normalcase_age_years_normalcase_age_days'),
('generic', '0029_add_unique_constraint_supervisor_email'),
]
operations = [
migrations.AddField(
model_name='resource',
name='sites',
field=models.ManyToManyField(blank=True, help_text='The site(s) this resource is associated with (if any).', related_name='resources', to='generic.site'),
),
migrations.AddField(
model_name='resource',
name='subspecialty',
field=models.ManyToManyField(blank=True, help_text='Subspecialty or subspecialties this resource relates to.', to='atlas.subspecialty'),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-11-14 21:02
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0082_resource_sites_resource_subspecialty'),
]
operations = [
migrations.AddField(
model_name='resource',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name='resource',
name='modified_date',
field=models.DateTimeField(auto_now=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-15 22:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0083_resource_created_date_resource_modified_date'),
]
operations = [
migrations.AddField(
model_name='resource',
name='open_access',
field=models.BooleanField(default=True, help_text='If true the resource is available to all users, otherwise only in the context of cases.'),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-11-17 10:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0084_resource_open_access'),
]
operations = [
migrations.RenameField(
model_name='caseprior',
old_name='case_detail',
new_name='casedetail',
),
migrations.AlterUniqueTogether(
name='caseprior',
unique_together={('casedetail', 'prior_case')},
),
]
+69
View File
@@ -0,0 +1,69 @@
from django.db import migrations, models
def forwards(apps, schema_editor):
Condition = apps.get_model("atlas", "Condition")
# Build connected components using existing M2M 'synonym' links
visited = set()
for cond in Condition.objects.all():
if cond.pk in visited:
continue
# BFS/DFS to collect component
stack = [cond]
comp = []
while stack:
c = stack.pop()
if c.pk in visited:
continue
visited.add(c.pk)
comp.append(c)
# access existing M2M relations
for s in c.synonym.all():
if s.pk not in visited:
stack.append(s)
# choose canonical: prefer primary==True else lowest pk
canon = None
for c in comp:
if getattr(c, "primary", False):
canon = c
break
if not canon:
canon = min(comp, key=lambda x: x.pk)
# set canonical for all non-canonical members
for c in comp:
if c.pk != canon.pk:
c.canonical_id = canon.pk
c.save()
def backwards(apps, schema_editor):
Condition = apps.get_model("atlas", "Condition")
for c in Condition.objects.all():
if c.canonical_id:
c.canonical_id = None
c.save()
class Migration(migrations.Migration):
dependencies = [
("atlas", "0085_rename_case_detail_caseprior_casedetail_and_more"),
]
operations = [
migrations.AddField(
model_name="condition",
name="canonical",
field=models.ForeignKey(
related_name="aliases",
null=True,
on_delete=models.SET_NULL,
to="atlas.condition",
blank=True,
),
),
migrations.RunPython(forwards, backwards),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-11-20 22:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0086_add_canonical'),
]
operations = [
migrations.AlterField(
model_name='condition',
name='canonical',
field=models.ForeignKey(blank=True, help_text='If set, this Condition is an alias and points to the canonical Condition.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.condition'),
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.2.7 on 2025-11-24 10:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0087_alter_condition_canonical'),
]
operations = [
migrations.RemoveField(
model_name='finding',
name='primary',
),
migrations.RemoveField(
model_name='finding',
name='synonym',
),
migrations.RemoveField(
model_name='structure',
name='primary',
),
migrations.RemoveField(
model_name='structure',
name='synonym',
),
migrations.AddField(
model_name='finding',
name='canonical',
field=models.ForeignKey(blank=True, help_text='If set, this Finding is an alias and points to the canonical Finding.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.finding'),
),
migrations.AddField(
model_name='structure',
name='canonical',
field=models.ForeignKey(blank=True, help_text='If set, this Structure is an alias and points to the canonical Structure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.structure'),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 5.2.7 on 2025-11-24 10:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0088_remove_finding_primary_remove_finding_synonym_and_more'),
]
operations = [
migrations.RemoveField(
model_name='condition',
name='primary',
),
migrations.RemoveField(
model_name='condition',
name='synonym',
),
]
+374 -41
View File
@@ -59,6 +59,7 @@ from generic.models import (
SeriesBase,
SeriesImageBase,
Modality,
Site,
UserUserGroup,
)
@@ -109,36 +110,121 @@ class SynMixin(object):
# abstract = True
def __str__(self) -> str:
if self.primary:
return self.name
else:
return f"{self.name} [syn]"
# Determine whether this record should be displayed as the
# canonical/primary entry. Prefer the new canonical FK pattern
# (`canonical is None` means this object is the master). Fallback
# to legacy `primary` boolean when present.
# If the model supports canonical FK, consider canonical==None as
# the primary/master entry. For models without canonical (legacy
# edge-cases), treat the instance as primary by default.
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return self.name
return f"{self.name} [syn]"
except Exception:
# defensive fallback
return f"{self.name} [syn]"
# No canonical support: assume primary
return self.name
def get_old_str(self) -> str:
if self.primary:
if self.synonym.count() == 0:
return self.name
else:
synonyms = ",".join([i.name for i in self.synonym.all()])
return f"{self.name} ({synonyms})"
# Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
try:
syns = self.get_synonyms()
except Exception:
syns = None
# If the model supports canonical, only canonical==None entries
# should be rendered as the primary name. For models without
# canonical, treat instance as primary.
if hasattr(self, "canonical"):
try:
is_primary = self.canonical is None or self.canonical == self
except Exception:
is_primary = False
else:
return f"{self.name} [syn]"
is_primary = True
if is_primary:
if syns is None:
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None or (hasattr(synonyms_field, "count") and synonyms_field.count() == 0):
return self.name
synonyms = ",".join([i.name for i in synonyms_field.all()])
return f"{self.name} ({synonyms})"
else:
if not syns:
return self.name
synonyms = ",".join([i.name for i in syns])
return f"{self.name} ({synonyms})"
return f"{self.name} [syn]"
def get_synonym(self):
if self.primary:
return "[Primary]"
else:
s = self.synonym.filter(primary=True).values_list("name", flat=True)
# Return a readable synonym string. For models that implement
# get_synonyms (Condition with canonical/aliases), use that.
try:
syns = self.get_synonyms()
except Exception:
syns = None
# If canonical is present and this instance is the master, mark Primary
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return "[Primary]"
except Exception:
pass
if syns is None:
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None:
return ""
# If the old M2M exists, prefer those marked primary if such a
# attribute exists on the related objects.
try:
# Select all synonym names; legacy `primary` flag removed.
s = synonyms_field.values_list("name", flat=True)
except Exception:
s = []
return ", ".join(s)
# prefer primary names among synonyms if present
primary_names = []
for i in syns:
# Prefer items that are canonical/master in the group
if getattr(i, "canonical", None) is None:
primary_names.append(i.name)
if primary_names:
return ", ".join(primary_names)
return ", ".join([i.name for i in syns])
def get_synonym_link(self):
if self.primary:
return "[Primary]"
else:
syns = self.synonym.filter(primary=True)
return ", ".join(
[f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
)
try:
syns = self.get_synonyms()
except Exception:
syns = None
# Prefer canonical/master check
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return "[Primary]"
except Exception:
pass
if syns is None:
# fall back to all M2M synonyms (not just primary) for models
# that still use the old synonym field (e.g. Finding, Structure)
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None:
return ""
syns_qs = synonyms_field.all()
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
return ", ".join(items)
# render links for synonyms/canonical group
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
return ", ".join(items)
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
@@ -146,21 +232,49 @@ class SynMixin(object):
class Finding(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
synonym = models.ManyToManyField("self", blank=True)
primary = models.BooleanField(default="True")
# New canonical/alias field: if set, this Finding is an alias and points
# to the canonical/master Finding. We remove the old M2M `synonym` and
# `primary` boolean in favour of this single canonical FK.
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Finding is an alias and points to the canonical Finding.",
)
def get_absolute_url(self):
return reverse("atlas:finding_detail", kwargs={"pk": self.pk})
@property
def canonical_finding(self):
return self.canonical if self.canonical else self
def get_synonyms(self):
"""Return other Findings that are aliases/synonyms for this concept.
Behaviour mirrors Condition.get_synonyms: if this Finding is an alias
(has canonical set) return the canonical and other aliases (excluding
self). If this Finding is canonical, return all aliases (excluding
self).
"""
master = self.canonical_finding
qs = Finding.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
class Condition(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
synonym = models.ManyToManyField(
# New canonical/alias field (Option B): if set, this Condition is an alias
# and points to the canonical/master Condition.
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
help_text="Use if a direct synonym for the condition exists, e.g. 'Wegener granulomatosis' and 'Granulomatosis with Polyangitis'.",
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Condition is an alias and points to the canonical Condition.",
)
parent = models.ManyToManyField(
"self",
@@ -171,10 +285,10 @@ class Condition(SynMixin, models.Model):
help_text="Use if the condition could be considered a subset of another, e.g. 'Alzheimer disease' and 'Dementia'.",
)
primary = models.BooleanField(
default="True",
help_text="Sets if this should be the canonical item, all other synonyms will then redirect to this by default.",
)
# Legacy fields `synonym` (M2M) and `primary` (boolean) have been removed.
# Canonical/aliases are represented by the `canonical` ForeignKey and
# the reverse `aliases` related_name. Use `get_synonyms()` /
# `canonical_condition` to access canonical groups.
subspecialty = models.ManyToManyField(
"subspecialty",
@@ -197,6 +311,23 @@ class Condition(SynMixin, models.Model):
def get_absolute_url(self):
return reverse("atlas:condition_detail", kwargs={"pk": self.pk})
@property
def canonical_condition(self):
"""Return the canonical/master condition for this Condition (self if none)."""
return self.canonical if self.canonical else self
def get_synonyms(self):
"""Return other Conditions that are synonyms/aliases for this concept.
Behaviour:
- If this Condition is an alias (canonical set), return the canonical and
any other aliases (excluding self).
- If this Condition is canonical, return all aliases (and exclude self).
"""
master = self.canonical_condition
qs = Condition.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
def get_children(self):
return self.child.all()
@@ -275,14 +406,28 @@ class Differential(models.Model):
class Structure(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
synonym = models.ManyToManyField("self", blank=True)
primary = models.BooleanField(default="True")
# Migrate to canonical/aliases model like Condition and Finding
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Structure is an alias and points to the canonical Structure.",
)
def get_absolute_url(self):
return reverse("atlas:structure_detail", kwargs={"pk": self.pk})
@property
def canonical_structure(self):
return self.canonical if self.canonical else self
def get_synonyms(self):
master = self.canonical_structure
qs = Structure.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
@reversion.register
class Case(models.Model, AuthorMixin, QuestionMixin):
@@ -591,7 +736,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
stacks = []
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
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})
return stacks
@@ -611,6 +756,155 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return json.dumps(results)
class NormalCase(models.Model):
"""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).
We keep the examination/modality metadata and authored fields. The
age fields previously stored (`age_years`, `age_value`, `age_unit`) have
been removed in favour of the single canonical `age_days` value.
"""
case = models.OneToOneField(
Case,
on_delete=models.CASCADE,
related_name="normal_case",
help_text="The Case that is marked as a normal example.",
)
# Canonical internal representation: patient age in days
age_days = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Patient age at scan in days (canonical).",
)
# Helpful constants for converting/displaying ages. These are not
# stored directly on the model but are used by forms/views/templates.
AGE_UNIT_YEARS = "years"
AGE_UNIT_MONTHS = "months"
AGE_UNIT_WEEKS = "weeks"
AGE_UNIT_DAYS = "days"
AGE_UNIT_CHOICES = [
(AGE_UNIT_YEARS, "years"),
(AGE_UNIT_MONTHS, "months"),
(AGE_UNIT_WEEKS, "weeks"),
(AGE_UNIT_DAYS, "days"),
]
examination = models.ForeignKey(
Examination,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="normal_cases",
help_text="Primary examination for this normal case (optional).",
)
modality = models.ForeignKey(
Modality,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="normal_cases",
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)
notes = models.TextField(null=True, blank=True)
class Meta:
ordering = ["-added_date"]
def __str__(self):
return f"Normal: {self.case} ({self.display_age()})"
def get_absolute_url(self):
return self.case.get_absolute_url()
@staticmethod
def to_days(value: int | float, unit: str) -> int | None:
"""Convert a numeric value + unit into integer days (rounded).
Examples:
to_days(2, 'years') -> ~730
to_days(6, 'months') -> ~183
"""
try:
if value is None or unit is None:
return None
v = float(value)
if unit == NormalCase.AGE_UNIT_YEARS:
return int(round(v * 365.25))
if unit == NormalCase.AGE_UNIT_MONTHS:
return int(round(v * 30.4375))
if unit == NormalCase.AGE_UNIT_WEEKS:
return int(round(v * 7))
if unit == NormalCase.AGE_UNIT_DAYS:
return int(round(v))
except Exception:
return None
@staticmethod
def days_to_value_unit(days: int | None) -> tuple[int | None, str | None]:
"""Convert canonical days into a (value, unit) tuple for display.
Prefer years for ages >= 365 days, months for ages >= 30 days,
weeks for >=7 days, else days.
"""
if days is None:
return (None, None)
try:
d = int(days)
if d >= 365:
yrs = int(round(d / 365.25))
return (yrs, NormalCase.AGE_UNIT_YEARS)
if d >= 30:
months = int(round(d / 30.4375))
return (months, NormalCase.AGE_UNIT_MONTHS)
if d >= 7:
weeks = int(round(d / 7))
return (weeks, NormalCase.AGE_UNIT_WEEKS)
return (d, NormalCase.AGE_UNIT_DAYS)
except Exception:
return (None, None)
def display_age(self) -> str:
"""Return a human friendly age string derived from age_days."""
if self.age_days is None:
return "age unknown"
val, unit = NormalCase.days_to_value_unit(self.age_days)
if val is None or unit is None:
return "age unknown"
# abbreviate years to 'y' for backwards compatibility where helpful
if unit == NormalCase.AGE_UNIT_YEARS:
return f"{val}y"
return f"{val} {unit}"
def save(self, *args, **kwargs):
# If examination/modality not specified, try to infer from the first series
try:
if (self.examination is None or self.modality is None) and self.case.series.exists():
first_series = self.case.series.first()
if self.examination is None and getattr(first_series, 'examination', None) is not None:
self.examination = first_series.examination
if self.modality is None and getattr(first_series, 'modality', None) is not None:
self.modality = first_series.modality
except Exception:
# Be defensive: if anything goes wrong whilst inferring, continue and save as-is
pass
super().save(*args, **kwargs)
def extract_image_dicom_json_from_ds(ds, url, image_index):
to_keep = [
"Columns",
@@ -1293,6 +1587,13 @@ class CaseDetail(models.Model):
def __str__(self) -> str:
return f"{self.case} -> {self.collection}"
def get_case_index(self):
"""Returns the question index (number) in the collection (0 indexed)"""
case_details = list(self.collection.casedetail_set.all().order_by("sort_order"))
return case_details.index(self)
def get_question_schema(self):
return json.dumps(self.question_schema)
@@ -1395,7 +1696,7 @@ class CaseDetail(models.Model):
return mark_safe(html)
class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
casedetail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
@@ -1414,10 +1715,10 @@ class CasePrior(models.Model):
)
class Meta:
unique_together = ("case_detail", "prior_case")
unique_together = ("casedetail", "prior_case")
def __str__(self) -> str:
return f"{self.case_detail.case} -> {self.prior_case}"
return f"{self.casedetail.case} -> {self.prior_case}"
class BaseReportAnswer(models.Model):
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
@@ -1726,6 +2027,27 @@ class Resource(models.Model, AuthorMixin):
help_text="Author of the resource",
related_name="resources",
)
created_date = models.DateTimeField(default=timezone.now)
modified_date = models.DateTimeField(auto_now=True)
# Link resources to one or more Sites (from generic.Site)
sites = models.ManyToManyField(
'generic.Site',
blank=True,
help_text='The site(s) this resource is associated with (if any).',
related_name='resources',
)
# Categorise resources by subspecialty
subspecialty = models.ManyToManyField(
Subspecialty,
blank=True,
help_text='Subspecialty or subspecialties this resource relates to.',
)
open_access = models.BooleanField(
default=True,
help_text="If true the resource is available to all users, otherwise only in the context of cases."
)
def __str__(self) -> str:
return self.name
@@ -1753,7 +2075,18 @@ class Resource(models.Model, AuthorMixin):
else:
html = self.name
return format_html("<details class='resource-block'><summary>{}</summary>{}</details>", self.name, mark_safe(html))
meta = ''
if self.sites.exists():
meta += ' Sites: ' + ', '.join([s.short_code or s.full_name for s in self.sites.all()])
if self.subspecialty.exists():
meta += ' Subspecialty: ' + ', '.join([str(s) for s in self.subspecialty.all()])
return format_html(
"<details class='resource-block'><summary>{}</summary><div>{}</div><div class='text-muted small'>{}</div></details>",
self.name,
mark_safe(html),
meta,
)
+16 -3
View File
@@ -14,6 +14,7 @@ from .models import (
Structure,
Finding,
Subspecialty,
Resource,
)
from django.utils.html import format_html
@@ -275,7 +276,7 @@ class ConditionTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Condition
template_name = "django_tables2/bootstrap4.html"
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
sequence = ("name",)
@@ -311,7 +312,7 @@ class FindingTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Finding
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
fields = ("name",)
sequence = ("name",)
@@ -338,7 +339,7 @@ class StructureTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Structure
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
fields = ("name",)
sequence = ("name",)
@@ -414,6 +415,18 @@ class SubspecialtyTable(SelectionTable):
return format_html(record.get_synonym_link())
return f"{record.get_synonym_link()}"
class ResourceTable(SelectionTable):
name = tables.Column(linkify=("atlas:resource_detail", {"pk": tables.A("pk")}), verbose_name="Resource")
edit = tables.LinkColumn("atlas:resource_update", text="Edit", args=[A("pk")], orderable=False)
delete = tables.LinkColumn("atlas:resource_delete", text="Delete", args=[A("pk")], orderable=False)
class Meta(SelectionTable.Meta):
model = Resource
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "description")
class CaseCollectionTable(SelectionTable):
edit = tables.LinkColumn(
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
@@ -1,14 +1,9 @@
<span id="add-case-form">
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
{% csrf_token %}
<label for="case">Select Case to add:</label>
<select name="case" id="case">
{% for case in cases %}
<option value="{{ case.id }}">{{ case.title }}</option>
{% endfor %}
</select>
<button type="submit">Add</button>
<button _="on click remove #add-case-form">Cancel</button>
</form>
</span
<div id="add-case-form" class="p-2 border rounded">
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
{% include 'atlas/partials/case_search_widget.html' %}
<div class="mt-2">
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
</div>
</div>
+43 -4
View File
@@ -46,6 +46,9 @@
<li>
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
</li>
</ul>
</li>
<li class="nav-item dropdown">
@@ -63,12 +66,48 @@
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'atlas:categories_list' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>All categories</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'atlas:condition_view' %}">Conditions</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:finding_view' %}">Findings</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:structure_view' %}">Structures</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item-text small text-muted px-3">Create</li>
<li><a class="dropdown-item" href="{% url 'atlas:condition_create' %}">New condition</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:finding_create' %}">New finding</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:structure_create' %}">New structure</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
{% endif %}
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{% url 'atlas:resource_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Resources</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:resource_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Resource</a>
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
</li>
@@ -147,6 +147,10 @@
{# Diagnostic certainty as a badge #}
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
{# Normal toggle HTMX block #}
<div class="mt-1">
{% include "atlas/partials/_normal_toggle.html" %}
</div>
</div>
</div>
@@ -460,7 +464,7 @@
{% if casedetail %}
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail can_edit=can_edit %}
{% include 'atlas/partials/collection_question_block.html' with can_edit=can_edit %}
{% endif %}
@@ -3,6 +3,40 @@
{% load crispy_forms_tags %}
{% block css %}
<style>
/* Turn the existing form-check radio markup into horizontal button-like radios */
#div_id_collection_type .form-check { display: inline-block; margin-right: .5rem; }
#div_id_collection_type .form-check-input {
/* visually hide the native radio but keep it focusable */
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
#div_id_collection_type .form-check-label {
display: inline-block;
padding: .375rem .75rem;
border: 1px solid #0d6efd;
border-radius: .375rem;
color: #0d6efd;
cursor: pointer;
user-select: none;
}
#div_id_collection_type .form-check-input:focus + .form-check-label {
box-shadow: 0 0 0 .25rem rgba(13,110,253,.25);
}
#div_id_collection_type .form-check-input:checked + .form-check-label {
background-color: #0d6efd;
color: #fff;
}
/* small spacing tweak when legend present */
#div_id_collection_type fieldset { border: 0; padding: 0; margin: 0 0 1rem 0; }
</style>
{% endblock %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
@@ -3,17 +3,9 @@
{% block content %}
<div>
{% if previous %}
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
{% endif %}
</div>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_details" %}
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
<p>This page allows you to configure how the case is displayed as part of the collection.</p>
@@ -3,66 +3,67 @@
{% block content %}
<h2>Setup default display for case</h2>
{% 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;"
data-auto-cache-stack="true"
{% if casedetail.default_viewerstate %}
data-viewerstate={{ casedetail.default_viewerstate }}
{% endif %}
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
data-auto-cache-stack="true"
{% if case_detail.default_viewerstate %}
data-viewerstate={{ case_detail.default_viewerstate }}
{% endif %}
></div>
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
Save Viewer State
</button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
Reset to Default
</button>
<div id="viewerstate-save-response" class="mt-2"></div>
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
Save Viewer State
</button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
Reset to Default
</button>
<div id="viewerstate-save-response" class="mt-2"></div>
<script>
document.getElementById('save-viewerstate').addEventListener('click', function() {
let viewerState = window.exportViewerState_root();
if (!viewerState) {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
return;
}
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({viewerstate: viewerState})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
});
});
document.getElementById('reset-viewerstate').addEventListener('click', function() {
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({reset_viewerstate: true})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
<script>
document.getElementById('save-viewerstate').addEventListener('click', function() {
let viewerState = window.exportViewerState_root();
if (!viewerState) {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
return;
}
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({viewerstate: viewerState})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
});
});
document.getElementById('reset-viewerstate').addEventListener('click', function() {
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({reset_viewerstate: true})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
// Optionally reload the page or viewer here if needed
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
});
});
</script>
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
});
});
</script>
{% endblock %}
@@ -4,106 +4,28 @@
{% block content %}
<div>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_priors" %}
{% if previous %}
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
{% endif %}
<h2 class="mb-3">Managing Priors for Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}" class="text-decoration-none">{{casedetail.case.title}}</a></h2>
<div class="card mb-4">
<div class="card-body">
<p class="mb-1">This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
<p class="mb-0">It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
</div>
</div>
<h3 class="mb-3">Available priors</h3>
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
<p>This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
<p>It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
<h3>Available priors</h3>
<ul>
<div class="row g-3">
{% for case, added, relation, visibility in available_priors %}
<li class='
{% if added %}
added
{% else %}
not-added
{% endif %}
' data-case={{case.id}}>
{{case.title}} / {{added}} / {{relation}} / <a href="{% url 'atlas:case_detail' case.id %}">View case</a>
<br/>{{case.get_series_blocks|safe}}
<br/>
{% if not added %}
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
hx-target="#htmx-response-{{case.id}}"
_="on htmx:afterRequest log 'test' then remove me">
{% csrf_token %}
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
<br />
<label for="prior_visibility_{{case.id}}">Visibility:</label>
<select name="prior_visibility" id="prior_visibility_{{case.id}}">
<option value="AL"
{% if visibility == 'AL' %}
selected
{% endif %}
>Always</option>
<option value="RE"
{% if visibility == 'RE' %}
selected
{% endif %}
>Review</option>
<option value="NO"
{% if visibility == 'NO' %}
selected
{% endif %}
>None</option>
</select>
<br />
<button class="btn primary">Add to case</button>
</form>
{% else %}
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
hx-target="#htmx-response-{{case.id}}"
_="on htmx:afterRequest log 'test' then remove me">
Case available: {{relation}}
<input type="hidden" name="remove" value="{{case.id}}" />
<br />
<label for="prior_visibility_{{case.id}}">Visibility:</label>
<select name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
<option value="AL"
{% if visibility == 'AL' %}
selected
{% endif %}
>Always</option>
<option value="RE"
{% if visibility == 'RE' %}
selected
{% endif %}
>Review</option>
<option value="NO"
{% if visibility == 'NO' %}
selected
{% endif %}
>None</option>
</select>
<br />
<button class="btn primary">Remove from case</button>
</form>
{% endif %}
<span id="htmx-response-{{case.id}}"></span>
</li>
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
{% empty %}
<div class="col-12">
<div class="alert alert-info">No available prior cases found for the case.</div>
</div>
{% endfor %}
</ul>
</div>
@@ -4,21 +4,11 @@
{% block content %}
<div>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
{% if previous %}
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
{% endif %}
</div>
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
{% if case_detail.question_schema is not None and not case_detail.question_answers %}
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
<div class="alert alert-danger" role="alert">
@@ -42,7 +32,7 @@
Import schema from:
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
hx-get="{% url 'atlas:collection_question_schemas' case_detail.collection.id %}"
hx-get="{% url 'atlas:collection_question_schemas' casedetail.collection.id %}"
hx-target=".offcanvas-body"
>Collection questions</button>
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
@@ -54,7 +44,7 @@
Question Block Title
</summary>
<p>Title of the question block.</p>
<input id="question_block_title_input" type="text" name="title" value="{{case_detail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
<input id="question_block_title_input" type="text" name="title" value="{{casedetail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
</details>
<details><summary>Modify Questions</summary>
<div id="questions-container" style="margin-top: 20px;">
@@ -123,7 +113,7 @@
</form> {% endcomment %}
{% if case_detail.question_schema %}
{% if casedetail.question_schema %}
<h2>Question display</h2>
This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below.
@@ -2,28 +2,48 @@
{% load crispy_forms_tags %}
{% block content %}
<span class="collection-name-blend">Collection: {{collection}}</span>
<h2>Case {{case_number|add:1}}
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
{% if show_title %}
: {{case.title}}
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h4 mb-0">Case {{case_number|add:1}}
{% if show_title %}
: {{case.title}}
{% endif %}
{% if question_completed %}
<span class="stamp-white">REVIEW</span>
{% endif %}
{% if question_completed %}
<span class="badge bg-warning text-dark ms-2 small">REVIEW</span>
{% endif %}
</h2>
</div>
</h2>
<div class="d-flex align-items-center ms-3">
<div class="dropdown">
<a class="btn btn-outline-secondary btn-sm dropdown-toggle" href="#" role="button" id="caseJump" data-bs-toggle="dropdown" aria-expanded="false"
hx-get="{% url 'atlas:collection_case_jump_partial' pk=collection.pk %}?current={{case_number}}{% if cid %}&cid={{cid}}&passcode={{passcode}}{% endif %}"
hx-target="#case-jump-menu"
hx-swap="innerHTML"
hx-trigger="click">
Jump
</a>
<div class="dropdown-menu dropdown-menu-end" id="case-jump-menu">
<div class="px-3 py-2 text-muted">Loading...</div>
</div>
</div>
</div>
</div>
{% if not question_completed and collection.question_time_limit is not None %}
<div id="question-timer-block" style="margin-top:8px; margin-bottom:8px;" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
<strong>Time remaining:</strong>
<span id="question-timer" aria-live="polite">&nbsp;</span>
<div id="question-timer-progress" style="display:inline-block; vertical-align: middle; width: 200px; margin-left:12px;">
<div id="question-timer-progress-outer" style="background:#e9ecef; border-radius:6px; height:10px; overflow:hidden;">
<div id="question-timer-progress-inner" style="width:100%; height:100%; background:var(--timer-color, #28a745);"></div>
<div id="question-timer-block" class="mb-3" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
<div class="d-flex align-items-center gap-3">
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite">&nbsp;</span></div>
<div style="flex:1; max-width:320px;">
<div class="progress" style="height:10px;">
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
</div>
</div>
</div>
<div id="timer-htmx-target" style="display:none;"></div>
@@ -38,12 +58,10 @@
{% if not question_completed %}
{% if resources %}
<h5>Resources</h4>
<ul class="no-list-style">
<h5 class="mt-3">Resources</h5>
<ul class="list-group list-group-flush mb-3">
{% for caseresource in resources %}
<li>
{{caseresource.resource.get_display}}
</li>
<li class="list-group-item py-1 small">{{caseresource.resource.get_display}}</li>
{% endfor %}
</ul>
{% endif %}
@@ -51,14 +69,21 @@
{% if show_description and case.description %}
<div>
Description: {{case.description}}
<div class="mb-3">
<h6 class="mb-1">Description</h6>
<p class="mb-0">{{case.description}}</p>
</div>
{% endif %}
{% if show_history %}
<div>
History: {{case_detail.get_history_pre|linebreaks}}
<div class="card mb-3">
<div class="card-body p-2 d-flex justify-content-between align-items-start">
<div class="me-3 small text-muted">History: {{casedetail.get_history_pre|linebreaks}}</div>
{% if has_priors %}
<div class="ms-2">
<span title="This case has priors available for comparison" class="badge bg-secondary text-white rounded-pill small">Priors{% if prior_count %} ({{ prior_count }}){% endif %}</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
@@ -66,9 +91,9 @@
<div>
{% if question_completed %}
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe>
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe>
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
</div>
@@ -78,9 +103,9 @@
{% if collection.show_ohif_viewer_link %}
<div>
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
@@ -92,7 +117,7 @@
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-named-stacks='{{case_detail.get_case_named_stacks}}'
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
@@ -131,28 +156,24 @@
{% endif %}
{% if show_discussion and case.discussion %}
<p>
<details>
<summary>
Discussion:
</summary>
<div>
{{case.discussion|linebreaks}}
</div>
</details>
</p>
<details class="card mb-3">
<summary class="card-header p-2" style="cursor: pointer;">
<strong>Discussion</strong>
</summary>
<div class="card-body p-2 small">
{{case.discussion|linebreaks}}
</div>
</details>
{% endif %}
{% if show_report and case.report %}
<p>
<details>
<summary>
Report:
</summary>
<div>
{{case.report|linebreaks}}
</div>
</details>
</p>
<details class="card mb-3">
<summary class="card-header p-2" style="cursor: pointer;">
<strong>Report</strong>
</summary>
<div class="card-body p-2 small">
{{case.report|linebreaks}}
</div>
</details>
{% endif %}
{% if question_completed %}
@@ -246,25 +267,28 @@
{% endif %}
{% endif %}
{% if previous %}
<button type="submit" name="previous" class="save btn btn-default">Previous</button>
{% endif %}
{% if next %}
<button type="submit" name="next" class="save btn btn-default">Next</button>
{% elif collection.collection_type == "REP" %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="save btn btn-default">Save</button>
<div class="mb-2">
{% if previous %}
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
{% endif %}
{% endif %}
{% if next %}
<button type="submit" name="next" class="btn btn-primary btn-sm me-2">Next</button>
{% elif collection.collection_type == "REP" %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="btn btn-primary btn-sm me-2">Save</button>
{% endif %}
{% endif %}
</div>
{% if collection.self_review and not collection.feedback_once_collection_complete and not question_completed %}
<button type="submit" name="complete_case" class="save btn btn-default">Finish Case</button>
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
{% endif %}
<br />
<button type="submit" name="finish" class="save btn btn-default">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
<div class="mt-3">
<button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div>
</form>
<style>
label {
@@ -3,10 +3,10 @@
{% block content %}
<h2>Case: {{ case.title }}</h2>
{{ case_detail.question_schema }}
<h2>Case: {{ case.title }}</h2>
{{ casedetail.question_schema }}
{{ case.question_answers }}
{{ case.question_answers }}
{% endblock %}
+130 -115
View File
@@ -4,139 +4,154 @@
{% partialdef casedetails-management-links %}
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
{% include 'atlas/partials/casedetails_management_links.html' %}
</div>
{% if casedetail.default_viewerstate %}
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
{% endif %}
)
(<a href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-info-square" title="Case details"></i></a>)
{% if collection.collection_type == "QUE" %}
(<a href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}'>
{% if casedetail.question_schema %}
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
{% else %}
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
{% endif %}
</a>
)
{% endif %}
{% if casedetail.case.previous_case %}
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
{% endif %}
{% endpartialdef %}
{% block content %}
<h2>{{collection.name}}</h2>
{% include 'exam_notes.html' %}
<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>
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
{% include "generic/partials/exams/exam_status.html#publish-results" %}
{% include "generic/partials/exams/exam_status.html#exam-active" %}
Collection Type: {{collection.get_collection_type_display}}<br />
Self review: {{collection.self_review}}<br />
Open access: {{collection.open_access}}<br />
{% if collection.prerequisites.exists %}
Prerequisite collections:
<ul>
{% for prereq in collection.prerequisites.all %}
<li>
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
&nbsp;
</li>
{% endfor %}
</ul>
{% include 'exam_notes.html' %}
<p>
<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>
</p>
{% endif %}
<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>
{% 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>
<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>
{% 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>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<h3 class="mb-3">Cases</h3>
{% if collection.collection_type == "VIV" %}
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
{% else %}
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
_="on click
writeText(#take-url's href) on navigator.clipboard
put 'copied url!' into me
wait 1s
put 'copy url' into me"
class="btn btn-sm">copy link</button>
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% endif %}
<h3>Cases</h3>
<ol id="full-question-list" class="sortable">
{% for casedetail in casesdetails %}
<li data-question_pk={{casedetail.case.pk}}>
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
:
{% if casedetail.case.series.count == 0 %}
&nbsp;<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>&nbsp;
{% endif %}
{{casedetail.case.title}}
{% partial casedetails-management-links %}
</li>
{% endfor %}
</ol>
<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 %}
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Remove cases</button></p>
<p><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">
Add case
</button>
<div id='case-list'></div>
</p>
<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>
<details>
<summary>Answer management</summary>
<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 class="alert alert-danger">
<p>Manage answers for this collection. </p>
Please note these are permanant and cannot be undone.
<div id='case-list'></div>
</div>
</div>
<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."
>Reset all answers</button>
<div class="row mt-3">
<div class="col-12">
<details>
<summary>Answer management</summary>
<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"
>Reset user answers</button>
<div id="user-list"></div>
<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>
</details>
<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' %}
</div>
{% endblock %}
+39 -16
View File
@@ -1,20 +1,43 @@
{% if request.user.is_authenticated %}
Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
<a href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a> /
<a href="{% url 'atlas:collection_history' collection.pk %}">History</a> /
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
<a href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a> /
<a href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a> /
<div class="floating-header">
<a href="{% url 'atlas:exam_update' collection.id %}" title="Edit the Collection">Edit</a>
\ <a href="{% url 'atlas:exam_deleted' collection.id %}" title="Delete the Collection">Delete</a>
\ <a href="{% url 'atlas:exam_clone' collection.id %}" title="Clone the Collection">Clone</a>
\ <a href="{% url 'atlas:collection_authors' collection.pk %}" title="Edit Authors">Authors</a>
{% if request.user.is_authenticated and collection %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Collection:</strong> {{ collection.name }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collectionNav" aria-controls="collectionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{% if request.user.is_superuser %}
\ <a href="{% url 'admin:atlas_casecollection_change' collection.id %}" title="Edit the Collection using the admin interface">Admin Edit</a>
{% endif %}
<div class="collapse navbar-collapse" id="collectionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_history' collection.pk %}">History</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="collectionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="collectionActions">
<li><a class="dropdown-item" href="{% url 'atlas:collection_case_update' collection.pk %}">Edit Cases</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_update' collection.id %}">Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_clone' collection.id %}">Clone</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
{% endif %}
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
@@ -2,9 +2,9 @@
{% block content %}
<div class="vstack gap-3">
{% for case_detail, answers in case_map.items %}
{% for casedetail, answers in case_map.items %}
<div class="border border-dark">
<h4>Case: {{case_detail.case.title}}</h4>
<h4>Case: {{casedetail.case.title}}</h4>
Answers
<ul>
{% for answer in answers %}
@@ -3,15 +3,15 @@
{% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
<ol>
{% for case_detail in case_details %}
{% for casedetail in casedetails %}
<li id="case-detail-{{case_detail.pk}}"{% if case_detail.question_schema %} class="cases"{% endif %}>
<h2>Case: {{ case_detail.case.title }}</h2>
<li id="case-detail-{{casedetail.pk}}"{% if casedetail.question_schema %} class="cases"{% endif %}>
<h2>Case: {{ casedetail.case.title }}</h2>
{% if case_detail.question_schema %}
{% if casedetail.question_schema %}
<button class="btn btn-sm btn-secondary use-layout">Use schema</button>
<details><summary>Schema:</summary>
<div class="schema" data-schema='{{ case_detail.get_question_schema }}'>{{ case_detail.question_schema }}</div>
<div class="schema" data-schema='{{ casedetail.get_question_schema }}'>{{ casedetail.question_schema }}</div>
</details>
<div class="form"></div>
{% else %}
@@ -0,0 +1,22 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content %}
<div class="container-fluid">
<div class="d-flex align-items-start justify-content-between mb-3">
<h2 class="mb-0">Collections</h2>
{% if request.user.is_staff %}
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
{% endif %}
</div>
<p class="mb-3">Manage and browse case collections. Use filters to narrow results.</p>
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div>
{% endblock %}
+14 -14
View File
@@ -69,7 +69,7 @@
</div>
{# Hidden per-case question snippet to inject when case is loaded #}
<div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail %}
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div>
{% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary>
@@ -212,14 +212,14 @@
let c = this.closest(".case-item");
console.log('clicked local', c)
console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson);
casedetails = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
@@ -239,15 +239,15 @@
let c = this;
console.log('clicked local', c)
console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson);
casedetails = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
let annotations = JSON.parse(c.dataset.annotations) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
@@ -302,15 +302,15 @@
let c = $(this).closest(".case-item");
case_details = c.data('casejson');
console.log(case_details);
casedetails = c.data('casejson');
console.log(casedetails);
$('#loading-case').show()
$("#open-viewer").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.data('title'));
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
console.log($(this).data('type'))
if ($(this).data('type') == 'case') {
+4 -6
View File
@@ -18,7 +18,7 @@
<h5 class="mb-2">Details</h5>
<dl class="row">
<dt class="col-sm-4">Primary name</dt>
<dd class="col-sm-8">{{ condition.primary }}</dd>
<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>
<dd class="col-sm-8">
@@ -31,11 +31,9 @@
<dt class="col-sm-4">Synonyms</dt>
<dd class="col-sm-8">
{% for syn in condition.synonym.all %}
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
{% empty %}
&mdash;
{% endfor %}
<div id="synonyms-container">
{% include 'atlas/partials/_synonyms_list.html' %}
</div>
</dd>
<dt class="col-sm-4">Parent</dt>
+9 -16
View File
@@ -1,27 +1,20 @@
{% extends "atlas/base.html" %}
<!-- {% load static from static %} -->
{% load crispy_forms_tags %}
{% block css %}{% endblock %}
{% block css %}
{% endblock %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
{{form.media}}
<script type="text/javascript">
</script>
<!-- {{ form.media }} -->
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Add/Edit Condition</h2>
<h2>Add / Edit Condition</h2>
<p>Use this form to create or edit a condition.</p>
<p>Please check if it already <a href='{% url "atlas:condition_view" %}'>exists</a> before doing so!</p>
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
{% csrf_token %}
<table>
{{ form.as_table }}
</table>
<input type="submit" class="submit-button" value="Submit" name="submit">
{% crispy form %}
</form>
{% endblock %}
-1
View File
@@ -7,7 +7,6 @@
{% if collection %}
{% include "atlas/collection_headers.html" %}
{% endif %}
{% comment %} Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=exam.pk %}">Overview</a> /
<a href="{% url 'atlas:collection_mark_overview' exam.pk %}">Mark</a> /
<a href="{% url 'atlas:collection_scores_cid' exam.pk %}">Scores</a> /
+2 -2
View File
@@ -12,8 +12,8 @@
</div>
<div>
<h3>Name: {{finding.name}}</h3>
Primary name: {{finding.primary}}<br />
Synonyms: {{finding.synonym.all|join:", "}}<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>
+61 -34
View File
@@ -1,49 +1,76 @@
{% 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 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>
</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="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>
</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="card mb-3">
<div class="card-body">
<h5 class="card-title">Search cases</h5>
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="case-search-input" name="q" type="search"
placeholder="Search cases by title or description"
hx-get="{% url 'atlas:case_search_partial' %}"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#case-search-results"
hx-indicator="#case-search-indicator" />
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading</span>
</span>
</div>
</div>
<style>
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
#case-search-indicator { display: none; }
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
</style>
<div id="case-search-results">
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "atlas/base.html" %}
{% load static %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<h1>Normals</h1>
<div class="row mb-3">
<div class="col-md-4">
<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">
<button class="btn btn-primary" type="submit">Filter</button>
<a class="btn btn-link" href="?">Clear</a>
</div>
</form>
</div>
</div>
{% if page_obj.object_list %}
<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>
<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>
{% endfor %}
</div>
{% else %}
<div class="list-group-item">No normal cases found.</div>
{% endif %}
<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>
{% 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>
{% endif %}
</ul>
</nav>
</div>
{% endblock %}
@@ -0,0 +1,35 @@
{# 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>
<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>
<p class="mb-1 text-truncate">{{ case.description }}</p>
</a>
{% endfor %}
{% else %}
<div class="list-group-item">No cases found</div>
{% endif %}
</div>
@@ -0,0 +1,11 @@
{% 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 %}
{% endfor %}
@@ -0,0 +1,37 @@
<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">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Mark case {{ case.pk }} as Normal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% load crispy_forms_tags %}
{{ form.media }}
{% crispy form %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Normal</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Show the modal after HTMX injects it
(function(){
try {
var el = document.getElementById('normal-modal-{{ case.pk }}');
if(el){
var m = new bootstrap.Modal(el);
m.show();
}
} catch(e) {
console.error('Failed to show normal modal', e);
}
})();
</script>
@@ -0,0 +1,11 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1"><a href="{{ normal.case.get_absolute_url }}">{{ normal.case.title }}</a></h5>
<small>{{ normal.added_date|date:"Y-m-d" }}</small>
</div>
<p class="mb-1">Age: {{ normal.display_age }}</p>
<p class="mb-1">Examination: {{ normal.examination }} | Modality: {{ normal.modality }}</p>
{% if normal.notes %}
<p class="mb-0">Notes: {{ normal.notes }}</p>
{% endif %}
</div>
@@ -0,0 +1,28 @@
{% load static %}
<div id="normal-toggle-block">
{% 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>
{% endif %}
{% else %}
{% if can_mark_normal %}
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
hx-target="body"
hx-swap="beforeend">
Mark as normal
</button>
{% else %}
<span class="text-muted">Normal status only editable by atlas editors or case authors</span>
{% endif %}
{% endif %}
</div>
@@ -0,0 +1,80 @@
<div id="prior-card-{{case.id}}" class="col-12 col-md-6 col-lg-4" data-case="{{case.id}}">
<div class="card h-100 shadow-sm {% if added %}border-success{% else %}border-secondary{% endif %}">
<div class="card-body d-flex flex-column">
{% if error %}
<div class="alert alert-danger py-1 mb-2 small">{{ error }}</div>
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">{{case.title}}</h5>
<div>
{% if added %}
<span class="badge bg-success">Added</span>
{% else %}
<span class="badge bg-secondary">Available</span>
{% endif %}
</div>
</div>
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
<div class="mb-3 small">{{case.get_series_blocks|safe}}</div>
<div class="mt-auto">
<div class="d-flex gap-2 mb-2">
<a href="{% url 'atlas:case_detail' case.id %}" class="btn btn-sm btn-outline-primary">View case</a>
</div>
{% if not added %}
<form class="row g-2"
{% if case_number %}
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number %}"
{% else %}
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
{% endif %}
hx-target="#prior-card-{{case.id}}" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
<div class="col-12">
<input class="form-control form-control-sm" type="text" name="relation" value="{{relation}}" placeholder="Relation to case" />
</div>
<div class="col-8">
<select class="form-select form-select-sm" name="prior_visibility" id="prior_visibility_{{case.id}}">
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
<option value="RE" {% if visibility == 'RE' %}selected{% endif %}>Review</option>
<option value="NO" {% if visibility == 'NO' %}selected{% endif %}>None</option>
</select>
</div>
<div class="col-4 d-grid">
<button class="btn btn-sm btn-primary" type="submit">Add</button>
</div>
</form>
{% else %}
<form
{% if case_number %}
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number %}"
{% else %}
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
{% endif %}
hx-target="#prior-card-{{case.id}}" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="remove" value="{{case.id}}" />
<div class="mb-2 small text-muted">Current relation: <strong>{{relation}}</strong></div>
<div class="d-flex gap-2">
<select class="form-select form-select-sm w-50" name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
<option value="RE" {% if visibility == 'RE' %}selected{% endif %}>Review</option>
<option value="NO" {% if visibility == 'NO' %}selected{% endif %}>None</option>
</select>
<button class="btn btn-sm btn-danger" type="submit">Remove</button>
</div>
</form>
{% endif %}
</div>
</div>
<div class="card-footer bg-transparent">
<small class="text-muted">Case ID: {{ case.id }}</small>
</div>
</div>
</div>
@@ -0,0 +1,56 @@
{% comment %}Partial that renders the synonyms list + add form. Used by condition_detail and HTMX responses.{% endcomment %}
{% load static %}
{% with syns=condition.get_synonyms %}
{% if syns %}
{% for syn in syns %}
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
{% endfor %}
{% else %}
&mdash;
{% endif %}
{% endwith %}
{% if request.user.is_authenticated and can_merge %}
<div class="mt-2 d-flex gap-2">
<!-- Button to add an existing condition as a synonym -->
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#addSynonymCollapse" role="button" aria-expanded="false" aria-controls="addSynonymCollapse">Add synonym</a>
<!-- Button to create a new condition and add it as a synonym -->
<a class="btn btn-sm btn-outline-success" data-bs-toggle="collapse" href="#createSynonymCollapse" role="button" aria-expanded="false" aria-controls="createSynonymCollapse">Create synonym</a>
</div>
<!-- Collapse: add existing condition -->
<div class="collapse mt-2" id="addSynonymCollapse">
<div class="card card-body">
<form method="POST"
hx-post="{% url 'atlas:condition_detail' condition.pk %}"
hx-target="#synonyms-container" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="action" value="add_synonym" />
<div class="mb-2">
<label class="form-label small">Select existing condition</label>
{{ synonym_form.condition }}
</div>
<button class="btn btn-primary btn-sm" type="submit">Add</button>
</form>
</div>
</div>
<!-- Collapse: create new condition -->
<div class="collapse mt-2" id="createSynonymCollapse">
<div class="card card-body">
<form method="POST"
hx-post="{% url 'atlas:condition_detail' condition.pk %}"
hx-target="#synonyms-container" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="action" value="add_synonym" />
<div class="mb-2">
<label class="form-label small">Create new synonym name</label>
<input class="form-control" type="text" name="synonym_name" placeholder="e.g. Granulomatosis with Polyangitis" required />
</div>
<button class="btn btn-success btn-sm" type="submit">Create</button>
</form>
</div>
</div>
{% endif %}
@@ -0,0 +1,24 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
{% if previous %}
<a class="btn btn-outline-secondary btn-sm" href="{% url nav_link_view collection.id case_number|add:-1 %}">&larr; Previous</a>
{% endif %}
</div>
<div class="text-center">
<div class="h6 mb-0">Viewing as part of a collection</div>
<div>
<a href="{% url 'atlas:collection_detail' collection.id %}" class="fw-bold">{{collection.name}}</a>
<small class="text-muted">[{{case_number|add:1}}/{{collection_length}}]</small>
</div>
</div>
<div>
{% if next %}
<a class="btn btn-outline-secondary btn-sm" href="{% url nav_link_view collection.id case_number|add:1 %}">Next &rarr;</a>
{% endif %}
</div>
<div class="mb-1">
{% include "atlas/collection_detail.html#casedetails-management-links" %}
</div>
</div>
@@ -0,0 +1,31 @@
<li class="list-group-item d-flex gap-3 align-items-start" data-question_pk="{{ casedetail.case.pk }}">
<div class="flex-fill">
<div class="d-flex justify-content-between align-items-start">
<div>
<a class="h6 mb-0 text-decoration-none case-link"
href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}"
data-case-base-href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=0 %}">
{{ casedetail.case.title }}
</a>
<div class="small text-muted"><span class="case-number">Case {{ forloop.counter }}</span> &mdash; <span class="text-truncate">{{ casedetail.case.description|default_if_none:""|truncatechars:80 }}</span></div>
</div>
</div>
<div class="mt-2 small text-muted">
{% if casedetail.case.series.count == 0 %}
<span class="text-warning">This case has no series / stacks attached</span>
{% else %}
{{ casedetail.case.series.count }} series
{% endif %}
{% if casedetail.question_schema %}
&nbsp;&nbsp;<span class="text-success">Questions defined</span>
{% endif %}
</div>
</div>
<div class="d-flex align-items-center ms-3">
{% include 'atlas/partials/casedetails_management_links.html' %}
</div>
</li>
@@ -0,0 +1,45 @@
{# Search results #}
{% if q %}
{% if cases and cases.exists %}
<div class="list-group mb-2">
{% for case in cases %}
<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="fw-semibold">{{ case.title }}</div>
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
</div>
<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-primary">Add</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small mb-2">No cases found.</div>
{% endif %}
{% endif %}
{# Recent cases quick-add (rendered below search results) #}
{% if recent_cases and recent_cases|length > 0 %}
<div>
<div class="small text-muted mb-1">Recently created</div>
<div class="list-group">
{% for case in recent_cases %}
<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="fw-semibold">{{ case.title }}</div>
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
</div>
<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>
</div>
{% endfor %}
</div>
</div>
{% endif %}
@@ -0,0 +1,46 @@
<div class="case-search-widget">
<label for="case-search-input" class="form-label">Search cases</label>
<input id="case-search-input" name="q" class="form-control" type="search"
placeholder="Type to search cases..."
hx-get="{% url 'atlas:case_search' %}"
hx-include="#case-search-input"
hx-vals='{"collection": "{{ collection.pk }}"}'
hx-target="#case-search-results"
hx-trigger="keyup changed delay:400ms"
autocomplete="off">
<div id="case-search-results" class="mt-2">
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection %}
</div>
</div>
<script>
(function(){
// Robust fallback: ensure repeated searches always work by using htmx.ajax when available.
try {
var input = document.getElementById('case-search-input');
if (!input) return;
var timer = null;
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
try {
var q = encodeURIComponent(input.value || '');
var url = "{% url 'atlas:case_search' %}?q=" + q + "&collection={{ collection.pk }}";
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: '#case-search-results', swap: 'innerHTML' });
} else {
// fallback to fetch
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.getElementById('case-search-results');
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('case search fetch error', e); });
}
} catch(e) { console.error(e); }
}, 400);
});
} catch(e) { console.error('case search widget init error', e); }
})();
</script>
@@ -0,0 +1,42 @@
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
<i class="bi bi-display"></i>
</a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details">
<i class="bi bi-info-square"></i>
</a>
{% if collection.collection_type == "QUE" %}
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions">
{% if casedetail.question_schema %}
<i class="bi bi-question-square text-success"></i>
{% else %}
<i class="bi bi-question-square text-danger"></i>
{% endif %}
</a>
{% endif %}
{% if casedetail.case.previous_case %}
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors">
<i class="bi bi-link-45deg"></i>
</a>
{% endif %}
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
{% if casedetail.default_viewerstate %}
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
<i class="bi bi-check"></i>
</button>
{% endif %}
</div>
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
{% if can_edit %}
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk casedetail.collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?">
<button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
@@ -1,10 +1,10 @@
{# Partial: render questions and answers as HTML. #}
{% if case_detail.question_schema %}
{% if casedetail.question_schema %}
<div class="collection-question-block">
<h4>Questions</h4>
<dl class="row">
{% for name, prop in case_detail.question_schema.properties.items %}
{% for name, prop in casedetail.question_schema.properties.items %}
<dt class="col-sm-4">{{ prop.title|default:name }}</dt>
<dd class="col-sm-8">
{% if prop.description %}
@@ -18,8 +18,8 @@
{% endif %}
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
{% if case_detail.question_answers %}
{% with correct=case_detail.question_answers|get_item:name %}
{% if casedetail.question_answers %}
{% with correct=casedetail.question_answers|get_item:name %}
<div class="mt-2">
<strong>Example / Correct:</strong>
{% if correct %}
@@ -37,7 +37,7 @@
{% if user_answer %}
{% if user_answer|get_item:name %}
{% with ua=user_answer|get_item:name %}
{% with correct=case_detail.question_answers|get_item:name %}
{% with correct=casedetail.question_answers|get_item:name %}
<div class="mt-1">
<strong>Your answer:</strong>
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
@@ -0,0 +1,33 @@
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-secondary">Atlas</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,17 @@
{# Partial: series cases list. Expects `series` and `can_edit` in context. Returns the <ul> that is inserted into #cases-list. #}
<ul class="list-group list-group-flush">
{% for case in series.case.all %}
<li id="case-link-{{ case.pk }}" class="list-group-item d-flex justify-content-between align-items-center case-item">
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
{% if can_edit %}
<button hx-post="{% url 'atlas:remove_series_from_case' series.pk case.pk %}"
hx-target="#cases-list"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to remove this series from the case?"
class="btn btn-link btn-sm text-danger remove-button">Remove</button>
{% endif %}
</li>
{% empty %}
<li class="list-group-item">This series is not associated with any cases.</li>
{% endfor %}
</ul>
@@ -0,0 +1,31 @@
{% load static %}
<div class="series-images-list">
{% for image in images %}
<div class="d-flex align-items-center mb-2">
{% if image.image %}
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" target="_blank" class="me-2">
<img src="{{ image.image.url }}" alt="image-{{ image.pk }}" style="width:96px;height:auto;object-fit:cover;border-radius:.25rem;" class="img-thumbnail">
</a>
{% else %}
<div class="me-2 bg-secondary" style="width:96px;height:72px;border-radius:.25rem;"></div>
{% endif %}
<div class="flex-grow-1">
<div><strong>{{ image.image.name|default:"(no file)" }}</strong></div>
{% if image.image %}
<div class="small text-muted">{{ image.image.size|filesizeformat }}</div>
{% endif %}
{% if image.image_blake3_hash %}
<div class="small text-muted">hash: {{ image.image_blake3_hash|truncatechars:12 }}</div>
{% endif %}
</div>
<div class="ms-auto text-end">
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" class="btn btn-sm btn-outline-secondary">View DICOM</a>
</div>
</div>
{% empty %}
<p class="text-muted">No images found for this series.</p>
{% endfor %}
</div>
@@ -0,0 +1,23 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block js %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Add / Edit Pathological process</h2>
<p>Use this form to create or edit a pathological process.</p>
<p>Please check if it already <a href='{% url "atlas:pathological_process_view" %}'>exists</a> before doing so!</p>
<form action="" method="post" enctype="multipart/form-data" id="pathological-process-form">
{% csrf_token %}
{% crispy form %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary ms-2" href="{% url 'atlas:pathological_process_view' %}">Cancel</a>
</div>
</form>
{% endblock %}
@@ -0,0 +1,23 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block js %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Add / Edit Presentation</h2>
<p>Use this form to create or edit a presentation.</p>
<p>Please check if it already <a href='{% url "atlas:presentation_view" %}'>exists</a> before doing so!</p>
<form action="" method="post" enctype="multipart/form-data" id="presentation-form">
{% csrf_token %}
{% crispy form %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary ms-2" href="{% url 'atlas:presentation_view' %}">Cancel</a>
</div>
</form>
{% endblock %}
@@ -18,22 +18,8 @@
</div>
</div>
{% if collection %}
<div class="text-end mb-2">
<div class="small text-muted">Collection</div>
<div class="mb-1">
{% include "atlas/collection_detail.html#casedetails-management-links" %}
</div>
<div class="small">
{% if previous %}
<a class="me-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous</a>
{% endif %}
Viewing question as part of a collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{ collection.name }}</a>
[{{ case_number|add:1 }} / {{ collection_length }}]
{% if next %}
<a class="ms-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if collection %}
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_view" %}
{% endif %}
+91 -23
View File
@@ -5,32 +5,100 @@
{% include "atlas/resource_link_header.html" %}
{% endif %}
{{resource}}
<div class="container mt-3">
<div class="row">
<div class="col-lg-8">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ resource.name }}</h5>
<small class="text-muted">by {{ resource.get_authors|default:"Unknown" }}</small>
</div>
<div class="card-body">
{% if resource.description %}
<p class="card-text">{{ resource.description|linebreaksbr }}</p>
{% endif %}
Name: {{resource.name}}<br>
Description: {{resource.description}}<br>
URL: {{resource.url}}<br>
file: {{resource.file}}<br>
author: {{resource.get_authors}}<br>
<dl class="row">
{% if resource.url %}
<dt class="col-sm-3">URL</dt>
<dd class="col-sm-9"><a href="{{ resource.url }}" target="_blank" rel="noopener">{{ resource.url }}</a></dd>
{% endif %}
{{resource.case}}
{% if resource.file %}
<dt class="col-sm-3">File</dt>
<dd class="col-sm-9">
<a href="{{ resource.file.url }}" target="_blank" rel="noopener">Download</a>
{% if resource.file.name %}
<small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small>
{% endif %}
</dd>
{% endif %}
<div>
<a href="{% url 'atlas:resource_view' resource.pk %}" target="_blank">View Resource</a>
<dt class="col-sm-3">Source</dt>
<dd class="col-sm-9">{{ resource.get_display }}</dd>
</dl>
<div class="mt-3">
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:resource_view' resource.pk %}" target="_blank">Open resource page</a>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0">Used in cases</h6>
</div>
<div class="card-body">
{% if resource.case_set.all %}
<ul class="list-unstyled mb-0">
{% for case in resource.case_set.all %}
<li><a href="{% url 'atlas:case_detail' case.id %}">{{ case.title }}</a></li>
{% endfor %}
</ul>
{% else %}
<p class="mb-0 text-muted">This resource is not used in any cases.</p>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<aside class="card mb-3">
<div class="card-body">
<h6 class="card-title">Details</h6>
<ul class="list-unstyled small mb-0">
<li><strong>Author:</strong> {{ resource.get_authors|default:"-" }}</li>
<li><strong>Sites:</strong>
{% if resource.sites.all %}
{% for s in resource.sites.all %}
<div><a href="{% url 'generic:site_detail' s.pk %}">{{ s.name }}</a></div>
{% endfor %}
{% else %}
-
{% endif %}
</li>
<li class="mt-2"><strong>Subspecialty:</strong>
{% if resource.subspecialty.all %}
{% for sp in resource.subspecialty.all %}
<div>{{ sp.name }}</div>
{% endfor %}
{% else %}
-
{% endif %}
</li>
<li class="mt-2"><strong>Created:</strong> {{ resource.created_date|date:"Y-m-d H:i" }}</li>
<li><strong>Updated:</strong> {{ resource.modified_date|date:"Y-m-d H:i" }}</li>
</ul>
</div>
</aside>
<div class="d-grid gap-2">
<a class="btn btn-primary" href="{% url 'atlas:resource_update' resource.pk %}">Edit</a>
<a class="btn btn-outline-danger" href="{% url 'atlas:resource_delete' resource.pk %}">Delete</a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:resource_view' %}">Back to resources</a>
</div>
</div>
</div>
</div>
{% if resource.case_set.all %}
<h3>Cases</h3>
This resource is used in the following cases:
<ul>
{% for case in resource.case_set.all %}
<li><a href="{% url 'atlas:case_detail' case.id %}">{{case.title}}</a></li>
{% endfor %}
</ul>
{% else %}
<p>This resource is not used in any cases.</p>
{% endif %}
{% endblock %}
+7 -8
View File
@@ -1,16 +1,15 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content %}
<a href="{% url 'atlas:resource_create' %}">Create a new resource</a>
<h2>Resources</h2>
<ul id="resource-list">
{% for resource in object_list %}
<li class="">
<a href="{% url 'atlas:resource_detail' resource.pk %}">{{resource}}</a>
</li>
{% endfor %}
</ul>
<form>
{% render_table table %}
</form>
{% include "generic/partials/page_size_form.html" %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
{% endblock %}
+1 -1
View File
@@ -267,7 +267,7 @@
<form action="" method="post" enctype="multipart/form-data" id="atlas-form">
{% csrf_token %}
<a href="/generic/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<table>
{{ form.as_table }}
</table>
+339 -339
View File
@@ -1,363 +1,363 @@
{% load crispy_forms_tags %}
<div>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div>
<div>Description: {{series.description}}</div>
Associated case:
{% for case in series.case.all %}
<span id="case-link-{{ case.pk }}" class="case-item">
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
{% if can_edit %}
<button hx-post="{% url 'atlas:remove_series_from_case' series.pk case.pk %}"
hx-target="#case-link-{{ case.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to remove this series from the case?"
class="btn btn-link btn-sm button-unset remove-button">
Remove
</button>
{% endif %}
</span>
{% empty %}
This series is not associated with any cases.
{% if can_edit %}
<details>
<summary>Add Series to Case</summary>
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}"
hx-target="#add-series-to-case-result"
hx-swap="innerHTML">
{% csrf_token %}
<label for="case-select">Select Case:</label>
<select name="case_id" id="case-select" required>
{% for case in available_cases %}
<option value="{{ case.pk }}">{{ case.title }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Add to Case</button>
</form>
<div id="add-series-to-case-result"></div>
</details>
{% endif %}
{% endfor %}
<div>Author: {{ series.get_author_display }}</div>
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
</div> {% endcomment %}
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
style="max-width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
data-auto-cache-stack=false
></div>
{% if can_edit %}
{% if editing_finding < 1 %}
<button id="add-finding-button">Add finding</button>
<button id="clone-finding-button" title="Click to copy the details of a finding that is already associated with the series/case"
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
hx-target="#clone-findings-modal"
hx-trigger="click"
data-bs-toggle="modal"
data-bs-target="#clone-findings-modal"
>Clone existing finding</button>
{% endif %}
<button id="reset-viewport-button" class="btn btn-secondary">Reset viewport</button>
<div id="finding-form">
<div class="hide" id="hidden-form">
{% if editing_finding > 0 %}
<h3>Editing Finding</h3>
<p>Editing finding with ID: {{editing_finding}}</p>
{% else %}
<h3>Add Finding</h3>
<p>Click the button below to add a new finding.</p>
{% endif %}
<form method="post" id="series_finding_form">
{% csrf_token %}
{{series_finding_form|crispy}}
<input type="submit" value="Submit" class="btn btn-lg btn-success fw-bold px-4 py-2" style="font-size: 1.2rem;">
<button id="cancel-add-finding-button">Cancel</button>
</form>
</div>
</div>
{% else %}
<button id="reset-viewport-button">Reset viewport</button>
{% endif %}
<details class="styled-detail open">
<summary>Findings</summary>
{% for finding in series.findings.all %}
<div class="finding-box">
<button id="finding-{{finding.pk}}-3d" class="view-finding-button-3d" data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>Click to view</button>
<span class="view-finding-details">
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
Description: {{finding.description}}<br />
{% if request.user.is_superuser %}
<span _="on click toggle .hidden on next .extra-details">+</span>
<div class="hidden extra-details">
<h4>Annotation JSON</h4>
<pre>{{finding.annotation_json}}</pre>
<h4>Viewport JSON</h4>
<pre>{{finding.viewport_json}}</pre>
<h4>Image ID</h4>
<pre>{{finding.current_image_id_index}}</pre>
<h4>3D Annotation JSON</h4>
<pre>{{finding.annotation_json_3d}}</pre>
<h4>Viewer State</h4>
<pre>{{finding.viewer_state_3d}}</pre>
<div class="container my-4">
<div class="row">
<div class="col-12 mb-3">
<div class="card shadow-sm">
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
<div>
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
<div class="text-muted small">{{ series.plane }}, {{ series.contrast }}</div>
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
</div>
{% endif %}
</span>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
{% if can_edit %}
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
{% else %}
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
<div class="card">
<div class="card-body p-2">
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
<div id="root" class="dicom-viewer-root w-100" data-images="{{ image_url_array_and_count.0 }}"
style="height: 600px; box-sizing: border-box; background: #222;" data-auto-cache-stack=false>
</div>
{% endwith %}
</div>
<div class="card-footer bg-white d-flex flex-wrap gap-2 align-items-center">
{% if can_edit and editing_finding < 1 %}
<button id="add-finding-button" class="btn btn-primary btn-sm">Add finding</button>
<button id="clone-finding-button" class="btn btn-outline-primary btn-sm"
title="Clone an existing finding"
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
hx-target="#clone-findings-modal"
hx-trigger="click"
data-bs-toggle="modal"
data-bs-target="#clone-findings-modal">Clone finding</button>
{% endif %}
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
</div>
</div>
<div class="mt-3">
<!-- Finding form moved below the viewport -->
<div class="card mt-3">
<div class="card-header">Finding form</div>
<div class="card-body">
<div id="finding-form">
<div class="hide" id="hidden-form">
{% if editing_finding > 0 %}
<h5>Editing Finding</h5>
<p class="small">Editing finding with ID: {{editing_finding}}</p>
{% else %}
<h5>Add Finding</h5>
<p class="small">Click the button below to add a new finding.</p>
{% endif %}
<form method="post" id="series_finding_form">
{% csrf_token %}
{{series_finding_form|crispy}}
<div class="d-flex gap-2 mt-2">
<input type="submit" value="Submit" class="btn btn-success btn-sm">
<button id="cancel-add-finding-button" class="btn btn-secondary btn-sm">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
<details class="styled-detail open">
<summary>Findings</summary>
<div class="mt-2">
{% for finding in series.findings.all %}
<div class="card mb-2">
<div class="card-body d-flex flex-column flex-md-row justify-content-between gap-2">
<div>
<button id="finding-{{finding.pk}}-3d" class="btn btn-sm btn-outline-secondary view-finding-button-3d me-2"
data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>View 3D</button>
<strong>Finding(s):</strong>
{% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
<strong>Structure(s):</strong> {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
<strong>Condition(s):</strong> {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
<div class="mt-2">{{finding.description}}</div>
</div>
<div class="text-end">
{% if request.user.is_superuser %}
<button class="btn btn-sm btn-outline-dark mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#extra-{{finding.pk}}">Raw JSON</button>
<div class="collapse" id="extra-{{finding.pk}}">
<pre class="small bg-light p-2">{{finding.annotation_json}}</pre>
<pre class="small bg-light p-2">{{finding.viewport_json}}</pre>
</div>
{% endif %}
{% if can_edit %}
<div class="d-flex flex-column">
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="btn btn-sm btn-outline-primary mb-1">Edit</a>
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="btn btn-sm btn-outline-danger">Delete</a>
</div>
{% endif %}
</div>
</div>
</div>
{% empty %}
<p class="text-muted">No findings available.</p>
{% endfor %}
</div>
</details>
</div>
<!-- Images (loaded on demand via HTMX) -->
<div class="card mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Images</span>
<div>
<button id="load-images-button" class="btn btn-sm btn-outline-primary"
hx-get="{% url 'atlas:series_images' series.pk %}"
hx-target="#series-images-container"
hx-swap="innerHTML"
hx-trigger="click"
hx-on="htmx:beforeRequest: document.getElementById('images-loading-indicator').style.display='inline-block'; htmx:afterSwap: document.getElementById('images-loading-indicator').style.display='none'; document.getElementById('load-images-button').disabled = true">
Load images
</button>
<a href="{% url 'atlas:series_download' series.pk %}" class="btn btn-sm btn-outline-success ms-2" target="_blank" rel="noopener">Download</a>
<span id="images-loading-indicator" class="spinner-border spinner-border-sm ms-2" role="status" aria-hidden="true" style="display:none"></span>
</div>
</div>
<div class="card-body">
<div id="series-images-container">
<p class="text-muted small">Click 'Load images' to fetch.</p>
</div>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card mb-3">
<div class="card-header">Cases</div>
<div class="card-body">
<div id="cases-list">
{% include 'atlas/partials/series_cases_list.html' %}
</div>
{% if can_edit %}
<details id="add-series-to-case-details" class="mt-2">
<summary>Add Series to Case</summary>
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}" hx-target="#cases-list" hx-swap="innerHTML" class="mt-2">
{% csrf_token %}
<div class="mb-2">
<label for="case-select" class="form-label">Select Case</label>
<select name="case_id" id="case-select" class="form-select" required>
{% for case in available_cases %}
<option value="{{ case.pk }}">{{ case.title }}</option>
{% endfor %}
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Add to Case</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="document.getElementById('add-series-to-case-details').open = false">Close</button>
</div>
</form>
</details>
{% endif %}
</div>
</div>
<div class="card mb-3">
<div class="card-header">Actions</div>
<div class="card-body d-flex flex-column gap-2">
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
<a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
</div>
</div>
{% if can_edit %}
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a>
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
<div class="card">
<div class="card-header">Truncate series</div>
<div class="card-body">
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p>
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
<div class="mb-2">
<label class="form-label">Start</label>
<div class="input-group">
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
</div>
</div>
<div class="mb-2">
<label class="form-label">End</label>
<div class="input-group">
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
</div>
</div>
<div class="d-flex gap-2">
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
</div>
<div id="truncate-output" class="mt-2"></div>
</div>
</div>
{% endif %}
</div>
{% empty %}
<p>No findings available.</p>
{% endfor %}
</details>
{% if can_edit %}
<details class="styled-detail">
<summary>Truncate series</summary>
<p>
This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first.
</p>
<div role="alert" class="alert alert-warning">
<strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted.
</div>
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
<button id="truncate-test-button">Test truncate</button>
<button id="truncate-button">Trucate series</button>
<div id="truncate-output"></div>
</details>
{% endif %}
{% endwith %}
{% if can_edit %}
<details class="styled-detail">
<summary>Series info</summary>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_anonymise_dicom' pk=series.pk %}'" title="Anonymise dicom images">
Anonymise dicoms
</button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom' pk=series.pk %}'" title="orders dicom by slice location">
Order by slice location
</button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_instance' pk=series.pk %}'" title="orders dicom by instance number">
Order by instance number
</button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}'" title="orders dicom by SeriesInstanceUID">
Order by SeriesInstanceUID
</button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_upload_filename' pk=series.pk %}'" title="orders dicom by uploaded filename">
Order by uploaded filename
</button>
<button type="button" class="btn btn-outline-warning btn-sm" onclick="window.location.href='{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}'" title="split series by dicom tag ImageType">
Split by ImageType tag
</button>
</div>
<details>
<summary>
Image details
</summary>
<form hx-post="{% url 'atlas:image_diff' %}"
hx-target="#temp"
>
{% for image in series.get_images %}
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
{% if image.image %}
[{{image.get_file_size|filesizeformat}}]
{% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
<input type="checkbox" name="image-id" value="{{image.id}}">
<br />
{% endfor %}
<button>Diff files</button>
</form>
<span id="temp"></span>
<span id="diff-controls">show <span id="diff-show-all" _="on click
log '1'
for row in .diff.rows
show row
end">all</span> <span id="diff-show-diff" _="
on click
log 'go'
for row in <.diff tr/>
log row.children[0].innerHTML
if row.children[0].innerHTML is empty
hide row
end
end
">diff</span></span>
</details>
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
<p>
<button type="button" class="btn btn-outline-success btn-sm" onclick="window.location.href='{% url 'atlas:series_download' pk=series.pk %}'">
Download images
</button>
</p>
</details>
{% endif %}
<div id="clone-findings-modal"
class="modal modal-blur fade"
style="display: none"
aria-hidden="false"
tabindex="-1">
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
<div class="modal-content"></div>
</div>
</div>
<script>
$(document).ready(function () {
const apiKey = "root"
$("#set-lower-truncation-bound-button").click(() => {
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
});
$("#set-upper-truncation-bound-button").click(() => {
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
});
$("#truncate-test-button").click(() => {
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
const ok = window[`truncateStack_${apiKey}`](lower, upper);
if (ok) {
$("#truncate-output").text("The truncated images are shown above.");
}
});
$("#truncate-button").click(() => {
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
if (lower_bound >= upper_bound) {
alert("The lower bound must be less than the upper bound.");
return;
}
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#truncate-output")
}
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
<div class="modal-content"></div>
</div>
</div>
})
<script>
$(document).ready(function () {
const apiKey = "root"
$("#add-finding-button").click(() => {
$("#hidden-form").show()
$("#set-lower-truncation-bound-button").click(() => {
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
});
$("#set-upper-truncation-bound-button").click(() => {
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
});
$("#truncate-test-button").click(() => {
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
const ok = window[`truncateStack_${apiKey}`](lower, upper);
if (ok) {
$("#truncate-output").text("The truncated images are shown above.");
}
});
$("#truncate-button").click(() => {
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
if (lower_bound >= upper_bound) {
alert("The lower bound must be less than the upper bound.");
return;
}
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#truncate-output")
}
})
$("#add-finding-button").click(() => {
$("#hidden-form").show()
//dicom_element = $(".cornerstone-element").get(0);
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
//cornerstone.reset(dicom_element);
////cornerstone.resize(dicom_element, true);
$("#add-finding-button").hide()
$("#clone-finding-button").hide()
$("#add-finding-button").hide()
$("#clone-finding-button").hide()
//$("#finding-form").empty().append(
// $("#hidden-form form").clone()
//);
});
$("#cancel-add-finding-button").click((e) => {
$("#hidden-form").hide()
$("#add-finding-button").show()
$("#clone-finding-button").show()
e.preventDefault();
});
$("#reset-viewport-button").click(() => {
resetViewer_root();
});
$(".view-finding-button-3d").each((n, el) => {
$(el).click((e) => {
dicom_element = $(".cornerstone-element").get(0);
annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
importAnnotations_root(annotationjson3d);
importViewerState_root(viewer_state_json);
$(el).addClass("active");
});
});
{% if editing_finding > 0 %}
$("#hidden-form").show()
setTimeout(function() {
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
}, 1000)
{% endif %}
{% if request.GET.show_finding %}
function show_finding() {
console.log("show finding")
$("#finding-{{ request.GET.show_finding }}").trigger("click");
}
setTimeout(show_finding, 500);
{% endif %}
});
$("#cancel-add-finding-button").click((e) => {
$("#hidden-form").hide()
$("#add-finding-button").show()
$("#clone-finding-button").show()
$(document).on('submit', '#series_finding_form', function (e) {
e.preventDefault();
});
$("#reset-viewport-button").click(() => {
resetViewer_root();
});
$(".view-finding-button-3d").each((n, el) => {
$(el).click((e) => {
dicom_element = $(".cornerstone-element").get(0);
annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
importAnnotations_root(annotationjson3d);
importViewerState_root(viewer_state_json);
$(el).addClass("active");
$.ajax({
type: 'POST',
url: '{% url "atlas:add_finding" %}',
data: {
series_finding_id: {{ editing_finding }},
description: $('#id_description').val(),
series: {{ series.pk }},
annotation_json_3d: window.exportAnnotations_root(),
viewer_state_3d: window.exportViewerState_root(),
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
structures: JSON.stringify($('#finding-form select[name="structures"]').find(
":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
conditions: JSON.stringify($('#finding-form select[name="conditions"]').find(
":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
action: 'post'
},
success: function (json) {
$("#finding-form").empty();
toastr.info('Finding added.');
location.href='{{series.get_absolute_url}}';
},
error: function (xhr, errmsg, err) {
console.log(xhr.status + ": " + xhr
.responseText); // provide a bit more info about the error to the console
}
});
});
</script>
{% if editing_finding > 0 %}
$("#hidden-form").show()
setTimeout(function() {
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
}, 1000)
{% endif %}
{% if request.GET.show_finding %}
function show_finding() {
console.log("show finding")
$("#finding-{{ request.GET.show_finding }}").trigger("click");
}
setTimeout(show_finding, 500);
{% endif %}
});
$(document).on('submit', '#series_finding_form', function (e) {
e.preventDefault();
$.ajax({
type: 'POST',
url: '{% url "atlas:add_finding" %}',
data: {
series_finding_id: {{ editing_finding }},
description: $('#id_description').val(),
series: {{ series.pk }},
annotation_json_3d: window.exportAnnotations_root(),
viewer_state_3d: window.exportViewerState_root(),
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
structures: JSON.stringify($('#finding-form select[name="structures"]').find(
":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
conditions: JSON.stringify($('#finding-form select[name="conditions"]').find(
":selected")
.map((i, el) => {
return $(el).val()
}).toArray()),
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
action: 'post'
},
success: function (json) {
$("#finding-form").empty();
toastr.info('Finding added.');
location.href='{{series.get_absolute_url}}';
},
error: function (xhr, errmsg, err) {
console.log(xhr.status + ": " + xhr
.responseText); // provide a bit more info about the error to the console
}
});
});
</script>
{% endwith %}
<style>
.case-item {
@@ -426,14 +426,14 @@ Associated case:
transition: background 0.2s, font-size 0.2s;
}
details.styled-detail:not([open]) {
padding-bottom: 0;
min-height: 0;
}
details.styled-detail:not([open]) {
padding-bottom: 0;
min-height: 0;
}
details.styled-detail:not([open]) > *:not(summary) {
display: none;
}
details.styled-detail:not([open]) > *:not(summary) {
display: none;
}
details.styled-detail:not([open]) > summary {
padding: 0.7em 1.2em 0.3em 1.2em;
+2 -2
View File
@@ -14,8 +14,8 @@
</div>
<div>
<h3>Name: {{structure.name}}</h3>
Primary name: {{structure.primary}}<br />
Synonyms: {{structure.synonym.all|join:", "}}<br />
Primary name: {% if structure.canonical %}No (alias of <a href="{{ structure.canonical.get_absolute_url }}">{{ structure.canonical.name }}</a>){% else %}Yes{% endif %}<br />
Synonyms: {{ structure.get_synonym_link|safe }}<br />
</div>
<h4>Associated Cases</h4>
<ul>
@@ -0,0 +1,23 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block js %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Add / Edit Subspecialty</h2>
<p>Use this form to create or edit a subspecialty.</p>
<p>Please check if it already <a href='{% url "atlas:subspecialty_view" %}'>exists</a> before doing so!</p>
<form action="" method="post" enctype="multipart/form-data" id="subspecialty-form">
{% csrf_token %}
{% crispy form %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary ms-2" href="{% url 'atlas:subspecialty_view' %}">Cancel</a>
</div>
</form>
{% endblock %}
+8 -2
View File
@@ -7,7 +7,14 @@
{% block content %}
<div class="container-fluid">
<h2>My Collections</h2>
<div class="d-flex align-items-start justify-content-between">
<h2 class="mb-0">{{ model_verbose_name_plural|capfirst }}</h2>
{% if request.user.is_staff and create_url %}
<div>
<a class="btn btn-sm btn-primary" href="{{ create_url }}">Add new {{ model_verbose_name }}</a>
</div>
{% endif %}
</div>
{% comment %} <details>
<summary>
<h4>Filter</h4>
@@ -19,7 +26,6 @@
</form>
</div>
</details> {% endcomment %}
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div>
@@ -0,0 +1,24 @@
{% load i18n %}
<div class="btn-group" role="group" aria-label="{{ widget.name }}">
{% for val, label in widget.choices %}
{% with forloop.counter0 as idx %}
{% with id=widget.name|add:"_"|add:idx %}
<input
type="radio"
class="btn-check"
name="{{ widget.name }}"
id="{{ widget.name }}_{{ idx }}"
value="{{ val }}"
{% if val|stringformat:"s" == widget.value|stringformat:"s" %}checked{% endif %}
autocomplete="off"
>
<label
class="btn btn-outline-primary {% if val|stringformat:"s" == widget.value|stringformat:"s" %}active{% endif %}"
for="{{ widget.name }}_{{ idx }}"
>
{{ label }}
</label>
{% endwith %}
{% endwith %}
{% endfor %}
</div>
+73 -6
View File
@@ -34,6 +34,7 @@ urlpatterns = [
path("author/", views.author_list, name="author_list"),
path("case/", views.CaseView.as_view(), name="case_view"),
path("collection/<int:collection_id>/add_case", views.add_case_to_collection, name="add_case_to_collection"),
path("collection/case_search", views.case_search, name="case_search"),
path("collection/", views.CollectionView.as_view(), name="collection_view"),
path("collection/user", views.user_collections, name="user_collections"),
path("uploads", views.user_uploads, name="user_uploads"),
@@ -111,6 +112,11 @@ urlpatterns = [
views.CollectionCaseUpdate.as_view(),
name="collection_case_update",
),
path(
"collection/<int:pk>/case_jump",
views.collection_case_jump_partial,
name="collection_case_jump_partial",
),
path(
"collection/add_cases",
views.add_cases_to_collection,
@@ -167,30 +173,56 @@ urlpatterns = [
),
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
path(
"collection/<int:exam_id>/case/<int:case_id>/details",
"collection/<int:exam_id>/case/<int:case_number>/details",
views.collection_case_details,
name="collection_case_details",
),
# Backwards-compatible redirect from legacy case_id-style URLs
path(
"collection/<int:exam_id>/case/<int:case_id>/questions",
"collection/<int:exam_id>/case/id/<int:case_id>/details",
views.redirect_collection_case_details_by_id,
name="collection_case_details_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/questions",
views.collection_case_questions,
name="collection_case_questions",
),
path(
"collection/<int:exam_id>/case/<int:case_id>/priors",
"collection/<int:exam_id>/case/id/<int:case_id>/questions",
views.redirect_collection_case_questions_by_id,
name="collection_case_questions_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/priors",
views.collection_case_priors,
name="collection_case_priors",
),
path(
"collection/<int:exam_id>/case/<int:case_id>/dicom_json",
"collection/<int:exam_id>/case/id/<int:case_id>/priors",
views.redirect_collection_case_priors_by_id,
name="collection_case_priors_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/dicom_json",
views.collection_case_dicom_json,
name="collection_case_dicom_json",
),
path(
"collection/<int:exam_id>/case/<int:case_id>/dicom_json/review",
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json",
views.redirect_collection_case_dicom_json_by_id,
name="collection_case_dicom_json_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/dicom_json/review",
views.collection_case_dicom_json_review,
name="collection_case_dicom_json_review",
),
path(
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json/review",
views.redirect_collection_case_dicom_json_review_by_id,
name="collection_case_dicom_json_review_legacy",
),
path(
"collection/<int:exam_id>/question_schemas",
views.collection_question_schemas,
@@ -271,26 +303,52 @@ urlpatterns = [
views.collection_case_view,
name="collection_case_view",
),
# legacy path that used case PK; redirect to canonical case_number URL
path(
"collection/<int:collection_id>/<int:case_id>/display_setup",
"collection/<int:pk>/id/<int:case_id>",
views.redirect_collection_case_view_by_id,
name="collection_case_view_legacy",
),
path(
"collection/<int:collection_id>/<int:case_number>/display_setup",
views.collection_case_displaysetup,
name="collection_case_displaysetup",
),
path(
"collection/<int:collection_id>/id/<int:case_id>/display_setup",
views.redirect_collection_case_displaysetup_by_id,
name="collection_case_displaysetup_legacy",
),
path(
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
views.collection_case_view_take,
name="collection_case_view_take",
),
path(
"collection/<int:pk>/id/<int:case_id>/take/<int:cid>/<str:passcode>",
views.redirect_collection_case_view_take_by_id,
name="collection_case_view_take_legacy",
),
path(
"collection/<int:pk>/<int:case_number>/take/",
views.collection_case_view_take_user,
name="collection_case_view_take_user",
),
path(
"collection/<int:pk>/id/<int:case_id>/take/",
views.redirect_collection_case_view_take_user_by_id,
name="collection_case_view_take_user_legacy",
),
path(
"collection/<int:pk>/<int:case_number>/take/answers",
views.collection_case_view_take_user_answers,
name="collection_case_view_take_user_answers",
),
path(
"collection/<int:pk>/id/<int:case_id>/take/answers",
views.redirect_collection_case_view_take_user_answers_by_id,
name="collection_case_view_take_user_answers_legacy",
),
path(
"collection/<int:pk>/json_edit",
views.GenericExamViews.exam_json_edit,
@@ -348,6 +406,8 @@ urlpatterns = [
path("condition/", views.ConditionView.as_view(), name="condition_view"),
path("categories/", views.categories_list, name="categories_list"),
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
path("search/cases/", views.case_search_partial, name="case_search_partial"),
path("normals/", views.NormalCaseList.as_view(), name="normals_list"),
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
path(
"condition/<int:pk>/delete",
@@ -368,6 +428,7 @@ urlpatterns = [
path(
"subspecialty/<int:pk>", views.subspecialty_detail, name="subspecialty_detail"
),
path("subspecialty/create", views.SubspecialtyCreate.as_view(), name="subspecialty_create"),
path("condition/create", views.ConditionCreate.as_view(), name="condition_create"),
path("finding/", views.FindingView.as_view(), name="finding_view"),
path("finding/<int:pk>", views.finding_detail, name="finding_detail"),
@@ -396,6 +457,7 @@ urlpatterns = [
# TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
path("series/", views.SeriesView.as_view(), name="series_view"),
@@ -453,6 +515,9 @@ urlpatterns = [
# path("unchecked/", views.unchecked_list, name="unchecked_list"),
# path("verified/<int:pk>/", views.verified_detail, name="verified_detail"),
path("case/<int:pk>/", views.case_detail, name="case_detail"),
path("case/<int:pk>/toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"),
path("case/<int:pk>/normal_form/", views.case_normal_form, name="case_normal_form"),
path("case/<int:pk>/create_normal/", views.create_case_normal, name="case_create_normal"),
# path("case/<int:pk>/collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"),
path(
"case/<int:case_id>/collection-form",
@@ -538,6 +603,7 @@ urlpatterns = [
path(
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
),
path("presentation/create", views.PresentationCreate.as_view(), name="presentation_create"),
path(
"process/",
views.PathologicalProcessView.as_view(),
@@ -548,6 +614,7 @@ urlpatterns = [
views.pathological_process_detail,
name="pathological_process_detail",
),
path("process/create", views.PathologicalProcessCreate.as_view(), name="pathological_process_create"),
path("combine_series/", views.combine_series, name="combine_series"),
path(
"case/<int:case_id>/linked/",
+1054 -141
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
upstream app_server {
server web:8000;
}
server {
listen 80;
listen [::]:80;
server_name _;
client_max_body_size 100M;
location /static/ {
alias /usr/src/app/static/;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
location /media {
alias /usr/src/app/media;
}
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://app_server;
}
}
+127
View File
@@ -0,0 +1,127 @@
upstream app_server {
# In production we proxy to the 'web' service (gunicorn) listening on 8000
server web:8000;
}
server {
listen 80;
listen [::]:80;
server_name _;
client_max_body_size 100M;
# Serve static files from the static volume
location /static/ {
alias /usr/src/app/static/;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve media files from the media volume. CORS handling preserved.
set $cors "";
if ($http_origin ~* (https?://(localhost:5173|viewer\.penracourses\.org\.uk))) {
set $cors $http_origin;
}
location /media {
if ($request_method = OPTIONS ) {
add_header 'Access-Control-Allow-Origin' $cors always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Headers' "Origin, X-Requested-With, Content-Type, Accept" always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 200;
}
add_header 'Access-Control-Allow-Origin' $cors always;
alias /usr/src/app/media;
}
# Viewer / OHIF / other static frontends - mount these into the nginx container at deploy time
location /viewer {
alias /srv/www/viewer;
index index.html;
try_files $uri $uri/ /viewer/index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header 'Cross-Origin-Resource-Policy' 'cross-origin';
}
location /ohif {
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
alias /srv/www/ohif;
try_files $uri /ohif/index.html;
}
# rts and other host-mounted folders
location /rts {
alias /srv/www/rts;
}
location /rota {
alias /srv/proc/rota;
autoindex on;
}
location /uploader {
alias /srv/uploader;
autoindex on;
}
# Reporter proxy - if you run a reporter service, set it up on the 'reporter' network name
location ~ ^/reporter/(.*)$ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
proxy_pass http://reporter:5129/$1?$args;
proxy_set_header X-Forwarded-Prefix /reporter;
proxy_redirect http://reporter:5129/ /reporter/;
}
# Default proxy to the Django/gunicorn upstream
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_redirect off;
proxy_buffering off;
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
proxy_pass http://app_server;
}
# Default dev / health server block; keep 443 managed outside of docker unless you mount certs
}
# A minimal HTTPS server block that expects certs to be mounted at /etc/letsencrypt
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.penracourses.org.uk penracourses.org.uk viewer.penracourses.org.uk;
ssl_certificate /etc/letsencrypt/live/www.penracourses.org.uk/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/www.penracourses.org.uk/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 4G;
# Same static/media handling as above
location /static/ { alias /usr/src/app/static/; }
location /media { alias /usr/src/app/media; }
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://app_server;
}
}
+122
View File
@@ -0,0 +1,122 @@
"""
Example `settings_local.py` derived from the production server file.
This file is a template only DO NOT commit real secrets. Copy it to
`rad/deploy/settings_local.py` on your server (or provide your own) and
ensure the real file is excluded from git.
"""
import os
DEBUG = True
INTERNAL_IPS = ["82.69.88.125", "217.155.198.96", "localhost", "127.0.0.1"]
# When running in Docker (dev), requests will often come from an internal
# container/network address. Add any discovered host/container IPs (and a
# likely gateway address) to INTERNAL_IPS so the debug toolbar can render.
if DEBUG:
try:
import socket
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
for ip in ips:
if ip not in INTERNAL_IPS:
INTERNAL_IPS.append(ip)
# common docker gateway pattern: replace last octet with '1'
parts = ip.split('.')
if len(parts) == 4:
parts[-1] = '1'
gw = '.'.join(parts)
if gw not in INTERNAL_IPS:
INTERNAL_IPS.append(gw)
except Exception:
# best-effort only; don't fail startup if this lookup doesn't work
pass
# In local/dev enable the toolbar unconditionally to avoid IP/proxy
# headaches while developing inside Docker/behind nginx.
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
}
# Paths inside the nginx container / host-mounted deploy directories.
# On the server, these paths are mounted into nginx as described in the
# deployment README. Adjust as needed.
STATIC_ROOT = '/usr/src/app/static/'
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
if not DEBUG:
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
'datefmt': "%d/%b/%Y %H:%M:%S",
},
'simple': {
'format': '%(levelname)s %(message)s',
},
},
'handlers': {
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': 'log.txt',
'formatter': 'verbose',
},
},
'loggers': {
'django': {
'handlers': ['file'],
'propagate': True,
'level': 'DEBUG',
},
'atlas': {
'handlers': ['file'],
'level': 'DEBUG',
},
}
}
# External service credentials (placeholders)
CIMAR_USERNAME = "your.username@example.com"
CIMAR_PASSWORD = "<REPLACE-WITH-SECRET>"
# Celery settings — when running in docker the redis service is
# reachable at the `redis` hostname provided by docker compose.
CELERY_BROKER_URL = "redis://redis:6379"
CELERY_RESULT_BACKEND = "redis://redis:6379"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": os.environ.get("DB_NAME", "django"),
"USER": os.environ.get("DB_USER", "django"),
"PASSWORD": os.environ.get("DB_PASSWORD", "AVNS_NG_s4i7SMMobWLO"),
#"HOST": os.environ.get("DB_HOST", "db-postgresql-lon1-05515-do-user-8165014-0.b.db.ondigitalocean.com"),
"HOST": os.environ.get("DB_HOST", "db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com"),
"PORT": os.environ.get("DB_PORT", "25060"),
#"NAME": os.environ.get("DB_NAME", "django"),
#"USER": os.environ.get("DB_USER", "django"),
#"PASSWORD": os.environ.get("DB_PASSWORD", "f7bf31dc9bda1256ea827953480d1917"),
#"HOST": os.environ.get("DB_HOST", "161.35.163.87"),
#"PORT": os.environ.get("DB_PORT", "5432"),
}
}
# Development overrides to avoid forcing HTTPS and secure cookies in local dev
# (production should keep these True).
SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SECURE_HSTS_SECONDS = 0
# Allow requests from the local dev nginx/dev server and direct runserver
# (include scheme+host+port for Django's origin checking).
CSRF_TRUSTED_ORIGINS = [
"http://127.0.0.1:8000",
"http://localhost:8000",
]
REMOTE_URL = "http://127.0.0.1:8000"

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