Compare commits

...
769 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
Ross d3669e8316 Uncomment table control creation logic to ensure selectable tables have row-selection controls 2025-11-03 15:54:15 +00:00
Ross b92a969a3b Refactor index and view templates to improve layout; remove unnecessary details tag for cleaner code 2025-11-03 15:36:15 +00:00
Ross c45d7a5464 Update navbar dropdown toggle to maintain active state for improved user experience 2025-11-03 15:29:27 +00:00
Ross b08154f804 Refactor index.html layout for improved responsiveness and user navigation; update QuestionForm to use responsive columns for better display on small screens. 2025-11-03 13:47:03 +00:00
Ross 6d02b35f86 Add Crispy Forms integration to QuestionForm and update question_form template for improved layout and usability 2025-11-03 13:39:35 +00:00
Ross 3a5ceabff0 Refactor auth group dropdown in profile and fragment templates for improved functionality and user experience 2025-11-03 12:37:32 +00:00
Ross 3461e45f93 Refactor user group management by moving auth groups to profile.html and creating a dedicated auth_groups_fragment.html for better organization and clarity 2025-11-03 12:31:49 +00:00
Ross eec04e3197 Refactor user scores and index templates for improved layout and help information presentation 2025-11-03 12:27:20 +00:00
Ross 68dc7c05d8 Enhance user group management by adding support for Django auth groups and improving group toggle functionality for superusers and user managers 2025-11-03 12:25:29 +00:00
Ross ff02e3f02f Update profile link button style to use outline-light variant for better visibility 2025-11-03 12:23:25 +00:00
Ross 3db74607ce Enhance index.html with login prompts for unauthenticated users and additional help information for external users 2025-11-03 12:20:25 +00:00
Ross 37eae568f0 Revamp index.html layout with improved user authentication display and enhanced profile information section 2025-11-03 12:13:19 +00:00
Ross 772e852763 Implement user group management with toggle functionality for superusers 2025-11-03 12:10:58 +00:00
Ross d3a39d11a5 Add permission groups display and admin edit link to user profile 2025-11-03 12:04:06 +00:00
Ross 9cc4ea3cb0 Revamp user profile layout with enhanced styling and improved group and exam display 2025-11-03 11:58:57 +00:00
Ross d93483db26 Refactor permission checks in UpdateQuestionMixin to include author and reviewer group validation 2025-11-03 11:53:45 +00:00
Ross aff8d205ed Remove filter options section from user view template and update permission checks in toggle function 2025-11-03 11:44:14 +00:00
Ross e58f070ae2 Enhance question review functionality with latest review status annotation and display in templates 2025-11-03 11:37:09 +00:00
Ross 7a00efdf39 Add filtering option for questions reviewed by the current user in question review list 2025-11-03 11:21:17 +00:00
Ross e173427d84 Fix HTML escaping for question titles in question review list 2025-11-03 11:11:13 +00:00
Ross 3c1bd40ce6 Add user and supervisor search endpoints for trainees bulk-update UI 2025-11-03 11:08:02 +00:00
Ross 0679a0ec13 Refactor bulk delete error handling and update user search visibility for superusers 2025-11-03 10:59:18 +00:00
Ross 5d91b4a4ff Adjust padding in floating filter for improved layout consistency 2025-11-03 10:53:18 +00:00
Ross 83a60203f7 Enhance filtering functionality and UI consistency across user-related views by implementing a generic search filter and updating filter display in templates. 2025-11-03 10:47:40 +00:00
Ross 2d0098b2b6 Merge branch 'master' of https://git.xkjq.uk/ross/penracourses 2025-11-03 10:38:27 +00:00
Ross d4aa3e73e2 Merge branch 'master' of https://git.xkjq.uk/ross/penracourses 2025-11-03 10:34:46 +00:00
Ross 8dbc39a317 Refactor filter bar implementation for consistency and improved usability across case and question views 2025-11-03 10:34:07 +00:00
Ross 66f9dadcc8 Normalize supervisor emails and add unique constraint for case-insensitive email uniqueness 2025-11-03 10:30:45 +00:00
Ross 7d079e0466 Update Dockerfile to use Python 3.14 and fix docker-compose volume definitions 2025-11-03 10:25:20 +00:00
Ross 3ea384542d Enhance user list handling by adding supervisor email input and updating JSON submission logic 2025-11-03 09:46:24 +00:00
Ross 7eeb989b9e Enhance supervisor email handling and input format options in bulk user creation 2025-11-03 09:31:55 +00:00
Ross 206f0afd32 Refactor QuestionTable to inherit Meta options from SelectionTable for consistency 2025-11-01 21:42:33 +00:00
Ross 0e7307de18 Remove row selection controls from case and question table views for cleaner UI 2025-11-01 21:31:13 +00:00
Ross 09d9befceb Implement row selection controls in templates and refactor related JavaScript functionality 2025-11-01 21:20:10 +00:00
Ross 62c26d199e Refactor table row selection logic and remove associated CSS styles for improved clarity and maintainability 2025-11-01 21:04:01 +00:00
Ross cbf4c3497d Ensure selection column is always placed last in the column order for improved table layout 2025-11-01 20:57:35 +00:00
Ross a269cf1f52 Add row selection functionality with toggle and count updates in table view 2025-11-01 20:56:07 +00:00
Ross 57a3b02da3 Implement selection-enabled table base class and update QuestionTable to inherit from it 2025-11-01 20:45:59 +00:00
Ross 8e8041cdc4 Enhance row selection functionality by updating row highlight based on checkbox state and managing visibility of selection column 2025-11-01 13:21:31 +00:00
Ross ad4b06d691 Remove row-selector class from QuestionTable attributes for improved styling 2025-11-01 13:16:55 +00:00
Ross b1f0f9932f Add row selection functionality to question table view; enhance user interaction 2025-11-01 13:07:46 +00:00
Ross b32d265c2c Add buttons for copying and downloading prompts in LLM view; enhance user interaction 2025-11-01 08:27:19 +00:00
Ross 0de7c345a2 Refactor case display to enhance layout and add subspecialty, pathological process, and diagnostic certainty badges 2025-11-01 08:15:30 +00:00
Ross 77edb83688 Refactor button group in case display for improved styling and alignment 2025-10-31 22:00:07 +00:00
Ross f921a46b5f Refactor case display buttons for improved usability and styling 2025-10-31 21:44:11 +00:00
Ross 7116983e3d Add subspecialty, pathological process, and diagnostic certainty to case display 2025-10-31 21:33:25 +00:00
Ross c0d71dfabc Refactor card layout in case display for improved responsiveness 2025-10-31 21:27:57 +00:00
Ross 55371a9d0b Refactor case display layout for improved organization and responsiveness 2025-10-31 20:56:33 +00:00
Ross 6bc43135b9 Refactor question link header for improved layout and button styling 2025-10-30 21:31:53 +00:00
Ross b294cf310f Refactor condition detail view for improved layout and styling 2025-10-30 21:27:38 +00:00
Ross 3805c701d3 Preserve existing skip value when navigating to the next matching item 2025-10-29 21:43:27 +00:00
Ross 76d0db0e94 Add FRCR toggle functionality to question detail view 2025-10-29 21:37:19 +00:00
Ross dca4ff249a Add 'frcr_appropriate' field to Question model for exam relevance 2025-10-29 21:03:01 +00:00
Ross f4cc3cc5b4 Enhance navbar items with icons for improved visual clarity and user experience 2025-10-28 21:58:02 +00:00
Ross 1dc02d1fc9 Fix supervisor link visibility condition for improved access control 2025-10-28 21:34:10 +00:00
Ross ab89ca22c9 Enhance navbar and form usability with improved styles for touch targets and typography 2025-10-28 21:27:34 +00:00
Ross ab29a7adf6 Enhance navbar usability with improved tap targets and mobile dropdown behavior 2025-10-28 21:11:25 +00:00
Ross 937edde023 Fix navbar collapse ID for proper functionality in base template 2025-10-28 21:06:38 +00:00
Ross 299ac317e3 Add search prompt to category search form and comment out submit button 2025-10-28 21:04:00 +00:00
Ross c45410b5e5 Refactor category search form to enhance usability with advanced filters and improved loading indicators 2025-10-28 18:02:27 +00:00
Ross 11cd044485 Add category filtering options to search functionality 2025-10-28 17:51:37 +00:00
Ross 4a3055cab8 Enhance category search form with loading indicator and improved HTMX integration 2025-10-28 17:45:41 +00:00
Ross 945a6e5e86 Add category search functionality with results display 2025-10-28 17:40:52 +00:00
Ross 85229124de Remove unused category and filter options from question overview page 2025-10-28 08:58:29 +00:00
Ross 2676cb49a1 Add advanced filtering options to question overview page 2025-10-28 08:40:21 +00:00
Ross 15791fa883 Enhance question overview filters to support multi-selection for categories, findings, structures, conditions, subspecialties, and presentations 2025-10-28 08:35:03 +00:00
Ross 15c243172d Add filtering options for findings, structures, conditions, subspecialties, and presentations in question overview 2025-10-28 08:23:42 +00:00
Ross 293b5b6682 Add question overview page with filtering options and counts by category 2025-10-28 08:15:10 +00:00
Ross 82ce6b0f93 Refactor exam_take.html for improved layout by making columns full-width and adjusting padding for better responsiveness 2025-10-28 07:51:07 +00:00
Ross 02e844006d t 2025-10-27 23:10:14 +00:00
Ross 4cce444153 Refactor exam_take.html for improved layout, accessibility, and user experience 2025-10-27 22:23:52 +00:00
Ross f462002162 Update Django version to 5.2.7 in requirements.txt 2025-10-27 22:11:55 +00:00
Ross b10110d50f Refactor question_form.html for consistent formatting and improved readability 2025-10-27 21:33:14 +00:00
Ross 640f1841e6 Refactor docker-compose.yml for improved context and volume mounts; update question_form.html to conditionally include question details 2025-10-27 21:30:01 +00:00
Ross a491337a2e Add merge category functionality for admin users with confirmation prompt 2025-10-27 20:23:44 +00:00
Ross 65e95b698d Include active filters in navigation for review-next and skip actions 2025-10-27 17:01:46 +00:00
Ross e503576e7a Enhance trainees table with sortable columns and improved bulk edit options 2025-10-27 15:22:41 +00:00
Ross fba8d5ab05 Add supervisor search functionality with HTMX support for manual selection in bulk updates 2025-10-27 14:43:41 +00:00
Ross be94ed8d0f Refactor HTML structure and indentation in trainees bulk update template for improved readability 2025-10-27 14:35:59 +00:00
Ross 668007b899 Implement user search functionality with HTMX support for manual user selection in bulk updates 2025-10-27 14:32:03 +00:00
Ross 1c8f862303 Add user search functionality for manual trainee matching in bulk update 2025-10-27 14:26:55 +00:00
Ross 1f2a25c3ad Set 'UNREVIEWED' as the default selected option in review status dropdown 2025-10-27 14:21:09 +00:00
Ross 690cabe30c Update default status filter to return only unreviewed questions if not specified 2025-10-27 14:13:41 +00:00
Ross 4dc07b1860 Refactor trainees bulk update logic to improve user matching criteria and enhance clarity in code comments 2025-10-27 14:03:21 +00:00
Ross d9602eeae9 Add bulk update functionality for trainees from spreadsheet input 2025-10-27 13:07:50 +00:00
Ross 5d0eb56b37 Remove note about question review status from exam generation form 2025-10-27 13:00:09 +00:00
Ross 64238f4052 Add exam generation functionality with customizable name and filters 2025-10-27 12:58:13 +00:00
Ross ef26182b1a Refactor personal exam creation section for improved readability and structure 2025-10-27 12:42:34 +00:00
Ross 3f56af4881 Add functionality to create personal exams from filtered questions and update exam overview with edit option 2025-10-27 12:39:44 +00:00
Ross 473efbc8f4 Add source display to question review and improve wording in review start template 2025-10-27 12:02:56 +00:00
Ross 6e8c86ab10 Add question review list view and template with filtering options 2025-10-27 12:00:05 +00:00
Ross 511b3c3777 Add skip and next buttons to question review with corresponding JavaScript functionality 2025-10-27 11:54:02 +00:00
Ross a38c31ca87 Fix template path for question review block and remove unnecessary form wrapper to prevent nested forms 2025-10-27 11:27:59 +00:00
Ross 74c5f4d8fd Enhance question review template with active filters and improved metadata display 2025-10-27 11:19:07 +00:00
Ross 621e95cfd5 Add question title field to QuestionForm and improve question review template for better feedback display 2025-10-27 10:56:51 +00:00
Ross 3dc38ef57a Add filters to question review rendering for improved context 2025-10-27 10:39:53 +00:00
Ross 27e575f772 Add QuestionReviewForm to question review view and update template to include form 2025-10-27 10:38:49 +00:00
Ross a404a335c0 Refactor question review template for improved readability and structure; enhance question title display and answer rendering 2025-10-27 10:28:40 +00:00
Ross 8380ca1dd9 Implement question review flow with category and status filters; add templates for review start, question display, and completion 2025-10-27 10:21:07 +00:00
Ross ec45464e58 Refactor volume mounts in docker-compose.yml for clarity and consistency 2025-10-27 10:07:35 +00:00
Ross 702b7b35a7 Refactor docker-compose.yml build context and add question review URL and view; create question_review.html template 2025-10-27 09:47:35 +00:00
Ross 4f72de6be9 Add styling for correct-answer badge to allow text wrapping 2025-10-26 21:08:19 +00:00
Ross 9c68c4ff1c Add HTMX trigger to close review modal after saving 2025-10-26 21:06:34 +00:00
Ross b26d31b216 Refactor docker-compose.yml to mount the entire repository and update question_review_form.html to render radio buttons for status choices. 2025-10-26 20:46:16 +00:00
Ross c2d6cfa78e add local dev docker 2025-10-26 19:30:48 +00:00
Ross e687b55e6a Enhance QuestionReviewForm by adding autofocus to comment field and improve status button rendering with active state 2025-10-25 17:28:50 +01:00
Ross d19e937bd4 Enhance QuestionReviewForm layout with Bootstrap styling for status buttons and improve comment field presentation 2025-10-22 22:05:33 +01:00
Ross fa68034f03 Add 'In Progress' status to QuestionReview model for enhanced tracking 2025-10-22 22:02:38 +01:00
Ross 68f45c8561 Refactor QuestionReviewForm to use RadioSelect for status and update template for improved layout 2025-10-22 22:01:49 +01:00
Ross be68fcd0b9 Add QuestionReviewForm for handling question review submissions and update views for form integration 2025-10-22 22:00:05 +01:00
Ross e819645278 Remove Edit and Cancel buttons from review templates for cleaner UI 2025-10-22 21:56:11 +01:00
Ross 9efec6b79b Update button label for reviews history and relocate modal definition for clarity 2025-10-22 21:53:37 +01:00
Ross 3fb5bc2ae8 Add hx-trigger attribute to History button for improved interaction 2025-10-22 21:48:19 +01:00
Ross 6700c6a7d2 Add question reviews functionality with modal display and listing 2025-10-22 21:39:19 +01:00
Ross 55621b2dcc Refactor question review and form templates to use innerHTML for HTMX swaps, enhancing dynamic content updates 2025-10-22 21:32:57 +01:00
Ross b417266e56 Add QuestionReview model and implement review functionality with HTMX integration 2025-10-22 21:00:01 +01:00
Ross 6d589ef630 Add QuestionReview model and link to QuestionBase for enhanced review functionality 2025-10-22 20:15:20 +01:00
Ross 8addc900f5 Refactor exam overview layout to enhance responsiveness and integrate action buttons for improved user interaction 2025-10-21 22:47:22 +01:00
Ross a055e138c4 Enhance exam overview layout with responsive design improvements and candidate count buttons for better user interaction 2025-10-21 22:32:40 +01:00
Ross 132f3c48c9 Add hx-disabled-elt attribute to exam status buttons for improved user experience 2025-10-20 23:11:31 +01:00
Ross 07f121fb69 Refactor exam status display to use buttons for publish results and exam active states, enhancing user interaction and visual clarity. 2025-10-20 23:03:40 +01:00
Ross c7fa3a096c Enhance question display in exam overview by adding question titles and improved numbering format 2025-10-20 22:58:54 +01:00
Ross 196d472608 Add question numbering to exam overview for better clarity 2025-10-20 22:55:25 +01:00
Ross 1a04064e6b Refactor exam overview layout for improved readability and structure, enhancing question display with better styling and organization. 2025-10-20 22:53:35 +01:00
Ross a49f198d26 Add render_best_answer method to capitalize first character of best_answer 2025-10-20 22:49:37 +01:00
Ross 8663482a6b Enhance QuestionFilter with title and stem search capabilities and update fields in Meta class 2025-10-20 22:42:16 +01:00
Ross 5e4fea38d7 Fix layout issue by closing div tag for import items in preview 2025-10-20 22:36:47 +01:00
Ross 45e50e9e93 Enhance import preview functionality with editable fields and overlay logic for payload updates 2025-10-20 22:35:01 +01:00
Ross 8dac99b6d3 Add alert for stripped numeric references in import preview and refactor payload cleaning function 2025-10-20 22:23:10 +01:00
Ross a4f4cb9e96 Add function to strip numeric references from feedback fields in import_llm_questions 2025-10-20 22:16:51 +01:00
Ross 5e84ba3178 Enhance validation process in import_llm_questions to handle additionalProperties errors and clean payloads for improved data integrity 2025-10-20 22:03:36 +01:00
Ross 4050193cec Add validation error alerts to import preview for question items 2025-10-20 21:57:27 +01:00
Ross c975b3c967 Enhance exam overview and status templates with improved layout and functionality for open access management and publish results status. 2025-10-20 21:55:19 +01:00
Ross 329394dcf0 Refactor exam overview template for improved layout and accessibility, adding alerts for JSON status and user group requirements. 2025-10-20 21:37:59 +01:00
Ross 6cb1303630 Add bulk open access management for exam questions in overview template 2025-10-20 21:35:20 +01:00
Ross 9b9cd8c813 Add HTMX endpoint to set open access for exam questions and corresponding result template 2025-10-20 21:33:03 +01:00
Ross 593eafd427 Update view cell in QuestionTable to display SVG icon for open access questions 2025-10-20 21:28:18 +01:00
Ross 7f68fc23c6 Update question detail template to improve exam and finding link visibility with white text 2025-10-20 21:23:02 +01:00
Ross bad0d400a7 Add debug logging for POST requests in generic_exam_htmx and update exam selection form to include checked selections 2025-10-20 21:20:32 +01:00
Ross ae644f9c03 Restrict exam visibility in generic_exam_htmx based on user permissions 2025-10-20 21:18:46 +01:00
Ross 5b8896e2b2 Update exam query in generic_exam_htmx to remove limit on results 2025-10-20 21:16:47 +01:00
Ross 66711f5c15 Add HTMX support for exam selection and update related templates and views 2025-10-20 21:12:42 +01:00
Ross d342e7e047 Implement exam selection feature with HTMX support in exam edit view and add exam selection fragment template 2025-10-20 21:01:03 +01:00
Ross bdb7aff5d1 Add open_access field to Question model and update question detail template 2025-10-20 20:56:37 +01:00
Ross ac9faeccbd Remove unnecessary dark mode media query for cleaner CSS in question table view 2025-10-20 17:50:06 +01:00
Ross 1e7d765fc2 Refactor filter section in question table view for improved layout, usability, and dark mode support 2025-10-20 17:48:20 +01:00
Ross e3055e3998 Normalize whitespace and strip <p> wrappers in question answers and feedback 2025-10-20 17:20:48 +01:00
Ross f6f208b065 Refactor filter section layout in question table view for improved responsiveness and styling 2025-10-20 17:16:32 +01:00
Ross 815d853599 Refactor filter section in question table view for improved usability and styling 2025-10-20 17:05:31 +01:00
Ross 9cffdb3636 Refactor filter section layout in question and table views for improved styling and structure 2025-10-20 16:54:32 +01:00
Ross a2a43e51f9 Refactor filter section layout in question view for improved styling and structure 2025-10-20 16:33:45 +01:00
Ross 101326fea2 Enhance styling of correct answer display in question detail view 2025-10-20 16:31:16 +01:00
Ross b157fe8b00 Refactor question detail view for improved layout and styling 2025-10-20 16:26:59 +01:00
Ross 33b6d0d935 Add title display to question detail view 2025-10-20 16:18:33 +01:00
Ross 3daad1b83b Add Subspecialty autocomplete and filter to QuestionFilter 2025-10-20 16:17:07 +01:00
Ross 512b222ccc Add filters for condition, structure, finding, and presentation to QuestionFilter 2025-10-20 16:05:41 +01:00
Ross 5294c5f2f6 Add display of related SBA questions in finding, presentation, and structure detail views 2025-10-20 15:59:27 +01:00
Ross 4f47354ca1 Add display of related SBA questions in condition detail view 2025-10-20 14:47:47 +01:00
Ross bc21dd6ebd Add ordering by created date to QuestionTable and update import preview layout for better readability 2025-10-20 13:53:08 +01:00
Ross 5d09c34f6d Move feedback display to a more prominent position in question detail view 2025-10-20 13:43:09 +01:00
Ross 7e7a1b8763 Add edit link column to QuestionTable for improved item management 2025-10-20 13:39:23 +01:00
Ross 272c3381e1 Remove title from QuestionTable sequence and enhance render_stem method to include title in output 2025-10-20 13:08:06 +01:00
Ross 4a3a089941 Update QuestionTable sequence to include title for improved data representation 2025-10-20 13:05:07 +01:00
Ross aac4146b7f Add category display to import preview for better item context 2025-10-20 13:04:37 +01:00
Ross 42b64c11ff Refactor actions section in case view template for improved readability and structure 2025-10-20 13:01:14 +01:00
Ross 2a94e40a1e Update QuestionForm to increase TinyMCE editor height for better visibility and modify import_llm_confirm to set question title directly from payload 2025-10-20 12:58:17 +01:00
Ross 4d305e1fa2 Add logging for bulk delete request and update button for selected questions deletion 2025-10-20 12:54:17 +01:00
Ross 452143f1ef Enhance bulk delete functionality by adding user confirmation before submission and removing debug information from the response when no items are deleted. 2025-10-20 12:41:33 +01:00
Ross 264270ce00 Enhance bulk delete functionality to support multiple checkbox naming conventions and improve debug information display when no items are deleted. 2025-10-20 12:39:59 +01:00
Ross 980bb199cf Add bulk delete functionality for questions with confirmation feedback 2025-10-20 12:32:59 +01:00
Ross 1085b9e6a2 Update import_llm_questions to set question title directly from payload 2025-10-20 12:22:06 +01:00
Ross 52886d176b Remove exams column from QuestionTable and add current user as author during question import 2025-10-20 12:21:02 +01:00
Ross 4ad101d65e Add sources display to question detail view for better reference tracking 2025-10-20 12:13:08 +01:00
Ross def291c853 Fix source display formatting and improve missing_map handling in import preview and confirmation 2025-10-20 12:11:00 +01:00
Ross 9dd4fc0402 Add sources display to import preview for better reference tracking 2025-10-20 12:06:47 +01:00
Ross 8f5ce830fe Refactor M2M toggle functionality to synchronize button states and improve placeholder updates 2025-10-20 12:06:10 +01:00
Ross b680ae749f Add sources JSONField to Question model and update import functions to handle source references 2025-10-20 12:04:10 +01:00
Ross 441d33a382 Add sources field to Question model for reference tracking 2025-10-20 12:02:18 +01:00
Ross bddccd5768 Enhance import preview and backend handling for M2M relationships, adding sources to schema and improving missing values mapping for better template rendering and debugging. 2025-10-20 12:01:07 +01:00
Ross 88713aba5b Clarify statdx source reference format and enhance sources description in LLM prompt schema 2025-10-20 11:54:31 +01:00
Ross c577da4779 Enhance LLM import preview with detailed M2M field handling and improved exclusion toggle functionality 2025-10-20 11:54:12 +01:00
Ross 16387fff77 Refactor LLM import preview to improve layout and remove redundant controls 2025-10-20 11:41:01 +01:00
Ross d37376d998 Enhance LLM question import and detail views with feedback display and source references 2025-10-20 11:39:43 +01:00
Ross 80171da78d Enhance LLM import preview with improved question display and missing M2M handling 2025-10-20 11:14:44 +01:00
Ross 9be48140a4 Enhance LLM question import preview with detailed payload information and improved answer display 2025-10-20 11:04:33 +01:00
Ross 01f67f3e32 Enhance LLM question import process with preview and confirmation steps, including error handling and M2M record creation 2025-10-20 11:01:47 +01:00
Ross 06b7239daa Add LLM question import functionality with enhanced validation and M2M handling 2025-10-20 10:47:11 +01:00
Ross 93f0bb8927 Add LLM question import functionality and enhance question model with new fields 2025-10-20 10:43:12 +01:00
Ross 4986c2faa9 Add sync functionality for prerequisite users and enhance result display 2025-10-13 14:18:34 +01:00
Ross 2c30145d3a Add sync functionality for prerequisite users in collection detail 2025-10-13 14:14:17 +01:00
Ross eeef34bffc Add prerequisites handling to CaseCollection and implement user access checks 2025-10-13 13:46:01 +01:00
Ross a3712959ec Enhance form rendering with crispy forms and improve answer timestamp handling in collection views 2025-10-13 13:22:26 +01:00
Ross ed4bd95955 Refactor self-review display logic and enhance user answer feedback in collection templates; improve form serialization for timeout handling 2025-10-13 11:48:13 +01:00
Ross 32d8430f94 Add CID user support in collection history and reset answers functionality 2025-10-13 11:28:07 +01:00
Ross fa08f9cf76 Implement timed-out submission handling and AJAX response for collection case view 2025-10-13 10:32:25 +01:00
Ross 1cf18c6f0d Fix button formatting for user answer reset functionality 2025-10-13 10:26:11 +01:00
Ross 41d1fa605b Fix button formatting and ensure proper user checks in collection reset answers template 2025-10-13 10:05:43 +01:00
Ross a355dd223e Add timestamps for answer tracking in report models and views; enhance timer display in templates 2025-10-13 10:02:37 +01:00
Ross 6f99cf0d53 Add question time limit to CaseCollection model and update related template for countdown display 2025-10-13 09:45:12 +01:00
Ross 682e8160b5 Refactor user collections view to categorize collections into available, in-progress, and finished; update templates for improved display of collection statuses and question rendering. 2025-10-06 13:51:55 +01:00
Ross 569ef760fa Refactor case detail retrieval into a separate function for improved readability and error handling 2025-10-06 11:32:44 +01:00
Ross bdfa16bbb4 Refactor example answers form and remove unused iframe in collection view 2025-10-05 22:41:49 +01:00
Ross 974966e855 Add example answers form rendering and update viewer iframe 2025-10-05 22:24:40 +01:00
Ross 63f399d2c1 Comment out iframe for viewer to improve layout and prevent unnecessary loading 2025-10-05 22:04:27 +01:00
Ross bb20f7d20d Update login prompt to include redirect after successful login 2025-10-05 21:50:22 +01:00
Ross 850b6ddc84 Fix login URL reference in CID and passcode input section 2025-10-05 21:49:22 +01:00
Ross e051a4f15a Add login link prompt for registered users in CID and passcode input section 2025-10-05 21:48:30 +01:00
Ross 735a29e71b Open DICOM viewer details by default and comment out multi-image block for improved user experience 2025-10-01 20:28:54 +01:00
Ross 4fc1de4300 Refactor code structure and remove redundant functions for improved readability 2025-10-01 20:24:10 +01:00
Ross 5400959399 Enhance error handling in example form by displaying non-field errors and providing a debug view for all form errors 2025-10-01 20:09:36 +01:00
Ross 0839eb7e37 Enhance error handling and user experience for JSON answer input in form 2025-09-22 12:45:32 +01:00
Ross f087a6ef49 Add helper functions for question type inference and options management in question editor 2025-09-22 12:39:05 +01:00
Ross ac562ed1e1 Add default question block structure and validation for properties in question editor 2025-09-22 12:08:50 +01:00
Ross 5d6c5e8b1a Implement code changes to enhance functionality and improve performance 2025-09-22 11:29:35 +01:00
Ross 73b12dd96b Add management links to case details in collection_detail template and update question_link_header to include case details 2025-09-22 11:29:19 +01:00
Ross 990907f8f5 Refactor code structure for improved readability and maintainability 2025-09-22 10:45:21 +01:00
Ross ee42d5c1c2 Convert case series images to JSON format in CaseDetail model for improved data handling 2025-09-15 12:03:24 +01:00
Ross 85216b36b0 Fix typo in prior_cases reference and streamline relationship validation in collection_case_priors 2025-09-15 12:01:12 +01:00
Ross 32b246b5ad Update DICOM viewer data source to use get_case_series_nested method for improved case detail rendering 2025-09-15 11:50:09 +01:00
Ross 3b19ddcb5e Enhance get_case_series_nested method to include prior cases and update collection_case_view_take template to add DICOM viewer details 2025-09-15 11:49:09 +01:00
Ross 50f5813a75 Enhance get_series_images_nested method to support excluding specific series IDs and improve query efficiency 2025-09-15 11:43:18 +01:00
Ross 298493a390 Remove redundant author validation check in collection_take_start and streamline access control in ExamOrCollectionGenericBase 2025-09-15 11:34:17 +01:00
Ross de8050a8ca Add author validation in collection_take_start and implement is_author method in AuthorMixin 2025-09-15 11:31:05 +01:00
Ross 0b12d95bdd Fix navigation links in collection case details and enhance form handling for case detail updates 2025-09-15 11:23:20 +01:00
Ross b9f6b7c837 Refactor collection case navigation and question handling
- Updated links in collection_case_priors.html to point to the new collection_case_questions view.
- Modified collection_case_view_take.html to display case history using the new method.
- Changed collection_detail.html to include links to the new collection_case_questions view.
- Added new URL pattern for collection_case_questions in urls.py.
- Refactored collection_case_details view to handle question-related logic and rendering.
- Introduced new CaseQuestionForm for handling question submissions.
- Created new migration to add override_history and redact_history fields to CaseDetail model.
- Added new templates for collection_case_details.html and collection_case_questions.html to manage case questions.
- Enhanced JavaScript functionality for dynamic question management in collection_case_questions.html.
- Updated useranswer model fields in shorts app to improve feedback and scoring.
2025-09-15 11:17:57 +01:00
Ross 779559997e Enhance DICOM viewer interaction by adding context menu prevention and fullscreen support; register basic interaction tools for improved user experience 2025-09-08 14:00:19 +01:00
Ross 0db1e4c689 Implement code changes to enhance functionality and improve performance 2025-09-08 12:09:16 +01:00
Ross 6b1db6b3c5 Enhance image display in mark_answer.html and add remote URL to context in mark_answer view for improved layout and functionality 2025-09-08 11:25:28 +01:00
Ross 9ed8fd90b3 Enhance feedback display in exam scores template and update question data retrieval for improved user experience 2025-09-08 11:01:17 +01:00
Ross 30523c9296 Enhance exam scores and marking templates with improved feedback display and structured question details 2025-09-08 10:56:37 +01:00
Ross 83e8c14f31 Enhance navigation buttons for previous and next candidates with improved styling and accessibility features 2025-09-08 10:50:18 +01:00
Ross d7a410e8f8 Enhance marking completion message and candidate display in mark_answer.html for improved clarity 2025-09-08 10:47:58 +01:00
Ross 7cc6b2959a Add help tags for enhanced user guidance in templates and improve feedback descriptions in models 2025-09-08 10:45:33 +01:00
Ross 7ff5d1d37e Enhance shorts exam functionality by adding history tracking and updating feedback display in exam scores template 2025-09-08 10:26:16 +01:00
Ross 5270d5cd36 Refactor feedback image handling and enhance sample answers display in exam scores user template 2025-09-08 10:16:53 +01:00
Ross 214b531757 Add exam scores user template with dynamic answer display and feedback 2025-09-08 09:47:34 +01:00
Ross 963221633a Add Shorts exam type to USER_EXAM_TYPES and update index.html for course/exam navigation 2025-09-08 09:39:35 +01:00
Ross 149b95a7e4 Fix URL for viewing question details by adjusting index in mark_answer.html 2025-09-02 21:38:21 +01:00
Ross 8a92460cb2 Increase max_length of image_md5_hash field to 64 in SeriesImage and LongSeriesImage models 2025-09-02 21:22:06 +01:00
Ross a7e0438290 Add Shorts Exams support in ExamCollection forms and detail view 2025-09-01 22:07:27 +01:00
Ross 2fca5a098d Add Shorts category to USER_GROUP_EXAMS for exam grouping 2025-09-01 22:04:22 +01:00
Ross 6a8bded825 Add Shorts category to CID_GROUP_EXAMS for exam grouping 2025-09-01 22:02:55 +01:00
Ross 15c5b2e234 Add Shorts exam models and integrate into UserGroupExamForm and ExamViews 2025-09-01 22:00:04 +01:00
Ross 964bce8233 Enhance user experience by adding informative messages and edit mode functionality across various templates 2025-09-01 14:47:15 +01:00
Ross 16a8bf3d3a Refactor DICOM file reading to use dcmread for improved compatibility across models and helpers 2025-08-18 10:45:34 +01:00
Ross 9bda3f22db Refactor details elements to use styled-detail class for consistent styling and add empty state message for findings 2025-08-18 10:45:02 +01:00
Ross 61c3fb8a82 Enhance series viewer layout with improved author display and button interactions for DICOM operations 2025-08-18 10:41:00 +01:00
Ross f4ed53dedd Refactor DICOM file reading to use dcmread for improved compatibility 2025-08-18 10:29:13 +01:00
Ross 125cbef6ef Enhance case display with improved button interactions and add detailed instructions for reordering dicoms in help section 2025-08-18 10:18:12 +01:00
Ross 4afcfb6b26 Refactor case_order_dicom function to add type hint for case and improve error handling messages 2025-08-18 09:43:58 +01:00
Ross 2a351a8283 Change hx-swap attribute from outerHTML to innerHTML for case series reordering button 2025-08-18 09:40:54 +01:00
Ross d61ecaac66 Enhance check_user_can_edit method with detailed docstring for clarity 2025-08-18 09:40:49 +01:00
Ross e71aa4c802 Fix collection visibility logic to allow multiple authors in CaseTable 2025-08-11 17:18:31 +01:00
Ross a8840290f5 Add user-configurable pagination and enhance case view filters 2025-08-11 17:12:22 +01:00
Ross 99bc89fe20 Update help documentation and improve duplicate detection in upload process 2025-08-11 16:38:05 +01:00
Ross 754d46080d Enhance help documentation by adding detailed descriptions of collection types and improving uploader tool instructions 2025-08-11 11:32:44 +01:00
Ross 43d98d7adb bump d3vd (persist mouse settings) 2025-08-11 11:02:40 +01:00
Ross bc5749a308 Refactor collection detail and viva templates to improve link handling and enhance user instructions with modal display 2025-08-11 10:55:32 +01:00
Ross eb194aa1e6 Add case display setup template with viewer state management functionality 2025-08-11 10:55:23 +01:00
Ross 63c342ff21 Add duplicate check functionality to upload form with user alerts for detected duplicates 2025-08-11 10:42:08 +01:00
Ross 8e63dcc7c5 Enhance upload results display by organizing duplicate files in a collapsible section and adding alerts for detected duplicates 2025-08-11 09:57:19 +01:00
Ross ddb5783f7f Refactor CaseCollectionForm layout and add date fields with custom widgets for improved user experience 2025-08-11 09:57:14 +01:00
Ross 816b2adde2 Refactor case collection form to use crispy forms for improved layout and styling 2025-08-04 13:19:19 +01:00
Ross b3304b6113 Enhance case collection update form with additional instructions and improve case detail links with icons 2025-08-04 12:59:42 +01:00
Ross 0af6b25349 Add case collection update functionality with form handling and template (move from CaseCollection form) 2025-08-04 12:21:37 +01:00
Ross c33e8c0240 Add open access display to collection detail page 2025-08-04 11:48:43 +01:00
Ross a2fa94722a Add initial documentation for PenraCourses platform and features 2025-08-04 11:48:35 +01:00
Ross 9200e9aae5 Filter collections in CaseTable based on user access and open access status 2025-07-29 10:03:10 +01:00
Ross 8806e48eb2 Add open access management buttons and views for cases 2025-07-29 09:40:19 +01:00
Ross 4460545442 Too many changes :( 2025-07-29 09:20:58 +01:00
Ross ad4be1e9a6 improve collection overview page 2025-07-21 12:52:45 +01:00
Ross 6a919ffc53 all cid collectio naccess 2025-07-21 12:52:34 +01:00
Ross 8d80090af0 switch to watchman 2025-07-21 12:52:13 +01:00
Ross f5258d7e6f git displaysets working in viva view 2025-07-21 10:51:34 +01:00
Ross 5187885c3b lots of improvements 2025-07-14 13:26:11 +01:00
Ross ec97fa0c71 . 2025-07-07 20:44:14 +01:00
Ross 8fc38557ac . 2025-07-07 13:31:26 +01:00
Ross 804cff33f4 . 2025-07-07 12:58:17 +01:00
Ross 0cdccbcdfb . 2025-07-07 12:09:17 +01:00
Ross c8939cf385 . 2025-07-07 11:42:53 +01:00
Ross d7499e9166 some updates 2025-07-07 11:41:44 +01:00
Ross f3b6f8ef59 update bootstrap 2025-07-07 10:45:36 +01:00
Ross 2ac8344b13 improve case form 2025-06-30 13:33:35 +01:00
Ross 4df7e5aebd start moking case display sets 2025-06-30 13:04:05 +01:00
Ross 8229813eba improve vivas 2025-06-30 12:58:32 +01:00
Ross 153045a3e4 optimise some dicom manipulations 2025-06-30 11:04:39 +01:00
Ross bc26209f07 start removing lots of print 2025-06-30 10:39:42 +01:00
Ross afa3b0c0ef . 2025-06-30 09:46:48 +01:00
Ross 2f8601aa1a i should do these more often 2025-06-23 09:25:48 +01:00
Ross 820d0bc030 update dv3d 2025-06-10 11:47:25 +01:00
Ross 0c07400cc3 update viewer for atlas series 2025-06-10 11:36:28 +01:00
Ross b825342a24 . 2025-06-10 07:22:26 +01:00
Ross 4a86a95c91 . 2025-06-10 07:19:11 +01:00
Ross 4873732b39 . 2025-06-10 07:13:29 +01:00
Ross 6439ec9aa4 . 2025-06-10 07:12:03 +01:00
Ross 394ef2196e . 2025-06-10 07:09:58 +01:00
Ross 13e6bc37b8 . 2025-06-10 07:04:52 +01:00
Ross 7070da01b0 update deps in dv3d 2025-06-10 07:01:34 +01:00
Ross 363396786f . 2025-06-09 16:51:34 +01:00
Ross d66783b2b8 . 2025-06-09 13:53:01 +01:00
Ross e922a93a25 . 2025-06-09 12:14:49 +01:00
Ross 90612209f2 . 2025-06-09 12:13:43 +01:00
Ross 3c8cae769d . 2025-06-09 11:58:41 +01:00
Ross c750cb0fa9 . 2025-06-09 11:54:21 +01:00
Ross 3873d24f7b add coep middlewhere 2025-06-09 11:47:57 +01:00
Ross 8e92de3420 start migration to dv3d 2025-06-09 11:39:38 +01:00
Ross 503c8f854a improve findings 2025-05-19 11:15:40 +01:00
Ross ead5bfcace improve modal view 2025-05-12 23:41:25 +01:00
Ross 77ee83cb08 allow viewing of findings in case context 2025-05-12 23:25:41 +01:00
Ross 9f5fc09e23 further question editing improvements 2025-05-12 22:45:08 +01:00
Ross 7689bded66 . 2025-05-12 21:54:42 +01:00
Ross 79f196966e more improvements 2025-05-12 13:49:45 +01:00
Ross 5adbf619fa many improvements to questions 2025-05-12 13:29:36 +01:00
Ross 6ce54372ff only add to cases that you are author on 2025-05-12 10:25:26 +01:00
Ross e2aaf64258 add some simple help text 2025-05-12 10:25:16 +01:00
Ross 1599e48584 improve finrding 2025-05-12 10:18:44 +01:00
Ross e119dceaa1 move case block to a partial 2025-05-12 09:02:19 +01:00
Ross 5f94616168 . 2025-05-04 16:50:56 +01:00
Ross a43a6e0fed author update question schema 2025-05-04 16:05:45 +01:00
Ross fe44fa23a3 . 2025-04-28 23:13:29 +01:00
Ross 168398d9bc allow doleteing user answers 2025-04-28 22:53:33 +01:00
Ross 6564b25250 work on case priors 2025-04-28 22:36:04 +01:00
Ross 5e578ca2b0 improve case management 2025-04-28 15:45:02 +01:00
Ross a13be87077 . 2025-04-28 13:04:32 +01:00
Ross dc1bf46004 improve case tables 2025-04-28 13:00:46 +01:00
Ross 06e66ad52c shorts might now be functional? 2025-04-28 12:15:31 +01:00
Ross 405b17776f try adding series data to case table 2025-04-25 18:28:40 +01:00
Ross f584ea07a3 add series to table 2025-04-25 18:05:07 +01:00
Ross e3762c39cb . 2025-04-14 10:48:21 +01:00
Ross eea61fc99c thumbnail and overview 2025-04-14 10:39:08 +01:00
Ross edf4a992de test fixing thumbnails 2025-04-14 10:38:35 +01:00
Ross 5d37cf4284 . 2025-04-07 13:25:46 +01:00
Ross dd334c6d6d . 2025-04-07 12:10:34 +01:00
Ross 7fe7823deb . 2025-04-07 12:09:46 +01:00
Ross d387e32777 furher shorts improvements 2025-04-07 11:34:10 +01:00
Ross afa1379ce4 start setting up shorts 2025-04-07 09:12:30 +01:00
Ross bcc449eb34 tidy up some cimar stuff 2025-03-24 10:19:43 +00:00
Ross e27d45f3c5 basic stats page 2025-03-24 10:19:36 +00:00
Ross 35c67cbc80 . 2025-03-17 10:49:38 +00:00
Ross 2865935a95 . 2025-03-17 09:39:37 +00:00
Ross 0b575bc64c . 2025-03-10 11:40:17 +00:00
Ross 580249a82d . 2025-03-10 11:35:02 +00:00
Ross 2c22616cc7 . 2025-03-10 11:25:01 +00:00
Ross 73cd6b7591 . 2025-03-10 11:20:51 +00:00
Ross c25f285202 . 2025-03-10 11:19:07 +00:00
Ross baeaf3d9c6 . 2025-03-10 11:15:08 +00:00
Ross 8f3e8ab510 . 2025-03-10 11:13:28 +00:00
Ross fb931f5a15 . 2025-03-10 10:47:47 +00:00
Ross 2d9c83ff95 . 2025-03-10 10:43:17 +00:00
Ross 1a1c455c03 . 2025-03-10 10:27:17 +00:00
Ross c5ba153865 work on cimar tuhmbnails 2025-03-10 10:23:36 +00:00
Ross b3076d6dda . 2025-02-24 17:34:46 +00:00
Ross de655c0d6a . 2025-02-24 17:34:23 +00:00
Ross 598a7118b2 . 2025-02-24 17:29:21 +00:00
Ross f6b78a90ce . 2025-02-24 17:24:01 +00:00
Ross 486bb94ea9 . 2025-02-24 17:12:54 +00:00
Ross f43c54f4af test celery 2025-02-24 16:57:37 +00:00
Ross c1f5147af7 . 2025-02-24 12:15:11 +00:00
Ross 6cbba4f6fe . 2025-02-24 11:55:59 +00:00
Ross b924bb2390 . 2025-02-24 11:54:55 +00:00
Ross a0960c9b7c start integrating cimar 2025-02-24 11:53:36 +00:00
Ross 249bc656c8 . 2025-01-20 10:11:48 +00:00
Ross ec55d1507e . 2025-01-20 10:11:13 +00:00
Ross b4cc8a281d . 2025-01-20 09:59:54 +00:00
Ross 0ca17c23b5 improve suggest answers (and fix some other issues) 2025-01-20 09:54:56 +00:00
Ross 9cb83b62d2 . 2025-01-06 20:16:46 +00:00
Ross 9d4487b7f5 allow adding markers and authors to exams in collections 2025-01-06 20:11:57 +00:00
Ross 9bb4d8bc08 . 2025-01-06 11:27:52 +00:00
Ross 7b45483870 . 2025-01-06 11:16:19 +00:00
Ross d891c7b9ee . 2025-01-06 11:14:34 +00:00
Ross f0c5964a5a . 2025-01-02 22:27:29 +00:00
Ross ac96745fa1 . 2025-01-02 22:26:50 +00:00
Ross 7fc12737c7 . 2025-01-02 22:26:00 +00:00
Ross e6beb734ab . 2025-01-02 21:10:40 +00:00
Ross 3b258adf59 . 2025-01-02 20:47:29 +00:00
Ross ef49f6a5eb . 2025-01-02 20:44:16 +00:00
Ross 97e76bb100 . 2024-12-30 13:51:37 +00:00
Ross 58301dd251 lots of updates 2024-12-30 13:41:55 +00:00
Ross 3c72a06ad2 stop seperating active exams in exam list 2024-12-30 12:56:29 +00:00
Ross 70bb6dc15a . 2024-12-30 12:14:51 +00:00
Ross 3e32b33864 . 2024-12-30 12:04:29 +00:00
Ross 056a51331e . 2024-12-30 12:02:22 +00:00
Ross cb47e488ed . 2024-12-30 11:24:45 +00:00
Ross 7493d55bc2 allow completion of sbas / physics exams 2024-12-30 11:12:36 +00:00
Ross 13713d870f fix a silly bug 2024-12-23 16:38:37 +00:00
Ross 4981402ca9 . 2024-12-23 12:50:07 +00:00
Ross b6fcb9a401 . 2024-12-23 12:47:51 +00:00
Ross fa3f87181c . 2024-12-23 12:46:23 +00:00
Ross 6ee0187dd3 . 2024-12-23 12:45:33 +00:00
Ross ffc8a245e4 . 2024-12-23 12:44:06 +00:00
Ross 22812c5ec1 test cimar iframe 2024-12-23 12:36:41 +00:00
Ross 178ec034a3 more bootstrap nav convresion 2024-12-23 10:36:03 +00:00
Ross 1fdb4c9159 switch case form to crispy and start using bootstrap nav 2024-12-23 10:09:08 +00:00
Ross 55507d215c refresh image data vhen truncating series 2024-12-23 10:08:21 +00:00
Ross 8c43c40215 add simple case and series size display 2024-12-19 00:02:26 +00:00
Ross e0974ae300 add link to psutil on main page 2024-12-18 19:41:42 +00:00
Ross 7e928c6887 . 2024-12-18 19:35:06 +00:00
Ross b5e6a294ff . 2024-12-16 18:22:18 +00:00
Ross 267f53906e more collection stuff 2024-12-16 18:20:23 +00:00
Ross 7da968825c . 2024-12-16 18:04:18 +00:00
Ross 12144e06d1 improve exam collections 2024-12-16 17:57:53 +00:00
Ross 1c5f3a3425 improve previous cases selcetion 2024-12-16 17:38:33 +00:00
Ross 187f7d9a83 add loading indicatior when importitng cases 2024-12-16 17:38:23 +00:00
Ross d237cd0a5e . 2024-12-16 12:39:34 +00:00
Ross 99862e2e60 . 2024-12-16 12:38:44 +00:00
Ross 6a58cdb14f . 2024-12-16 12:37:31 +00:00
Ross 4b6ffd4559 . 2024-12-16 12:35:37 +00:00
Ross 6f19d0a3ed . 2024-12-16 12:35:07 +00:00
Ross 46f5a99697 . 2024-12-16 12:32:38 +00:00
Ross de2b84e099 . 2024-12-16 12:31:01 +00:00
Ross c6485333bb . 2024-12-16 12:29:36 +00:00
Ross 720debc2a5 . 2024-12-16 12:14:26 +00:00
Ross 69664b7b50 . 2024-12-16 11:56:04 +00:00
Ross 92f9cabcc0 improve exam collections management 2024-12-16 11:53:22 +00:00
Ross 4476d0b55f allow bulk adding cid candidates by group 2024-12-16 11:13:57 +00:00
Ross 6d20beebce allow adding users to group by email 2024-12-16 10:28:51 +00:00
Ross 8d62673f06 . 2024-12-09 17:12:05 +00:00
Ross 5eb5fe3f50 . 2024-12-09 17:02:37 +00:00
Ross 695e651ea5 . 2024-12-09 16:57:37 +00:00
Ross 91ad134115 start ouput 2024-12-09 14:51:00 +00:00
Ross 40ae9e1b64 update formas 2024-12-09 14:02:28 +00:00
Ross 1358b15986 . 2024-12-09 13:25:40 +00:00
Ross f4eb874544 . 2024-12-09 13:25:17 +00:00
Ross a9687345c6 update formats 2024-12-09 13:21:25 +00:00
Ross ba29c9c881 fix a few small upload bugs 2024-11-26 22:52:10 +00:00
Ross 4bc0e3be93 . 2024-11-18 12:59:54 +00:00
Ross d0d680edc5 . 2024-11-18 12:55:37 +00:00
Ross 8be4c4f923 . 2024-11-18 10:56:41 +00:00
Ross 00323cc6bd improve case user answer management (and fix small bug) 2024-11-18 10:44:27 +00:00
Ross 9c69792e87 . 2024-11-11 14:01:42 +00:00
Ross fad940bcd4 . 2024-11-11 13:59:48 +00:00
Ross 57497cc97c . 2024-11-11 13:53:48 +00:00
Ross 2a1f15683e . 2024-11-11 13:44:47 +00:00
Ross 2aff0f6a32 add nuclear modicien 2024-11-11 13:42:35 +00:00
Ross ed933c8104 inital case selection widget 2024-11-11 13:36:53 +00:00
Ross a65ceffbfb start thinking about presentations 2024-11-11 13:36:37 +00:00
Ross fa2d270dfb add a basic system info 2024-11-11 10:51:24 +00:00
Ross 4e2e350e52 order atlas tables by creation date by default 2024-11-11 10:10:26 +00:00
Ross 30168b7624 fix ordering when importing series 2024-11-11 10:10:13 +00:00
Ross 80358e2113 allow supervisors to view shared exams 2024-11-04 15:18:04 +00:00
Ross eab6fc9d7e . 2024-11-04 14:09:33 +00:00
Ross d8b07449a4 . 2024-11-04 13:09:36 +00:00
Ross 100d23b068 . 2024-11-04 13:05:54 +00:00
Ross a0a6d7f415 . 2024-11-04 13:00:31 +00:00
Ross bf14d4b7ee . 2024-11-04 12:55:48 +00:00
Ross 7c5cfae5d5 . 2024-11-04 12:51:10 +00:00
Ross a4c7aeb84d . 2024-11-04 12:42:56 +00:00
Ross 33c2054bb3 . 2024-11-04 12:35:04 +00:00
Ross ec36597dbc . 2024-11-04 12:23:34 +00:00
Ross 7b1fee464b . 2024-11-04 12:10:18 +00:00
Ross 3a2b89a1fd . 2024-11-04 11:48:51 +00:00
Ross 155c036f0c . 2024-11-04 11:41:38 +00:00
Ross cee4009eb8 . 2024-11-04 11:30:29 +00:00
Ross dedce34d6e . 2024-11-04 11:28:20 +00:00
Ross 2faedbcee6 allow trainees to be bulk updated 2024-11-04 10:34:44 +00:00
Ross 5397bf10ac . 2024-10-30 22:57:54 +00:00
Ross 162776c103 . 2024-10-28 21:58:47 +00:00
Ross 9f6740accb . 2024-10-28 21:56:49 +00:00
Ross a192157d81 . 2024-10-28 21:53:26 +00:00
Ross ac0b556dfa . 2024-10-28 21:53:10 +00:00
Ross 0776400941 . 2024-10-28 21:51:21 +00:00
Ross a20199cb98 . 2024-10-28 21:49:56 +00:00
Ross 37bf203bf2 . 2024-10-28 21:47:46 +00:00
Ross 3cb8d53d3f . 2024-10-28 21:45:57 +00:00
Ross 6ba7be7abd . 2024-10-28 21:36:14 +00:00
Ross eeb0483223 . 2024-10-28 17:05:08 +00:00
Ross 76c96a32fe . 2024-10-28 17:01:48 +00:00
Ross 2dc2346962 . 2024-10-28 17:00:29 +00:00
Ross 0bc6d63c7a . 2024-10-28 16:50:11 +00:00
Ross 68c9c33f4c . 2024-10-28 16:41:07 +00:00
Ross ce56de1b95 . 2024-10-28 16:38:59 +00:00
Ross fc6e215cc2 . 2024-10-28 16:37:34 +00:00
Ross a2907fa285 numerous supervisor upgrades 2024-10-28 16:30:13 +00:00
Ross 375261dd84 . 2024-10-28 10:47:16 +00:00
Ross 21fa4739ee . 2024-10-28 10:34:03 +00:00
Ross 7981684005 update htmx / hyperscript 2024-10-28 10:15:43 +00:00
Ross 0d7cac594b gap 2024-10-28 10:12:17 +00:00
Ross bfe5be807e add a few more anon checks 2024-10-21 13:27:22 +01:00
Ross eeaa345f3c make rapid form crispy 2024-10-21 12:30:21 +01:00
Ross ad54fce54b . 2024-10-14 16:37:17 +01:00
Ross aa9c109b68 . 2024-10-14 16:36:45 +01:00
Ross b8aa7691cd . 2024-10-14 16:34:18 +01:00
Ross 7b4b88b573 . 2024-10-14 16:29:20 +01:00
Ross 7151d036cf . 2024-10-14 16:10:41 +01:00
Ross ad0bc7c6ee . 2024-10-14 14:41:27 +01:00
Ross 87c1ac4e96 . 2024-10-14 14:39:18 +01:00
Ross 6b064b2653 . 2024-10-14 12:49:05 +01:00
Ross 185942b716 add open access exams 2024-10-14 12:41:17 +01:00
Ross 38fc924222 . 2024-10-14 12:21:33 +01:00
Ross 4630bf127b . 2024-10-14 11:57:53 +01:00
Ross 46cf69a7b6 . 2024-10-14 11:56:10 +01:00
Ross 78f2067bc0 . 2024-10-14 11:14:37 +01:00
Ross 16e3e12b2a . 2024-10-14 10:59:27 +01:00
Ross 615d5f8f80 . 2024-10-14 10:50:34 +01:00
Ross 61293f90a6 . 2024-10-14 10:38:39 +01:00
Ross b0deb17951 . 2024-10-14 10:32:18 +01:00
Ross 502e4d3f83 . 2024-10-14 10:17:01 +01:00
Ross bb33533501 . 2024-10-14 10:16:26 +01:00
Ross 8b84654ee3 fix some anatomy question creation bugs 2024-10-14 10:08:54 +01:00
Ross ffac3175e1 . 2024-10-10 22:04:07 +01:00
Ross 57d3ea909a . 2024-10-10 21:58:34 +01:00
Ross 2c1043c2dc . 2024-10-10 21:45:35 +01:00
Ross e69d1a6b9a . 2024-10-09 23:12:56 +01:00
Ross f4c82f45f5 . 2024-10-09 21:40:46 +01:00
Ross 436a04e8dd . 2024-10-08 17:03:42 +01:00
Ross 87192466ed . 2024-10-08 16:50:03 +01:00
Ross 4d0a105ab2 . 2024-10-08 16:46:04 +01:00
Ross 2d1ddbd5cf . 2024-10-08 16:36:20 +01:00
Ross ba607f3c52 . 2024-10-08 11:43:00 +01:00
Ross 955ec6831a . 2024-10-08 11:38:26 +01:00
Ross fc6b2027c3 . 2024-10-08 11:37:26 +01:00
Ross 6812935d6c . 2024-10-08 09:37:12 +01:00
Ross 2698e3f9d6 . 2024-10-08 09:36:44 +01:00
Ross b7a0c60b16 . 2024-10-08 09:34:50 +01:00
Ross 850da0c2ac . 2024-10-08 09:08:39 +01:00
Ross 8fe64fc316 . 2024-10-07 20:53:09 +01:00
Ross 8964a5598f . 2024-10-07 19:38:44 +01:00
Ross 2e80a4ee0a . 2024-10-07 17:48:02 +01:00
Ross 28bfa34367 . 2024-10-07 17:46:09 +01:00
Ross 7b938291ce . 2024-10-07 17:12:58 +01:00
Ross 1e55d195e9 start oef 2024-10-07 17:10:39 +01:00
548 changed files with 104455 additions and 5036 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/
+10 -4
View File
@@ -1,5 +1,6 @@
# pull official base image
FROM python:3.12.1-slim
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# set work directory
WORKDIR /usr/src/rad
@@ -13,13 +14,18 @@ ENV PYTHONUNBUFFERED 1
# python3 python3-dev gcc \
# gfortran musl-dev \
# libffi-dev openssl-dev
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc
RUN apt-get update && apt-get -y install postgresql postgresql-contrib git libcairo2-dev pkg-config gcc redis-tools watchman
# install dependencies
RUN pip install --upgrade pip
#RUN pip install --upgrade pip
COPY ./requirements.txt .
RUN pip install -r requirements.txt
RUN uv venv /opt/venv
# Use the virtual environment automatically
ENV VIRTUAL_ENV=/opt/venv
# Place entry points in the environment at the front of the path
ENV PATH="/opt/venv/bin:$PATH"
RUN uv pip install -r requirements.txt
# copy project
COPY . .
+75
View File
@@ -0,0 +1,75 @@
PenraCourses
# Access to the platform
URL: https://www.penracourses.org.uk/
Peninsula trainees are now automatically given access as numerous assessments / courses are run via the platform.
Other uses can have accounts created on request. Please contact ross.kruger@nhs.net
If available accounts will be set up with a users @nhs.net email
# Features
## Simulation of the RCR exam formats.
This includes the ability to mimic the pratique/risr software used in the actual exam. This web based so can be accessed from anywhere.
Supported exam types
- Physics
- Anatomy
- Rapids (old 2b format)
- Shorts (new 2b format)
- Longs
In each case exams/packets can be designed to either be "self marked" (user can take the exam and self review answers with no answer data submitted back to the platform) or "exam mode" where user answers are submitted back to the platform to be marked. The platform offers features to make marking as simple and quick as possible.
Once marked results can be published for the user to review.
Exams can be restricted to individual users or groups of users. Access can be restricted by date / time or manual activation.
Viva support is achieved via collections (see below).
Candidates for packets / exams can either be registered users of the platform or generated using a CID/Passcode (in which case no further user data need be stored on the platform, although an email address allows automatically messaging)
## Atlas
The platform supports uploading cases into a virtual "Atlas".
These cases can be categorised and tagged to allow easy retrieval via searching / other methods.
Each case can contain multiple image stacks/series. Related cases can be linked to allow display as a time series.
Cases can be viewed via the built in dicom viewer.
Images can be annotated and categorised (via finding, condition or anatomical location). It is also possible to create more complex display sets (analogous to bookmarks in Insight)
Resources (videos, pdfs, etc) can be attached to cases.
### Case Collections
Case collections are sets of cases. These can take a number of different formats that alters how they are displayed to the end user.
Once collections have been completed a user can share their results / progress with nominated supervisors.
- **Review**:
Cases are displayed sequentially to a user. No user input is stored. User are given predefined feedback (either after each case or at the end of the collection).
- **Report**:
Cases are displayed sequentially to the user. The user is able to enter a report for each case. Feedback can be given automatically (and a user self review) or the reports can be reviewed (and marked/assessed) by the collection author. Feedback can be given once the whole collection is complete or after each case.
- **Question**:
Cases are displayed sequentially to the user. Each case is linked to a predefined set of questions. Supported question types include
- Text answers
- True / False (or Yes / No)
- Multiple choice (single or multiselect)
- Numerical
Each case can have multiple questions associated, these may be the same or different for each case in the series.
- **Viva**:
Viva collections are designed to make the delivery of viva type sessions as easy as possible. This collection type allows the synchronisation of two windows. This allows one to be shared (via teams or other screen sharing software) whilst information pertinent to the case is displayed in the other window. Cases can be show as a case or series and it is possible to recreate predefined display sets.
+15 -18
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):
@@ -113,8 +112,6 @@ def get_exam_user_status_cid_user(request, exam_id: int, cid: int):
if not exam.check_user_can_edit(request.user):
raise HttpError(HTTPStatus.UNAUTHORIZED,"Exam author required")
print(exam_id, cid)
return exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
@router.get('/exam/{exam_id}/user_status/{user_id}/user', response=List[ExamStatusSchema])
+6 -1
View File
@@ -252,4 +252,9 @@ class ExamMarkerForm(ExamMarkerFormMixin):
class ExamGroupsForm(ExamGroupsFormMixin):
class Meta(ExamGroupsFormMixin.Meta):
model = Exam
model = Exam
class SuggestIncorrectAnswerForm(ModelForm):
class Meta:
model = AnatomyQuestion
fields = ["answer_suggest_incorrect"]
+1
View File
@@ -0,0 +1 @@
"""Management package for anatomy app."""
+1
View File
@@ -0,0 +1 @@
"""Management commands package for anatomy app."""
@@ -0,0 +1,180 @@
from __future__ import annotations
import os
import io
from uuid import uuid4
from typing import List, Tuple
from django.core.management.base import BaseCommand, CommandError
from django.core.files import File
from django.contrib.auth import get_user_model
from PIL import Image
from anatomy.models import AnatomyQuestion, Exam, ExamQuestionDetail, QuestionType, Structure
from generic.models import Modality as GenericModality # type: ignore
class Command(BaseCommand):
help = (
"Create anatomy questions and assign them to an exam."
"Requires --exam-id and --image-path (path readable inside the container)."
)
def add_arguments(self, parser):
parser.add_argument("--exam-id", type=int, required=False, help="ID of the anatomy.Exam to assign questions to (if omitted a new exam will be created)")
parser.add_argument("--exam-name", type=str, default=None, help="Name to use when creating a new exam if --exam-id omitted")
parser.add_argument("--count", type=int, default=1, help="Number of questions to create")
parser.add_argument("--image-path", type=str, required=False, help="Path to image file to use for each created question")
parser.add_argument("--generate-image", action="store_true", help="Generate a placeholder image automatically if --image-path is not provided or not found")
parser.add_argument("--image-size", type=str, default="800x600", help="Generated image size WIDTHxHEIGHT, e.g. 800x600")
parser.add_argument("--image-color", type=str, default="#777777", help="Background color for generated image (hex) e.g. #ffffff")
parser.add_argument("--description", type=str, default="Generated question {i}", help="Description template; use {i} for index")
parser.add_argument("--question-type-id", type=int, default=None, help="Optional QuestionType id to assign")
parser.add_argument("--modality-id", type=int, default=None, help="Optional Modality id to assign (generic.Modality id)")
parser.add_argument("--structure-id", type=int, default=None, help="Optional Structure id to assign")
parser.add_argument("--author-id", type=int, action="append", help="Author user id; repeatable")
parser.add_argument("--start-sort", type=int, default=1, help="Starting sort_order for the first question")
def handle(self, *args, **options):
exam_id = options.get("exam_id")
count = options["count"]
image_path = options.get("image_path")
generate_image = options.get("generate_image")
image_size_str = options.get("image_size") or "800x600"
image_color = options.get("image_color") or "#777777"
description_template = options["description"]
qtype_id = options.get("question_type_id")
modality_id = options.get("modality_id")
structure_id = options.get("structure_id")
author_ids: List[int] = options.get("author_id") or []
sort = options.get("start_sort") or 1
exam = None
if exam_id:
try:
exam = Exam.objects.get(pk=exam_id)
except Exam.DoesNotExist:
raise CommandError(f"Exam with id {exam_id} does not exist")
else:
# create new exam
exam_name = options.get("exam_name") or f"Auto Anatomy Exam {uuid4().hex[:8]}"
exam = Exam(name=exam_name, active=True, open_access=True)
exam.save()
self.stdout.write(self.style.SUCCESS(f"Created exam id={exam.id} name='{exam.name}'"))
# validate image path if provided
if image_path and not os.path.exists(image_path):
if not generate_image:
raise CommandError(f"Image file not found at path: {image_path}")
else:
image_path = None
# Resolve optional foreign keys
qtype = None
if qtype_id:
qtype = QuestionType.objects.filter(pk=qtype_id).first()
if not qtype:
raise CommandError(f"QuestionType id {qtype_id} not found")
modality = None
if modality_id:
# The project stores Modality in generic.models.Modality
try:
from generic.models import Modality as GModality
modality = GModality.objects.filter(pk=modality_id).first()
if not modality:
raise CommandError(f"Modality id {modality_id} not found in generic.Modality")
except Exception as e:
raise CommandError(str(e))
structure = None
if structure_id:
from atlas.models import Structure as AtlasStructure
structure = AtlasStructure.objects.filter(pk=structure_id).first()
if not structure:
raise CommandError(f"Structure id {structure_id} not found")
User = get_user_model()
authors = []
for uid in author_ids:
u = User.objects.filter(pk=uid).first()
if not u:
raise CommandError(f"Author user id {uid} not found")
authors.append(u)
# parse image size
try:
w, h = image_size_str.lower().split("x")
image_size: Tuple[int, int] = (int(w), int(h))
except Exception:
raise CommandError("Invalid --image-size; expected WIDTHxHEIGHT, e.g. 800x600")
created = []
for i in range(1, count + 1):
desc = description_template.format(i=i)
# determine image source: either provided path or generated image
unique_name = f"{uuid4().hex}_image.jpg"
if image_path:
# use provided file
with open(image_path, "rb") as f:
django_file = File(f, name=unique_name)
img_file_for_save = django_file
# create and save question
q = AnatomyQuestion(
question_type=qtype,
description=desc,
answer_help="",
)
if modality:
q.modality = modality
if structure:
q.structure = structure
q.image.save(unique_name, img_file_for_save, save=False)
q.save()
else:
# generate an image in memory
buf = io.BytesIO()
img = Image.new("RGB", image_size, image_color)
img.save(buf, format="JPEG")
buf.seek(0)
django_file = File(buf, name=unique_name)
q = AnatomyQuestion(
question_type=qtype,
description=desc,
answer_help="",
)
if modality:
q.modality = modality
if structure:
q.structure = structure
q.image.save(unique_name, django_file, save=False)
q.save()
# authors
if authors:
q.author.set(authors)
# add to exam via the many-to-many through relationship
try:
exam.exam_questions.add(q, through_defaults={"sort_order": sort})
except TypeError:
ExamQuestionDetail.objects.create(exam=exam, anatomyquestion=q, sort_order=sort)
self.stdout.write(self.style.SUCCESS(f"Created question id={q.id} and added to exam id={exam.id} with sort_order={sort}"))
created.append(q)
sort += 1
self.stdout.write(self.style.SUCCESS(f"Finished: created {len(created)} question(s)"))
@@ -0,0 +1,20 @@
# Generated by Django 5.0.2 on 2024-10-09 20:39
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0021_alter_anatomyquestion_answer_help_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='exam',
name='markers',
field=models.ManyToManyField(blank=True, help_text='Authorised markers for the exam', related_name='anatomy_exam_markers', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-14 11:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0022_alter_exam_markers'),
]
operations = [
migrations.AddField(
model_name='exam',
name='exam_open_access',
field=models.BooleanField(default=False, help_text='Set to true if you want any registered user to be able to take the exam.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0023_exam_exam_open_access'),
]
operations = [
migrations.AddField(
model_name='exam',
name='results_supervisor_visible',
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
),
]
+23 -10
View File
@@ -22,6 +22,7 @@ from collections import defaultdict
from helpers.images import image_as_base64
import reversion
import urllib.parse
image_storage = FileSystemStorage(
# Physical file location ROOT
@@ -131,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)
@@ -179,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)
@@ -335,6 +341,13 @@ class Answer(models.Model):
This just mirrors the result from the attached question"""
return self.question.can_edit(user)
def get_answer_url_safe(self):
return urllib.parse.quote(self.answer_compare)
def get_constituent_words(self):
return self.answer_compare.split()
# def get_compare_string(self):
# s = self.answer.lower().strip()
# s = s.translate(str.maketrans('', '', string.punctuation))
@@ -396,7 +409,6 @@ class Exam(ExamBase):
markers = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
null=True,
help_text="Authorised markers for the exam",
related_name="anatomy_exam_markers",
)
@@ -485,7 +497,6 @@ class Exam(ExamBase):
return answer
def get_question_user_user_answer(self, question_index, user):
print(user)
question = self.get_question_by_index(question_index)
answer = UserAnswer.objects.filter(question=question, user=user.pk, exam=self)
@@ -541,6 +552,8 @@ class UserAnswer(UserAnswerBase):
# s = self.answer.lower().strip()
# s = s.translate(str.maketrans('', '', string.punctuation))
# return s
def get_answer_url_safe(self):
return urllib.parse.quote(self.answer_compare)
def get_answer_string(self):
return self.answer
-10
View File
@@ -1,10 +0,0 @@
# serializers.py
from rest_framework import serializers, permissions
from .models import Exam
class ExamSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Exam
fields = ('name', 'id', 'active', "publish_results")
+394 -131
View File
@@ -12,9 +12,24 @@ window.control_pressed = false;
$(document).ready(function () {
// Select all text input fields and textareas with the clear button
clearableTextAreaSetup();
document.body.addEventListener("htmx:responseError", function(e) {
let error = e.detail.xhr.response;
let el = document.getElementById("htmx-error");
var error;
if (e.detail.pathInfo.requestPath.includes("cimar") && e.detail.xhr.status == 403) {
error = "Cimar permission error, please login and refresh the page.";
//document.getElementById("cimar-login-form").focus();
htmx.trigger("#cimar-login-needed", "cimar-login-needed");
} else {
error = e.detail.xhr.response;
}
el.innerHTML = error;
el.classList.remove("hidden");
});
@@ -29,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) {
@@ -63,7 +81,15 @@ $(document).ready(function () {
if (dicom_images.length) {
for (let element of dicom_images) {
setUpDicom(element)
setUpDicom3d(element)
}
}
let dicom_images_legacy = document.getElementsByClassName("dicom-image-legacy");
if (dicom_images_legacy.length) {
for (let element of dicom_images_legacy) {
setUpDicomLegacy(element)
}
}
@@ -184,10 +210,45 @@ window.addEventListener('loadDicomViewerUrls', function (e) {
loadDicomViewer(e.detail.images, e.detail.annotations);
}, false);
function clearableTextAreaSetup() {
const textFields = document.querySelectorAll(".position-relative input[type='text'], .position-relative textarea");
textFields.forEach((field) => {
const wrapper = field.closest(".position-relative");
const clearButton = wrapper.querySelector(".clear-input-button");
// Function to toggle the visibility of the clear button
const toggleClearButton = () => {
if (field.value.trim() !== "" && wrapper.matches(":hover")) {
clearButton.style.display = "inline-block"; // Show the button
} else {
clearButton.style.display = "none"; // Hide the button
}
};
// Initial check
toggleClearButton();
// Add event listeners to monitor changes in the input field or textarea
field.addEventListener("input", toggleClearButton);
// Add hover event listeners to the wrapper
wrapper.addEventListener("mouseenter", toggleClearButton);
wrapper.addEventListener("mouseleave", toggleClearButton);
// Add click event to clear the field or textarea
clearButton.addEventListener("click", function () {
field.value = ""; // Clear the field
toggleClearButton();
});
});
}
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;
@@ -201,7 +262,6 @@ function loadDicomViewer(images_to_load, annotations_to_load) {
images = JSON.parse(images);
}
console.log(images);
let annotations = single_dicom.dataset.annotations;
if (annotations_to_load != undefined) {
@@ -261,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;
@@ -284,9 +344,41 @@ function loadJsonToolStateOnCurrentImage(json) {
}
async function setUpDicom(element) {
// 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);
}
}
console.log("Dicom element found ", $(element));
// 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;
@@ -314,6 +406,8 @@ async function setUpDicom(element) {
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")) {
@@ -424,7 +518,7 @@ async function setUpDicom(element) {
}
if (element.dataset.annotations) {
loadJsonToolStateOnCurrentImage(element.dataset.annotations)
loadJsonToolStateOnCurrentImage(element, element.dataset.annotations)
}
cornerstone.resize(element);
@@ -435,115 +529,242 @@ async function setUpDicom(element) {
});
}
console.log("setUpDicom defined");
// keep reference to the existing 2D setup
window.setUpDicomLegacy = setUpDicomLegacy;
async function setUpDicom3d(element) {
console.log("setUpDicom3d (cornerstone3d) for", element);
// parse images from dataset
let raw = element.dataset.url || "";
if (!raw) return;
let images;
try {
images = JSON.parse(raw);
} catch (e) {
images = raw.split(",").map(s => s.trim()).filter(Boolean);
}
if (!images || images.length === 0) return;
// ensure element has a viewport-like appearance
element.style.width = element.style.width || "500px";
element.style.height = element.style.height || "500px";
element.style.position = element.style.position || "relative";
element.innerHTML = ""; // clear
// Disable right-click context menu on the element
element.addEventListener('contextmenu', function (e) {
e.preventDefault();
});
element.addEventListener('dblclick', function (e) {
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
});
// create a unique rendering engine / viewport ids per element
const renderingEngineId = `rendingEngine`;
const viewportId = `1`;
try {
// dynamic import of cornerstone3D core + dicom loader
const coreModule = window.cornerstone3d
const dicomLoaderModule = window.cornerstoneDICOMImageLoader
const toolsModule = window.cornerstone3dTools;
await coreModule.init();
await dicomLoaderModule.init();
await toolsModule.init();
const { RenderingEngine, Enums } = coreModule;
const { chunkManager } = dicomLoaderModule; // may exist for some setups
// create engine and enable a Stack viewport
const renderingEngine = new RenderingEngine(renderingEngineId);
console.log(renderingEngine)
const viewportInput = {
viewportId,
type: Enums.ViewportType.STACK,
element,
//viewportType: Enums.ViewportType.STACK, // Stack viewport for slice stacks
};
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);
// --- Register basic interaction tools (best-effort with @cornerstonejs/tools) ---
// dynamic import so bundlers/CDNs provide the package
// prefer module import, fall back to global if present
const { ToolGroupManager } = toolsModule;
toolsModule.addTool(toolsModule.PanTool)
toolsModule.addTool(toolsModule.WindowLevelTool)
//toolsModule.addTool(toolsModule.StackScrollTool)
toolsModule.addTool(toolsModule.ZoomTool)
const toolGroupId = `toolgroup-${viewportId}`;
console.log("ToolGroup id", toolGroupId);
console.log("ToolGroupManager", ToolGroupManager);
if (!ToolGroupManager.getToolGroup(toolGroupId)) {
ToolGroupManager.createToolGroup(toolGroupId);
}
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
toolGroup.addTool(toolsModule.PanTool.toolName);
toolGroup.addTool(toolsModule.WindowLevelTool.toolName);
//toolGroup.addTool(toolsModule.StackScrollTool.toolName);
toolGroup.addTool(toolsModule.ZoomTool.toolName);
// use actual tool names if available, else fall back to common strings
//const stackName = (StackScrollTool && (StackScrollTool.toolName || StackScrollTool.name)) || 'StackScroll';
//toolGroup.setToolEnabled(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
toolGroup.setToolActive(toolsModule.PanTool.toolName, { bindings: [{ mouseButton: 1 }] });
toolGroup.setToolActive(toolsModule.WindowLevelTool.toolName, { bindings: [{ mouseButton: 2 }] });
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 4 }] });
toolGroup.setToolActive(toolsModule.ZoomTool.toolName, { bindings: [{ mouseButton: 524288 }] });
toolGroup.addViewport(viewportId, renderingEngineId);
window.toolGroup = toolGroup; // expose for debugging
// 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
if (/^(wadouri:|base64:|data:|http:|https:)/i.test(url)) return url;
// default: treat as wadouri (likely a DICOM file)
return `wadouri:${url}`;
});
// If dicom-image-loader provides a metadata cache factory, pre-cache metadata
if (typeof dicomLoaderModule.createImageIdsAndCacheMetaData === "function") {
try {
// attempt to call helper to cache meta-data for the first image (best-effort)
await dicomLoaderModule.createImageIdsAndCacheMetaData({
imageIds,
});
} catch (e) {
// not fatal — continue
console.warn("createImageIdsAndCacheMetaData failed (continuing):", e);
}
}
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();
// store references for debugging/use elsewhere
element._cornerstone3d = {
renderingEngineId,
viewportId,
renderingEngine,
viewport,
imageIds,
};
console.log("setUpDicom3d complete", element._cornerstone3d);
return element._cornerstone3d;
} catch (err) {
console.error("setUpDicom3d failed:", err);
// per request: no fallback here — rethrow so caller can handle if desired
throw err;
}
}
// expose globally like existing code expects
window.setUpDicom = setUpDicom3d;
//function keyUpHandler(e) {
// if (e.key == "Control") {
// dicomViewer.registerPrimaryDicomInterface(e);
// window.control_pressed = false;
// }
//}
//
//function keyDownHandler(e) {
// // Ignore our custom keybindings if we are currently in a field that
// // accepts some kind of input
// if ($("*:focus:not(disabled)").is("textarea, input")) {
// // unless a modifier key is pressed (not shift)
// if (e.altKey ? true : false || e.ctrlKey ? true : false) {} else {
// return;
// }
// }
//
// if (e.key == "Control") {
// if (window.control_pressed == false) {
// window.control_pressed = true;
// dicomViewer.registerAltDicomInterface(e);
// }
// }
//
// var charCode = typeof e.which == "number" ? e.which : e.keyCode;
// console.log(e, charCode);
//
// function numberKeyPressed(e, x) {
// if (e.altKey ? true : false) {
// dicomViewer.selectThumb(x);
// e.preventDefault();
// } else {}
// }
//
// switch (charCode) {
// case 13: // Return
// if (e.shiftKey ? true : false) {
// //$(".next-button:last").click();
// } else {
// //$(".check-button:last").click();
// }
// break;
// case 32: // Space
// if (e.shiftKey ? true : false) {
// //$(".previous-button:last").click();
// } else {
// //$(".next-button:last").click();
// }
// e.preventDefault(); // Needed to stop the default action (scroll)
// break;
// case 46: // .
// //toggleFlagged();
// break;
//
// // Numbers 1-9 select the corresponding answer (if it exists)
// // TODO: fix for multi question questions
// case 49: // 1
// numberKeyPressed(e, 0);
// break;
// case 50: // 2
// numberKeyPressed(e, 1);
// break;
// case 51: // 3
// numberKeyPressed(e, 2);
// break;
// case 52: // 4
// numberKeyPressed(e, 3);
// break;
// case 53: // 5
// numberKeyPressed(e, 4);
// break;
// case 54: // 6
// numberKeyPressed(e, 5);
// break;
// case 55: // 7
// numberKeyPressed(e, 6);
// break;
// case 56: // 8
// numberKeyPressed(e, 7);
// break;
// case 57: // 9
// numberKeyPressed(e, 8);
// break;
// case 72: // H
// previousQuestion();
// break;
// case 76: // L
// //nextQuestion();
// break;
//
// case 102: // f
// //$("#filter-toggle").click();
// break;
// case 103: // g
// //$("#options").slideDown("slow");
// //$("#goto-question-input").focus();
// //e.preventDefault();
// break;
//
// // Vim like scrolling (incredibly important)
// case 106: // j
// //window.scrollBy(0, 25);
// break;
// case 107: // k
// //window.scrollBy(0, -25);
// break;
// }
//}
function create_popup_window(url, title) {
@@ -635,4 +856,46 @@ function initializeClock(id, endtime) {
updateClock();
}
window.initializeClock = initializeClock
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);
+1 -1
View File
@@ -1,4 +1,4 @@
let active_file_inputs = new Set();
var active_file_inputs = new Set();
function add_input_form() {
var form_idx = $('#id_images-TOTAL_FORMS').val();
+1
View File
@@ -47,6 +47,7 @@ class AnatomyQuestionTable(tables.Table):
"author",
)
sequence = ("view", "image", "exams")
order_by = "-created_date"
def __init__(self, data=None, *args, **kwargs):
super().__init__(
@@ -2,6 +2,7 @@
{% load static %}
{% load crispy_forms_tags %}
{% load help_tags %}
{% block title %}
Anatomy
@@ -11,69 +12,69 @@
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
<script type="text/javascript">
function showEditPopup(url) {
var win = window.open(url, "Edit",
'height=500,width=800,resizable=yes,scrollbars=yes');
return false;
}
function showAddPopup(triggeringLink) {
var name = triggeringLink.id.replace(/^add_/, '');
href = triggeringLink.href;
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
win.focus();
return false;
}
function closePopup(win, newID, newRepr, id) {
console.log(id)
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
win.close();
}
<script type="text/javascript">
function showEditPopup(url) {
var win = window.open(url, "Edit",
'height=500,width=800,resizable=yes,scrollbars=yes');
return false;
}
function showAddPopup(triggeringLink) {
var name = triggeringLink.id.replace(/^add_/, '');
href = triggeringLink.href;
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
win.focus();
return false;
}
function closePopup(win, newID, newRepr, id) {
console.log(id)
$(id).append('<option value=' + newID + ` title=${newRepr} >` + newRepr + '</option>').removeAttr("selected").val(newID)
win.close();
}
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
document.addEventListener('drop', function (e) { e.preventDefault(); }, false);
$(document).ready(function () {
$("#add_examination").appendTo($("label[for='id_examination']"));
$("#add_body_part").appendTo($("label[for='id_body_part']"));
$(document).ready(function () {
$("#add_examination").appendTo($("label[for='id_examination']"));
$("#add_body_part").appendTo($("label[for='id_body_part']"));
dropContainer = document.getElementById("drop-container");
dropContainer = document.getElementById("drop-container");
dropContainer.ondragover = function (evt) {
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragover = function (evt) {
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragenter = function (evt) {
$(evt.target).addClass("drop-target-active")
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragenter = function (evt) {
$(evt.target).addClass("drop-target-active")
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragleave = function (evt) {
$(evt.target).removeClass("drop-target-active")
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondragleave = function (evt) {
$(evt.target).removeClass("drop-target-active")
evt.preventDefault();
evt.stopPropagation();
};
dropContainer.ondrop = function (evt) {
console.log("drop event, ", evt);
$(evt.target).removeClass("drop-target-active");
dropContainer.ondrop = function (evt) {
console.log("drop event, ", evt);
$(evt.target).removeClass("drop-target-active");
// Get all input elements
input = document.getElementById("id_image");
input = document.getElementById("id_image");
//fileInput = document.getElementById("id_images-0-image");
f = evt.dataTransfer.files[0]
f = evt.dataTransfer.files[0]
let dT = new DataTransfer();
dT.clearData();
dT.items.add(f);
let dT = new DataTransfer();
dT.clearData();
dT.items.add(f);
input.files = dT.files;
input.files = dT.files;
ocr(input);
ocr(input);
// Make sure we have enough input targets
//input_diff = (evt.dataTransfer.files.length - inputs.length)
@@ -119,50 +120,50 @@
// dT.items.add(evt.dataTransfer.files[3]);
// fileInput.files = dT.files;
evt.preventDefault();
evt.stopPropagation();
updateFileList();
};
evt.preventDefault();
evt.stopPropagation();
updateFileList();
};
$('#add_more').click(() => { add_input_form() });
$('#add_more').click(() => { add_input_form() });
function updateFileList() {
$("#drop-filenames").empty()
$("#id_image").each((n, el) => {
console.log(el);
if (el.files.length > 0) {
extra_class = " image-ident-loading";
if ($(el).hasClass("image-ident-warning")) {
extra_class = " image-ident-warning"
} else if ($(el).hasClass("image-ident-ok")) {
extra_class = " image-ident-ok"
function updateFileList() {
$("#drop-filenames").empty()
$("#id_image").each((n, el) => {
console.log(el);
if (el.files.length > 0) {
extra_class = " image-ident-loading";
if ($(el).hasClass("image-ident-warning")) {
extra_class = " image-ident-warning"
} else if ($(el).hasClass("image-ident-ok")) {
extra_class = " image-ident-ok"
}
$("#drop-filenames").append(
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
)
}
$("#drop-filenames").append(
`<span>${n}: ${el.files[0].name}</span><img class='uploading${extra_class}' data-input-id='${el.id}' src=${URL.createObjectURL(el.files[0])}>`
)
}
})
})
}
}
$("input[type=file]").on('change', function () {
$(this).removeClass("image-ident-warning");
$(this).removeClass("image-ident-ok");
updateFileList();
console.log("input change1");
console.log("input change", this);
ocr(this);
});
$("input[type=file]").on('change', function () {
$(this).removeClass("image-ident-warning");
$(this).removeClass("image-ident-ok");
updateFileList();
console.log("input change1");
console.log("input change", this);
ocr(this);
});
//document.getElementById("id_question_type").selectedIndex = 1;
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
if (answer_1_status == 0) {
document.getElementById("id_answers-0-status").selectedIndex = 3;
}
answer_1_status = document.getElementById("id_answers-0-status").selectedIndex;
if (answer_1_status == 0) {
document.getElementById("id_answers-0-status").selectedIndex = 3;
}
});
function ocr(input) {
});
function ocr(input) {
// Tesseract.recognize(f, "eng",{ logger: m => console.log(m) }
// ).then(({ data: { text } }) => {
// console.log(text);
@@ -172,77 +173,78 @@
// }
// })
const worker = Tesseract.createWorker({
workerPath: '{% static "worker.min.js" %}',
langPath: '{% static "" %}',
const worker = Tesseract.createWorker({
workerPath: '{% static "worker.min.js" %}',
langPath: '{% static "" %}',
//langPath: '{{ STATIC_URL }}',
corePath: '{% static "tesseract-core.wasm.js" %}',
});
corePath: '{% static "tesseract-core.wasm.js" %}',
});
const url = URL.createObjectURL(input.files[0]);
let img = new Image;
img.src = url;
const url = URL.createObjectURL(input.files[0]);
let img = new Image;
img.src = url;
img.onload = function () {
img.onload = function () {
width = img.width;
width = img.width;
// const rectangle = { left: 0, top: 0, width: width, height: 10 }
(async () => {
await worker.load();
await worker.loadLanguage('eng');
await worker.initialize('eng');
(async () => {
await worker.load();
await worker.loadLanguage('eng');
await worker.initialize('eng');
// const { data: { text } } = await worker.recognize(input.files[0], { rectangle });
const { data: { text } } = await worker.recognize(input.files[0]);
const { data: { text } } = await worker.recognize(input.files[0]);
//console.log(text);
l = text.toLowerCase();
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
console.log(l);
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
console.log("SHIT", input);
$(input).addClass("image-ident-warning");
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
} else {
$(input).addClass("image-ident-ok");
l = text.toLowerCase();
$(`img[data-input-id='${input.id}'`).removeClass("image-ident-loading");
console.log(l);
if (l.includes("accesion") || l.includes("number") || l.search(/(ref|rk9|rh8|ra9|rbz)\d+/g) > -1) {
console.log("SHIT", input);
$(input).addClass("image-ident-warning");
$(`img[data-input-id='${input.id}'`).addClass("image-ident-warning");
} else {
$(input).addClass("image-ident-ok");
}
}
await worker.terminate();
})();
await worker.terminate();
})();
}
}
}
</script>
</script>
{{ form.media }}
{{ form.media }}
{% endblock %}
{% block content %}
{% if object %}
{% include 'anatomy/question_link_header.html' %}
{% endif %}
<h2>{{object|yesno:"Update,Create"}} Question</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This form can be used to create an Anatomy question.</p>
{% if object %}
{% include 'anatomy/question_link_header.html' %}
{% endif %}
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
</details>
<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>
<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>
<h2>{{object|yesno:"Update,Create"}} Question</h2>
{% help "Help" %}
<p>This form can be used to create an Anatomy question.</p>
{{ form|crispy }}
<div id="drop-container" class="drop-target">Drop image here
<div id="drop-filenames"></div>
</div>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
<p>Correct and incorrect answers can be defined once the question has been created.</p>
{% endhelp %}
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
{{ form|crispy }}
<div id="drop-container" class="drop-target">Drop image here
<div id="drop-filenames"></div>
</div>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
{% endblock %}
@@ -28,29 +28,60 @@
{% if object %}
{% include 'anatomy/question_link_header.html' %}
{% endif %}
<h2>Question Answers</h2>
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)
<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" 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 %}
+34 -11
View File
@@ -5,17 +5,40 @@
{% endblock %}
{% block navigation %}
Anatomy:
{% if request.user.is_authenticated %}
<a href="{% url 'anatomy:index' %}">Packets</a> /
<a href="{% url 'anatomy:exam_list' %}">Exams</a> /
<a href="{% url 'anatomy:exam_create' %}" title="Create a new exam">Create Exam</a> /
<a href="{% url 'anatomy:question_list' %}">Questions</a> /
<a href="{% url 'anatomy:anatomy_question_create' %}" title="Create a new question">Create Question</a>
{% if request.user.is_superuser %}
/ <a href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
{% endif %}
{% endif %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %}
{% block content %}
{% if simple_content %}
+9 -17
View File
@@ -15,7 +15,13 @@
<li data-question_pk={{question.pk}}>
<span class="flex-col">
<a href="{% url 'anatomy:exam_question_detail' pk=exam.pk sk=forloop.counter0 %}">
<img src="{{ question.image|thumbnail_url:'exam-list' }}" alt="thumbail" />
{% if question.image %}
<img src="{{ question.image|thumbnail_url:'exam-list' }}" alt="thumbail" />
{% else %}
No image added.
{% endif %}
</a>
</span>
<span class="flex-col-4">
@@ -37,22 +43,8 @@
{{ author }},
{% endfor %}
</div>
{% if can_edit %}
<p>
<button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Edit question order / Delete questions</button>
</p>
{% endif %}
<div>
JSON creation time: {{exam.json_creation_time}} ({{exam.json_creation_time|date:"c"}}),
JSON creation id: {{exam.exam_json_id}}
</div>
<a href="{% url 'anatomy:exam_json' pk=exam.pk %}">JSON</a>
<a href="{% url 'anatomy:exam_json_unbased' pk=exam.pk %}">JSON (unbased)</a>
<a href="{% url 'anatomy:exam_json_recreate' pk=exam.pk %}">Refresh JSON cache</a>
{% if request.user.is_superuser %}
<button id='button-open-access' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Make questions open access</button>
<button id='button-closed-access' data-posturl="{% url 'anatomy:exam_json_edit' pk=exam.pk %}">Make questions closed access</button>
{% endif %}
{% include 'generic/exam_footer.html' %}
</div>
{% include 'exam_overview_js.html' %}
@@ -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 %}
<a href="{% url 'anatomy:anatomy_create_exam' pk=exam.pk %}">Add New Question</a>
<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 %}
+35 -7
View File
@@ -3,7 +3,9 @@
{% block content %}
<div class="col-md-6">
<h2>Marking question {{question_details.current}} of {{question_details.total}}</h2>
<a href="{% url 'anatomy:question_detail' question.id %}" title="View the Question">View</a> <a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a> <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_detail' question.id %}" title="View the Question">View</a>
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
{% 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>{% endif %}
<h3>{{ question.question_type }}</h3>
{% if question.structure %}
Structure: {{question.structure}}<br/>
@@ -13,10 +15,36 @@
{% if question.answer_help %}
<details>
<summary>Marking help:</summary>
<div class="border border-secondary">
{{question.answer_help|safe}}
</div>
</details>
{% endif %}
{% 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
set btn to #answer-suggest-incorrect-button
js(btn) btn.click() end
repeat forever
if #id_answer_suggest_incorrect != null then break end
wait 0.1s
end
end
if (#id_answer_suggest_incorrect's value == '')
put (#id_answer_suggest_incorrect's value + my @data-word) into #id_answer_suggest_incorrect's value
else
put (#id_answer_suggest_incorrect's value + ',' + my @data-word) into #id_answer_suggest_incorrect's value
end
then remove closest parent <span/>" data-word='{{word}}'>(+)</span>,</span>
{% endfor %}
<div class="border border-secondary">
{% include "anatomy/question_detail.html#suggest-incorrect-form" %}
</div>
</details> {% endcomment %}
@@ -36,22 +64,22 @@
{% for answer in user_answers %}
{% if answer in answer_suggest_incorrect %}
<li><pre><span class="answer incorrect suggest" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
<li><pre><span class="answer incorrect suggest" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
{% else %}
<li><pre><span class="answer not-marked" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
<li><pre><span class="answer not-marked" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
{% endif %}
{% endfor %}
</ul>
Marked:
<ul id="marked-answer-list" class="answer-list">
{% for answer in correct_answers %}
<li><pre><span class="answer correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
<li><pre><span class="answer correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
{% endfor %}
{% for answer in half_mark_answers %}
<li><pre><span class="answer half-correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
<li><pre><span class="answer half-correct" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
{% endfor %}
{% for answer in incorrect_answers %}
<li><pre><span class="answer incorrect" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer %}">{{ answer }}</span></pre></li>
<li><pre><span class="answer incorrect" title="{{answer}}" data-answerurl="{% url 'anatomy:question_user_answers_by_compare' question.pk answer|urlencode:'' %}">{{ answer }}</span></pre></li>
{% endfor %}
</ul>
<div class="mark-buttons">
+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>
+222 -102
View File
@@ -1,111 +1,138 @@
{% extends 'anatomy/base.html' %}
{% load partials %}
{% block content %}
{% load static %}
{% include 'anatomy/question_link_header.html' %}
<div id="dicom-image" class="dicom-image" data-url="{{ remote_url }}{{ question.image.url}}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. To save the changes click the "Save Annotations" button.</p>
<p>There is no limit to the number of arrows that can be added to the image.</p>
</details>
<button id="save-annotations">Save Annotations</button>
<div 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>
Answer suggest incorrect: {{ question.answer_suggest_incorrect }}
</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">
@@ -151,7 +178,7 @@
function getJsonToolStateNoId() {
const el = document.getElementById("dicom-image");
const el = document.getElementById("anatomy-dicom-image");
const c = cornerstone.getEnabledElement(el);
@@ -167,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 %}
@@ -175,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>
@@ -0,0 +1,8 @@
{% load crispy_forms_tags %}
<form method="POST" class="post-form">{% csrf_token %}
{{ form | crispy}}
<button type="button" class="btn btn-sm" hx-post="{% url 'anatomy:question_suggest_incorrect_answers' question.id %}"
hx-target="#incorrect-answers"
hx-swap="innerHTML">Save</button>
<button type="button" _="on click remove closest <form/>" >Cancel</button>
</form>
+23 -19
View File
@@ -6,27 +6,31 @@
{% block content %}
<div id="view-filter-options">
<h3>Filter Anatomy Questions</h3>
<form action="" method="get">
{{ filter.form }}
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
</form>
</div>
{% render_table table %}
<div id="view-filter-options">
<h3>Filter Anatomy Questions</h3>
<form action="" method="get">
{{ filter.form }}
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
</form>
</div>
{% render_table table %}
<button id="button-select-add-exam"
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}"
data-type="anatomy"
data-csrf="{{ csrf_token}}">Add selected questions to
exam</button>
<div id="exam-options"></div>
<button id="button-select-add-exam"
hx-get="{% url 'generic:generic_exam_htmx' %}?type=anatomy"
hx-target="#action-result"
hx-swap="innerHTML"
data-exam_json_edit_url="{% url 'generic:generic_exam_json_edit' %}"
data-exam_list_url="{% url 'api-1:anatomy_user_exams' %}"
data-type="anatomy"
data-csrf="{{ csrf_token }}">
Add selected questions to exam
</button>
<div id="exam-options"></div>
<script type="text/javascript">
$(document).ready(function () {
<script type="text/javascript">
$(document).ready(function () {
});
</script>
});
</script>
{% endblock %}
+2 -2
View File
@@ -34,7 +34,7 @@ APP_NAME = "anatomy"
def create_exam(db):
exam: Exam = AnatomyExamViews.Exam.objects.create(
name="Cid Exam", exam_mode=True, active=True
name="Cid Exam", exam_mode=True, active=True, open_access=False, exam_open_access=False
)
group1: CidUserGroup = CidUserGroup.objects.get(name="Group1")
@@ -365,7 +365,7 @@ def test_exams(db, client):
cid_exam_scores_soup = BeautifulSoup(cid_exam_scores_res.content, "html.parser")
# Check that we have an alert that exams are not published
alert = cid_exam_scores_soup.find("div", {"class": "alert"})
alert = cid_exam_scores_soup.find("div", {"class": "no-results"})
assert "Results are not currently published." in str(alert)
answer_lis = cid_exam_scores_soup.find(
+31 -8
View File
@@ -7,21 +7,16 @@ from django.views.decorators.cache import cache_page
app_name = "anatomy"
urlpatterns = [
# path('', views.question_list, name='question_list'),
# path('', views.question_view, name='question_view'),
path(
"question/",
views.AnatomyQuestionView.as_view(),
name="question_list",
name="question_view",
),
path(
"question/create/",
views.AnatomyQuestionCreate.as_view(),
name="anatomy_question_create",
),
path(
"create/exam/<int:pk>",
views.AnatomyQuestionCreate.as_view(),
name="anatomy_create_exam",
name="question_create",
),
path(
"question/<int:pk>/update",
@@ -38,6 +33,17 @@ urlpatterns = [
views.question_save_annotation,
name="question_save_annotation",
),
path(
"question/<int:pk>/suggest_incorrect_answers",
views.question_suggest_incorrect_answers,
name="question_suggest_incorrect_answers",
),
path(
"question/<int:pk>/suggest_incorrect_collapsed",
views.question_suggest_incorrect_collapsed,
name="question_suggest_incorrect_collapsed",
),
path("question/<int:pk>/answer/", views.answer_question, name="answer_question"),
path(
"question/<int:pk>/clone", views.QuestionClone.as_view(), name="question_clone"
@@ -91,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))
+874 -56
View File
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -3,6 +3,7 @@ from .models import (
Case,
CaseDetail,
CaseCollection,
NormalCase,
Differential,
Series,
SeriesImage,
@@ -16,7 +17,10 @@ from .models import (
Presentation,
UncategorisedDicom,
SeriesDetail,
Resource
Resource,
CaseDisplaySet,
UserReportAnswer,
CidReportAnswer,
)
from django.forms import ModelForm
@@ -46,6 +50,9 @@ admin.site.register(CaseDetail)
admin.site.register(UncategorisedDicom)
admin.site.register(SeriesDetail)
admin.site.register(Resource)
admin.site.register(CaseDisplaySet)
admin.site.register(UserReportAnswer)
admin.site.register(CidReportAnswer)
class DifferentialInline(admin.TabularInline):
@@ -73,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
@@ -87,6 +110,7 @@ class AtlasAdminForm(ModelForm):
"subspecialty",
"author",
"series",
"cimar_uuid"
]
widgets = {
+95 -93
View File
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
from django.urls import reverse
from ninja import ModelSchema, Router, Schema, Field
from django.db import transaction
from django.db import transaction, IntegrityError
# from .decorators import user_is_author_or_rapid_checker
from django.core.exceptions import PermissionDenied
@@ -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)
@@ -70,8 +70,10 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
except DuplicateDicom as e:
duplicate.append((file.name, ud.image_blake3_hash))
duplicate_series.add(e.duplicate.get_series().get_absolute_url())
print(duplicate_series)
if type(e.duplicate) == SeriesImage:
duplicate_series.add(e.duplicate.series.get_absolute_url())
pass
except InvalidDicomError:
failed.append(file.name)
@@ -107,7 +109,6 @@ def clear_dicoms(request):
dicoms = UncategorisedDicom.objects.filter(user=request.user)
dicoms.delete()
print(dicoms)
return True
@@ -127,12 +128,10 @@ def uncategorised_dicoms(request):
def import_dicoms_helper(request, case_id: int | None = None):
# dicoms = UncategorisedDicom.objects.filter(user=request.user)
if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection")
)
else:
dicoms = UncategorisedDicom.objects.filter(user=request.user)
@@ -141,70 +140,75 @@ def import_dicoms_helper(request, case_id: int | None = None):
else:
order = None
data = defaultdict(list)
for d in dicoms:
# Group dicoms by SeriesInstanceUID, but don't keep all tags in memory
series_map = {}
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
tags = d.basic_dicom_tags
series_uid = tags["SeriesInstanceUID"]
data[tags["SeriesInstanceUID"]].append((d, tags))
if series_uid not in series_map:
# Get or create Series and related objects as needed
try:
modality = Modality.objects.get(short_code=tags["Modality"])
except Modality.DoesNotExist:
modality = Modality.objects.create(short_code=tags["Modality"], modality=tags["Modality"])
series_list = []
for series_uid in data:
modality = get_object_or_404(Modality, short_code=tags["Modality"])
tags = data[series_uid][0][1]
series_tags = tags # Save the first tag for this series
try:
with transaction.atomic():
series, created = Series.objects.get_or_create(
series_instance_uid=series_uid,
defaults={
"modality": modality,
"description": tags.get("SeriesDescription", ""),
}
)
if created and tags.get("StudyDescription"):
examination, created_exam = Examination.objects.get_or_create(
examination=tags["StudyDescription"]
)
if created_exam:
examination.modality = modality
examination.save()
series.examination = examination
series.save()
except IntegrityError:
series = Series.objects.get(series_instance_uid=series_uid)
# Check if series with the id already exists (in which case we just add to htat)
if Series.objects.filter(
series_instance_uid=tags["SeriesInstanceUID"]
).exists():
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
else:
series = Series(
modality=modality,
description=tags["SeriesDescription"],
series_instance_uid=tags["SeriesInstanceUID"],
)
if tags["StudyDescription"]:
examination, created = Examination.objects.get_or_create(
examination=tags["StudyDescription"]
)
if created:
examination.modality = modality
examination.save()
series.examination = examination
series.save()
match order:
case "order-series-instance-number":
series.order_by_dicom("InstanceNumber")
case "order-series-slice-location":
series.order_by_dicom("SliceLocation")
case _:
# Add author and case if needed
series.author.add(request.user)
if case_id is not None:
case_object = get_object_or_404(Case, pk=case_id)
try:
series.case.add(case_object)
except IntegrityError:
pass
# We might only want to add the author during creation....
series.author.add(request.user)
series_map[series_uid] = series
else:
series = series_map[series_uid]
if case_id is not None:
case_object = get_object_or_404(Case, pk=case_id)
if not series.case.contains(case_object):
series.case.add(case_object)
# Create and save SeriesImage immediately
series_image = SeriesImage(
image=d.image,
series=series,
is_dicom=True,
image_blake3_hash=d.image_blake3_hash,
)
series_image.save()
d.delete() # Remove UncategorisedDicom immediately
for dicom, dicom_tags in data[series_uid]:
series_image = SeriesImage(
image=dicom.image,
series=series,
is_dicom=True,
image_blake3_hash=dicom.image_blake3_hash,
)
series_image.save()
# dicom.image.delete()
# Optionally, re-order series if needed
for series in series_map.values():
match order:
case "order-series-instance-number":
series.order_by_dicom("InstanceNumber")
case "order-series-slice-location":
series.order_by_dicom("SliceLocation")
case _:
pass
dicom.delete()
series_list.append((series, series.get_link()))
return series_list
return [(series, series.get_link()) for series in series_map.values()]
@router.post(
@@ -225,7 +229,6 @@ def import_dicoms_case(request, case_id: int):
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
def orphan_series(request):
# print(request.user.series.filter(case=None))
return request.user.series.filter(case=None)
@@ -248,7 +251,6 @@ def series_remove_duplicate_images(request, series_id: int):
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
def series_truncate(request, series_id: int, start: int, end: int):
print(start, end)
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
@@ -259,11 +261,13 @@ def series_truncate(request, series_id: int, start: int, end: int):
if n >= start and n <= end:
continue
else:
print(n, image)
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
series.get_total_image_size(refresh=True)
return {"status": "success", "images_removed": images_removed}
@@ -275,7 +279,6 @@ def get_cases_user(request):
@router.get("/check_image_hash/{hash}", auth=django_auth)
def check_image_hash(request, hash: str):
try:
print(hash)
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
data = {
"status": "success",
@@ -354,39 +357,38 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
image_map = defaultdict(list)
# Split teh associated images
for image in series.images.all():
print(image)
# Use iterator to avoid loading all images into memory
for image in series.images.iterator():
ds = image.get_dicom_data()
# print("2a", ds[dicom_tag])
if dicom_tag in ds:
val = ds[dicom_tag].value
if type(val) == pydicom.multival.MultiValue:
image_map[",".join((val))].append(image)
if isinstance(val, pydicom.multival.MultiValue):
key = ",".join(map(str, val))
else:
image_map[val].append(image)
key = str(val)
image_map[key].append(image)
else:
return [f"{dicom_tag} not found {image}"]
case = list(series.case.all())
authors = list(series.author.all())
new_series = []
case = series.case.all()
authors = series.author.all()
for n, option in enumerate(image_map):
if n == 0:
series.images.set(image_map[option])
else:
series.pk = None
series.save()
series.case.set(case)
series.author.set(authors)
series.images.set(image_map[option])
new_series.append(series.pk)
# Use a transaction for all DB changes
with transaction.atomic():
for n, (option, images) in enumerate(image_map.items()):
if n == 0:
# Update the original series
series.images.set(images)
new_series.append(series.pk)
else:
# Clone the series
series.pk = None
series.save()
series.case.set(case)
series.author.set(authors)
series.images.set(images)
new_series.append(series.pk)
return new_series
+2
View File
@@ -80,6 +80,8 @@ def user_is_collection_author_or_atlas_editor(function):
def wrap(request, *args, **kwargs):
if "exam_id" in kwargs:
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
elif "collection_id" in kwargs:
atlas = CaseCollection.objects.get(pk=kwargs["collection_id"])
else:
atlas = CaseCollection.objects.get(pk=kwargs["pk"])
if (
+132 -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
@@ -49,6 +52,8 @@ def get_series(request):
else:
queryset = Series.objects.all()
queryset = queryset.prefetch_related("examination", "plane")
return queryset
@@ -159,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"],
}
@@ -184,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,
@@ -204,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,
@@ -224,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:
@@ -284,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
+663 -140
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
# Generated by Django 5.0.2 on 2024-10-09 20:39
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0061_alter_seriesfinding_current_image_id_index'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='casecollection',
name='markers',
field=models.ManyToManyField(blank=True, help_text='Authorised markers for the exam', related_name='casecollection_markers', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0062_alter_casecollection_markers'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='results_supervisor_visible',
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-11-18 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0063_casecollection_results_supervisor_visible'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='exam_open_access',
field=models.BooleanField(default=False, help_text='Set to true if you want any registered user to be able to take the exam.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-12-18 23:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0064_casecollection_exam_open_access'),
]
operations = [
migrations.AddField(
model_name='series',
name='total_image_size',
field=models.BigIntegerField(blank=True, help_text='Stores a (cached) total size of images within the series', null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-12-18 23:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0065_series_total_image_size'),
]
operations = [
migrations.AddField(
model_name='case',
name='total_images_series_size',
field=models.BigIntegerField(blank=True, null=True),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-02-10 13:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0066_case_total_images_series_size'),
]
operations = [
migrations.AddField(
model_name='case',
name='cimar_uuid',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-04-07 11:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0067_case_cimar_uuid'),
]
operations = [
migrations.AlterField(
model_name='seriesfinding',
name='description',
field=models.TextField(blank=True, help_text='Findings on the question', null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-04-28 15:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0068_alter_seriesfinding_description'),
]
operations = [
migrations.AddField(
model_name='caseprior',
name='prior_visibility',
field=models.CharField(choices=[('NO', 'None'), ('AL', 'Always'), ('RE', 'Review')], default='AL', help_text='Defines when the prior case is shown to the user', max_length=2),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-06-09 10:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0069_caseprior_prior_visibility'),
]
operations = [
migrations.AddField(
model_name='seriesfinding',
name='annotation_json_3d',
field=models.JSONField(default=dict),
),
migrations.AddField(
model_name='seriesfinding',
name='viewer_state_3d',
field=models.JSONField(default=dict),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-06-16 12:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0070_seriesfinding_annotation_json_3d_and_more'),
]
operations = [
migrations.AddField(
model_name='casedetail',
name='default_viewerstate',
field=models.JSONField(blank=True, help_text='Default viewer state for the case', null=True),
),
]
+27
View File
@@ -0,0 +1,27 @@
# Generated by Django 5.1.4 on 2025-06-30 12:03
import django.db.models.deletion
import generic.mixins
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0071_casedetail_default_viewerstate'),
]
operations = [
migrations.CreateModel(
name='CaseDisplaySet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name of the display set', max_length=255)),
('description', models.TextField(blank=True, help_text='Description of the display set')),
('viewerstate', models.JSONField(blank=True, help_text='Viewer state for the display set', null=True)),
('annotations', models.JSONField(blank=True, help_text='Annotations for the display set', null=True)),
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.case')),
],
bases=(models.Model, generic.mixins.AuthorMixin),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.1.4 on 2025-07-14 09:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0072_casedisplayset'),
]
operations = [
migrations.AddField(
model_name='casedisplayset',
name='conditions',
field=models.ManyToManyField(blank=True, to='atlas.condition'),
),
migrations.AddField(
model_name='casedisplayset',
name='findings',
field=models.ManyToManyField(blank=True, to='atlas.finding'),
),
migrations.AddField(
model_name='casedisplayset',
name='structures',
field=models.ManyToManyField(blank=True, to='atlas.structure'),
),
migrations.AlterField(
model_name='casedisplayset',
name='case',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='display_sets', to='atlas.case'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-09-02 20:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0073_casedisplayset_conditions_casedisplayset_findings_and_more'),
]
operations = [
migrations.AlterField(
model_name='seriesimage',
name='image_md5_hash',
field=models.CharField(blank=True, max_length=64, null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-09-15 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0074_alter_seriesimage_image_md5_hash'),
]
operations = [
migrations.AddField(
model_name='casedetail',
name='override_history',
field=models.TextField(blank=True, help_text='This will override the case history for the purpose of the exam/collection.', null=True),
),
migrations.AddField(
model_name='casedetail',
name='redact_history',
field=models.BooleanField(default=False, help_text='Set to true if the history should be redacted whilst taking the case.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-10-13 08:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0075_casedetail_override_history_and_more'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='question_time_limit',
field=models.PositiveIntegerField(help_text='Time limit for answering questions in seconds.', null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-10-13 08:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0076_casecollection_question_time_limit'),
]
operations = [
migrations.AlterField(
model_name='casecollection',
name='question_time_limit',
field=models.PositiveIntegerField(blank=True, help_text='Time limit for answering questions in seconds.', null=True),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.1.4 on 2025-10-13 08:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0077_alter_casecollection_question_time_limit'),
]
operations = [
migrations.AddField(
model_name='cidreportanswer',
name='started_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='cidreportanswer',
name='submitted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='userreportanswer',
name='started_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='userreportanswer',
name='submitted_at',
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-10-13 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0078_cidreportanswer_started_at_and_more'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='prerequisites',
field=models.ManyToManyField(blank=True, help_text='Collections that must be completed before this collection can be taken', related_name='dependents', to='atlas.casecollection'),
),
]
+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',
),
]
+783 -90
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
# serializers.py
from rest_framework import serializers, permissions
+178 -51
View File
@@ -1,7 +1,7 @@
import django_tables2 as tables
from django_tables2.utils import A
from generic.tables import SeriesImageColumn
from generic.tables import SeriesImageColumn, SelectionTable
from .models import (
Case,
@@ -14,6 +14,7 @@ from .models import (
Structure,
Finding,
Subspecialty,
Resource,
)
from django.utils.html import format_html
@@ -22,11 +23,64 @@ from easy_thumbnails.files import get_thumbnailer
from easy_thumbnails.exceptions import InvalidImageFormatError
from django.db.models import Prefetch
class CaseTable(SelectionTable):
def __init__(self, data=None, *args, **kwargs):
# Optimize prefetching for casecollection_set
#case_collections_prefetch = Prefetch(
# "casecollection_set",
# queryset=CaseCollection.objects.select_related("author"), # Adjust fields as needed
#)
super().__init__(
data.prefetch_related(
"author", # Many-to-many or reverse relationship
"series", # Many-to-many relationship
"series__examination", # ForeignKey in the related model
"series__plane", # ForeignKey in the related model
"series__images", # Reverse relationship
"previous_case", # Prefetch the previous case relationship
"next_case", # Prefetch the next case relationship
"casecollection_set", # Prefetch the case collection set
#case_collections_prefetch, # Optimized prefetch for collections
),
*args,
**kwargs,
)
def render_title(self, value, record):
return value
def render_description(self, value):
max_length = 50 # Set the maximum length for truncation
if len(value) > max_length:
truncated_value = f"{value[:max_length]}..." # Truncate and add ellipsis
else:
truncated_value = value
# Add a tooltip with the full text
return format_html(
'<span title="{}">{}</span>',
value, # Full text for the tooltip
truncated_value, # Truncated text for display
)
def render_series(self, value):
max_length = 50 # Set the maximum length for truncation
series_list = [str(series) for series in value.all()]
full_text = ", ".join(series_list) # Full text for the tooltip
if len(full_text) > max_length:
truncated_text = f"{full_text[:max_length]}..." # Truncate and add ellipsis
else:
truncated_text = full_text
# Add a tooltip with the full text
return format_html(
'<span title="{}">{}</span>',
full_text, # Full text for the tooltip
truncated_text, # Truncated text for display
)
class CaseTable(tables.Table):
def get_view_cell(record):
if record.open_access:
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
@@ -36,6 +90,78 @@ class CaseTable(tables.Table):
else:
return f'View'
def render_author(self, value):
max_length = 15 # Set the maximum length for truncating individual author names
authors = []
for author in value.all():
full_name = str(author) # Convert the author object to a string (e.g., full name)
if len(full_name) > max_length:
truncated_name = f"{full_name[:max_length]}..." # Truncate and add ellipsis
else:
truncated_name = full_name
# Add a tooltip with the full name
authors.append(
format_html(
'<span title="{}">{}</span>',
full_name, # Full name for the tooltip
truncated_name, # Truncated name for display
)
)
# Join all authors with commas
return format_html(", ".join(authors))
def render_associated_cases(self, record):
links = []
if record.previous_case:
links.append(
format_html(
'<a href="{}" title="View previous case">Previous Case</a>',
record.previous_case.get_absolute_url(),
)
)
try:
if record.next_case:
links.append(
format_html(
'<a href="{}" title="View next case">Next Case</a>',
record.next_case.get_absolute_url(),
)
)
except Case.DoesNotExist:
pass
if links:
return format_html(", ".join(links))
return "No associated cases"
def render_collections(self, record):
collections = [
collection
for collection in record.casecollection_set.all()
if (
hasattr(self, 'request')
and self.request.user in collection.author.all()
) or collection.open_access
]
if collections:
links = [
format_html(
'<a href="{}" title="View collection">{}</a>',
collection.get_absolute_url(),
collection.name,
)
for collection in collections
]
return format_html(", ".join(links))
return "No collections"
associated_cases = tables.Column(
verbose_name="Associated Cases", accessor="pk", orderable=False
)
collections = tables.Column(
verbose_name="Collections", accessor="pk", orderable=False
)
edit = tables.LinkColumn(
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
)
@@ -49,31 +175,23 @@ class CaseTable(tables.Table):
"atlas:case_delete", text="Delete", args=[A("pk")], orderable=False
)
#differential = tables.ManyToManyColumn(verbose_name="Differential")
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
class Meta:
class Meta(SelectionTable.Meta):
model = Case
template_name = "django_tables2/bootstrap4.html"
fields = (
"title",
"description",
#"history",
#"condition",
"created_date",
"series",
"author",
"associated_cases",
"collections",
)
sequence = ("view", )#, "series")
sequence = ("view", )
order_by = "-created_date"
def __init__(self, data=None, *args, **kwargs):
super().__init__(
data.prefetch_related(
"author",#"condition", "condition__synonym"
),
*args,
**kwargs,
)
class PopupLinkColumn(tables.Column):
def render(self, value):
@@ -86,12 +204,7 @@ class PopupLinkColumn(tables.Column):
return obj.get_thumbnail()[0]
def create_link(test):
print(test)
return "Hello"
class SeriesTable(tables.Table):
class SeriesTable(SelectionTable):
edit = tables.LinkColumn(
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
)
@@ -105,13 +218,12 @@ class SeriesTable(tables.Table):
delete = tables.LinkColumn(
"atlas:series_delete", text="Delete", args=[A("pk")], orderable=False
)
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
case = tables.ManyToManyColumn(verbose_name="Cases")
findings = tables.ManyToManyColumn(verbose_name="Findings")
structures = tables.ManyToManyColumn(verbose_name="Structures")
class Meta:
class Meta(SelectionTable.Meta):
model = Series
template_name = "django_tables2/bootstrap4.html"
fields = (
@@ -125,6 +237,8 @@ class SeriesTable(tables.Table):
"modified_date",
)
sequence = ("view", "popup", "images", "case")
order_by = "-created_date"
def __init__(self, data=None, *args, **kwargs):
super().__init__(
@@ -142,7 +256,7 @@ class SeriesTable(tables.Table):
)
class ConditionTable(tables.Table):
class ConditionTable(SelectionTable):
name = tables.Column(
linkify=("atlas:condition_detail", {"pk": tables.A("pk")}),
verbose_name="Condition",
@@ -154,18 +268,18 @@ class ConditionTable(tables.Table):
delete = tables.LinkColumn(
"atlas:condition_delete", text="Delete", args=[A("pk")], orderable=False
)
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
synonym = tables.Column(empty_values=())
parent = tables.ManyToManyColumn(verbose_name="Parents")
class Meta:
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",)
def __init__(self, data=None, *args, **kwargs):
super().__init__(
data.prefetch_related(
@@ -180,7 +294,7 @@ class ConditionTable(tables.Table):
return f"{record.get_synonym_link()}"
class FindingTable(tables.Table):
class FindingTable(SelectionTable):
name = tables.Column(
linkify=("atlas:finding_detail", {"pk": tables.A("pk")}), verbose_name="Finding"
)
@@ -190,23 +304,23 @@ class FindingTable(tables.Table):
delete = tables.LinkColumn(
"atlas:finding_delete", text="Delete", args=[A("pk")], orderable=False
)
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
synonym = tables.Column(empty_values=())
parent = tables.ManyToManyColumn(verbose_name="Parents")
class Meta:
class Meta(SelectionTable.Meta):
model = Finding
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
fields = ("name",)
sequence = ("name",)
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
class StructureTable(tables.Table):
class StructureTable(SelectionTable):
name = tables.Column(
linkify=("atlas:structure_detail", {"pk": tables.A("pk")}),
verbose_name="Structure",
@@ -217,23 +331,23 @@ class StructureTable(tables.Table):
delete = tables.LinkColumn(
"atlas:structure_delete", text="Delete", args=[A("pk")], orderable=False
)
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
synonym = tables.Column(empty_values=())
parent = tables.ManyToManyColumn(verbose_name="Parents")
class Meta:
class Meta(SelectionTable.Meta):
model = Structure
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
fields = ("name",)
sequence = ("name",)
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
class PresentationTable(tables.Table):
class PresentationTable(SelectionTable):
name = tables.Column(
linkify=("atlas:presentation_detail", {"pk": tables.A("pk")}),
verbose_name="Presentation",
@@ -245,18 +359,19 @@ class PresentationTable(tables.Table):
# delete = tables.LinkColumn(
# "atlas:presentation_delete", text="Delete", args=[A("pk")], orderable=False
# )
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
subspecialty = tables.ManyToManyColumn(verbose_name="Subspecialty")
class Meta:
class Meta(SelectionTable.Meta):
model = Presentation
template_name = "django_tables2/bootstrap4.html"
fields = ("name",)
sequence = ("name",)
class PathologicalProcessTable(tables.Table):
class PathologicalProcessTable(SelectionTable):
name = tables.Column(
linkify=("atlas:pathological_process_detail", {"pk": tables.A("pk")}),
verbose_name="PathologicalProcess",
@@ -268,16 +383,15 @@ class PathologicalProcessTable(tables.Table):
# delete = tables.LinkColumn(
# "atlas:pathological_process_delete", text="Delete", args=[A("pk")], orderable=False
# )
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
class Meta:
class Meta(SelectionTable.Meta):
model = PathologicalProcess
template_name = "django_tables2/bootstrap4.html"
fields = ("name",)
sequence = ("name",)
class SubspecialtyTable(tables.Table):
class SubspecialtyTable(SelectionTable):
name = tables.Column(
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
verbose_name="Subspecialty",
@@ -289,19 +403,31 @@ class SubspecialtyTable(tables.Table):
# delete = tables.LinkColumn(
# "atlas:subspecialty_delete", text="Delete", args=[A("pk")], orderable=False
# )
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
class Meta:
class Meta(SelectionTable.Meta):
model = Subspecialty
template_name = "django_tables2/bootstrap4.html"
fields = ()
sequence = ("name",)
def render_synonym(self, value, record):
return format_html(record.get_synonym_link())
return f"{record.get_synonym_link()}"
class CaseCollectionTable(tables.Table):
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
)
@@ -317,9 +443,8 @@ class CaseCollectionTable(tables.Table):
#differential = tables.ManyToManyColumn(verbose_name="Differential")
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
class Meta:
class Meta(SelectionTable.Meta):
model = CaseCollection
template_name = "django_tables2/bootstrap4.html"
fields = (
@@ -331,6 +456,7 @@ class CaseCollectionTable(tables.Table):
)
sequence = ("view", )#, "series")
def __init__(self, data=None, *args, **kwargs):
super().__init__(
data.prefetch_related(
@@ -340,7 +466,7 @@ class CaseCollectionTable(tables.Table):
**kwargs,
)
class QuestionSchemaTable(tables.Table):
class QuestionSchemaTable(SelectionTable):
name = tables.Column(
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
verbose_name="QuestionSchema",
@@ -353,10 +479,11 @@ class QuestionSchemaTable(tables.Table):
"atlas:question_schema_delete", text="Delete", args=[A("pk")], orderable=False
)
class Meta:
class Meta(SelectionTable.Meta):
model = QuestionSchema
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "description", "schema")
def render_schema(self, value, record):
return format_html("<details><summary>Schema</summary>{}</details>", value)
+52
View File
@@ -0,0 +1,52 @@
from time import sleep
from django.core.mail import send_mail
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from celery import shared_task
from atlas.models import Case
from generic.models import CimarCase
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
from helpers.cimar import CimarAPI, NotFoundError
from pydicom.uid import generate_uid
@shared_task()
def push_case_to_cimar_task(case_id):
"""Sends an email when the feedback form has been submitted."""
case = get_object_or_404(Case, pk=case_id)
api = CimarAPI()
api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)
# We use the same study_uid for all images in a case
study_uid = generate_uid()
for series in case.get_series():
print(f"Upload series: {series}")
for image in series.images.filter(removed=False):
data = api.upload_dicom(image.image.path, study_uid=study_uid)
retries = 5
delay = 20
for attempt in range(retries):
print("attempt 1")
try:
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
break
except NotFoundError:
if attempt < retries - 1:
sleep(delay)
delay *= 2
else:
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
case.cimar_uuid = cimar_uuid
case.save()
cimar_case, created = CimarCase.objects.get_or_create(uuid=cimar_uuid)
cimar_case.refresh_study()
return 10
@@ -0,0 +1,34 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Search results for "{{ query }}"</h5>
{% for group_name, items in results.items %}
{% if items.exists %}
<h6 class="mt-3">{{ group_name|capfirst }}</h6>
<ul class="list-unstyled">
{% for item in items %}
<li>
{% if group_name == 'conditions' %}
<a href="{% url 'atlas:condition_detail' item.pk %}">{{ item.name }}</a>
{% elif group_name == 'findings' %}
<a href="{% url 'atlas:finding_detail' item.pk %}">{{ item.name }}</a>
{% elif group_name == 'structures' %}
<a href="{% url 'atlas:structure_detail' item.pk %}">{{ item.name }}</a>
{% elif group_name == 'subspecialties' %}
<a href="{% url 'atlas:subspecialty_detail' item.pk %}">{{ item.name }}</a>
{% elif group_name == 'presentations' %}
<a href="{% url 'atlas:presentation_detail' item.pk %}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists %}
<p class="text-muted">No matches found.</p>
{% endif %}
</div>
</div>
@@ -0,0 +1,9 @@
{# Render a small example answers form for a given CaseDetail - expects a context variable `form` (JsonAnswerForm) #}
<form method="POST" class="post-form">
{% csrf_token %}
{# Include any media the field needs (json-editor) #}
<div class="json-editor-wrapper">
{{ form.as_p}}
</div>
</form>
@@ -1,13 +1,9 @@
<span id="add-case-form">
<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>
+115 -12
View File
@@ -5,19 +5,122 @@
{% endblock %}
{% block navigation %}
Atlas:
{% if request.user.is_authenticated %}
<a href="{% url 'atlas:collection_view' %}">Collections</a> /
<a href="{% url 'atlas:case_view' %}?sort=-created_date">Cases</a> /
<a href="{% url 'atlas:series_view' %}">Series</a> /
<a href="{% url 'atlas:categories_list' %}">Categories</a> /
{% comment %} <a href="{% url 'atlas:resource_view' %}">Resources</a> / {% endcomment %}
<a href="{% url 'atlas:case_create' %}" title="Create a new atlas case">Create Case</a> /
<a href="{% url 'atlas:series_create' %}" title="Create a new image series">Create Series</a> /
<a href="{% url 'atlas:resource_view' %}" title="Resources">Resources</a> /
<a href="{% url 'atlas:user_uploads' %}" title="View unimported uploads">Uploads</a> /
<a href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas">Question Schemas</a> /
<a href="{% url 'atlas:help' %}" title="View the atlas help pages">Help</a>
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'atlas:index' %}">Atlas</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}"><i class="bi bi-collection me-1" aria-hidden="true"></i>Collections</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:user_collections' %}"><i class="bi bi-eye me-1" aria-hidden="true"></i>Collections to view / take</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'atlas:collection_view' %}"><i class="bi bi-gear me-1" aria-hidden="true"></i>Manage Collections</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:exam_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create collection</a>
</li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:case_view' %}"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Cases</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:case_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Cases</a>
</li>
<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">
<a class="nav-link d-inline" href="{% url 'atlas:series_view' %}"><i class="bi bi-collection-play me-1" aria-hidden="true"></i>Series</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:series_view' %}"><i class="bi bi-gear me-1" aria-hidden="true"></i>Manage Series</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:series_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Series</a>
</li>
</ul>
</li>
<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 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>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
{% comment %} </br>
Questions by:
+676 -66
View File
@@ -1,13 +1,168 @@
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
<div><a href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">View case in OHIF</a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">(new tab)</a>
{% load partials %}
<div class="date">
{{ case.created_date|date:"d/m/Y" }}
{% partialdef case-series %}
{% for series in case.get_ordered_series %}
<span class="series-block" id="series-block-{{ series.pk }}">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
{{series.get_block}}
</a>
<br>
<button type="button"
class="btn btn-outline-primary btn-sm select-series-btn"
style="display: none;"
data-series-id="{{series.pk}}">
Select
</button>
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<span class="series-block-popup-link">
<a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
</span>
</span>
</span>
{% endfor %}
{% endpartialdef %}
<div class="atlas {% if case.scrapped %}atlas-scrapped{% endif %}">
<h2>Case: {{ case.title }}</h2>
{% if case.previous_case or case.next_case %}
<div class="alert alert-info" role="alert">
This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/>
<div class="row">
{% if case.previous_case %}
<div class="col-sm-6 mb-3 mb-sm-0">
<h5 class="card-title">Previous Case</h5>
{{ case.previous_case.get_link }}
</div>
{% endif %}
{% if case.next_case %}
<div class="col-sm-6">
<h5 class="card-title">Next Case</h5>
{{ case.next_case.get_link }}
</div>
{% endif %}
</div>
</div>
<div class="id">
ID: {{ case.id }}
{% endif %}
<div>
{% if case.cimar_uuid %}
<details><summary>Details</summary>
Case uuid: {{case.cimar_uuid}}
<br/>
{% if cimar_sid %}
<a href="{{case.get_cimar_case.viewer_link}}?sid={{cimar_sid}}" target="_blank">External</a>
{% else %}
<a href="{{case.get_cimar_case.viewer_link}}" target="_blank">External</a>
{% endif %}
<button
hx-get='{% url "cimar_study_viewer_embed" case.cimar_uuid %}'
hx-target="#modals-here"
hx-trigger="click"
data-bs-toggle="modal"
data-bs-target="#modals-here"
class="btn primary">Open Modal</button>
<div id="modals-here"
class="modal modal-blur fade"
style="display: none"
aria-hidden="false"
tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content"></div>
</div>
</div>
<a href='{% url "cimar_study_viewer" case.cimar_uuid %}'>Viewer</a>
<details>
<summary>Study details</summary>
{{ case.get_cimar_case.get_pretty_study_details }}
</details>
<details>
<summary>Study schema</summary>
{{ case.get_cimar_case.get_pretty_study_schema }}
</details>
<details>
<summary>Study series</summary>
<button
id='load-series'
hx-get='{% url "cimar_study_series_block" case.cimar_uuid %}'
hx-trigger='load'
hx-swap='outerHTML'>
Load Series
</button>
</details>
<a href='{% url "generic:cimar_case_refresh" case.cimar_uuid %}'>Refresh</a>
</details>
{% else %}
<div class="btn-group link-buttons" role="group">
<button type="button" class="btn btn-primary" onclick="window.location.href='/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}'">View case in OHIF</button>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open in new tab</a>
</li>
</ul>
</div>
{% endif %}
<div class="clearfix mb-2">
<div class="float-start">
<span class="id">ID: {{ case.id }}</span>
<span class="mx-1">/</span>
<span class="date">{{ case.created_date|date:"d/m/Y" }}</span>
</div>
<p class="pre-whitespace"><b>Title:</b> {{ case.title }}</p>
<div class="float-end text-end">
{# Subspecialty badges (preserve any anchor returned by get_link) #}
{% if case.subspecialty.all %}
{% for sub in case.subspecialty.all %}
<span class="badge bg-secondary ms-1">{{ sub.get_link|safe }}</span>
{% endfor %}
{% else %}
<span class="text-muted me-2">No subspecialty</span>
{% endif %}
{# Pathological process badges #}
{% if case.pathological_process.all %}
{% for p in case.pathological_process.all %}
<span class="badge bg-secondary ms-1">{{ p.get_link|safe }}</span>
{% endfor %}
{% endif %}
{# 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>
<details id="dicom-viewer-details">
<summary>Viewer</summary>
<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.get_case_named_stacks}}'
></div>
</details>
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
@@ -20,11 +175,32 @@
</ul>
</p>
<p class="pre-whitespace"><b>History:</b> {{ case.history }}</p>
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion }}</p>
<p class="pre-whitespace"><b>Report:</b> {{ case.report }}</p>
<div class="row g-3 mb-3">
<div class="col-12 col-md-12">
<div class="card h-100">
<div class="card-header">History</div>
<div class="card-body">
<div class="pre-whitespace">{{ case.history|linebreaks }}</div>
</div>
</div>
</div>
<div class="col-12 col-md-12">
<div class="card h-100">
<div class="card-header">Discussion</div>
<div class="card-body">
<div class="pre-whitespace">{{ case.discussion|linebreaks }}</div>
</div>
</div>
</div>
<div class="col-12 col-md-12">
<div class="card h-100">
<div class="card-header">Report</div>
<div class="card-body">
<div class="pre-whitespace">{{ case.report|linebreaks }}</div>
</div>
</div>
</div>
</div>
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %}
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
{% if caseresource.pre_review %}
@@ -37,40 +213,35 @@
<div class="pre-whitespace multi-image-block"><b>Series:</b>
<form>
{% for series in case.series.all %}
<span class="series-block">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
{{series.get_block}}
</a>
<br>
<input type="checkbox" name="series-ids" value="{{series.pk}}">
<span class="series-block-popup-link">
<a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
</span>
</span>
</span>
{% endfor %}
<div class="d-flex flex-wrap gap-2 align-items-start" id="case-series-row">
{% partial case-series %}
</div>
</form>
<span>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
<details>
<summary>+</summary>
<button type="button"
class="btn btn-info"
onclick="window.location.href='{% url 'atlas:user_uploads_case' case_id=case.pk %}'">
Import new uploads
</button><br />
<details class="series-actions" id="series-actions">
<summary>
<button type="button" class="btn btn-primary" title="Click to manage series" onclick="this.closest('details').open = !this.closest('details').open;">
Manage Series
</button>
</summary>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a><br />
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location"
hx-target="this"
hx-swap="outerHTML"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will reorder all case series based upon slice location"
>
Order all series dicoms by slice location
Order dicoms by slice location
</button>
<button hx-post="{% url 'atlas:combine_series' %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-merge-results"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will merge all selected series into one"
>
@@ -79,14 +250,33 @@
<button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-merge-results"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will use the dicom date as the series description"
>
Use dates as description
</button>
<button hx-post="{% url 'atlas:remove_selected_series_from_case' case.pk %}"
title="remove selected series from this case"
hx-include="[name='series-ids']:checked"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to remove the selected series from this case? They will not be deleted from the database."
class="btn btn-warning mt-2"
type="button"
>
Remove selected series from case
</button>
<button type="button"
class="btn btn-secondary mt-2"
data-bs-toggle="modal"
data-bs-target="#reorderSeriesModal">
Reorder series
</button>
<br/>
<span id="series-merge-results"></span>
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
<span id="series-action-results" ></span>
</div>
</details>
</span>
@@ -95,7 +285,7 @@
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
<div class="atlas-answer">
<p class="pre-whitespace">
<div class="pre-whitespace">
Condition:
<ul>
{% for con in case.condition.all %}
@@ -103,23 +293,46 @@
{% endfor %}
</ul>
<div>
<details>
<details open>
<summary><b>Findings</b></summary>
{% if case.series.all %}
{% for series in case.series.all %}
{% if can_edit %}
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Findings are display sets that can be added to series. Once added they will appear here on linked cases.</p>
<p>To add a finding to a series, select the series and then click the "Add finding" button.</p>
</details>
{% endif %}
<!-- Series Findings -->
<h5>Series Findings</h5>
{% if case.ordered_series %}
{% for series in case.ordered_series %}
{% for finding in series.findings.all %}
<div class="finding-box">
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
<span>
<a href="{{series.get_absolute_url}}">Series {{series.pk}}</a>: {{series.get_examination_full}} |&nbsp;
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
<button class="btn btn-primary btn-sm">View</button>
</a>
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
View in Modal
</button>
</span>
{% if finding.findings.all %}
{% if finding.findings %}
<span>
Findings: {% for f in finding.findings.all %}
{{f.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.structures.all %}
{% if finding.conditions %}
<span>
Conditions: {% for c in finding.conditions.all %}
{{c.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.structures %}
<span>
Structure: {% for s in finding.structures.all %}
{{s.get_link}},&nbsp;
@@ -137,6 +350,75 @@
{% else %}
No series associated with case.
{% endif %}
<!-- Case Display Sets -->
<h5 class="mt-3">Case Display Sets</h5>
{% if case.display_sets %}
<ul class="list-group">
{% for ds in case.display_sets.all %}
<li class="list-group-item">
<b>Name: {{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted">({{ ds.description }})</span>
{% endif %}
<span>
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
</span>
<div>
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul>
{% for finding in ds.findings.all %}
<li>{{ finding }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul>
{% for structure in ds.structures.all %}
<li>{{ structure }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul>
{% for condition in ds.conditions.all %}
<li>{{ condition }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<details>
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
<div>
<strong>Viewer State:</strong>
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
</div>
<div>
<strong>Annotations:</strong>
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
</div>
</details>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">No display sets for this case.</span>
{% endif %}
</details>
</div>
<div>
@@ -149,35 +431,54 @@
</ul>
</details>
</div>
</p>
</div>
</div>
Subspecialty:
<ul>
{% for sub in case.subspecialty.all %}
<li>{{sub.get_link}}</li>
{% endfor %}
</ul>
<b>Pathological Process:</b>
<ul>
{% for p in case.pathological_process.all %}
<li>{{p.get_link}}</li>
{% endfor %}
</ul>
<b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br />
<b>Collections:</b>
<button
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
hx-target="#collection-form">
Add collection</button>
{% if can_edit %}
<button
hx-get="{% url 'atlas:case_collection_form' case.pk %}"
hx-target="#collection-form">
Add to collection</button>
{% endif %}
<div id="collection-form"></div>
<ul>
{% for collection in case.casecollection_set.all %}
<li><a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{collection.name}}</a></li>
<li id="collection-{{ collection.pk }}">
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
{% if can_edit %}
<button
class="btn btn-danger btn-sm remove-button"
hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}"
hx-target="#collection-{{ collection.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to remove this case from the collection?">
Remove
</button>
{% endif %}
</li>
{% endfor %}
</ul>
</p>
{% if casedetail %}
{% include 'atlas/partials/collection_question_block.html' with can_edit=can_edit %}
{% endif %}
<p class="pre-whitespace"><b>Previous case:</b> {{ case.previous_case.get_link }}</p>
<p class="pre-whitespace"><b>Next case:</b> {{ case.next_case.get_link }}</p>
<p class="pre-whitespace"><b>Next case:</b>
{% if case.next_case %}
{{ case.next_case.get_link }}
{% else %}
<a href="{% url 'atlas:case_clone_next' case.pk %}">Clone and add next case</a>
{% endif %}
</p>
<div>
{% include 'question_notes.html' %}
@@ -188,3 +489,312 @@
<p><b>Author(s):</b> {% for author in case.author.all %} <a
href="{% url 'atlas:author_detail' pk=author.pk %}">{{author}}</a>, {% endfor %}</p>
<p><b>Case size:</b> {{ case.get_total_series_images_size | filesizeformat }}</p>
<div class="modal fade" id="findingModal" tabindex="-1" aria-labelledby="findingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="findingModalLabel">Finding Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="findingModalBody">
<!-- Finding details will be loaded here dynamically -->
<p>Loading...</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Reorder Series Modal -->
<div class="modal fade" id="reorderSeriesModal" tabindex="-1" aria-labelledby="reorderSeriesModalLabel" aria-hidden="true">
<div class="modal-dialog">
<form method="post" action="{% url 'atlas:reorder_series' case.pk %}" id="reorder-series-form">
{% csrf_token %}
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="reorderSeriesModalLabel">Reorder Series</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul id="series-reorder-list" class="list-group">
{% for series in case.get_ordered_series %}
<li class="list-group-item d-flex justify-content-between align-items-center" data-series-id="{{ series.pk }}">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span>
{{ series.get_block|default:series }}
</span>
<span>
<button type="button" class="btn btn-sm btn-outline-secondary move-up"></button>
<button type="button" class="btn btn-sm btn-outline-secondary move-down"></button>
</span>
<input type="hidden" name="series_order" value="{{ series.pk }}">
</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save order</button>
</div>
</div>
</form>
</div>
</div>
<script>
// From series_viewer.html
function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element, current_image_id_index) {
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
cornerstone.getEnabledElement(dicom_element).viewport = viewport
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(
annotation);
console.log("annotation", annotation)
console.log("viewport", viewport)
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
cornerstone.resize(dicom_element);
console.log("current_image_id_index", current_image_id_index)
//current_image_id_index = parseInt(current_image_id_index);
console.log(current_image_id_index, Number.isInteger(current_image_id_index))
if (Number.isInteger(current_image_id_index)) {
console.log("loading stack index")
dicomViewer.loadStackIndex(current_image_id_index, dicom_element);
} else {
try {
console.log("current_image_id_index", current_image_id_index)
dicomViewer.loadImageById(current_image_id_index, dicom_element);
} catch (e) {
console.log(e)
console.log("loading image by id failed")
console.log("loading next annotation image")
dicomViewer.getNextAnnotationImage(dicom_element);
}
}
}
document.addEventListener("DOMContentLoaded", function () {
const dicomDetails = document.getElementById("dicom-viewer-details");
let dicomViewerLoaded = false;
dicomDetails.addEventListener("toggle", function() {
if (!dicomViewerLoaded && dicomDetails.open) {
window.mountDicomViewers();
dicomViewerLoaded = true;
}
});
// Move up/down logic
document.querySelectorAll("#series-reorder-list .move-up, #series-reorder-list .move-down").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.preventDefault();
const li = btn.closest("li");
if (btn.classList.contains("move-up") && li.previousElementSibling) {
li.parentNode.insertBefore(li, li.previousElementSibling);
}
if (btn.classList.contains("move-down") && li.nextElementSibling) {
li.parentNode.insertBefore(li.nextElementSibling, li);
}
});
});
// On submit, update the hidden inputs to match the new order
document.getElementById("reorder-series-form").addEventListener("submit", function(e) {
const lis = document.querynelectorAll("#series-reorder-list li");
lis.forEach((li, idx) => {
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
});
});
// Drag and drop reordering for series list
const reorderList = document.getElementById("series-reorder-list");
let draggedItem = null;
if (reorderList) {
reorderList.querySelectorAll("li").forEach(function(li) {
li.setAttribute("draggable", "true");
li.addEventListener("dragstart", function(e) {
draggedItem = li;
li.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
li.addEventListener("dragend", function() {
draggedItem = null;
li.classList.remove("dragging");
});
li.addEventListener("dragover", function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Visual feedback
li.classList.add("drag-over");
});
li.addEventListener("dragleave", function() {
li.classList.remove("drag-over");
});
li.addEventListener("drop", function(e) {
e.preventDefault();
li.classList.remove("drag-over");
if (draggedItem && draggedItem !== li) {
// Insert before or after depending on mouse position
const bounding = li.getBoundingClientRect();
const offset = e.clientY - bounding.top;
if (offset < bounding.height / 2) {
li.parentNode.insertBefore(draggedItem, li);
} else {
li.parentNode.insertBefore(draggedItem, li.nextSibling);
}
}
});
});
}
const detailsElement = document.getElementById("series-actions");
const buttons = document.querySelectorAll(".select-series-btn");
function toggleCheckboxes() {
const isOpen = detailsElement.hasAttribute("open");
buttons.forEach(button => {
button.style.display = isOpen ? "inline-block" : "none";
});
}
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
const parent = btn.closest(".series-block");
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
checkbox.checked = !checkbox.checked;
btn.classList.toggle("btn-primary", checkbox.checked);
btn.classList.toggle("btn-outline-primary", !checkbox.checked);
btn.textContent = checkbox.checked ? "Selected" : "Select";
// Highlight the series-block when selected
if (checkbox.checked) {
parent.classList.add("highlight-series");
} else {
parent.classList.remove("highlight-series");
}
});
});
// Initial state
toggleCheckboxes();
// Show the alert when a result is received
const resultsSpan = document.getElementById("series-action-results");
const alertDiv = document.getElementById("series-action-alert");
const observer = new MutationObserver(function(mutations) {
if (resultsSpan.textContent.trim() !== "") {
alertDiv.style.display = "block";
} else {
alertDiv.style.display = "none";
}
});
observer.observe(resultsSpan, { childList: true, subtree: true });
// Listen for toggle events on the <details> element
detailsElement.addEventListener("toggle", toggleCheckboxes);
const modal = new bootstrap.Modal(document.getElementById("findingModal"));
const modalBody = document.getElementById("findingModalBody");
document.querySelectorAll(".view-finding-modal").forEach(button => {
button.addEventListener("click", function () {
const findingId = this.getAttribute("data-finding-id");
const seriesId = this.getAttribute("data-series-id");
// Show loading text
modalBody.innerHTML = "<p>Loading...</p>";
// Fetch the finding details (replace with your actual endpoint)
fetch(`/atlas/series_finding/${findingId}/details/`)
.then(response => response.text())
.then(html => {
modalBody.innerHTML = html; // Load the fetched HTML into the modal body
load_3d = false;
viewer_element_3d = document.getElementById('root');
if (viewer_element_3d.dataset.viewerstate3d != undefined) {
load_3d = true;
viewerState = JSON.parse(viewer_element_3d.dataset.viewerstate3d || {});
annotationjson3d = JSON.parse(viewer_element_3d.dataset.annotationjson3d || {});
} else {
viewer_element_3d = null;
}
// We have a slight delay to allow the viewer to load
setTimeout(function () {
// Then we load the annotation and viewport
window.mountDicomViewers();
if (load_3d) {
console.log("loading 3d")
//setTimeout(function () {
// console.log("loading 3d annotation")
// importAnnotations_root(annotationjson3d)
// importViewerState_root(viewerState);
//}, 1000);
} else {
console.log("not loading 3d")
}
}, 200);
document.getElementById("reload-finding").addEventListener("click", function () {
window.mountDicomViewers();
});
// Initialize the DICOM viewer with the images and annotations
})
.catch(error => {
console.error("Error loading finding details:", error);
modalBody.innerHTML = "<p>Failed to load finding details.</p>";
});
// Show the modal
modal.show();
});
});
});
</script>
<style>
/* Highlight style for the series-block */
.highlight-series {
background-color: blue;
transition: background-color 0.3s ease, border 0.3s ease;
}
/* Make series blocks act as flex items and wrap neatly on small screens */
.series-block {
display: inline-block;
vertical-align: top;
min-width: 180px;
max-width: 320px;
margin-right: 0.5rem;
}
#series-reorder-list .dragging {
opacity: 0.5;
}
#series-reorder-list .drag-over {
border-top: 2px solid #0d6efd;
}
.series-actions[open] {
border: 2px solid #0d6efd;
border-radius: 6px;
padding: 8px;
margin-top: 8px;
}
.link-buttons {
float: right;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
{% extends 'atlas/base.html' %}
{% block content %}
{% include 'atlas/question_link_header.html' %}
<h2>Display Sets: {{ case.title }}</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This page shows the display sets associated with the case.</p>
<p>If you have access you can add and edit display sets.</p>
</details>
<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-images='{{case.get_series_images_nested}}'
></div>
<div class="mb-3">
<button hx-get="{% url 'atlas:case_displaysets_add' case.pk %}" hx-target="#displayset-add-form" hx-swap="innerHTML">Add Display Set</button>
<div id="displayset-add-form"></div>
</div>
<ul class="list-group mb-4" id="display-set-list">
{% for ds in case.display_sets.all %}
{% include 'atlas/partials/displayset_row.html' %}
{% empty %}
<li class="list-group-item text-muted">No display sets for this case.</li>
{% endfor %}
</ul>
{% endblock %}
{% block js %}
<script>
document.addEventListener("DOMContentLoaded", function () {
window.mountDicomViewers();
{% if selected_displayset %}
setTimeout(function() {
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
// Highlight the selected displayset row
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
if (selectedRow) {
selectedRow.style.border = "2px solid #ffc107";
}
}, 1000);
{% if edit %}
var editButton = document.getElementById("displayset-{{ selected_displayset.pk }}-edit-btn");
if (editButton) {
editButton.click();
}
{% endif %}
{% endif %}
});
document.addEventListener("htmx:beforeRequest", function(evt) {
console.log("htmx:beforeRequest", evt.detail);
// Only for the display set add form
var form = evt.target;
console.log("Form:", form);
console.log("Form matches:", form.matches("form[hx-post*='{% url 'atlas:case_displaysets_add' case.pk %}']"));
console.log("Form matches:", form.matches("form[hx-post*='display_sets/edit']"));
if (form && (form.matches("form[hx-post*='{% url 'atlas:case_displaysets_add' case.pk %}']") ||form.matches("form[hx-post*='display_sets/edit']" ))) {
// Set hidden fields from JS functions
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : "{}";
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : "{}";
evt.detail.requestConfig.parameters["annotations"] = annotations;
evt.detail.requestConfig.parameters["viewerstate"] = viewerState;
console.log(evt)
}
});
</script>
{% endblock %}
+128 -50
View File
@@ -1,6 +1,7 @@
{% extends "atlas/base.html" %}
<!-- {% load static from static %} -->
{% load crispy_forms_tags %}
{% block css %}
{% endblock %}
{% block js %}
@@ -50,8 +51,70 @@
$('#add_more_resource').click(() => { add_resource_input_form() });
})
</script>
{% comment %} document.addEventListener("DOMContentLoaded", function () {
// Select all text input fields and textareas
const textFields = document.querySelectorAll("input[type='text'], textarea");
textFields.forEach((field) => {
// Create a clear button for each text field or textarea
const clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.className = "btn btn-danger btn-sm clear-input-button";
clearButton.textContent = "×";
// Wrap the input field or textarea in a container with position-relative
const wrapper = document.createElement("div");
wrapper.className = "position-relative";
field.parentNode.insertBefore(wrapper, field);
wrapper.appendChild(field);
wrapper.appendChild(clearButton);
// Function to toggle the visibility of the clear button
const toggleClearButton = () => {
if (field.value.trim() !== "") {
clearButton.classList.add("show"); // Add the "show" class
} else {
clearButton.classList.remove("show"); // Remove the "show" class
}
};
// Initial check
toggleClearButton();
// Add event listeners to monitor changes in the input field or textarea
field.addEventListener("input", toggleClearButton);
// Add click event to clear the field or textarea
clearButton.addEventListener("click", function () {
field.value = ""; // Clear the field
toggleClearButton(); // Update the button visibility
});
});
});
{% endcomment %}
</script>
{% comment %} <style>
/* Ensure the parent container is positioned relatively */
.position-relative {
position: relative;
}
/* Style the clear button */
.clear-input-button {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: none; /* Hidden by default */
z-index: 1;
}
/* Show the clear button only when the input field or textarea is hovered and contains text */
.position-relative:hover .clear-input-button.show {
display: inline-block;
}
</style> {% endcomment %}
<!-- {{ form.media }} -->
{% endblock %}
{% block content %}
@@ -59,6 +122,8 @@
{% include "atlas/question_link_header.html" %}
{% endif %}
<h2>Submit Case</h2>
<details>
<summary>Instructions</summary>
@@ -68,74 +133,87 @@
</details>
{% if form.collection %}
<div class="alert alert-info" role="alert">Creating a case in the collection: <a href="{% url 'atlas:collection_detail' pk=form.collection.pk %}">{{form.collection.name}}</a></div>
<div class="alert alert-info" role="alert">Creating a case in the collection: <a href="{% url 'atlas:collection_detail' pk=form.collection.pk %}">{{form.collection.name}}</a></div>
{% endif %}
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
{% csrf_token %}
{% crispy form form.helper %}
{{ form }}
<h3>Series:</h3>
Add image sets here. These can only be added once created (they can also be added to cases on creation).
<input type="button" value="Add More Series" id="add_more_series">
<div id="series_formset" class="list_formset">
<ol>
{% for form in series_formset %}
<li class="no-error series-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form }}
</li>
{% endfor %}
</ol>
{% comment %} {% crispy series_formset %} {% endcomment %}
{% comment %} {{ form }} {% endcomment %}
<!-- Differential Collapsible Panel -->
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
<h3 class="d-inline">Differential</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="differentialPanel">
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="casedifferential_formset" class="list_formset">
<ol>
{% for form in casedifferential_formset %}
<li class="no-error casedifferential_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ casedifferential_formset.management_form | crispy }}
</div>
{{ series_formset.management_form }}
<h3>Differential:</h3>
Add differential here.
<input type="button" value="Add Another Differential" id="add_more_differential">
<div id="casedifferential_formset" class="list_formset">
<ol>
{% for form in casedifferential_formset %}
<li class="no-error casedifferential_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form }}
</li>
{% endfor %}
</ol>
</div>
</div>
<!-- Resource Collapsible Panel -->
<div class="card my-3 bg-dark text-white">
<div class="card-header">
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
<h3 class="d-inline">Resource</h3>
<span class="ms-2">(click to expand/collapse)</span>
</a>
</div>
<div class="collapse" id="resourcePanel">
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
<div class="card-body">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
<li class="no-error caseresource_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form | crispy}}
</li>
{% endfor %}
</ol>
</div>
{{ caseresource_formset.management_form | crispy}}
</div>
{{ casedifferential_formset.management_form }}
<h3>Resource:</h3>
Add resource here. Resource can be anything that is useful for the case (videos, pdf, etc). This can either be avalaible before attempting the case (such a for a pre reading list) or after.
<input type="button" value="Add Another Resource" id="add_more_resource">
<div id="caseresource_formset" class="list_formset">
<ol>
{% for form in caseresource_formset %}
<li class="no-error caseresource_formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form }}
</li>
{% endfor %}
</ol>
</div>
{{ caseresource_formset.management_form }}
</div>
</div>
<br/>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<div id="empty_series_form" style="display:none">
<li class='no_error series-formset'>
{{ series_formset.empty_form }}
{{ series_formset.empty_form | crispy}}
</li>
</div>
<div id="empty_casedifferential_form" style="display:none">
<li class='no_error casedifferential_formset'>
{{ casedifferential_formset.empty_form }}
{{ casedifferential_formset.empty_form | crispy}}
</li>
</div>
<div id="empty_caseresource_form" style="display:none">
<li class='no_error caseresource_formset'>
{{ caseresource_formset.empty_form }}
{{ caseresource_formset.empty_form | crispy}}
</li>
</div>
{% endblock %}
@@ -0,0 +1,42 @@
<details class="case-details">
<summary>Cases</summary>
<input type="text" placeholder="Search Cases..."
_="on keyup
if the event's key is 'Escape'
set my value to ''
trigger keyup
else
log 'test' then
show .case-option in #case-options when its textContent contains my value
">
<ul id="case-options">
{% for group_name, group_choices, group_index in widget.optgroups reversed %}
{% for widget in group_choices reversed %}
{% with widget.value.instance as case %}
{% if not forloop.parentloop.first %}
<li class="case-option">
<span class="d-flex flex-row ">
<span class="p-2">{{case.id}}</span>
<span class="p-2">{{case.title}}</span>
<span class="p-2">{{case.created_date}}</span>
<button class="p-2 btn btn-sm border border-dark rounded text-primary"
type="button"
_="on click set (next <select/>)'s value to '{{case.pk}}' then remove @open from .case-details then go to top of (next <select/>)'s parent"
>select</button>
</span>
</li>
{% endif %}
{% endwith %}
{% endfor %}
{% endfor %}
</ul>
</details>
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
<optgroup label="{{ group_name }}">{% endif %}{% for widget in group_choices %}
{% include widget.template_name %}{% endfor %}{% if group_name %}
</optgroup>{% endif %}{% endfor %}
</select>
@@ -0,0 +1,65 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% load static %}
{% block content %}
{% include "atlas/question_link_header.html" %}
<h2>Update Series for Case: {{ object.title }}</h2>
<form method="post" enctype="multipart/form-data" data-bs-theme="dark">
{% csrf_token %}
{% crispy form form.helper %}
{{ series_formset.management_form }}
<div id="series_formset">
{% for form in series_formset %}
<div class="card mb-3 series-form">
<div class="card-body">
{{ form.non_field_errors }}
{{ form.errors }}
{{ form|crispy }}
{% if form.can_delete %}
<div class="form-check mt-2">
{{ form.DELETE }} <label for="{{ form.DELETE.id_for_label }}">Delete</label>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Empty form template for JS -->
<div id="empty-form" style="display:none;">
<div class="card mb-3 series-form">
<div class="card-body">
{{ series_formset.empty_form|crispy }}
{% if series_formset.empty_form.can_delete %}
<div class="form-check mt-2">
{{ series_formset.empty_form.DELETE }} <label for="{{ series_formset.empty_form.DELETE.id_for_label }}">Delete</label>
</div>
{% endif %}
</div>
</div>
</div>
<button type="button" id="add-series" class="btn btn-secondary mt-2">Add Series</button>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<script>
document.addEventListener("DOMContentLoaded", function() {
const addBtn = document.getElementById("add-series");
const formsetDiv = document.getElementById("series_formset");
const emptyFormDiv = document.getElementById("empty-form");
let totalForms = document.getElementById("id_seriesdetail_set-TOTAL_FORMS");
addBtn.addEventListener("click", function() {
let formIdx = parseInt(totalForms.value);
let newFormHtml = emptyFormDiv.innerHTML.replace(/__prefix__/g, formIdx);
let tempDiv = document.createElement('div');
tempDiv.innerHTML = newFormHtml;
// Clear the id field value for the new form
let idInput = tempDiv.querySelector('input[name$="-id"]');
if (idInput) {
idInput.value = "";
}
formsetDiv.appendChild(tempDiv.firstElementChild);
totalForms.value = formIdx + 1;
});
});
</script>
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block css %}
{% endblock %}
{% block content %}
<div class="container-fluid">
<h2>Cases</h2>
Quick filters:<br/>
Show <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>my cases only</a>, <a href='{% url "atlas:case_view" %}'>all cases</a>
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
<form>
<details id="actions-detail" class="mt-3">
<summary>
<strong>Actions</strong>
</summary>
<div class="row mb-2">
<div class="col-auto">
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
<!-- Dynamically load collection options via HTMX -->
<option value="">Loading collections...</option>
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
</select>
</div>
<div class="col-auto">
<button id="add-to-collection-btn"
class="btn btn-sm btn-outline-primary"
hx-post="{% url 'atlas:add_cases_to_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Add selected cases to collection
</button>
<button id="remove-from-collection-btn"
class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove selected cases from collection
</button>
<br/>
<button id="set-open-access-btn"
class="btn btn-sm btn-outline-success ms-2"
hx-post="{% url 'atlas:set_open_access' %}"
hx-include="[name='selection']:checked"
hx-target="#action-result"
hx-swap="innerHTML"
>
Set Open Access
</button>
<button id="remove-open-access-btn"
class="btn btn-sm btn-outline-warning ms-2"
hx-post="{% url 'atlas:remove_open_access' %}"
hx-include="[name='selection']:checked"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove Open Access
</button>
<span id="action-result"></span>
</div>
</div>
</details>
{% 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" %}
<div id="exam-options"></div>
{% endblock %}
+40 -4
View File
@@ -1,7 +1,42 @@
{% extends "atlas/exams.html" %}
<!-- {% load static from static %} -->
{% 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>-->
@@ -36,8 +71,8 @@
<form class="highlight" action="" method="post" enctype="multipart/form-data" id="atlas-form">
{% csrf_token %}
{{ form }}
<h3>Cases:</h3>
{% crispy form form.helper %}
{% comment %} <h3>Cases:</h3>
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
<input type="button" value="Add More Cases" id="add_more_case">
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
@@ -54,13 +89,14 @@
</div>
{{ case_formset.management_form }}
<input type="submit" class="submit-button" value="Submit" name="submit">
</form>
<div id="empty_case_form" style="display:none">
<li class='no_error case-formset'>
{{ case_formset.empty_form }}
</li>
</div>
</div> {% endcomment %}
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
</form>
<script>
$(document).ready(() => {
sortable('.sortable');
@@ -0,0 +1,78 @@
{% extends "atlas/exams.html" %}
{% load crispy_forms_tags %}
{% load static %}
{% block content %}
<h2>Update Cases for Case: {{ object.title }}</h2>
This form allows you to update the cases associated with this collection. You can add, remove, or reorder cases as needed. This functionality is also available on the collection overview page (which is likely easier to use).
<form method="post" enctype="multipart/form-data" data-bs-theme="dark">
{% csrf_token %}
{% crispy form form.helper %}
{{ case_formset.management_form }}
<div id="case_formset">
{% for form in case_formset %}
<div class="card mb-3 case-form">
<div class="card-body">
{{ form.non_field_errors }}
{{ form.errors }}
{{ form|crispy }}
{% if form.can_delete %}
<div class="form-check mt-2">
{{ form.DELETE }} <label for="{{ form.DELETE.id_for_label }}">Delete</label>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Empty form template for JS -->
<div id="empty-form" style="display:none;">
<div class="card mb-3 case-form">
<div class="card-body">
{{ case_formset.empty_form|crispy }}
{% if case_formset.empty_form.can_delete %}
<div class="form-check mt-2">
{{ case_formset.empty_form.DELETE }} <label for="{{ case_formset.empty_form.DELETE.id_for_label }}">Delete</label>
</div>
{% endif %}
</div>
</div>
</div>
<button type="button" id="add-case" class="btn btn-secondary mt-2">Add Case</button>
<button type="submit" class="btn btn-primary">Save</button>
{% comment %} <input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" /> {% endcomment %}
</form>
<script>
document.addEventListener("DOMContentLoaded", function() {
const addBtn = document.getElementById("add-case");
const formsetDiv = document.getElementById("case_formset");
const emptyFormDiv = document.getElementById("empty-form");
let totalForms = document.getElementById("id_casedetail_set-TOTAL_FORMS");
addBtn.addEventListener("click", function() {
let formIdx = parseInt(totalForms.value);
let newFormHtml = emptyFormDiv.innerHTML.replace(/__prefix__/g, formIdx);
let tempDiv = document.createElement('div');
tempDiv.innerHTML = newFormHtml;
// Clear the id field value for the new form
let idInput = tempDiv.querySelector('input[name$="-id"]');
if (idInput) {
idInput.value = "";
}
formsetDiv.appendChild(tempDiv.firstElementChild);
totalForms.value = formIdx + 1;
});
document.getElementById("case-order-button").addEventListener("click", function(e) {
const cases = document.querySelectorAll("#case_formset ul");
cases.forEach((el, n) => {
const inputEl = el.querySelector("input[name$='sort_order']");
if (inputEl) {
inputEl.value = n + 1;
}
});
e.preventDefault();
});
});
</script>
{% endblock %}
+103
View File
@@ -12,6 +12,109 @@
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
</ul>
<div class="mt-4 mb-3">
{# HTMX loading indicator styles: hide by default, show when HTMX adds the .htmx-request class #}
<style>
#category-search-loading { display: none; }
#category-search-loading.htmx-request { display: inline-block; }
</style>
<p>Alternatively you can use the search function below</p>
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
<div class="d-flex align-items-center">
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</div>
<button class="btn btn-primary ms-2" type="submit">Search</button>
</div>
{# Collapsed advanced filters block - defaults to collapsed #}
<div class="mt-2">
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
Advanced filters ▾
</button>
<div class="collapse mt-2" id="category-filters">
<div class="card card-body">
<div class="d-flex flex-wrap gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-conditions">Conditions</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-findings">Findings</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-structures">Structures</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
hx-get="{% url 'atlas:categories_search_partial' %}"
hx-target="#category-search-results"
hx-swap="innerHTML"
hx-include="#category-search-form"
hx-indicator="#category-search-loading" />
<label class="form-check-label" for="type-presentations">Presentations</label>
</div>
</div>
</div>
</div>
</div>
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</div>
{% comment %} <button class="btn btn-primary ms-2" type="submit">Search</button> {% endcomment %}
</form>
</div>
<div id="category-search-results">
{% if results is not None %}
{% include 'atlas/_categories_search_results.html' %}
{% endif %}
</div>
{% if request.user.is_staff %}
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
</p>
@@ -1,102 +0,0 @@
{% extends 'atlas/exams.html' %}
{% 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>
<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 %}
<div class="alert alert-danger" role="alert">
<h4 class="alert-heading">Answers not defined!</h4>
<p>A question schema has been defined but no question answers are found.</p>
</div>
{% endif %}
<p>This form allows you to add questions to a case. A example of how the quesiton will be displayed is shown below.</p>
<p>If answers are supplied the question will be automarked</p>
<p>The question system is built using https://github.com/json-editor/json-editor, this allows for highly flexible forms / question design at a cost of some complexity. To help with this a number of preset questions are avalible to choose from (these can then be edited if needed). It is also possible to copy questions from other cases in the collection. </p>
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-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"
hx-get="{% url 'atlas:question_schema_schemas' %}"
hx-target=".offcanvas-body"
>Preset questions</button>
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel"
>
<div class="offcanvas-header">
<h5 id="offcanvasRightLabel">Question schemas</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
...
</div>
</div>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" value="save" name="submit">Submit</button>
</form>
{% comment %} <h2>Blank form</h2>
<form>
{{ blank_form}}
</form> {% endcomment %}
{% if case_detail.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.
<form method="POST" class="post-form">
{% csrf_token %}
{{example_form}}
<button type="submit" value="answer" name="submit">Save Correct Answers</button>
</form>
{% endif %}
{% endblock %}
{% block js %}
{% comment %} {{ example_form.media }} {% endcomment %}
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
<script>
document.addEventListener('htmx:afterRequest', function(evt) {
// Put the JS code that you want to execute here
console.log($('.use-layout').each((n, el) => {
el.addEventListener('click', (e) => {
console.log(e.target.parentElement.querySelector('.schema').dataset.schema);
window.jsoneditor_id_question_schema.set(
{
json: JSON.parse(e.target.parentElement.querySelector('.schema').dataset.schema)
}
)
});
}))
});
</script>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends 'atlas/exams.html' %}
{% load crispy_forms_tags %}
{% block content %}
{% 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' 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>
<form method="POST" class="post-form">
{% csrf_token %}
{% crispy form form.helper %}
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
</form>
{% endblock %}
{% block js %}
<script>
</script>
<style></style>
{% endblock %}
@@ -0,0 +1,69 @@
{% extends 'atlas/exams.html' %}
{% block content %}
{% 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>
<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({% 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>
{% endblock %}
@@ -4,67 +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_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 %}
<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>
{% for case, added, relation 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" />
<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}}" />
<button class="btn primary">Remove from case</button>
</form>
{% endif %}
<span id="htmx-response-{{case.id}}"></span>
</li>
<div class="row g-3">
{% for case, added, relation, visibility in available_priors %}
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
{% empty %}
<div class="col-12">
<div class="alert alert-info">No available prior cases found for the case.</div>
</div>
{% endfor %}
</ul>
</div>
@@ -0,0 +1,812 @@
{% extends 'atlas/exams.html' %}
{% block content %}
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
<div class="alert alert-danger" role="alert">
<h4 class="alert-heading">Answers not defined!</h4>
<p>A question schema has been defined but no question answers are found. Please set them below.</p>
</div>
{% endif %}
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This form allows you to add questions to a case. A example of how the quesiton will be displayed is shown below.</p>
<p>If answers are supplied the question will be automarked</p>
<p>The question system is built using https://github.com/json-editor/json-editor, this allows for highly flexible forms / question design at a cost of some complexity. To help with this a number of preset questions are avalible to choose from (these can then be edited if needed). It is also possible to copy questions from other cases in the collection. </p>
<p>In most cases it is not necessary to edit the JSON schema directly as questions can be added / edited via the GUI.</p>
<p>To save the question schema you need to click the "Save Questions" button at the bottom of the page. The page will then refresh and you can see how questions will be displayed at the bottom of the page.</p>
</details>
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' 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"
hx-get="{% url 'atlas:question_schema_schemas' %}"
hx-target=".offcanvas-body"
>Preset questions</button>
<details><summary>
Question Block Title
</summary>
<p>Title of the question block.</p>
<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;">
<!-- Questions will be dynamically rendered here -->
</div>
</details>
<details open id="add-question-block"><summary>Add/Edit Question</summary>
<div>
<label for="question-type-select">Select Question Type:</label>
<select id="question-type-select" class="form-select">
<option value="radio">Multiple Choice (Radio)</option>
<option value="dropdown">Multiple Choice (Dropdown)</option>
<option value="dropdown-select">Multiple Choice (Dropdown - Select2)</option>
<option value="text">Text Input</option>
<option value="textarea">Text Area</option>
<option value="multiselect">Multiselect</option>
<option value="yesno">Yes/No</option>
<option value="truefalse">True/False</option>
<option value="number">Number</option>
<option value="range">Range</option>
</select>
</div>
<div id="options-configurator" style="margin-top: 10px; ">
<label>Enter Options:</label>
<div id="question-options-container">
<!-- Individual option input fields will be added here -->
</div>
<button type="button" class="btn btn-secondary btn-sm" id="add-option-button" style="margin-top: 10px;">Add Option</button>
</div>
<div style="margin-top: 10px;">
<label for="question-title-input">Question Title:</label>
<input id="question-title-input" type="text" class="form-control" placeholder="Enter question title">
</div>
<div style="margin-top: 10px;">
<label for="question-description-input">Question Description:</label>
<input id="question-description-input" type="text" class="form-control" placeholder="Enter question description">
</div>
<button class="btn btn-primary btn-sm" id="add-new-question">Add New Question</button>
<button class="btn btn-secondary btn-sm" id="reset-question-options">Reset</button>
</details>
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel"
>
<div class="offcanvas-header">
<h5 id="offcanvasRightLabel">Question schemas</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
...
</div>
</div>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" value="save" name="submit">Save Questions</button>
</form>
{% comment %} <h2>Blank form</h2>
<form>
{{ blank_form}}
</form> {% endcomment %}
{% 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.
<form method="POST" class="post-form">
{% csrf_token %}
{# Show example_form non-field errors first #}
{% if example_form.non_field_errors %}
<div class="alert alert-danger">
<strong>Errors:</strong>
<ul class="mb-0">
{% for err in example_form.non_field_errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Render example_form fields explicitly so we can improve error UX for json_answer #}
{% for field in example_form %}
{% if field.name == 'json_answer' %}
<div class="mb-3">
{% if field.errors %}
<div class="alert alert-warning" role="alert">
<strong>Answer required.</strong>
<div>Some questions do not have a default answer, please select below.</div>
<details style="margin-top:8px;">
<summary class="small">Validation details</summary>
<ul class="mb-0">
{% for err in field.errors %}
<li class="small text-muted">{{ err }}</li>
{% endfor %}
</ul>
</details>
</div>
{% endif %}
{{ field }} {# hidden/JS-bound field stays rendered #}
</div>
{% else %}
<div class="mb-3">
{{ field.label_tag }} {{ field }}
{% if field.errors %}
{% for err in field.errors %}
<div class="text-danger small">{{ err }}</div>
{% endfor %}
{% endif %}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
</div>
{% endif %}
{% endfor %}
{# Debug: list all example_form.errors (field -> [errors]) in a collapsed block so nothing is missed #}
{% if example_form.errors %}
<details style="margin-top:8px;">
<summary class="small">All example form errors (debug)</summary>
<ul class="mb-0">
{% for name, errs in example_form.errors.items %}
<li><strong>{{ name }}</strong>
<ul>
{% for e in errs %}
<li class="small text-muted">{{ e }}</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<button type="submit" value="answer" name="submit">Save Correct Answers</button>
</form>
{% endif %}
{% endblock %}
{% block js %}
{% comment %} {{ example_form.media }} {% endcomment %}
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
<script>
document.addEventListener('htmx:afterRequest', function(evt) {
// Put the JS code that you want to execute here
console.log($('.use-layout').each((n, el) => {
el.addEventListener('click', (e) => {
console.log(e.target.parentElement.querySelector('.schema').dataset.schema);
window.jsoneditor_id_question_schema.set(
{
json: JSON.parse(e.target.parentElement.querySelector('.schema').dataset.schema)
}
)
});
}))
});
$(document).ready(function() {
const question_editor = window.jsoneditor_id_question_schema;
const questionBlockTitleInput = document.getElementById('question_block_title_input');
// Wrap the existing onChange handler
const originalOnChange = question_editor.$$.ctx[question_editor.$$.props.onChange];
const addNewQuestionButton = document.getElementById('add-new-question');
const optionsConfigurator = document.getElementById('options-configurator');
const questionOptionsContainer = document.getElementById('question-options-container');
const addOptionButton = document.getElementById('add-option-button');
const questionTypeSelect = document.getElementById('question-type-select');
const questionTitleInput = document.getElementById('question-title-input');
const questionDescriptionInput = document.getElementById('question-description-input');
const resetQuestionOptionsButton = document.getElementById('reset-question-options');
const questionsContainer = document.getElementById('questions-container');
// Helper: get options array from a question regardless of schema shape
function getQuestionOptions(question) {
if (!question) return [];
if (Array.isArray(question.enum)) return question.enum.slice();
if (question.items && Array.isArray(question.items.enum)) return question.items.enum.slice();
return [];
}
// Helper: infer a UI questionType from the schema if question.questionType is not present
function inferQuestionType(question) {
if (!question) return 'text';
if (question.type === 'array') return 'multiselect';
if (question.format === 'select2' && question.type === 'string' && Array.isArray(getQuestionOptions(question))) return 'dropdown-select';
if (question.format === 'select' && question.type === 'string') return 'dropdown';
if (question.format === 'radio' && question.type === 'string') return 'radio';
if (question.format === 'textarea') return 'textarea';
if (question.type === 'number') return 'number';
if (question.type === 'range') return 'range';
if (question.enum && Array.isArray(question.enum) && question.enum.length === 2) {
const opts = question.enum.map(String).map(s => s.toLowerCase());
if ((opts.includes('yes') && opts.includes('no'))) return 'yesno';
if ((opts.includes('true') && opts.includes('false'))) return 'truefalse';
}
return 'text';
}
// Helper: apply an options array to a question object according to the selected UI type
function applyOptionsToQuestion(question, optionsArray, selectedType) {
optionsArray = (optionsArray || []).filter(o => o !== '');
if (selectedType === 'multiselect') {
question.type = 'array';
question.items = question.items || { type: 'string', enum: [] };
question.items.enum = optionsArray.length ? optionsArray : (question.items.enum || []);
question.uniqueItems = true;
question.format = 'select2';
delete question.enum;
} else {
// convert array->single if present
if (question.type === 'array' && question.items && Array.isArray(question.items.enum)) {
question.enum = question.items.enum.slice();
delete question.items;
delete question.uniqueItems;
}
if (optionsArray.length) {
question.enum = optionsArray;
}
// ensure string type for single-select/other
if (question.enum && Array.isArray(question.enum)) {
question.type = 'string';
}
// set formats for some known selected types
if (selectedType === 'dropdown-select') question.format = 'select2';
else if (selectedType === 'dropdown') question.format = 'select';
else if (selectedType === 'radio') question.format = 'radio';
}
// always store the UI mapping
question.questionType = selectedType;
}
question_editor.updateProps({
onChange: (updatedContent, previousContent, context) => {
// Call the original onChange handler if it exists
if (typeof originalOnChange === 'function') {
originalOnChange(updatedContent, previousContent, context);
}
const value = updatedContent.json ? updatedContent.json.title : '';
if (value !== questionBlockTitleInput.value) {
questionBlockTitleInput.value = value; // Update the input field
}
}
});
// Sync changes from the input field to the editor
// Yes this is a bit backwards but it works
questionBlockTitleInput.addEventListener('input', () => {
try {
currentContent = JSON.parse(question_editor.get().text);
}
catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
if (currentContent) {
currentContent.title = questionBlockTitleInput.value; // Update the title in the editor
question_editor.update({json: currentContent}); // Apply the changes to the editor
}
});
// Show or hide the options configurator based on the selected question type
questionTypeSelect.addEventListener('change', () => {
const selectedType = questionTypeSelect.value;
if (selectedType === 'radio' || selectedType === 'dropdown' || selectedType === 'multiselect') {
optionsConfigurator.style.display = 'block';
} else {
optionsConfigurator.style.display = 'none';
}
});
// Add a new option input field
addOptionButton.addEventListener('click', () => {
const optionIndex = questionOptionsContainer.children.length + 1;
const optionInput = document.createElement('div');
optionInput.className = 'option-input';
optionInput.style.marginBottom = '5px';
optionInput.innerHTML = `
<input type="text" class="form-control option-field" placeholder="Option ${optionIndex}" style="display: inline-block; width: 90%;">
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">&times;</button>
`;
questionOptionsContainer.appendChild(optionInput);
// Add event listener to remove the option
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
questionOptionsContainer.removeChild(optionInput);
});
});
// Reset the question options
resetQuestionOptionsButton.onclick = resetQuestion;
function resetQuestion() {
// Clear the title and description fields
questionTitleInput.value = '';
questionDescriptionInput.value = '';
// Clear all option input fields
while (questionOptionsContainer.firstChild) {
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
}
// Reset the question type to the default value
questionTypeSelect.value = 'radio';
// Hide the options configurator if necessary
optionsConfigurator.style.display = 'block';
// Reset the "Add New Question" button
addNewQuestionButton.textContent = 'Add New Question';
addNewQuestionButton.onclick = addNewQuestion;
toastr.success('Question options have been reset.');
}
// Add a new question
addNewQuestionButton.onclick = addNewQuestion;
function addNewQuestion() {
// Get the current content from the editor
try {
currentContent = JSON.parse(question_editor.get().text);
} catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
if (!currentContent) {
currentContent = {
title: questionBlockTitleInput.value || "Case Questions",
type: "object",
properties: {}
};
}
if (!currentContent.properties) {
currentContent.properties = {};
current_question_number = 1;
} else {
current_question_number = Object.keys(currentContent.properties).length + 1;
}
// Get the selected question type
const selectedType = questionTypeSelect.value;
// Collect options from individual input fields
const options = Array.from(questionOptionsContainer.querySelectorAll('.option-field'))
.map(input => input.value.trim())
.filter(option => option !== ""); // Exclude empty options
// Get the title and description
const title = questionTitleInput.value || `Question ${current_question_number}`;
const description = questionDescriptionInput.value || "Enter the question description here.";
// Define the new question template based on the selected type
let newQuestion;
if (selectedType === 'radio') {
newQuestion = {
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
type: "string",
title: title,
format: "radio",
description: description,
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'text') {
newQuestion = {
type: "string",
title: title,
format: "text",
description: description,
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'dropdown') {
newQuestion = {
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
type: "string",
title: title,
format: "select",
description: description,
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'dropdown-select') {
newQuestion = {
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
type: "string",
title: title,
format: "select2",
description: description,
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'multiselect') {
newQuestion = {
type: "array",
items: {
enum: options.length > 0 ? options : ["Option 1", "Option 2", "Option 3"], // Default options if none provided
type: "string",
},
title: title,
format: "select2", // Multiselect format
description: description,
uniqueItems: true, // Ensure unique selections
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'yesno') {
newQuestion = {
enum: ["Yes", "No"],
type: "string",
title: title,
format: "radio",
description: description,
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'truefalse') {
newQuestion = {
enum: ["True", "False"],
type: "string",
title: title,
format: "radio",
description: description,
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'textarea') {
newQuestion = {
type: "string",
title: title,
format: "textarea", // Use textarea format
description: description,
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'number') {
newQuestion = {
type: "number",
title: title,
description: description,
questionType: selectedType, // Add questionType property
};
} else if (selectedType === 'range') {
newQuestion = {
type: "range",
title: title,
description: description,
minimum: 0, // Default minimum value
maximum: 100, // Default maximum value
questionType: selectedType, // Add questionType property
};
}
newQuestion.propertyOrder = current_question_number; // Set the property order
// Add the new question to the properties
currentContent.properties[`question_${current_question_number}`] = newQuestion;
// Update the editor with the new question
question_editor.update({ json: currentContent });
toastr.success(`New question added: ${title}`);
renderQuestions(); // Re-render the questions list
}
function renderQuestions() {
questionsContainer.innerHTML = ''; // Clear the container
try {
let currentContent;
try {
currentContent = JSON.parse(question_editor.get().text);
} catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
if (!currentContent || !currentContent.properties) {
console.warn('No properties found in the current content.');
return;
}
const properties = currentContent.properties || {};
// Sort questions by propertyOrder
const sortedKeys = Object.keys(properties).sort((a, b) => {
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : 1000;
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : 1000;
return orderA - orderB;
});
sortedKeys.forEach((key, index) => {
const question = properties[key];
const questionElement = document.createElement('div');
questionElement.className = 'question-item';
questionElement.style.marginBottom = '10px';
questionElement.setAttribute('draggable', 'true'); // Make the element draggable
questionElement.setAttribute('data-question-key', key);
questionElement.setAttribute('data-index', index);
questionElement.innerHTML = `
<strong>${question.title || key}</strong>
<button type="button" class="btn btn-warning btn-sm edit-question-button" data-question-key="${key}" style="margin-left: 10px;">Edit</button>
<button type="button" class="btn btn-danger btn-sm delete-question-button" data-question-key="${key}" style="margin-left: 10px;">Delete</button>
`;
questionsContainer.appendChild(questionElement);
// Add event listener to delete the question
questionElement.querySelector('.delete-question-button').addEventListener('click', (e) => {
const questionKey = e.target.getAttribute('data-question-key');
deleteQuestion(questionKey);
});
// Add event listener to edit the question
questionElement.querySelector('.edit-question-button').addEventListener('click', (e) => {
const questionKey = e.target.getAttribute('data-question-key');
editQuestion(questionKey);
});
// Add drag-and-drop event listeners
questionElement.addEventListener('dragstart', handleDragStart);
questionElement.addEventListener('dragover', handleDragOver);
questionElement.addEventListener('drop', handleDrop);
questionElement.addEventListener('dragend', handleDragEnd);
});
} catch (error) {
console.error('Error rendering questions:', error);
}
}
// Drag-and-drop event handlers
let draggedElement = null;
function handleDragStart(e) {
draggedElement = this;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', this.outerHTML);
this.classList.add('dragging');
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function handleDrop(e) {
e.preventDefault();
if (draggedElement !== this) {
const draggedIndex = parseInt(draggedElement.getAttribute('data-index'));
const targetIndex = parseInt(this.getAttribute('data-index'));
try {
let currentContent;
try {
currentContent = JSON.parse(question_editor.get().text);
} catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
const properties = currentContent.properties || {};
const keys = Object.keys(properties);
// Sort keys by propertyOrder
const sortedKeys = keys.sort((a, b) => {
const orderA = properties[a].propertyOrder !== undefined ? properties[a].propertyOrder : Infinity;
const orderB = properties[b].propertyOrder !== undefined ? properties[b].propertyOrder : Infinity;
return orderA - orderB;
});
// Move the dragged question to the target position
const draggedKey = sortedKeys.splice(draggedIndex, 1)[0]; // Remove dragged key
sortedKeys.splice(targetIndex, 0, draggedKey); // Insert dragged key at target position
// Reassign propertyOrder to ensure uniqueness and correct order
sortedKeys.forEach((key, index) => {
properties[key].propertyOrder = index + 1;
});
// Assign propertyOrder to any questions without it
keys.forEach((key) => {
if (properties[key].propertyOrder === undefined) {
properties[key].propertyOrder = sortedKeys.length + 1;
sortedKeys.push(key);
}
});
currentContent.properties = properties;
question_editor.update({ json: currentContent });
toastr.success('Questions reordered successfully.');
renderQuestions(); // Re-render the questions list
} catch (error) {
console.error('Error reordering questions:', error);
toastr.error('Failed to reorder questions.');
}
}
}
function handleDragEnd() {
this.classList.remove('dragging');
}
// Function to delete a question
function deleteQuestion(questionKey) {
try {
try {
currentContent = JSON.parse(question_editor.get().text);
} catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
if (currentContent.properties && currentContent.properties[questionKey]) {
delete currentContent.properties[questionKey]; // Remove the question
question_editor.update({ json: currentContent }); // Update the editor
toastr.success(`Question "${questionKey}" has been deleted.`);
renderQuestions(); // Re-render the questions list
}
} catch (error) {
console.error('Error deleting question:', error);
toastr.error('Failed to delete the question.');
}
}
// Initial render of questions
renderQuestions();
function editQuestion(questionKey) {
try {
let currentContent;
try {
currentContent = JSON.parse(question_editor.get().text);
} catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
const properties = currentContent.properties || {};
const question = properties[questionKey];
if (question) {
// Populate the form fields with the question's data
questionTitleInput.value = question.title || '';
questionDescriptionInput.value = question.description || '';
questionTypeSelect.value = question.questionType || 'text';
// Clear existing options and populate them if applicable
while (questionOptionsContainer.firstChild) {
questionOptionsContainer.removeChild(questionOptionsContainer.firstChild);
}
// Support both single-select (question.enum) and multiselect (question.items.enum)
const enumList = getQuestionOptions(question) || [];
if (enumList.length) {
enumList.forEach((option, index) => {
const optionInput = document.createElement('div');
optionInput.className = 'option-input';
optionInput.style.marginBottom = '5px';
optionInput.innerHTML = `
<input type="text" class="form-control option-field" value="${option}" placeholder="Option ${index + 1}" style="display: inline-block; width: 90%;">
<button type="button" class="btn btn-danger btn-sm remove-option-button" style="display: inline-block; width: 8%;">&times;</button>
`;
questionOptionsContainer.appendChild(optionInput);
// Add event listener to remove the option
optionInput.querySelector('.remove-option-button').addEventListener('click', () => {
questionOptionsContainer.removeChild(optionInput);
});
});
optionsConfigurator.style.display = 'block';
} else {
optionsConfigurator.style.display = 'none';
}
// If questionType is not set, infer it from the schema
questionTypeSelect.value = question.questionType || inferQuestionType(question);
// Update the "Add New Question" button to save changes
addNewQuestionButton.textContent = 'Save Changes';
addNewQuestionButton.onclick = () => {
saveEditedQuestion(questionKey);
};
document.getElementById('add-question-block').open = true; // Open the question block
document.getElementById('add-question-block').scrollIntoView({ behavior: 'smooth', block: "center" }); // Scroll to the question block
}
} catch (error) {
console.error('Error editing question:', error);
toastr.error('Failed to edit the question.');
}
}
function saveEditedQuestion(questionKey) {
try {
let currentContent;
try {
currentContent = JSON.parse(question_editor.get().text);
} catch {
currentContent = JSON.parse(JSON.stringify(question_editor.get().json));
}
const properties = currentContent.properties || {};
const question = properties[questionKey];
if (question) {
// Update the question with the new values from the form
question.title = questionTitleInput.value || `Question ${questionKey}`;
question.description = questionDescriptionInput.value || '';
question.format = questionTypeSelect.value;
// Update the options if applicable
const newOptions = questionOptionsContainer ? Array.from(questionOptionsContainer.querySelectorAll('.option-field')).map((input) => input.value.trim()).filter((option) => option !== '') : [];
const selectedType = questionTypeSelect ? questionTypeSelect.value : null;
applyOptionsToQuestion(question, newOptions, selectedType);
// Update the editor with the modified question
currentContent.properties[questionKey] = question;
try { question_editor.update({ json: currentContent }); } catch (e) { console.warn('Failed to update editor after saveEditedQuestion', e); }
toastr.success(`Question "${question.title}" updated successfully.`);
renderQuestions(); // Re-render the questions list
// Reset the "Add New Question" button
addNewQuestionButton.textContent = 'Add New Question';
addNewQuestionButton.onclick = addNewQuestion;
resetQuestion(); // Reset the question options
}
} catch (error) {
console.error('Error saving edited question:', error);
toastr.error('Failed to save the question.');
}
}
});
</script>
<style>
/* Style the <details> block */
details {
margin: 20px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
background-color:rgba(13, 8, 8, 0.86);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
/* Style the <summary> element */
details summary {
font-weight: bold;
font-size: 1.1em;
cursor: pointer;
margin-bottom: 10px;
color: #333;
}
/* Add hover effect to <summary> */
details summary:hover {
color: #007bff;
}
/* Add padding to the content inside <details> */
details > *:not(summary) {
margin-top: 10px;
padding-left: 10px;
}
/* Add a border to the expanded <details> */
details[open] {
border-color: #007bff;
background-color: black;
}
</style>
{% endblock %}

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