Compare commits

...
239 Commits
Author SHA1 Message Date
Ross 19eb10c089 Add user content management views and templates for content review 2026-02-13 22:26:41 +00:00
Ross 2ab5a8e3b5 Add author parameter to My Questions link in frcr_index.html 2026-02-10 22:19:22 +00:00
Ross c2f12f384a Add questions section with navigation links to frcr_index.html 2026-02-10 22:05:06 +00:00
Ross 33f32dab8b Create frcr_index.html template and update ExamViews to render it 2026-02-10 21:48:11 +00:00
Ross a676d437bb Add badges to FRCR and Atlas sections for better categorization 2026-02-10 21:13:44 +00:00
Ross ba5b11cce2 Update call-to-action section in index.html for improved user engagement 2026-02-10 21:12:49 +00:00
Ross a6c5b99620 Update label for AccordionGroup in AnatomyQuestionForm to improve clarity 2026-02-10 20:53:34 +00:00
Ross 666f49f2c2 Refactor AnatomyQuestionForm layout to arrange modality and related fields in a horizontal flex row 2026-02-10 20:51:49 +00:00
Ross 46608a0758 Update index.html text for clarity in user instructions 2026-02-10 20:48:12 +00:00
Ross cfdf33b9ce Add open access field to AnatomyQuestionForm and update index template layout 2026-02-10 20:42:42 +00:00
Ross bdc97922af Refactor AnatomyQuestionForm layout and update template to use crispy forms 2026-02-10 20:33:18 +00:00
Ross 3626f3918e Enhance image annotation help text and layout in question detail view 2026-02-09 22:50:40 +00:00
Ross 49fbff76e9 Replace FilteredSelectMultiple with custom ExamSearchWidget in AnatomyQuestionForm 2026-02-09 22:39:50 +00:00
Ross 7ec81133d8 Allow any authenticated user to create new Structure entries in autocomplete 2026-02-09 22:33:30 +00:00
Ross c67db4a39e Update hidden input type in open access form to use exam app name 2026-02-09 22:25:43 +00:00
Ross d2f4c21883 Add "View as viva" option to collection actions dropdown 2026-02-09 13:03:17 +00:00
Ross 1f5931aab2 Refactor question search template: rename and move to sbas_search.html 2026-02-09 12:50:31 +00:00
Ross 08c3558861 Add dismissible performance warning for large collections with persistence 2026-02-09 12:45:46 +00:00
Ross add7a1cf38 Add dismissible performance warning for large collections in collection_detail template 2026-02-09 12:44:19 +00:00
Ross 65f08cce6c Add performance warning for large collections in collection_detail template 2026-02-09 12:42:03 +00:00
Ross 3c8238d737 Add user permission checks for series editing and enhance author display method 2026-02-09 12:27:20 +00:00
Ross 23f33ac7ae Optimize collection_viva by aggressively prefetching and annotating related objects to prevent N+1 queries 2026-02-09 12:15:36 +00:00
Ross ab130a08ed Optimize collection_viva by annotating and prefetching related objects to prevent N+1 queries 2026-02-09 12:13:00 +00:00
Ross 45e01a78c1 Refactor case management links and button structure for improved readability and consistency 2026-02-09 11:24:43 +00:00
Ross 25e5ef78ed Update case series count and management links to use collection.pk for consistency 2026-02-09 11:24:33 +00:00
Ross 13dd79b508 Rename parameter 'case_id' to 'case_number' in collection_case_dicom_json_review for consistency 2026-02-09 11:15:57 +00:00
Ross 89986ba2e9 Refactor collection_case_dicom_json to use case_number instead of case_id for improved clarity and error handling 2026-02-09 11:14:53 +00:00
Ross f9e69e1206 Rename parameter 'case_id' to 'case_number' in collection_case_dicom_json function for clarity 2026-02-09 11:13:44 +00:00
Ross e7de6c8970 Set default archive filter to False in CaseCollectionFilter 2026-02-09 11:06:39 +00:00
Ross ea139456a8 Fix URL for editing display set in modal 2026-02-09 10:56:37 +00:00
Ross 035da12e96 Add edit permission check for display set modal and update template 2026-02-09 10:49:28 +00:00
Ross 3b9942054d Increase timeout for loading display set details and remove redundant script from modal 2026-02-09 10:34:20 +00:00
Ross 5761117e68 Enhance case display with modal functionality for findings and display sets 2026-02-09 10:33:06 +00:00
Ross f316dc9bcb Filter out removed images in Case model's image retrieval 2026-02-09 09:46:16 +00:00
Ross bdb7b653b6 Add modal for viewing display set details and update URLs and views 2026-02-08 22:42:14 +00:00
Ross 1eeb0d89d9 Add get_history method to CaseDetail model and update case_list_item template for history modal 2026-02-08 21:42:17 +00:00
Ross f3702db5f6 Refactor case field buttons to use hidden modal content and remove unused endpoint 2026-02-02 22:39:49 +00:00
Ross a6a7d75144 Implement modal for case fields and add partial endpoint for dynamic content retrieval 2026-02-02 22:29:52 +00:00
Ross 14bba93c4d Add quick-view icons for case details in case list item template 2026-02-02 22:22:37 +00:00
Ross f29df840c6 Refactor collection detail template to improve display of exam mode and access information 2026-02-02 22:15:20 +00:00
Ross 10d30cdaa6 Update Django entrypoint to include collectstatic command 2026-02-02 22:08:22 +00:00
Ross dc4cf0c3a2 Comment out conditional block for cid_user_exam in user_score_header template 2026-02-02 17:22:41 +00:00
Ross 99b951b32b Add normalized score to exam template context 2026-02-02 17:15:35 +00:00
Ross c2c8ec5b13 Update question prefetching logic to include 'sbas' app 2026-02-02 17:12:23 +00:00
Ross 6d5303cd0d Update exam scores URL for clarity and remove commented-out paths 2026-02-02 17:11:12 +00:00
Ross f6b990a00a Add user-friendly explanation to 404 error template 2026-02-02 17:04:33 +00:00
Ross 3c0deec140 Improve error messages for exam availability in Http404 responses 2026-02-02 17:02:02 +00:00
Ross e3241b503d Refactor exam take help template for improved readability and structure 2026-02-02 16:54:49 +00:00
Ross 04174484b1 Refactor exam take help template for improved URL handling and readability 2026-02-02 16:54:38 +00:00
Ross 168a4126d3 Refactor exam start template for improved readability and URL handling 2026-02-02 16:51:56 +00:00
Ross a0850c0030 Add conditional rendering for reason in 404 error template 2026-02-02 16:41:11 +00:00
Ross a882a3592b Add debug logging for exam take view to track user actions 2026-02-02 16:38:47 +00:00
Ross 8074b56a80 Fix indentation in 404 error template for improved readability 2026-02-02 16:33:59 +00:00
Ross 2e79df1eae Fix indentation for resolved message in 404 error template 2026-02-02 16:33:52 +00:00
Ross 9bf68cd136 Add condition filters to CaseFilter for enhanced search capabilities 2026-02-02 13:58:21 +00:00
Ross 42163448bf Add free-text search filter for Examination in CaseFilter to improve searchability 2026-02-02 13:56:31 +00:00
Ross 1066a3fb9f Update password reset link text for clarity in login template 2026-02-02 13:35:08 +00:00
Ross 3ade162b28 Remove series truncation endpoint from API 2026-02-02 13:30:06 +00:00
Ross f4186bf923 Fix indentation for confirmation prompt in series truncation logic 2026-02-02 13:24:37 +00:00
Ross 5bb2aea8ef Add HTMX endpoint for truncating series images and corresponding result template 2026-02-02 13:24:27 +00:00
Ross 8911c09c0e Enhance CaseFilter to show distinct examination values for series, improving clarity in selection options 2026-02-02 12:14:16 +00:00
Ross 7342b11b27 Fix button formatting for adding to collection in case display block 2026-02-02 12:09:37 +00:00
Ross 953295cc4d Refactor button styles for collection management to ensure consistent appearance across templates 2026-02-02 12:09:25 +00:00
Ross 374ee17911 Refactor detail templates for condition, finding, and structure to improve layout consistency and readability 2026-02-02 11:59:35 +00:00
Ross 18aef751ca Add display sets section to condition, finding, and structure detail templates; list associated cases for better context 2026-02-02 11:59:28 +00:00
Ross f362be43ef Add inline collection management features; implement search and add functionality for collections in case view 2026-02-02 11:55:43 +00:00
Ross 00680c8b9e Enhance NormalCaseFilter to support flexible age filtering with unit selection; update normals_list template with detailed help text for improved user guidance 2026-02-02 11:26:11 +00:00
Ross eea3433042 Enhance NormalCaseFilter to limit examination and modality choices based on user restrictions; integrate Crispy Forms for improved filter form layout in normals_list template 2026-02-02 11:03:38 +00:00
Ross 8dad4a5a5c Refactor case badges to support multiple conditions, presentations, and subspecialties in search results 2026-02-02 10:59:05 +00:00
Ross 75d23c0ba9 Improve condition, presentation, and subspecialty badge rendering in case search results 2026-02-02 10:56:02 +00:00
Ross ff2b6f4c65 Refactor case search results template for improved readability and structure 2026-02-02 10:52:43 +00:00
Ross 22e5f9a496 Enhance case search results display with additional information and tooltips for condition, presentation, and subspecialty 2026-02-02 10:52:36 +00:00
Ross 16865fab03 Refactor permission checks for marking cases as normal to use can_edit method 2026-02-02 10:23:46 +00:00
Ross 5e14df4836 Refactor case search page layout and improve indentation in search results 2026-02-02 10:21:13 +00:00
Ross 625f6017a6 Add advanced case search page with filtering options and pagination 2026-02-02 10:20:12 +00:00
Ross c45e3c8765 Fix hx-trigger attribute for case search input to improve functionality 2026-02-02 10:08:34 +00:00
Ross ee33f62def . 2026-02-02 09:32:40 +00:00
Ross 3a5fe9a85c Fix indentation in display set list and clean up whitespace in JavaScript section 2026-02-02 09:29:20 +00:00
Ross efc47bb2e8 Delay mounting DICOM viewers to ensure assets and DOM are ready 2026-02-02 09:29:07 +00:00
Ross 38d70f8ff0 Refactor resource search button for improved formatting and readability 2026-02-02 09:25:32 +00:00
Ross 2159b045cd Add resource search panel with HTMX integration for dynamic resource addition 2026-02-02 09:18:16 +00:00
Ross 4628f39a49 Refactor case size template for improved readability and structure 2026-02-02 09:12:56 +00:00
Ross 90ceff1b02 Add admin view for cases ordered by total image size with filtering options 2026-01-28 14:45:01 +00:00
Ross a512c2ba09 Refactor HTML formatting to use mark_safe for safe rendering in various tables and models 2026-01-28 14:28:36 +00:00
Ross a61c8529ae Clean up whitespace in various template files for consistency 2026-01-26 12:49:04 +00:00
Ross a667566db6 Remove unused partials loading from various template files 2026-01-26 12:43:11 +00:00
Ross d070adc326 Add button to view all collections in user related collections template 2026-01-26 11:11:37 +00:00
Ross 048168e87d Add user-related collections feature with HTMX support and timestamps for collections 2026-01-26 11:07:15 +00:00
Ross c881da244b Refactor collections button to use HTMX for dynamic loading of user collections 2026-01-26 10:47:59 +00:00
Ross 797eb9c54a Implement HTMX support for user collections display and refactor rendering logic 2026-01-26 10:44:54 +00:00
Ross 0a1b21cec6 Enhance local-up script with CLI options for no-build and help 2026-01-26 10:29:03 +00:00
Ross d3d3638c1c Refactor score filtering logic in exam scores user template for consistent layout 2026-01-19 12:57:10 +00:00
Ross 81e22dfc88 Refactor answers display in exam scores user template for improved layout and styling 2026-01-19 12:56:20 +00:00
Ross 7ea43a6f44 Refactor score display logic in exam scores user template for accuracy 2026-01-19 12:53:19 +00:00
Ross eb8fe58cf7 Refactor exam scores user template to implement question filtering by score selection 2026-01-19 12:51:02 +00:00
Ross f149a88bd6 Enhance exam scores user template with question filtering and improved score display 2026-01-19 12:49:34 +00:00
Ross d4d747517c Refactor exam scores user template to unify view button class for consistency 2026-01-19 12:47:34 +00:00
Ross 0ee378484b Refactor view button in exam scores user template to remove unnecessary class 2026-01-19 12:45:55 +00:00
Ross ea583744c9 Refactor exam scores user template to improve answers display structure 2026-01-19 12:45:18 +00:00
Ross e0d589f4d0 Refactor exam scores user template to enhance viewer block structure and improve question display functionality 2026-01-19 12:43:41 +00:00
Ross 1c95526ff9 Refactor exam scores user template to simplify question display structure and remove unnecessary wrapper 2026-01-19 12:40:54 +00:00
Ross 662fc4e06e Refactor CSS in exam scores user template to enforce minimum width for sticky viewer on larger viewports 2026-01-19 12:39:36 +00:00
Ross a333ff492d Refactor CSS in exam scores user template for improved layout and styling of sticky viewer 2026-01-19 12:35:01 +00:00
Ross bb9741b786 Refactor exam scores user template to improve viewer card styling and functionality 2026-01-19 12:32:48 +00:00
Ross 75be7c1739 Refactor exam scores user template to enhance question display and viewer functionality 2026-01-19 12:30:49 +00:00
Ross 015a5d18b5 Refactor exam scores user template for improved layout and styling of answers 2026-01-19 12:28:20 +00:00
Ross d16041026f Add links to question stems in exam scores user template for authenticated and unauthenticated users 2026-01-19 12:24:59 +00:00
Ross 774a1b8c8a Refactor question display in exam scores user template to include badges as links for authenticated and unauthenticated users 2026-01-19 12:23:45 +00:00
Ross 8fc42169f3 Add view link for questions in exam scores user template for staff users 2026-01-19 12:22:58 +00:00
Ross 834d91e84d Enhance layout of answer options with improved spacing and conditional category display 2026-01-19 12:21:42 +00:00
Ross ef03a97e9e Refactor exam scores user template for improved layout and clarity 2026-01-19 12:18:28 +00:00
Ross dd7437bc66 Add alignment to feedback display for user answers 2026-01-19 12:16:29 +00:00
Ross 21d3130fa5 Add feedback display for questions based on user answers 2026-01-19 12:13:47 +00:00
Ross 89e7e49e31 Add feedback display for answer options based on exam settings 2026-01-19 12:11:24 +00:00
Ross 8bceb10a13 Refactor CID user overview template for improved layout and clarity 2026-01-19 12:06:08 +00:00
Ross 13a98a1e66 Add exam_mode parameter to exam take URL for enhanced functionality 2026-01-19 11:10:10 +00:00
Ross 9c16f93820 rename cid scores to something more appropriate 2026-01-19 10:28:08 +00:00
Ross 1c27d48a36 Add admin edit link for submitted candidates in the details view 2026-01-19 10:14:00 +00:00
Ross 562ad1c0a4 Refactor submitted candidates fragment to group count and admin link for improved layout 2026-01-19 10:11:09 +00:00
Ross fcd77fd799 Add logic to display Reviewed badges for submitted exams and update exam link parameters 2026-01-19 10:03:18 +00:00
Ross d8f8ef7326 Add --nothreading option to Django runserver command and implement HTMX debug modal for JSON responses 2026-01-12 09:45:48 +00:00
Ross f551d071f0 Remove login requirement for exam_toggle_flag function to allow access without authentication. 2026-01-09 20:39:27 +00:00
Ross 9b6fd31cee Merge branch 'part12026live' into master 2026-01-05 20:55:29 +00:00
Ross 8b7da1554a Refactor exam question fragment to reposition flag button within the button row for improved layout and accessibility during HTMX updates. 2026-01-05 14:53:03 +00:00
Ross d4247adb13 Enhance loading indicators for question fragment during HTMX requests; ensure overview tile text color remains unchanged for flagged questions 2026-01-05 14:50:07 +00:00
Ross b89ca918fa Add loading spinner and blur effect for question fragment during HTMX requests; optimize answered and flagged state queries 2026-01-05 14:48:21 +00:00
Ross 569ddb4343 Enhance question navigation by updating local state on button clicks and ensuring DOM updates after HTMX settle 2026-01-05 14:41:48 +00:00
Ross b040dfcd82 Enhance question menu update logic by adding flag icon management and logging for better debugging 2026-01-05 14:38:03 +00:00
Ross db51ef23be Refactor exam question fragment to move flag button inside the fragment for HTMX updates and enhance local state management for answered and flagged questions. 2026-01-05 14:30:55 +00:00
Ross 8c796c3048 Enhance question menu state management by adding a centralized updater for answered and flagged questions 2026-01-05 14:24:59 +00:00
Ross b837c406cf Refactor exam overview template for improved readability and consistency in HTML structure 2026-01-05 14:22:33 +00:00
Ross 42d209af5a Expose current question index for global listeners and update browser URL to reflect the current question position 2026-01-05 14:19:01 +00:00
Ross 3d1b9a57f9 Enhance exam question fragment by exposing current question index for global listeners and updating question list state locally to reflect answered questions 2026-01-05 14:16:02 +00:00
Ross 8d638cc08b Enhance exam overview and fragment by adding question status indicators for answered and flagged questions 2026-01-05 14:13:52 +00:00
Ross 20a06323d5 Enhance navigation after save by improving URL handling and fallback mechanisms for question transitions 2026-01-05 14:04:30 +00:00
Ross 3928df6d75 Refactor exam question display to include flag button and enhance layout for better usability 2026-01-05 14:01:27 +00:00
Ross bf344f20e1 Add flagging functionality for exam questions with a reusable Flag model and toggle feature 2026-01-05 13:47:45 +00:00
Ross e4cfb1f782 Enhance exam navigation by adding a confirmation modal for unsaved changes when skipping questions. 2026-01-05 12:32:14 +00:00
Ross f44f3bbcfe Enhance exam navigation by adding a skip button for non-saving question transitions and implementing unsaved changes confirmation. 2026-01-05 11:59:23 +00:00
Ross ba0c380f18 Enhance candidate lists by restructuring HTML for improved readability and maintainability. 2026-01-05 11:55:03 +00:00
Ross 459b7f9b93 Enhance candidate lists by adding badge containers for improved visual feedback on submission status. 2026-01-05 11:52:58 +00:00
Ross 378d784435 Enhance submitted candidates display by adding dynamic exam status indicators and improving badge tooltip functionality for better user feedback. 2026-01-05 11:30:53 +00:00
Ross 96e88c05a4 Enhance submission tracking by adding first and last name data attributes for candidates, improving badge tooltip information in the submitted candidates list. 2026-01-05 11:25:10 +00:00
Ross 457fb06dc0 Enhance highlightSubmitted function to add badges for submitted CIDs and users, improving visual feedback in the candidate list. 2026-01-05 11:05:26 +00:00
Ross debe093bc9 Add exam_mode parameter to exam take URL for improved functionality 2026-01-05 10:59:40 +00:00
Ross 086bd8f589 Add permission check for user marker access in mark2 overview 2026-01-05 10:18:41 +00:00
Ross 71dc50b307 Enhance CID and User candidate filtering with clear buttons and indicators for improved usability 2026-01-05 09:51:54 +00:00
Ross c0c12ea9f9 revert last commit 2026-01-04 13:19:36 +00:00
Ross 429adfc376 Optimize exam retrieval by prefetching related data to reduce N+1 queries and enhance access checks for user permissions 2026-01-04 13:15:15 +00:00
Ross 36a809ea40 Update type hints for cid and passcode parameters in ExamViews for improved clarity 2026-01-04 13:11:02 +00:00
Ross 443d93c35f Enhance user marking query to ensure distinct exams and order by name for better organization 2026-01-04 13:02:59 +00:00
Ross d7682ab31d Update exam links in user marking list to use dynamic URL generation based on exam type 2026-01-04 13:01:42 +00:00
Ross e600b8eb91 Fix count annotations in exam collection to use distinct values for accurate user counts 2026-01-04 12:55:53 +00:00
Ross 3e691d774c Refactor exam list annotation to use unfiltered counts for accurate overview matching 2026-01-04 12:50:47 +00:00
Ross c3a70b079d Refactor HTML structure for user marking templates to improve readability and maintainability 2026-01-04 12:40:56 +00:00
Ross 4ffadd3914 add user marknig pages 2026-01-04 12:39:33 +00:00
Ross fa18c33e8a Optimize exam list retrieval by consolidating queries and annotating candidate counts for improved performance 2025-12-29 12:55:15 +00:00
Ross 057c391995 Refactor exam status components to use consistent class names and improve HX target selectors for better maintainability 2025-12-29 12:50:02 +00:00
Ross 94fbd38634 Enhance exam status management with HTMX loading indicators and streamline AJAX handling by removing legacy jQuery code 2025-12-29 12:44:42 +00:00
Ross 4ecc32f837 Implement HTMX lazy-loading for exam collections and optimize queryset annotations for performance 2025-12-29 12:26:56 +00:00
Ross 768e1bf8c5 Enhance ExamCollectionDetail queryset to prefetch related exams and annotate candidate counts for improved performance in templates 2025-12-29 12:18:31 +00:00
Ross 32c4305754 Fix indentation and formatting in exam order editing functionality for improved readability 2025-12-29 12:15:18 +00:00
Ross 465f819973 Improve error handling in state change failure response for better user feedback 2025-12-29 12:15:06 +00:00
Ross 3e1cb4b54f Optimize question loading and user answer retrieval in ExamViews to reduce database queries 2025-12-29 12:09:18 +00:00
Ross b9d86a7639 Remove debug print statement for answers_marks in ExamViews 2025-12-29 12:05:29 +00:00
Ross e5fd99f491 Remove unused exam scores refresh function and related logic for CID user exam scores 2025-12-29 12:04:48 +00:00
Ross efeed2a8b1 Refactor stats and answers display for improved layout and readability 2025-12-29 11:41:16 +00:00
Ross f14ede86d3 Refactor notes display logic in CID user list for improved readability 2025-12-29 11:24:18 +00:00
Ross 517c2ebe06 Add notes display with tooltip support in CID user list 2025-12-29 11:23:52 +00:00
Ross c5f9b830eb Refactor submitted candidates display to use a table format with action buttons and add submission count 2025-12-29 11:18:10 +00:00
Ross 2ab85357a9 Implement HTMX support for CID and User candidates lists, and add submitted candidates summary 2025-12-29 11:14:15 +00:00
Ross 91cf2cab29 Update exam time limit display to show '1Q' for time per question 2025-12-29 10:50:53 +00:00
Ross 34c4c0d830 Add notes display and tooltip support in exam CIDs template 2025-12-29 10:25:27 +00:00
Ross 98cef9ea57 Add notes field to CidUser model and update related forms and templates 2025-12-29 10:17:54 +00:00
Ross df0b26de36 Add user group archiving functionality with HTMX support 2025-12-29 09:53:57 +00:00
Ross 17330681a2 Refactor ExamCollection views to enforce author requirements and improve access control 2025-12-29 09:44:29 +00:00
Ross 112c96dc00 Restrict ExamCollectionList to authenticated users and filter by current user as author 2025-12-29 09:41:45 +00:00
Ross 0bea34d406 Add author requirement to ExamCollectionList view 2025-12-29 09:35:55 +00:00
Ross 06ceb9c2ce Add manual submission flag to CidUserExam and improve logging for exam completion 2025-12-22 12:07:25 +00:00
Ross 8ac292eb0c Add ExamUserStatus logging for exam completion and start events 2025-12-22 11:35:09 +00:00
Ross ff6b8a8a48 Add filtering functionality for CID and User candidates in exam view 2025-12-22 11:23:59 +00:00
Ross 46313cf5bb Refactor exam candidates view for improved layout and user experience 2025-12-22 11:20:39 +00:00
Ross 13a23eee12 Refactor CID candidate list for improved readability and maintainability 2025-12-22 11:14:00 +00:00
Ross a1bc96f99a Add CidUserExam management features: forms, views, and templates for exam records 2025-12-22 11:13:29 +00:00
Ross 9fd5e56356 Enhance exam completion confirmation messages for clarity and user assurance 2025-12-22 10:46:03 +00:00
Ross a02f274a4e Enhance user experience with visual indicators for HTMX actions and streamline button interactions in CID group view 2025-12-22 10:44:50 +00:00
Ross d3d0e865c3 Add conditional preview generation for CID group types in user details 2025-12-22 10:38:45 +00:00
Ross 7163e51335 Add modal for group users table and CSV export functionality 2025-12-22 10:29:47 +00:00
Ross 7b47a75657 Add email preview functionality for candidate details: allow users to preview email content before sending 2025-12-22 10:26:22 +00:00
Ross 54e1cf4ce0 Improve permissions handling in local-up script: only change log and Loki data directory permissions if necessary 2025-12-22 10:06:54 +00:00
Ross c071f38920 Implement custom logging email backend for development: log outgoing emails to Loguru and stdout 2025-12-15 13:14:12 +00:00
Ross 03b23b9178 Configure email backend for local development and update NGINX HTTPS port in environment settings 2025-12-15 12:44:57 +00:00
Ross 00b736d8d6 Implement bulk user management features: add user generation preview and bulk creation functionality in CID group view 2025-12-15 12:23:26 +00:00
Ross 885ab1aad4 Enhance user sorting and email resend functionality in CID group view: add sorting options for user list and improve email resend handling in candidate details view 2025-12-15 12:15:42 +00:00
Ross 77edc17732 Refactor CID group view: enhance user display with improved layout, add help section, and implement user sorting functionality 2025-12-15 12:09:51 +00:00
Ross d98305caff Refactor CID user group form: enhance layout with Bootstrap styling, improve error handling, and update user list presentation 2025-12-15 12:04:42 +00:00
Ross 12bd66d4d8 Refactor bulk user management: update HTMX target attributes to replace wrappers for improved rendering of user lists 2025-12-15 12:01:42 +00:00
Ross 508f8a22a2 Implement bulk user management for exams: add user bulk edit endpoint, update templates for user list rendering, and enhance toggle functionality with HTMX support 2025-12-15 12:01:01 +00:00
Ross 158db133b3 Refactor CID user list rendering: consolidate user list into a single template and enhance toggle feedback with count updates 2025-12-15 11:54:21 +00:00
Ross 83a009e31b Refactor HTMX response handling in ExamViews: streamline rendering of updated CID user list items and ensure consistent response structure 2025-12-15 11:51:49 +00:00
Ross 00dfaf9888 Add bulk edit functionality for CID users: implement endpoint and update templates for bulk add/remove actions 2025-12-15 11:49:06 +00:00
Ross 7e4e66a74f Refactor exam candidate edit script: improve user feedback on add/remove actions and streamline sorting logic 2025-12-15 11:22:47 +00:00
Ross 7a4cf3627d Refactor CID users display: enhance layout with card structure, add sorting functionality, and improve toggle button interactions 2025-12-15 11:21:44 +00:00
Ross 4db1f2ca1a Add toggle functionality for pre-review flag on case resources 2025-12-15 11:15:56 +00:00
Ross cbff1317d0 Enhance resource display: conditionally render resource search panel and update pre/post review badge logic 2025-12-15 10:53:21 +00:00
Ross 0644ed99f4 Refactor resource search panel: simplify input trigger and improve layout for search results 2025-12-15 10:47:42 +00:00
Ross c01000f06e Refactor resource search functionality: replace inline form with a dedicated search panel and improve result handling 2025-12-15 10:42:58 +00:00
Ross 2f014ce1a5 Enhance resource management: refresh case data and notify listeners on resource changes in case attachment/detachment 2025-12-15 10:12:04 +00:00
Ross 654d0ae686 Enhance local viewer: implement centralized hide functionality and improve close handling for resource overlays 2025-12-15 10:08:14 +00:00
Ross 18dbb27a3e Enhance resource handling: add support for loading and displaying non-DICOM resources (images, videos, embeds) in viewer overlays 2025-12-15 10:06:54 +00:00
Ross 4bec329aff Implement resource search functionality and enhance resource management in case details 2025-12-15 09:53:19 +00:00
Ross 1e2300bd70 Add media preview functionality for resources with image or video support 2025-12-15 09:47:07 +00:00
Ross 7674f29124 . 2025-12-08 12:56:39 +00:00
Ross d39879bbca Enhance ExamSearchSelectMultipleWidget: improve button styling and add functionality for adding multiple exams 2025-12-08 12:54:52 +00:00
Ross 012c86340d . 2025-12-08 12:51:17 +00:00
Ross 6f50c53519 . 2025-12-08 12:37:24 +00:00
Ross 53aefa29df . 2025-12-08 12:35:13 +00:00
Ross 4617a643d2 Refactor exam collection form layout for improved readability and structure 2025-12-08 12:34:23 +00:00
Ross d7afab15e1 Refactor templates and views for improved structure and maintainability 2025-12-08 12:31:58 +00:00
Ross ad5e7aeed8 . 2025-12-08 12:17:16 +00:00
Ross b4cf5b9d70 Refactor exam collection templates to improve navigation structure and enhance user experience 2025-12-08 12:12:54 +00:00
Ross f156f4c85e Refactor navbar styles and improve dropdown accessibility for better mobile usability 2025-12-08 12:12:00 +00:00
Ross 4ab2e3a15e Enhance ExamSearch widgets to support customizable default filters and improve metadata display 2025-12-08 12:11:24 +00:00
Ross 6daa643df8 Refactor navigation structure for improved readability and maintainability 2025-12-08 12:07:24 +00:00
Ross f8874a2556 Refactor navigation to use Bootstrap navbar with dropdowns for better organization and accessibility 2025-12-08 12:07:00 +00:00
Ross b4f5992986 . 2025-12-08 11:56:39 +00:00
Ross 9a3a8b932a . 2025-12-08 11:51:36 +00:00
Ross eb06e63f77 . 2025-12-08 11:18:47 +00:00
Ross a4f4ba464a Add advanced search help panel and functionality to ExamSearchSelectMultipleWidget 2025-12-08 11:15:03 +00:00
Ross 6fafccc907 . 2025-12-08 11:10:55 +00:00
Ross d96dd77cdc Add user authentication to index and user collections views; update CSRF trusted origins and internal IPs 2025-12-08 10:57:38 +00:00
Ross 4e47083fcd . 2025-12-08 10:35:58 +00:00
Ross bf895e516e . 2025-12-08 10:32:30 +00:00
Ross 49229b0408 Enhance local-up script to ensure Loki and nginx log directories exist with appropriate permissions 2025-12-08 10:30:00 +00:00
Ross 997b5374c2 . 2025-12-08 10:29:52 +00:00
Ross 9d434ec80d Merge branch 'dockerprod' into master 2025-12-08 10:13:08 +00:00
Ross af616a9f4a Add tooltips for open access settings and improve UI elements in exam overview 2025-12-08 10:05:44 +00:00
Ross d29c23a799 . 2025-12-08 09:54:15 +00:00
Ross 0917947a98 . 2025-12-08 09:52:18 +00:00
146 changed files with 8917 additions and 2765 deletions
+10 -2
View File
@@ -18,5 +18,13 @@ GUNICORN_WORKERS=3
GUNICORN_LOGLEVEL=info GUNICORN_LOGLEVEL=info
# Nginx host ports for local development (override prod defaults) # Nginx host ports for local development (override prod defaults)
NGINX_HTTP_PORT=8000 NGINX_HTTP_PORT=8080
NGINX_HTTPS_PORT=8443 # Pick a non-privileged HTTPS port to avoid conflicts with host installs
NGINX_HTTPS_PORT=8444
# Use Django's file-based email backend for local development so messages
# are written to files under the mounted `./logs/emails` directory and are
# easy to inspect on the host.
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
#EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
EMAIL_FILE_PATH=/var/log/rad/emails
+4 -2
View File
@@ -14,5 +14,7 @@ venv
temp/ temp/
# Loki runtime data # Loki runtime data
/docker/loki-data/ docker/loki-data/
/docker/loki-wal/ docker/loki-wal/
# Local runtime logs collected by Promtail
/logs/
+55
View File
@@ -0,0 +1,55 @@
README
======
Quick notes for running this repository (development & observability)
Running locally (development)
- Use the included compose files. To run the dev stack (overrides) set `COMPOSE_ENV=dev` so the correct env file is loaded:
```sh
# run nginx on non-privileged ports (see .env.dev)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
```
- By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are:
- `NGINX_HTTP_PORT=8080`
- `NGINX_HTTPS_PORT=8444`
Logging & observability (Loki + Promtail + Grafana)
- Promtail is configured to scrape `/var/log/rad/*.log` and push to Loki. Promtail's config is at `docker/promtail-config.yml` and the Loki config is at `docker/loki-config/local-config.yaml`.
- The nginx configs (both `deploy/nginx/prod.conf` and `deploy/nginx/dev.conf`) are configured to write access and error logs to `/var/log/rad/nginx.access.log` and `/var/log/rad/nginx.error.log` respectively.
- The compose files mount a host `logs/` folder into `/var/log/rad` so both nginx (write) and promtail (read) can access the same files:
- `../logs:/var/log/rad:rw` (nginx)
- `../logs:/var/log/rad:ro` (promtail)
Host setup for logs
- Create the host logs folder at the repo root (this repo's `logs/`). In development you can use permissive permissions so containers can write to it quickly:
```sh
mkdir -p logs
# development: make writable by all (change this for production)
sudo chown $USER:$USER logs
chmod 0777 logs
```
- In production prefer setting the folder owner to the nginx worker UID (or run nginx under a specific user) and use `0755` or `0750` as appropriate. Example:
```sh
# run on the host (determine nginx UID as needed)
sudo chown -R 101:101 /srv/rad/logs
sudo chmod 0755 /srv/rad/logs
```
Viewing logs in Grafana
- Grafana is available on the port exposed by compose (default `3000`).
- The Loki datasource is configured to use `http://loki:3100` (see compose). In Grafana Explore choose the Loki datasource and run queries such as:
- `{job="rad_app"}` (all collected logs)
- `{job="rad_app", filename="/var/log/rad/nginx.access.log"}`
Notes & recommendations
- In development the `logs/` folder is ignored by git (`/.gitignore` updated). Do not commit runtime logs.
- Consider a log rotation/retention policy for production (e.g., `logrotate` or use Loki retention policies).
- If you need nginx to re-resolve the backend service IP without restarting, consider using `resolver` and variable-based upstreams in the nginx config. For many development workflows restarting nginx after web restarts is the simplest approach.
If you want, I can also add a short `docs/OBSERVABILITY.md` with more details and recommended production settings (TLS/auth for Loki, retention, log rotation).
+35 -2
View File
@@ -34,7 +34,9 @@ from dal import autocomplete
from tinymce.widgets import TinyMCE from tinymce.widgets import TinyMCE
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit from crispy_forms.layout import Submit, Layout, Div
from crispy_forms.bootstrap import Accordion, AccordionGroup
from generic.widgets import ExamSearchWidget
class AnatomyAnswerForm(ModelForm): class AnatomyAnswerForm(ModelForm):
class Meta: class Meta:
@@ -127,7 +129,38 @@ class AnatomyQuestionForm(ModelForm):
self.fields["exams"] = ModelMultipleChoiceField( self.fields["exams"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=exam_queryset, queryset=exam_queryset,
widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), widget=ExamSearchWidget(exam_model=Exam),
)
# Now that all fields have been created, set the helper layout so
# Crispy can find the referenced field names (including
# `answer_suggest_incorrect`). Placing the layout here ensures the
# collapse/accordion renders server-side.
self.helper.layout = Layout(
"question_type",
"image",
"description",
# Place modality → body_part fields in a horizontal flex row
Div(
Div("modality", css_class="flex-fill me-2"),
Div("region", css_class="flex-fill me-2"),
Div("structure", css_class="flex-fill me-2"),
Div("examination", css_class="flex-fill me-2"),
Div("body_part", css_class="flex-fill"),
css_class="d-flex flex-wrap align-items-start mb-2",
),
"answer_help",
Accordion(
AccordionGroup(
"Suggest incorrect answers",
"answer_suggest_incorrect",
active=False,
)
),
"feedback",
"open_access",
"exams",
Div(Submit("submit", "Submit"), css_class="form-group"),
) )
def save(self, commit=True): def save(self, commit=True):
@@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2026-01-26 11:05
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0024_exam_results_supervisor_visible'),
]
operations = [
migrations.AddField(
model_name='exam',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
preserve_default=False,
),
migrations.AddField(
model_name='exam',
name='updated',
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
),
]
+3 -3
View File
@@ -191,9 +191,9 @@ class AnatomyQuestion(QuestionBase):
if not unmarked_answers: if not unmarked_answers:
return "No answers to mark" return "No answers to mark"
return format_html( return format_html(
"<span class='warn'>{} answer unmarked:</span> {}".format( "<span class='warn'>{} answer unmarked:</span> {}",
len(unmarked_answers), ", ".join(unmarked_answers) len(unmarked_answers),
) ", ".join(unmarked_answers),
) )
def get_unmarked_user_answers(self, exam_pk: int|None = None): def get_unmarked_user_answers(self, exam_pk: int|None = None):
@@ -233,6 +233,10 @@
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p> <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> <p>Correct and incorrect answers can be defined once the question has been created.</p>
<p>Images can be selected either directly via the browse button or by dragging and dropping them into the designated area below.</p>
<p>Supported formats include JPEG, PNG, and GIF. It is also possible to use DICOM images.</p>
{% endhelp %} {% endhelp %}
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form"> <form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %} {% csrf_token %}
@@ -241,10 +245,9 @@
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup" <a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a> onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
{{ form|crispy }} {% crispy form %}
<div id="drop-container" class="drop-target">Drop image here <div id="drop-container" class="drop-target">Drop image here
<div id="drop-filenames"></div> <div id="drop-filenames"></div>
</div> </div>
<input type="submit" class="submit-button" value="Submit" name="submit">
</form> </form>
{% endblock %} {% endblock %}
+117 -43
View File
@@ -1,47 +1,127 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<div class="question-display-block"> <div class="container anatomy my-3">
<div class="hide-show">HIDE/SHOW</div>
<div class="inner-display-block">
Question <span class="question-number">No question loaded</span><br />
<span id="question-title"></span><br />
<span id="question-question"></span>
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
</div>
<div class="answers">Answers: </div>
</div>
</div>
<div class="anatomy">
{% include 'user_score_header.html' %} {% include 'user_score_header.html' %}
<ul class="score-answer-list"> <div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body p-0">
<div class="p-2 d-flex align-items-center justify-content-between">
<div class="small text-muted">Filter questions by marks</div>
<div>
<select id="filter-score" class="form-select form-select-sm" style="min-width:120px;">
<option value="all">All scores</option>
<option value="2">2 (full)</option>
<option value="1">1</option>
<option value="0">0</option>
</select>
</div>
</div>
<ul class="list-group list-group-flush">
{% for ans, score, correct_answer in answers_and_marks %} {% for ans, score, correct_answer in answers_and_marks %}
<li class="user-answer-li" data-question-number="{{forloop.counter}}">Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{correct_answer}}</span>{% endif %} <li class="list-group-item d-flex justify-content-between align-items-start" data-score="{{ score }}">
<br/> <div class="d-flex align-items-start">
<span class="user-answer-score user-answer-score-{{score}}"> <div class="me-3">
<span class="submitted-user-answer"> <span class="badge bg-secondary">{{ forloop.counter }}</span>
<pre>{{ans}}</pre> </div>
</span> <div>
<div class="fw-semibold">Question {{ forloop.counter }}</div>
<div class="small text-muted">Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{ correct_answer }}</span>{% else %}—{% endif %}</div>
<div class="mt-2"><pre class="mb-0">{{ ans }}</pre></div>
{% if exam.publish_results or view_all_results %} {% if exam.publish_results or view_all_results %}
Marks: <span class="answer-score">{{score}}</span> <div class="small mt-2">
Marks:
{% if score == 2 %}
<span class="badge bg-success ms-1">{{ score }}</span>
{% elif score == 1 %}
<span class="badge bg-warning text-dark ms-1">{{ score }}</span>
{% else %}
<span class="badge bg-secondary ms-1">{{ score }}</span>
{% endif %} {% endif %}
</span> </div>
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span> {% endif %}
</div>
</div>
<div class="text-end ms-3">
{% if exam.publish_results %}
<button type="button" class="btn btn-sm btn-outline-primary view-question-button" data-qn={{forloop.counter0}}>View</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>View</button>
{% endif %}
{% if request.user.is_staff %}
<button type="button" class="btn btn-sm btn-outline-secondary ms-2 admin-edit-button" data-qn="{{ forloop.counter0 }}">Edit</button>
{% endif %}
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</div>
</div>
{% include 'user_scores_footer.html' %} {% include 'user_scores_footer.html' %}
</div> </div>
<div class="col-md-4">
{% if exam.publish_results %}
<div class="card sticky-top" style="top:1rem;">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Viewer</h6>
<button class="btn btn-sm btn-outline-secondary" id="hide-viewer">Hide</button>
</div>
<div class="card-body p-0">
<div class="viewer-block">
<div class="viewer-body overflow-auto p-3" style="max-height: calc(100vh - 160px);">
<div>Question <span class="question-number">No question loaded</span></div>
<div id="question-title" class="fw-bold my-2"></div>
<div id="question-question" class="mb-2"></div>
<div id="single-dicom-viewer" class="dicom-viewer mb-2" data-images="" data-annotations=''></div>
<div class="small text-muted">Answers:</div>
<div class="answers mt-1"></div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block css %}
<style>
/* Ensure the sticky viewer stays within its column and the canvas scales */
.anatomy .card.sticky-top { max-width: 100%; }
/* Keep viewer usable by enforcing a minimum width on larger viewports */
@media (min-width: 768px) {
.anatomy .card.sticky-top { min-width: 600px; }
}
.anatomy .viewer-block .viewer-body { overflow: auto; }
.viewer-block .answers { background: #f8f9fa; padding: .5rem; border-radius: .25rem; color: #212529; }
.viewer-block .answers, .viewer-block .answers * { color: #212529 !important; }
.viewer-block .answers pre { margin: 0; white-space: pre-wrap; }
.dicom-viewer, .dicom-viewer .canvas-panel, .dicom-viewer .cornerstone-element { max-width: 100%; box-sizing: border-box; }
.dicom-viewer canvas { max-width: 100% !important; width: 100% !important; height: auto !important; display: block; }
#single-dicom-viewer { width: 100%; }
/* hide horizontal overflow from the card body so the page doesn't scroll sideways */
.anatomy .card .card-body { overflow-x: hidden; }
</style>
{% endblock %} {% endblock %}
{% block js %} {% block js %}
{% if exam.publish_results %}
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$(".hide-show").click((evt, el) => { $("#hide-viewer").on('click', function(){ $('.viewer-block .viewer-body').toggle(); });
$(".inner-display-block").toggle();
})
$(".view-question-link").on("click", function (e) { // Filter questions by score using Bootstrap's d-none for consistent layout
console.log("click", e) $("#filter-score").on('change', function(){
const val = $(this).val();
$(".list-group-item[data-score]").each(function(){
const s = $(this).data('score');
if(val === 'all' || String(s) === String(val)) $(this).removeClass('d-none'); else $(this).addClass('d-none');
});
});
$(".view-question-button").on("click", function (e) {
question_number = e.currentTarget.dataset.qn; question_number = e.currentTarget.dataset.qn;
$("#single-dicom-viewer").empty(); $("#single-dicom-viewer").empty();
$.ajax({ $.ajax({
@@ -53,29 +133,23 @@
type: "POST", type: "POST",
dataType: "json", dataType: "json",
}) })
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) { .done(function (data) {
console.log(data); let event = new CustomEvent('loadDicomViewerUrls', { "detail": { "images": data.images, "annotations": data.annotations } });
let event = new CustomEvent('loadDicomViewerUrls', {
"detail": { "images": data.images, "annotations": data.annotations }
});
window.dispatchEvent(event); window.dispatchEvent(event);
$(".question-display-block .answers").empty().append(data.answers.toString()); $(".viewer-block .answers").empty().append(data.answers.toString());
$("#question-title").empty().append(data.title); $("#question-title").empty().append(data.title);
$("#question-question").empty().append(data.question); $("#question-question").empty().append(data.question);
n = parseInt(question_number) + 1 n = parseInt(question_number) + 1
$(".question-display-block .question-number").empty().append(n); $(".viewer-block .question-number").empty().append(n);
$(".inner-display-block").show(); $(".viewer-block .viewer-body").show();
})
.always(function () {
console.log('[Done]');
})
}) })
.always(function () { console.log('[Done]'); });
});
$(".view-question-link").first().click(); // auto-click first view button to populate viewer
const firstView = $(".view-question-button:visible").first();
if(firstView.length){ firstView.click(); }
}); });
</script> </script>
{% endif %}
{% endblock %} {% endblock %}
@@ -1,5 +1,5 @@
{% extends 'anatomy/base.html' %} {% extends 'anatomy/base.html' %}
{% load partials %}
{% block content %} {% block content %}
{% load static %} {% load static %}
{% include 'anatomy/question_link_header.html' %} {% include 'anatomy/question_link_header.html' %}
@@ -10,10 +10,10 @@
<div class="card-body"> <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 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>
<div class="mt-3 d-flex justify-content-between align-items-center"> <div class="mt-3 d-flex justify-content-between align-items-start">
<details class="mb-0"> <details class="mb-0 flex-grow-1 me-2">
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image help</summary> <summary class="small text-muted"><i class="bi bi-info-circle"></i> Image annotation help</summary>
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist.</div> <div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist. If required use the middle button to zoom and the left button to pan.</div>
</details> </details>
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button> <button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
+3 -96
View File
@@ -786,6 +786,9 @@ def mark2_overview(request, pk):
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2.""" """Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
exam = get_object_or_404(Exam, pk=pk) exam = get_object_or_404(Exam, pk=pk)
if not GenericExamViews.check_user_marker_access(request.user, pk):
raise PermissionDenied
if not exam.exam_mode: if not exam.exam_mode:
raise Http404("Packet not in exam mode") raise Http404("Packet not in exam mode")
@@ -1045,102 +1048,6 @@ def mark2_question_marked(request, pk):
return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context) return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context)
#@login_required
#def exam_scores_refresh(request, pk):
# exam = get_object_or_404(Exam, pk=pk)
#
# if not exam.exam_mode:
# raise Http404("Packet not in exam mode")
#
# questions = exam.exam_questions.all() # Don't worry about order here
#
# cids = UserAnswer.objects.filter(question__in=questions, exam__id=pk)
#
# # Force a score update
# for c in cids:
# c.get_answer_score(cached=False)
#
# return render(
# request,
# "anatomy/base.html",
# {
# "simple_content": "Answer scores updated",
# },
# )
def exam_scores_cid_user(request, pk, cid, passcode):
exam = get_object_or_404(Exam, pk=pk)
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
if not exam.check_cid_user(cid, passcode, request.user):
raise Http404("Error accessing exam")
questions = exam.get_questions().prefetch_related("answers")
answers_and_marks = []
answers_marks = []
answers = []
view_all_results = False
if request.user.groups.filter(name="view_all_results").exists():
view_all_results = True
for q in questions:
# Get user answer
user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first()
if not user_answer:
# skip if no answer
answers_marks.append(0)
answers.append("")
answer_score = 0
ans = "Not answered"
else:
ans = user_answer.answer
answer_score = user_answer.get_answer_score()
correct_answer = q.get_primary_answer()
if not exam.publish_results and not view_all_results:
correct_answer = "*****"
answer_score = 0
answers.append(ans)
answers_marks.append(answer_score)
answers_and_marks.append((ans, answer_score, correct_answer))
if "unmarked" in answers_marks:
answered = [i for i in answers_marks if type(i) == int]
total_score = sum(answered)
unmarked_number = len(answers_marks) - len(answered)
total_score = "{} ({} unmarked)".format(total_score, unmarked_number)
else:
total_score = sum(answers_marks)
max_score = len(questions) * 2
return render(
request,
"anatomy/exam_scores_user.html",
{
"exam": exam,
"cid": cid,
"passcode": passcode,
"questions": questions,
"answers": answers,
"answers_marks": answers_marks,
"total_score": total_score,
"max_score": max_score,
"answers_and_marks": answers_and_marks,
"view_all_results": view_all_results,
},
)
class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
model = AnatomyQuestion model = AnatomyQuestion
form_class = AnatomyQuestionForm form_class = AnatomyQuestionForm
-22
View File
@@ -249,28 +249,6 @@ def series_remove_duplicate_images(request, series_id: int):
return len(dupes) return len(dupes)
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
def series_truncate(request, series_id: int, start: int, end: int):
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return {"status": "permission denied"}
images_removed = []
for n, image in enumerate(series.get_images()):
if n >= start and n <= end:
continue
else:
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
series.get_total_image_size(refresh=True)
return {"status": "success", "images_removed": images_removed}
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema]) @router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
def get_cases_user(request): def get_cases_user(request):
return Case.objects.filter(author=request.user) return Case.objects.filter(author=request.user)
+187 -10
View File
@@ -1,4 +1,8 @@
import django_filters import django_filters
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Div, Field
from collections import OrderedDict
from .models import ( from .models import (
Case, Case,
@@ -17,6 +21,7 @@ from .models import (
) )
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.http import QueryDict
def get_authors(request): def get_authors(request):
@@ -81,6 +86,22 @@ class CaseCollectionFilter(django_filters.FilterSet):
user=None, user=None,
request=None, request=None,
): ):
# Default the archive filter to False when not provided so collections
# are not archived by default in listings.
if data is None:
data = QueryDict('', mutable=True)
data['archive'] = 'false'
else:
# QueryDict instances need to be copied to be mutable
try:
if 'archive' not in data:
data = data.copy()
data['archive'] = 'false'
except Exception:
# Fallback for non-QueryDict mappings
if isinstance(data, dict) and 'archive' not in data:
data = dict(data)
data['archive'] = 'false'
if not request.user.groups.filter(name="atlas_editor").exists(): if not request.user.groups.filter(name="atlas_editor").exists():
queryset = queryset.prefetch_related("author").filter( queryset = queryset.prefetch_related("author").filter(
author__id=request.user.id author__id=request.user.id
@@ -95,9 +116,22 @@ class CaseFilter(django_filters.FilterSet):
author = django_filters.ModelMultipleChoiceFilter( author = django_filters.ModelMultipleChoiceFilter(
queryset=get_authors, null_label="No author" queryset=get_authors, null_label="No author"
) )
# Show a distinct list of `Series.examination` values instead of raw Series
# entries so examinations that differ only by plane/description appear once.
series = django_filters.ModelMultipleChoiceFilter( series = django_filters.ModelMultipleChoiceFilter(
queryset=get_series, null_label="No series" field_name='series__examination',
queryset=lambda request: __import__('generic.models', fromlist=['Examination']).Examination.objects.filter(atlas_series_examination__isnull=False).distinct(),
label='Examination',
) )
# Free-text search to help find the correct examination when there are
# many similarly-named Series/Examination entries.
examination_text = django_filters.CharFilter(method='filter_examination_text', label='Examination (text)')
# Full list of conditions and a text search to find conditions
condition = django_filters.ModelMultipleChoiceFilter(
queryset=Condition.objects.all(),
label='Condition',
)
condition_text = django_filters.CharFilter(method='filter_condition_text', label='Condition (text)')
differential_case = django_filters.ModelMultipleChoiceFilter( differential_case = django_filters.ModelMultipleChoiceFilter(
label="Differential", label="Differential",
field_name="differentialcase__condition", field_name="differentialcase__condition",
@@ -132,6 +166,58 @@ class CaseFilter(django_filters.FilterSet):
) )
pass pass
def filter_examination_text(self, queryset, name, value):
"""Filter Cases by a text search on related Examination records.
Matches against the Examination string representation and any
common identifying fields (name/code). Uses a case-insensitive
contains lookup and filters Cases which have a Series linked to
a matching Examination.
"""
if not value:
return queryset
from generic.models import Examination
# Only attempt to match PK when the provided value looks like an integer
try:
pk_val = int(value)
except Exception:
pk_val = None
if pk_val is not None:
matches = Examination.objects.filter(
Q(examination__icontains=value) | Q(pk=pk_val)
).distinct()
else:
matches = Examination.objects.filter(
Q(examination__icontains=value)
).distinct()
return queryset.filter(series__examination__in=matches).distinct()
def filter_condition_text(self, queryset, name, value):
"""Filter Cases by a text search on related Condition records.
Matches both direct `condition` M2M and `differential` entries.
"""
if not value:
return queryset
# Only try PK match when numeric
try:
pk_val = int(value)
except Exception:
pk_val = None
if pk_val is not None:
matches = Condition.objects.filter(Q(name__icontains=value) | Q(pk=pk_val)).distinct()
else:
matches = Condition.objects.filter(Q(name__icontains=value)).distinct()
# Match either direct condition M2M or differential (through Differential)
return queryset.filter(Q(condition__in=matches) | Q(differential__in=matches)).distinct()
class SeriesFilter(django_filters.FilterSet): class SeriesFilter(django_filters.FilterSet):
case = django_filters.ModelMultipleChoiceFilter( case = django_filters.ModelMultipleChoiceFilter(
@@ -353,8 +439,16 @@ class NormalCaseFilter(django_filters.FilterSet):
modality = 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 # Accept min/max as years in the UI but filter against the canonical
# `age_days` field by converting years -> days. # `age_days` field by converting years -> days.
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age") # Numeric min/max values entered by the user (interpreted via `age_unit`)
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age") min_value = django_filters.NumberFilter(label="Min", method="filter_min_value")
max_value = django_filters.NumberFilter(label="Max", method="filter_max_value")
AGE_UNIT_CHOICES = (
("days", "Days"),
("weeks", "Weeks"),
("months", "Months"),
("years", "Years"),
)
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES)
class Meta: class Meta:
model = NormalCase model = NormalCase
@@ -364,9 +458,10 @@ class NormalCaseFilter(django_filters.FilterSet):
# Use the global Examination and Modality querysets from the Series model # Use the global Examination and Modality querysets from the Series model
from generic.models import Examination, Modality from generic.models import Examination, Modality
# Set up the actual querysets for the filters # We'll set the actual querysets for the filters after applying
self.base_filters.get("examination").queryset = Examination.objects.all() # any NormalCase queryset restrictions for the current user so
self.base_filters.get("modality").queryset = Modality.objects.all() # that the choices only include examinations/modalities that
# actually have NormalCase records available.
# If not an atlas editor restrict to cases authored by the user or open access cases # If 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(): if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
@@ -376,18 +471,100 @@ class NormalCaseFilter(django_filters.FilterSet):
queryset = NormalCase.objects.select_related("case") queryset = NormalCase.objects.select_related("case")
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True))) queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
# Determine the NormalCase queryset we'll use to build choices
nc_qs = queryset if queryset is not None else NormalCase.objects.all()
# Limit examination and modality choices to those present on the
# available NormalCase records (respecting user restrictions).
try:
exam_ids = nc_qs.values_list("examination_id", flat=True)
mod_ids = nc_qs.values_list("modality_id", flat=True)
self.base_filters.get("examination").queryset = Examination.objects.filter(id__in=exam_ids).distinct()
self.base_filters.get("modality").queryset = Modality.objects.filter(id__in=mod_ids).distinct()
except Exception:
# Fallback to all if something goes wrong
self.base_filters.get("examination").queryset = Examination.objects.all()
self.base_filters.get("modality").queryset = Modality.objects.all()
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request) super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
def filter_min_age(self, queryset, name, value): # Attach a Crispy FormHelper to render the filter form as a
# horizontal Bootstrap grid. We set `form_tag=False` because the
# template already provides the <form> element.
try: try:
days = int(float(value) * 365.25) helper = FormHelper()
helper.form_method = "get"
helper.form_tag = False
helper.form_class = "row g-2"
helper.layout = Layout(
Div(
Div("examination", css_class="col-md-3"),
Div("modality", css_class="col-md-3"),
css_class="row g-2",
),
Div(
Div(
Field("min_value", wrapper_class="mb-0 d-flex align-items-center"),
Field("max_value", wrapper_class="mb-0 d-flex align-items-center"),
Field("age_unit", wrapper_class="mb-0 d-flex align-items-center"),
css_class="col-12 d-flex gap-2 align-items-center",
),
css_class="row g-2",
),
)
# Assign helper to the generated form instance and tweak widgets
if hasattr(self, 'form') and self.form is not None:
# Wider numeric inputs and default unit
try:
form = self.form
if "min_value" in form.fields:
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
if "max_value" in form.fields:
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
if "age_unit" in form.fields:
form.fields["age_unit"].initial = "days"
form.fields["age_unit"].widget.attrs.update({"class": "form-select w-auto", "style": "min-width:220px;"})
form.helper = helper
except Exception:
# don't crash if form structure differs
self.form.helper = helper
except Exception:
pass
def _convert_to_days(self, value, unit: str) -> int:
try:
v = float(value)
except Exception:
raise
if unit == "days":
return int(v)
if unit == "weeks":
return int(v * 7)
if unit == "months":
# approximate month length
return int(v * (365.25 / 12.0))
# default: years
return int(v * 365.25)
def filter_min_value(self, queryset, name, value):
try:
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "years")
except Exception:
unit = "years"
try:
days = self._convert_to_days(value, unit)
except Exception: except Exception:
return queryset return queryset
return queryset.filter(age_days__gte=days) return queryset.filter(age_days__gte=days)
def filter_max_age(self, queryset, name, value): def filter_max_value(self, queryset, name, value):
try: try:
days = int(float(value) * 365.25) unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "years")
except Exception:
unit = "years"
try:
days = self._convert_to_days(value, unit)
except Exception: except Exception:
return queryset return queryset
return queryset.filter(age_days__lte=days) return queryset.filter(age_days__lte=days)
@@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2026-01-26 11:05
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0089_remove_condition_primary_remove_condition_synonym'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
preserve_default=False,
),
migrations.AddField(
model_name='casecollection',
name='updated',
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
),
]
+42 -5
View File
@@ -720,11 +720,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
series_qs = self.series.all() series_qs = self.series.all()
if exclude_series_ids is not None: if exclude_series_ids is not None:
series_qs = self.series.exclude(id__in=exclude_series_ids) series_qs = series_qs.exclude(id__in=exclude_series_ids)
images = [
[image.image.url for image in series.images.all()] # Ensure series are ordered by the through model sort_order
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images') series_list = list(series_qs.order_by('seriesdetail__sort_order').prefetch_related('images'))
]
images = []
for series in series_list:
# If images were prefetched, use the cached list and filter in Python
prefetched = getattr(series, '_prefetched_objects_cache', {}).get('images')
if prefetched is not None:
imgs = [img.image.url for img in prefetched if not getattr(img, 'removed', False)]
else:
imgs = [image.image.url for image in series.images.filter(removed=False)]
images.append(imgs)
if as_json: if as_json:
return json.dumps(images) return json.dumps(images)
@@ -1039,6 +1048,27 @@ class Series(SeriesBase):
else: else:
return f"{self.examination} ({self.plane})" return f"{self.examination} ({self.plane})"
def user_can_edit(self, user, allow_if_can_edit_cases=True) -> bool:
"""Check if the user can edit the series.
Args:
user (User): The user to check.
Returns:
bool: True if the user can edit the series, False otherwise.
"""
if user.is_superuser:
return True
if self.author.filter(id=user.id).exists():
return True
if allow_if_can_edit_cases:
# Check if user can edit any of the cases this series is in
for case in self.case.all():
if case.check_user_can_edit(user):
return True
return False
def get_full_str(self): def get_full_str(self):
if self.case: if self.case:
case_id = ", ".join([str(case) for case in self.case.all()]) case_id = ", ".join([str(case) for case in self.case.all()])
@@ -1627,6 +1657,13 @@ class CaseDetail(models.Model):
return self.case.history or "No history provided" return self.case.history or "No history provided"
return "" return ""
def get_history(self):
if self.redact_history:
return "[Redacted]"
if self.override_history and self.override_history != "":
return self.override_history
return self.case.history or "No history provided"
def get_case_series_nested(self, include_priors=True): def get_case_series_nested(self, include_priors=True):
case_series_images = self.case.get_series_images_nested(as_json=False) case_series_images = self.case.get_series_images_nested(as_json=False)
+44 -16
View File
@@ -19,6 +19,8 @@ from .models import (
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe
from easy_thumbnails.files import get_thumbnailer from easy_thumbnails.files import get_thumbnailer
from easy_thumbnails.exceptions import InvalidImageFormatError from easy_thumbnails.exceptions import InvalidImageFormatError
@@ -83,12 +85,14 @@ class CaseTable(SelectionTable):
def get_view_cell(record): def get_view_cell(record):
if record.open_access: 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"> return mark_safe(
<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/> '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">'
<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/> '<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>'
</svg> View""") '<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>'
'</svg> View'
)
else: else:
return f'View' return 'View'
def render_author(self, value): def render_author(self, value):
max_length = 15 # Set the maximum length for truncating individual author names max_length = 15 # Set the maximum length for truncating individual author names
@@ -108,7 +112,7 @@ class CaseTable(SelectionTable):
) )
) )
# Join all authors with commas # Join all authors with commas
return format_html(", ".join(authors)) return mark_safe(", ".join(authors))
def render_associated_cases(self, record): def render_associated_cases(self, record):
links = [] links = []
@@ -130,7 +134,7 @@ class CaseTable(SelectionTable):
except Case.DoesNotExist: except Case.DoesNotExist:
pass pass
if links: if links:
return format_html(", ".join(links)) return mark_safe(", ".join(links))
return "No associated cases" return "No associated cases"
def render_collections(self, record): def render_collections(self, record):
@@ -151,7 +155,7 @@ class CaseTable(SelectionTable):
) )
for collection in collections for collection in collections
] ]
return format_html(", ".join(links)) return mark_safe(", ".join(links))
return "No collections" return "No collections"
associated_cases = tables.Column( associated_cases = tables.Column(
@@ -166,7 +170,7 @@ class CaseTable(SelectionTable):
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False "atlas:case_update", text="Edit", args=[A("pk")], orderable=False
) )
view = tables.LinkColumn( view = tables.LinkColumn(
"atlas:case_detail", text=get_view_cell, args=[A("pk")], orderable=False "atlas:case_detail", text=get_view_cell, args=[A("pk")], accessor="pk", orderable=False
) )
clone = tables.LinkColumn( clone = tables.LinkColumn(
"atlas:case_clone", text="Clone", args=[A("pk")], orderable=False "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
@@ -199,7 +203,7 @@ class PopupLinkColumn(tables.Column):
obj = value.first() obj = value.first()
if obj is None: if obj is None:
return format_html("<span>No image<span>") return mark_safe("<span>No image</span>")
return obj.get_thumbnail()[0] return obj.get_thumbnail()[0]
@@ -209,7 +213,7 @@ class SeriesTable(SelectionTable):
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False "atlas:series_update", text="Edit", args=[A("pk")], orderable=False
) )
view = tables.LinkColumn( view = tables.LinkColumn(
"atlas:series_detail", text="View", args=[A("pk")], orderable=False "atlas:series_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
) )
popup = tables.Column(empty_values=(), orderable=False) popup = tables.Column(empty_values=(), orderable=False)
@@ -290,7 +294,7 @@ class ConditionTable(SelectionTable):
) )
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return mark_safe(record.get_synonym_link())
return f"{record.get_synonym_link()}" return f"{record.get_synonym_link()}"
@@ -317,7 +321,7 @@ class FindingTable(SelectionTable):
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return mark_safe(record.get_synonym_link())
class StructureTable(SelectionTable): class StructureTable(SelectionTable):
@@ -344,7 +348,7 @@ class StructureTable(SelectionTable):
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return mark_safe(record.get_synonym_link())
class PresentationTable(SelectionTable): class PresentationTable(SelectionTable):
@@ -412,7 +416,7 @@ class SubspecialtyTable(SelectionTable):
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return mark_safe(record.get_synonym_link())
return f"{record.get_synonym_link()}" return f"{record.get_synonym_link()}"
@@ -432,8 +436,11 @@ class CaseCollectionTable(SelectionTable):
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False "atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
) )
view = tables.LinkColumn( view = tables.LinkColumn(
"atlas:collection_detail", text="View", args=[A("pk")], orderable=False "atlas:collection_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
) )
created = tables.DateTimeColumn(verbose_name="Created", accessor="created", orderable=True)
updated = tables.DateTimeColumn(verbose_name="Updated", accessor="updated", orderable=True)
#clone = tables.LinkColumn( #clone = tables.LinkColumn(
# "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False # "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
#) #)
@@ -449,6 +456,8 @@ class CaseCollectionTable(SelectionTable):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ( fields = (
"name", "name",
"created",
"updated",
"collection_type", "collection_type",
"publish_results", "publish_results",
"active", "active",
@@ -466,6 +475,25 @@ class CaseCollectionTable(SelectionTable):
**kwargs, **kwargs,
) )
def render_created(self, value):
if not value:
return ""
# compact date for display, full datetime in tooltip
return format_html(
'<span class="small text-muted" title="{}">{}</span>',
value.strftime("%Y-%m-%d %H:%M"),
value.strftime("%Y-%m-%d"),
)
def render_updated(self, value):
if not value:
return ""
return format_html(
'<span class="small text-muted" title="{}">{}</span>',
value.strftime("%Y-%m-%d %H:%M"),
value.strftime("%Y-%m-%d"),
)
class QuestionSchemaTable(SelectionTable): class QuestionSchemaTable(SelectionTable):
name = tables.Column( name = tables.Column(
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}), linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
+9
View File
@@ -49,6 +49,15 @@
<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> <a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
</li> </li>
<li>
<a class="dropdown-item" href="{% url 'atlas:case_search_page' %}"><i class="bi bi-search me-1" aria-hidden="true"></i>Search Cases</a>
</li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'atlas:cases_by_size' %}"><i class="bi bi-hDD-stack me-1" aria-hidden="true"></i>Large cases</a>
</li>
{% endif %}
</ul> </ul>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
+67 -20
View File
@@ -1,4 +1,3 @@
{% load partials %}
{% partialdef case-series %} {% partialdef case-series %}
{% for series in case.get_ordered_series %} {% for series in case.get_ordered_series %}
@@ -201,15 +200,26 @@
</div> </div>
</div> </div>
</div> </div>
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %} <div class="mb-3">
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a> <h5>Resources</h5>
{% if caseresource.pre_review %}
<span title="This resource will be avalible to review when or taking a case">(pre)</span> {% if can_edit%}
{% else %} <div class="mb-2">
<span title="This resource will only be available when reviewing a completed case">(post)</span> <div id="resource-search-panel-placeholder">
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'atlas:resource_search_panel' %}?case_id={{ case.pk }}"
hx-target="#resource-search-panel-placeholder"
hx-swap="innerHTML">
Add Resource
</button>
</div>
</div>
{% endif %} {% endif %}
{% endfor %}
</p> <div>
{% include 'atlas/partials/case_resources_list.html' with case=case %}
</div>
</div>
<div class="pre-whitespace multi-image-block"><b>Series:</b> <div class="pre-whitespace multi-image-block"><b>Series:</b>
<form> <form>
@@ -306,7 +316,6 @@
<!-- Series Findings --> <!-- Series Findings -->
<h5>Series Findings</h5> <h5>Series Findings</h5>
{% if case.ordered_series %}
{% for series in case.ordered_series %} {% for series in case.ordered_series %}
{% for finding in series.findings.all %} {% for finding in series.findings.all %}
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}"> <div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
@@ -346,14 +355,12 @@
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% endfor %} {% empty%}
{% else %}
No series associated with case. No series associated with case.
{% endif %} {% endfor %}
<!-- Case Display Sets --> <!-- Case Display Sets -->
<h5 class="mt-3">Case Display Sets</h5> <h5 class="mt-3">Case Display Sets</h5>
{% if case.display_sets %}
<ul class="list-group"> <ul class="list-group">
{% for ds in case.display_sets.all %} {% for ds in case.display_sets.all %}
<li class="list-group-item"> <li class="list-group-item">
@@ -363,6 +370,7 @@
{% endif %} {% endif %}
<span> <span>
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a> <a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
<button type="button" class="btn btn-secondary btn-sm view-displayset-modal ms-2" data-ds-id="{{ ds.pk }}" data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">View in Modal</button>
</span> </span>
<div> <div>
<strong>Findings:</strong> <strong>Findings:</strong>
@@ -414,11 +422,10 @@
</details> </details>
</div> </div>
</li> </li>
{% empty %}
<span class="text-muted">No display sets for this case.</span>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %}
<span class="text-muted">No display sets for this case.</span>
{% endif %}
</details> </details>
</div> </div>
<div> <div>
@@ -437,19 +444,19 @@
<b>Collections:</b> <b>Collections:</b>
{% if can_edit %} {% if can_edit %}
<button <button class="btn btn-sm btn-primary"
hx-get="{% url 'atlas:case_collection_form' case.pk %}" hx-get="{% url 'atlas:case_collection_form' case.pk %}"
hx-target="#collection-form"> hx-target="#collection-form">
Add to collection</button> Add to collection</button>
{% endif %} {% endif %}
<div id="collection-form"></div> <div id="collection-form"></div>
<ul> <ul id="case-{{ case.pk }}-collections">
{% for collection in case.casecollection_set.all %} {% for collection in case.casecollection_set.all %}
<li id="collection-{{ collection.pk }}"> <li id="collection-{{ collection.pk }}">
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
{% if can_edit %} {% if can_edit %}
<button <button
class="btn btn-danger btn-sm remove-button" class="btn btn-sm btn-outline-danger remove-button"
hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}" hx-post="{% url 'atlas:remove_case_from_collection' case.pk collection.pk %}"
hx-target="#collection-{{ collection.pk }}" hx-target="#collection-{{ collection.pk }}"
hx-swap="outerHTML" hx-swap="outerHTML"
@@ -483,6 +490,22 @@
{% include 'question_notes.html' %} {% include 'question_notes.html' %}
</div> </div>
<div class="modal fade" id="displaysetModal" tabindex="-1" aria-labelledby="displaysetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="displaysetModalLabel">Display Set Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="displaysetModalBody">
<p>Loading...</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<p><b>Checked by:</b> {% for verified in case.verified.all %} <a <p><b>Checked by:</b> {% for verified in case.verified.all %} <a
href="{% url 'atlas:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p> href="{% url 'atlas:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p>
@@ -763,6 +786,30 @@
}); });
}); });
// Display Set modal handler
const dsModal = new bootstrap.Modal(document.getElementById("displaysetModal"));
const dsModalBody = document.getElementById("displaysetModalBody");
document.querySelectorAll(".view-displayset-modal").forEach(button => {
button.addEventListener("click", function () {
const url = this.getAttribute("data-url");
dsModalBody.innerHTML = "<p>Loading...</p>";
fetch(url)
.then(response => response.text())
.then(html => {
dsModalBody.innerHTML = html;
setTimeout(function () {
try { window.mountDicomViewers(); } catch (e) { console.warn('mountDicomViewers not available', e); }
}, 500);
})
.catch(error => {
console.error("Error loading display set details:", error);
dsModalBody.innerHTML = "<p>Failed to load display set.</p>";
});
dsModal.show();
});
});
}); });
@@ -0,0 +1,79 @@
<div class="case-displayset-modal">
<div class="mb-3">
<h5>{{ ds.name }}</h5>
{% if can_edit %}
<a href="{% url 'atlas:case_displaysets' ds.case.pk %}?displayset={{ ds.pk }}&edit=true" class="btn btn-sm btn-outline-secondary ms-2">Edit display set</a>
{% endif %}
{% if ds.description %}
<div class="text-muted">{{ ds.description }}</div>
{% endif %}
</div>
{# Viewer area for the modal - mounted by rad/static/dv3d bundle #}
<div class="mb-3">
<div id="modal_viewer_{{ ds.pk }}" class="dicom-viewer-root" style="height:420px;" data-auto-cache-stack="false" data-annotationjson="{{ ds.annotations }}" data-viewerstate="{{ ds.viewerstate }}"></div>
</div>
{% if ds.viewerstate and ds.annotations %}
<div class="mb-3">
<button
type="button"
class="btn btn-primary"
onclick='(window.importAnnotations_modal_viewer_{{ ds.pk }}||window.importAnnotations_modal_viewer||window.importAnnotations)({{ ds.annotations|safe }}); (window.importViewerState_modal_viewer_{{ ds.pk }}||window.importViewerState_modal_viewer||window.importViewerState)({{ ds.viewerstate|safe }});'
>Reload display set</button>
</div>
{% endif %}
<div class="mt-2">
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul>
{% for finding in ds.findings.all %}
<li>{{ finding }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul>
{% for structure in ds.structures.all %}
<li>{{ structure }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul>
{% for condition in ds.conditions.all %}
<li>{{ condition }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div class="mt-3">
<details>
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
<div>
<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>
</div>
+2 -1
View File
@@ -38,7 +38,8 @@
{% block js %} {% block js %}
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
window.mountDicomViewers(); // Delay mounting slightly to ensure viewer assets and DOM are ready
setTimeout(function() { window.mountDicomViewers(); }, 500);
{% if selected_displayset %} {% if selected_displayset %}
@@ -0,0 +1,67 @@
{% extends 'atlas/base.html' %}
{% block content %}
<div class="container">
<h2>Search Cases</h2>
<form method="get" class="row g-2 mb-3" id="case-search-form">
<div class="col-md-6">
<input type="search" name="q" value="{{ q }}" class="form-control" placeholder="Search cases...">
</div>
<div class="col-md-2">
<select name="field" class="form-select">
<option value="all" {% if field == 'all' %}selected{% endif %}>All fields</option>
<option value="title" {% if field == 'title' %}selected{% endif %}>Title</option>
<option value="description" {% if field == 'description' %}selected{% endif %}>Description</option>
<option value="history" {% if field == 'history' %}selected{% endif %}>History</option>
<option value="author" {% if field == 'author' %}selected{% endif %}>Author</option>
</select>
</div>
<div class="col-md-2">
<select name="subspecialty" class="form-select">
<option value="">Any subspecialty</option>
{% for s in subspecialty_queryset %}
<option value="{{ s.pk }}" {% if selected_subspecialty and selected_subspecialty|stringformat:"s" == s.pk|stringformat:"s" %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<label class="form-check-label me-2"><input type="checkbox" name="my" value="1" class="form-check-input" {% if my %}checked{% endif %}> My cases</label>
<label class="form-check-label me-2"><input type="checkbox" name="open" value="1" class="form-check-input" {% if open %}checked{% endif %}> Open access</label>
<label class="form-check-label"><input type="checkbox" name="has_images" value="1" class="form-check-input" {% if has_images %}checked{% endif %}> Has images</label>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Search</button>
<a href="{% url 'atlas:case_search_page' %}" class="btn btn-outline-secondary ms-2">Clear</a>
</div>
</form>
<div id="case-search-results">
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=None q=q %}
{# pagination controls #}
{% if cases.has_other_pages %}
<nav aria-label="Case results pagination">
<ul class="pagination mt-3">
{% if cases.has_previous %}
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&amp;{% endif %}{% if field %}field={{ field }}&amp;{% endif %}{% if my %}my=1&amp;{% endif %}{% if open %}open=1&amp;{% endif %}{% if has_images %}has_images=1&amp;{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&amp;{% endif %}page={{ cases.previous_page_number }}">Previous</a></li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ cases.number }} of {{ cases.paginator.num_pages }}</span></li>
{% if cases.has_next %}
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&amp;{% endif %}{% if field %}field={{ field }}&amp;{% endif %}{% if my %}my=1&amp;{% endif %}{% if open %}open=1&amp;{% endif %}{% if has_images %}has_images=1&amp;{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&amp;{% endif %}page={{ cases.next_page_number }}">Next</a></li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
{% endblock %}
@@ -72,29 +72,6 @@
{% csrf_token %} {% csrf_token %}
{% crispy form form.helper %} {% crispy form form.helper %}
{% comment %} <h3>Cases:</h3>
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
<input type="button" value="Add More Cases" id="add_more_case">
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
<div id="case_formset" class="sortable list_formset">
<ol>
{% for form in case_formset %}
<li class="no-error case-formset">
{{form.non_field_errors}}
{{form.errors}}
{{ form }}
</li>
{% endfor %}
</ol>
</div>
{{ case_formset.management_form }}
</form>
<div id="empty_case_form" style="display:none">
<li class='no_error case-formset'>
{{ case_formset.empty_form }}
</li>
</div> {% endcomment %}
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %} {% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
</form> </form>
<script> <script>
+1 -1
View File
@@ -25,7 +25,7 @@
<div class="d-flex align-items-center"> <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" <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-get="{% url 'atlas:categories_search_partial' %}"
hx-trigger="keyup changed delay:500ms" hx-trigger="keyup delay:500ms"
hx-target="#category-search-results" hx-target="#category-search-results"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-include="#category-search-form" hx-include="#category-search-form"
+38 -7
View File
@@ -1,6 +1,5 @@
{% extends 'atlas/exams.html' %} {% extends 'atlas/exams.html' %}
{% load partials %}
{% partialdef casedetails-management-links %} {% partialdef casedetails-management-links %}
@@ -20,25 +19,30 @@
<h2 class="card-title mb-2">{{ collection.name }}</h2> <h2 class="card-title mb-2">{{ collection.name }}</h2>
{% include 'exam_notes.html' %} {% include 'exam_notes.html' %}
{% if casesdetails|length > 20 %}
<div id="collection-warning-{{ collection.pk }}" class="alert alert-warning alert-dismissible fade show" role="alert">
<strong>Performance warning:</strong> This collection contains {{ casesdetails|length }} cases — in some situations this may adversely affect performance, all features will still work but page loading may be slower. Consider splitting into smaller collections if performance is an issue.
<button id="collection-warning-close-{{ collection.pk }}" type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<p class="mb-1"> <p class="mb-1">
<span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
<small class="text-muted">(open access: {{ collection.exam_open_access }})</small>
</p> </p>
<p class="mb-2"> <p class="mb-2">
{% include "generic/partials/exams/exam_status.html#publish-results" %} {% include "generic/partials/exams/exam_status.html#publish-results" %} {% include "generic/partials/exams/exam_status.html#exam-active" %}
{% include "generic/partials/exams/exam_status.html#exam-active" %}
</p> </p>
<p class="mb-1"> <p class="mb-1">
<span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span> Collection type: <span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
<span class="badge bg-light text-dark me-2">Self review: {{ collection.self_review }}</span> Self review: <span class="badge bg-light text-dark me-2">{{ collection.self_review }}</span>
Open access:
{% if collection.open_access %} {% if collection.open_access %}
<span class="badge bg-success">Open</span> <span class="badge bg-success">Open</span>
{% else %} {% else %}
<span class="badge bg-warning text-dark">Restricted</span> <span class="badge bg-warning text-dark">Restricted</span>
{% endif %} {% endif %}
Exam mode: <span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
</p> </p>
{% if collection.prerequisites.exists %} {% if collection.prerequisites.exists %}
@@ -152,6 +156,33 @@
{% endif %} {% endif %}
{% include 'exam_overview_js.html' %} {% include 'exam_overview_js.html' %}
<!-- Modal for case fields (history / discussion / report) -->
<div id="case-field-modal" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content"></div>
</div>
</div>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function() {
try {
var pk = '{{ collection.pk }}';
var key = 'collection_warning_dismissed_' + pk;
var alertEl = document.getElementById('collection-warning-' + pk);
if (!alertEl) return;
if (localStorage.getItem(key) === '1') {
alertEl.remove();
return;
}
var btn = document.getElementById('collection-warning-close-' + pk);
if (btn) {
btn.addEventListener('click', function() {
try { localStorage.setItem(key, '1'); } catch (e) { /* ignore */ }
});
}
} catch (e) { console.warn('collection warning persistence error', e); }
});
</script>
{% endblock %} {% endblock %}
@@ -28,6 +28,8 @@
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li> <li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li> <li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
{% if request.user.is_superuser %} {% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li> <li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
@@ -71,6 +71,22 @@
<div class="question-block-snippet" style="display:none;"> <div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %} {% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div> </div>
{# Attached resources for this case: allow loading into the shared viewer #}
{% if case.caseresource_set.exists %}
<div class="mt-2">
<strong>Resources:</strong>
<div class="d-flex flex-column gap-1">
{% for cr in case.caseresource_set.all %}
{% with res=cr.resource %}
<div>
<span class="me-2">{{ res.name }}</span>
<button class="open-resource btn btn-sm btn-outline-primary ms-1" data-src="{% if res.file %}{{ res.file.url }}{% else %}{{ res.url }}{% endif %}" data-title="{{ res.name|escapejs }}">Load</button>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
{% if case.display_sets.all %} {% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary> <details class="displayset-detail"><summary>Display Sets:</summary>
{% for ds in case.display_sets.all %} {% for ds in case.display_sets.all %}
@@ -335,6 +351,19 @@
//} //}
//win2 = openSecondaryWindow(url); //win2 = openSecondaryWindow(url);
}) })
// Load resource into linked viewer
$('.open-resource').click(function() {
var src = $(this).data('src');
var title = $(this).data('title') || 'Resource';
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Resource:</span> "+title);
$('#current-case-history').html('');
$('#current-case-discussion').html('');
$('#current-case-report').html('');
bc.postMessage({"type": "open", "url": src});
});
$('.send-message').click(function() { $('.send-message').click(function() {
var url = $(this).data('target'); var url = $(this).data('target');
bc.postMessage(url); bc.postMessage(url);
@@ -83,6 +83,20 @@
</div> </div>
{% endif %} {% endif %}
{# Also include cases that reference this condition via CaseDisplaySet #}
{% if condition.casedisplayset_set.exists %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Cases via Display Sets</h5>
<ul class="list-unstyled mb-0">
{% for ds in condition.casedisplayset_set.all %}
<li class="mb-1">{{ ds.case.get_link }} <small class="text-muted">[Display set: {{ ds.name }}]</small></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% if condition.differential_set.exists %} {% if condition.differential_set.exists %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
+10
View File
@@ -27,6 +27,16 @@
</ul> </ul>
{% endif %} {% endif %}
{# Also include cases referenced via CaseDisplaySet that list this finding #}
{% if finding.casedisplayset_set.all %}
<h4>Cases via Display Sets</h4>
<ul>
{% for ds in finding.casedisplayset_set.all %}
<li>Case: {{ ds.case.get_link }} (Display set: {{ ds.name }})</li>
{% endfor %}
</ul>
{% endif %}
{% if finding.sbas_questions.all %} {% if finding.sbas_questions.all %}
<h4>Related SBA Questions</h4> <h4>Related SBA Questions</h4>
<ul> <ul>
+19 -3
View File
@@ -14,14 +14,27 @@
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Collections</h5> <h5 class="card-title">Collections</h5>
<p class="card-text">Quick access to collections you can view or take.</p> <p class="card-text">Quick access to collections you can view or take.</p>
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a> <button class="btn btn-primary"
hx-get="{% url 'atlas:user_collections' %}"
hx-target="#user-collections-placeholder"
hx-swap="innerHTML">
Collections to view / take
</button>
<button class="btn btn-outline-primary ms-2"
hx-get="{% url 'atlas:user_related_collections' %}"
hx-target="#user-related-collections-placeholder"
hx-swap="innerHTML">
My collections
</button>
<div id="user-collections-placeholder" class="mt-3"></div>
<div id="user-related-collections-placeholder" class="mt-3"></div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">My cases / collections</h5> <h5 class="card-title">My cases / collections</h5>
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p> <p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a> or <a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">my collections</a>.</p>
<p> <p>
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary"> <a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import <i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
@@ -29,6 +42,7 @@
</p> </p>
</div> </div>
</div> </div>
<!-- user collections will load into the Collections card when requested -->
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
@@ -38,6 +52,7 @@
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0">
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li> <li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li> <li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
<li><a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">My collections</a></li>
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li> <li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
</ul> </ul>
</div> </div>
@@ -50,9 +65,10 @@
<input class="form-control" id="case-search-input" name="q" type="search" <input class="form-control" id="case-search-input" name="q" type="search"
placeholder="Search cases by title or description" placeholder="Search cases by title or description"
hx-get="{% url 'atlas:case_search_partial' %}" hx-get="{% url 'atlas:case_search_partial' %}"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']" hx-trigger="input delay:500ms, keyup[key=='Enter']"
hx-target="#case-search-results" hx-target="#case-search-results"
hx-indicator="#case-search-indicator" /> hx-indicator="#case-search-indicator" />
<a class="btn btn-outline-secondary ms-2" href="{% url 'atlas:case_search_page' %}" title="Open advanced case search">Advanced</a>
<span class="input-group-text" id="case-search-indicator" aria-hidden="true"> <span class="input-group-text" id="case-search-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading</span> <span class="visually-hidden">Loading</span>
@@ -1,7 +1,6 @@
<!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html --> <!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html -->
{% extends "base.html" %} {% extends "base.html" %}
{% load partials %}
{% block content %} {% block content %}
+13 -7
View File
@@ -1,20 +1,24 @@
{% extends "atlas/base.html" %} {% extends "atlas/base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load help_tags %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<h1>Normals</h1> <h1>Normals</h1>
{% help "Help" %}
<p>This page lists all normal cases available in the atlas.</p>
<p>You can use the filter form to narrow down the list based on criteria such as age, modality, or examination type.</p>
<p>Click on a case title to view the case.</p>
<p>See a case that isn't normal? Please add feedback to the case so it can be reviewed.</p>
{% endhelp%}
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-4"> <div class="col-12">
<form method="get" class="form-inline"> <form method="get" class="form-inline">
{% for field in filter.form.visible_fields %} {% crispy filter.form %}
<div class="mb-2">
{{ field.label_tag }}
{{ field }}
</div>
{% endfor %}
<div class="mb-2"> <div class="mb-2">
<button class="btn btn-primary" type="submit">Filter</button> <button class="btn btn-primary" type="submit">Filter</button>
<a class="btn btn-link" href="?">Clear</a> <a class="btn btn-link" href="?">Clear</a>
@@ -59,3 +63,5 @@
</nav> </nav>
</div> </div>
{% endblock %} {% endblock %}
{% block css %}{% endblock %}
@@ -9,18 +9,25 @@
</div> </div>
<div class="mb-1"> <div class="mb-1">
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small> <small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
{% if case.condition %}<span class="badge bg-secondary me-1">{{ case.condition.name }}</span>{% endif %} {% for cond in case.condition.all %}
{% if case.presentation %}<span class="badge bg-secondary me-1">{{ case.presentation.name }}</span>{% endif %} {% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
{% if case.subspecialty %}<span class="badge bg-secondary">{{ case.subspecialty.name }}</span>{% endif %} {% endfor %}
{% for pres in case.presentation.all %}
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
{% endfor %}
{% for ss in case.subspecialty.all %}
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
{% endfor %}
</div> </div>
<div class="mb-1"> <div class="mb-1">
{# show up to three series modalities/examinations if present #} {# show up to three series modalities/examinations if present #}
{% for s in case.series.all|slice:":3" %} {% for s in case.series.all|slice:":3" %}
<span class="badge bg-light text-dark me-1"> {% if s.modality and s.modality.modality or s.examination and s.examination.examination %}
{% if s.modality %}{{ s.modality.modality }}{% endif %} <span class="badge bg-light text-dark me-1" title="Series: {% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %} / {{ s.examination.examination }}{% endif %}">
{% if s.examination %} / {{ s.examination.examination }}{% endif %} {% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %}{% if s.modality and s.modality.modality %} / {% endif %}{{ s.examination.examination }}{% endif %}
</span> </span>
{% endif %}
{% empty %} {% empty %}
<small class="text-muted">No series</small> <small class="text-muted">No series</small>
{% endfor %} {% endfor %}
@@ -4,7 +4,7 @@
{% if case.normal_case %} {% if case.normal_case %}
<span class="badge bg-success">Normal</span> <span class="badge bg-success">Normal</span>
<small class="text-muted">{{ case.normal_case.display_age }}</small> <small class="text-muted">{{ case.normal_case.display_age }}</small>
{% if can_mark_normal %} {% if can_edit %}
<button class="btn btn-sm btn-outline-danger ms-2" <button class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}" hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
hx-target="#normal-toggle-block" hx-target="#normal-toggle-block"
@@ -14,7 +14,7 @@
</button> </button>
{% endif %} {% endif %}
{% else %} {% else %}
{% if can_mark_normal %} {% if can_edit %}
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'atlas:case_normal_form' case.pk %}" hx-get="{% url 'atlas:case_normal_form' case.pk %}"
hx-target="body" hx-target="body"
@@ -0,0 +1,46 @@
{% comment %}Partial listing of a user's collections for HTMX swaps{% endcomment %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Your collections</h5>
<h6 class="mt-3">Available to start</h6>
<ul class="list-unstyled">
{% for collection in available_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
</li>
{% empty %}
<li class="text-muted">No collections available to start.</li>
{% endfor %}
</ul>
<h6 class="mt-3">In progress</h6>
<ul class="list-unstyled">
{% for collection in in_progress_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-warning text-dark ms-2">In progress</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
</li>
{% empty %}
<li class="text-muted">No in-progress collections.</li>
{% endfor %}
</ul>
<h6 class="mt-3">Finished</h6>
<ul class="list-unstyled">
{% for collection in finished_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-success ms-2">Completed</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
</li>
{% empty %}
<li class="text-muted">No finished collections.</li>
{% endfor %}
</ul>
</div>
</div>
@@ -0,0 +1,23 @@
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Related collections</h5>
<ul class="list-unstyled">
{% for item in related_collections %}
<li>
<a href="{% url 'atlas:collection_detail' item.collection.pk %}">{{ item.collection.name }}</a>
{% if item.is_author %}<span class="badge bg-primary ms-2">Author</span>{% endif %}
{% if item.is_marker %}<span class="badge bg-secondary ms-2">Marker</span>{% endif %}
{% if item.collection.description %}<small class="text-muted"> — {{ item.collection.description }}</small>{% endif %}
</li>
{% empty %}
<li class="text-muted">No related collections.</li>
{% endfor %}
</ul>
<div class="mt-3">
<a href="{% url 'atlas:collection_view' %}" class="btn btn-sm btn-outline-primary">View all collections</a>
</div>
</div>
</div>
@@ -0,0 +1,26 @@
<div id="add-collection-inline" class="p-2 border rounded">
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="collection-inline-search" name="q" type="search" placeholder="Search collections"
{% if case %}
hx-get="{% url 'atlas:collection_inline_search' case.pk %}"
{% else %}
hx-get="{% url 'atlas:collection_inline_search' 0 %}"
{% endif %}
hx-trigger="input delay:400ms, keyup[key=='Enter']"
hx-target="#collection-inline-list"
hx-indicator="#collection-inline-indicator">
<span class="input-group-text" id="collection-inline-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</span>
</div>
</div>
<div id="collection-inline-list">
{% include 'atlas/partials/collection_inline_list.html' with collections=recent_collections case=case %}
</div>
<div class="mt-2">
<button class="btn btn-sm btn-secondary" _="on click remove #add-collection-inline">Close</button>
</div>
</div>
@@ -0,0 +1,14 @@
<div class="modal-header">
<h5 class="modal-title">{{ field|capfirst }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if content %}
<div class="pre-whitespace">{{ content|linebreaks }}</div>
{% else %}
<div class="text-muted">No {{ field }} available for this case.</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
@@ -13,19 +13,89 @@
</div> </div>
<div class="mt-2 small text-muted"> <div class="mt-2 small text-muted">
{% if casedetail.case.series.count == 0 %} {% if casedetail.series_count|default:0 == 0 %}
<span class="text-warning">This case has no series / stacks attached</span> <span class="text-warning">This case has no series / stacks attached</span>
{% else %} {% else %}
{{ casedetail.case.series.count }} series {{ casedetail.series_count }} series
{% endif %} {% endif %}
{% if casedetail.question_schema %} {% if casedetail.question_schema %}
&nbsp;&nbsp;<span class="text-success">Questions defined</span> &nbsp;&nbsp;<span class="text-success">Questions defined</span>
{% endif %} {% endif %}
</div> </div>
<div class="btn-group btn-group-sm ms-2" role="group" aria-label="case-field-links">
<!-- History button: opens modal populated from hidden content on page -->
{% if casedetail.case.history %}
<a class="btn btn-outline-secondary" href="#"
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-history').innerHTML;"
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View history">
<i class="bi bi-clock-history text-primary"></i>
</a>
{% else %}
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No history">
<i class="bi bi-clock-history text-muted"></i>
</a>
{% endif %}
<!-- Discussion button -->
{% if casedetail.case.discussion %}
<a class="btn btn-outline-secondary" href="#"
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-discussion').innerHTML;"
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View discussion">
<i class="bi bi-chat-left-text text-primary"></i>
</a>
{% else %}
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No discussion">
<i class="bi bi-chat-left-text text-muted"></i>
</a>
{% endif %}
<!-- Report button -->
{% if casedetail.case.report %}
<a class="btn btn-outline-secondary" href="#"
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-report').innerHTML;"
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View report">
<i class="bi bi-file-earmark-text text-primary"></i>
</a>
{% else %}
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No report">
<i class="bi bi-file-earmark-text text-muted"></i>
</a>
{% endif %}
</div>
</div> </div>
<div class="d-flex align-items-center ms-3"> <div class="d-flex align-items-center ms-3">
{% include 'atlas/partials/casedetails_management_links.html' %} {% include 'atlas/partials/casedetails_management_links.html' %}
</div> </div>
{# Pre-render hidden modal content for this case to avoid HTMX calls #}
<div class="d-none">
<div id="case-field-{{ casedetail.case.pk }}-history">
<div class="modal-header">
<h5 class="modal-title">History</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<br/>
{% if casedetail.get_history %}
<div class="pre-whitespace">{{ casedetail.get_history|linebreaks }}</div>
{% else %}
<div class="text-muted">No history available for this case.</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
<div id="case-field-{{ casedetail.case.pk }}-discussion">
{% include 'atlas/partials/case_field_modal.html' with field='discussion' content=casedetail.case.discussion %}
</div>
<div id="case-field-{{ casedetail.case.pk }}-report">
{% include 'atlas/partials/case_field_modal.html' with field='report' content=casedetail.case.report %}
</div>
</div>
</li> </li>
@@ -0,0 +1,26 @@
<div id="case-resources-list">
{% if case.caseresource_set.exists %}
<ul class="list-unstyled mb-0">
{% for cr in case.caseresource_set.all %}
<li class="py-1 d-flex justify-content-between align-items-center" data-caseresource-id="{{ cr.id }}" data-pre="{{ cr.pre_review|yesno:'1,0' }}">
<div>
<a href="{% url 'atlas:resource_detail' cr.resource.pk %}">{{ cr.resource.name }}</a>
{% if cr.pre_review %}
<span class="badge bg-info text-dark ms-2 small">pre</span>
{% else %}
<span class="badge bg-secondary text-light ms-2 small">post</span>
{% endif %}
</div>
{% if can_edit %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" title="Toggle pre/post" hx-post="{% url 'atlas:case_toggle_resource_pre' pk=case.pk %}" hx-vals='{"resource_id": "{{ cr.resource.pk }}"}' hx-swap="outerHTML" hx-target="#case-resources-list">Toggle</button>
<button type="button" class="btn btn-sm btn-outline-danger" hx-post="{% url 'atlas:case_detach_resource' pk=case.pk %}" hx-vals='{"resource_id": "{{ cr.resource.pk }}"}' hx-swap="outerHTML" hx-target="#case-resources-list">Remove</button>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-muted">No resources attached to this case.</div>
{% endif %}
</div>
@@ -1,26 +1,31 @@
{# Search results #} {# Search results #}
{% if q %} {% if cases and cases|length > 0 %}
{% if cases and cases.exists %}
<div class="list-group mb-2"> <div class="list-group mb-2">
{% for case in cases %} {% for case in cases %}
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}"> <a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action">
<div class="flex-fill me-3"> <div class="d-flex w-100 justify-content-between">
<div class="fw-semibold">{{ case.title }}</div> <h6 class="mb-1">{{ case.title }}</h6>
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div> <small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
</div> </div>
<div class="mb-1">
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend"> <small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
{% csrf_token %} {% for cond in case.condition.all %}
<input type="hidden" name="case" value="{{ case.pk }}"> {% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
<button type="submit" class="btn btn-sm btn-primary">Add</button> {% endfor %}
</form> {% for pres in case.presentation.all %}
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
{% endfor %}
{% for ss in case.subspecialty.all %}
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
{% endfor %}
</div> </div>
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
</a>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="text-muted small mb-2">No cases found.</div> <div class="text-muted small mb-2">No cases found.</div>
{% endif %} {% endif %}
{% endif %}
{# Recent cases quick-add (rendered below search results) #} {# Recent cases quick-add (rendered below search results) #}
{% if recent_cases and recent_cases|length > 0 %} {% if recent_cases and recent_cases|length > 0 %}
@@ -33,11 +38,15 @@
<div class="fw-semibold">{{ case.title }}</div> <div class="fw-semibold">{{ case.title }}</div>
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div> <div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
</div> </div>
{% if collection %}
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend"> <form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="case" value="{{ case.pk }}"> <input type="hidden" name="case" value="{{ case.pk }}">
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button> <button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
</form> </form>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' case.pk %}">View</a>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -1,14 +1,14 @@
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions"> <div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display"> <a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' collection.pk casedetail.case.pk %}" title="Setup default display">
<i class="bi bi-display"></i> <i class="bi bi-display"></i>
</a> </a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details"> <a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' collection.pk casedetail.case.pk %}" title="Case details">
<i class="bi bi-info-square"></i> <i class="bi bi-info-square"></i>
</a> </a>
{% if collection.collection_type == "QUE" %} {% if collection.collection_type == "QUE" %}
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions"> <a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" collection.pk casedetail.case.pk %}' title="Manage questions">
{% if casedetail.question_schema %} {% if casedetail.question_schema %}
<i class="bi bi-question-square text-success"></i> <i class="bi bi-question-square text-success"></i>
{% else %} {% else %}
@@ -18,12 +18,14 @@
{% endif %} {% endif %}
{% if casedetail.case.previous_case %} {% if casedetail.case.previous_case %}
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors"> <a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" collection.pk casedetail.case.pk %}' title="Manage priors">
<i class="bi bi-link-45deg"></i> <i class="bi bi-link-45deg"></i>
</a> </a>
{% endif %} {% endif %}
</div> </div>
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator"> <div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
{% if casedetail.default_viewerstate %} {% if casedetail.default_viewerstate %}
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined"> <button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
@@ -34,7 +36,7 @@
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #} {# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
{% if can_edit %} {% if can_edit %}
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk casedetail.collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?"> <form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?">
<button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection"> <button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
@@ -0,0 +1,25 @@
<ul class="list-group" id="collection-inline-list-items">
{% if collections and collections|length > 0 %}
{% for collection in collections %}
<li class="list-group-item d-flex justify-content-between align-items-center" data-collection-pk="{{ collection.pk }}">
<div class="flex-fill">
<div class="d-flex align-items-baseline">
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}" class="me-2">{{ collection.name }}</a>
<small class="text-muted">{% if collection.author.all %}By {{ collection.author.all|join:", " }}{% endif %}</small>
</div>
{% if collection.description %}<div class="small text-muted">{{ collection.description|truncatechars:120 }}</div>{% endif %}
</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' pk=collection.pk %}" target="_blank" rel="noopener">Open</a>
{% if case %}
<button class="btn btn-sm btn-primary" hx-post="{% url 'atlas:add_collection_to_case_inline' case.pk collection.pk %}" hx-target="#case-{{ case.pk }}-collections" hx-swap="beforeend">Add</button>
{% else %}
<span class="text-muted small">Open a case to add</span>
{% endif %}
</div>
</li>
{% endfor %}
{% else %}
<li class="list-group-item text-muted">No collections found.</li>
{% endif %}
</ul>
@@ -0,0 +1,12 @@
<li id="collection-{{ collection.pk }}">
<a href="{% url 'atlas:collection_detail' pk=collection.pk %}">{{ collection.name }}</a>
{% if can_edit %}
<button class="btn btn-sm btn-outline-danger 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>
@@ -0,0 +1,146 @@
<div class="resource-media-wrapper" id="resource_media_wrapper_{{ resource.pk }}" data-src="{{ src|escape }}">
<noscript>
<div><a href="{{ src }}" target="_blank" rel="noopener">Open media</a></div>
</noscript>
</div>
<script>
(function(){
var wrapperId = 'resource_media_wrapper_{{ resource.pk }}';
if (document.getElementById(wrapperId).dataset._inited) return;
var wrap = document.getElementById(wrapperId);
if (!wrap) return;
wrap.dataset._inited = '1';
var src = wrap.getAttribute('data-src');
if (!src) return;
var lower = src.toLowerCase();
function createImg(src) {
var img = document.createElement('img');
img.src = src;
img.className = 'img-fluid resource-media-element rounded';
img.style.maxHeight = '60vh';
img.style.objectFit = 'contain';
img.style.cursor = 'zoom-in';
return img;
}
function createVideo(src) {
var video = document.createElement('video');
video.className = 'resource-media-element rounded';
video.controls = true;
video.preload = 'metadata';
video.style.maxWidth = '100%';
video.style.maxHeight = '60vh';
video.style.objectFit = 'contain';
var srcEl = document.createElement('source');
srcEl.src = src;
video.appendChild(srcEl);
return video;
}
function createIframe(src, embedUrl) {
var iframe = document.createElement('iframe');
iframe.src = embedUrl || src;
iframe.setAttribute('frameborder','0');
iframe.setAttribute('allowfullscreen','');
iframe.className = 'resource-media-element rounded';
iframe.style.width = '100%';
iframe.style.height = '60vh';
return iframe;
}
var el = null;
// YouTube
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) {
// build embed url
var videoId = null;
try {
var m = src.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
if (m && m[1]) videoId = m[1];
} catch(e){}
var embed = videoId ? ('https://www.youtube.com/embed/' + videoId) : src;
el = createIframe(src, embed);
} else if (lower.indexOf('vimeo.com') !== -1) {
var m2 = src.match(/vimeo\.com\/(\d+)/);
var embed2 = m2 && m2[1] ? ('https://player.vimeo.com/video/' + m2[1]) : src;
el = createIframe(src, embed2);
} else if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) {
el = createVideo(src);
} else if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) {
el = createImg(src);
} else {
// unknown: try image first, fallback to link
el = createImg(src);
// if image fails to load, replace with link
el.addEventListener('error', function(){
var a = document.createElement('a');
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource';
wrap.innerHTML = ''; wrap.appendChild(a);
});
}
// append preview element
if (el) {
wrap.appendChild(el);
// click -> open modal/full-size
el.addEventListener('click', function(e) {
e.preventDefault();
// create modal container if not present
var modalId = 'resource_media_modal_{{ resource.pk }}';
var existing = document.getElementById(modalId);
if (!existing) {
var modalDiv = document.createElement('div');
modalDiv.className = 'modal fade resource-media-modal';
modalDiv.id = modalId;
modalDiv.tabIndex = -1;
modalDiv.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content bg-dark"><div class="modal-body p-0 text-center"></div></div></div>';
document.body.appendChild(modalDiv);
}
var modalEl = document.getElementById(modalId);
var body = modalEl.querySelector('.modal-body');
body.innerHTML = '';
// create full-size node depending on type
if (el.tagName.toLowerCase() === 'img') {
var big = document.createElement('img');
big.src = src;
big.style.maxWidth = '95vw';
big.style.maxHeight = '95vh';
big.style.objectFit = 'contain';
big.className = 'img-fluid';
body.appendChild(big);
} else if (el.tagName.toLowerCase() === 'video') {
var bigv = document.createElement('video');
bigv.controls = true; bigv.autoplay = false; bigv.style.maxWidth = '95vw'; bigv.style.maxHeight = '95vh'; bigv.style.objectFit = 'contain'; bigv.className = 'w-100';
var s = document.createElement('source'); s.src = src; bigv.appendChild(s);
body.appendChild(bigv);
} else if (el.tagName.toLowerCase() === 'iframe') {
var frame = document.createElement('iframe');
frame.src = el.src;
frame.setAttribute('frameborder','0');
frame.setAttribute('allowfullscreen','');
frame.style.width = '95vw'; frame.style.height = '80vh';
body.appendChild(frame);
} else {
var a = document.createElement('a'); a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource'; body.appendChild(a);
}
// show modal using Bootstrap if available
try {
var bsModal = new bootstrap.Modal(modalEl);
bsModal.show();
} catch (e) {
// fallback: open in new tab
window.open(src, '_blank', 'noopener');
}
});
}
})();
</script>
<style>
.resource-media-wrapper .resource-media-element { display: inline-block; }
.resource-media-wrapper img.resource-media-element { max-width: 100%; height: auto; }
.resource-media-wrapper video.resource-media-element { max-width: 100%; height: auto; }
.resource-media-modal .modal-content { background: rgba(0,0,0,0.95); }
</style>
@@ -0,0 +1,43 @@
<div id="resource-search-panel" class="mb-2">
<div id="resource-search-form" class="row g-2">
<input type="hidden" name="case_id" value="{{ case.pk }}" />
<div class="col-auto" style="flex: 1 1 300px;">
<input type="search" id="resource-search-q" name="q" class="form-control form-control-sm" placeholder="Search resources by name, description or URL"
hx-get="{% url 'atlas:resource_search_widget' %}"
hx-trigger="input delay:500ms"
hx-target="#resource-search-results"
hx-swap="innerHTML"
hx-include="#resource-search-panel"
hx-indicator=".htmx-indicator">
</div>
<div class="htmx-indicator col-auto">
<img src="/img/bars.svg" alt=""/> Searching...
</div>
<div class="col-auto">
<select name="site" class="form-select form-select-sm">
<option value="">All sites</option>
{% for s in site_queryset %}
<option value="{{ s.pk }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="subspecialty" class="form-select form-select-sm">
<option value="">All subspecialties</option>
{% for sp in subspecialty_queryset %}
<option value="{{ sp.pk }}">{{ sp.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="resource-open-access-{{ case.pk }}" name="open_access" value="1">
<label class="form-check-label small" for="resource-open-access-{{ case.pk }}">Open</label>
</div>
</div>
</div>
<div id="resource-search-results" class="mt-2"></div>
</div>
<!-- pure HTMX: no JS fallback; hx-trigger on the input uses input+debounce -->
@@ -0,0 +1,39 @@
{% if results %}
<div id="resource-search-results" hx-swap-oob="true">
<ul class="list-unstyled mb-0" id="resource_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-resource-id="{{ item.id }}">
<div class="flex-grow-1">
<a href="{% url 'atlas:resource_detail' item.pk %}" target="_blank">{{ item.name|escape }}</a>
<div class="small text-muted">{{ item.get_display|safe }}</div>
</div>
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-sm btn-success resource-attach-btn"
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "0"}'
hx-swap="outerHTML"
hx-target="#case-resources-list">
Attach
</button>
<button
type="button"
class="btn btn-sm btn-outline-success resource-attach-pre-btn"
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "1"}'
hx-swap="outerHTML"
hx-target="#case-resources-list">
Attach (pre)
</button>
</div>
</li>
{% endfor %}
</ul>
<div class="mt-2 text-end">
<small class="text-muted">{{ results|length }} results</small>
</div>
</div>
{% else %}
<div id="resource-search-results" hx-swap-oob="true"><em>No resources found</em></div>
{% endif %}
@@ -0,0 +1,10 @@
<div class="alert alert-success">
<strong>Truncate complete.</strong>
<div class="small mt-1">Removed {{ images_removed|length }} images from series {{ series.pk }}.</div>
</div>
{% if images_removed %}
<details class="mt-2">
<summary class="small">Show removed image IDs</summary>
<div class="small mt-1">{{ images_removed|join:", " }}</div>
</details>
{% endif %}
@@ -14,6 +14,22 @@
<small class="text-muted">by {{ resource.get_authors|default:"Unknown" }}</small> <small class="text-muted">by {{ resource.get_authors|default:"Unknown" }}</small>
</div> </div>
<div class="card-body"> <div class="card-body">
{# Media preview: image or video autosized, clickable to open full-size modal #}
{% if resource.file or resource.url %}
<div class="resource-media-preview mb-3 text-center">
{% comment %}Prefer local file if present, otherwise try URL{% endcomment %}
{% if resource.file %}
{% with src=resource.file.url %}
{% include 'atlas/partials/resource_media.html' with src=src resource=resource %}
{% endwith %}
{% else %}
{% with src=resource.url %}
{% include 'atlas/partials/resource_media.html' with src=src resource=resource %}
{% endwith %}
{% endif %}
</div>
{% endif %}
{% if resource.description %} {% if resource.description %}
<p class="card-text">{{ resource.description|linebreaksbr }}</p> <p class="card-text">{{ resource.description|linebreaksbr }}</p>
{% endif %} {% endif %}
+3 -1
View File
@@ -252,7 +252,9 @@
} }
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) { if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#truncate-output") const truncateTemplate = `{% url 'atlas:series_truncate' series.id 0 0 %}`; // e.g. /atlas/series/920/truncate/0/0/
const truncateUrl = truncateTemplate.replace('/0/0/', `/${lower_bound}/${upper_bound}/`);
htmx.ajax("GET", truncateUrl, "#truncate-output")
} }
@@ -27,6 +27,16 @@
{% endfor %} {% endfor %}
</ul> </ul>
{# Include cases referenced via CaseDisplaySet that list this structure #}
{% if structure.casedisplayset_set.all %}
<h4>Cases via Display Sets</h4>
<ul>
{% for ds in structure.casedisplayset_set.all %}
<li>Case: {{ ds.case.get_link }} (Display set: {{ ds.name }})</li>
{% endfor %}
</ul>
{% endif %}
{% if structure.sbas_questions.all %} {% if structure.sbas_questions.all %}
<h4>Related SBA Questions</h4> <h4>Related SBA Questions</h4>
<ul> <ul>
+6 -45
View File
@@ -3,51 +3,12 @@
{% block content %} {% block content %}
<h2>Collections</h2> <h2>Collections</h2>
The following collections are available for you to view / take. If you received a directly link to a collection it will not appear here unless you have started it. <p>
The following collections are available for you to view / take. If you received a direct
link to a collection it will not appear here unless you have started it.
</p>
{# Reuse the HTMX-friendly partial so the same markup is used for full-page and HTMX responses #}
<h3>Available to start</h3> {% include 'atlas/partials/_user_collections.html' %}
<ul>
{% for collection in available_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
{% if collection.description %}
<small class="text-muted">— {{ collection.description }}</small>
{% endif %}
</li>
{% empty %}
<li>No collections available to start.</li>
{% endfor %}
</ul>
<h3>In progress</h3>
<ul>
{% for collection in in_progress_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-warning text-dark">In progress</span>
{% if collection.description %}
<small class="text-muted">— {{ collection.description }}</small>
{% endif %}
</li>
{% empty %}
<li>No in-progress collections.</li>
{% endfor %}
</ul>
<h3>Finished</h3>
<ul>
{% for collection in finished_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-success">Completed</span>
{% if collection.description %}
<small class="text-muted">— {{ collection.description }}</small>
{% endif %}
</li>
{% empty %}
<li>No finished collections.</li>
{% endfor %}
</ul>
{% endblock %} {% endblock %}
+147 -1
View File
@@ -18,7 +18,14 @@
<body> <body>
<p>Awating load request</p> <p>Awating load request</p>
This page will display series when loaded from other pages. This page will display series when loaded from other pages.
<div id="viewerContainer" style="width:100%; height:100%; position:relative;">
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px;" ></iframe> <iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px;" ></iframe>
<div id="resourceDisplay" style="display:none; position:absolute; inset:0; background:#000; align-items:center; justify-content:center; z-index:100000;">
<div id="resourceInner" style="position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center;">
</div>
<button id="resourceClose" style="position:absolute; top:8px; right:8px; z-index:9999;" class="btn btn-sm btn-light">Close</button>
</div>
</div>
<script type="text/javascript"> <script type="text/javascript">
var win2 = false; var win2 = false;
@@ -27,12 +34,151 @@
document.title = "Case: {{current_case.title}}"; document.title = "Case: {{current_case.title}}";
console.log('ready'); console.log('ready');
const bc = new BroadcastChannel("viva_load"); const bc = new BroadcastChannel("viva_load");
function isMediaUrl(url) {
if (!url) return false;
const lower = url.toLowerCase();
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
return false;
}
function showResource(url) {
const type = isMediaUrl(url);
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
inner.innerHTML = '';
if (!type) {
// fallback: open in iframe
document.getElementById('viewer').src = url;
disp.style.display = 'none';
return;
}
// create a transform wrapper so we can pan/zoom the content uniformly
const transformWrap = document.createElement('div');
transformWrap.style.display = 'flex';
transformWrap.style.alignItems = 'center';
transformWrap.style.justifyContent = 'center';
transformWrap.style.width = '100%';
transformWrap.style.height = '100%';
transformWrap.style.overflow = 'hidden';
transformWrap.style.touchAction = 'none';
transformWrap.style.position = 'relative';
if (type === 'image') {
const img = document.createElement('img');
img.src = url;
img.style.maxWidth = '95%';
img.style.maxHeight = '95%';
img.style.objectFit = 'contain';
img.style.cursor = 'grab';
img.draggable = false;
transformWrap.appendChild(img);
} else if (type === 'video') {
const v = document.createElement('video');
v.controls = true; v.autoplay = false; v.style.maxWidth = '95%'; v.style.maxHeight = '95%'; v.style.objectFit = 'contain';
v.style.cursor = 'grab';
const s = document.createElement('source'); s.src = url; v.appendChild(s); transformWrap.appendChild(v);
} else if (type === 'embed') {
let embed = url;
try {
const m = url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
if (m && m[1]) embed = 'https://www.youtube.com/embed/' + m[1];
else if (url.indexOf('vimeo.com') !== -1) {
const m2 = url.match(/vimeo\.com\/(\d+)/);
if (m2 && m2[1]) embed = 'https://player.vimeo.com/video/' + m2[1];
}
} catch(e){}
const iframe = document.createElement('iframe');
iframe.src = embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen','');
iframe.style.width = '95%'; iframe.style.height = '85%'; iframe.style.maxWidth = '95%'; iframe.style.maxHeight = '95%';
transformWrap.appendChild(iframe);
}
inner.appendChild(transformWrap);
// Setup zoom/pan controls for the transformWrap
(function enablePanZoom(target){
let scale = 1; let minScale = 0.5; let maxScale = 6; let startX=0, startY=0; let translateX=0, translateY=0; let dragging=false;
const content = target.firstElementChild;
function updateTransform(){
if (content) content.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
}
// Wheel to zoom (allow wheel-only zoom while overlay is shown)
target.addEventListener('wheel', function(e){
// require overlay active (safety) - if not active, ignore
const dispEl = document.getElementById('resourceDisplay');
if (!dispEl || dispEl.style.display === 'none') return;
e.preventDefault();
const delta = -e.deltaY || e.wheelDelta;
const zoomFactor = delta > 0 ? 1.12 : 0.9;
const newScale = Math.min(maxScale, Math.max(minScale, scale * zoomFactor));
// adjust translate to zoom to mouse position
const rect = content.getBoundingClientRect();
const mx = e.clientX - rect.left; const my = e.clientY - rect.top;
const dx = (mx - rect.width/2); const dy = (my - rect.height/2);
// compute scale ratio
const ratio = newScale/scale - 1;
translateX -= dx * ratio;
translateY -= dy * ratio;
scale = newScale; updateTransform();
}, {passive:false});
// Pointer drag to pan
target.addEventListener('pointerdown', function(e){
dragging = true; startX = e.clientX; startY = e.clientY; target.setPointerCapture(e.pointerId); content.style.cursor = 'grabbing';
});
target.addEventListener('pointermove', function(e){
if (!dragging) return; e.preventDefault(); const dx = e.clientX - startX; const dy = e.clientY - startY; startX = e.clientX; startY = e.clientY; translateX += dx; translateY += dy; updateTransform();
});
target.addEventListener('pointerup', function(e){ dragging = false; try{ target.releasePointerCapture(e.pointerId);}catch(_){} if (content) content.style.cursor = 'grab'; });
target.addEventListener('pointercancel', function(e){ dragging=false; if (content) content.style.cursor='grab'; });
// double click to reset
target.addEventListener('dblclick', function(e){ scale = 1; translateX = 0; translateY = 0; updateTransform(); });
// Escape to close
document.addEventListener('keydown', function(ev){ if (ev.key === 'Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
})(transformWrap);
}
disp.style.display = 'flex';
}
// Centralized hide helper
function hideResource() {
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
if (disp) disp.style.display = 'none';
if (inner) inner.innerHTML = '';
}
// Robust close: delegate clicks on the overlay so pointer capture doesn't block the button
(function(){
const disp = document.getElementById('resourceDisplay');
if (!disp) return;
disp.addEventListener('click', function(e){
// if click was on the close button (or inside it), hide
const close = e.target.closest && e.target.closest('#resourceClose');
if (close) { hideResource(); }
});
})();
bc.onmessage = (event) => { bc.onmessage = (event) => {
console.log("message received"); console.log("message received");
console.log(event); console.log(event);
// Always hide any existing resource overlay on a new load
try { hideResource(); } catch (e) { /* ignore if not available */ }
if (event.data.type == "open") { if (event.data.type == "open") {
// If URL looks like an image/video/embed, render in the resource panel
if (event.data.url && isMediaUrl(event.data.url)) {
showResource(event.data.url);
bc.postMessage({"type": "open-complete", "url" : event.data.url});
return;
}
document.getElementById("viewer").src = event.data.url; document.getElementById("viewer").src = event.data.url;
//document.querySelectorAll("[data-cy='seriesList-btn']")[0].click()
bc.postMessage({"type": "open-complete", "url" : event.data.url}); bc.postMessage({"type": "open-complete", "url" : event.data.url});
} }
}; };
+76
View File
@@ -73,6 +73,12 @@
data-auto-cache-stack="true" data-auto-cache-stack="true"
></div> ></div>
<!-- Overlay for non-DICOM resources (images/videos/embeds) -->
<div id="resourceDisplay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.96); z-index:100000; align-items:center; justify-content:center;">
<div id="resourceInner" style="max-width:95vw; max-height:95vh; display:flex; align-items:center; justify-content:center;"></div>
<button id="resourceClose" style="position:fixed; top:12px; right:12px; z-index:2001;" class="btn btn-sm btn-light">Close</button>
</div>
<script type="text/javascript"> <script type="text/javascript">
var win2 = false; var win2 = false;
@@ -80,10 +86,80 @@
document.title = "Case: {{current_case.title}}"; document.title = "Case: {{current_case.title}}";
console.log('ready'); console.log('ready');
const bc = new BroadcastChannel("viva_load"); const bc = new BroadcastChannel("viva_load");
// Centralized hide helper and delegated close handling for local viewer
function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; }
(function(){ const disp=document.getElementById('resourceDisplay'); if(!disp) return; disp.addEventListener('click', function(e){ const close = e.target.closest && e.target.closest('#resourceClose'); if (close) hideResourceLocal(); }); })();
bc.onmessage = (event) => { bc.onmessage = (event) => {
// Ensure any existing overlay is closed on new load
try { hideResourceLocal(); } catch(_) {}
console.log("message received"); console.log("message received");
console.log(event); console.log(event);
if (event.data.type == "open") { if (event.data.type == "open") {
// If a resource URL is provided (image/video/embed), show it in overlay
function isMediaUrl(url) {
if (!url) return false;
const lower = url.toLowerCase();
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
return false;
}
function showResource(url) {
const type = isMediaUrl(url);
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
inner.innerHTML = '';
if (!type) return false;
const transformWrap = document.createElement('div');
transformWrap.style.display = 'flex'; transformWrap.style.alignItems = 'center'; transformWrap.style.justifyContent = 'center';
transformWrap.style.width = '100%'; transformWrap.style.height = '100%'; transformWrap.style.overflow = 'hidden'; transformWrap.style.touchAction = 'none';
if (type === 'image') {
const img = document.createElement('img'); img.src = url; img.style.maxWidth = '95%'; img.style.maxHeight = '95%'; img.style.objectFit = 'contain'; img.style.cursor='grab'; img.draggable=false; transformWrap.appendChild(img);
} else if (type === 'video') {
const v = document.createElement('video'); v.controls=true; v.autoplay=false; v.style.maxWidth='95%'; v.style.maxHeight='95%'; v.style.objectFit='contain'; const s=document.createElement('source'); s.src=url; v.appendChild(s); transformWrap.appendChild(v);
} else if (type === 'embed') {
let embed = url; try{ const m=url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); if (m&&m[1]) embed='https://www.youtube.com/embed/'+m[1]; else if (url.indexOf('vimeo.com')!==-1){ const m2=url.match(/vimeo\.com\/(\d+)/); if(m2&&m2[1]) embed='https://player.vimeo.com/video/'+m2[1]; } }catch(e){}
const iframe=document.createElement('iframe'); iframe.src=embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen',''); iframe.style.width='95%'; iframe.style.height='85%'; transformWrap.appendChild(iframe);
}
inner.appendChild(transformWrap);
// pan/zoom
(function enablePanZoom(target){
let scale=1, minScale=0.5, maxScale=6, startX=0, startY=0, translateX=0, translateY=0, dragging=false;
const content=target.firstElementChild;
function update(){ if(content) content.style.transform=`translate(${translateX}px, ${translateY}px) scale(${scale})`; }
target.addEventListener('wheel', function(e){
const dispEl = document.getElementById('resourceDisplay');
if (!dispEl || dispEl.style.display === 'none') return;
e.preventDefault();
const delta=-e.deltaY||e.wheelDelta; const zoom=delta>0?1.12:0.9; const newScale=Math.min(maxScale, Math.max(minScale, scale*zoom)); const rect=content.getBoundingClientRect(); const mx=e.clientX-rect.left, my=e.clientY-rect.top; const ratio=newScale/scale-1; translateX -= (mx-rect.width/2)*ratio; translateY -= (my-rect.height/2)*ratio; scale=newScale; update();
}, {passive:false});
target.addEventListener('pointerdown', function(e){ dragging=true; startX=e.clientX; startY=e.clientY; target.setPointerCapture(e.pointerId); if(content) content.style.cursor='grabbing'; });
target.addEventListener('pointermove', function(e){ if(!dragging) return; e.preventDefault(); const dx=e.clientX-startX, dy=e.clientY-startY; startX=e.clientX; startY=e.clientY; translateX+=dx; translateY+=dy; update(); });
target.addEventListener('pointerup', function(e){ dragging=false; try{target.releasePointerCapture(e.pointerId)}catch(_){} if(content) content.style.cursor='grab'; });
target.addEventListener('dblclick', function(){ scale=1; translateX=0; translateY=0; update(); });
document.addEventListener('keydown', function(ev){ if(ev.key==='Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
})(transformWrap);
disp.style.display='flex'; return true;
}
if (event.data.url && isMediaUrl(event.data.url)) {
// show resource overlay and do not attempt to mount DICOM viewer
if (showResource(event.data.url)) {
bc.postMessage({"type": "open-complete-local"});
return;
}
}
// Hide modal when data is loaded // Hide modal when data is loaded
document.getElementById("awaiting-modal").style.display = "none"; document.getElementById("awaiting-modal").style.display = "none";
document.getElementById("root").dataset.images = event.data.images; document.getElementById("root").dataset.images = event.data.images;
+34
View File
@@ -37,6 +37,7 @@ urlpatterns = [
path("collection/case_search", views.case_search, name="case_search"), path("collection/case_search", views.case_search, name="case_search"),
path("collection/", views.CollectionView.as_view(), name="collection_view"), path("collection/", views.CollectionView.as_view(), name="collection_view"),
path("collection/user", views.user_collections, name="user_collections"), path("collection/user", views.user_collections, name="user_collections"),
path("collection/related", views.user_related_collections, name="user_related_collections"),
path("uploads", views.user_uploads, name="user_uploads"), path("uploads", views.user_uploads, name="user_uploads"),
path("uploads/<int:user_pk>/user", views.other_user_uploads, name="other_user_uploads"), path("uploads/<int:user_pk>/user", views.other_user_uploads, name="other_user_uploads"),
path("uploads/all", views.all_uploads, name="all_uploads"), path("uploads/all", views.all_uploads, name="all_uploads"),
@@ -49,6 +50,9 @@ urlpatterns = [
path("resource/<int:pk>/view", views.resource_view, name="resource_view"), path("resource/<int:pk>/view", views.resource_view, name="resource_view"),
path("resource/<int:pk>/update", views.ResourceUpdate.as_view(), name="resource_update"), path("resource/<int:pk>/update", views.ResourceUpdate.as_view(), name="resource_update"),
path("resource/<int:pk>/delete", views.ResourceDelete.as_view(), name="resource_delete"), path("resource/<int:pk>/delete", views.ResourceDelete.as_view(), name="resource_delete"),
path("resource-search-panel/", views.resource_search_panel, name="resource_search_panel"),
path("resource-search-widget/", views.resource_search_widget, name="resource_search_widget"),
path( path(
"resource/create", "resource/create",
views.ResourceCreate.as_view(), views.ResourceCreate.as_view(),
@@ -177,6 +181,11 @@ urlpatterns = [
views.collection_case_details, views.collection_case_details,
name="collection_case_details", name="collection_case_details",
), ),
# HTMX endpoints for attaching/detaching resources to a case
path("case/<int:pk>/attach-resource/", views.case_attach_resource, name="case_attach_resource"),
path("case/<int:pk>/detach-resource/", views.case_detach_resource, name="case_detach_resource"),
path("case/<int:pk>/toggle-resource-pre/", views.case_toggle_resource_pre, name="case_toggle_resource_pre"),
# Backwards-compatible redirect from legacy case_id-style URLs # Backwards-compatible redirect from legacy case_id-style URLs
path( path(
"collection/<int:exam_id>/case/id/<int:case_id>/details", "collection/<int:exam_id>/case/id/<int:case_id>/details",
@@ -218,6 +227,8 @@ urlpatterns = [
views.collection_case_dicom_json_review, views.collection_case_dicom_json_review,
name="collection_case_dicom_json_review", name="collection_case_dicom_json_review",
), ),
# Partial endpoint to return a single case field (history/discussion/report) for modal display
path( path(
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json/review", "collection/<int:exam_id>/case/id/<int:case_id>/dicom_json/review",
views.redirect_collection_case_dicom_json_review_by_id, views.redirect_collection_case_dicom_json_review_by_id,
@@ -354,6 +365,11 @@ urlpatterns = [
views.GenericExamViews.exam_json_edit, views.GenericExamViews.exam_json_edit,
name="exam_json_edit", name="exam_json_edit",
), ),
path(
"collection/<int:pk>/cid_bulk_edit",
views.GenericExamViews.exam_cid_bulk_edit,
name="exam_cid_bulk_edit",
),
path( path(
"collection/<int:pk>/dicom_json", "collection/<int:pk>/dicom_json",
views.collection_dicom_json, views.collection_dicom_json,
@@ -364,6 +380,7 @@ urlpatterns = [
path("case/<int:pk>/display_sets/add", views.case_displaysets_add, name="case_displaysets_add"), path("case/<int:pk>/display_sets/add", views.case_displaysets_add, name="case_displaysets_add"),
path("<int:pk>/display_sets/edit", views.case_displaysets_edit, name="case_displaysets_edit"), path("<int:pk>/display_sets/edit", views.case_displaysets_edit, name="case_displaysets_edit"),
path("<int:pk>/display_sets/detail", views.case_displaysets_detail, name="case_displaysets_detail"), path("<int:pk>/display_sets/detail", views.case_displaysets_detail, name="case_displaysets_detail"),
path("<int:pk>/display_sets/modal", views.case_displaysets_modal, name="case_displaysets_modal"),
path("<int:pk>/display_sets/delete", views.case_displaysets_delete, name="case_displaysets_delete"), path("<int:pk>/display_sets/delete", views.case_displaysets_delete, name="case_displaysets_delete"),
path( path(
"case/<int:pk>/dicom_json", "case/<int:pk>/dicom_json",
@@ -407,6 +424,7 @@ urlpatterns = [
path("categories/", views.categories_list, name="categories_list"), path("categories/", views.categories_list, name="categories_list"),
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"), path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
path("search/cases/", views.case_search_partial, name="case_search_partial"), path("search/cases/", views.case_search_partial, name="case_search_partial"),
path("search/cases/page/", views.case_search_page, name="case_search_page"),
path("normals/", views.NormalCaseList.as_view(), name="normals_list"), path("normals/", views.NormalCaseList.as_view(), name="normals_list"),
path("condition/<int:pk>", views.condition_detail, name="condition_detail"), path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
path( path(
@@ -457,6 +475,11 @@ urlpatterns = [
# TODO: case context series viewing (so that we can view series in the context of a case) # TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"), path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"), path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
path(
"series/<int:series_id>/truncate/<int:start>/<int:end>/",
views.series_truncate_htmx,
name="series_truncate",
),
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"), path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"), path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"), path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
@@ -524,6 +547,16 @@ urlpatterns = [
views.add_collection_to_case_form, views.add_collection_to_case_form,
name="case_collection_form", name="case_collection_form",
), ),
path(
"case/<int:case_id>/collection-inline-search/",
views.collection_inline_search,
name="collection_inline_search",
),
path(
"case/<int:case_id>/add-collection/<int:collection_id>/",
views.add_collection_to_case_inline,
name="add_collection_to_case_inline",
),
path("case/<int:pk>/clone", views.AtlasClone.as_view(), name="case_clone"), path("case/<int:pk>/clone", views.AtlasClone.as_view(), name="case_clone"),
path("case/<int:pk>/clone/next", views.AtlasCloneNext.as_view(), name="case_clone_next"), path("case/<int:pk>/clone/next", views.AtlasCloneNext.as_view(), name="case_clone_next"),
# path("verified/", views.verified, name="verified"), # path("verified/", views.verified, name="verified"),
@@ -621,4 +654,5 @@ urlpatterns = [
views.linked_cases_overview, views.linked_cases_overview,
name="linked_cases_overview", name="linked_cases_overview",
), ),
path("cases/by_size", views.cases_by_size, name="cases_by_size"),
] ]
+588 -49
View File
@@ -30,6 +30,8 @@ from django.views.generic.detail import DetailView
import pydicom import pydicom
from pydicom.uid import generate_uid from pydicom.uid import generate_uid
from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMixin from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMixin
from django.contrib.admin.views.decorators import staff_member_required
from django.core.paginator import Paginator
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from django.views.generic import ListView from django.views.generic import ListView
@@ -46,7 +48,7 @@ from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from generic.models import CidUser, CidUserExam, CimarCase from generic.models import CidUser, CidUserExam, CimarCase, Site
from atlas.helpers import get_cases_available_to_user from atlas.helpers import get_cases_available_to_user
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
@@ -194,6 +196,43 @@ from helpers.cimar import CimarAPI
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@login_required
def series_truncate_htmx(request, series_id, start, end):
"""HTMX endpoint to truncate a series' images to the given inclusive
start/end bounds (zero-based). Returns an HTML fragment with the result.
"""
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
images_removed = []
for n, image in enumerate(series.get_images()):
if n >= int(start) and n <= int(end):
# keep images within bounds
continue
else:
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
# Recalculate stored series size
try:
series.get_total_image_size(refresh=True)
except Exception:
# ignore errors when recalculating
pass
# Render a simple message fragment for HTMX swap
html = render_to_string(
"atlas/partials/truncate_result.html",
{"images_removed": images_removed, "series": series},
request=request,
)
return HttpResponse(html)
@login_required @login_required
def case_search_partial(request): def case_search_partial(request):
"""Return an HTMX partial listing cases matching the query parameter `q`. """Return an HTMX partial listing cases matching the query parameter `q`.
@@ -343,6 +382,104 @@ def case_displaysets(request, pk):
}, },
) )
@login_required
def case_search_page(request):
"""Full page case search with quick filters.
Supports GET params:
- q: query string
- field: one of ['all','title','description','history','author']
- my: '1' to limit to cases authored by current user
- open: '1' to limit to open_access cases
- has_images: '1' to require series images
- subspecialty: id of subspecialty to filter
- page: pagination page
"""
q = request.GET.get("q", "").strip()
field = request.GET.get("field", "all")
my_cases = request.GET.get("my", "0") == "1"
open_access = request.GET.get("open", "0") == "1"
has_images = request.GET.get("has_images", "0") == "1"
subspecialty_id = request.GET.get("subspecialty")
qs = get_cases_available_to_user(request.user)
if my_cases:
qs = qs.filter(author=request.user)
if open_access:
qs = qs.filter(open_access=True)
if subspecialty_id:
try:
sid = int(subspecialty_id)
qs = qs.filter(subspecialty__id=sid)
except (ValueError, TypeError):
pass
if q:
if field == "title":
qs = qs.filter(title__icontains=q)
elif field == "description":
qs = qs.filter(description__icontains=q)
elif field == "history":
qs = qs.filter(history__icontains=q)
elif field == "author":
qs = qs.filter(
Q(author__first_name__icontains=q)
| Q(author__last_name__icontains=q)
| Q(author__username__icontains=q)
)
else:
qs = qs.filter(
Q(title__icontains=q)
| Q(description__icontains=q)
| Q(history__icontains=q)
| Q(discussion__icontains=q)
| Q(report__icontains=q)
| Q(condition__name__icontains=q)
| Q(presentation__name__icontains=q)
| Q(subspecialty__name__icontains=q)
| Q(series__description__icontains=q)
| Q(series__modality__modality__icontains=q)
| Q(series__examination__examination__icontains=q)
| Q(author__first_name__icontains=q)
| Q(author__last_name__icontains=q)
| Q(author__username__icontains=q)
).distinct()
if has_images:
qs = qs.filter(series__images__isnull=False).distinct()
qs = qs.order_by("-created_date")
# Paginate results
paginator = Paginator(qs, 25)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Recent cases (authored by current user)
recent_cases = get_cases_available_to_user(request.user).filter(author=request.user).order_by("-created_date")[:5]
subspecialty_queryset = Subspecialty.objects.all().order_by("name")
return render(
request,
"atlas/case_search_page.html",
{
"cases": page_obj,
"q": q,
"field": field,
"my": my_cases,
"open": open_access,
"has_images": has_images,
"subspecialty_queryset": subspecialty_queryset,
"selected_subspecialty": subspecialty_id,
"recent_cases": recent_cases,
},
)
@login_required @login_required
@user_has_case_view_access @user_has_case_view_access
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
@@ -403,6 +540,18 @@ def case_displaysets_detail(request, pk):
# Redirect to the case_displaysets page with ?displayset=<pk> # Redirect to the case_displaysets page with ?displayset=<pk>
return redirect(f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}") return redirect(f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}")
@login_required
def case_displaysets_modal(request, pk):
"""Return HTML fragment for a CaseDisplaySet to be shown inside a modal."""
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
can_edit = False
try:
can_edit = displayset.case.check_user_can_edit(request.user)
except Exception:
can_edit = False
return render(request, 'atlas/case_displayset_modal.html', {'ds': displayset, 'can_edit': can_edit})
def case_displaysets_delete(request, pk): def case_displaysets_delete(request, pk):
try: try:
displayset = CaseDisplaySet.objects.get(pk=pk) displayset = CaseDisplaySet.objects.get(pk=pk)
@@ -464,9 +613,6 @@ def case_detail(request, pk):
case = get_case_for_case_detail(pk) case = get_case_for_case_detail(pk)
can_edit = case.check_user_can_edit(request.user) can_edit = case.check_user_can_edit(request.user)
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
return render( return render(
request, request,
@@ -475,9 +621,9 @@ def case_detail(request, pk):
"case": case, "case": case,
"cimar_sid": request.user.userprofile.cimar_sid, "cimar_sid": request.user.userprofile.cimar_sid,
"can_edit": can_edit, "can_edit": can_edit,
"is_atlas_editor": is_atlas_editor, # Provide helper lists for resource search filters
"is_case_author": is_case_author, "site_queryset": Site.objects.all(),
"can_mark_normal": can_mark_normal, "subspecialty_queryset": Subspecialty.objects.all(),
}, },
) )
@@ -493,11 +639,9 @@ def toggle_case_normal(request, pk):
""" """
case = get_case_for_case_detail(pk) case = get_case_for_case_detail(pk)
# Permission: atlas editors, case authors, or superusers # Permission: rely on `can_edit` check on the Case model
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists() can_edit = case.check_user_can_edit(request.user)
is_case_author = case.author.filter(id=request.user.id).exists() if not can_edit:
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
if not can_mark_normal:
return HttpResponse(status=403) return HttpResponse(status=403)
# If already marked normal, unmark (delete the NormalCase) # If already marked normal, unmark (delete the NormalCase)
@@ -581,10 +725,10 @@ def toggle_case_normal(request, pk):
# Recompute flags for the fragment render # Recompute flags for the fragment render
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists() is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists() is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser can_edit = case.check_user_can_edit(request.user)
html = render_to_string( html = render_to_string(
"atlas/partials/_normal_toggle.html", "atlas/partials/_normal_toggle.html",
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_mark_normal": can_mark_normal}, {"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit},
request=request, request=request,
) )
return HttpResponse(html) return HttpResponse(html)
@@ -595,11 +739,9 @@ def case_normal_form(request, pk):
"""Return an HTMX modal with a NormalCaseForm prepopulated with auto-extracted values.""" """Return an HTMX modal with a NormalCaseForm prepopulated with auto-extracted values."""
case = get_case_for_case_detail(pk) case = get_case_for_case_detail(pk)
# Permission check: allow atlas editors, case authors, or superusers # Permission check: rely on `can_edit` helper on the Case model
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists() can_edit = case.check_user_can_edit(request.user)
is_case_author = case.author.filter(id=request.user.id).exists() if not can_edit:
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
if not can_mark_normal:
return HttpResponse(status=403) return HttpResponse(status=403)
# Extract defaults # Extract defaults
@@ -691,11 +833,9 @@ def case_normal_form(request, pk):
def create_case_normal(request, pk): def create_case_normal(request, pk):
case = get_case_for_case_detail(pk) case = get_case_for_case_detail(pk)
# Permission check: allow atlas editors, case authors, or superusers # Permission check: rely on `can_edit` helper on the Case model
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists() can_edit = case.check_user_can_edit(request.user)
is_case_author = case.author.filter(id=request.user.id).exists() if not can_edit:
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
if not can_mark_normal:
return HttpResponse(status=403) return HttpResponse(status=403)
from .forms import NormalCaseForm from .forms import NormalCaseForm
@@ -721,10 +861,10 @@ def create_case_normal(request, pk):
# Return the updated toggle fragment and close modal via inline script # Return the updated toggle fragment and close modal via inline script
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists() is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists() is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser can_edit = case.check_user_can_edit(request.user)
toggle_html = render_to_string( toggle_html = render_to_string(
"atlas/partials/_normal_toggle.html", "atlas/partials/_normal_toggle.html",
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_mark_normal": can_mark_normal}, {"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit},
request=request, request=request,
) )
toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(case.pk) toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(case.pk)
@@ -1090,10 +1230,12 @@ def author_list(request):
return render(request, "atlas/author_list.html", {"authors": authors}) return render(request, "atlas/author_list.html", {"authors": authors})
@login_required
def index(request): def index(request):
return render(request, "atlas/index.html", {}) return render(request, "atlas/index.html", {})
@login_required
def user_collections(request): def user_collections(request):
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users) # Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
available_collections = request.user.user_casecollection_exams.all() available_collections = request.user.user_casecollection_exams.all()
@@ -1126,16 +1268,59 @@ def user_collections(request):
# Finished (completed) # Finished (completed)
finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name") finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name")
return render( context = {
request,
"atlas/user_collections.html",
{
"available_collections": available_qs, "available_collections": available_qs,
"in_progress_collections": in_progress_qs, "in_progress_collections": in_progress_qs,
"finished_collections": finished_qs, "finished_collections": finished_qs,
}, }
# If called via HTMX return a small fragment suitable for swapping
# into the index page. Otherwise render the full page.
is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true"
if is_htmx:
html = render_to_string("atlas/partials/_user_collections.html", context, request=request)
return HttpResponse(html)
return render(request, "atlas/user_collections.html", context)
# Note: keep the above full-page render for normal requests. If this
# endpoint is called by HTMX we prefer returning a small fragment so it
# can be swapped into the index page without duplicating layout.
@login_required
def user_related_collections(request):
"""Return up to 10 most recently modified collections where the user
is an author or a marker. If called via HTMX return a fragment suitable
for swapping into the index page.
"""
user = request.user
qs = (
CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
.distinct()
.order_by("-updated")[:10]
) )
related = []
for c in qs:
related.append(
{
"collection": c,
"is_author": c.author.filter(id=user.id).exists(),
"is_marker": c.markers.filter(id=user.id).exists(),
}
)
context = {"related_collections": related}
is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true"
if is_htmx:
html = render_to_string("atlas/partials/_user_related_collections.html", context, request=request)
return HttpResponse(html)
return render(request, "atlas/user_collections.html", context)
@login_required @login_required
def collection_options(request): def collection_options(request):
if not request.htmx: if not request.htmx:
@@ -1566,8 +1751,10 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(CaseCollectionCreate, self).get_context_data(**kwargs) context = super(CaseCollectionCreate, self).get_context_data(**kwargs)
# Only bind the case formset when the POST actually contains formset data.
if self.request.POST: # We don't require adding cases on create, so if the template doesn't
# include the casedetail management fields we should skip validation.
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
context["case_formset"] = CaseCollectionCaseFormSet( context["case_formset"] = CaseCollectionCaseFormSet(
self.request.POST, self.request.POST,
self.request.FILES, self.request.FILES,
@@ -1587,14 +1774,19 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
context = self.get_context_data(form=form) context = self.get_context_data(form=form)
case_formset = context["case_formset"] case_formset = context["case_formset"]
# If the POST included case formset data, validate and save it. Otherwise
# skip formset processing so creating a collection without cases redirects
# normally to the detail view.
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
if case_formset.is_valid(): if case_formset.is_valid():
response = super().form_valid(form) response = super().form_valid(form)
case_formset.instance = self.object case_formset.instance = self.object
case_formset.save() case_formset.save()
return response return response
else: else:
return super().form_invalid(form) return super().form_invalid(form)
# No formset data present: proceed with normal create redirect.
return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
"""Redirect to the collection detail page after successful creation.""" """Redirect to the collection detail page after successful creation."""
@@ -2143,6 +2335,35 @@ def add_collection_to_case_form(request, case_id):
return render(request, "web/htmx_contact_form_confirm.html") return render(request, "web/htmx_contact_form_confirm.html")
else: else:
form = AddCollectionToCaseForm(user=request.user) form = AddCollectionToCaseForm(user=request.user)
# Resolve the Case early so templates always receive it when available
try:
case = Case.objects.get(pk=case_id)
except Exception:
case = None
# If called via HTMX (inline from case view) return a compact fragment
is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true"
if is_htmx:
# Provide the 10 most recently updated collections for this user by default
user = request.user
recent_base = (
CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
.distinct()
.order_by("-updated")
)
# Exclude collections that already contain the case (if resolved)
if case is not None:
recent_base = recent_base.exclude(pk__in=case.casecollection_set.values_list("pk", flat=True))
recent_qs = recent_base[:10]
html = render_to_string(
"atlas/partials/add_collection_inline.html",
{"recent_collections": recent_qs, "case": case},
request=request,
)
return HttpResponse(html)
return render( return render(
request, request,
"generic_form.html", "generic_form.html",
@@ -2153,6 +2374,84 @@ def add_collection_to_case_form(request, case_id):
) )
@login_required
def collection_inline_search(request, case_id):
# HTMX endpoint: return recent collections or search results for the user
if not request.htmx:
return Http404
q = request.GET.get("q", "").strip()
user = request.user
base_qs = (
CaseCollection.objects.filter(Q(author=user) | Q(markers=user) | Q(valid_user_users=user) | Q(open_access=True))
.distinct()
)
try:
case = Case.objects.get(pk=case_id)
except Exception:
case = None
# Exclude collections already containing the case
if case is not None:
base_qs = base_qs.exclude(pk__in=case.casecollection_set.values_list("pk", flat=True))
if q:
results = base_qs.filter(name__icontains=q).order_by("-updated")[:30]
else:
results = base_qs.order_by("-updated")[:10]
html = render_to_string(
"atlas/partials/collection_inline_list.html",
{"collections": results, "case": case},
request=request,
)
return HttpResponse(html)
@login_required
@require_http_methods(["POST"])
def add_collection_to_case_inline(request, case_id, collection_id):
# HTMX endpoint to add a single collection to a case and return the new list item
if not request.htmx:
return Http404
case = get_object_or_404(Case, pk=case_id)
collection = get_object_or_404(CaseCollection, pk=collection_id)
# Permission: user must be able to edit the case
if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to add collections to this case.", status=403)
# Add the association
if collection in case.casecollection_set.all():
return HttpResponse("Already in collection")
case.casecollection_set.add(collection)
# Render a list item to insert into the case's collection list
rendered = render_to_string(
"atlas/partials/collection_list_item.html",
{"collection": collection, "case": case, "can_edit": case.check_user_can_edit(request.user)},
request=request,
)
# Also instruct the client to remove the collection entry from the inline search list
remove_script = (
'<script>'
'(function(){'
'try {'
"var pk = '%s';" % collection.pk +
"document.querySelectorAll('#collection-inline-list [data-collection-pk=\"' + pk + '\"]').forEach(function(el){ el.remove(); });" +
'} catch(e) { console.error("remove added collection from inline list error", e); }'
'})();'
'</script>'
)
return HttpResponse(rendered + remove_script)
# class AddCollectionToCaseView(FormView): # class AddCollectionToCaseView(FormView):
# form_class = AddCollectionToCaseForm # form_class = AddCollectionToCaseForm
# template_name = "generic_form.html" # template_name = "generic_form.html"
@@ -2249,6 +2548,66 @@ class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableM
filterset_class = CaseFilter filterset_class = CaseFilter
@staff_member_required
def cases_by_size(request):
"""Admin view: list cases ordered by total images size to find large cases.
Shows case id, title, number of series, number of images and total size (bytes).
Optional GET parameter `min_size` (bytes) to filter cases larger than that.
"""
min_size = request.GET.get("min_size")
try:
min_size = int(min_size) if min_size else 0
except Exception:
min_size = 0
cases_qs = Case.objects.prefetch_related("series__images")
def human(n):
# simple human readable bytes
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024.0:
return f"{n:.1f}{unit}"
n /= 1024.0
return f"{n:.1f}PB"
rows = []
for c in cases_qs:
try:
total = c.get_total_series_images_size()
except Exception:
total = 0
series_count = c.series.count()
image_count = sum(s.images.filter(removed=False).count() for s in c.series.all())
if total >= min_size:
rows.append({
"case": c,
"total": total,
"series_count": series_count,
"image_count": image_count,
"human_total": human(total),
})
rows.sort(key=lambda r: r["total"], reverse=True)
rows.sort(key=lambda r: r["total"], reverse=True)
paginator = Paginator(rows, 50)
page_num = request.GET.get("page")
page = paginator.get_page(page_num)
def human(n):
# simple human readable bytes
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024.0:
return f"{n:.1f}{unit}"
n /= 1024.0
return f"{n:.1f}PB"
context = {"page": page, "human": human, "min_size": min_size}
return render(request, "atlas/cases_by_size.html", context)
class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Series model = Series
table_class = SeriesTable table_class = SeriesTable
@@ -2381,7 +2740,7 @@ def case_order_dicom(request, pk):
# TODO: don't try to order non stack series # TODO: don't try to order non stack series
for series in case.series.all(): for series in case.series.all():
try: try:
if request.user in series.get_author_objects(): if series.check_user_can_edit(request.user):
series.order_by_dicom() series.order_by_dicom()
else: else:
fail.append(f"{series.pk}: invalid permission") fail.append(f"{series.pk}: invalid permission")
@@ -2647,6 +3006,21 @@ class StructureAutocomplete(autocomplete.Select2QuerySetView):
return qs return qs
def has_add_permission(self, request):
"""Allow any authenticated user to create new Structure entries.
By default DAL requires the user to have the model add permission.
For Anatomy question creation we want any registered user to be
able to create structures, so relax that check to only require
authentication.
"""
try:
auth = request.user.is_authenticated
except Exception:
auth = False
return bool(auth)
class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView): class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self): def get_queryset(self):
@@ -2752,20 +3126,32 @@ def collection_viva(request, pk):
# ) # )
#) #)
# Prefetch and annotate aggressively to avoid N+1 queries in the viva view.
# - Prefetch `case__series` using the ordering from SeriesDetail
# - Prefetch only non-removed images for those series (ordered by position)
# - Prefetch case resources and display sets
from django.db.models import Prefetch as _Prefetch
series_images_qs = SeriesImage.objects.filter(removed=False).order_by("position")
series_qs = Series.objects.order_by("seriesdetail__sort_order").prefetch_related(
_Prefetch("images", queryset=series_images_qs)
)
casedetails = ( casedetails = (
CaseDetail.objects CaseDetail.objects
.filter(collection=collection) .filter(collection=collection)
.select_related("case") .select_related("case")
.order_by("sort_order") .order_by("sort_order")
.prefetch_related( .prefetch_related(
# Prefetch series and their images directly on the case _Prefetch("case__series", queryset=series_qs),
"case__series", _Prefetch("case__series__modality"),
"case__series__modality", _Prefetch("case__series__examination"),
"case__series__examination", _Prefetch("case__series__plane"),
"case__series__plane", _Prefetch("case__series__contrast"),
"case__series__contrast", _Prefetch("case__caseresource_set"),
"case__series__images", _Prefetch("case__display_sets"),
) )
.annotate(series_count=Count("case__seriesdetail"))
) )
@@ -3968,18 +4354,15 @@ def use_dates_as_descriptions(request, pk):
return HttpResponse("Done") return HttpResponse("Done")
def collection_case_dicom_json_review(request, exam_id, case_id): def collection_case_dicom_json_review(request, exam_id, case_number):
return collection_case_dicom_json(request, exam_id, case_id, review=True) return collection_case_dicom_json(request, exam_id, case_number, review=True)
def collection_case_dicom_json(request, exam_id, case_id, review=False): def collection_case_dicom_json(request, exam_id, case_number, review=False):
# Support either case_number (index) or case_id (case PK) # Support either case_number (index) or case_id (case PK)
try:
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
except Exception:
collection = get_object_or_404(CaseCollection, pk=exam_id) collection = get_object_or_404(CaseCollection, pk=exam_id)
try: try:
case_obj = collection.get_case_by_index(int(case_id)) case_obj = collection.get_case_by_index(int(case_number))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection) casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception: except Exception:
raise Http404("Case not found in collection") raise Http404("Case not found in collection")
@@ -3998,6 +4381,162 @@ def case_dicom_json(request, pk):
return JsonResponse(case.get_case_dicom_json()) return JsonResponse(case.get_case_dicom_json())
@login_required
def resource_search_widget(request):
"""HTMX endpoint: search existing `Resource` objects with simple filters.
GET params:
- q: query string (name/description)
- site: site id (optional)
- subspecialty: subspecialty id (optional)
- open_access: '1' or '0' (optional)
Returns rendered partial `atlas/partials/resource_search_widget_results.html`.
"""
q = request.GET.get("q", "").strip()
site = request.GET.get("site")
subspecialty = request.GET.get("subspecialty")
open_access = request.GET.get("open_access")
qs = Resource.objects.all()
if q:
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q) | Q(url__icontains=q))
if site:
try:
qs = qs.filter(sites__id=int(site))
except Exception:
pass
if subspecialty:
try:
qs = qs.filter(subspecialty__id=int(subspecialty))
except Exception:
pass
if open_access in ["0", "1"]:
qs = qs.filter(open_access=(open_access == "1"))
# Restrict to what the user may see: open_access or authored (unless editor)
if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
qs = qs.filter(Q(open_access=True) | Q(author__id=request.user.id)).distinct()
results = qs.order_by("name")[:50]
field = request.GET.get("field", "resource_search")
case_id = request.GET.get("case_id")
return render(request, "atlas/partials/resource_search_widget_results.html", {"results": results, "field": field, "case_id": case_id})
@login_required
def resource_search_panel(request):
"""HTMX endpoint: return the full resource search panel HTML fragment.
Expects optional GET param `case_id` to prefill the hidden field.
"""
case_id = request.GET.get("case_id")
return render(request, "atlas/partials/resource_search_panel.html", {
"case": (Case.objects.filter(pk=case_id).first() if case_id else None),
"site_queryset": Site.objects.all(),
"subspecialty_queryset": Subspecialty.objects.all(),
})
@login_required
@require_POST
def case_attach_resource(request, pk):
"""Attach an existing resource to the case (HTMX POST).
POST params:
- resource_id: integer
- pre_review: '1' or '0' (optional)
Returns the updated `atlas/partials/case_resources_list.html` fragment.
"""
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
pre_review = request.POST.get("pre_review", "0") == "1"
try:
resource = Resource.objects.get(pk=int(resource_id))
except Exception:
return HttpResponse(status=400)
# create if not exists
cr, created = CaseResource.objects.get_or_create(case=case, resource=resource, defaults={"pre_review": pre_review})
if not created:
# update flag
cr.pre_review = pre_review
cr.save()
# Refresh case from DB to ensure related sets reflect changes and render updated list
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
resp = HttpResponse(html)
# Notify any HTMX listeners that the case resources have changed
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
return resp
@login_required
@require_POST
def case_detach_resource(request, pk):
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
try:
cr = CaseResource.objects.filter(case=case, resource__id=int(resource_id))
cr.delete()
except Exception:
pass
# Refresh case from DB and return updated partial
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
return resp
@login_required
@require_POST
def case_toggle_resource_pre(request, pk):
"""Toggle the pre_review flag on a CaseResource (HTMX POST).
POST params:
- resource_id: integer
Returns the updated `atlas/partials/case_resources_list.html` fragment.
"""
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
try:
cr = CaseResource.objects.get(case=case, resource__id=int(resource_id))
except Exception:
return HttpResponse(status=400)
cr.pre_review = not bool(cr.pre_review)
cr.save()
# Refresh case from DB to ensure related sets reflect changes and render updated list
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request)
resp = HttpResponse(html)
resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}})
return resp
def series_dicom_json(request, pk): def series_dicom_json(request, pk):
series = get_object_or_404(Series, pk=pk) series = get_object_or_404(Series, pk=pk)
+4
View File
@@ -7,6 +7,10 @@ server {
listen [::]:80; listen [::]:80;
server_name _; server_name _;
# Write logs to the host-mounted folder so Promtail can scrape them
access_log /var/log/rad/nginx.access.log combined;
error_log /var/log/rad/nginx.error.log warn;
client_max_body_size 100M; client_max_body_size 100M;
location /static/ { location /static/ {
+8
View File
@@ -8,6 +8,10 @@ server {
listen [::]:80; listen [::]:80;
server_name _; server_name _;
# Write logs to the host-mounted folder so Promtail can scrape them
access_log /var/log/rad/nginx.access.log combined;
error_log /var/log/rad/nginx.error.log warn;
client_max_body_size 100M; client_max_body_size 100M;
# Serve static files from the static volume # Serve static files from the static volume
@@ -112,6 +116,10 @@ server {
client_max_body_size 4G; client_max_body_size 4G;
# Write logs to the host-mounted folder so Promtail can scrape them
access_log /var/log/rad/nginx.access.log combined;
error_log /var/log/rad/nginx.error.log warn;
# Same static/media handling as above # Same static/media handling as above
location /static/ { alias /usr/src/app/static/; } location /static/ { alias /usr/src/app/static/; }
location /media { alias /usr/src/app/media; } location /media { alias /usr/src/app/media; }
+9 -1
View File
@@ -43,7 +43,11 @@ DEBUG_TOOLBAR_CONFIG = {
# deployment README. Adjust as needed. # deployment README. Adjust as needed.
STATIC_ROOT = '/usr/src/app/static/' STATIC_ROOT = '/usr/src/app/static/'
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" import os
# Allow environment to override the email backend (so dev .env can select
# a file-based backend while production keeps SMTP).
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
if not DEBUG: if not DEBUG:
LOGGING = { LOGGING = {
@@ -117,6 +121,10 @@ SECURE_HSTS_SECONDS = 0
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
"http://127.0.0.1:8000", "http://127.0.0.1:8000",
"http://localhost:8000", "http://localhost:8000",
"http://127.0.0.1:8080",
"http://localhost:8080",
"http://127.0.0.1:8080/",
"http://localhost:8080/",
] ]
REMOTE_URL = "http://127.0.0.1:8000" REMOTE_URL = "http://127.0.0.1:8000"
+2 -1
View File
@@ -8,7 +8,7 @@ services:
# Development: run Django's autoreloading development server instead of # Development: run Django's autoreloading development server instead of
# the production entrypoint/gunicorn. Mount the repository into the # the production entrypoint/gunicorn. Mount the repository into the
# container so code edits on the host trigger Django's autoreload. # container so code edits on the host trigger Django's autoreload.
entrypoint: ["python", "manage.py", "runserver", "0.0.0.0:8000"] entrypoint: ["sh", "-c", "python manage.py collectstatic --noinput && python manage.py runserver 0.0.0.0:8000 --nothreading"]
# Clear any command set by the prod compose file (which would be passed # Clear any command set by the prod compose file (which would be passed
# as an extra argument to manage.py). Setting an empty command prevents # as an extra argument to manage.py). Setting an empty command prevents
# the image `command` from being appended to the entrypoint. # the image `command` from being appended to the entrypoint.
@@ -24,6 +24,7 @@ services:
image: nginx:1.25-alpine image: nginx:1.25-alpine
volumes: volumes:
- ../deploy/nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro - ../deploy/nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro
- ../logs:/var/log/rad:rw
loki: loki:
image: grafana/loki:2.8.2 image: grafana/loki:2.8.2
+1 -1
View File
@@ -13,7 +13,7 @@ services:
# lives at rad/Dockerfile inside the context. # lives at rad/Dockerfile inside the context.
context: .. context: ..
dockerfile: rad/Dockerfile dockerfile: rad/Dockerfile
command: python manage.py runserver 0.0.0.0:8000 command: python manage.py runserver 0.0.0.0:8000 --nothreading
#command: pip install -r requirements.txt #command: pip install -r requirements.txt
volumes: volumes:
# Mount the repository root into the container WORKDIR so manage.py and # Mount the repository root into the container WORKDIR so manage.py and
+1
View File
@@ -45,6 +45,7 @@ services:
- web - web
volumes: volumes:
- ../deploy/nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro - ../deploy/nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
- ../logs:/var/log/rad:rw
- static_volume:/usr/src/app/static:ro - static_volume:/usr/src/app/static:ro
- media_volume:/usr/src/app/media:ro - media_volume:/usr/src/app/media:ro
- ../deploy/nginx/certs:/etc/letsencrypt:ro - ../deploy/nginx/certs:/etc/letsencrypt:ro
@@ -1 +0,0 @@
{"UID":"f8cade59-3cf9-4ae3-965d-8b0a57007d58","created_at":"2025-12-01T17:14:05.101735276Z","version":{"version":"2.8.2","revision":"9f809eda7","branch":"HEAD","buildUser":"root@e401cfcb874f","buildDate":"2023-05-03T11:07:54Z","goVersion":"go1.20.4"}}
+34 -290
View File
@@ -20,6 +20,7 @@ from django.contrib.auth.models import User
from generic.models import ( from generic.models import (
CidUser, CidUser,
CidUserExam,
CidUserGroup, CidUserGroup,
Examination, Examination,
ExamCollection, ExamCollection,
@@ -81,6 +82,7 @@ from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidg
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.urls import reverse from django.urls import reverse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget, ExamSearchWidget
class SplitDateTimeFieldDefaultTime(SplitDateTimeField): class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
@@ -240,7 +242,7 @@ class ExamAuthorFormMixin(ModelForm):
ModelForm.__init__(self, *args, **kwargs) ModelForm.__init__(self, *args, **kwargs)
self.fields["author"] = ModelMultipleChoiceField( self.fields["author"] = ModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), widget=UserSearchWidget(),
) )
@@ -252,7 +254,7 @@ class ExamMarkerFormMixin(ModelForm):
ModelForm.__init__(self, *args, **kwargs) ModelForm.__init__(self, *args, **kwargs)
self.fields["markers"] = ModelMultipleChoiceField( self.fields["markers"] = ModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Markers", is_stacked=False), widget=UserSearchWidget(),
) )
self.fields["markers"].required = False self.fields["markers"].required = False
@@ -425,6 +427,7 @@ class CidUserForm(ModelForm):
"internal_candidate", "internal_candidate",
"name", "name",
"email", "email",
"notes",
"supervisor", "supervisor",
"login_email_sent", "login_email_sent",
"results_email_sent", "results_email_sent",
@@ -540,6 +543,19 @@ class CidUserExamForm(ModelForm):
fields = [] fields = []
class CidUserExamRecordForm(ModelForm):
class Meta:
model = CidUserExam
fields = [
"start_time",
"end_time",
"completed",
"manual_submission",
"share_with_supervisor",
"results_emailed_status",
]
class CidUserGroupModelChoiceField(ModelMultipleChoiceField): class CidUserGroupModelChoiceField(ModelMultipleChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
return f"{obj.cid} [{obj.email}]" return f"{obj.cid} [{obj.email}]"
@@ -556,219 +572,7 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]" return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
class UserSearchSelectMultipleWidget:
"""Reusable lightweight widget renderer for a searchable multi-user selector.
Usage: instantiate with (name, value) where value is an iterable of user ids
and call .render() to get the HTML fragment. This mirrors the previous
nested widget but is available module-wide for reuse.
"""
def __init__(self, name, value):
self.name = name
self.value = value or []
def render(self):
field_id = f"id_{self.name}"
search_id = f"user_search_{self.name}"
results_id = f"user_search_results_{self.name}"
selected_list_id = f"selected_users_{self.name}"
users = User.objects.filter(pk__in=self.value) if self.value else []
options_html = ""
selected_html = ""
for u in users:
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
grade_text = ""
try:
up = getattr(u, "userprofile", None)
if up and getattr(up, "grade", None):
g = up.grade
grade_text = getattr(g, "name", str(g)) if g is not None else ""
except Exception:
grade_text = ""
selected_html += (
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
+ '</span>'
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
f'</li>'
)
url = reverse("generic:user_search_widget")
grades_options = '<option value="">All grades</option>'
try:
from generic.models import UserGrades
grades_qs = UserGrades.objects.all()
for g in grades_qs:
grades_options += f'<option value="{g.pk}">{g.name}</option>'
except Exception:
grades_options = '<option value="">All grades</option>'
html = f"""
<div class="user-search-widget">
<select name="{self.name}" id="{field_id}" multiple class="d-none">
{options_html}
</select>
<div class="mb-2">
<div class="d-flex gap-2 align-items-start">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
{grades_options}
</select>
<!-- Help button for advanced search features -->
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
</div>
<div id="{results_id}" class="mt-2"></div>
<!-- Collapsible help panel (hidden by default) -->
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong>
<ul class="mb-0 mt-1">
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> or <code>full_name:</code> matches first OR last name (e.g. <code>name:smith</code>).</li>
<li><code>first:</code> or <code>first_name:</code> matches first name.</li>
<li><code>last:</code> or <code>last_name:</code> matches last name.</li>
<li><code>email:</code> matches email address.</li>
<li><code>username:</code> matches username.</li>
<li><code>id:</code> or <code>pk:</code> match by numeric user id (e.g. <code>id:123</code>).</li>
<li><code>grade:</code> match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
</ul>
</li>
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
</ul>
</div>
</div>
<div>
<label class="form-label small">Selected users</label>
<ul id="{selected_list_id}" class="list-group list-group-flush">
{selected_html}
</ul>
</div>
<script>
(function() {{
const search = document.getElementById('{search_id}');
const results = document.getElementById('{results_id}');
const select = document.getElementById('{field_id}');
const selectedList = document.getElementById('{selected_list_id}');
const gradeSelect = document.getElementById('grade_filter_{self.name}');
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
let timeout = null;
function fetchResults(q) {{
const grade = gradeSelect ? gradeSelect.value : '';
if (!q || q.trim().length === 0) {{
results.innerHTML = '';
return;
}}
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
fetch(url)
.then(r => r.text())
.then(html => {{ results.innerHTML = html; }});
}}
search.addEventListener('keyup', function(e) {{
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 300);
}});
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
// re-run search with same query when grade changes
clearTimeout(timeout);
timeout = setTimeout(() => fetchResults(search.value), 50);
}});
// Toggle help panel visibility
if (helpBtn && helpPanel) {{
helpBtn.addEventListener('click', function(e) {{
e.preventDefault();
helpPanel.classList.toggle('d-none');
const expanded = !helpPanel.classList.contains('d-none');
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}});
}}
// fieldName for this widget instance
const widgetFieldName = '{self.name}';
// Add button click delegation: results list buttons have data attributes
results.addEventListener('click', function(e) {{
const btn = e.target.closest('.user-add-btn');
if (!btn) return;
const id = btn.getAttribute('data-user-id');
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
const grade = btn.getAttribute('data-user-grade') || '';
addUserToField(widgetFieldName, id, text, grade);
}});
// Wire up the "Add all results" button if present
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
if (addAllBtn) {{
addAllBtn.addEventListener('click', function(e) {{
e.preventDefault();
addAllFromResults(widgetFieldName);
}});
}}
window.addUserToField = function(fieldName, id, text, grade) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
// prevent duplicate
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
sel.appendChild(opt);
// add to visible list (include grade if provided)
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.id = 'selected_user_' + fieldName + '_' + id;
const span = document.createElement('span');
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
li.appendChild(span); li.appendChild(btn);
selectedList.appendChild(li);
}}
window.removeUserFromField = function(fieldName, id) {{
const sel = document.getElementById('id_' + fieldName);
if (!sel) return;
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
if (li && li.parentNode) li.parentNode.removeChild(li);
}}
window.addAllFromResults = function(fieldName) {{
const list = document.getElementById('user_search_results_list_' + fieldName);
if (!list) return;
const items = list.querySelectorAll('li[data-user-id]');
items.forEach(function(it) {{
const id = it.getAttribute('data-user-id');
const text = it.getAttribute('data-user-text') || it.textContent.trim();
const grade = it.getAttribute('data-user-grade') || '';
addUserToField(fieldName, id, text, grade);
}});
}}
}})();
</script>
</div>
"""
return mark_safe(html)
@@ -789,61 +593,9 @@ class UserUserGroupForm(ModelForm):
if 'users' in self.fields: if 'users' in self.fields:
orig_field = self.fields['users'] orig_field = self.fields['users']
class RenderWrapper: # Use the reusable UserSearchWidget wrapper so templates and other
def __init__(self, field): # modules can reuse the same behaviour consistently.
self.field = field self.fields['users'].widget = UserSearchWidget(orig_field)
# Minimal widget-compatible wrapper used by Django templates and
# form machinery. We implement the small subset of the Widget
# API that the templates/checks expect: `is_hidden`,
# `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`.
is_hidden = False
needs_multipart_form = False
use_fieldset = False
# attrs is expected by BoundField.id_for_label (widget.attrs.get('id'))
attrs = {}
def render(self, name, value, attrs=None, renderer=None):
# store attrs so template helpers can inspect them
self.attrs = attrs or {}
value = value or []
widget = UserSearchSelectMultipleWidget(name, value)
return widget.render()
def value_from_datadict(self, data, files, name):
# data is usually QueryDict with getlist
if hasattr(data, 'getlist'):
return data.getlist(name)
# fallback
val = data.get(name)
if val is None:
return []
return [val]
def id_for_label(self, id_):
# Called by BoundField.id_for_label; prefer explicit id in attrs
try:
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
return self.attrs.get('id')
except Exception:
pass
return id_
def use_required_attribute(self, initial):
# Called by Django to decide whether to add the required HTML attribute.
# We defer to the underlying field/widget if available; otherwise
# return False to avoid unexpected 'required' attributes.
try:
if hasattr(self, 'field') and getattr(self, 'field') is not None:
# If original field/widget had such method, prefer it.
orig_widget = getattr(self.field, 'widget', None)
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
return orig_widget.use_required_attribute(initial)
except Exception:
pass
return False
self.fields['users'].widget = RenderWrapper(orig_field)
class Meta: class Meta:
model = UserUserGroup model = UserUserGroup
@@ -886,36 +638,32 @@ class UserGroupExamForm(ModelForm):
self.fields["anatomy_user_user_groups"] = ModelMultipleChoiceField( self.fields["anatomy_user_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=AnatomyExam.objects.filter(archive=False), queryset=AnatomyExam.objects.filter(archive=False),
widget=FilteredSelectMultiple( widget=ExamSearchWidget(exam_model=AnatomyExam),
verbose_name="Anatomy Exams", is_stacked=False
),
) )
self.fields["rapid_user_user_groups"] = ModelMultipleChoiceField( self.fields["rapid_user_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=RapidsExam.objects.filter(archive=False), queryset=RapidsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False), widget=ExamSearchWidget(exam_model=RapidsExam),
) )
self.fields["shorts_user_user_groups"] = ModelMultipleChoiceField( self.fields["shorts_user_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=ShortsExam.objects.filter(archive=False), queryset=ShortsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Shorts Exams", is_stacked=False), widget=ExamSearchWidget(exam_model=ShortsExam),
) )
self.fields["longs_user_user_groups"] = ModelMultipleChoiceField( self.fields["longs_user_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=LongsExam.objects.filter(archive=False), queryset=LongsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Longs Exams", is_stacked=False), widget=ExamSearchWidget(exam_model=LongsExam),
) )
self.fields["physics_user_user_groups"] = ModelMultipleChoiceField( self.fields["physics_user_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=PhysicsExam.objects.filter(archive=False), queryset=PhysicsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple( widget=ExamSearchWidget(exam_model=PhysicsExam),
verbose_name="Physics Exams", is_stacked=False
),
) )
self.fields["sba_user_user_groups"] = ModelMultipleChoiceField( self.fields["sba_user_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=SbasExam.objects.filter(archive=False), queryset=SbasExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False), widget=ExamSearchWidget(exam_model=SbasExam),
) )
class Meta: class Meta:
@@ -983,31 +731,27 @@ class CidGroupExamForm(ModelForm):
self.fields["anatomy_cid_user_groups"] = ModelMultipleChoiceField( self.fields["anatomy_cid_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=AnatomyExam.objects.filter(archive=False), queryset=AnatomyExam.objects.filter(archive=False),
widget=FilteredSelectMultiple( widget=ExamSearchWidget(exam_model=AnatomyExam),
verbose_name="Anatomy Exams", is_stacked=False
),
) )
self.fields["rapid_cid_user_groups"] = ModelMultipleChoiceField( self.fields["rapid_cid_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=RapidsExam.objects.filter(archive=False), queryset=RapidsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False), widget=ExamSearchWidget(exam_model=RapidsExam),
) )
self.fields["longs_cid_user_groups"] = ModelMultipleChoiceField( self.fields["longs_cid_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=LongsExam.objects.filter(archive=False), queryset=LongsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Longs Exams", is_stacked=False), widget=ExamSearchWidget(exam_model=LongsExam),
) )
self.fields["physics_cid_user_groups"] = ModelMultipleChoiceField( self.fields["physics_cid_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=PhysicsExam.objects.filter(archive=False), queryset=PhysicsExam.objects.filter(archive=False),
widget=FilteredSelectMultiple( widget=ExamSearchWidget(exam_model=PhysicsExam),
verbose_name="Physics Exams", is_stacked=False
),
) )
self.fields["sba_cid_user_groups"] = ModelMultipleChoiceField( self.fields["sba_cid_user_groups"] = ModelMultipleChoiceField(
required=False, required=False,
queryset=SbasExam.objects.filter(archive=False), queryset=SbasExam.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name="Sbas Exams", is_stacked=False), widget=ExamSearchWidget(exam_model=SbasExam),
) )
class Meta: class Meta:
@@ -1132,12 +876,12 @@ class ExamCollectionForm(ModelForm):
self.fields[reverse_prop] = ModelMultipleChoiceField( self.fields[reverse_prop] = ModelMultipleChoiceField(
required=False, required=False,
queryset=exam_obj.objects.filter(archive=False), queryset=exam_obj.objects.filter(archive=False),
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False), widget=ExamSearchWidget(exam_model=exam_obj),
) )
self.fields["author"] = ModelMultipleChoiceField( self.fields["author"] = ModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), required=False, widget=UserSearchWidget(), required=False,
) )
def save(self, commit=True): def save(self, commit=True):
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-12-29 10:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0029_add_unique_constraint_supervisor_email'),
]
operations = [
migrations.AddField(
model_name='ciduser',
name='notes',
field=models.TextField(blank=True, help_text='Internal notes about the candidate'),
),
]
+32
View File
@@ -0,0 +1,32 @@
# Generated by Django 5.2.7 on 2026-01-05 12:41
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('generic', '0030_ciduser_notes'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Flag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('note', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('cid_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to='generic.ciduser')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='flags', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-created_at',),
},
),
]
+6
View File
@@ -65,8 +65,14 @@ class AuthorMixin():
def is_author(self, user: User) -> bool: def is_author(self, user: User) -> bool:
"""Returns True if the user is an author of the object""" """Returns True if the user is an author of the object"""
if user.is_superuser:
return True
return self.author.filter(id=user.id).exists() return self.author.filter(id=user.id).exists()
def get_author_display(self):
return ", ".join([i.username for i in self.get_author_objects()])
class UserConfigurablePaginationMixin: class UserConfigurablePaginationMixin:
default_per_page = 25 default_per_page = 25
+86 -34
View File
@@ -387,7 +387,7 @@ class SeriesImageBase(models.Model):
super().save(*args, **kwargs) # Call the "real" save() method. super().save(*args, **kwargs) # Call the "real" save() method.
class SeriesBase(models.Model): class SeriesBase(models.Model, AuthorMixin):
info = models.TextField( info = models.TextField(
blank=True, blank=True,
help_text="Description of stack, for admin organisation, will not be visible when taking", help_text="Description of stack, for admin organisation, will not be visible when taking",
@@ -444,16 +444,6 @@ class SeriesBase(models.Model):
return False return False
def get_author_objects(self):
"""Returns a list of authors"""
if self.author:
return self.author.all()
else:
return ["None"]
def get_author_display(self):
return ", ".join([i.username for i in self.get_author_objects()])
def get_examination(self): def get_examination(self):
"""Returns a comma seperated text list of regions""" """Returns a comma seperated text list of regions"""
return str(self.examination) return str(self.examination)
@@ -667,6 +657,9 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
name = models.CharField(max_length=200, help_text="Name of the exam/collection") name = models.CharField(max_length=200, help_text="Name of the exam/collection")
created = models.DateTimeField(auto_now_add=True, help_text="Creation timestamp")
updated = models.DateTimeField(auto_now=True, help_text="Last updated timestamp")
start_date = models.DateTimeField( start_date = models.DateTimeField(
help_text="Date (and time) the exam/collection starts", null=True, blank=True help_text="Date (and time) the exam/collection starts", null=True, blank=True
) )
@@ -780,10 +773,12 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
if self.start_date >= timezone.now() or ( if self.start_date >= timezone.now() or (
self.end_date is not None and self.end_date <= timezone.now() self.end_date is not None and self.end_date <= timezone.now()
): ):
raise Http404("Exam not found") # Raise with an explanatory message so the frontend can
# surface the reason to the end user.
raise Http404("Exam not currently available (outside allowed dates)")
# If it is not active no one can take # If it is not active no one can take
elif active_only and not self.active: elif active_only and not self.active:
raise Http404("Exam not found") raise Http404("Exam currently inactive")
# If candidates only check they have access # If candidates only check they have access
if self.candidates_only: if self.candidates_only:
@@ -1442,6 +1437,8 @@ class CidUser(models.Model):
"CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True "CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True
) )
notes = models.TextField(blank=True, help_text="Internal notes about the candidate")
class Meta: class Meta:
indexes = [models.Index(fields=["cid"])] indexes = [models.Index(fields=["cid"])]
@@ -1552,27 +1549,9 @@ class CidUser(models.Model):
if self.login_email_sent and not resend: if self.login_email_sent and not resend:
return False, "Already sent." return False, "Already sent."
login_url = reverse("cid_selector") # Build the message payload. Exposed as a separate method so callers
# can preview the email without actually sending it.
msg = f""" msg = self.generate_email_details_msg()
See below for your details for: {self.group}
---------
CID: {self.cid}
Passcode: {self.passcode}
---------
You will need these to access to the test system.
Direct link to login:
{settings.REMOTE_URL}{login_url}?cid={self.cid}&passcode={self.passcode}
The above details are unique to you. Please do not share as otherwise others will be able to access and submit your results.
Please note this inbox is not monitored. Direct all enquiries to plh-tr.radiology-academy@nhs.net
"""
try: try:
# send_mail( # send_mail(
@@ -1597,6 +1576,36 @@ class CidUser(models.Model):
self.save() self.save()
return True, "" return True, ""
def generate_email_details_msg(self) -> str:
"""Generate the candidate details email body without sending it.
Returns:
str: The plaintext email body that would be sent.
"""
login_url = reverse("cid_selector")
msg = f"""
See below for your details for: {self.group}
---------
CID: {self.cid}
Passcode: {self.passcode}
---------
You will need these to access to the test system.
Direct link to login:
{settings.REMOTE_URL}{login_url}?cid={self.cid}&passcode={self.passcode}
The above details are unique to you. Please do not share as otherwise others will be able to access and submit your results.
Please note this inbox is not monitored. Direct all enquiries to plh-tr.radiology-academy@nhs.net
"""
return msg
def get_email_details_url(self): def get_email_details_url(self):
return reverse("generic:candidate_email_details", kwargs={"cid": self.cid}) return reverse("generic:candidate_email_details", kwargs={"cid": self.cid})
@@ -1686,9 +1695,52 @@ class CidUserExam(models.Model):
# TODO add examuserstatus? # TODO add examuserstatus?
self.end_time = timezone.now() self.end_time = timezone.now()
self.completed = True self.completed = True
self.manual_submission = True
self.save() self.save()
class Flag(models.Model):
"""A reusable flag model that can be attached to any object via a
GenericForeignKey. Useful for marking questions, items or other objects
across apps.
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
# Optional actor who created the flag (either a user or a CID user)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="flags",
)
cid_user = models.ForeignKey(
"CidUser",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="flags",
)
note = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-created_at",)
def __str__(self) -> str:
actor = None
if self.user:
actor = f"user:{self.user.username}"
elif self.cid_user:
actor = f"cid:{self.cid_user.cid}"
else:
actor = "anon"
return f"Flag [{actor}] -> {self.content_type}({self.object_id})"
CID_GROUP_EXAMS = ( CID_GROUP_EXAMS = (
("SBAs", "sba_cid_user_groups"), ("SBAs", "sba_cid_user_groups"),
("Physics", "physics_cid_user_groups"), ("Physics", "physics_cid_user_groups"),
+7 -8
View File
@@ -3,6 +3,7 @@ import django_tables2 as tables
from django_tables2.utils import A from django_tables2.utils import A
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe
from easy_thumbnails.files import get_thumbnailer from easy_thumbnails.files import get_thumbnailer
@@ -85,7 +86,7 @@ class SelectionTable(tables.Table):
# Also include a small data attribute so client scripts can locate the # Also include a small data attribute so client scripts can locate the
# controls by selection name if they prefer. # controls by selection name if they prefer.
return format_html(html) return mark_safe(html)
class SingleImageColumn(tables.Column): class SingleImageColumn(tables.Column):
def render(self, value): def render(self, value):
@@ -100,7 +101,7 @@ class BlockImageColumn(tables.Column):
blocks = [] blocks = []
for obj in value.all(): for obj in value.all():
blocks.append(obj.get_block()) blocks.append(obj.get_block())
return format_html( return mark_safe(
"""<span class='multi-image-block'> """<span class='multi-image-block'>
{} {}
</span>""".format("".join(blocks)) </span>""".format("".join(blocks))
@@ -112,9 +113,7 @@ class SeriesImageColumn(tables.Column):
images = value.all() images = value.all()
if not images: if not images:
return format_html( return mark_safe('<span>None</span><br/>')
'<span>None<span><br/>',
)
obj = findMiddle(images) obj = findMiddle(images)
@@ -141,7 +140,7 @@ class FirstImageColumn(tables.Column):
obj = value.all() obj = value.all()
if not obj: if not obj:
return format_html('<span>No image<span>') return mark_safe('<span>No image</span>')
image_object = obj[0].image image_object = obj[0].image
try: try:
@@ -196,7 +195,7 @@ class CidUserTable(SelectionTable):
if record.supervisor is not None: if record.supervisor is not None:
return format_html("""{}\n[{}]""", record.email, record.supervisor.email) return format_html("""{}\n[{}]""", record.email, record.supervisor.email)
else: else:
return format_html(record.email) return record.email
class CidUserExamTable(SelectionTable): class CidUserExamTable(SelectionTable):
@@ -249,7 +248,7 @@ class CidUserExamTable(SelectionTable):
if record.supervisor is not None: if record.supervisor is not None:
return format_html("""{}\n[{}]""", record.email, record.supervisor.email) return format_html("""{}\n[{}]""", record.email, record.supervisor.email)
else: else:
return format_html(record.email) return record.email
# def render_cid(self, value, record): # def render_cid(self, value, record):
+36 -9
View File
@@ -15,16 +15,43 @@
{% block content %} {% block content %}
{% endblock %} {% endblock %}
{% block navigation %} {% block navigation %}
Candidates: <nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Candidates</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#genericNavbar" aria-controls="genericNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"><i class="bi bi-text-indent-right"></i></span>
</button>
<div class="collapse navbar-collapse" id="genericNavbar">
<ul class="navbar-nav">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<a href="{% url 'generic:manage_cids' %}">Cids</a> / <li class="nav-item dropdown">
<a href="{% url 'generic:manage_cid_exams' %}">Cids (exams)</a> / <a class="nav-link dropdown-toggle" href="#" id="navCids" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-journal-text me-1"></i> Cids</a>
<a href="{% url 'generic:cid_group_view' %}">Cid Groups</a> / <ul class="dropdown-menu" aria-labelledby="navCids">
<a href="{% url 'generic:user_group_view' %}">User Groups</a> / <li><a class="dropdown-item" href="{% url 'generic:manage_cids' %}"><i class="bi bi-file-earmark-text me-1"></i> All Cids</a></li>
<a href="{% url 'trainees' %}">Trainees</a> / <li><a class="dropdown-item" href="{% url 'generic:manage_cid_exams' %}"><i class="bi bi-mortarboard me-1"></i> Cids (exams)</a></li>
<a href="{% url 'accounts_list' %}">Manage Users</a> / </ul>
<a href="{% url 'generic:supervisor' %}">Manage Supervisors</a> / </li>
<a href="{% url 'generic:examcollection_list' %}">Collections</a>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navGroups" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-people me-1"></i> Groups</a>
<ul class="dropdown-menu" aria-labelledby="navGroups">
<li><a class="dropdown-item" href="{% url 'generic:cid_group_view' %}"><i class="bi bi-people-fill me-1"></i> Cid Groups</a></li>
<li><a class="dropdown-item" href="{% url 'generic:user_group_view' %}"><i class="bi bi-person-lines-fill me-1"></i> User Groups</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navUsers" role="button" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-person-badge me-1"></i> People</a>
<ul class="dropdown-menu" aria-labelledby="navUsers">
<li><a class="dropdown-item" href="{% url 'trainees' %}"><i class="bi bi-people me-1"></i> Trainees</a></li>
<li><a class="dropdown-item" href="{% url 'accounts_list' %}"><i class="bi bi-gear me-1"></i> Manage Users</a></li>
<li><a class="dropdown-item" href="{% url 'generic:supervisor' %}"><i class="bi bi-person-workspace me-1"></i> Manage Supervisors</a></li>
</ul>
</li>
{% endif %} {% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %} {% endblock %}
@@ -1,120 +1,124 @@
{% extends 'generic/cid_group_base.html' %} {% extends 'generic/cid_group_base.html' %}
{% block content %} {% block content %}
<h2>Group: {{group.name}}</h2> <div class="card mb-3">
<div class="card-body">
<details> <div class="d-flex justify-content-between align-items-start">
<summary>Help</summary>
<p>This page shows an overview of the candidates and exams that are associated with the group.</p>
<p>Once a candidate has been added to a group and the group has been added to an exam individual candidates can be allocated to the exam. Please note: simply adding the group to the exam will not automatically give the group candidates access to that exam, they will have to be added individually/as a group.</p>
</details>
<h3>Users</h3>
<p title="The number of users that have been added to this group">Count: {{users|length}}</p>
{% if users %}
<table>
<thead>
{% if group_type == "cid" %}
<tr><th>Name</th><th>Email</th><th>CID</th><th>Passcode</th></tr>
{% else %}
<tr><th>User</th></tr>
{% endif %}
</thead>
{% for user in users %}
{% if group_type == "cid" %}
<tr><td>{{user.name}}</td><td>{{user.email}}</td><td>{{user.cid}}</td><td>{{user.passcode}}</td></tr>
{% else %}
<tr><td>{{user.username}}</td></tr>
{% endif %}
{% endfor %}
</table>
{% else %}
This group has no users.
{% if group_type == "cid" %}
They can be created (or added) <a href="{% url 'generic:manage_cids' %}">here</a>
{% endif %}
{% endif %}
<div class="border border-secondary">
<details>
<summary >Add users by email</summary>
<div> <div>
<form method="post"> <h2 class="card-title mb-1">Group: {{ group.name }}</h2>
{% csrf_token %} <div class="small text-muted">This page shows an overview of the candidates and exams associated with the group.</div>
<textarea name="emails" placeholder="Email addresses (one per line)"></textarea> </div>
<button type="submit" <div class="text-end">
hx-post="{% url 'generic:user_group_add_by_email_user' group.pk %}" <div class="small text-muted">Users in group</div>
hx-target="#add-users-by-email-results" <div class="fw-bold">{{ users|length }}</div>
>Add</button> </div>
</form> </div>
<div id="add-users-by-email-results"></div>
<div class="mt-3">
<details>
<summary class="small">Help</summary>
<div class="small text-muted mt-2">
Once a candidate has been added to a group and the group has been added to an exam individual candidates can be allocated to the exam. Adding a group to an exam does not automatically give group members access — they must be added.
</div> </div>
</details> </details>
</div> </div>
</div>
</div>
<h3>Exams</h3> <div class="card mb-3">
The group is currently associated with the following exams. This allows the users within the group to be easily added to the exam. It will not automatically give members of the group access to the exam. <div class="card-body">
<h3 class="card-title mb-2">Users</h3>
<div class="d-flex align-items-center mb-2">
<div class="me-auto small text-muted">Sort by:</div>
<div class="btn-group btn-group-sm user-sort-group" role="group" aria-label="Sort">
<button type="button" class="btn btn-sm btn-outline-secondary sort-btn" data-key="username">{% if group_type == 'cid' %}CID{% else %}Username{% endif %} <span class="sort-caret"></span></button>
<button type="button" class="btn btn-sm btn-outline-secondary sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
<button type="button" class="btn btn-sm btn-outline-secondary sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
</div>
</div>
{% if group_type == 'cid' %}
<div class="mb-2">
<form method="post" hx-post="{% url 'generic:cid_group_generate_preview' group.pk %}" hx-target="#cid-generate-preview" hx-swap="innerHTML">
{% csrf_token %}
<label class="small text-muted">Quick generate users (one per line: name,email)</label>
<textarea name="rows" class="form-control form-control-sm mb-2" rows="5" placeholder="John Smith,j.smith@example.org\nJane Doe,jane@example.org"></textarea>
<div>
<button type="submit" class="btn btn-sm btn-outline-primary">Preview</button>
</div>
</form>
<div id="cid-generate-preview"></div>
</div>
{% endif %}
<div id="group-users-wrap">
{% if users %}
{% include 'generic/partials/group_users_list.html' with users=users group_type=group_type %}
{% else %}
<p class="small text-muted">This group has no users.
{% if group_type == "cid" %}
You can create or add users <a href="{% url 'generic:manage_cids' %}">here</a>.
{% endif %}
</p>
{% endif %}
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h3 class="card-title mb-2">Exams</h3>
<div class="small text-muted mb-2">The group is associated with these exams. Adding the group to an exam helps add members quickly but does not grant automatic access.</div>
<div class="mb-2">
{% if group_type == "cid" %} {% if group_type == "cid" %}
Add more <a href='{% url "generic:cid_group_exams" group.pk %}'>here</a>. Add more <a href='{% url "generic:cid_group_exams" group.pk %}'>here</a>.
{% else %} {% else %}
Add more <a href='{% url "generic:user_group_exams" group.pk %}'>here</a>. Add more <a href='{% url "generic:user_group_exams" group.pk %}'>here</a>.
{% endif %} {% endif %}
<p>It is possible to quickly add or remove all group candidates from an exam by clicking the buttons below. This will only affect the <b>{{users|length}}</b> candidates in this group. You will need to refresh the page to see updated candidate counts.</p> </div>
{% with group.GetGroupExams as exam_map %} {% with group.GetGroupExams as exam_map %}
{% for key, value in exam_map.items %} {% for key, value in exam_map.items %}
<h4>{{key}}</h4> <h5 class="mt-3">{{ key }}</h5>
<div> <div class="list-group list-group-flush">
{% for exam in value %} {% for exam in value %}
<div class="d-flex"> <div class="list-group-item bg-transparent border-0 py-2 d-flex align-items-center">
<a href="{{exam.get_absolute_url}}" class="px-2">{{exam}}</a> <div>
<a href="{{ exam.get_absolute_url }}" class="fw-bold">{{ exam }}</a>
<div class="small text-muted">{{ exam }}</div>
</div>
<div class="ms-3">
{% if group_type == "cid" %} {% if group_type == "cid" %}
<a href="{{exam.get_cid_edit_url}}" class="edit-link" px-2>Edit candidates</a> <a href="{{ exam.get_cid_edit_url }}" class="edit-link small">Edit candidates</a>
<form> {% else %}
<a href="{{ exam.get_user_edit_url }}" class="edit-link small">Edit candidates</a>
{% endif %}
</div>
<div class="ms-auto">
<div class="btn-group btn-group-sm" role="group">
{% if group_type == "cid" %}
<form class="m-0 d-inline">
<input type="hidden" name="exam_id" value="{{ exam.pk }}"> <input type="hidden" name="exam_id" value="{{ exam.pk }}">
<input type="hidden" name="exam_type" value="{{ exam.app_name }}"> <input type="hidden" name="exam_type" value="{{ exam.app_name }}">
<button <button class="btn btn-sm btn-outline-success hx-action-btn" hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}" name="action" value="add">Add</button>
class="btn btn-sm px-2" <button class="btn btn-sm btn-outline-danger hx-action-btn" hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}" name="action" value="remove">Remove</button>
hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}"
title="Add all group candidates to this exam"
name="action"
value="add"
>Add group candidates</button>
<button
hx-post="{% url 'generic:cid_group_add_candidates_to_exams' group.pk %}"
title="Remove all group candidates to this exam"
class="btn btn-sm px-2"
name="action"
value="remove"
>Remove group candidates</button>
</form> </form>
{% else %} {% else %}
<a href="{{exam.get_user_edit_url}}" class="edit-link px-2">Edit candidates</a> <form class="m-0 d-inline">
<form>
<input type="hidden" name="exam_id" value="{{ exam.pk }}"> <input type="hidden" name="exam_id" value="{{ exam.pk }}">
<input type="hidden" name="exam_type" value="{{ exam.app_name }}"> <input type="hidden" name="exam_type" value="{{ exam.app_name }}">
<button <button class="btn btn-sm btn-outline-success hx-action-btn" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" name="action" value="add">Add</button>
class="btn btn-sm px-2" <button class="btn btn-sm btn-outline-danger hx-action-btn" hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}" name="action" value="remove">Remove</button>
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
title="Add all group candidates to this exam"
name="action"
value="add"
>Add group candidates</button>
<button
hx-post="{% url 'generic:user_group_add_candidates_to_exams' group.pk %}"
title="Remove all group candidates to this exam"
class="btn btn-sm px-2"
name="action"
value="remove"
>Remove group candidates</button>
</form> </form>
{% endif %} {% endif %}
</div>
</div>
<div class="px-3 ms-2 small text-muted">
<div class="px-2 ms-auto ">
{% if group_type == "cid" %} {% if group_type == "cid" %}
(Cid candidate count: {{ exam.valid_cid_users.count }}) (Cid candidate count: {{ exam.valid_cid_users.count }})
{% else %} {% else %}
@@ -126,17 +130,122 @@
</div> </div>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
</div>
</div>
{% endblock %} {% endblock %}
{% block js %} {% block js %}
<style> <style>
td, th { padding-left: 10px } .edit-link { font-size: 0.85rem; opacity: 0.7; }
.list-group-item .fw-bold { font-size: 0.95rem; }
.edit-link { .card-title { font-size: 1rem; }
font-size: smaller; /* keep existing small visual tweaks */
opacity: 60%; .sort-caret { font-weight: 600; }
}
</style> </style>
<script>
document.addEventListener('DOMContentLoaded', function(){
(function(){
const sortGroup = document.querySelector('.user-sort-group');
if(!sortGroup) return;
let sortState = { key: 'username', dir: 'asc' };
function updateCarets(){
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){
const key = b.dataset.key;
const caret = b.querySelector('.sort-caret');
if(!caret) return;
caret.textContent = (sortState.key === key) ? (sortState.dir === 'asc' ? ' ▲' : ' ▼') : '';
});
}
function getVal(li, key){
if(!li) return '';
return (li.dataset[key] || '').toString().toLowerCase();
}
function sortList(key){
const ul = document.getElementById('group-users-list'); if(!ul) return;
const items = Array.from(ul.children);
const dir = (sortState.key === key && sortState.dir === 'asc') ? 'desc' : 'asc';
items.sort(function(a,b){ const va = getVal(a,key); const vb = getVal(b,key); if(va < vb) return dir === 'asc' ? -1 : 1; if(va > vb) return dir === 'asc' ? 1 : -1; return 0; });
items.forEach(function(it){ ul.appendChild(it); });
sortState = { key: key, dir: dir };
updateCarets();
}
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortList(b.dataset.key); }); });
updateCarets();
})();
});
</script>
<script>
// Send/resend login email handlers for CID users
document.addEventListener('DOMContentLoaded', function(){
document.body.addEventListener('click', function(e){
const btn = e.target.closest && e.target.closest('.send-login-email-btn');
if(!btn) return;
e.preventDefault();
const url = btn.dataset.url + '?resend=1';
const cid = btn.dataset.cid;
// show spinner on button
btn.disabled = true;
const spinner = document.createElement('span'); spinner.className = 'spinner-border spinner-border-sm ms-2'; spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true'); btn.appendChild(spinner);
fetch(url, { method: 'GET', credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const li = btn.closest('li');
if(!li) return;
const status = li.querySelector('.login-email-status');
if(data.sent){
if(status){ status.textContent = 'Email sent'; status.className = 'badge bg-success ms-1 login-email-status'; }
} else {
if(status){ status.textContent = 'Not sent'; status.className = 'badge bg-secondary ms-1 login-email-status'; }
if(data.comment){ console.error('Email send comment:', data.comment); }
}
}).catch(err => { console.error('Failed to send login email', err); })
.finally(()=>{ if(btn){ const s = btn.querySelector('.spinner-border'); if(s) s.remove(); btn.disabled = false; } });
});
});
// Visual indicators for HTMX add/remove actions
document.addEventListener('DOMContentLoaded', function(){
document.body.addEventListener('htmx:beforeRequest', function(evt){
try{
const elt = evt.detail.elt;
if(!elt) return;
const btn = elt.closest && elt.closest('.hx-action-btn') ? elt.closest('.hx-action-btn') : (elt.classList && elt.classList.contains('hx-action-btn') ? elt : null);
if(!btn) return;
// stash original HTML for restore
btn.dataset._origHtml = btn.innerHTML;
btn.disabled = true;
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true');
spinner.dataset._hx_spinner = '1';
btn.appendChild(spinner);
}catch(e){ console.error(e); }
});
document.body.addEventListener('htmx:afterRequest', function(evt){
try{
const elt = evt.detail.elt;
if(!elt) return;
const btn = elt.closest && elt.closest('.hx-action-btn') ? elt.closest('.hx-action-btn') : (elt.classList && elt.classList.contains('hx-action-btn') ? elt : null);
if(!btn) return;
// remove spinner(s)
const sp = btn.querySelectorAll('span[data-_hx_spinner]');
sp.forEach(s => s.remove());
// show transient status
const status = document.createElement('span');
status.className = 'badge bg-success ms-2 hx-action-status';
status.textContent = 'Done';
btn.appendChild(status);
// restore after short delay
setTimeout(function(){
if(btn.dataset._origHtml){ btn.innerHTML = btn.dataset._origHtml; delete btn.dataset._origHtml; }
btn.disabled = false;
}, 1500);
}catch(e){ console.error(e); }
});
});
</script>
{% endblock %} {% endblock %}
@@ -4,12 +4,84 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block css %} {% block css %}
<style>
/* Simple modal for showing JSON debug info */
.hx-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 1200;
}
.hx-modal {
background: #fff;
max-width: 90%;
max-height: 80%;
overflow: auto;
padding: 1rem;
border-radius: 6px;
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
font-family: monospace;
}
.hx-modal pre { white-space: pre-wrap; word-wrap: break-word; }
.hx-modal .hx-close { float: right; cursor: pointer; margin-left: 0.5rem; }
</style>
{% endblock %} {% endblock %}
{% block js %} {% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>--> <!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
{{form.media}} {{form.media}}
<!-- htmx for making the request; include if not already present -->
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
<script type="text/javascript"> <script type="text/javascript">
(function(){
// Listen for HTMX requests and show the JSON response inside our modal.
document.addEventListener('htmx:afterRequest', function(evt){
try {
// We only care about requests triggered by our debug button
var trigger = evt.detail.elt;
if(!trigger) return;
if(trigger.id !== 'show-exam-json') return;
var xhr = evt.detail.xhr;
if(!xhr) return;
var txt = xhr.responseText || '';
var json = null;
try { json = JSON.parse(txt); }
catch(e) { json = null; }
var out = '';
if(json !== null) {
out = JSON.stringify(json, null, 2);
} else {
// If not valid JSON, just show raw text
out = txt;
}
var overlay = document.getElementById('hx-debug-overlay');
var pre = document.getElementById('hx-debug-pre');
pre.textContent = out;
overlay.style.display = 'flex';
} catch (e) {
console.error('hx debug modal error', e);
}
});
// Close button and overlay click
document.addEventListener('DOMContentLoaded', function(){
var overlay = document.getElementById('hx-debug-overlay');
if(!overlay) return;
overlay.addEventListener('click', function(ev){
if(ev.target === overlay) overlay.style.display = 'none';
});
var close = document.getElementById('hx-debug-close');
if(close) close.addEventListener('click', function(){ overlay.style.display = 'none'; });
});
})();
</script> </script>
<!-- {{ form.media }} --> <!-- {{ form.media }} -->
@@ -48,4 +120,23 @@
These should be edited via the exam candidate page. If you need to manually toggle you can do so <a href="{% url 'generic:update_cid_exams' object.pk %}">here</a> (you shouldn't) These should be edited via the exam candidate page. If you need to manually toggle you can do so <a href="{% url 'generic:update_cid_exams' object.pk %}">here</a> (you shouldn't)
</p> </p>
<!-- HTMX debug button to show what the /exam/json/<cid>/<passcode> response will be -->
<p>
<button id="show-exam-json" class="button"
hx-get="{% url 'active_exams_cid' ciduser.cid ciduser.passcode %}"
hx-trigger="click"
hx-swap="none">
Show exam JSON (debug)
</button>
</p>
<!-- Modal overlay for JSON output -->
<div id="hx-debug-overlay" class="hx-modal-overlay" role="dialog" aria-hidden="true">
<div class="hx-modal" role="document">
<button id="hx-debug-close" class="hx-close">Close</button>
<h3>Exam JSON (debug)</h3>
<pre id="hx-debug-pre">(loading...)</pre>
</div>
</div>
{% endblock %} {% endblock %}
@@ -0,0 +1,12 @@
{% extends "generic/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>Edit CidUserExam record</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-primary">Save</button>
</form>
{% endblock %}
@@ -23,28 +23,46 @@
<form action="" method="post" enctype="multipart/form-data" id="condition-form"> <form action="" method="post" enctype="multipart/form-data" id="condition-form">
{% csrf_token %} {% csrf_token %}
<table> <div class="card mb-3">
{{ form.as_table }} <div class="card-body">
</table> <div class="row">
<input type="submit" class="submit-button" value="Submit" name="submit"> <div class="col-12 col-md-8">
{% for field in form.visible_fields %}
<div class="mb-3">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<div class="form-text text-muted">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="text-danger small">{{ field.errors|striptags }}</div>
{% endif %}
</div>
{% endfor %}
<div class="mt-2">
<button type="submit" class="btn btn-primary btn-sm">Save group</button>
<a href="{% url 'generic:manage_cids' %}" class="btn btn-link btn-sm ms-2">Manage CIDs</a>
</div>
</div>
<div class="col-12 col-md-4">
{% if cidusergroup %} {% if cidusergroup %}
<p>Users can be added to the group <a href="{% url 'generic:manage_cids' %}">here</a></p> <div class="small text-muted mb-2">Users currently in this group</div>
<ul class="list-group list-group-flush small">
<h3>Users</h3>
<ul>
{% for user in cidusergroup.ciduser_set.all %} {% for user in cidusergroup.ciduser_set.all %}
<li><a href="{% url 'generic:update_cid' user.pk %}">{{user.cid}}</a>: {{user.name}} [{{user.email}} {{user.last_name}}] {{user.userprofile.grade}}</li> <li class="list-group-item bg-transparent border-0 px-0 py-1">
<a href="{% url 'generic:update_cid' user.pk %}">{{ user.cid }}</a>
<div class="text-muted">{{ user.name }} &middot; {{ user.email }}</div>
</li>
{% empty %} {% empty %}
<li>Group currently has no users.</li> <li class="list-group-item bg-transparent border-0 px-0 py-1 text-muted">Group currently has no users.</li>
{% endfor %} {% endfor %}
</ul> </ul>
Count: {{cidusergroup.ciduser_set.count}} <div class="small text-muted mt-2">Count: {{ cidusergroup.ciduser_set.count }}</div>
{% endif %} {% endif %}
</div>
</div>
</div>
</div>
</form> </form>
{% endblock %} {% endblock %}
+301 -127
View File
@@ -1,147 +1,321 @@
<div id="history-modal" {% block content %}
class="modal modal-blur fade" <div class="container py-3">
style="display: none" <div class="row mb-3">
aria-hidden="false" <div class="col-12 d-flex justify-content-between align-items-center">
tabindex="-1"> <h3 class="mb-0">Candidates for {{ exam.get_exam_name }}</h3>
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<h2>Exam user history:
<span id="history-user"></span>
</h2>
<div class="modal-content"></div>
</div>
</div>
<div class="card text-white bg-dark">
<h3>CID candidates</h3>
{% if cid_users %}
<p>{{cid_user_count}} CID candidates.</p>
<div id="htmx-info"></div>
<ol class="cid-candidate-list">
{% for cid in cid_users %}
<li class="cid-user" data-cid={{cid.cid}}><div class="cid-data">
<i class="bi bi-clock-history"
title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
></i>
<a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}]</div><div class="cid-name">{{cid.name}}</div> <div class="cid-email">Email: {{cid.email}}</div>
</li>
{% endfor %}
</ol>
{% else %}
<p>This exam has no CID candidates. Add some with the below link.</p>
{% endif %}
<a href="{% url exam.app_name|add:':exam_cids_edit' exam.pk %}">Edit Exam CIDs</a>
</div>
<br/>
<div class="card text-white bg-dark">
<h3>User candidates</h3>
{% if user_users %}
<p>{{user_user_count}} User candidates.</p>
<ol class="user-candidate-list">
{% for user in user_users %}
<li class="user-user" data-user="{{user.username}}">
<div> <div>
<i class="bi bi-clock-history" <a class="btn btn-sm btn-outline-light" href="{% url exam.app_name|add:':exam_cids_edit' exam.pk %}">Edit Exam CIDs</a>
title="View user exam history" <a class="btn btn-sm btn-outline-light ms-2" href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}" </div>
hx-target=".modal-content" </div>
data-bs-toggle="modal"
data-bs-target="#history-modal"
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
></i>
{% if request.user.is_superuser %}
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
{{user.username}}</div>
<div>Email: {{user.email}}</div>
</li>
{% endfor %}
</ol>
{% else %}
<p>This exam has no User candidates. Add some with the below link.</p>
{% endif %}
<a href="{% url exam.app_name|add:':exam_users_edit' exam.pk %}">Edit Exam Users</a>
</div> </div>
<br/> <div class="row">
<div class="col-lg-6 mb-3">
{% if user_exam_data %} <div class="card h-100">
<div class="card text-white bg-dark"> <div class="card-body">
<details> <h5 class="card-title">CID candidates <small class="text-muted">({{ cid_user_count }})</small></h5>
<summary> <div class="mb-2 input-group input-group-sm">
<h3>Submitted candidates</h3> <input id="cid-filter" class="form-control form-control-sm" placeholder="Filter CID candidates by CID, name, email or passcode">
</summary> <button type="button" id="cid-filter-clear" class="btn btn-outline-secondary d-none" title="Clear filter">×</button>
The following candidate submissions have been received. Candidates with submissions will be shown in green above. <span id="cid-filter-indicator" class="input-group-text d-none">Filtered</span>
<ul id="submitted-candidates"> </div>
{% for user_data in user_exam_data %} <div id="htmx-info"></div>
<li data-cid="{{user_data.cid_user}}" data-user="{{user_data.user_user}}"> <div id="cid-list" hx-get="{% url exam.app_name|add:':exam_cids_cid_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
CID: {{user_data.cid_user}} / <ul class="list-group list-group-flush">
User: {{user_data.user_user}} / <li class="list-group-item">
Start time: {{user_data.start_time}} / <div class="d-flex align-items-center placeholder-glow">
End time: {{user_data.end_time}} / <span class="placeholder col-2 me-2"></span>
<span class="placeholder col-4 me-2"></span>
<span class="placeholder col-3"></span>
</div>
</li>
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-2 me-2"></span>
<span class="placeholder col-4 me-2"></span>
<span class="placeholder col-3"></span>
</div>
</li>
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-2 me-2"></span>
<span class="placeholder col-4 me-2"></span>
<span class="placeholder col-3"></span>
</div>
</li> </li>
{% endfor %}
</ul> </ul>
</details>
</div> </div>
<br/> </div>
{% endif %} </div>
<span </div>
title="View all user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status' exam.id %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
_="on htmx:afterOnLoad put {{cid.cid}} into #history-user.innerHTML "
>
Exam history <i class="bi bi-clock-history"></i>
</span>
<div class="col-lg-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">User candidates <small class="text-muted">({{ user_user_count }})</small></h5>
<div class="mb-2 input-group input-group-sm">
<input id="user-filter" class="form-control form-control-sm" placeholder="Filter User candidates by username or email">
<button type="button" id="user-filter-clear" class="btn btn-outline-secondary d-none" title="Clear filter">×</button>
<span id="user-filter-indicator" class="input-group-text d-none">Filtered</span>
</div>
<div id="user-list" hx-get="{% url exam.app_name|add:':exam_cids_user_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-3 me-2"></span>
<span class="placeholder col-4"></span>
</div>
</li>
<li class="list-group-item">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-3 me-2"></span>
<span class="placeholder col-4"></span>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% if has_submissions %}
<div id="submitted-list" hx-get="{% url exam.app_name|add:':exam_cids_submitted_list' exam.id %}" hx-trigger="load" hx-swap="innerHTML">
<div class="card">
<div class="card-body">
<div class="placeholder-glow">
<h5 class="card-title placeholder col-4"></h5>
<p class="placeholder col-8"></p>
<ul class="list-unstyled small">
<li class="placeholder col-6 mb-2"></li>
<li class="placeholder col-6 mb-2"></li>
</ul>
</div>
</div>
</div>
</div>
{% endif %}
<div class="row mt-3">
<div class="col-12">
<button class="btn btn-sm btn-outline-secondary" title="View all user exam history" hx-get="{% url exam.app_name|add:':exam_user_status' exam.id %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal">Exam history <i class="bi bi-clock-history"></i></button>
</div>
</div>
<!-- HTMX modal for history / partials -->
<div id="history-modal" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Exam user history: <span id="history-user"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %} {% block js %}
<script> <script>
$(document).ready(function() { (function(){
// Highlight users that have submitted answers // Utilities to run both on initial load and after HTMX swaps
$("#submitted-candidates li").each(function(n, el) {
console.log(el); function normalizeText(s){ return (s||'').toString().toLowerCase(); }
if (el.dataset.cid != "None") {
$(`.cid-user[data-cid=${el.dataset.cid}]`).addClass("submitted") function filterList(inputElem, listSelector, indicatorId, clearBtnId){
} const q = normalizeText(inputElem.value.trim());
if (el.dataset.user != "None") { document.querySelectorAll(listSelector).forEach(function(li){
$(`.user-user[data-user="${el.dataset.user}"]`).addClass("submitted") const text = normalizeText(li.textContent || '');
if(!q || text.indexOf(q) !== -1){
try{ li.style.removeProperty('display'); }catch(e){}
li.classList.remove('d-none');
} else {
try{ li.style.setProperty('display','none','important'); }catch(e){ li.style.display = 'none'; }
li.classList.add('d-none');
} }
}); });
}) // Toggle indicator & clear button
try{
const ind = document.getElementById(indicatorId);
const clearBtn = document.getElementById(clearBtnId);
if(ind) ind.classList.toggle('d-none', !q);
if(clearBtn) clearBtn.classList.toggle('d-none', !q);
}catch(e){}
}
function initTooltips(){
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el){
try{ new bootstrap.Tooltip(el); }catch(e){/* ignore if bootstrap not available */}
});
}
// Exam-level state exposed to the script so we can show Reviewed badges
const examResultsPublished = {{ exam.publish_results|yesno:'true,false' }};
function highlightSubmitted(){
// Find any submitted rows (table rows or list items) that include
// data attributes for cid/user. Then insert a small badge on matching list items.
// Collect submitted identifiers first to avoid duplicate processing
const submittedEls = document.querySelectorAll('#submitted-candidates [data-cid], #submitted-candidates [data-user]');
// Build maps of submitted identifiers -> their timestamps so we
// can attach tooltip text to the badges we create below.
const submittedCids = new Map();
const submittedUsers = new Map();
submittedEls.forEach(function(el){
try{
const cid = el.dataset && el.dataset.cid ? el.dataset.cid.toString() : null;
const user = el.dataset && el.dataset.user ? el.dataset.user.toString() : null;
const first = el.dataset && el.dataset.first ? el.dataset.first.toString() : '';
const last = el.dataset && el.dataset.last ? el.dataset.last.toString() : '';
if(cid && cid !== 'None') submittedCids.set(cid, {first:first, last:last});
if(user && user !== 'None') submittedUsers.set(user, {first:first, last:last});
}catch(e){ console.error('highlightSubmitted collect error', e); }
});
// Helper to ensure a single badge exists inside an li and update its tooltip
function ensureBadge(li, selector, className, text, title){
// Prefer the explicit badge container if present
const container = li.querySelector('.badge-container') || li;
let b = container.querySelector(selector);
if(!b){
b = document.createElement('span');
b.className = className;
b.textContent = text;
container.appendChild(b);
}
if(title){
b.setAttribute('title', title);
b.setAttribute('data-bs-toggle', 'tooltip');
}
return b;
}
// Add/update badges for CID list items
if(submittedCids.size){
document.querySelectorAll('#cid-list .list-group-item').forEach(function(li){
try{
const link = li.querySelector && li.querySelector('.cid-link');
const text = (link && link.textContent) ? link.textContent.trim() : (li.textContent||'').trim();
if(submittedCids.has(text)){
const info = submittedCids.get(text) || {first:'', last:''};
// add Started badge if start time exists
if(info.first){
const t = `Started\nFirst: ${info.first}`;
ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t);
}
// Only show a 'Completed' badge when the candidate has an end time
if(info.last){
const tComp = `Completed\nFirst: ${info.first}\nLast: ${info.last}`;
ensureBadge(li, '.completed-badge', 'completed-badge badge bg-primary ms-2', 'Completed', tComp);
// If the exam results have been published (reviewed), show Reviewed/Submitted badge in green
if(examResultsPublished){
const t = `Reviewed\nFirst: ${info.first}\nLast: ${info.last}`;
ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Reviewed', t);
}
}
}
}catch(e){ console.error('highlightSubmitted CID item error', e); }
});
}
// Add/update badges for User list items
if(submittedUsers.size){
document.querySelectorAll('#user-list .list-group-item').forEach(function(li){
try{
const txt = (li.textContent||'').trim();
for(const user of submittedUsers.keys()){
if(txt.indexOf(user) !== -1){
const info = submittedUsers.get(user) || {first:'', last:''};
if(info.first){
const t = `Started\nFirst: ${info.first}`;
ensureBadge(li, '.started-badge', 'started-badge badge bg-info ms-2', 'Started', t);
}
if(info.last){
const tComp = `Completed\nFirst: ${info.first}\nLast: ${info.last}`;
ensureBadge(li, '.completed-badge', 'completed-badge badge bg-primary ms-2', 'Completed', tComp);
if(examResultsPublished){
const t = `Reviewed\nFirst: ${info.first}\nLast: ${info.last}`;
ensureBadge(li, '.submitted-badge', 'submitted-badge badge bg-success ms-2', 'Reviewed', t);
}
}
break;
}
}
}catch(e){ console.error('highlightSubmitted User item error', e); }
});
}
}
// Wire filters
document.addEventListener('DOMContentLoaded', function(){
const cidFilter = document.getElementById('cid-filter');
if(cidFilter && !cidFilter.dataset.bound){
cidFilter.addEventListener('input', function(){ filterList(cidFilter, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); });
cidFilter.dataset.bound = '1';
}
const userFilter = document.getElementById('user-filter');
if(userFilter && !userFilter.dataset.bound){
userFilter.addEventListener('input', function(){ filterList(userFilter, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); });
userFilter.dataset.bound = '1';
}
// Clear buttons
const cidClear = document.getElementById('cid-filter-clear');
if(cidClear && !cidClear.dataset.bound){
cidClear.addEventListener('click', function(){ const f=document.getElementById('cid-filter'); if(f){ f.value=''; filterList(f, '#cid-list .list-group-item', 'cid-filter-indicator', 'cid-filter-clear'); }});
cidClear.dataset.bound = '1';
}
const userClear = document.getElementById('user-filter-clear');
if(userClear && !userClear.dataset.bound){
userClear.addEventListener('click', function(){ const f=document.getElementById('user-filter'); if(f){ f.value=''; filterList(f, '#user-list .list-group-item', 'user-filter-indicator', 'user-filter-clear'); }});
userClear.dataset.bound = '1';
}
// initial runs (may be no partials yet)
initTooltips();
highlightSubmitted();
});
// After HTMX swaps, re-run highlight and tooltips when our partials load
document.body.addEventListener('htmx:afterSwap', function(evt){
try{
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
if(!target) return;
const id = target.id || (target.closest && target.closest('[id]') ? target.closest('[id]').id : null);
if(id === 'cid-list' || id === 'user-list' || id === 'submitted-list'){
initTooltips();
// submitted-list may have loaded now; re-run highlighting
highlightSubmitted();
// Re-run current filters for newly-loaded partials
try{
const cf = document.getElementById('cid-filter');
if(cf) filterList(cf, '#cid-list .list-group-item');
const uf = document.getElementById('user-filter');
if(uf) filterList(uf, '#user-list .list-group-item');
}catch(e){ /* ignore */ }
}
}catch(e){ console.error(e); }
});
})();
</script> </script>
{% endblock js %} {% endblock %}
{% block css %} {% block css %}
<style> <style>
.submitted { .cid-name { margin-top: 0.25rem; }
color: green; .cid-link { font-size: 1.05rem; }
} .submitted { color: green; }
.cid-notes { font-style: italic; color: #6c757d; }
</style> </style>
{% endblock css %} {% endblock %}
+261 -78
View File
@@ -1,97 +1,280 @@
{% include "generic/exam_candidate_headers.html" %} {% include "generic/exam_candidate_headers.html" %}
<h3>CID Users</h3>
<div class="card mb-2 bg-dark text-light border-0">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-0">CID Users</h5>
{% if groups %} {% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} . <div class="small text-muted mt-1">Users from the following groups are available:
{% for group in groups %}
<a href='{{group.get_absolute_url}}' class="badge bg-light text-dark border ms-1">{{group}}</a>
{% endfor %}
</div>
{% else %} {% else %}
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management. <div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
{% endif %} {% endif %}
<ol> </div>
{% for cid in current_cid_users %} <div class="btn-group">
{% if current_cid_users or available_cid_users %}
<li class="current"><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} / <button class="btn btn-sm btn-outline-light toggle-all-btn">Toggle all</button>
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</li>
{% endfor %}
{% for cid in available_cid_users %}
<li><a href="{% url 'generic:update_cid' cid.pk %}">{{cid.cid}}</a> [{{cid.passcode}}] {{cid.name}} /
Email: {{cid.email}} <button class="toggle-btn" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}">Toggle</button>
</li>
{% endfor %}
</ol>
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
<p>{% if current_cid_users or available_cid_users %}
<button class="toggle-all-btn">Toggle all</button>
{% endif %} {% endif %}
<button class="add-all-btn">Add all</button> <button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Add all</button>
<button class="remove-all-btn">Remove all</button> <button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_cid_bulk_edit' exam.pk %}">Remove all</button>
</div>
</div>
</p> <div class="d-flex align-items-center mb-2">
<div class="me-auto small text-muted">Sort by:</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Sort">
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="cid">CID <span class="sort-caret"></span></button>
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
</div>
</div>
{% include 'generic/partials/cid_user_list.html' with exam=exam current_cid_users=current_cid_users available_cid_users=available_cid_users %}
</div>
</div>
<script> <script>
$(document).ready(() => { document.addEventListener('DOMContentLoaded', function(){
$(".remove-all-btn").click((el) => { // Bulk Add / Remove using a single HTMX POST
$(".current .toggle-btn").click(); const bulkForm = document.createElement('form');
}); bulkForm.id = 'bulk-cid-form';
$(".add-all-btn").click((el) => { bulkForm.className = 'd-none';
$(":not(.current) .toggle-btn").click(); bulkForm.setAttribute('hx-target', '#cid-users-wrap');
}); bulkForm.setAttribute('hx-swap', 'outerHTML');
$(".toggle-all-btn").click((el) => { const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input'; bulkForm.appendChild(bulkInput);
$(".toggle-btn").click(); const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input'; bulkForm.appendChild(bulkAddInput);
}); // include csrf token
$(".toggle-btn").click((el) => { const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
let parent = $(el.target).parent() document.body.appendChild(bulkForm);
$.ajax({
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
data: {
csrfmiddlewaretoken: "{{csrf_token}}",
edit_cid_user: el.target.dataset.pk,
add: !parent.hasClass("current"),
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log("done", data);
if (data.status == "success") { function setLoading(btn, loading){
if (data.added) { if(!btn) return;
parent.addClass("current"); try{
toastr.info(`User ${el.target.dataset.cid} added to exam`) if(loading){
btn.dataset._origDisabled = btn.disabled ? '1' : '0';
btn.disabled = true;
const spinner = document.createElement('span');
spinner.className = 'btn-spinner spinner-border spinner-border-sm ms-2';
spinner.setAttribute('role','status');
spinner.setAttribute('aria-hidden','true');
btn.appendChild(spinner);
} else { } else {
parent.removeClass("current"); if(typeof btn.dataset._origDisabled !== 'undefined'){
toastr.info(`User ${el.target.dataset.cid} removed from exam`) btn.disabled = btn.dataset._origDisabled === '1';
delete btn.dataset._origDisabled;
}
} else { } else {
toastr.error(data.status) btn.disabled = false;
}
const s = btn.querySelector('.btn-spinner'); if(s) s.remove();
}
}catch(e){ /* ignore */ }
} }
})
.fail(function (data) {
console.log("fail", data);
toastr.error(data.responseJSON.status)
}) function submitBulk(url, pks, add, triggeringButton){
.always(function () { // prefer HTMX if available
}) if (window.htmx && typeof htmx.submit === 'function'){
if(triggeringButton) setLoading(triggeringButton, true);
bulkForm.setAttribute('hx-post', url);
bulkInput.value = JSON.stringify(pks);
if (add === null) {
bulkAddInput.disabled = true;
} else {
bulkAddInput.disabled = false;
bulkAddInput.value = add ? 'true' : 'false';
}
htmx.submit(bulkForm);
return;
}
// fallback: use fetch and swap the list manually
if(triggeringButton) setLoading(triggeringButton, true);
const fd = new FormData();
fd.append('bulk_pks', JSON.stringify(pks));
if (add !== null) fd.append('add', add ? 'true' : 'false');
const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
const headers = { 'X-CSRFToken': csrf };
fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' })
.then(resp => resp.text().then(text => ({ resp, text })))
.then(({ resp, text }) => {
// swap the list
try{
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
// Prefer replacing the whole wrapper (contains counts + list)
const newWrap = doc.querySelector('#cid-users-wrap');
if (newWrap){
const oldWrap = document.querySelector('#cid-users-wrap');
if (oldWrap) oldWrap.replaceWith(newWrap);
} else {
const newList = doc.querySelector('#cid-users-list');
if (newList){
const old = document.querySelector('#cid-users-list');
if (old) old.replaceWith(newList);
}
}
}catch(e){ console.error('bulk swap failed', e); }
// dispatch HX-Trigger equivalent if present
const trigger = resp.headers.get('HX-Trigger');
if (trigger){
try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){}
}
}).catch(err => { console.error('bulk request failed', err); })
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
}
document.querySelectorAll('.add-all-btn').forEach(function(btn){
btn.addEventListener('click', function(){
const url = btn.dataset.bulkUrl;
const items = document.querySelectorAll('#cid-users-list li:not(.current)');
const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number);
if (!pks.length) return;
submitBulk(url, pks, true, btn);
});
});
document.querySelectorAll('.remove-all-btn').forEach(function(btn){
btn.addEventListener('click', function(){
if(!confirm('Remove all selected users from the exam?')) return;
const url = btn.dataset.bulkUrl;
const items = document.querySelectorAll('#cid-users-list li.current');
const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number);
if (!pks.length) return;
submitBulk(url, pks, false, btn);
});
}); });
}) // Toggle all: send mixed per-item {pk, add} payload so server can apply add/remove per item
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){
btn.addEventListener('click', function(){
const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl;
// build mixed list: for each li include {pk, add}
const items = Array.from(document.querySelectorAll('#cid-users-list li')).map(function(li){
return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') };
});
if (!items.length) return;
submitBulk(url, items, null, btn);
});
});
// HTMX: listen for server HX-Trigger to show toasts for toggles and update counts
document.body.addEventListener('cid_toggled', function(evt){
try {
const detail = evt.detail || {};
const added = detail.added;
if (window.toastr) {
if (added) toastr.info(`User added to exam`);
else toastr.info(`User removed from exam`);
}
// Update counts for single-item toggles (bulk swaps replace the whole wrapper)
const addedEl = document.getElementById('cid-count-added');
const availEl = document.getElementById('cid-count-available');
if (addedEl && availEl){
let addedCount = parseInt(addedEl.textContent || '0') || 0;
let availCount = parseInt(availEl.textContent || '0') || 0;
if (added){ addedCount += 1; availCount = Math.max(0, availCount - 1); }
else { addedCount = Math.max(0, addedCount - 1); availCount += 1; }
addedEl.textContent = String(addedCount);
availEl.textContent = String(availCount);
}
} catch(e) { /* ignore */ }
});
// HTMX: clear loading indicators when requests finish
document.body.addEventListener('htmx:afterRequest', function(evt){
try{
const elt = (evt.detail && evt.detail.elt) || null;
let btn = null;
if(elt){
if(elt.tagName === 'BUTTON') btn = elt;
else if(elt.tagName === 'FORM') btn = elt.querySelector('button[type=submit]');
else btn = elt.closest && elt.closest('button') ? elt.closest('button') : null;
}
if(btn) setLoading(btn, false);
}catch(e){ }
});
// Delegate toggle button clicks so newly-inserted items work
document.body.addEventListener('click', function(e){
const btn = e.target.closest && e.target.closest('.toggle-btn');
if(!btn) return;
// find enclosing form
const form = btn.closest('form');
if(!form) return;
e.preventDefault();
setLoading(btn, true);
if(window.htmx && typeof htmx.submit === 'function'){
htmx.submit(form);
return;
}
// fallback: send via fetch and swap the nearest li with returned HTML
(async function(){
try{
const url = form.action || form.getAttribute('hx-post');
const fd = new FormData(form);
const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value;
const headers = { 'HX-Request': 'true' };
if(csrf) headers['X-CSRFToken'] = csrf;
const resp = await fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' });
const text = await resp.text();
// try to parse HTML and replace the closest li
try{
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
// the server should return the li outerHTML
const newLi = doc.querySelector('li');
const oldLi = form.closest('li');
if(newLi && oldLi){ oldLi.replaceWith(newLi); }
// dispatch any HX-Trigger header events
const trigger = resp.headers.get('HX-Trigger');
if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} }
}catch(err){ console.error('toggle parse/swap failed', err); }
}catch(err){ console.error('toggle request failed', err); }
finally{ setLoading(btn, false); }
})();
});
// Sorting: pure JS implementation
let currentSort = { key: 'cid', dir: 'asc' };
function updateCarets(){
document.querySelectorAll('.sort-btn').forEach(function(b){
const key = b.dataset.key;
const caret = b.querySelector('.sort-caret');
if (caret) caret.textContent = (currentSort.key === key) ? (currentSort.dir === 'asc' ? ' ▲' : ' ▼') : '';
});
}
function sortList(key){
const ul = document.getElementById('cid-users-list');
const items = Array.from(ul.children);
const dir = (currentSort.key === key && currentSort.dir === 'asc') ? 'desc' : 'asc';
items.sort(function(a,b){
const va = (a.dataset[key] || '').toString().toLowerCase();
const vb = (b.dataset[key] || '').toString().toLowerCase();
if (va < vb) return dir === 'asc' ? -1 : 1;
if (va > vb) return dir === 'asc' ? 1 : -1;
return 0;
});
items.forEach(function(it){ ul.appendChild(it); });
currentSort = { key: key, dir: dir };
updateCarets();
}
document.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortList(b.dataset.key); }); });
updateCarets();
});
</script> </script>
<style>
.current {
color: lightgray;
}
.current::before {
content: "[ADDED TO EXAM] - "
}
button { <style>
padding: 0; /* Compact, dark-mode adjustments */
} .card.bg-dark { background-color: #111 !important; }
.card-body { padding: .5rem !important; }
.list-group-item { padding: .35rem .5rem; font-size: .88rem; border-left: 0; border-right: 0; }
.list-group-item.current { background: rgba(255,255,255,0.03); border-left: 4px solid rgba(13,110,253,0.25); }
.list-group-item .small { display:block; color: #bfc6cf; }
.btn-group .btn { padding: .25rem .45rem; font-size: .8rem; }
.btn-outline-light { color: #e9ecef; border-color: rgba(233,236,239,0.12); }
.btn-outline-light:hover { color:#fff; background: rgba(233,236,239,0.06); }
</style> </style>
@@ -41,7 +41,7 @@
<div class="mt-2 small text-muted"> <div class="mt-2 small text-muted">
This exam has <strong>{{ question_number }}</strong> questions &nbsp;&nbsp; This exam has <strong>{{ question_number }}</strong> questions &nbsp;&nbsp;
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">per question</span>) Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">1Q</span>)
</div> </div>
<div class="mt-2 small text-muted">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div> <div class="mt-2 small text-muted">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
@@ -93,7 +93,7 @@
<div class="card"> <div class="card">
<div class="card-body p-2"> <div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <div title="Defines if this exam is freely available to all users.">
Open access: Open access:
<span class="fw-bold">{{ exam.open_access }}</span> <span class="fw-bold">{{ exam.open_access }}</span>
</div> </div>
@@ -121,19 +121,18 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header p-2"> <div class="card-header p-2" title="Allow bulk setting of question open access status. Note: questions and exams have different open access settings.">
{# Use d-block on small screens so the summary text wraps nicely; md uses flex for spacing #} {# Use d-block on small screens so the summary text wraps nicely; md uses flex for spacing #}
<a class="d-block d-md-flex justify-content-between align-items-center" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk"> <a class="d-block d-md-flex justify-content-between align-items-center text-decoration-none text-reset" data-bs-toggle="collapse" data-bs-target="#open-access-bulk" role="button" aria-expanded="false" aria-controls="open-access-bulk">
<span>Open access (questions)</span> <small>Open access (questions)</small>
<small class="text-muted">Expand to change selected questions open access status</small> <small class="text-muted">Expand to change selected questions open access status</small>
</a> </a>
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2">
<div class="collapse" id="open-access-bulk"> <div class="collapse" id="open-access-bulk">
<form hx-post="{% url 'generic:generic_exam_set_open_access' %}" hx-target="#action-result" hx-swap="innerHTML" class="row g-2 align-items-center"> <form hx-post="{% url 'generic:generic_exam_set_open_access' %}" hx-target="#action-result" hx-swap="innerHTML" class="row g-2 align-items-center">
<input type="hidden" name="type" value="sbas" /> <input type="hidden" name="type" value="{{exam.app_name}}" />
<input type="hidden" name="exam_id" value="{{ exam.pk }}" /> <input type="hidden" name="exam_id" value="{{ exam.pk }}" />
<div class="col-12 small text-muted">Select questions on the list above (checkboxes) then click one of the actions below.</div>
<div class="col-12 d-flex flex-column flex-md-row gap-2 justify-content-md-end"> <div class="col-12 d-flex flex-column flex-md-row gap-2 justify-content-md-end">
<button class="btn btn-sm btn-outline-success" type="submit" name="set_open_access" value="true" hx-include="input[name='selection']:checked">Set Open Access = True</button> <button class="btn btn-sm btn-outline-success" type="submit" name="set_open_access" value="true" hx-include="input[name='selection']:checked">Set Open Access = True</button>
<button class="btn btn-sm btn-outline-danger" type="submit" name="set_open_access" value="false" hx-include="input[name='selection']:checked">Set Open Access = False</button> <button class="btn btn-sm btn-outline-danger" type="submit" name="set_open_access" value="false" hx-include="input[name='selection']:checked">Set Open Access = False</button>
+28 -12
View File
@@ -39,17 +39,17 @@
</div> </div>
<div id="stats-plot">{{plot|safe}}</div> <div id="stats-plot">{{plot|safe}}</div>
<div id="stats-block" title="low scores (<20% max) are excluded from statistics"> <div id="stats-block" title="low scores (<20% max) are excluded from statistics" class="mb-2 small">
<h3>Stats</h3> <h5 class="mb-1">Stats</h5>
Candidates: {{cids|length}}<br /> <div>Candidates: <span class="fw-semibold">{{cids|length}}</span></div>
Questions: <span id="question-number">{{question_number}}</span><br /> <div>Questions: <span id="question-number">{{question_number}}</span></div>
<span title="Max score">Available marks: {{max_score}}</span><br /> <div title="Max score">Available marks: {{max_score}}</div>
Mean: {{mean}}, Median {{median}}, Mode {{mode}}<br /> <div>Mean: {{mean}}, Median {{median}}, Mode {{mode}}</div>
Top score: {{exam.stats_max}} <div>Top score: {{exam.stats_max}}</div>
</div> </div>
<div> <div class="mb-3">
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable"> <div class="table-responsive">
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable small">
<tr> <tr>
<th title="click to sort by candidate / user id">Candidate ID</th> <th title="click to sort by candidate / user id">Candidate ID</th>
<th title="click to sort by score">Score</th> <th title="click to sort by score">Score</th>
@@ -80,9 +80,17 @@
{% endfor %} {% endfor %}
</table> </table>
</div> </div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 class="mb-0">Answers as a table</h5>
<div> <div>
<h3>Answers as a table</h3> <button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#answers-table" aria-expanded="true">Toggle answers</button>
<table class="table table-dark table-striped table-hover table-sm col-sm"> </div>
</div>
<div class="collapse show" id="answers-table">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover table-sm col-sm small">
<thead class="thead-dark"> <thead class="thead-dark">
<tr> <tr>
<th>Candidate</th> <th>Candidate</th>
@@ -128,6 +136,8 @@
{% endfor %} {% endfor %}
</tr> </tr>
</table> </table>
</div>
</div>
{% block breakdown %}{% endblock breakdown %} {% block breakdown %}{% endblock breakdown %}
@@ -192,6 +202,12 @@
display: inline-block; display: inline-block;
color: darkblue; color: darkblue;
} }
/* Reduce vertical spacing for dense score tables */
.cid-score-table td, .cid-score-table th, table.table-sm td, table.table-sm th {
padding: .25rem .4rem;
line-height: 1;
}
#stats-block { font-size: .9rem; }
</style> </style>
{% endblock %} {% endblock %}
+97 -80
View File
@@ -1,103 +1,120 @@
{% include "generic/exam_candidate_headers.html" %} {% include "generic/exam_candidate_headers.html" %}
<h3>Users</h3> <div class="card mb-2 bg-dark text-light border-0">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-0">Users</h5>
{% if groups %} {% if groups %}
Users from the following groups are available: {% for group in groups %}<a href='{{group.get_absolute_url}}'>{{group}}</a>, {% endfor %} . <div class="small text-muted mt-1">Users from the following groups are available:
{% for group in groups %}
<a href='{{group.get_absolute_url}}' class="badge bg-light text-dark border ms-1">{{group}}</a>
{% endfor %}
</div>
{% else %} {% else %}
This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management. <div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url exam.app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
{% endif %} {% endif %}
<ol> </div>
{% for user in current_user_users %} <div class="btn-group">
<li class="current">{{user.username}} /
Email: {{user.email}}
{% if user.userprofile.grade %}
[{{user.userprofile.grade}}]
{% endif %}
{% if request.user.is_superuser %}
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
</li>
{% endfor %}
{% for user in available_user_users %}
<li>{{user.username}} /
Email: {{user.email}}
{% if user.userprofile.grade %}
[{{user.userprofile.grade}}]
{% endif %}
{% if request.user.is_superuser %}
<a href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<button class="toggle-btn" data-pk="{{user.pk}}" data-user="{{user.username}}">Toggle</button>
</li>
{% endfor %}
</ol>
{% comment %} Hide the toggle button if there are no users (to add or remove) {% endcomment %}
<p>
{% if current_user_users or available_user_users %} {% if current_user_users or available_user_users %}
<button class="toggle-all-btn">Toggle all</button> <button class="btn btn-sm btn-outline-light toggle-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Toggle all</button>
{% endif %} {% endif %}
<button class="add-all-btn">Add all</button> <button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Add all</button>
<button class="remove-all-btn">Remove all</button> <button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}">Remove all</button>
</div>
</div>
</p> <div class="d-flex align-items-center mb-2">
<div class="me-auto small text-muted">Sort by:</div>
<div class="btn-group btn-group-sm user-sort-group" role="group" aria-label="Sort">
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="cid">Username <span class="sort-caret"></span></button>
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="name">Name <span class="sort-caret"></span></button>
<button type="button" class="btn btn-sm btn-outline-light sort-btn" data-key="email">Email <span class="sort-caret"></span></button>
</div>
</div>
{% include 'generic/partials/user_user_list.html' with exam=exam current_user_users=current_user_users available_user_users=available_user_users %}
</div>
</div>
<script> <script>
$(document).ready(() => { // Mirror the HTMX-enabled behavior used for CID users.
$(".remove-all-btn").click((el) => { document.addEventListener('DOMContentLoaded', function(){
$(".current .toggle-btn").click(); // Bulk form for users
}); const bulkForm = document.createElement('form');
$(".add-all-btn").click((el) => { bulkForm.id = 'bulk-user-form';
$("li:not(.current) .toggle-btn").click(); bulkForm.className = 'd-none';
}); bulkForm.setAttribute('hx-target', '#user-users-wrap');
$(".toggle-all-btn").click((el) => { bulkForm.setAttribute('hx-swap', 'outerHTML');
$(".toggle-btn").click(); const bulkInput = document.createElement('input'); bulkInput.type='hidden'; bulkInput.name='bulk_pks'; bulkInput.id='bulk_pks_input_user'; bulkForm.appendChild(bulkInput);
}); const bulkAddInput = document.createElement('input'); bulkAddInput.type='hidden'; bulkAddInput.name='add'; bulkAddInput.id='bulk_add_input_user'; bulkForm.appendChild(bulkAddInput);
$(".toggle-btn").click((el) => { const csrfTokenInput = document.createElement('input'); csrfTokenInput.type='hidden'; csrfTokenInput.name='csrfmiddlewaretoken'; csrfTokenInput.value='{{ csrf_token }}'; bulkForm.appendChild(csrfTokenInput);
parent = $(el.target).parent() document.body.appendChild(bulkForm);
$.ajax({
url: "{% url exam.app_name|add:':exam_json_edit' exam.pk %}",
data: {
csrfmiddlewaretoken: "{{csrf_token}}",
edit_user_user: el.target.dataset.pk,
add: !parent.hasClass("current"),
},
type: "POST",
dataType: "json",
})
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) {
console.log(data);
if (data.status == "success") { function setLoading(btn, loading){ if(!btn) return; try{ if(loading){ btn.dataset._origDisabled = btn.disabled ? '1':'0'; btn.disabled = true; const spinner = document.createElement('span'); spinner.className='btn-spinner spinner-border spinner-border-sm ms-2'; spinner.setAttribute('role','status'); spinner.setAttribute('aria-hidden','true'); btn.appendChild(spinner);} else { if(typeof btn.dataset._origDisabled !== 'undefined'){ btn.disabled = btn.dataset._origDisabled === '1'; delete btn.dataset._origDisabled;} else { btn.disabled = false;} const s = btn.querySelector('.btn-spinner'); if(s) s.remove(); } }catch(e){} }
if (data.added) {
parent.addClass("current");
toastr.info(`User ${el.target.dataset.user} added to exam`)
} else {
parent.removeClass("current");
toastr.info(`User ${el.target.dataset.user} removed from exam`)
function submitBulk(url, pks, add, triggeringButton){
if (window.htmx && typeof htmx.submit === 'function'){
if(triggeringButton) setLoading(triggeringButton, true);
bulkForm.setAttribute('hx-post', url);
bulkInput.value = JSON.stringify(pks);
if (add === null) bulkAddInput.disabled = true; else { bulkAddInput.disabled = false; bulkAddInput.value = add ? 'true' : 'false'; }
htmx.submit(bulkForm); return;
} }
if(triggeringButton) setLoading(triggeringButton, true);
const fd = new FormData(); fd.append('bulk_pks', JSON.stringify(pks)); if (add !== null) fd.append('add', add ? 'true' : 'false'); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]').value; const headers = { 'X-CSRFToken': csrf };
fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' })
.then(resp => resp.text().then(text => ({ resp, text })))
.then(({ resp, text }) => { try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newWrap = doc.querySelector('#user-users-wrap'); if (newWrap){ const oldWrap = document.querySelector('#user-users-wrap'); if (oldWrap) oldWrap.replaceWith(newWrap); } else { const newList = doc.querySelector('#user-users-list'); if (newList){ const old = document.querySelector('#user-users-list'); if (old) old.replaceWith(newList); } } }catch(e){ console.error('bulk swap failed', e);} const trigger = resp.headers.get('HX-Trigger'); if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} } })
.catch(err => console.error('bulk request failed', err))
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
} }
})
.always(function () { const defaultBulkUrl = "{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}";
}) document.querySelectorAll('.add-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ const url = btn.dataset.bulkUrl || defaultBulkUrl; const items = document.querySelectorAll('#user-users-list li:not(.current)'); const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number); if (!pks.length) return; submitBulk(url, pks, true, btn); }); });
document.querySelectorAll('.remove-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ if(!confirm('Remove all selected users from the exam?')) return; const url = btn.dataset.bulkUrl || defaultBulkUrl; const items = document.querySelectorAll('#user-users-list li.current'); const pks = Array.from(items).map(i => i.dataset.pk).filter(Boolean).map(Number); if (!pks.length) return; submitBulk(url, pks, false, btn); }); });
document.querySelectorAll('.toggle-all-btn').forEach(function(btn){ btn.addEventListener('click', function(){ const url = btn.dataset.bulkUrl || document.querySelector('.add-all-btn')?.dataset.bulkUrl || defaultBulkUrl; const items = Array.from(document.querySelectorAll('#user-users-list li')).map(function(li){ return { pk: Number(li.dataset.pk), add: !li.classList.contains('current') }; }); if (!items.length) return; submitBulk(url, items, null, btn); }); });
// Listen for single-item toggle events to update counts and show toasts
document.body.addEventListener('user_toggled', function(evt){ try { const detail = evt.detail || {}; const added = detail.added; if (window.toastr) { if (added) toastr.info('User added to exam'); else toastr.info('User removed from exam'); } const addedEl = document.getElementById('user-count-added'); const availEl = document.getElementById('user-count-available'); if (addedEl && availEl){ let addedCount = parseInt(addedEl.textContent || '0') || 0; let availCount = parseInt(availEl.textContent || '0') || 0; if (added){ addedCount += 1; availCount = Math.max(0, availCount - 1); } else { addedCount = Math.max(0, addedCount - 1); availCount += 1; } addedEl.textContent = String(addedCount); availEl.textContent = String(availCount); } } catch(e){} });
// Delegated click handler for per-item toggle buttons (HTMX preferred)
document.body.addEventListener('click', function(e){ const btn = e.target.closest && e.target.closest('.toggle-btn'); if(!btn) return; const form = btn.closest('form'); if(!form) return; e.preventDefault(); setLoading(btn, true); if(window.htmx && typeof htmx.submit === 'function'){ htmx.submit(form); return; } (async function(){ try{ const url = form.action || form.getAttribute('hx-post'); const fd = new FormData(form); const csrf = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value; const headers = { 'HX-Request': 'true' }; if(csrf) headers['X-CSRFToken'] = csrf; const resp = await fetch(url, { method: 'POST', body: fd, headers: headers, credentials: 'same-origin' }); const text = await resp.text(); try{ const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/html'); const newLi = doc.querySelector('li'); const oldLi = form.closest('li'); if(newLi && oldLi){ oldLi.replaceWith(newLi); } const trigger = resp.headers.get('HX-Trigger'); if (trigger){ try{ const detail = JSON.parse(trigger); for(const k in detail){ document.body.dispatchEvent(new CustomEvent(k, { detail: detail[k] })); } }catch(e){} } }catch(err){ console.error('toggle parse/swap failed', err); } }catch(err){ console.error('toggle request failed', err); } finally{ setLoading(btn, false); } })(); });
// Sorting for user list (scoped to this card)
(function(){
const sortGroup = document.querySelector('.user-sort-group');
if(!sortGroup) return;
let userSort = { key: 'cid', dir: 'asc' };
function updateUserCarets(){
const buttons = sortGroup.querySelectorAll('.sort-btn');
buttons.forEach(function(b){ const key = b.dataset.key; const caret = b.querySelector('.sort-caret'); if(caret) caret.textContent = (userSort.key === key) ? (userSort.dir === 'asc' ? ' ▲' : ' ▼') : ''; });
}
function getUserValue(li, key){
if(!li) return '';
if(key === 'cid') return (li.dataset.username || '').toString().toLowerCase();
return (li.dataset[key] || '').toString().toLowerCase();
}
function sortUserList(key){
const ul = document.getElementById('user-users-list'); if(!ul) return;
const items = Array.from(ul.children);
const dir = (userSort.key === key && userSort.dir === 'asc') ? 'desc' : 'asc';
items.sort(function(a,b){ const va = getUserValue(a,key); const vb = getUserValue(b,key); if(va < vb) return dir === 'asc' ? -1 : 1; if(va > vb) return dir === 'asc' ? 1 : -1; return 0; });
items.forEach(function(it){ ul.appendChild(it); });
userSort = { key: key, dir: dir };
updateUserCarets();
}
sortGroup.querySelectorAll('.sort-btn').forEach(function(b){ b.addEventListener('click', function(){ sortUserList(b.dataset.key); }); });
updateUserCarets();
})();
}); });
})
</script> </script>
<style> <style>
.current { .current {
color: lightgray; color: lightgray;
} }
.current::after { /* removed decorative pseudo-element that showed [ADDED TO EXAM] */
content: "[ADDED TO EXAM]"
}
button { button {
padding: 0; padding: 0;
@@ -1,10 +1,30 @@
{% extends 'generic/base.html' %} {% extends 'base.html' %}
{% block title %}
Exam Collections
{% endblock %}
{% block navigation %} {% block navigation %}
{{block.super}} <nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
<br/> <div class="container-fluid">
{% comment %} <a href="{% url 'generic:cid_group_detail' cidusergroup.pk %}">Collections</a> / {% endcomment %} <a class="navbar-brand" href="{% url 'generic:examcollection_list' %}">Collections</a>
<a href="{% url 'generic:examcollection_create' %}">Create Exam Collection</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examCollectionNavbar" aria-controls="examCollectionNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"><i class="bi bi-text-indent-right"></i></span>
</button>
<div class="collapse navbar-collapse" id="examCollectionNavbar">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'generic:examcollection_list' %}"><i class="bi bi-collection me-1" aria-hidden="true"></i> View Collections</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'generic:examcollection_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i> Create Collection</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
{% endblock %} {% endblock %}
@@ -2,106 +2,154 @@
{% block content %} {% block content %}
<h1> <div class="container py-4">
{{ object.name }} <div class="row align-items-start mb-3">
{% if object.date %} <div class="col">
[{{object.date}}] <h1 class="h3 mb-0">{{ object.name }} {% if object.date %}<small class="text-muted">[{{ object.date }}]</small>{% endif %}</h1>
{% endif %} <div class="small text-muted mt-1">Authors: {{ object.get_authors }}</div>
</h1> </div>
<div class="col-auto">
<div class="btn-group" role="group" aria-label="collection-actions">
<a class="btn btn-outline-primary btn-sm" href="{% url 'generic:examcollection_edit' object.pk %}"><i class="bi bi-pencil"></i> Edit</a>
<a class="btn btn-outline-secondary btn-sm" href="{% url 'generic:examcollection_clone' object.pk %}"><i class="bi bi-files"></i> Clone</a>
<a class="btn btn-outline-danger btn-sm" href="{% url 'generic:examcollection_delete' object.pk %}"><i class="bi bi-trash"></i> Delete</a>
</div>
</div>
</div>
<p> <p class="lead small text-muted">This collection contains the following exams</p>
<a href="{% url 'generic:examcollection_edit' object.pk %}">Edit</a>
<a href="{% url 'generic:examcollection_clone' object.pk %}">Clone</a>
<a href="{% url 'generic:examcollection_delete' object.pk %}">Delete</a>
</p>
<div>Authors: {{ object.get_authors }}</div>
<p>This collection contains the following exams<p>
{% if object.anatomy_exams.all %} {% if object.anatomy_count %}
<h2>Anatomy Exams:</h2> <div class="card mb-3">
{% with exams=object.anatomy_exams.all app_name="anatomy" %} <div class="card-header d-flex justify-content-between align-items-center">
{% include "exam_list.html#exam-list" %} <strong>Anatomy Exams</strong>
{% endwith %} <div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:exam_list_collection' object.pk %}">View exam list</a>
<a href="{% url 'anatomy:exam_list_collection' object.pk %}">View exam list</a> <button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
</div>
</div>
<div class="add-marker"> <div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'anatomy' %}" hx-trigger="revealed" hx-swap="innerHTML">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Anatomy Exams</button> <div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading anatomy exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
<div class="card-footer add-marker small"></div>
</div> </div>
{% endif %} {% endif %}
{% if object.longs_exams.all %} {% if object.longs_count %}
<h2>Longs Exams:</h2> <div class="card mb-3">
{% with exams=object.longs_exams.all app_name="longs" %} <div class="card-header d-flex justify-content-between align-items-center">
{% include "exam_list.html#exam-list" %} <strong>Longs Exams</strong>
{% endwith %} <div class="btn-group">
<a href="{% url 'longs:exam_list_collection' object.pk %}">View exam list</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'longs:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker"> <button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Longs Exams</button> </div>
</div>
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'longs' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading longs exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
<div class="card-footer add-marker small"></div>
</div> </div>
{% endif %} {% endif %}
{% if object.rapids_exams.all %} {% if object.rapids_count %}
<h2>Rapids Exams:</h2> <div class="card mb-3">
{% with exams=object.rapids_exams.all app_name="rapids" %} <div class="card-header d-flex justify-content-between align-items-center">
{% include "exam_list.html#exam-list" %} <strong>Rapids Exams</strong>
{% endwith %} <div class="btn-group">
<a href="{% url 'rapids:exam_list_collection' object.pk %}">View exam list</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'rapids:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker"> <button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Rapids Exams</button> </div>
</div>
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'rapids' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading rapids exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
<div class="card-footer add-marker small"></div>
</div> </div>
{% endif %} {% endif %}
{% if object.shorts_exams.all %} {% if object.shorts_count %}
<h2>Shorts Exams:</h2> <div class="card mb-3">
{% with exams=object.shorts_exams.all app_name="shorts" %} <div class="card-header d-flex justify-content-between align-items-center">
{% include "exam_list.html#exam-list" %} <strong>Shorts Exams</strong>
{% endwith %} <div class="btn-group">
<a href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker"> <button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Shorts Exams</button> </div>
</div>
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'shorts' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading shorts exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
<div class="card-footer add-marker small"></div>
</div> </div>
{% endif %} {% endif %}
{% if object.physics_exams.all %} {% if object.physics_count %}
<h2>Physics Exams:</h2> <div class="card mb-3">
{% with exams=object.physics_exams.all app_name="physics" %} <div class="card-header d-flex justify-content-between align-items-center">
{% include "exam_list.html#exam-list" %} <strong>Physics Exams</strong>
{% endwith %} <a class="btn btn-sm btn-outline-secondary" href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a>
<a href="{% url 'physics:exam_list_collection' object.pk %}">View exam list</a> </div>
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'physics' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading physics exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
</div>
{% endif %} {% endif %}
{% if object.sbas_exams.all %} {% if object.sbas_count %}
<h2>SBAs Exams:</h2> <div class="card mb-3">
{% with exams=object.sbas_exams.all app_name="sbas" %} <div class="card-header d-flex justify-content-between align-items-center">
{% include "exam_list.html#exam-list" %} <strong>SBAs Exams</strong>
{% endwith %} <a class="btn btn-sm btn-outline-secondary" href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a>
<a href="{% url 'sbas:exam_list_collection' object.pk %}">View exam list</a> </div>
<div class="card-body p-2" hx-get="{% url 'generic:exam_collection_exams_partial' object.pk 'sbas' %}" hx-trigger="revealed" hx-swap="innerHTML">
<div class="d-flex align-items-center">
<div class="placeholder small text-muted">Loading SBAs exams…</div>
<div class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></div>
</div>
</div>
</div>
{% endif %} {% endif %}
<br/> <br/>
<div class="d-flex gap-2 mt-3">
<div id="bulk-add-groups"> <div id="bulk-add-groups">
<button class="btn-sm" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button> <button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
</div> </div>
<div id="add-user"> <div id="add-user">
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button> <button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
</div>
</div> </div>
{% include 'exam_overview_js.html' %} {% include 'exam_overview_js.html' %}
</div> {# .container end #}
{% endblock %} {% endblock %}
{% block css %} {% block css %}
<style> <style>
.add-marker-button { /* subtle visual tweaks for the new layout */
opacity: 0.5; .card-body .exam-list { margin: 0; }
} .card .card-footer.add-marker { min-height: 1.5rem; }
.card-header strong { font-weight: 600; }
</style> </style>
{% endblock %} {% endblock %}
@@ -1,4 +1,4 @@
{% extends "generic/base.html" %} {% extends "generic/examcollection_base.html" %}
<!-- {% load static from static %} --> <!-- {% load static from static %} -->
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
@@ -16,11 +16,135 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4">Edit Exam Collection <small class="text-muted">{{ object.name }}</small></h2>
<h2>Edit Exam Collection {{object.name}}</h2> <form action="" method="post" enctype="multipart/form-data" id="examcollection-form" class="needs-validation" novalidate>
<form action="" method="post" enctype="multipart/form-data" id="examcollection-form">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {% if form.non_field_errors %}
<input type="submit" class="submit-button" value="Submit" name="submit"> <div class="alert alert-danger">{{ form.non_field_errors }}</div>
{% endif %}
<style>
/* Improve visual separation of form sections for this collection form */
#examcollection-form .mb-3 {
padding: .75rem 1rem;
margin-bottom: 1rem;
border-radius: .375rem;
background: rgba(255,255,255,0.02);
border-left: 3px solid rgba(255,255,255,0.03);
}
#examcollection-form .mb-3:nth-child(odd) {
background: rgba(255,255,255,0.015);
}
#examcollection-form .form-section-divider {
height: 1px;
background: rgba(255,255,255,0.04);
margin: .5rem 0 1rem 0;
border-radius: 2px;
}
#examcollection-form .form-section-title {
font-size: .95rem;
margin-bottom: .5rem;
font-weight: 600;
color: var(--bs-light);
}
</style>
<div class="mb-3">
<div class="row gy-3">
<div class="col-md-6">
<div class="mb-2">
<label for="id_name" class="form-label requiredField">Name <span class="text-danger">*</span></label>
{{ form.name }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">{{ form.name.errors }}</div>
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="mb-2">
<label for="id_date" class="form-label">Date</label>
{{ form.date }}
{% if form.date.errors %}
<div class="invalid-feedback d-block">{{ form.date.errors }}</div>
{% endif %}
</div>
</div>
<div class="col-md-3 d-flex align-items-center">
<div class="form-check mb-2">
{{ form.archive }}
<label for="id_archive" class="form-check-label ms-2">Archive</label>
</div>
</div>
</div>
<hr class="my-3" />
<div class="mb-3">
<h5 class="mb-2">Authors</h5>
<div>
{{ form.author }}
{% if form.author.errors %}
<div class="invalid-feedback d-block">{{ form.author.errors }}</div>
{% endif %}
</div>
</div>
<div class="row row-cols-1 row-cols-md-2 g-3">
{% for label, field_name, bound in exam_groups %}
<div class="col">
<div class="p-3 border rounded h-100">
<h6 class="mb-2">{{ label }}</h6>
<div>
{{ bound }}
{% if bound.errors %}
<div class="invalid-feedback d-block">{{ bound.errors }}</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
// Insert subtle dividers between logical groups (.mb-3 blocks)
(function(){
var form = document.getElementById('examcollection-form');
if (!form) return;
// find direct .mb-3 children inside the form
var groups = Array.from(form.querySelectorAll(':scope > .mb-3'));
if (groups.length <= 1) return;
for (var i = 1; i < groups.length; i++) {
var divider = document.createElement('div');
divider.className = 'form-section-divider';
groups[i].parentNode.insertBefore(divider, groups[i]);
}
})();
</script>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1" aria-hidden="true"></i> Save</button>
<a href="{% url 'generic:examcollection_list' %}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1" aria-hidden="true"></i> Back to collections</a>
<button type="reset" class="btn btn-light">Reset</button>
</div>
</form> </form>
<script>
// Simple client-side bootstrap validation
(function () {
'use strict'
var forms = document.querySelectorAll('.needs-validation')
Array.prototype.slice.call(forms).forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
})()
</script>
{% endblock %} {% endblock %}
@@ -1,6 +1,13 @@
{% extends app_name|add:'/base.html' %} {% extends app_name|add:'/base.html' %}
{% block content %} {% block content %}
<h3>Questions </h3>
<a href="{% url app_name|add:':question_view' %}?author={{ request.user.id }}" class="btn btn-sm btn-outline-secondary">My questions</a>
<div class="d-flex justify-content-end mb-3">
<a href="{% url app_name|add:':question_create' %}" class="btn btn-sm btn-primary">Create question</a>
</div>
<h3>Exams</h3>
<div id="exam-list-wrapper"> <div id="exam-list-wrapper">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
@@ -0,0 +1,22 @@
{% comment %} Preview partial for parsed rows; includes a form to create users {% endcomment %}
<div id="cid-generate-preview" class="mt-2">
<h6 class="small">Preview ({{ rows|length }} rows)</h6>
<div class="table-responsive">
<table class="table table-sm table-borderless">
<thead>
<tr><th>Name</th><th>Email</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr><td>{{ r.name }}</td><td>{{ r.email }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<form method="post" hx-post="{% url 'generic:cid_group_create_bulk' group.pk %}" hx-target="#group-users-wrap" hx-swap="outerHTML">
{% csrf_token %}
<textarea name="rows" class="d-none">{{ original|escape }}</textarea>
<button type="submit" class="btn btn-sm btn-primary">Create users and add to group</button>
</form>
</div>
@@ -0,0 +1,32 @@
<li class="list-group-item d-flex justify-content-between align-items-center {% if current %}current{% endif %} py-1 px-2 bg-transparent border-secondary" data-pk="{{cid.pk}}" data-cid="{{cid.cid}}" data-name="{{cid.name|escape}}" data-email="{{cid.email|escape}}">
<div class="me-2">
<div class="d-flex align-items-center">
<div class="fw-bold small mb-0">{{cid.cid}} <span class="text-muted">[{{cid.passcode}}]</span></div>
<span class="badge {% if current %}bg-success{% else %}bg-secondary text-dark{% endif %} ms-2 small">{% if current %}Added{% else %}Not added{% endif %}</span>
</div>
<div class="small text-muted">{{cid.name}} &middot; {{cid.email}}</div>
</div>
<div class="d-flex align-items-center ms-2">
{% if cid.notes %}
<div class="small text-muted me-2">Notes:
<span class="cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">{{ cid.notes|truncatechars:60 }}</span>
</div>
{% endif %}
{% if cid.group and cid.group in exam.cid_user_groups.all %}
<div class="small text-muted me-2"><span class="badge bg-info text-dark">{{ cid.group.name }}</span></div>
{% endif %}
<div class="btn-group">
<a href="{% url 'generic:update_cid' cid.pk %}" class="btn btn-sm btn-outline-light">Edit</a>
<form method="post" class="d-inline m-0 p-0"
action="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
hx-post="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
hx-target="closest li"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="edit_cid_user" value="{{cid.pk}}">
<input type="hidden" name="add" value="{% if current %}false{% else %}true{% endif %}">
<button type="submit" class="btn btn-sm btn-outline-light toggle-btn">Toggle</button>
</form>
</div>
</div>
</li>
@@ -0,0 +1,14 @@
<div id="cid-users-wrap">
<div class="d-flex justify-content-between align-items-center mb-2 small text-muted">
<div>Added: <span id="cid-count-added">{{ current_cid_users|length }}</span></div>
<div>Available: <span id="cid-count-available">{{ available_cid_users|length }}</span></div>
</div>
<ul class="list-group list-group-flush" id="cid-users-list">
{% for cid in current_cid_users %}
{% include 'generic/partials/cid_user_li.html' with cid=cid current=True exam=exam %}
{% endfor %}
{% for cid in available_cid_users %}
{% include 'generic/partials/cid_user_li.html' with cid=cid current=False exam=exam %}
{% endfor %}
</ul>
</div>
@@ -0,0 +1,22 @@
<div class="p-3">
<h4>CidUserExam: {% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.cid }}{% elif cid_user_exam.user_user %}{{ cid_user_exam.user_user.username }}{% else %}-{% endif %}</h4>
<table class="table table-sm">
<tbody>
<tr><th>CID</th><td>{% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.cid }}{% else %}-{% endif %}</td></tr>
<tr><th>User</th><td>{% if cid_user_exam.user_user %}{{ cid_user_exam.user_user.username }}{% else %}-{% endif %}</td></tr>
<tr><th>Start time</th><td>{{ cid_user_exam.start_time }}</td></tr>
<tr><th>End time</th><td>{{ cid_user_exam.end_time }}</td></tr>
<tr><th>Completed</th><td>{{ cid_user_exam.completed }}</td></tr>
<tr><th>Manual submission</th><td>{{ cid_user_exam.manual_submission }}</td></tr>
<tr><th>Share with supervisor</th><td>{{ cid_user_exam.share_with_supervisor }}</td></tr>
<tr><th>Results emailed status</th><td>{{ cid_user_exam.results_emailed_status }}</td></tr>
<tr><th>CID User email</th><td>{% if cid_user_exam.cid_user %}{{ cid_user_exam.cid_user.email }}{% else %}-{% endif %}</td></tr>
</tbody>
</table>
<div class="d-flex justify-content-end">
{% if cid_user_exam %}
<a class="btn btn-sm btn-outline-primary me-2" href="{% url 'generic:update_cid_user_exam' cid_user_exam.pk %}">Edit</a>
{% endif %}
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
@@ -0,0 +1,32 @@
{% comment %} Partial: list of CID candidates for HTMX swap {% endcomment %}
{% if cid_users %}
<ul class="list-group list-group-flush">
{% for cid in cid_users %}
<li class="list-group-item">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_cid' exam.id cid.cid %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
></i>
<a class="fw-bold me-2 cid-link" href="{% url 'generic:update_cid' cid.pk %}">{{ cid.cid }}</a>
<span class="text-muted">[{{ cid.passcode }}]</span>
<a class="ms-3" title="View CID exam details" hx-get="{% url 'generic:cid_user_exam_partial' exam.app_name exam.id cid.cid %}" hx-target=".modal-content" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-box-arrow-up-right"></i></a>
</div>
<div class="small text-muted mt-1">{{ cid.name }}</div>
<div class="small">Email: {{ cid.email }}</div>
{% if cid.notes %}
<div class="small text-muted mt-1 cid-notes" title="{{ cid.notes|escape }}" data-bs-toggle="tooltip" data-bs-placement="top">Notes: {{ cid.notes|truncatechars:120 }}</div>
{% endif %}
</div>
<div class="badge-container d-flex align-items-center ms-3"></div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">This exam has no CID candidates.</p>
{% endif %}
@@ -0,0 +1,60 @@
{% comment %} Partial: submitted candidates fragment for HTMX swap {% endcomment %}
{% if user_exam_data %}
<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-0">Submitted candidates</h5>
<div class="small text-muted">The following candidate submissions have been received. Candidates with submissions are highlighted above.</div>
</div>
<div class="d-flex align-items-center">
<div class="small text-muted">Count: <span class="fw-semibold">{{ user_exam_data|length }}</span></div>
{% if request.user.is_superuser or request.user.is_staff %}
<div class="ms-3">
<a class="btn btn-sm btn-outline-primary" href="{% url 'admin:generic_ciduserexam_changelist' %}" target="_blank" rel="noopener">View submissions (admin)</a>
</div>
{% endif %}
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th scope="col">CID</th>
<th scope="col">User</th>
<th scope="col">Start</th>
<th scope="col">End</th>
<th scope="col" class="text-end">Actions</th>
</tr>
</thead>
<tbody id="submitted-candidates">
{% for user_data in user_exam_data %}
<tr data-cid="{{ user_data.cid_user }}" data-user="{{ user_data.user_user }}" data-first="{{ user_data.start_time|default_if_none:'' }}" data-last="{{ user_data.end_time|default_if_none:'' }}" {% if not user_data.in_exam %}class="table-warning"{% endif %}>
<td class="fw-bold">{{ user_data.cid_user }}{% if not user_data.in_exam %} <span class="badge bg-danger ms-2" title="Candidate/User not added to exam">Not added</span>{% endif %}</td>
<td>{{ user_data.user_user }}</td>
<td class="text-nowrap small {% if user_data.in_exam %}text-muted{% else %}text-dark{% endif %}">{{ user_data.start_time }}</td>
<td class="text-nowrap small {% if user_data.in_exam %}text-muted{% else %}text-dark{% endif %}">{{ user_data.end_time }}</td>
<td class="text-end">
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" title="View details" hx-get="{% url exam.get_app_name|add:':exam_user_status' exam.id %}?cid={{ user_data.cid_user }}" hx-target="#history-modal .modal-body" data-bs-toggle="modal" data-bs-target="#history-modal"><i class="bi bi-eye"></i></button>
{% if request.user.is_superuser or request.user.is_staff %}
<a class="btn btn-sm btn-outline-primary" href="{% url 'admin:generic_ciduserexam_change' user_data.pk %}" target="_blank" rel="noopener" title="Edit submission in admin"><i class="bi bi-pencil-square"></i></a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body">
<p class="mb-0 small text-muted">No submissions yet.</p>
</div>
</div>
{% endif %}
@@ -0,0 +1,27 @@
{% comment %} Partial: list of User candidates for HTMX swap {% endcomment %}
{% if user_users %}
<ul class="list-group list-group-flush">
{% for user in user_users %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
<div class="d-flex align-items-center">
<i class="bi bi-clock-history text-muted me-2" title="View user exam history"
hx-get="{% url exam.app_name|add:':exam_user_status_user' exam.id user.id %}"
hx-target=".modal-content"
data-bs-toggle="modal"
data-bs-target="#history-modal"
></i>
{% if request.user.is_superuser %}
<a class="me-2" href='{% url "account_profile" user.username %}'><i class="bi bi-link"></i></a>
{% endif %}
<span class="fw-bold">{{ user.username }}</span>
</div>
<div class="small">Email: {{ user.email }}</div>
</div>
<div class="badge-container d-flex align-items-center ms-3"></div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">This exam has no User candidates.</p>
{% endif %}
@@ -0,0 +1,5 @@
{% comment %} Minimal fragment rendering the exam-list partialdef for HTMX swaps. {% endcomment %}
{% with exams=exams app_name=app_name marking=marking collection=collection %}
{% include "exam_list.html#exam-list" %}
{% endwith %}
@@ -0,0 +1,28 @@
{% if results %}
<ul class="list-unstyled mb-0" id="exam_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center"
data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}"
data-exam-archive="{{ item.archive|yesno:'1,0' }}" data-exam-open-access="{{ item.open_access|yesno:'1,0' }}"
data-exam-exam-mode="{{ item.exam_mode|yesno:'1,0' }}" data-exam-candidates-only="{{ item.candidates_only|yesno:'1,0' }}">
<div class="flex-grow-1">
{{ item.text|escape }}
{% if item.archive %}<span class="badge bg-secondary ms-2">Archived</span>{% endif %}
{% if item.open_access %}<span class="badge bg-success ms-2">Open</span>{% endif %}
{% if item.exam_mode %}<span class="badge bg-info text-dark ms-2">Exam mode</span>{% endif %}
{% if item.candidates_only %}<span class="badge bg-warning text-dark ms-2">Candidates only</span>{% endif %}
</div>
<div>
<button type="button" class="btn btn-sm btn-primary exam-add-btn" data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}">Add</button>
</div>
</li>
{% endfor %}
</ul>
{% if results %}
<div class="mt-2 text-end">
<button type="button" class="btn btn-sm btn-outline-success exam-add-all-btn" data-field="{{ field }}">Add all results</button>
</div>
{% endif %}
{% else %}
<div><em>No exams found</em></div>
{% endif %}
@@ -1,30 +1,31 @@
{% load partials %}
{% partialdef publish-results %} {% partialdef publish-results %}
<div id="publish-results-status" class="d-flex align-items-center gap-2"> <div class="exam-status-container d-flex align-items-center gap-2">
<div class="me-2 small text-muted">Publish results</div> <div class="me-2 small text-muted">Results</div>
<div> <div>
{% if exam.publish_results %} {% if exam.publish_results %}
<button <button
class="btn btn-sm btn-success" class="btn btn-sm btn-success published-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}" hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#publish-results-status" hx-target="closest .exam-status-container"
title="Click to unpublish results" title="Click to unpublish results"
aria-label="Unpublish results" aria-label="Unpublish results"
hx-disabled-elt="this" hx-disabled-elt="this"
>Published</button> >Published</button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% else %} {% else %}
<button <button
class="btn btn-sm btn-secondary" class="btn btn-sm btn-secondary published-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}" hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#publish-results-status" hx-target="closest .exam-status-container"
title="Click to publish results" title="Click to publish results"
aria-label="Publish results" aria-label="Publish results"
hx-disabled-elt="this" hx-disabled-elt="this"
>Unpublished</button> >Unpublished</button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -32,30 +33,60 @@
{% partialdef exam-active %} {% partialdef exam-active %}
<div id="exam-active-status" class="d-flex align-items-center gap-2"> <div class="exam-status-container d-flex align-items-center gap-2">
<div class="me-2 small text-muted">Exam active</div> <div class="me-2 small text-muted">Exam status:</div>
<div> <div>
{% if exam.active %} {% if exam.active %}
<button <button
class="btn btn-sm btn-success" class="btn btn-sm btn-success active-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}" hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#exam-active-status" hx-target="closest .exam-status-container"
title="Click to deactivate this exam" title="Click to deactivate this exam"
aria-label="Deactivate exam" aria-label="Deactivate exam"
hx-disabled-elt="this" hx-disabled-elt="this"
>Active</button> >Active</button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% else %} {% else %}
<button <button
class="btn btn-sm btn-secondary" class="btn btn-sm btn-secondary active-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}" hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#exam-active-status" hx-target="closest .exam-status-container"
title="Click to activate this exam" title="Click to activate this exam"
aria-label="Activate exam" aria-label="Activate exam"
hx-disabled-elt="this" hx-disabled-elt="this"
>Inactive</button> >Inactive</button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endpartialdef exam-active %} {% endpartialdef exam-active %}
{% partialdef exam-archived %}
<div class="exam-status-container d-flex align-items-center gap-2">
<div>
{% if exam.archive %}
<button
class="btn btn-sm btn-warning archived-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_archived' exam.pk %}"
hx-swap="outerHTML"
hx-target="closest .exam-status-container"
title="Click to unarchive this exam"
aria-label="Unarchive exam"
><i class="bi bi-archive"></i></button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% else %}
<button
class="btn btn-sm btn-secondary archived-icon"
hx-post="{% url exam.get_app_name|add:':exam_toggle_archived' exam.pk %}"
hx-swap="outerHTML"
hx-target="closest .exam-status-container"
title="Click to archive this exam"
aria-label="Archive exam"
><i class="bi bi-archive"></i></button>
<span class="exam-loading ms-2 d-none"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span></span>
{% endif %}
</div>
</div>
{% endpartialdef %}
@@ -0,0 +1,140 @@
{% comment %} Partial: renders the users <ul> for group detail pages {% endcomment %}
<div class="d-flex align-items-center mb-2">
<div class="me-auto"></div>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary show-group-table-btn">Show table</button>
</div>
</div>
<ul id="group-users-list" class="list-group list-group-flush">
{% for user in users %}
<li class="list-group-item bg-transparent border-0 py-2"
data-username="{% if group_type == 'cid' %}{{ user.cid|default:''|escape }}{% else %}{{ user.username|default:''|escape }}{% endif %}"
data-name="{% if group_type == 'cid' %}{{ user.name|default:''|escape }}{% else %}{{ user.get_full_name|default:user.username|escape }}{% endif %}"
data-email="{{ user.email|default:''|escape }}"
{% if group_type == 'cid' %}data-cid="{{ user.cid|default:''|escape }}" data-passcode="{{ user.passcode|default:''|escape }}"{% endif %}>
{% if group_type == "cid" %}
<div class="d-flex align-items-start">
<div>
<div class="fw-bold">{{ user.name }}</div>
<div class="small text-muted">{{ user.email }}</div>
{% if user.notes %}
<div class="small text-muted mt-1">Notes: {{ user.notes|truncatechars:120 }}</div>
{% endif %}
</div>
<div class="ms-auto text-end small text-muted">
<div>CID: {{ user.cid }}</div>
<div>Passcode: {{ user.passcode }}</div>
<div class="mt-1">
{% if user.login_email_sent %}
<span class="badge bg-success ms-1 login-email-status">Email sent</span>
{% else %}
<span class="badge bg-secondary ms-1 login-email-status">Not sent</span>
{% endif %}
{% if user.email %}
<button type="button" class="btn btn-sm btn-outline-primary ms-2 send-login-email-btn" data-cid="{{ user.cid }}" data-url="{% url 'generic:candidate_email_details' user.cid %}">Send Login Email</button>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ user.get_email_details_url }}?preview=1" target="_blank">View Login Email</a>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'generic:update_cid' pk=user.pk %}">Edit Cid User</a>
{% else %}
<span class="small text-muted ms-2">No email</span>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="d-flex align-items-center">
<div class="fw-bold">{{ user.username }}</div>
<div class="ms-2 small text-muted">{{ user.email }}</div>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
<!-- Modal: Group users table / CSV export -->
<div class="modal fade" id="groupUsersModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Group users</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="table-responsive">
<table class="table table-sm" id="group-users-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Username / CID</th>
<th>Passcode</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-outline-primary" id="download-group-csv-btn">Download CSV</button>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
const showBtn = document.querySelector('.show-group-table-btn');
const modalEl = document.getElementById('groupUsersModal');
if(!showBtn || !modalEl) return;
const bsModal = new bootstrap.Modal(modalEl);
function buildTable(){
const tbody = modalEl.querySelector('#group-users-table tbody');
tbody.innerHTML = '';
const items = document.querySelectorAll('#group-users-list > li');
items.forEach(function(li){
const name = li.dataset.name || '';
const email = li.dataset.email || '';
const username = li.dataset.username || '';
const cid = li.dataset.cid || '';
const passcode = li.dataset.passcode || '';
const userCell = cid ? cid : username;
const tr = document.createElement('tr');
tr.innerHTML = `<td>${escapeHtml(name)}</td><td>${escapeHtml(email)}</td><td>${escapeHtml(userCell)}</td><td>${escapeHtml(passcode)}</td>`;
tbody.appendChild(tr);
});
}
function escapeHtml(s){
if(!s) return '';
return s.toString().replace(/&/g, '&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
showBtn.addEventListener('click', function(){
buildTable();
bsModal.show();
});
document.getElementById('download-group-csv-btn').addEventListener('click', function(){
const rows = [];
const header = ['Name','Email','Username/CID','Passcode'];
rows.push(header);
document.querySelectorAll('#group-users-table tbody tr').forEach(function(tr){
const cols = Array.from(tr.children).map(td => td.textContent || '');
rows.push(cols);
});
const csv = rows.map(r => r.map(cell => `"${(cell||'').toString().replace(/"/g,'""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const ts = new Date().toISOString().replace(/[:.]/g,'-');
a.download = `group-users-${ts}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
});
});
</script>
@@ -0,0 +1,62 @@
{% load static %}
{% if user_obj %}
<div class="card p-2">
<h5>Content by {{ user_obj.get_full_name }} ({{ user_obj.username }})</h5>
{% for app_key, info in content.items %}
<div class="mb-2">
<strong>{{ info.label }}</strong>
<div class="small">
Questions: {{ info.questions|length }} &nbsp; Exams: {{ info.exams|length }}
</div>
<div class="mt-1">
{% if info.questions %}
<ul class="list-unstyled ms-3">
{% for q in info.questions %}
<li>
{% comment %} Try to link to app question detail if available {% endcomment %}
<a href="{% url app_key|add:':question_detail' q.pk %}" target="_blank">{{ q }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if info.exams %}
<ul class="list-unstyled ms-3">
{% for ex in info.exams %}
<li>{{ ex }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% endfor %}
<div class="mb-2">
<strong>Atlas Cases</strong>
<div class="small">Cases: {{ atlas_cases|length }}</div>
{% if atlas_cases %}
<ul class="list-unstyled ms-3">
{% for case in atlas_cases %}
<li><a href="{% url 'atlas:case_detail' case.pk %}" target="_blank">{{ case }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="mb-2">
<strong>Atlas Collections</strong>
<div class="small">Collections: {{ atlas_collections|length }}</div>
{% if atlas_collections %}
<ul class="list-unstyled ms-3">
{% for coll in atlas_collections %}
<li><a href="{% url 'atlas:collection_detail' coll.pk %}" target="_blank">{{ coll }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% else %}
<div>No user found</div>
{% endif %}
@@ -0,0 +1,30 @@
{% comment %}Partial for a single user group card. Rendered by HTMX after archive toggles.{% endcomment %}
<div class="card mb-3" id="group-card-{{ group.pk }}">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0"><a href="{% url 'generic:user_group_detail' group.pk %}">{{ group.name }}</a></h5>
{% if group.description %}
<p class="mb-0 text-muted small">{{ group.description }}</p>
{% endif %}
</div>
<div class="btn-group" role="group" aria-label="Group actions">
<a class="btn btn-sm btn-outline-primary" href="{% url 'generic:user_group_update' pk=group.pk %}">Edit</a>
<a class="btn btn-sm btn-outline-danger" href="{% url 'generic:user_group_delete' pk=group.pk %}">Delete</a>
<form hx-post="{% url 'generic:user_group_toggle_archive' pk=group.pk %}" hx-swap="outerHTML" method="post" class="d-inline-block ms-1">
{% csrf_token %}
{% if view_all %}
<input type="hidden" name="view_all" value="1">
{% endif %}
{% if group.archive %}
<input type="hidden" name="archive" value="false">
<button class="btn btn-sm btn-outline-success">Unarchive</button>
{% else %}
<input type="hidden" name="archive" value="true">
<button class="btn btn-sm btn-outline-secondary">Archive</button>
{% endif %}
</form>
</div>
</div>
</div>
@@ -1,15 +1,15 @@
{% if results %} {% if results %}
<ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}"> <ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
{% for item in results %} {% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}"> <li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escape }}" data-user-grade="{{ item.grade|default_if_none:''|escape }}">
<div class="flex-grow-1"> <div class="flex-grow-1">
{{ item.text|escape }} {{ item.text|escape }}
{% if item.grade %} {% if item.grade %}
<small class="text-muted ms-2">{{ item.grade|escape }}</small> <span class="badge bg-secondary ms-2">{{ item.grade|escape }}</span>
{% endif %} {% endif %}
</div> </div>
<div> <div>
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">Add</button> <button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escape }}" data-user-grade="{{ item.grade|default_if_none:''|escape }}">Add</button>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
@@ -0,0 +1,34 @@
<li class="list-group-item d-flex justify-content-between align-items-center {% if current %}current{% endif %} py-1 px-2 bg-transparent border-secondary" data-pk="{{ user.pk }}" data-username="{{ user.username }}" data-name="{{ user.get_full_name|default:user.username|escape }}" data-email="{{ user.email|escape }}">
<div class="me-2">
<div class="d-flex align-items-center">
<div class="fw-bold small mb-0">{{ user.username }}</div>
<span class="badge {% if current %}bg-success{% else %}bg-secondary text-dark{% endif %} ms-2 small">{% if current %}Added{% else %}Not added{% endif %}</span>
</div>
<div class="small text-muted">{{ user.get_full_name|default:user.username }} &middot; {{ user.email }}</div>
</div>
<div class="d-flex align-items-center ms-2">
{% comment %} show any exam user-groups this user belongs to as badges {% endcomment %}
<div class="me-2">
{% for g in exam.user_user_groups.all %}
{% if user in g.users.all %}
<span class="badge bg-info text-dark me-1">{{ g.name }}</span>
{% endif %}
{% endfor %}
</div>
<div class="btn-group">
{% if request.user.is_superuser %}
<a href='{% url "account_profile" user.username %}' class="btn btn-sm btn-outline-light">Profile</a>
{% endif %}
<form method="post" class="d-inline m-0 p-0"
action="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
hx-post="{% url exam.app_name|add:':exam_json_edit' exam.pk %}"
hx-target="closest li"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="edit_user_user" value="{{ user.pk }}">
<input type="hidden" name="add" value="{% if current %}false{% else %}true{% endif %}">
<button type="submit" class="btn btn-sm btn-outline-light toggle-btn">Toggle</button>
</form>
</div>
</div>
</li>
@@ -0,0 +1,14 @@
<div id="user-users-wrap">
<div class="d-flex justify-content-between align-items-center mb-2 small text-muted">
<div>Added: <span id="user-count-added">{{ current_user_users|length }}</span></div>
<div>Available: <span id="user-count-available">{{ available_user_users|length }}</span></div>
</div>
<ul class="list-group list-group-flush" id="user-users-list">
{% for user in current_user_users %}
{% include 'generic/partials/user_user_li.html' with user=user current=True exam=exam %}
{% endfor %}
{% for user in available_user_users %}
{% include 'generic/partials/user_user_li.html' with user=user current=False exam=exam %}
{% endfor %}
</ul>
</div>
@@ -0,0 +1,35 @@
{% extends 'generic/base.html' %}
{% block content %}
<h2>User content</h2>
<p>Click "Show content" to load items created by a user.</p>
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Username</th>
<th>Content count</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.first_name }} {{ user.last_name }}</td>
<td><a href="{% url 'account_profile' user.username %}">{{ user.username }}</a></td>
<td>{{ user.content_count }}</td>
<td>
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'generic:user_content_review' user.pk %}"
hx-target="closest tr"
hx-swap="afterend">Show content</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+18 -31
View File
@@ -1,38 +1,25 @@
{% extends 'generic/base.html' %} {% extends 'generic/base.html' %}
{% block content %} {% block content %}
<h2>User Groups</h2> <div class="container">
<p> <div class="d-flex justify-content-between align-items-center mb-3">
<a href="{% url 'generic:user_group_create' %}">Create new group</a> <h2 class="mb-0">User Groups</h2>
</p> <div>
{% for group in groups %} <a class="btn btn-sm btn-primary me-2" href="{% url 'generic:user_group_create' %}">Create new group</a>
<p><a href="{% url 'generic:user_group_detail' group.pk %}"><h4>{{group.name}}</h4></a>
{% comment %} <button class="small-url-button" data-url="{% url 'generic:group_email' pk=group.pk %}
">Email candidate details</button>
<button class="small-url-button" data-url="{% url 'generic:group_email_resend' pk=group.pk %}
">Resend candidate details</button>
<button class="small-url-button" data-url="{% url 'generic:group_email_results' pk=group.pk %}
">Email candidate results</button>
<button class="small-url-button" data-url="{% url 'generic:group_email_results_resend' pk=group.pk %}
">Resend candidate results</button> {% endcomment %}
<button class="small-url-button" data-url="{% url 'generic:user_group_update' pk=group.pk %}
">Edit</button>
<button class="small-url-button" data-url="{% url 'generic:user_group_delete' pk=group.pk %}
">Delete</button>
</p>
{% endfor %}
<script type="text/javascript">
$(".small-url-button").click((evt) => {
window.location = evt.currentTarget.dataset.url;
})
</script>
{% if view_all %} {% if view_all %}
<a href="{% url 'generic:user_group_view' %}">View active</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_view' %}">View active</a>
{% else %} {% else %}
<a href="{% url 'generic:user_group_view_all' %}">View all</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_view_all' %}">View all</a>
{% endif %} {% endif %}
</div>
</div>
<div id="groups-list">
{% for group in groups %}
{% include 'generic/partials/user_group_card.html' with group=group view_all=view_all %}
{% empty %}
<div class="alert alert-info">No groups found.</div>
{% endfor %}
</div>
</div>
{% endblock %} {% endblock %}
+46 -1
View File
@@ -8,6 +8,9 @@ from generic.views import ExamViews as GenericExamViews, GenericViewBase
app_name = "generic" app_name = "generic"
urlpatterns = [ urlpatterns = [
# User content review views
path("user-content/", views.user_content_list, name="user_content_list"),
path("user-content/<int:user_id>/", views.user_content_review, name="user_content_review"),
path("examination/", views.ExaminationView.as_view(), name="examination_view"), path("examination/", views.ExaminationView.as_view(), name="examination_view"),
path("examination/<int:pk>", views.examination_detail, name="examination_detail"), path("examination/<int:pk>", views.examination_detail, name="examination_detail"),
path( path(
@@ -59,6 +62,7 @@ urlpatterns = [
), ),
path("user-search/", views.user_search, name="user_search"), path("user-search/", views.user_search, name="user_search"),
path("user-search-widget/", views.user_search_widget, name="user_search_widget"), path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
path("exam-search-widget/", views.exam_search_widget, name="exam_search_widget"),
path("supervisor-search/", views.supervisor_search, name="supervisor_search"), path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
path( path(
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid" "cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
@@ -87,6 +91,16 @@ urlpatterns = [
views.cid_group_view_detail, views.cid_group_view_detail,
name="cid_group_detail", name="cid_group_detail",
), ),
path(
"cids/group/<int:group_id>/generate_preview",
views.cid_group_generate_preview,
name="cid_group_generate_preview",
),
path(
"cids/group/<int:group_id>/create_bulk",
views.cid_group_create_bulk,
name="cid_group_create_bulk",
),
path("cids/group/<int:pk>/email", views.group_email, name="group_email"), path("cids/group/<int:pk>/email", views.group_email, name="group_email"),
path( path(
"cids/group/<int:pk>/exams", "cids/group/<int:pk>/exams",
@@ -113,6 +127,18 @@ urlpatterns = [
views.CidUserGroupUpdate.as_view(), views.CidUserGroupUpdate.as_view(),
name="cid_group_update", name="cid_group_update",
), ),
# HTMX partial to show a CidUserExam record for an exam (used from exam lists)
path(
"exam/<str:app_name>/<int:exam_id>/cid/<int:cid>/ciduserexam",
views.cid_user_exam_partial,
name="cid_user_exam_partial",
),
path(
"cids/ciduserexam/<int:pk>/update",
views.CidUserExamRecordUpdate.as_view(),
name="update_cid_user_exam",
),
path( path(
"cids/group/<int:pk>/delete", "cids/group/<int:pk>/delete",
views.CidUserGroupDelete.as_view(), views.CidUserGroupDelete.as_view(),
@@ -178,6 +204,11 @@ urlpatterns = [
views.UserUserGroupDelete.as_view(), views.UserUserGroupDelete.as_view(),
name="user_group_delete", name="user_group_delete",
), ),
path(
"user/group/<int:pk>/archive",
views.user_group_toggle_archive,
name="user_group_toggle_archive",
),
path( path(
"user/group/create", "user/group/create",
views.UserUserGroupCreate.as_view(), views.UserUserGroupCreate.as_view(),
@@ -258,6 +289,7 @@ urlpatterns = [
path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"), path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"),
path("collection/<int:collection_id>/bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"), path("collection/<int:collection_id>/bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"),
path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"), path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"),
path("collection/<int:collection_id>/exams/<str:exam_type>/partial", views.exam_collection_exams_partial, name="exam_collection_exams_partial"),
path( path(
"share_with_supervisor_toggle/<int:pk>/", "share_with_supervisor_toggle/<int:pk>/",
views.toggle_share_with_supervisor, views.toggle_share_with_supervisor,
@@ -416,7 +448,17 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
name="exam_json_edit", name="exam_json_edit",
), ),
path( path(
"exam/<int:pk>/scores", "exam/<int:pk>/cid_bulk_edit",
generic_exam_view.exam_cid_bulk_edit,
name="exam_cid_bulk_edit",
),
path(
"exam/<int:pk>/user_bulk_edit",
generic_exam_view.exam_user_bulk_edit,
name="exam_user_bulk_edit",
),
path(
"exam/<int:pk>/scores/all",
generic_exam_view.exam_scores_all, generic_exam_view.exam_scores_all,
name="exam_scores_all", name="exam_scores_all",
), ),
@@ -485,6 +527,9 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
name="order_questions", name="order_questions",
), ),
path("exam/<int:exam_id>/cids", generic_exam_view.exam_cids, name="exam_cids"), path("exam/<int:exam_id>/cids", generic_exam_view.exam_cids, name="exam_cids"),
path("exam/<int:exam_id>/cids/cid_list", generic_exam_view.exam_cids_cid_list, name="exam_cids_cid_list"),
path("exam/<int:exam_id>/cids/user_list", generic_exam_view.exam_cids_user_list, name="exam_cids_user_list"),
path("exam/<int:exam_id>/cids/submitted_list", generic_exam_view.exam_cids_submitted_list, name="exam_cids_submitted_list"),
# path("exam/<int:exam_id>/groups", generic_exam_view.exam_groups_edit, name="exam_groups_edit"), # path("exam/<int:exam_id>/groups", generic_exam_view.exam_groups_edit, name="exam_groups_edit"),
path( path(
"exam/<int:exam_id>/reset_answers", "exam/<int:exam_id>/reset_answers",

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