Compare commits

...
105 Commits
Author SHA1 Message Date
Ross e28bf641b7 fix: Correct indentation for viewer div elements in collection_case_view_take.html and series_viewer.html 2026-05-11 13:31:50 +01:00
Ross 59956670df feat: Implement responsive design enhancements with new utility classes and improved layout for various components 2026-05-11 13:31:25 +01:00
Ross d2a4ee9f70 feat: Refactor navigation bar for consistent structure and improved user experience across templates 2026-05-11 13:26:45 +01:00
Ross c7fd99f6f3 feat: Update navbar identifiers for better accessibility and mobile responsiveness 2026-05-11 13:25:28 +01:00
Ross cf4cb32c5e feat: Enhance media cleanup functionality with loading indicator and detailed results 2026-05-11 13:10:05 +01:00
Ross f4c2c4bdaf feat: Update index.html layout and improve user authentication flow 2026-05-11 13:01:45 +01:00
Ross 8da8f27433 feat: Add media cleanup functionality with UI and backend support for unused media management 2026-05-11 12:58:41 +01:00
Ross c3131564e6 fix: Correct logical condition for better OCR candidate selection 2026-05-11 12:50:11 +01:00
Ross 0bda5e2c41 feat: Enhance OCR processing with increased sample counts and improved candidate selection logic 2026-05-11 12:49:22 +01:00
Ross 6b9c7be633 feat: Implement OCR debug panel visibility sync and enhance OCR processing with improved canvas handling 2026-05-11 12:42:00 +01:00
Ross 35887fbcaf refactor: Clean up OCR debug table structure and remove redundant regex checks 2026-05-11 12:32:54 +01:00
Ross f9a9b9bb38 feat: Add OCR debug panel with detailed status and results for image processing 2026-05-11 12:30:19 +01:00
Ross 916ac3b69c feat: Improve OCR sample selection and detection of clinical identifiers 2026-05-11 12:27:28 +01:00
Ross 7ac8517062 feat: Enhance duplicate series handling with deselect functionality and UI updates 2026-05-11 12:20:51 +01:00
Ross 22c5576fc9 feat: Add anonymisation issue summary rendering for flagged series in upload process 2026-05-11 12:17:43 +01:00
Ross 52e6e79c1b fix: Remove unnecessary field reference from series in check_images_hashes function 2026-05-11 12:11:59 +01:00
Ross 3455dc3855 fix: Update field reference from series__pk to series__id in check_images_hashes function 2026-05-11 12:10:12 +01:00
Ross 2b9f3e3b68 feat: Optimize image hash checks with bulk queries and add database indexing for performance 2026-05-11 12:07:55 +01:00
Ross e55a3bf36a fix: Correct duplicate check logic in upload process 2026-05-11 12:00:38 +01:00
Ross b24eac862b feat: Implement duplicate check functionality during DICOM uploads and update viewer element ID 2026-05-11 11:53:18 +01:00
Ross 84b032addb Refactor code structure for improved readability and maintainability 2026-05-11 11:31:04 +01:00
Ross d046b7a151 Refactor code structure for improved readability and maintainability 2026-05-11 11:06:51 +01:00
Ross aaa3ee6313 feat: Add support for study instance UID in series and enhance DICOM import functionality 2026-05-11 10:05:39 +01:00
Ross c1e351c7db feat: Enhance user uploads with support for partial imports and improved UI feedback 2026-05-11 09:25:41 +01:00
Ross 863d65b4b3 feat: Refactor smoke tests to focus on standard named routes and improve route filtering 2026-05-11 09:09:22 +01:00
RossandCopilot 26a73d1d3b feat: Update test configurations and add smoke tests for named view routes
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:35:11 +01:00
RossandCopilot d12933e62a feat: Enhance user uploads with import suggestions for already uploaded DICOM series
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:20:09 +01:00
RossandCopilot 771abf62e2 feat: Refactor series tag consistency modal with improved UI and analysis functionality
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:08:26 +01:00
RossandCopilot 859042bf96 feat: Add DICOM tag consistency analysis with dynamic loading in series viewer
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 22:01:54 +01:00
RossandCopilot 6574c29a70 feat: Enhance series splitting functionality with DICOM tag analysis and improved user feedback
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:57:19 +01:00
RossandCopilot 9edac288f5 feat: Add transaction import to views for improved database handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:51:17 +01:00
RossandCopilot ab7dcaed76 feat: Implement series splitting by DICOM tag with HTMX support
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:46:36 +01:00
RossandCopilot 2aff9516ac feat: Enhance security by adding @login_required to multiple views and replacing print statements with logger.debug
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 21:35:08 +01:00
Ross 5d37cad48d feat: Refactor resource, description, history, and viewer sections for improved readability 2026-04-30 20:32:14 +01:00
RossandCopilot 6575c50507 feat: Enhance case collection and detail forms with timing overrides and custom screens
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 20:31:16 +01:00
RossandCopilot 74cdf14af3 feat: Add option to include template debug noise in logs view
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:47:43 +01:00
Ross 9ef4b2dac1 feat: Add button to view raw log file in logs viewer 2026-04-29 22:43:45 +01:00
RossandCopilot 2e35fa7036 feat: Add raw log file viewing option in logs view
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:42:02 +01:00
RossandCopilot 6be7398b42 feat: Enhance logs view with level filtering and improved entry display
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:40:08 +01:00
RossandCopilot 2478de6b98 feat: Enhance logs view with support for displaying all entries and improved pagination
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:37:13 +01:00
RossandCopilot 7479b44f53 feat: Add logs view for superusers to display and search production logs
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:32:18 +01:00
RossandCopilot 89c8cd565f feat: Implement supervisor trainee management features, including search, add, and remove functionalities
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 22:27:09 +01:00
RossandCopilot f63f3b3687 feat: Enhance supervision page with collapsible group sections for better organization
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:50:45 +01:00
RossandCopilot 8201f91697 fix: Improve sorting and normalization of study dates in user uploads
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:36:20 +01:00
RossandCopilot 082a260757 feat: Consolidate shared attempts functionality into supervision page and remove legacy shared-with-me page
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:28:57 +01:00
RossandCopilot 5bd1338b5e feat: Add supervision page to display collection attempts shared with the user
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:18:43 +01:00
RossandCopilot 7062723433 feat: Implement sharing functionality for CidUserExam attempts, including user management and sharing settings
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:09:06 +01:00
RossandCopilot cc100dba89 fix: Update case messaging conditionals for clarity and correctness
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 21:36:30 +01:00
RossandCopilot 64aa2974ee feat: Add admin edit links for superusers in case history view
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 21:34:46 +01:00
Ross 47cfa79083 try to fix thumbnails in d3vd 2026-04-28 21:13:02 +01:00
RossandCopilot 5c217266c4 feat: Add safe mounting for display set viewers to enhance viewer initialization
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:56:43 +01:00
RossandCopilot 68dfcdfa7e feat: Implement dynamic loading for display set modals and fullscreen viewer
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:48:20 +01:00
RossandCopilot e894e02445 feat: Refactor collection take start template for improved layout and user experience
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:11:19 +01:00
Ross f7cc51c221 feat: Add documentation for Atlas widgets including case_search_widget, finding_search_widget, and displayset_search_widget 2026-04-28 14:04:48 +01:00
Ross 56a644c5c1 fix: Correct indentation for hx-include attribute in case search widget 2026-04-27 23:11:57 +01:00
Ross 4a787aa3eb feat: Add show select button option to case search widget and related templates for improved case selection 2026-04-27 23:10:20 +01:00
Ross 7031942198 feat: Enhance case selection functionality with dedicated select button and improved event handling 2026-04-27 23:07:06 +01:00
Ross 585a19013a feat: Enhance case:selected event handling with logging for better debugging 2026-04-27 22:58:20 +01:00
Ross 61a83ed836 feat: Refactor move series case selection logic with improved event handling and state synchronization 2026-04-27 22:55:23 +01:00
Ross bf12204c51 feat: Refactor move series functionality by simplifying event handling and removing redundant code 2026-04-27 22:49:52 +01:00
Ross 29165811d1 feat: Enhance linked cases overview with detailed case information and navigation, and add linked case card partial 2026-04-27 22:41:16 +01:00
Ross 02ad8c61e5 feat: Implement per-case query and messaging functionality with UI updates and tests 2026-04-27 22:28:45 +01:00
Ross 70ea3788ca feat: Add functionality to move selected series between cases with a new form and UI enhancements 2026-04-27 22:16:06 +01:00
Ross 3772846e55 feat: Update user messages inbox and collection user messages with enhanced acknowledgment statistics and navigation links 2026-04-27 22:05:30 +01:00
Ross 1876a7f845 feat: Enhance collection history page with detailed user and CID exam statistics 2026-04-27 21:49:59 +01:00
Ross d4f25de9d3 feat: Enhance case conversation display with detailed message stats and loading indicator 2026-04-27 21:45:27 +01:00
Ross 0b1f842e31 feat: Update case conversation details to reflect total unacknowledged messages 2026-04-27 21:40:58 +01:00
Ross 29f9b724b0 feat: Add guidance for session submission and clarify editing options 2026-04-27 21:37:06 +01:00
Ross 1f1b324cfe feat: Update collection overview header and remove completed state display 2026-04-27 21:35:29 +01:00
Ross 474ad205f6 feat: Add feedback overview button visibility based on review mode and exam completion status 2026-04-27 21:34:30 +01:00
Ross 1f806d2e60 feat: Update self-review and conversation visibility based on review mode and exam completion status 2026-04-27 21:33:34 +01:00
Ross 20987274af feat: Update self-review button visibility based on question completion status 2026-04-27 21:30:34 +01:00
Ross d82551674b feat: Conditionally load case conversation based on question completion status 2026-04-27 21:28:57 +01:00
Ross 0d1318564c feat: Enhance series item display with additional metadata and modularize template 2026-04-27 21:25:31 +01:00
Ross 9a8a1a1b57 feat: Add navigation buttons for collection overview and user answers in messages inbox 2026-04-27 13:29:42 +01:00
Ross 9df74edc8c feat: Add moderator action buttons in user messages inbox for enhanced navigation 2026-04-27 13:17:06 +01:00
Ross 3db2c1a768 feat: Implement user messages inbox with message acknowledgment tracking and UI updates 2026-04-27 13:09:19 +01:00
Ross dc479cafd0 feat: Add case review messaging system with feedback and query capabilities
- Introduced CaseReviewMessage model to handle feedback and queries for cases.
- Implemented views for displaying user and CID messages, including outstanding feedback.
- Created templates for collection feedback overview and user messages.
- Added functionality for acknowledging messages and sending feedback or queries.
- Enhanced collection detail view to show outstanding messages and user message statistics.
- Added tests for handling duplicate DICOM uploads without series.
2026-04-27 12:55:27 +01:00
Ross b02120d5e7 update dv3d 2026-04-27 12:50:10 +01:00
Ross be02269af6 Fix duplicate handling in DICOM upload: ensure series is not None before adding to duplicate series 2026-04-27 12:48:13 +01:00
Ross 5843547a05 Add end time display for completed user exams in collection overview 2026-04-27 12:06:45 +01:00
Ross 36a77981c8 Enhance self-review functionality with new detail views, improved UI, and dynamic case management 2026-04-27 11:43:15 +01:00
Ross 95793c78c9 Refactor self-review functionality: replace inline actions with dedicated partials and enhance dynamic panel updates 2026-04-27 11:16:48 +01:00
Ross 37e1bf29a3 Add self-review popup functionality with success message and auto-refresh 2026-04-27 11:12:44 +01:00
Ross b864f3d3fd Implement self-review functionality with enhanced UI and dynamic scoring options 2026-04-27 11:07:09 +01:00
Ross e82b353ad9 Enhance case display sets management with HTMX integration and improved UI elements 2026-04-27 10:48:02 +01:00
Ross 854f265623 Add agent guidelines documentation for RAD project architecture, models, views, templates, testing, and deployment 2026-04-27 10:04:56 +01:00
Ross bebac18625 Refactor user_scores.html to replace Case Collections section with Atlas Collections, implementing HTMX for dynamic loading of user-specific collections. 2026-04-27 09:58:00 +01:00
Ross 068dd3df80 Refactor normal case toggle buttons for improved readability and structure 2026-04-27 09:46:39 +01:00
Ross 9bae94a595 Refactor collection case view documentation and enhance management view with edit permissions 2026-04-27 09:42:46 +01:00
Ross df7f56f34c Update dv3d 2026-04-27 09:37:57 +01:00
Ross f47f408cce Add empty case handling for differentials in partial template 2026-04-13 14:32:33 +01:00
Ross c0870a8765 Fix loop to use correct method for retrieving ordered series in findings partial 2026-04-13 14:30:48 +01:00
Ross 113424d1e4 Fix indentation for included partials in case display block template 2026-04-13 14:28:23 +01:00
Ross e007e35f4f Add show_differentials_post field and update templates for case differentials display 2026-04-13 14:27:14 +01:00
Ross e162e60f44 Refactor case display logic by creating partials for findings and display sets to enhance maintainability and readability 2026-04-13 14:15:04 +01:00
Ross 5c50ee75da Add post-exam display options to CaseCollection model and update template 2026-04-13 14:07:54 +01:00
Ross 90c8f0b60d Enhance logging and error handling in collection access methods and WSGI configuration 2026-04-13 13:44:25 +01:00
Ross 463a741185 Refactor exam_user_bulk_edit method to support both generic and app-specific exam IDs for improved flexibility 2026-04-13 10:00:03 +01:00
Ross f7b6ec1447 Refactor exam_users_edit.html to improve formatting and maintainability of the user addition panel 2026-04-13 09:57:25 +01:00
Ross 3e03413047 Add user search widget to exam_users_edit.html for adding arbitrary users 2026-04-13 09:54:18 +01:00
Ross 126d186ad1 Add disabled nav link for 'Candidate Management' in exam_candidate_headers.html for improved navigation clarity 2026-04-13 09:53:41 +01:00
Ross a749373a21 Refactor exam_candidate_headers.html to wrap navigation links in a structured nav and ul for improved semantics and styling 2026-04-13 09:48:24 +01:00
Ross d9331fe279 Refactor exam_groups_edit.html to use crispy forms for improved form rendering and styling 2026-04-13 09:45:58 +01:00
Ross 3e936e494a Update exam-related URL patterns to use 'collection' prefix and add bulk edit routes 2026-04-13 09:38:41 +01:00
110 changed files with 12191 additions and 2359 deletions
+117
View File
@@ -0,0 +1,117 @@
# RAD Project — Agent Guidelines
Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL + Redis + Celery stack.
## Architecture
### Apps
`generic` · `anatomy` · `physics` · `rapids` · `shorts` · `longs` · `sbas` · `wally` · `atlas` · `rcr` · `rota` · `oef`
- `generic/` — shared base models, views, forms, mixins, decorators, widgets. **Always check here before adding new base classes or utilities.**
- `atlas/` — CaseCollection-based learning (DICOM cases, findings, display sets, differentials)
- `rad/` — project config (`settings.py`, `urls.py`, `celery.py`, root API)
### Request/Response Pattern
- HTMX-first: `if request.htmx:` returns a partial, else returns a full page.
- Full pages extend `base.html`. HTMX partials live in `{app}/templates/{app}/partials/`.
- Partials must be standalone fragments — no `{% extends %}`.
- HTMX detection via `django_htmx` middleware (`request.htmx`).
### API
- Django Ninja (not DRF). Routers in `{app}/api.py`, registered in `rad/api.py` at `/api/v1/`.
- Use `ModelSchema` with explicit `Meta.fields` lists (avoid `"__all__"` on public endpoints).
## Models
- **Auto-timestamp**: use `created_at = models.DateTimeField(auto_now_add=True)` — match the convention already on the model you are editing.
- **Primary keys**: default Django `AutoField` (integer). Do not switch to UUID without discussion.
- **Audit trail**: wrap models that need history with `@reversion.register()`.
- **Base classes** in `generic/models.py`:
- `QuestionBase`, `ExamBase`, `UserAnswerBase` — exam content
- `AuthorMixin` — M2M authors field
- `CidUser`, `CidUserGroup` — cohort-based access
- After any model change: run `python manage.py makemigrations && python manage.py migrate`.
## Views
- Mix of FBVs and CBVs. Prefer the pattern already used in the app you are editing.
- **Login**: `@login_required` (FBV) or `LoginRequiredMixin` (CBV).
- **Permission decorators** in `generic/decorators.py`: `@user_is_cid_user_manager`, `@check_user_in_group`.
- **Key CBV mixins** in `generic/views.py`: `CidManagerRequiredMixin`, `AuthorRequiredMixin`, `CheckCanEditMixin`.
- Do not expose `can_edit=True` to candidate-facing views. Management and candidate views are intentionally separate.
- When editing views please update docstrings to include their scope and functionality.
## Templates
- **Inheritance**: all full pages use `{% extends "base.html" %}`.
- **Partials path**: `{app}/templates/{app}/partials/_fragment_name.html` (prefix with `_`).
- **Template tags**: always load `{% load static %}`, `{% load django_htmx %}`, `{% load crispy_forms_tags %}` as needed at the top of the file.
- **Bootstrap 5** (dark theme). Use existing utility classes; do not add inline styles.
- To include a partial: `{% include 'app/partials/_fragment.html' %}`. Pass context explicitly when using `with` keyword.
- **Multi line comment** NEVER use {# ... #} style comments for multiline comments as it will render into the html.
## Forms
- **Crispy Forms** with `crispy_bootstrap5`. All forms should use a `FormHelper`.
- `Layout`, `Fieldset`, `Div`, `Row`, `Column` from `crispy_forms.layout` for structure.
- Custom widgets in `generic/widgets.py` — check before creating new widgets.
- Autocomplete fields use `django-autocomplete-light` (`dal`).
## Testing
- **Runner**: `pytest` (config in `pytest.ini`; `--nomigrations` is on by default).
- **Files**: `{app}/tests/test_*.py` or `{app}/tests.py`.
- **Fixtures**: shared fixtures in `{app}/tests/conftest.py`. Check `generic/tests/conftest.py` for common fixtures (`create_user`, `create_examination`, etc.).
- Do not use `TestCase` unless necessary — prefer plain `pytest` functions with `@pytest.mark.django_db`.
- Run tests: `pytest` or `pytest {app}/tests/`.
## Celery / Tasks
- Tasks autodiscovered from `{app}/tasks.py`.
- Broker and result backend: Redis at `redis://redis:6379` (Docker service name).
- Import the shared Celery app: `from rad.celery import app as celery_app`.
## Static & Media
- Static: `python manage.py collectstatic --noinput`. App static dirs at `{app}/static/`.
- Media: custom `HashedFilenameFileSystemStorage`. Do not assume filenames are original.
- DICOM thumbnails via `easy_thumbnails` + `helpers.images.pil_dicom_image`.
## Settings & Secrets
- **Never hardcode secrets**. Use `settings_local.py` (gitignored) or environment variables.
- Env vars: `SECRET_KEY`, `DB_NAME/USER/PASSWORD/HOST/PORT`, `MEMCACHE_HOST`, `DEBUG`.
- Local overrides: `rad/settings_local.py` (imported at the bottom of `settings.py`).
## Docker (Dev)
```bash
# Start dev environment
COMPOSE_ENV=dev docker compose -f docker/docker-compose.dev.yml up -d --build
# Run management commands inside the container
docker compose exec web python manage.py migrate
docker compose exec web python manage.py createsuperuser
```
Dev server runs on `http://localhost:8080` (nginx → gunicorn/runserver).
## Key Conventions
| Convention | Detail |
|---|---|
| HTMX partial detection | `if request.htmx: return render(...)` |
| Partial naming | `_fragment_name.html` with leading underscore |
| Permission checks | Decorators/mixins from `generic/`; never inline `if user.is_staff` in templates |
| Model timestamps | `created_at` (auto_now_add) preferred |
| API schemas | Django Ninja `ModelSchema`; explicit field lists |
| Audit trail | `@reversion.register()` on audited models |
| Logging | `loguru` (`from loguru import logger`) |
| Candidate vs management views | Keep separate; `can_edit` must not leak to candidate views |
## Before Opening a PR
1. `pytest` — all tests pass
2. `python manage.py makemigrations --check` — no missing migrations
3. `python manage.py check` — no system check errors
4. No hardcoded secrets or debug prints
+231
View File
@@ -0,0 +1,231 @@
# Security Audit Report
**Date:** 2026-04-30
**Scope:** All `views.py` files and `generic/decorators.py` across the entire project.
---
## Issues Fixed
### 1. Missing `@login_required` on `exam_collection_exams_partial` (generic/views.py)
**Severity:** High
**File:** `generic/views.py`
The `exam_collection_exams_partial` view was publicly accessible with no authentication. It exposed exam names, candidate counts, and author information from any collection to anonymous users.
**Fix:** Added `@login_required` decorator.
---
### 2. Unauthenticated `get_*_id` popup-helper views (multiple files)
**Severity:** High
**Files:** `generic/views.py`, `anatomy/views.py`, `rapids/views.py`, `longs/views.py`, `atlas/views.py`
All of the following views were decorated with `@csrf_exempt` but had **no authentication**. They allowed unauthenticated users to enumerate internal database records (Examinations, BodyParts, Structures, Abnormalities, Regions) by name via GET requests.
Additionally, all of these views contained a bug: `request.accepts("application/json")()` calls the boolean result of `accepts()` as a function, which raises `TypeError: 'bool' object is not callable`. This made them effectively non-functional (always returning `HttpResponse("/")`).
| File | View |
|---|---|
| `generic/views.py` | `get_examination_id` |
| `anatomy/views.py` | `get_body_part_id`, `get_examination_id`, `get_structure_id` |
| `rapids/views.py` | `get_abnormality_id`, `get_examination_id`, `get_region_id` |
| `longs/views.py` | `get_examination_id` |
| `atlas/views.py` | `get_examination_id` |
**Fix:**
- Removed `@csrf_exempt` (GET-only views do not need CSRF exemption).
- Added `@login_required`.
- Fixed `request.accepts("application/json")()``request.accepts("application/json")`.
- Replaced `request.GET["key"]` dict access (raises `KeyError` on missing key) with `request.GET.get("key", "")`.
- Added docstrings documenting scope and functionality.
---
### 3. Unauthenticated `answer_suggestion_submit` with CSRF exemption (rad/views.py)
**Severity:** High
**File:** `rad/views.py` (line ~781)
This view accepted anonymous POST requests with no CSRF protection to create new `Answer` records (marked `proposed=True`) on any existing question. This allowed anyone on the internet to submit arbitrary proposed answers to anatomy or rapids questions, polluting the moderation queue.
**Fix:**
- Removed `@csrf_exempt`.
- Added `@login_required`.
- Added `if request.method != "POST": return ...` guard.
- Fixed a minor typo in the response message (`"submited"``"submitted"`).
- Added docstring documenting scope and functionality.
---
### 4. Debug `print()` in `check_user_in_group` decorator (generic/decorators.py)
**Severity:** Medium
**File:** `generic/decorators.py`
The `check_user_in_group` decorator printed `request.user` to stdout on every invocation, leaking usernames/email addresses to application logs.
**Fix:** Removed the `print(request.user)` statement.
---
### 5. Debug `print()` statements leaking sensitive data (multiple files)
**Severity:** Medium
**Files:** `generic/views.py`, `atlas/views.py`, `longs/views.py`, `sbas/views.py`, `physics/views.py`, `shorts/views.py`, `rcr/views.py`, `oef/views.py`
Numerous active (non-commented) `print()` calls throughout view code leaked sensitive data to stdout/production logs, including:
- User objects and usernames
- CID numbers and passcodes
- Email address lists being processed
- Exam and queryset data
**Fix:** All active `print()` statements replaced with `logger.debug()` (using the project-standard `loguru` logger) or removed where they provided no diagnostic value. Commented-out print statements were left in place.
---
### 6. `@csrf_exempt` on `collection_case_displaysetup` (atlas/views.py)
**Severity:** Low
**File:** `atlas/views.py`
The view had `@csrf_exempt` applied alongside a comment saying "Only if you have CSRF issues; otherwise, keep CSRF protection". The view already has proper authentication via `@user_is_collection_author_or_atlas_editor`, so the CSRF exemption was unnecessary and weakened security.
**Fix:** Removed `@csrf_exempt`.
---
### 7. Missing `@login_required` on `toggle_share_with_supervisor` (generic/views.py)
**Severity:** Medium
**File:** `generic/views.py`
This HTMX view had no authentication check. An anonymous user with a known `CidUserExam` PK could toggle the `share_with_supervisor` flag (although the `request.user != cid_user_exam.user_user` check would prevent the toggle from succeeding, it still leaks whether the record exists).
**Fix:** Added `@login_required` and updated docstring.
---
### 8. Missing `@login_required` on `active_exams` in sbas/views.py
**Severity:** Medium
**File:** `sbas/views.py`
The `active_exams` view had no authentication. It returned a list of all active SBA exams to anonymous users.
**Fix:** Added `@login_required`. Updated docstring.
---
### 9. Unreachable dead code in `rcr/views.py`
**Severity:** Low
**File:** `rcr/views.py`
In the `get_object` method of an `UpdateView` subclass, two lines appeared after a `return obj` statement:
```python
return obj
print("test") # unreachable
return super().get_object() # unreachable
```
**Fix:** Removed the unreachable `print("test")` and `return super().get_object()` lines.
---
## Issues Requiring Clarification (Not Fixed)
### A. `@csrf_exempt` on `exam_submit` (rad/views.py)
**File:** `rad/views.py`
This view accepts POST submissions of exam answers without CSRF protection and without `@login_required`. It appears to be a legacy mobile/external client endpoint that dispatches to `post_exam_answers()` on individual exam view classes, which may perform their own CID/passcode authentication.
**Risk:** Without authentication at this layer, the endpoint could be abused to submit bogus exam answers for real users if a valid CID/passcode combination is known.
**Recommendation:** Clarify whether this endpoint is still in active use by a mobile client. If so, implement token-based authentication (e.g. Django REST Framework token auth) or require CID/passcode in the request body and validate before dispatching. If not in use, remove it.
---
### B. Unauthenticated views in `rcr/views.py`
**File:** `rcr/views.py`
The following views have no authentication decorators:
- `radiology_gap` — renders a full RCR gap analysis page with all curriculum items
- `radiology_results` — renders all RCR assessment results
**Risk:** If these pages are intended to be internal-only (for assessors), they should require authentication. If they are intentionally public (e.g., public-facing curriculum overview), this is acceptable.
**Recommendation:** Confirm intended visibility. If internal, add `@login_required` (or a more specific decorator).
---
### C. Unauthenticated class-based views in `oef/views.py`
**File:** `oef/views.py`
The following CBVs lack `LoginRequiredMixin`:
- `EntryUpdateView` — allows updating OEF entry fields
- `EntryTableView` — lists all entries
- `FormatsCreateView` — creates new Formats objects
- `FormatsUpdateView` — updates Formats objects
The update views in particular are a concern if this is not an intentionally public tool.
**Recommendation:** Add `LoginRequiredMixin` (and appropriate permission checks) if this app is not intended to be publicly editable.
---
### D. Commented-out `@login_required` on `exam_toggle_flag` in physics/views.py
**File:** `physics/views.py`
The `exam_toggle_flag` view has `#@login_required` commented out. The view falls back to `exam.check_user_can_take(cid, passcode, request.user)` for access control, suggesting this is intentionally accessible to CID users (non-Django-auth users). However, the intent should be confirmed.
**Recommendation:** If the CID/passcode check is the intended authentication mechanism, document this clearly. If the view should also support Django-authenticated users, restore the decorator appropriately.
---
### E. `exam_submit` potential `AttributeError` (rad/views.py)
**File:** `rad/views.py`
```python
exam_type, exam_id = request.POST.get("eid").split("/")
```
If `eid` is missing from the POST body, `request.POST.get("eid")` returns `None`, and `.split("/")` raises `AttributeError`. This would return a 500 response rather than a clean error.
**Recommendation:** Guard with:
```python
eid = request.POST.get("eid", "")
if "/" not in eid:
return JsonResponse({"success": False, "error": "Invalid eid"})
exam_type, exam_id = eid.split("/", 1)
```
---
### F. Open redirect risk in `RedirectMixin` and `UpdateUserProfileView` (generic/views.py, rad/views.py)
**File:** `generic/views.py`, `rad/views.py`
Both `RedirectMixin.get_success_url` and `UpdateUserProfileView.get_success_url` use `self.request.GET["redirect"]` / `self.request.GET.get("redirect")` as the redirect target without validation. A malicious actor could craft a URL like `?redirect=https://evil.com` to redirect authenticated users to a phishing site after a successful form submission.
**Recommendation:** Validate the redirect URL with Django's `url_has_allowed_host_and_scheme`:
```python
from django.utils.http import url_has_allowed_host_and_scheme
redirect_url = self.request.GET.get("redirect", "")
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts={self.request.get_host()}):
return redirect_url
return super().get_success_url()
```
---
### G. Missing login check on `sbas/views.py` `#@login_required` commented-out views
**File:** `sbas/views.py`
Two views have `@login_required` commented out:
- `question_view` (around line 222) — currently commented out entirely
- `question_detail` (around line 226) — currently commented out entirely
These are fully commented-out functions; no action needed unless they are re-enabled.
---
## Notes on Existing `@csrf_exempt` Usage
The following `@csrf_exempt` usages remain after fixes. All are on GET-only admin popup helpers that are now also `@login_required`. CSRF protection is not relevant for idempotent GET requests, but the decorators are harmless:
> All `@csrf_exempt` instances have been removed from the codebase as part of this audit. The pattern `@csrf_exempt` on GET-only views is redundant since CSRF protection only applies to state-changing requests (POST/PUT/PATCH/DELETE).
+12
View File
@@ -212,6 +212,12 @@ window.loadDicomViewerEvent = new Event('loadDicomViewerUrls');
window.addEventListener('loadDicomViewerUrls', function (e) {
console.log("LoadDicomViewer unls event", e.detail)
// Skip loading old viewer if using new dv3d viewer (set on new_uploads page)
if (window.skipLoadDicomViewer) {
console.log("Skipping old loadDicomViewer - using dv3d instead");
return;
}
loadDicomViewer(e.detail.images, e.detail.annotations);
}, false);
@@ -250,6 +256,12 @@ function clearableTextAreaSetup() {
}
function loadDicomViewer(images_to_load, annotations_to_load) {
// Skip loading old viewer if using new dv3d viewer (set on new_uploads page)
if (window.skipLoadDicomViewer) {
console.log("Skipping old loadDicomViewer - using dv3d instead");
return;
}
console.log("loadDicomViewer", images_to_load);
let single_dicom = document.getElementById("single-dicom-viewer");
if (single_dicom) {
+29 -29
View File
@@ -6,39 +6,39 @@
{% block navigation %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'anatomy:index' %}">Anatomy</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#anatomyNavbarNav" aria-controls="anatomyNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="anatomyNavbarNav">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
<a class="nav-link" href="{% url 'anatomy:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'anatomy:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'anatomy:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'anatomy:user_answer_table_view' %}" title="User answers">Answers</a>
</li>
{% endif %}
{% endif %}
{% endif %}
</ul>
</ul>
</div>
</div>
</div>
</nav>
</nav>
{% endblock %}
{% block content %}
{% if simple_content %}
+27 -9
View File
@@ -1242,10 +1242,16 @@ def create_body_part(request):
)
@csrf_exempt
@login_required
def get_body_part_id(request):
if request.accepts("application/json")():
body_part_name = request.GET["body_part_name"]
"""Return the primary key for a BodyPart looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up a BodyPart by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
body_part_name = request.GET.get("body_part_name", "")
body_part_id = BodyPart.objects.get(name=body_part_name).id
data = {
"body_part_id": body_part_id,
@@ -1268,10 +1274,16 @@ def create_examination(request):
)
@csrf_exempt
@login_required
def get_examination_id(request):
if request.accepts("application/json")():
examination_name = request.GET["examination_name"]
"""Return the primary key for an Examination looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up an Examination by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
examination_name = request.GET.get("examination_name", "")
examination_id = Examination.objects.get(name=examination_name).id
data = {
"examination_id": examination_id,
@@ -1282,10 +1294,16 @@ def get_examination_id(request):
@csrf_exempt
@login_required
def get_structure_id(request):
if request.accepts("application/json")():
structure_name = request.GET["structure_name"]
"""Return the primary key for a Structure looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up a Structure by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
structure_name = request.GET.get("structure_name", "")
structure_id = Structure.objects.get(name=structure_name).id
data = {
"structure_id": structure_id,
+255
View File
@@ -0,0 +1,255 @@
# Atlas Widgets
This document describes the reusable search widgets provided by Atlas, how they work, and how to use them safely.
## Scope
These widgets are Django inclusion tags implemented in `atlas/templatetags/case_widgets.py` and rendered through partial templates in `atlas/templates/atlas/partials/`.
Current widgets:
- `case_search_widget`
- `finding_search_widget`
- `displayset_search_widget`
They are HTMX-first widgets with JavaScript fallback to `fetch`.
## Load The Tags
In any template that uses these widgets:
```django
{% load case_widgets %}
```
## 1) case_search_widget
### Tag Signature
```django
{% case_search_widget
collection=None
cases=None
recent_cases=None
input_id='case-search-input'
target_id='case-search-results'
selection_mode='single'
action_url=None
show_select_button=False
%}
```
### Parameters
- `collection`:
Optional collection object. When set, search requests include `collection=<pk>` and rows render an `Add` action.
- `cases`:
Optional initial case queryset/list for first render.
- `recent_cases`:
Optional initial recent list. If omitted, widget auto-populates with up to 5 recent cases authored by the current user.
- `input_id`:
DOM id for the search input. Must be unique per page.
- `target_id`:
DOM id for results container. Must be unique per page.
- `selection_mode`:
Present in tag API but currently not used by rendering logic.
- `action_url`:
Present in tag API but currently not used by rendering logic.
- `show_select_button`:
When `True`, rows include a `Select` button and clicking rows emits selection events.
### How It Works
- Input triggers search on: `keyup changed delay:400ms`.
- HTMX call target: `atlas:case_search` (`/atlas/collection/case_search`).
- The widget includes all local hidden state via:
- `hx-include="closest .case-search-widget"`
- Search results are swapped into the configured `target_id`.
- If JavaScript cannot use HTMX, fallback `fetch` performs the same GET and swaps `innerHTML`.
### Events Emitted
The widget dispatches a bubbling custom event from the widget container:
- Event name: `case:selected`
- Detail payload:
- `casePk` (string)
- `caseTitle` (string)
Example listener:
```javascript
document.body.addEventListener('case:selected', function (e) {
const pk = e.detail && e.detail.casePk;
const title = e.detail && e.detail.caseTitle;
if (!pk) return;
// consume selection
});
```
### Result Row Contract
Rows are rendered with:
- `data-case-pk`
- visible title in `h6`
Actions per mode:
- `collection` provided: `Add` form + `View`
- no collection, `show_select_button=True`: `Select` + `View`
- no collection, `show_select_button=False`: `View`
### Common Usage Patterns
#### A. Case picker behavior (select destination/import target)
```django
{% case_search_widget
collection=None
input_id='move-series-case-search-input'
target_id='move-series-case-search-results'
show_select_button=True
%}
```
Then consume `case:selected` and write `casePk` to your hidden form field.
#### B. Add to collection flow
```django
{% case_search_widget
collection=collection
cases=cases
recent_cases=recent_cases
input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s'
target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s'
%}
```
No event handling is required; rows provide server-posted `Add` action.
### Endpoint Notes
The widget uses `atlas:case_search` for HTMX/fetch updates. That view supports:
- `q` search text
- `collection` optional collection id
- `show_select` optional flag to preserve select-button rendering on refresh
## 2) finding_search_widget
### Tag Signature
```django
{% finding_search_widget
case=None
findings=None
input_id='finding-search-input'
target_id='finding-search-results'
%}
```
### Parameters
- `case`:
Optional case instance; when present, search is filtered to findings linked to series in that case.
- `findings`:
Optional initial findings list.
- `input_id`, `target_id`:
DOM ids. Must be unique per page.
### How It Works
- HTMX endpoint: `atlas:finding_search_partial` / `atlas:finding_search`.
- Search query is sent as `q`.
- Case filter (if set) is sent as `case=<pk>`.
- Widget shows a case filter badge and a clear (`x`) button.
- Script mirrors active case into dataset fields used by markup insertion workflows:
- `input.dataset.currentCase`
- nearby `textarea.dataset.currentCase`
### Result Row Contract
Rows are rendered with:
- `data-finding-pk`
- `data-seriesfinding-pk`
- `data-label`
These attributes are consumed by `atlas/static/atlas/js/markup_inserter.js` to insert tokens like:
- `[[seriesfinding:<pk>|<label>]]`
- `[[finding:<pk>|<label>]]`
## 3) displayset_search_widget
### Tag Signature
```django
{% displayset_search_widget
case=None
displaysets=None
input_id='displayset-search-input'
target_id='displayset-search-results'
%}
```
### Parameters
- `case`:
Optional case instance; when present, search is filtered to display sets in that case.
- `displaysets`:
Optional initial list.
- `input_id`, `target_id`:
DOM ids. Must be unique per page.
### How It Works
- HTMX endpoint: `atlas:displayset_search_partial` / `atlas:displayset_search`.
- Query sent as `q`; case scoping sent as `case=<pk>`.
- Includes case badge + clear button behavior similar to finding widget.
- Also mirrors case context into input/textarea datasets for markup insertion workflows.
### Result Row Contract
Rows are rendered with:
- `data-ds-pk`
- `data-label`
Used by `atlas/static/atlas/js/markup_inserter.js` to insert:
- `[[displayset:<pk>|<label>]]`
## Integration Guidelines
- Keep each widget instance ids unique:
- unique `input_id`
- unique `target_id`
- Prefer one event listener per feature container or page for `case:selected`.
- Do not depend on button text (`Select`, `View`) in JS. Use data attributes and events.
- If your workflow needs selection, set `show_select_button=True` on `case_search_widget`.
- If your workflow is add-to-collection, pass `collection` and rely on row `Add` forms.
## Known Constraints
- `selection_mode` and `action_url` are currently API placeholders for `case_search_widget`; they are not applied by templates/views yet.
- Widget scripts are inline with each widget include. If many instances are rendered, listeners are instance-scoped but still duplicated per include.
## Quick Troubleshooting
- No results update:
- verify `input_id` and `target_id` are unique and present in DOM.
- verify endpoint routes exist and user is authenticated.
- Selection not received:
- ensure `show_select_button=True` and listener is bound (for example on `document.body`).
- inspect `case:selected` payload in DevTools.
- Case scoping not applied in finding/displayset widgets:
- ensure `case` is passed to the tag.
- verify `case=<pk>` appears in network requests.
## Source Files
- `atlas/templatetags/case_widgets.py`
- `atlas/templates/atlas/partials/case_search_widget.html`
- `atlas/templates/atlas/partials/case_search_results.html`
- `atlas/templates/atlas/partials/_case_search_item.html`
- `atlas/templates/atlas/partials/finding_search_widget.html`
- `atlas/templates/atlas/partials/_finding_search_results.html`
- `atlas/templates/atlas/partials/displayset_search_widget.html`
- `atlas/templates/atlas/partials/_displayset_search_results.html`
- `atlas/views.py` (`case_search`, `finding_search`, `displayset_search`)
- `atlas/static/atlas/js/markup_inserter.js`
+48 -29
View File
@@ -146,11 +146,8 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
except DuplicateDicom as e:
duplicate.append((file.name, ud.image_blake3_hash))
if type(e.duplicate) == SeriesImage:
if isinstance(e.duplicate, SeriesImage) and e.duplicate.series is not None:
duplicate_series.add(e.duplicate.series.get_absolute_url())
pass
except InvalidDicomError:
failed.append(file.name)
@@ -223,6 +220,7 @@ def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
tags = d.basic_dicom_tags
series_uid = tags["SeriesInstanceUID"]
study_uid = tags.get("StudyInstanceUID")
if series_uid not in series_map:
# Get or create Series and related objects as needed
@@ -239,6 +237,7 @@ def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
defaults={
"modality": modality,
"description": tags.get("SeriesDescription", ""),
"study_instance_uid": study_uid,
}
)
if created and tags.get("StudyDescription"):
@@ -253,6 +252,10 @@ def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
except IntegrityError:
series = Series.objects.get(series_instance_uid=series_uid)
if study_uid and not series.study_instance_uid:
series.study_instance_uid = study_uid
series.save(update_fields=["study_instance_uid"])
# Add author and case if needed
# Attach author to the series
series.author.add(request.user)
@@ -580,41 +583,57 @@ def check_image_hash(request, hash: str):
@router.post("/check_image_hashes/", auth=TokenAuth())
def check_images_hashes(request, hashes: List[str]):
"""Checks a list of image hashes and returns the series id / url if found
"""Checks a list of image hashes and returns the series id / url if found.
Uses two bulk queries (one for SeriesImage, one for UncategorisedDicom) to
handle large batches efficiently regardless of batch size.
Return format
{ "hash_id": {"id": "series_id|false", "url": "series_url|false"}, ...}
"""
try:
hash_status = {}
hash_status: dict = {h: {"id": False, "url": False} for h in hashes}
for hash in hashes:
# Prefer filter().first() to avoid MultipleObjectsReturned
series_image = SeriesImage.objects.filter(image_blake3_hash=hash).select_related("series").first()
if series_image:
try:
url = series_image.series.get_absolute_url()
except Exception:
url = None
data = {"id": series_image.pk, "url": url, "type": "series"}
hash_status[hash] = data
continue
# Single bulk query for all SeriesImage matches
series_images = (
SeriesImage.objects.filter(image_blake3_hash__in=hashes)
.select_related("series")
.only("pk", "image_blake3_hash", "series__id")
)
matched_hashes: set = set()
for si in series_images:
if si.image_blake3_hash in matched_hashes:
continue # keep first match per hash
try:
url = si.series.get_absolute_url()
except Exception:
url = None
hash_status[si.image_blake3_hash] = {"id": si.pk, "url": url, "type": "series"}
matched_hashes.add(si.image_blake3_hash)
uncategorised = UncategorisedDicom.objects.filter(image_blake3_hash=hash).first()
if uncategorised:
try:
uploads_url = reverse("atlas:user_uploads")
except Exception:
uploads_url = None
data = {"id": uncategorised.pk, "url": uploads_url, "type": "uncategorised"}
hash_status[hash] = data
continue
hash_status[hash] = {"id": False, "url": False}
# Single bulk query for remaining hashes in UncategorisedDicom
remaining = [h for h in hashes if h not in matched_hashes]
if remaining:
try:
uploads_url = reverse("atlas:user_uploads")
except Exception:
uploads_url = None
uncategorised_qs = (
UncategorisedDicom.objects.filter(image_blake3_hash__in=remaining)
.only("pk", "image_blake3_hash")
)
for uc in uncategorised_qs:
if uc.image_blake3_hash not in matched_hashes:
hash_status[uc.image_blake3_hash] = {
"id": uc.pk,
"url": uploads_url,
"type": "uncategorised",
}
matched_hashes.add(uc.image_blake3_hash)
return hash_status
except Exception as e:
except Exception:
logger.exception("check_images_hashes failed")
return JsonResponse({"detail": "Internal server error"}, status=500)
+137 -2
View File
@@ -46,6 +46,7 @@ from atlas.models import (
UncategorisedDicom,
UserReportAnswer,
CaseDisplaySet,
CaseReviewMessage,
)
from .models import NormalCase
@@ -116,6 +117,26 @@ class CaseSelect(Select):
pass
class MoveSelectedSeriesForm(Form):
destination_case = ModelChoiceField(
queryset=Case.objects.none(),
widget=HiddenInput(),
label="Move selected series to case",
required=True,
)
def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
source_case = kwargs.pop("source_case", None)
super().__init__(*args, **kwargs)
available_cases = get_cases_available_to_user(user)
if source_case is not None:
available_cases = available_cases.exclude(pk=source_case.pk)
self.fields["destination_case"].queryset = available_cases.order_by("pk")
class ConditionForm(ModelForm):
class Meta:
model = Condition
@@ -227,6 +248,10 @@ class CaseCollectionForm(ModelForm):
"show_description_post",
"show_discussion_post",
"show_report_post",
"show_case_link_post",
"show_findings_post",
"show_displaysets_post",
"show_differentials_post",
]
if f in self.fields
]
@@ -263,9 +288,22 @@ class CaseCollectionForm(ModelForm):
"exam_mode",
"self_review",
"feedback_once_collection_complete",
"case_query_messaging_enabled",
"show_results_sharing_information",
"viewer_mode",
"question_time_limit",
"answer_entry_grace_period",
"prerequisites",
Fieldset(
"Custom Start Screen",
"start_screen_content",
"start_screen_resources",
),
Fieldset(
"Custom End Overview",
"end_overview_content",
"end_overview_resources",
),
),
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
Div(
@@ -1058,6 +1096,14 @@ class AddCollectionToCaseForm(Form):
class SelfReviewForm(ModelForm):
SCORE_CHOICES = [
(1, "1 - Poor"),
(2, "2 - Limited"),
(3, "3 - Adequate"),
(4, "4 - Good"),
(5, "5 - Excellent"),
]
class Meta:
model = SelfReview
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
@@ -1072,6 +1118,75 @@ class SelfReviewForm(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
super(SelfReviewForm, self).__init__(*args, **kwargs)
# Render scoring fields as explicit 1-5 selector choices for cleaner UX.
self.fields["findings"] = forms.TypedChoiceField(
choices=self.SCORE_CHOICES,
coerce=int,
empty_value=None,
required=False,
widget=RadioSelect,
label="Findings",
help_text="How well did you identify key findings?",
)
self.fields["interpretation"] = forms.TypedChoiceField(
choices=self.SCORE_CHOICES,
coerce=int,
empty_value=None,
required=False,
widget=RadioSelect,
label="Interpretation",
help_text="How strong was your overall interpretation?",
)
self.fields["comments"].widget.attrs.update(
{
"rows": 5,
"placeholder": "What went well? What would you do differently next time?",
}
)
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Field("user_exam"),
Field("case"),
Field("comments"),
Field("findings"),
Field("interpretation"),
)
class CaseReviewMessageForm(ModelForm):
class Meta:
model = CaseReviewMessage
fields = ["message", "requires_acknowledgement"]
def __init__(self, *args, allow_ack=False, message_placeholder=None, **kwargs):
super(CaseReviewMessageForm, self).__init__(*args, **kwargs)
self.fields["message"].widget.attrs.update(
{
"rows": 3,
"placeholder": message_placeholder or "Write a message...",
"class": "form-control",
}
)
self.fields["requires_acknowledgement"].widget.attrs.update(
{
"class": "form-check-input",
}
)
if not allow_ack:
self.fields["requires_acknowledgement"].widget = HiddenInput()
self.fields["requires_acknowledgement"].required = False
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
class UncategorisedDicomForm(ModelForm):
class Meta:
@@ -1182,7 +1297,12 @@ class CaseQuestionForm(ModelForm):
class CaseDetailForm(ModelForm):
class Meta:
model = CaseDetail
fields = ["redact_history", "override_history"]
fields = [
"redact_history",
"override_history",
"question_time_limit_override",
"answer_entry_grace_period_override",
]
def __init__(self, *args, case_history: str = None, **kwargs):
"""
@@ -1205,6 +1325,16 @@ class CaseDetailForm(ModelForm):
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
if "question_time_limit_override" in self.fields:
self.fields["question_time_limit_override"].help_text = (
"Leave blank to use the collection default answer time limit."
)
if "answer_entry_grace_period_override" in self.fields:
self.fields["answer_entry_grace_period_override"].help_text = (
"Leave blank to use the collection default answer-only grace period."
)
# Build crispy helper/layout embedding the original history and buttons tied to override_history
self.helper = FormHelper()
self.helper.form_tag = False
@@ -1242,7 +1372,12 @@ class CaseDetailForm(ModelForm):
self.helper.layout = Layout(
HTML(history_block),
Field("override_history"),
Field("redact_history")
Field("redact_history"),
Fieldset(
"Case Timing Overrides",
Field("question_time_limit_override"),
Field("answer_entry_grace_period_override"),
),
)
@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-04-13 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0094_apitoken'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='show_case_link_post',
field=models.BooleanField(default=False, help_text='Show a link to the case (post exam)'),
),
migrations.AddField(
model_name='casecollection',
name='show_displaysets_post',
field=models.BooleanField(default=False, help_text='Show display sets for the case (post exam)'),
),
migrations.AddField(
model_name='casecollection',
name='show_findings_post',
field=models.BooleanField(default=False, help_text='Show findings related to the case (post exam)'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-04-13 13:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0095_casecollection_show_case_link_post_and_more'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='show_differentials_post',
field=models.BooleanField(default=False, help_text='Show differentials for the case (post exam)'),
),
]
@@ -0,0 +1,33 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("generic", "0031_flag"),
("atlas", "0096_casecollection_show_differentials_post"),
]
operations = [
migrations.CreateModel(
name="CaseReviewMessage",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("message_type", models.CharField(choices=[("feedback", "Feedback"), ("query", "Query"), ("reply", "Reply")], default="query", max_length=20)),
("message", models.TextField()),
("author_label", models.CharField(blank=True, max_length=255)),
("requires_acknowledgement", models.BooleanField(default=False)),
("acknowledged_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("author", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages_authored", to=settings.AUTH_USER_MODEL)),
("case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="atlas.case")),
("cid_user_exam", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="generic.ciduserexam")),
],
options={
"ordering": ("created_at",),
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-04-27 21:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0097_casereviewmessage'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='case_query_messaging_enabled',
field=models.BooleanField(default=True, help_text='Enable per-case query and messaging for this collection.'),
),
]
@@ -0,0 +1,77 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("atlas", "0098_casecollection_case_query_messaging_enabled"),
]
operations = [
migrations.AddField(
model_name="casedetail",
name="answer_entry_grace_period_override",
field=models.PositiveIntegerField(
blank=True,
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
null=True,
),
),
migrations.AddField(
model_name="casedetail",
name="question_time_limit_override",
field=models.PositiveIntegerField(
blank=True,
help_text="Optional per-case override for the answer time limit in seconds.",
null=True,
),
),
migrations.AddField(
model_name="casecollection",
name="answer_entry_grace_period",
field=models.PositiveIntegerField(
blank=True,
default=0,
help_text="Additional seconds after the case view is locked during which answers can still be entered.",
null=True,
),
),
migrations.AddField(
model_name="casecollection",
name="end_overview_content",
field=models.TextField(
blank=True,
help_text="Optional HTML content shown on the collection end overview screen.",
null=True,
),
),
migrations.AddField(
model_name="casecollection",
name="end_overview_resources",
field=models.ManyToManyField(
blank=True,
help_text="Optional resources shown on the collection end overview screen.",
related_name="end_overview_collections",
to="atlas.resource",
),
),
migrations.AddField(
model_name="casecollection",
name="start_screen_content",
field=models.TextField(
blank=True,
help_text="Optional HTML content shown on the collection start screen (for example embedded resources).",
null=True,
),
),
migrations.AddField(
model_name="casecollection",
name="start_screen_resources",
field=models.ManyToManyField(
blank=True,
help_text="Optional resources shown on the collection start screen.",
related_name="start_screen_collections",
to="atlas.resource",
),
),
]
@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("atlas", "0099_casecollection_workflow_customisation"),
]
operations = [
migrations.AddField(
model_name="casecollection",
name="show_results_sharing_information",
field=models.BooleanField(
default=True,
help_text="If true, show result-sharing information on candidate start and overview screens.",
),
),
]
@@ -0,0 +1,65 @@
# Generated by Django 5.2.7 on 2026-05-11 08:56
from django.db import migrations, models
import pydicom
def backfill_series_study_instance_uid(apps, schema_editor):
Series = apps.get_model('atlas', 'Series')
SeriesImage = apps.get_model('atlas', 'SeriesImage')
series_qs = Series.objects.filter(study_instance_uid__isnull=True)
for series in series_qs.iterator():
image_obj = (
SeriesImage.objects.filter(series_id=series.pk, removed=False)
.exclude(image='')
.order_by('pk')
.first()
)
if image_obj is None:
image_obj = (
SeriesImage.objects.filter(series_id=series.pk)
.exclude(image='')
.order_by('pk')
.first()
)
if image_obj is None or not image_obj.image:
continue
try:
with image_obj.image.open('rb') as fp:
ds = pydicom.dcmread(fp, stop_before_pixels=True, force=True)
value = ds.get('StudyInstanceUID')
if value is None:
continue
if hasattr(value, 'value'):
value = value.value
study_uid = str(value).strip()
if not study_uid:
continue
Series.objects.filter(pk=series.pk).update(study_instance_uid=study_uid)
except Exception:
continue
def noop_reverse(apps, schema_editor):
return
class Migration(migrations.Migration):
dependencies = [
('atlas', '0100_casecollection_show_results_sharing_information'),
]
operations = [
migrations.AddField(
model_name='series',
name='study_instance_uid',
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
),
migrations.RunPython(backfill_series_study_instance_uid, noop_reverse),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-05-11 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0101_series_study_instance_uid'),
]
operations = [
migrations.AlterField(
model_name='seriesimage',
name='image_blake3_hash',
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
),
migrations.AlterField(
model_name='uncategoriseddicom',
name='image_blake3_hash',
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
),
]
+151 -2
View File
@@ -1114,6 +1114,7 @@ class Series(SeriesBase):
)
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
def __str__(self) -> str:
@@ -1256,6 +1257,7 @@ class Series(SeriesBase):
class CaseCollection(ExamOrCollectionGenericBase):
app_name = "atlas"
cases = models.ManyToManyField(Case, through="CaseDetail")
@@ -1292,6 +1294,21 @@ class CaseCollection(ExamOrCollectionGenericBase):
default=False, help_text="Show the case report (post exam)"
)
# New post-exam display options
show_case_link_post = models.BooleanField(
default=False, help_text="Show a link to the case (post exam)"
)
show_findings_post = models.BooleanField(
default=False, help_text="Show findings related to the case (post exam)"
)
show_displaysets_post = models.BooleanField(
default=False, help_text="Show display sets for the case (post exam)"
)
show_differentials_post = models.BooleanField(
default=False, help_text="Show differentials for the case (post exam)"
)
author = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
@@ -1340,6 +1357,14 @@ class CaseCollection(ExamOrCollectionGenericBase):
cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="atlas_exams")
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
case_query_messaging_enabled = models.BooleanField(
default=True,
help_text="Enable per-case query and messaging for this collection.",
)
show_results_sharing_information = models.BooleanField(
default=True,
help_text="If true, show result-sharing information on candidate start and overview screens.",
)
question_time_limit = models.PositiveIntegerField(
blank=True,
@@ -1347,6 +1372,39 @@ class CaseCollection(ExamOrCollectionGenericBase):
help_text="Time limit for answering questions in seconds."
)
answer_entry_grace_period = models.PositiveIntegerField(
blank=True,
null=True,
default=0,
help_text="Additional seconds after the case view is locked during which answers can still be entered."
)
start_screen_content = models.TextField(
blank=True,
null=True,
help_text="Optional HTML content shown on the collection start screen (for example embedded resources).",
)
start_screen_resources = models.ManyToManyField(
"Resource",
blank=True,
related_name="start_screen_collections",
help_text="Optional resources shown on the collection start screen.",
)
end_overview_content = models.TextField(
blank=True,
null=True,
help_text="Optional HTML content shown on the collection end overview screen.",
)
end_overview_resources = models.ManyToManyField(
"Resource",
blank=True,
related_name="end_overview_collections",
help_text="Optional resources shown on the collection end overview screen.",
)
# Collections that must be completed before this collection can be taken
prerequisites = models.ManyToManyField(
"self",
@@ -1460,6 +1518,16 @@ class CaseCollection(ExamOrCollectionGenericBase):
"""Returns the cases in the collection in order of the CaseDetail sort_order"""
return self.cases.all().order_by("casedetail__sort_order")
def get_effective_case_time_limit(self, casedetail):
if casedetail.question_time_limit_override is not None:
return casedetail.question_time_limit_override
return self.question_time_limit
def get_effective_answer_entry_grace_period(self, casedetail):
if casedetail.answer_entry_grace_period_override is not None:
return casedetail.answer_entry_grace_period_override
return self.answer_entry_grace_period or 0
def get_next_case(self, case):
cases = list(self.get_cases())
@@ -1518,7 +1586,9 @@ class CaseCollection(ExamOrCollectionGenericBase):
Extend base check_user_can_take to also require completion of any
prerequisite collections.
"""
# Perform the normal access checks first
logger.error("Checking if user can take collection with prerequisites")
logger.error(f"CID: {cid}, user: {user}, active_only: {active_only}")
#` Perform the normal access checks first
super().check_user_can_take(cid, passcode, user=user, active_only=active_only)
# If there are prerequisites, the user (or CID) must have completed them
@@ -1685,6 +1755,18 @@ class CaseDetail(models.Model):
help_text="This will override the case history for the purpose of the exam/collection."
)
question_time_limit_override = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Optional per-case override for the answer time limit in seconds.",
)
answer_entry_grace_period_override = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
)
class Meta:
ordering = ("sort_order",)
@@ -2006,6 +2088,71 @@ class SelfReview(models.Model):
)
class CaseReviewMessage(models.Model):
"""Conversation message for one learner attempt on one case.
Supervisors/collection authors use this for per-case feedback and replies.
Learners use it to raise questions/queries. Supervisor messages may require
acknowledgement so they continue to flag on the learner-facing pages until
explicitly acknowledged.
"""
class MessageType(models.TextChoices):
FEEDBACK = "feedback", _("Feedback")
QUERY = "query", _("Query")
REPLY = "reply", _("Reply")
cid_user_exam = models.ForeignKey(
CidUserExam,
on_delete=models.CASCADE,
related_name="case_review_messages",
)
case = models.ForeignKey(
Case,
on_delete=models.CASCADE,
related_name="case_review_messages",
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="case_review_messages_authored",
null=True,
blank=True,
)
author_label = models.CharField(max_length=255, blank=True)
message_type = models.CharField(
max_length=20,
choices=MessageType.choices,
default=MessageType.QUERY,
)
message = models.TextField()
requires_acknowledgement = models.BooleanField(default=False)
acknowledged_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("created_at",)
def __str__(self) -> str:
return f"{self.get_message_type_display()} for {self.case}"
@property
def is_acknowledged(self) -> bool:
return self.acknowledged_at is not None
def get_author_display(self) -> str:
if self.author is not None:
full_name = self.author.get_full_name().strip()
return full_name or self.author.username
return self.author_label or "Learner"
def acknowledge(self):
if self.acknowledged_at is None:
self.acknowledged_at = timezone.now()
self.save(update_fields=["acknowledged_at"])
class UncategorisedDicom(models.Model):
# We use image to maintain consitency across apps
image = models.FileField(upload_to=uncategorised_dicom_directory_path)
@@ -2026,7 +2173,7 @@ class UncategorisedDicom(models.Model):
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
image_blake3_hash = models.CharField(max_length=64, null=True, blank=True)
image_blake3_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)
created_date = models.DateTimeField(default=timezone.now)
@@ -2100,6 +2247,8 @@ class UncategorisedDicom(models.Model):
"SeriesInstanceUID",
"BodyPartExamined",
"SliceThickness",
"SeriesNumber",
"StudyDate",
)
tags = {}
+19 -7
View File
@@ -119,14 +119,26 @@
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Uploads Menu</span>
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{% url 'atlas:user_uploads' %}"><i class="bi bi-hourglass-split me-1" aria-hidden="true"></i>Awaiting Import</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:new_uploads' %}"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Web Upload</a>
</li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'atlas:uploads_hash_search' %}" title="Search uploads and imported images by hash, series UID or study UID"><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Upload Search</a>
</li>
{% endif %}
</ul>
</li>
{% if request.user.is_staff %}
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:uploads_hash_search' %}" title="Search uploads and imported images by pixel hash"><i class="bi bi-fingerprint me-1" aria-hidden="true"></i>Hash Search</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
</li>
+237 -270
View File
@@ -1,3 +1,4 @@
{% load case_widgets %}
{% partialdef case-series %}
{% for series in case.get_ordered_series %}
@@ -103,6 +104,17 @@
}
.quick-edit-wrap .quick-edit-button:hover { opacity: 1; }
@media (max-width: 767.98px) {
.quick-edit-wrap .quick-edit-button,
.case-meta-item .quick-edit-button,
.case-inline-field .quick-edit-compact {
opacity: 0.95;
pointer-events: auto;
padding: 0.15rem 0.35rem;
font-size: 0.9rem;
}
}
/* Metadata-specific quick-edit: ensure edit button overlays without moving badges */
.case-meta-item { position: relative; }
.case-meta-item .quick-edit-button {
@@ -417,50 +429,136 @@
Manage Series
</button>
</summary>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a><br />
<button class="btn btn-sm btn-outline-secondary mb-1" hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will reorder all case series based upon slice location"
>
Order dicoms by slice location
</button>
<button class="btn btn-sm btn-outline-primary mb-1" hx-post="{% url 'atlas:combine_series' %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will merge all selected series into one"
>
Merge selected series
</button>
<button class="btn btn-sm btn-outline-secondary mb-1" hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will use the dicom date as the series description"
>
Use dates as description
</button>
<button hx-post="{% url 'atlas:remove_selected_series_from_case' case.pk %}"
title="remove selected series from this case"
hx-include="[name='series-ids']:checked"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to remove the selected series from this case? They will not be deleted from the database."
class="btn btn-warning mt-2"
type="button"
>
Remove selected series from case
</button>
<button type="button"
class="btn btn-secondary mt-2"
data-bs-toggle="modal"
data-bs-target="#reorderSeriesModal">
Reorder series
</button>
<div class="series-actions-body">
<a class="btn btn-sm btn-outline-secondary text-start" href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a>
<div class="series-selection-toolbar d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="series-select-all-btn">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="series-deselect-all-btn">Deselect all</button>
</div>
<div class="series-actions-grid d-flex flex-column gap-2">
<button class="btn btn-sm btn-outline-secondary text-start" hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will reorder all case series based upon slice location"
>
Order dicoms by slice location
</button>
<button class="btn btn-sm btn-outline-primary text-start" hx-post="{% url 'atlas:combine_series' %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will merge all selected series into one"
>
Merge selected series
</button>
<button class="btn btn-sm btn-outline-secondary text-start" hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
title="use dicom dates as series descriptions"
hx-include="[name='series-ids']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will use the dicom date as the series description"
>
Use dates as description
</button>
<button hx-post="{% url 'atlas:remove_selected_series_from_case' case.pk %}"
title="remove selected series from this case"
hx-include="[name='series-ids']:checked"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to remove the selected series from this case? They will not be deleted from the database."
class="btn btn-sm btn-warning text-start"
type="button"
>
Remove selected series from case
</button>
<button type="button"
class="btn btn-sm btn-secondary text-start"
data-bs-toggle="modal"
data-bs-target="#reorderSeriesModal">
Reorder series
</button>
</div>
<details class="series-move-panel border rounded p-2">
<summary class="small fw-semibold">Move selected series to another case</summary>
{% if move_series_form %}
<form id="move-selected-series-form" class="d-flex flex-column gap-2 mt-2">
{{ move_series_form.destination_case }}
<div class="small text-muted">Click any case row below to select the destination case.</div>
<div id="selected-move-case" class="small text-muted">No destination case selected.</div>
{% case_search_widget collection=None input_id='move-series-case-search-input' target_id='move-series-case-search-results' show_select_button=True %}
<button type="button"
id="move-selected-series-submit"
class="btn btn-sm btn-outline-primary text-start"
disabled
hx-post="{% url 'atlas:move_selected_series_to_case' case.pk %}"
hx-include="[name='series-ids']:checked, #move-selected-series-form [name='destination_case']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Move selected series to the chosen destination case?">
Move selected series
</button>
</form>
<script>
(function () {
if (window.__moveSeriesCaseSelectionBound) return;
window.__moveSeriesCaseSelectionBound = true;
function applyMoveCaseSelection(casePk, caseTitle) {
if (!casePk) return;
const form = document.getElementById('move-selected-series-form');
const hiddenInput = form ? form.querySelector("[name='destination_case']") : null;
const label = document.getElementById('selected-move-case');
const results = document.getElementById('move-series-case-search-results');
if (!hiddenInput) return;
hiddenInput.value = casePk;
if (label) {
label.innerHTML = 'Selected destination: <strong>' + caseTitle + ' (' + casePk + ')</strong>';
label.classList.remove('text-muted');
}
if (results) {
results.querySelectorAll('.list-group-item[data-case-pk]').forEach(function (item) {
item.classList.toggle('active', String(item.getAttribute('data-case-pk')) === String(casePk));
});
}
syncMoveSeriesSubmitState();
}
function syncMoveSeriesSubmitState() {
const form = document.getElementById('move-selected-series-form');
const hiddenInput = form ? form.querySelector("[name='destination_case']") : null;
const submitBtn = document.getElementById('move-selected-series-submit');
if (!hiddenInput || !submitBtn) return;
submitBtn.disabled = !hiddenInput.value;
}
document.body.addEventListener('case:selected', function (e) {
try {
const detail = e.detail || {};
const casePk = detail.casePk;
const caseTitle = detail.caseTitle || ('Case #' + casePk);
applyMoveCaseSelection(casePk, caseTitle);
} catch (err) {
console.error('Move series case:selected handler error', err);
}
});
syncMoveSeriesSubmitState();
})();
</script>
{% else %}
<div class="text-muted small mt-2">Case move options are unavailable in this view.</div>
{% endif %}
</details>
</div>
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
<span id="series-action-results" ></span>
</div>
@@ -678,144 +776,12 @@
{% endif %}
</div>
<div>
<details open>
<summary><b>Findings</b></summary>
{% if can_edit %}
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Findings are display sets that can be added to series. Once added they will appear here on linked cases.</p>
<p>To add a finding to a series, select the series and then click the "Add finding" button.</p>
</details>
{% endif %}
{% include 'atlas/partials/_series_findings.html' %}
<!-- Series Findings -->
<h5>Series Findings</h5>
{% for series in case.ordered_series %}
{% for finding in series.findings.all %}
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
<span>
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
<button class="btn btn-primary btn-sm">View</button>
</a>
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
View in Modal
</button>
</span>
{% if finding.findings %}
<span>
Findings: {% for f in finding.findings.all %}
{{f.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.conditions %}
<span>
Conditions: {% for c in finding.conditions.all %}
{{c.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.structures %}
<span>
Structure: {% for s in finding.structures.all %}
{{s.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.description %}
<span>
Description: {{finding.description}}
</span>
{% endif %}
</div>
{% endfor %}
{% empty%}
No series associated with case.
{% endfor %}
<!-- Case Display Sets -->
<h5 class="mt-3">Case Display Sets</h5>
<ul class="list-group">
{% for ds in case.display_sets.all %}
<li class="list-group-item">
<b>Name: {{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted">({{ ds.description }})</span>
{% endif %}
<span>
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
<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>
<div>
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul>
{% for finding in ds.findings.all %}
<li>{{ finding.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul>
{% for structure in ds.structures.all %}
<li>{{ structure.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul>
{% for condition in ds.conditions.all %}
<li>{{ condition.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<details>
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
<div>
{% include 'atlas/partials/json_pretty.html' with json_value=ds.viewerstate title='Viewer State' %}
</div>
<div>
{% include 'atlas/partials/json_pretty.html' with json_value=ds.annotations title='Annotations' %}
</div>
</details>
</div>
</li>
{% empty %}
<div class="displayset-empty-state">
<span class="text-muted">No display sets for this case.</span>
{% if can_edit %}
<a class="displayset-empty-link" href="{% url 'atlas:case_displaysets' case.pk %}">Add one now</a>
{% endif %}
</div>
{% endfor %}
</ul>
</details>
</div>
<div>
<details open>
<summary><b>Differentials</b></summary>
<ul>
{% for diff in case.differentialcase.all %}
<li>{{diff.condition.get_link}} - {{diff.text}}</li>
{% endfor %}
</ul>
</details>
{% include 'atlas/partials/_case_displaysets.html' %}
</div>
{% include 'atlas/partials/_case_differentials.html' %}
</div>
</div>
@@ -868,7 +834,6 @@
{% include 'question_notes.html' %}
</div>
{% include 'atlas/partials/_displayset_modals.html' %}
<p><b>Checked by:</b> {% for verified in case.verified.all %} <a
href="{% url 'atlas:verified_detail' pk=verified.pk %}">{{verified}}</a>, {% endfor %}</p>
@@ -976,12 +941,15 @@
});
// On submit, update the hidden inputs to match the new order
document.getElementById("reorder-series-form").addEventListener("submit", function(e) {
const lis = document.querynelectorAll("#series-reorder-list li");
lis.forEach((li, idx) => {
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
const reorderSeriesForm = document.getElementById("reorder-series-form");
if (reorderSeriesForm) {
reorderSeriesForm.addEventListener("submit", function(e) {
const lis = document.querySelectorAll("#series-reorder-list li");
lis.forEach((li, idx) => {
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
});
});
});
}
// Drag and drop reordering for series list
const reorderList = document.getElementById("series-reorder-list");
@@ -1033,32 +1001,80 @@
const detailsElement = document.getElementById("series-actions");
const buttons = document.querySelectorAll(".select-series-btn");
const seriesCheckboxes = document.querySelectorAll("input[type='checkbox'][name='series-ids']");
const selectAllBtn = document.getElementById("series-select-all-btn");
const deselectAllBtn = document.getElementById("series-deselect-all-btn");
function toggleCheckboxes() {
const isOpen = detailsElement.hasAttribute("open");
buttons.forEach(button => {
const isOpen = detailsElement && detailsElement.hasAttribute("open");
buttons.forEach((button) => {
button.style.display = isOpen ? "inline-block" : "none";
});
}
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
function syncSeriesSelectionUi() {
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
const parent = btn.closest(".series-block");
if (!parent) {
return;
}
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
checkbox.checked = !checkbox.checked;
if (!checkbox) {
return;
}
btn.classList.toggle("btn-primary", checkbox.checked);
btn.classList.toggle("btn-outline-primary", !checkbox.checked);
btn.textContent = checkbox.checked ? "Selected" : "Select";
// Highlight the series-block when selected
if (checkbox.checked) {
parent.classList.add("highlight-series");
} else {
parent.classList.remove("highlight-series");
}
});
const selectedCount = Array.from(seriesCheckboxes).filter((checkbox) => checkbox.checked).length;
if (selectAllBtn) {
selectAllBtn.disabled = selectedCount === seriesCheckboxes.length && seriesCheckboxes.length > 0;
}
if (deselectAllBtn) {
deselectAllBtn.disabled = selectedCount === 0;
}
}
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
const parent = btn.closest(".series-block");
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
checkbox.checked = !checkbox.checked;
syncSeriesSelectionUi();
});
});
seriesCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", syncSeriesSelectionUi);
});
if (selectAllBtn) {
selectAllBtn.addEventListener("click", function() {
seriesCheckboxes.forEach((checkbox) => {
checkbox.checked = true;
});
syncSeriesSelectionUi();
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener("click", function() {
seriesCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
syncSeriesSelectionUi();
});
}
// Initial state
toggleCheckboxes();
syncSeriesSelectionUi();
// Show the alert when a result is received
@@ -1074,63 +1090,9 @@
observer.observe(resultsSpan, { childList: true, subtree: true });
// Listen for toggle events on the <details> element
detailsElement.addEventListener("toggle", toggleCheckboxes);
const modal = new bootstrap.Modal(document.getElementById("findingModal"));
const modalBody = document.getElementById("findingModalBody");
document.querySelectorAll(".view-finding-modal").forEach(button => {
button.addEventListener("click", function () {
const findingId = this.getAttribute("data-finding-id");
const seriesId = this.getAttribute("data-series-id");
// Show loading text
modalBody.innerHTML = "<p>Loading...</p>";
// Fetch the finding details (replace with your actual endpoint)
fetch(`/atlas/series_finding/${findingId}/details/`)
.then(response => response.text())
.then(html => {
modalBody.innerHTML = html; // Load the fetched HTML into the modal body
load_3d = false;
viewer_element_3d = document.getElementById('root');
if (viewer_element_3d.dataset.viewerstate3d != undefined) {
load_3d = true;
viewerState = JSON.parse(viewer_element_3d.dataset.viewerstate3d || {});
annotationjson3d = JSON.parse(viewer_element_3d.dataset.annotationjson3d || {});
} else {
viewer_element_3d = null;
}
// Initialize viewer deterministically after injecting HTML
(async function(){
try {
await mountDicomViewersSafely();
if (load_3d) {
console.log("loading 3d");
// deferred 3D import can be handled here if needed
} else {
console.log("not loading 3d");
}
} catch(e){
console.warn('Viewer init error:', e);
}
})();
document.getElementById("reload-finding").addEventListener("click", function () {
mountDicomViewersSafely();
});
// Initialize the DICOM viewer with the images and annotations
})
.catch(error => {
console.error("Error loading finding details:", error);
modalBody.innerHTML = "<p>Failed to load finding details.</p>";
});
// Show the modal
modal.show();
});
});
if (detailsElement) {
detailsElement.addEventListener("toggle", toggleCheckboxes);
}
// Display Set modal handler
const dsModal = new bootstrap.Modal(document.getElementById("displaysetModal"));
@@ -1247,28 +1209,11 @@
}
}
document.querySelectorAll(".view-displayset-modal").forEach(button => {
button.addEventListener("click", function () {
const url = this.getAttribute("data-url");
const dsId = this.getAttribute('data-ds-id');
loadDisplaysetModal(url, dsId);
});
});
// remove displayset param when modal closed
document.getElementById('displaysetModal').addEventListener('hidden.bs.modal', function () {
try { removeUrlParam('displayset'); removeUrlParam('displayset_full'); } catch (e) {}
});
// Fullscreen button handler: call loader function
document.querySelectorAll('.displayset-fullscreen-btn').forEach(btn => {
btn.addEventListener('click', function () {
const url = btn.dataset.url;
const dsId = btn.dataset.dsId || null;
loadDisplaysetFullscreen(url, dsId);
});
});
// remove fullscreen param when fullscreen modal closed
document.getElementById('displaysetFullscreenModal').addEventListener('hidden.bs.modal', function () {
try { removeUrlParam('displayset_full'); } catch (e) {}
@@ -1278,7 +1223,8 @@
document.querySelectorAll(".view-finding-modal").forEach(button => {
button.addEventListener("click", function () {
const findingId = this.getAttribute("data-finding-id");
try { setUrlParam('finding', findingId); } catch (e) {}
const url = this.getAttribute('data-url');
loadFindingModal(findingId, url);
});
});
document.getElementById('findingModal').addEventListener('hidden.bs.modal', function () {
@@ -1363,12 +1309,33 @@
background: rgba(255,255,255,0.02);
margin-top: .5rem;
}
.series-actions-body {
display: flex;
flex-direction: column;
gap: .5rem;
}
.series-selection-toolbar {
border-top: 1px solid rgba(0,0,0,0.08);
border-bottom: 1px solid rgba(0,0,0,0.08);
padding: .5rem 0;
}
.series-actions-grid .btn,
.series-actions-body > a.btn,
.series-move-panel .btn {
width: 100%;
}
.series-move-panel {
background: rgba(255,255,255,0.03);
}
.series-move-panel .case-details {
margin-bottom: .5rem;
}
.series-actions summary { list-style: none; }
.series-actions summary::-webkit-details-marker { display:none; }
.series-actions .btn { vertical-align: middle; }
.series-actions > a, .series-actions > button { width: 100%; text-align: left; }
.series-actions > button.btn.btn-warning,
.series-actions > button.btn.btn-secondary { width: auto; align-self: flex-start; }
.series-actions .btn {
vertical-align: middle;
text-align: left;
}
@media (prefers-color-scheme: dark) {
.series-actions { background: rgba(255,255,255,0.02); border-color: rgba(255,255,255,0.04); }
}
@@ -6,8 +6,7 @@
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_displaysetup" %}
<h2>Setup default display for case</h2>
<div id="root" class="dicom-viewer-root" data-images="{{casedetail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
<div id="root" class="dicom-viewer-root viewer-frame-setup" data-images="{{casedetail.case.get_series_images_nested}}"
data-auto-cache-stack="true"
{% if casedetail.default_viewerstate %}
data-viewerstate={{ casedetail.default_viewerstate }}
@@ -36,90 +36,103 @@
</div>
</div>
{% if not question_completed and collection.question_time_limit is not None %}
<div id="question-timer-block" class="mb-3" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
{% if not question_completed and effective_question_time_limit is not None %}
<div id="question-timer-block" class="mb-3" title="This case has a view time limit of {{ effective_question_time_limit }} seconds.">
<div class="d-flex align-items-center gap-3">
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite">&nbsp;</span></div>
<div style="flex:1; max-width:320px;">
<div class="progress" style="height:10px;">
<div><strong>Case view:</strong> <span id="question-timer" aria-live="polite">&nbsp;</span></div>
<div class="flex-max-320">
<div class="progress progress-compact-sm">
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
</div>
</div>
</div>
{% if effective_answer_entry_grace_period %}
<div id="answer-window-info" class="small text-muted mt-1 {% if not case_view_locked or answer_entry_closed %}d-none{% endif %}">
Answer window remaining: <span id="answer-window-timer">{{ effective_answer_entry_grace_period }}s</span>
</div>
{% endif %}
<div id="timer-htmx-target" style="display:none;"></div>
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
</div>
{% endif %}
<div id="case-view-locked-alert" class="alert alert-warning small {% if not case_view_locked %}d-none{% endif %}">
{% if answer_entry_closed %}
Case view is locked and the answer window has closed.
{% else %}
Case view is now locked. You can continue entering your answer until the answer window closes.
{% endif %}
</div>
{% comment %} <details>
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
</details> {% endcomment %}
{% if not question_completed %}
{% if resources %}
<h5 class="mt-3">Resources</h5>
<ul class="list-group list-group-flush mb-3">
{% for caseresource in resources %}
<li class="list-group-item py-1 small">{{caseresource.resource.get_display}}</li>
{% endfor %}
</ul>
<div id="case-view-content" {% if case_view_locked %}style="display:none;"{% endif %}>
{% if not question_completed %}
{% if resources %}
<h5 class="mt-3">Resources</h5>
<ul class="list-group list-group-flush mb-3">
{% for caseresource in resources %}
<li class="list-group-item py-1 small">{{caseresource.resource.get_display}}</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
{% endif %}
{% if show_description and case.description %}
<div class="mb-3">
<h6 class="mb-1">Description</h6>
<p class="mb-0">{{case.description}}</p>
</div>
{% endif %}
{% if show_history %}
<div class="card mb-3">
<div class="card-body p-2 d-flex justify-content-between align-items-start">
<div class="me-3 small text-muted">History: {{casedetail.get_history_pre|linebreaks}}</div>
{% if has_priors %}
<div class="ms-2">
<span title="This case has priors available for comparison" class="badge bg-secondary text-white rounded-pill small">Priors{% if prior_count %} ({{ prior_count }}){% endif %}</span>
</div>
{% if show_description and case.description %}
<div class="mb-3">
<h6 class="mb-1">Description</h6>
<p class="mb-0">{{case.description}}</p>
</div>
{% endif %}
{% if show_history %}
<div class="card mb-3">
<div class="card-body p-2 d-flex justify-content-between align-items-start">
<div class="me-3 small text-muted">History: {{casedetail.get_history_pre|linebreaks}}</div>
{% if has_priors %}
<div class="ms-2">
<span title="This case has priors available for comparison" class="badge bg-secondary text-white rounded-pill small">Priors{% if prior_count %} ({{ prior_count }}){% endif %}</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if collection.show_ohif_viewer %}
<div>
{% if question_completed %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer_link %}
<div>
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
{% if collection.show_ohif_viewer %}
<div>
{% if collection.show_built_in_viewer %}
{% if question_completed %}
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer_link %}
<div>
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
{% if collection.show_built_in_viewer %}
<details id="dicom-viewer-details" open>
<summary>Viewer</summary>
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
<details id="dicom-viewer-details" open>
<summary>Viewer</summary>
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
{% comment %} <div class="pre-whitespace multi-image-block">
<details open>
@@ -152,31 +165,49 @@
</div>
</div> {% endcomment %}
{% else %}
{% endif %}
{% else %}
{% endif %}
{% if show_discussion and case.discussion %}
<details class="card mb-3">
<summary class="card-header p-2" style="cursor: pointer;">
<strong>Discussion</strong>
</summary>
<div class="card-body p-2 small">
{{case.discussion|linebreaks}}
</div>
</details>
{% endif %}
{% if show_report and case.report %}
<details class="card mb-3">
<summary class="card-header p-2" style="cursor: pointer;">
<strong>Report</strong>
</summary>
<div class="card-body p-2 small">
{{case.report|linebreaks}}
</div>
</details>
{% endif %}
{% if show_discussion and case.discussion %}
<details class="card mb-3">
<summary class="card-header p-2" style="cursor: pointer;">
<strong>Discussion</strong>
</summary>
<div class="card-body p-2 small">
{{case.discussion|linebreaks}}
</div>
</details>
{% endif %}
{% if show_report and case.report %}
<details class="card mb-3">
<summary class="card-header p-2" style="cursor: pointer;">
<strong>Report</strong>
</summary>
<div class="card-body p-2 small">
{{case.report|linebreaks}}
</div>
</details>
{% endif %}
</div>
{% if question_completed %}
{% if collection.show_case_link_post %}
<div class="mb-3">
<a class="btn btn-outline-secondary btn-sm" target="_blank" href="{{ case.get_absolute_url }}">Open case</a>
</div>
{% endif %}
{% if collection.show_findings_post %}
{% include 'atlas/partials/_series_findings.html' %}
{% endif %}
{% if collection.show_displaysets_post %}
{% include 'atlas/partials/_case_displaysets.html' %}
{% endif %}
{% if collection.show_differentials_post %}
{% include 'atlas/partials/_case_differentials.html' %}
{% endif %}
{% if resources %}
<h5>Resources</h4>
<ul class="no-list-style">
@@ -188,7 +219,6 @@
</ul>
{% endif %}
{% endif %}
<form method="POST" class="post-form{% if question_completed %} completed{% endif %}">{% csrf_token %}
{% if collection.collection_type == "QUE" %}
@@ -197,18 +227,8 @@
{% include "atlas/partials/collection_question_answer_block.html" %}
{% if collection.self_review %}
<div>
<p>
<a href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button type="button">Add self review</button></a>
</p>
{% if self_review %}
<h4>Self Feedback</h4>
{% for review in self_review %}
{{review.get_display_block}}
{% endfor %}
{% endif %}
<div id="self-review-panel-que">
{% include 'atlas/partials/_self_review_block.html' with panel_key='que' %}
</div>
{% else %}
<h4>Answer score: {{answer.score}}</h4>
@@ -229,7 +249,7 @@
{{form.json.errors}}
<div class="form-contents">
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
<fieldset {% if question_completed or answer_entry_closed %}disabled="disabled"{% endif %}>
{{form}}
</fieldset>
</div>
@@ -239,7 +259,7 @@
{% elif collection.collection_type == "REP" %}
{{form.json.errors}}
<div class="form-contents">
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
<fieldset {% if question_completed or answer_entry_closed %}disabled="disabled"{% endif %}>
{{form | crispy}}
</fieldset>
</div>
@@ -247,17 +267,9 @@
{% if question_completed %}
<div>
{% if collection.self_review %}
<p>
<a target="_blank" title="Add self review, this will open in a new page / tab" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button type="button">Add self review</button></a>
</p>
{% if self_review %}
<h4>Self Feedback</h4>
{% for review in self_review %}
{{review.get_display_block}}
{% endfor %}
{% endif %}
<div id="self-review-panel-rep">
{% include 'atlas/partials/_self_review_block.html' with panel_key='rep' %}
</div>
{% else %}
<h4>Answer score: {{answer.score}}</h4>
Answer feedback: {{answer.feedback|safe}}
@@ -290,6 +302,39 @@
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div>
</form>
{% if question_completed and collection.case_query_messaging_enabled %}
<details class="mt-3" {% if case_review_stats.has_outstanding_any %}open{% endif %}>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case conversation</span>
{% if case_review_stats.has_outstanding_any %}
<span class="badge bg-danger">{{ case_review_stats.outstanding_total_count }} unacknowledged message{{ case_review_stats.outstanding_total_count|pluralize }}</span>
{% elif case_review_stats.has_messages %}
<span class="badge bg-secondary">{{ case_review_stats.message_count }} message{{ case_review_stats.message_count|pluralize }}</span>
{% else %}
<span class="text-muted">No messages yet</span>
{% endif %}
</summary>
<div class="mt-2"
hx-get="{% url 'atlas:collection_case_review_thread' exam_id=collection.id cid_user_exam_id=cid_user_exam.id case_id=case.id %}{% if cid %}?cid={{ cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Loading case conversation...</div>
</div>
</details>
{% endif %}
<aside id="selfReviewSidebar" class="self-review-sidebar" aria-live="polite" aria-label="Self review sidebar">
<div class="self-review-sidebar-header">
<h5 class="mb-0" id="selfReviewSidebarLabel">Self Review</h5>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="closeSelfReviewSidebar()">Close</button>
</div>
<div id="selfReviewSidebarBody" class="self-review-sidebar-body">
<div class="p-3 text-muted small">Choose Sidebar to load the self-review form here.</div>
</div>
</aside>
<style>
label {
vertical-align: top;
@@ -360,6 +405,44 @@
background-color: darkblue;
}
.self-review-actions .btn {
white-space: nowrap;
}
.self-review-sidebar {
position: fixed;
top: 0;
right: 0;
width: min(760px, 94vw);
height: 100vh;
background: var(--bs-body-bg);
border-left: 1px solid var(--bs-border-color);
box-shadow: -8px 0 18px rgba(0, 0, 0, 0.35);
z-index: 1050;
transform: translateX(100%);
transition: transform 0.2s ease-in-out;
display: flex;
flex-direction: column;
}
.self-review-sidebar.open {
transform: translateX(0);
}
.self-review-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-secondary-bg);
}
.self-review-sidebar-body {
overflow: auto;
flex: 1 1 auto;
}
</style>
{% endblock %}
@@ -371,6 +454,22 @@
// Question time limit countdown + auto-submit
$(function () {
function lockCaseView() {
var $view = $('#case-view-content');
if ($view.length) {
$view.hide();
}
$('#case-view-locked-alert').removeClass('d-none');
$('#answer-window-info').removeClass('d-none');
}
function closeAnswerWindow() {
$('#answer-window-info').addClass('d-none');
$('#case-view-locked-alert')
.removeClass('d-none')
.text('Case view is locked and the answer window has closed.');
}
function lockQuestion() {
// Only disable save buttons to prevent further saves
$form = $('form.post-form');
@@ -384,15 +483,17 @@
$('#question-timer').text('Locked');
$progressInner.css('background', '#dc3545');
$progressInner.css('width', '0%');
closeAnswerWindow();
$("#question-timer-progress-outer").hide();
}
try {
var timeLimit = {{ collection.question_time_limit|default:'null' }};
var timeLimit = {{ effective_question_time_limit|default:'null' }};
var answerGrace = {{ effective_answer_entry_grace_period|default:'0' }};
var questionCompleted = {{ question_completed|yesno:"true,false" }};
var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
console.debug('Timer init:', {timeLimit: timeLimit, answerGrace: answerGrace, questionCompleted: questionCompleted});
if (!timeLimit || questionCompleted) {
return;
@@ -414,25 +515,32 @@
return;
}
// Compute remaining based on the canonical start time (if provided)
var remaining = parseInt(timeLimit, 10);
// Compute remaining values based on the canonical start time (if provided)
var remainingToLock = parseInt(timeLimit, 10);
var remainingToClose = parseInt(timeLimit, 10) + parseInt(answerGrace || 0, 10);
if (answerStartedAtIso) {
try {
var started = new Date(answerStartedAtIso);
var now = new Date();
var elapsed = Math.floor((now - started) / 1000);
remaining = Math.max(0, remaining - elapsed);
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remaining:', remaining);
remainingToLock = Math.max(0, remainingToLock - elapsed);
remainingToClose = Math.max(0, remainingToClose - elapsed);
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remainingToLock:', remainingToLock, 'remainingToClose:', remainingToClose);
} catch (e) {
console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e);
}
}
// If time has already elapsed when the page loads, lock the question and do not autosubmit.
if (remaining <= 0) {
console.debug('Timer: time already expired on load, locking without autosubmit');
// If case-view time has elapsed on load, hide case content.
if (remainingToLock <= 0) {
lockCaseView();
}
// If answer-entry time has elapsed on load, fully lock.
if (remainingToClose <= 0) {
console.debug('Timer: answer-entry window already expired on load, locking without autosubmit');
lockQuestion();
$timer.text(formatTime(0));
$timer.text('Locked');
return;
}
@@ -443,10 +551,16 @@
}
var $progressInner = $('#question-timer-progress-inner');
$timer.text(formatTime(remaining));
$timer.text(remainingToLock > 0 ? formatTime(remainingToLock) : 'Locked');
if ($('#answer-window-timer').length && remainingToLock <= 0 && remainingToClose > 0) {
$('#answer-window-info').removeClass('d-none');
$('#answer-window-timer').text(formatTime(remainingToClose));
} else if ($('#answer-window-timer').length) {
$('#answer-window-info').addClass('d-none');
}
function updateProgress() {
var pct = Math.max(0, Math.min(100, Math.round((remaining / timeLimit) * 100)));
var pct = Math.max(0, Math.min(100, Math.round((remainingToLock / timeLimit) * 100)));
var widthPct = pct;
$progressInner.css('width', widthPct + '%');
@@ -470,10 +584,31 @@
updateProgress();
var intervalId = setInterval(function () {
remaining -= 1;
if (remaining <= 0) {
remainingToLock -= 1;
remainingToClose -= 1;
if (remainingToLock <= 0) {
lockCaseView();
$timer.text('Locked');
$progressInner.css('width', '0%');
$progressInner.css('background', '#dc3545');
} else {
$timer.text(formatTime(remainingToLock));
updateProgress();
}
if ($('#answer-window-timer').length) {
if (remainingToLock <= 0 && remainingToClose > 0) {
$('#answer-window-info').removeClass('d-none');
$('#answer-window-timer').text(formatTime(Math.max(0, remainingToClose)));
} else {
$('#answer-window-info').addClass('d-none');
}
}
if (remainingToClose <= 0) {
clearInterval(intervalId);
$timer.text('0:00');
$timer.text('Locked');
$progressInner.css('width', '0%');
$progressInner.css('background', '#dc3545');
//var $next = $form.find('button[name="next"]');
@@ -560,9 +695,6 @@
target: "#timer-htmx-target",
});
})();
} else {
$timer.text(formatTime(remaining));
updateProgress();
}
}, 1000);
@@ -574,6 +706,121 @@
}
});
window.openSelfReviewPopup = function (url) {
var popupUrl = url + (url.indexOf('?') === -1 ? '?popup=1' : '&popup=1');
var features = 'width=980,height=840,resizable=yes,scrollbars=yes';
window.open(popupUrl, 'selfReviewPopup', features);
};
window.selfReviewCurrentEmbedUrl = null;
window.refreshSelfReviewPanels = async function () {
try {
var resp = await fetch(window.location.href, { credentials: 'same-origin' });
var html = await resp.text();
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var currentPanels = document.querySelectorAll('.js-self-review-panel[data-refresh-key]');
currentPanels.forEach(function (currentPanel) {
var key = currentPanel.getAttribute('data-refresh-key');
if (!key) return;
var replacement = doc.querySelector('.js-self-review-panel[data-refresh-key="' + key + '"]');
if (replacement) {
currentPanel.outerHTML = replacement.outerHTML;
}
});
} catch (e) {
console.warn('Unable to refresh self review panels', e);
}
};
window.addEventListener('message', function (event) {
if (!event || !event.data) return;
if (event.data.type === 'self_review_saved') {
window.refreshSelfReviewPanels();
}
});
window.bindSelfReviewEmbedForm = function () {
var root = document.getElementById('selfReviewSidebarBody');
if (!root) return;
var form = root.querySelector('form.self-review-embed-form');
if (!form) return;
if (form.dataset.bound === '1') return;
form.dataset.bound = '1';
form.addEventListener('submit', async function (event) {
event.preventDefault();
var formData = new FormData(form);
var csrftokenInput = form.querySelector('input[name="csrfmiddlewaretoken"]');
var csrftoken = csrftokenInput ? csrftokenInput.value : '';
try {
var resp = await fetch(window.selfReviewCurrentEmbedUrl || window.location.href, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrftoken,
},
credentials: 'same-origin',
});
if (resp.redirected) {
root.innerHTML = '<div class="p-3"><div class="alert alert-success mb-0">Self review saved. You can continue reviewing this case.</div></div>';
setTimeout(function () {
window.closeSelfReviewSidebar();
window.refreshSelfReviewPanels();
}, 500);
return;
}
var html = await resp.text();
root.innerHTML = html;
window.bindSelfReviewEmbedForm();
} catch (e) {
var msg = root.querySelector('.self-review-embed-message');
if (msg) {
msg.innerHTML = '<div class="alert alert-danger py-2">Unable to save self review. Please try again.</div>';
}
}
});
};
window.closeSelfReviewSidebar = function () {
var sidebar = document.getElementById('selfReviewSidebar');
if (sidebar) {
sidebar.classList.remove('open');
}
};
window.openSelfReviewSidebar = async function (url) {
var sidebar = document.getElementById('selfReviewSidebar');
var body = document.getElementById('selfReviewSidebarBody');
if (!sidebar || !body) {
window.location.href = url;
return;
}
var embedUrl = url + (url.indexOf('?') === -1 ? '?embed=1' : '&embed=1');
window.selfReviewCurrentEmbedUrl = embedUrl;
body.innerHTML = '<div class="p-3 text-muted small">Loading self-review form...</div>';
sidebar.classList.add('open');
try {
var resp = await fetch(embedUrl, { credentials: 'same-origin' });
var html = await resp.text();
body.innerHTML = html;
window.bindSelfReviewEmbedForm();
} catch (e) {
body.innerHTML = '<div class="p-3"><div class="alert alert-danger py-2 mb-0">Could not load self-review sidebar content. Use Popup as fallback.</div></div>';
}
};
</script>
{% endblock js %}
@@ -85,6 +85,59 @@
>Sync prerequisite users</button>
</div>
{% endif %}
<div class="card border-secondary w-100 mt-3">
<div class="card-body py-2 px-3 text-start text-md-end">
{% if collection.case_query_messaging_enabled %}
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} pending acknowledgement
</div>
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Inbox</a>
</div>
{% if user_message_rows %}
<div class="mt-2 small text-muted">Users</div>
<div class="d-flex flex-column gap-1">
{% for row in user_message_rows|slice:":5" %}
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_user_messages' collection.pk row.user_user.pk %}">
{{ row.get_user_name }}
{% if row.outstanding_message_count %}
({{ row.outstanding_message_count }} outstanding)
{% else %}
({{ row.total_review_message_count }} history)
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if cid_message_rows %}
<div class="mt-2 small text-muted">CID users</div>
<div class="d-flex flex-column gap-1">
{% for row in cid_message_rows|slice:":5" %}
<a class="small {% if row.outstanding_message_count %}text-danger{% endif %}" href="{% url 'atlas:collection_cid_messages' collection.pk row.cid_user.cid %}">
{{ row.cid_user }}
{% if row.outstanding_message_count %}
({{ row.outstanding_message_count }} outstanding)
{% else %}
({{ row.total_review_message_count }} history)
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="small text-muted">Case query/messaging</div>
<div class="fw-semibold text-muted">Disabled for this collection</div>
<div class="mt-2 d-flex flex-wrap gap-2 justify-content-md-end">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Open History</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">
{% if cid is not None %}
CID: <strong>{{ cid }}</strong>
{% else %}
Feedback hub
{% endif %}
</div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Feedback Summary</div>
<div class="fw-semibold">{{ total_outstanding_feedback }} outstanding feedback item{{ total_outstanding_feedback|pluralize }}</div>
<div class="small text-muted mt-1">Across {{ outstanding_case_count }} case{{ outstanding_case_count|pluralize }}</div>
<div class="mt-2">
<a class="btn btn-sm btn-outline-secondary"
href="{% if cid is not None %}{% url 'atlas:collection_take_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_take_overview_user' pk=collection.id %}{% endif %}">
Back to Overview
</a>
</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3">Outstanding Feedback</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
<div class="card-header d-flex justify-content-between align-items-center gap-2">
<div>
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_feedback_count }} awaiting acknowledgement</span>
</div>
<div class="card-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success">No outstanding feedback needs acknowledgement.</div>
{% endif %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3">Previous Conversations</h3>
<div class="accordion" id="feedbackHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item" id="case-feedback-{{ row.casedetail.case.id }}">
<h2 class="accordion-header" id="heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} message{{ row.stats.message_count|pluralize }}</span>
</button>
</h2>
<div id="collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ row.casedetail.case.id }}" data-bs-parent="#feedbackHistoryAccordion">
<div class="accordion-body pt-0">
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}&passcode={{ passcode }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open to load conversation history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary">
<div class="card-body text-muted small">No case conversations have been started yet.</div>
</div>
{% endif %}
{% endblock %}
+158 -35
View File
@@ -1,54 +1,177 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>{{collection.name}}</h2>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Collection attempt history and message activity</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.pk %}">Collection Detail</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:user_messages_inbox' %}">Global Messages Inbox</a>
{% endif %}
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Cases</div>
<div class="h4 mb-0">{{ total_cases }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Attempts</div>
<div class="h4 mb-0">{{ total_attempt_count }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100">
<div class="card-body py-3">
<div class="small text-muted">Completed Attempts</div>
<div class="h4 mb-0">{{ completed_attempt_count }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card border-secondary h-100 {% if total_outstanding_messages %}border-danger-subtle{% endif %}">
<div class="card-body py-3">
{% if collection.case_query_messaging_enabled %}
<div class="small text-muted">Outstanding Messages</div>
<div class="h4 mb-0 {% if total_outstanding_messages %}text-danger{% endif %}">{{ total_outstanding_messages }}</div>
{% else %}
<div class="small text-muted">Case Messaging</div>
<div class="h6 mb-0 text-muted">Disabled</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if userexams %}
<h3>Users</h3>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h5 mb-0">Users</h3>
<div class="small text-muted">{{ userexams|length }} user attempt{{ userexams|length|pluralize }}</div>
</div>
<ul>
<div class="d-flex flex-column gap-3 mb-4">
{% for userexam in userexams %}
<li id="user-history-{{ userexam.user_user.pk }}">
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
Completed: {{userexam.completed}}<br/>
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
<button
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
hx-target="#user-history-{{ userexam.user_user.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</li>
<div id="user-history-{{ userexam.user_user.pk }}" class="card {% if userexam.outstanding_feedback_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">
<a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{ userexam.get_user_name }}</a>
{% if userexam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% else %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">In progress</span>
{% endif %}
</div>
<div class="small text-muted mt-1">Started: {{ userexam.start_time|default:"-" }}</div>
<div class="small text-muted">Ended: {{ userexam.end_time|default:"-" }}</div>
<div class="mt-2 d-flex flex-wrap gap-2">
{% if collection.case_query_messaging_enabled %}
{% if userexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ userexam.outstanding_feedback_count }} outstanding message{{ userexam.outstanding_feedback_count|pluralize }}</span>
{% endif %}
{% if userexam.total_review_message_count %}
<span class="badge bg-secondary">{{ userexam.total_review_message_count }} conversation message{{ userexam.total_review_message_count|pluralize }}</span>
{% else %}
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">Open Attempt</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_user_messages' collection.pk userexam.user_user.pk %}">Messages</a>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_user' collection.pk userexam.user_user.pk %}"
hx-target="#user-history-{{ userexam.user_user.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</ul>
</div>
{% endif %}
{% if cidexams %}
<h3>CID Users</h3>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h5 mb-0">CID Users</h3>
<div class="small text-muted">{{ cidexams|length }} CID attempt{{ cidexams|length|pluralize }}</div>
</div>
<ul>
<div class="d-flex flex-column gap-3">
{% for cidexam in cidexams %}
<li id="cid-history-{{ cidexam.cid_user.pk }}">
<b><a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{cidexam.cid_user}}</a><b><br/>
Completed: {{cidexam.completed}}<br/>
Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}<br/>
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this CID user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</li>
<div id="cid-history-{{ cidexam.cid_user.cid }}" class="card {% if cidexam.outstanding_feedback_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">
<a href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">{{ cidexam.cid_user }}</a>
{% if cidexam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% else %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">In progress</span>
{% endif %}
</div>
<div class="small text-muted mt-1">Started: {{ cidexam.start_time|default:"-" }}</div>
<div class="small text-muted">Ended: {{ cidexam.end_time|default:"-" }}</div>
<div class="mt-2 d-flex flex-wrap gap-2">
{% if collection.case_query_messaging_enabled %}
{% if cidexam.outstanding_feedback_count %}
<span class="badge bg-danger">{{ cidexam.outstanding_feedback_count }} outstanding message{{ cidexam.outstanding_feedback_count|pluralize }}</span>
{% endif %}
{% if cidexam.total_review_message_count %}
<span class="badge bg-secondary">{{ cidexam.total_review_message_count }} conversation message{{ cidexam.total_review_message_count|pluralize }}</span>
{% else %}
<span class="badge bg-dark-subtle text-body-secondary">No conversation yet</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary" href="{% url 'atlas:collection_history_ciduser' collection.pk cidexam.cid_user.cid %}">Open Attempt</a>
{% if collection.case_query_messaging_enabled %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_cid_messages' collection.pk cidexam.cid_user.cid %}">Messages</a>
{% endif %}
<button
hx-post="{% url 'atlas:collection_reset_answers_ciduser' collection.pk cidexam.cid_user.cid %}"
hx-target="#cid-history-{{ cidexam.cid_user.cid }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this CID user's collection history?"
class="btn btn-danger btn-sm remove-button">
Delete
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</ul>
</div>
{% endif %}
{% if not userexams and not cidexams %}
<div class="card border-secondary">
<div class="card-body text-muted small">No attempts have been recorded for this collection yet.</div>
</div>
{% endif %}
{% endblock %}
@@ -1,101 +1,165 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>Collection: {{collection.name}}</h2>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Learner: <strong>{{ user }}</strong></div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Attempt Progress</div>
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
<div class="progress progress-compact mt-2">
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
</div>
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
</div>
</div>
</div>
<h3>User: {{user}}</h3>
<ul>
{% for casedetail, user_answer in user_answers %}
<li class="case">
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
Question started: {{user_answer.started_at}} - Answer submitted: {{user_answer.submitted_at}}
{% if request.user.is_superuser and user_answer %}
&nbsp;(<a href="{% url 'admin:atlas_userreportanswer_change' user_answer.pk %}" target="_blank">Edit in admin</a>)
{% endif %}
<br/>
<br/>
{% if not user_answer %}
<span class="case-not-answered">Case not answered.</span>
{% else %}
<div class="answer-block">
User answer:
<div class="user-answer">
{% if user_answer.answer %}
{{user_answer.answer}}
{% else %}
{{user_answer.json_answer}}
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
{% if not user_answer %}
Not answered
{% else %}
<div class="{% if answer_is_correct %}
correct
{% else %}
incorrect
{% endif %}
{% if automark %}
automark
{% endif %}
">
<h5>{{value.title}}</h5>
{{value.description}}
<div
>
Answer : {{user_answer}}
{% if not answer_is_correct %}
<br/>Correct answer: {{correct_answer}}
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
<div class="list-group mb-4">
{% for row in history_rows %}
<div id="case-history-{{ row.casedetail.case.id }}" class="list-group-item {% if not row.user_answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:80 }}
{% if row.user_answer and row.user_answer.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif row.user_answer %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
{% endif %}
{% if row.self_reviews %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">{{ row.self_reviews|length }} self review{{ row.self_reviews|length|pluralize }}</span>
{% endif %}
</div>
<div class="small text-muted">
Started: {{ row.user_answer.started_at|default:'-' }}
{% if row.user_answer %}
· Submitted: {{ row.user_answer.submitted_at|default:'-' }}
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary"
href="{% url 'atlas:collection_case_view' pk=collection.id case_number=forloop.counter0 %}">
Open Case
</a>
{% if request.user.is_superuser and row.user_answer %}
{% if row.user_answer.user_id %}
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'admin:atlas_userreportanswer_change' row.user_answer.pk %}"
target="_blank">
Edit in admin
</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'admin:atlas_cidreportanswer_change' row.user_answer.pk %}"
target="_blank">
Edit in admin
</a>
{% endif %}
{% endif %}
{% if row.self_reviews %}
{% for review in row.self_reviews %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
View Self Review {{ forloop.counter }}
</a>
{% endfor %}
{% endif %}
</div>
</div>
{% if not row.user_answer %}
<div class="small text-muted">This case has not been answered yet.</div>
{% else %}
<details class="mb-3" open>
<summary class="small fw-semibold">Answer / Report</summary>
<div class="small mt-2 p-3 rounded bg-body-tertiary border">
{% if row.user_answer.answer %}
{{ row.user_answer.answer|linebreaksbr }}
{% else %}
<div class="mb-2">{{ row.user_answer.json_answer }}</div>
{% for value, single_answer, correct_answer, answer_is_correct, automark in row.user_answer.get_correct_json_answers %}
<div class="mb-2 p-2 border rounded {% if answer_is_correct %}border-success-subtle{% else %}border-danger-subtle{% endif %}">
<div class="fw-semibold">
{{ value.title }}
{% if answer_is_correct %}
<span class="text-success"></span>
{% elif automark %}
<span class="text-danger"></span>
{% else %}
<span class="text-warning"></span>
{% endif %}
</div>
<div class="small text-muted">{{ value.description }}</div>
<div class="small mt-1">Answer: {{ single_answer|default:'Not answered' }}</div>
{% if not answer_is_correct and correct_answer %}
<div class="small text-muted">Correct answer: {{ correct_answer }}</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</details>
{% endif %}
</li>
{% if row.self_reviews %}
<details class="mb-3">
<summary class="small fw-semibold">Self Reviews</summary>
<div class="d-flex flex-column gap-2 mt-2">
{% for review in row.self_reviews %}
<div class="border rounded p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-start gap-2 mb-2">
<div class="small text-muted">Updated: {{ review.review_update_date|date:"Y-m-d H:i" }}</div>
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id row.casedetail.case.id review.id %}">
View
</a>
<a class="btn btn-sm btn-outline-warning"
href="{% url 'atlas:self_review_update' cid_user_exam.id row.casedetail.case.id review.id %}">
Edit
</a>
</div>
</div>
<div class="small mb-1"><strong>Findings:</strong> {{ review.findings|default:'-' }}/5</div>
<div class="small mb-2"><strong>Interpretation:</strong> {{ review.interpretation|default:'-' }}/5</div>
<div class="small text-body-secondary">{{ review.comments|default:'No comments added.'|linebreaksbr }}</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if collection.case_query_messaging_enabled %}
<details class="mt-3" {% if row.case_review_stats.has_outstanding_any %}open{% endif %}>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case conversation</span>
{% if row.case_review_stats.has_outstanding_any %}
<span class="badge bg-danger">{{ row.case_review_stats.outstanding_total_count }} unacknowledged message{{ row.case_review_stats.outstanding_total_count|pluralize }}</span>
{% elif row.case_review_stats.has_messages %}
<span class="badge bg-secondary">{{ row.case_review_stats.message_count }} message{{ row.case_review_stats.message_count|pluralize }}</span>
{% else %}
<span class="text-muted">No messages yet</span>
{% endif %}
</summary>
<div class="mt-2"
hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Loading case conversation...</div>
</div>
</details>
{% endif %}
</div>
{% endfor %}
</ul>
</div>
{% endblock %}
{% block css %}
{{ block.super }}
<style>
.correct {
}
.correct h5::after {
content: '✓';
color: green;
}
.incorrect h5::after {
content: '✗';
color: orange;
}
.incorrect.automark h5::after {
content: '✗';
color: red;
}
.answer-block>div {
padding-bottom: 10px;
}
.case:has(.case-not-answered) {
opacity: 0.5;
}
</style>
{% endblock %}
@@ -3,6 +3,25 @@
{% block content %}
<h2>Collection: {{exam}}</h2>
{% if collection.start_screen_content %}
<div class="card mb-3">
<div class="card-body">
{{ collection.start_screen_content|safe }}
</div>
</div>
{% endif %}
{% if collection.start_screen_resources.exists %}
<div class="card mb-3">
<div class="card-body">
<h6>Resources</h6>
{% for resource in collection.start_screen_resources.all %}
<div class="small mb-1">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
{% endif %}
{% comment %} <ul>
{% for case in collection.cases.all %}
<li><a href="{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}">Case {{forloop.counter}}</a></li>
@@ -1,78 +1,195 @@
{% extends 'base.html' %}
{% block content %}
{% if cid is not None %}
CID: {{cid}}
{% endif %}
<h2>Collection: {{collection.name}}
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="stamp-white">REVIEW</span>
{% endif %}
</h2>
{% if not collection.review_only %}
{{answer_count}} out of {{collection_length}} cases answered. Click to go to case.
{% endif %}
<div class="sba-finish-list">
<ul>
{% for question, answer, self_review in question_answer_tuples %}
<li> <span class="{% if not collection.review_only and not answer %}unanswered{% endif %}">
<a href="
{% if cid is not None %}
{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}
{% else %}
{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}
{% endif %}
">
Case: {{forloop.counter}}
{% if answer.completed or cid_user_exam.completed %}
<span title="You have completed this case"><i class="bi bi-check-square-fill"></i>
</span>
{% endif %}
{% if self_review %} <span class="self-review-complete" title="You have reviewed this case."> <i class="bi-card-checklist"></i></span>{% endif %}
</a>
<br/>
{% if not collection.review_only %}
{% if not answer %}
No answer
{% else %}
<details><summary>Answered</summary>
{% if aswer.answer %}
{{answer.answer}}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">
{{ collection.name }}
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="badge bg-light text-dark ms-2">Review</span>
{% endif %}
{% if collection.case_query_messaging_enabled and total_outstanding_feedback %}
<span class="badge bg-danger ms-2">{{ total_outstanding_feedback }} outstanding feedback</span>
{% endif %}
</h2>
<div class="text-muted small">
{% if cid is not None %}
CID: <strong>{{ cid }}</strong>
{% else %}
<h5>Overview</h5>
{% endif %}
</div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Progress</div>
{% if not collection.review_only %}
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
<div class="progress progress-compact mt-2">
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
</div>
{% else %}
<div class="fw-semibold">Review-only collection</div>
{% endif %}
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
{% if cid_user_exam.completed %}
<div class="mt-1 small">End time: {{ cid_user_exam.end_time|default:"-" }}</div>
{% endif %}
{% if collection.case_query_messaging_enabled and collection.in_review_mode or collection.case_query_messaging_enabled and cid_user_exam.completed %}
<div class="mt-2">
<a class="btn btn-sm {% if total_outstanding_feedback %}btn-danger{% else %}btn-outline-secondary{% endif %}"
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}">
{% if total_outstanding_feedback %}
Review Feedback Hub
{% else %}
{{answer.json_answer}}
Open Feedback Hub
{% endif %}
</details>
{% endif %}
{% endif %}
</span>
</li>
{% endfor %}
</ul>
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="list-group mb-3">
{% for question, answer, self_review, review_stats, timing_state in question_answer_tuples %}
<div class="list-group-item {% if review_stats.has_outstanding_feedback %}border-warning border-2{% elif not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}{% if collection.show_title_post %}: {{ question.case.title|default:question.case.pk|truncatechars:70 }}{% endif %}
{% if answer and answer.completed or cid_user_exam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif answer %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
{% endif %}
{% if self_review %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
{% endif %}
{% if timing_state.effective_time_limit is not None and timing_state.case_view_locked %}
<span class="badge bg-dark ms-1">Time expired</span>
{% endif %}
{% if collection.case_query_messaging_enabled %}
{% if review_stats.has_outstanding_feedback %}
<span class="badge bg-danger ms-1">{{ review_stats.outstanding_feedback_count }} feedback item{{ review_stats.outstanding_feedback_count|pluralize }} to review</span>
{% elif review_stats.has_messages %}
<span class="badge bg-secondary ms-1">{{ review_stats.message_count }} conversation message{{ review_stats.message_count|pluralize }}</span>
{% endif %}
{% endif %}
</div>
{% if collection.show_title_post %}
<div class="small text-muted">
{{ question.case }}
</div>
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary"
href="{% if cid is not None %}{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}{% endif %}">
Open Case
</a>
{% if collection.in_review_mode or cid_user_exam.completed %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:add_self_review' cid_user_exam.id question.case.id %}">
{% if self_review %}Add New Self Review{% else %}Add Self Review{% endif %}
</a>
{% if collection.case_query_messaging_enabled and review_stats.has_messages %}
<a class="btn btn-sm {% if review_stats.has_outstanding_feedback %}btn-danger{% else %}btn-outline-dark{% endif %}"
href="{% if cid is not None %}{% url 'atlas:collection_feedback_overview' pk=collection.id cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_feedback_overview_user' pk=collection.id %}{% endif %}#case-feedback-{{ question.case.id }}">
View Conversation
</a>
{% endif %}
{% endif %}
</div>
</div>
Start time: {{cid_user_exam.start_time}}
{% if self_review %}
<div class="mt-2 pt-2 border-top">
<div class="small fw-semibold mb-1">Your self reviews</div>
<div class="d-flex flex-wrap gap-2">
{% for review in self_review %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id question.case.id review.id %}">
View review {{ forloop.counter }}
</a>
<a class="btn btn-sm btn-outline-warning"
href="{% url 'atlas:self_review_update' cid_user_exam.id question.case.id review.id %}">
Edit review {{ forloop.counter }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if not collection.review_only %}
{% if answer %}
<details class="mt-2">
<summary class="small text-muted">Preview saved answer/report</summary>
<div class="small mt-2 p-2 bg-body-tertiary rounded">
{% if answer.answer %}
{{ answer.answer|linebreaksbr }}
{% else %}
{{ answer.json_answer }}
{% endif %}
</div>
</details>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% if not collection.review_only and not cid_user_exam.completed %}
<p>Completed: <span id="completed-state">{{cid_user_exam.completed}}</span></p>
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
>
<button type="submit" name="finish" value="finish">Finish</button>
</form>
<div class="card border-warning-subtle">
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div class="fw-semibold">Ready to submit this session?</div>
<div class="small text-muted">You can continue to review and edit your answers until you finish the session.</div>
<div id="completed-state" class="mt-2 small text-danger"></div>
</div>
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
class="m-0">
<button type="submit" name="finish" value="finish" class="btn btn-warning">Finish Session</button>
</form>
</div>
</div>
{% endif %}
{% if collection.end_overview_content %}
<div class="card mt-3">
<div class="card-body">
{{ collection.end_overview_content|safe }}
</div>
</div>
{% endif %}
{% if end_overview_resources %}
<div class="card mt-3">
<div class="card-body">
<h6 class="mb-2">Further Resources</h6>
<div class="d-grid gap-2">
{% for resource in end_overview_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Sharing panel only for registered-user attempts (not CID candidates) #}
{% if collection.show_results_sharing_information and cid_user_exam.user_user_id %}
<div hx-get="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-muted small mt-3">Loading sharing settings…</div>
</div>
{% endif %}
+163 -44
View File
@@ -1,69 +1,188 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Start: {{collection.name}}{% if cid_exam.completed %} <span class="stamp-white">REVIEW</span>{% endif %}</h2>
<style>
.take-start-hero {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, rgba(13, 110, 253, 0.22), rgba(25, 135, 84, 0.2));
border-radius: .75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.take-start-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border-radius: .75rem;
padding: 1rem;
}
.take-start-meta {
display: grid;
grid-template-columns: 9rem 1fr;
gap: .35rem .75rem;
}
.take-start-meta dt {
font-weight: 600;
color: var(--bs-secondary-color);
margin: 0;
}
.take-start-meta dd {
margin: 0;
}
</style>
<section class="take-start-hero">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h2 class="mb-0">Start: {{ collection.name }}</h2>
{% if cid_exam.completed %}
<span class="badge bg-light text-dark">Review Mode</span>
{% endif %}
</div>
<p class="mb-0 mt-2 text-body-secondary">Open the collection and begin or resume your session.</p>
</section>
{% if collection.start_screen_content %}
<section class="take-start-card mb-3">
{{ collection.start_screen_content|safe }}
</section>
{% endif %}
{% if start_screen_resources %}
<section class="take-start-card mb-3">
<h6 class="mb-2">Resources</h6>
<div class="d-grid gap-2">
{% for resource in start_screen_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if request.user.is_authenticated and valid_user %}
User: {{request.user}}<br/>
<section class="take-start-card">
<dl class="take-start-meta mb-3">
<dt>User</dt>
<dd>{{ request.user }}</dd>
{% if cid_exam %}
<dt>Started</dt>
<dd>{{ cid_exam.start_time }}</dd>
{% if cid_exam %}
Started: {{cid_exam.start_time}} <br/>
{% if cid_exam.completed %}
<dt>Ended</dt>
<dd>{{ cid_exam.end_time }}</dd>
{% endif %}
{% endif %}
</dl>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary"
href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}Review{% else %}Start{% endif %}
</a>
{% if cid_exam.completed %}
Ended: {{cid_exam.end_time}} <br/>
{% endif %}
{% if cid_exam.completed %}
<a class="btn btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}">Overview</a>
{% endif %}
</div>
</section>
{# ---- Who can see your results ---- #}
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
— you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they don't have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
<a href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}
<button>Review</button>
{% else %}
<button>Start</button>
{% endif %}
</a>
{% if cid_exam.completed %}
<a href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}"><button>Overview</button></a>
{% endif %}
{% else %}
Enter your CID and passcode in the below boxes. (Registered user should login <a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">here</a>)<br />
<p><input id="cid-box" type="text" value="Candidate ID"></p>
<p><input id="passcode-box" type="text" value="Passcode"></p>
<section class="take-start-card">
<p class="mb-3">
Enter your CID and passcode below.
Registered users can
<a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">log in here</a>.
</p>
<button>Start</button>
<div class="row g-2 align-items-end">
<div class="col-12 col-md-5">
<label class="form-label" for="cid-box">Candidate ID</label>
<input id="cid-box" class="form-control" type="text" value="Candidate ID">
</div>
<div class="col-12 col-md-5">
<label class="form-label" for="passcode-box">Passcode</label>
<input id="passcode-box" class="form-control" type="text" value="Passcode">
</div>
<div class="col-12 col-md-2 d-grid">
<button id="start-candidate-btn" class="btn btn-primary" type="button">Start</button>
</div>
</div>
</section>
<script type="text/javascript">
$(document).ready(function () {
window.location.search.substr(1).split("&").forEach((item) =>
{
s = item.split("=");
if (s[0] == "cid") {
$("#cid-box").val(s[1]);
}
if (s[0] == "passcode") {
$("#passcode-box").val(s[1]);
}
});
$("#cid-box, #passcode-box").keypress(function(e) {
// Enter pressed?
console.log(e)
if(e.which == 10 || e.which == 13) {
$("button").click();
window.location.search.substr(1).split("&").forEach((item) => {
const s = item.split("=");
if (s[0] === "cid") {
$("#cid-box").val(s[1]);
}
if (s[0] === "passcode") {
$("#passcode-box").val(s[1]);
}
});
$("button").click(() => {
let cid = $("#cid-box").val();
let passcode = $("#passcode-box").val();
$("#cid-box, #passcode-box").keypress(function (e) {
if (e.which === 10 || e.which === 13) {
$("#start-candidate-btn").click();
}
});
$("#start-candidate-btn").click(() => {
const cid = $("#cid-box").val();
const passcode = $("#passcode-box").val();
if (Number.isInteger(parseInt(cid))) {
window.location.replace("{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=0 cid='0000000' passcode='ZZZZZZ' %}".replace("0000000", cid).replace("ZZZZZZ", passcode));
} else {
alert("Please enter a valid Candidate ID (CID).")
alert("Please enter a valid Candidate ID (CID).");
}
});
});
@@ -0,0 +1,121 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">{{ collection.name }}</h2>
<div class="text-muted small">Message hub for <strong>{{ target_label }}</strong></div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Outstanding messages</div>
<div class="fw-semibold {% if total_outstanding_messages %}text-danger{% endif %}">
{{ total_outstanding_messages }} awaiting acknowledgement
</div>
<div class="mt-2 d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_history' collection.pk %}">Back to Collection History</a>
<a class="btn btn-sm btn-outline-secondary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.pk target_user.pk %}{% else %}{% url 'atlas:collection_history_ciduser' collection.pk target_cid %}{% endif %}">
Open Full Attempt Page
</a>
</div>
<div class="mt-2">
{% if show_all %}
<a class="btn btn-sm btn-outline-primary" href="?">Show outstanding only</a>
{% else %}
<a class="btn btn-sm btn-primary" href="?show=all">Load all conversation history</a>
{% endif %}
</div>
</div>
</div>
</div>
{% if outstanding_rows %}
<div class="mb-4">
<h3 class="h5 mb-3">Outstanding Messages</h3>
<div class="d-flex flex-column gap-3">
{% for row in outstanding_rows %}
<section id="case-feedback-{{ row.casedetail.case.id }}" class="card border-danger-subtle">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<strong>Case {{ row.casedetail.sort_order|add:1 }}</strong>
{% if collection.show_title_post %}
<span class="ms-1">{{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:90 }}</span>
{% endif %}
</div>
<span class="badge bg-danger">{{ row.stats.outstanding_total_count }} outstanding</span>
</div>
<div class="card-body pt-0">
<div class="d-flex flex-wrap gap-2 mt-2 mb-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.id %}">
Collection
</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_case_view' pk=collection.id case_number=row.casedetail.sort_order %}">
Case In Collection
</a>
<a class="btn btn-sm btn-outline-primary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.id target_user.id %}{% else %}{% url 'atlas:collection_history_ciduser' collection.id target_cid %}{% endif %}#case-history-{{ row.casedetail.case.id }}">
User Answer In Context
</a>
</div>
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Loading conversation...</div>
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% else %}
<div class="alert alert-success">No outstanding messages for this user.</div>
{% endif %}
{% if show_all %}
{% if history_rows %}
<div>
<h3 class="h5 mb-3">Conversation History</h3>
<div class="accordion" id="userMessageHistoryAccordion">
{% for row in history_rows %}
<div class="accordion-item" id="history-case-{{ row.casedetail.case.id }}">
<h2 class="accordion-header" id="history-heading-{{ row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#history-collapse-{{ row.casedetail.case.id }}" aria-expanded="false" aria-controls="history-collapse-{{ row.casedetail.case.id }}">
Case {{ row.casedetail.sort_order|add:1 }}
{% if collection.show_title_post %}
: {{ row.casedetail.case.title|default:row.casedetail.case.pk|truncatechars:90 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ row.stats.message_count }} messages</span>
</button>
</h2>
<div id="history-collapse-{{ row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="history-heading-{{ row.casedetail.case.id }}" data-bs-parent="#userMessageHistoryAccordion">
<div class="accordion-body pt-0">
<div class="d-flex flex-wrap gap-2 mt-2 mb-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_detail' collection.id %}">
Collection
</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:collection_case_view' pk=collection.id case_number=row.casedetail.sort_order %}">
Case In Collection
</a>
<a class="btn btn-sm btn-outline-primary" href="{% if target_user %}{% url 'atlas:collection_history_user' collection.id target_user.id %}{% else %}{% url 'atlas:collection_history_ciduser' collection.id target_cid %}{% endif %}#case-history-{{ row.casedetail.case.id }}">
User Answer In Context
</a>
</div>
<div hx-get="{{ row.thread_url }}{% if row.cid %}?cid={{ row.cid }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted p-3">Open section to load history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% elif not outstanding_rows %}
<div class="card border-secondary">
<div class="card-body text-muted small">No conversation history for this user yet.</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
+113 -32
View File
@@ -1,38 +1,119 @@
<!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html -->
{% extends "base.html" %}
{% extends "atlas/base.html" %}
{% block content %}
<h1>Linked Cases Overview</h1>
<h2>Current Case: {{ current_case.title }}</h2>
<div class="container py-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h1 class="h2 mb-1">Linked Cases Overview</h1>
<p class="text-muted mb-0">Review the full case chain around this study, with quick access to related cases and their linked series.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-outline-secondary" href="{{ current_case.get_absolute_url }}">Open current case</a>
{% if direct_previous %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_previous.pk %}">Previous in chain</a>
{% endif %}
{% if direct_next %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_next.pk %}">Next in chain</a>
{% endif %}
</div>
</div>
<h3>Previous Cases</h3>
{% if previous_cases %}
<ul>
{% for case in previous_cases %}
<li>
<a href="{{ prev_case.get_absolute_url }}">{{ case.title }}</a>
<div class="row g-3 mb-4">
<div class="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<span class="badge bg-primary">Current case</span>
<span class="badge bg-dark-subtle text-dark-emphasis">{{ current_index }} of {{ chain_total }}</span>
{% if previous_cases %}
<span class="badge text-bg-light border">{{ previous_cases|length }} previous</span>
{% endif %}
{% if next_cases %}
<span class="badge text-bg-light border">{{ next_cases|length }} follow-up</span>
{% endif %}
</div>
<h2 class="h4 mb-1">{{ current_case.title|default:"Untitled case" }}</h2>
<div class="small text-muted mb-3">Case {{ current_case.pk }}</div>
{% if current_case.description %}
<p class="mb-3">{{ current_case.description|striptags|truncatechars:280 }}</p>
{% elif current_case.history %}
<p class="mb-3">{{ current_case.history|striptags|truncatechars:280 }}</p>
{% endif %}
</li>
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
<p>No previous cases.</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 small">
{% if current_case.open_access %}
<span class="badge bg-success">Open access</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
{% if current_case.verified %}
<span class="badge bg-info text-dark">Verified</span>
{% endif %}
{% if current_case.archive %}
<span class="badge bg-dark">Archived</span>
{% endif %}
{% if current_case.diagnostic_certainty %}
<span class="badge text-bg-light border">{{ current_case.get_diagnostic_certainty_display }}</span>
{% endif %}
<span class="badge text-bg-light border">{{ current_case.get_ordered_series|length }} series</span>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-secondary-subtle shadow-sm h-100">
<div class="card-body">
<h2 class="h5 mb-3">Chain Summary</h2>
<div class="list-group list-group-flush small">
{% for linked_case in chain_cases %}
<a href="{% url 'atlas:linked_cases_overview' linked_case.pk %}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center {% if linked_case.pk == current_case.pk %}active{% endif %}">
<span class="text-truncate pe-3">{{ forloop.counter }}. {{ linked_case.title|default:"Untitled case" }}</span>
<span class="badge {% if linked_case.pk == current_case.pk %}text-bg-light{% else %}text-bg-secondary{% endif %}">{{ linked_case.pk }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<h3>Next Cases</h3>
{% if next_cases %}
<ul>
{% for case in next_cases %}
<li>
<a href="{{ next_case.get_absolute_url }}">{{ case.title }}</a>
</li>
{% include "atlas/case_display_block.html#case-series" %}
{% endfor %}
</ul>
{% else %}
<p>No next cases.</p>
{% endif %}
<div class="row g-4">
<div class="col-xl-6">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Previous Cases</h2>
<span class="badge text-bg-light border">{{ previous_cases|length }}</span>
</div>
{% if previous_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in previous_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Earlier in the linked chain" is_current=False %}
{% endfor %}
</div>
{% else %}
<div class="card border-secondary-subtle shadow-sm">
<div class="card-body text-muted">No previous linked cases.</div>
</div>
{% endif %}
</div>
<div class="col-xl-6">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h4 mb-0">Next Cases</h2>
<span class="badge text-bg-light border">{{ next_cases|length }}</span>
</div>
{% if next_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in next_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Later in the linked chain" is_current=False %}
{% endfor %}
</div>
{% else %}
<div class="card border-secondary-subtle shadow-sm">
<div class="card-body text-muted">No follow-up linked cases.</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
{# Partial: Case Differentials #}
<div>
<details open>
<summary><b>Differentials</b></summary>
<ul>
{% for diff in case.differentialcase.all %}
<li>{{diff.condition.get_link}} - {{diff.text}}</li>
{% empty %}
<span class="text-muted">No differentials added for this case.</span>
{% endfor %}
</ul>
</details>
</div>
@@ -0,0 +1,271 @@
{# Partial: Case Display Sets (displaysets) #}
{# Buttons adapt at runtime: if an in-page viewer is present (importAnnotations_main_viewer), #}
{# "View" loads the display set into it; otherwise falls back to the modal viewer. #}
{# An Edit button is shown separately when can_edit is truthy. #}
<div>
<h5 class="mt-3">Case Display Sets</h5>
<ul class="list-group">
{% for ds in case.display_sets.all %}
<li class="list-group-item" id="displayset-row-{{ ds.pk }}">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<b>{{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted ms-1">({{ ds.description }})</span>
{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0">
{% if ds.viewerstate or ds.annotations %}
<button type="button"
class="btn btn-primary btn-sm ds-view-btn"
data-ds-id="{{ ds.pk }}"
data-modal-url="{% url 'atlas:case_displaysets_modal' ds.pk %}"
data-annotations='{{ ds.annotations|safe }}'
data-viewerstate='{{ ds.viewerstate|safe }}'>
<i class="bi bi-display"></i> View Display Set
</button>
{% endif %}
<button type="button"
class="btn btn-secondary btn-sm view-displayset-modal"
data-ds-id="{{ ds.pk }}"
data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">
<i class="bi bi-eye"></i> View in Modal
</button>
{% if can_edit %}
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}?edit=1"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Edit
</a>
{% endif %}
</div>
</div>
<div class="mt-2">
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul class="mb-1">
{% for finding in ds.findings.all %}
<li>{{ finding.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul class="mb-1">
{% for structure in ds.structures.all %}
<li>{{ structure.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul class="mb-1">
{% for condition in ds.conditions.all %}
<li>{{ condition.get_link }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item text-muted">
No display sets for this case.
{% if can_edit %}
<a href="{% url 'atlas:case_displaysets' case.pk %}">Add one now</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% include 'atlas/partials/_displayset_modals.html' %}
<script>
(function () {
if (!window.__displaysetPartialInitDone) {
window.__displaysetPartialInitDone = true;
if (typeof window.mountDisplaysetViewersSafely !== 'function') {
window.mountDisplaysetViewersSafely = async function () {
try {
await new Promise(function (resolve) {
requestAnimationFrame(function () {
requestAnimationFrame(resolve);
});
});
if (typeof window.mountDicomViewersSafely === 'function') {
await window.mountDicomViewersSafely();
} else if (typeof window.mountDicomViewers === 'function') {
window.mountDicomViewers();
}
requestAnimationFrame(function () {
try { window.dispatchEvent(new Event('resize')); } catch (e) {}
if (typeof window.resizeDicomViewers === 'function') {
try { window.resizeDicomViewers(); } catch (e) {}
}
});
} catch (e) {
console.warn('displayset viewer mount failed', e);
}
};
}
if (typeof window.loadDisplaysetModal !== 'function') {
window.loadDisplaysetModal = async function (url, dsId) {
if (!url && dsId) url = '/atlas/' + dsId + '/display_sets/modal';
if (!url) return;
var modalEl = document.getElementById('displaysetModal');
var modalBody = document.getElementById('displaysetModalBody');
if (!modalEl || !modalBody) return;
modalBody.innerHTML = '<p>Loading...</p>';
try {
var resp = await fetch(url, { credentials: 'same-origin' });
var html = await resp.text();
modalBody.innerHTML = html;
var fsBtn = modalEl.querySelector('.displayset-fullscreen-btn');
if (fsBtn) {
fsBtn.dataset.url = url;
if (dsId) fsBtn.dataset.dsId = dsId;
}
bootstrap.Modal.getOrCreateInstance(modalEl).show();
await window.mountDisplaysetViewersSafely();
} catch (error) {
console.error('Error loading display set details:', error);
modalBody.innerHTML = '<p>Failed to load display set.</p>';
}
};
}
if (typeof window.loadDisplaysetFullscreen !== 'function') {
window.loadDisplaysetFullscreen = async function (url, dsId) {
if (!url && dsId) url = '/atlas/' + dsId + '/display_sets/modal';
if (!url) return;
var leftEl = document.getElementById('displaysetFullscreenLeft');
var rightEl = document.getElementById('displaysetFullscreenRight');
var fullModalEl = document.getElementById('displaysetFullscreenModal');
if (!leftEl || !rightEl || !fullModalEl) return;
leftEl.innerHTML = '<p style="color:#fff; padding:1rem;">Loading viewer...</p>';
rightEl.innerHTML = '<p>Loading details...</p>';
try {
var resp = await fetch(url, { credentials: 'same-origin' });
var html = await resp.text();
var tmp = document.createElement('div');
tmp.innerHTML = html;
var viewer = tmp.querySelector('.dicom-viewer-root');
if (viewer) {
leftEl.innerHTML = '';
leftEl.appendChild(viewer);
try {
viewer.style.height = '100%';
viewer.style.minHeight = '100%';
viewer.style.width = '100%';
viewer.style.display = 'block';
viewer.style.flex = '1 1 auto';
} catch (e) {}
} else {
leftEl.innerHTML = '<p style="color:#fff; padding:1rem;">No viewer available</p>';
}
var viewerInTmp = tmp.querySelector('.dicom-viewer-root');
if (viewerInTmp) viewerInTmp.remove();
rightEl.innerHTML = tmp.innerHTML;
bootstrap.Modal.getOrCreateInstance(fullModalEl).show();
await window.mountDisplaysetViewersSafely();
} catch (error) {
console.error('Error loading fullscreen display set:', error);
leftEl.innerHTML = '<p style="color:#fff; padding:1rem;">Failed to load viewer</p>';
rightEl.innerHTML = '<p>Failed to load details.</p>';
}
};
}
document.addEventListener('click', function (e) {
var modalBtn = e.target.closest('.view-displayset-modal');
if (modalBtn) {
e.preventDefault();
var modalUrl = modalBtn.getAttribute('data-url');
var modalDsId = modalBtn.getAttribute('data-ds-id');
window.loadDisplaysetModal(modalUrl, modalDsId);
return;
}
var fsBtn = e.target.closest('.displayset-fullscreen-btn');
if (fsBtn) {
e.preventDefault();
var fsUrl = fsBtn.dataset.url;
var fsDsId = fsBtn.dataset.dsId || null;
if (typeof window.loadDisplaysetFullscreen === 'function') {
window.loadDisplaysetFullscreen(fsUrl, fsDsId);
}
}
});
}
/**
* For each "View Display Set" button:
* - If the page has an in-page viewer (importAnnotations_main_viewer available),
* load the display set's state into it and scroll the viewer into view.
* - Otherwise, fall back to the existing modal loader (loadDisplaysetModal).
*/
document.querySelectorAll('.ds-view-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var dsId = btn.dataset.dsId;
var modalUrl = btn.dataset.modalUrl;
var annotations = btn.dataset.annotations;
var viewerstate = btn.dataset.viewerstate;
var hasInPageViewer = (
typeof window.importAnnotations_main_viewer === 'function' &&
typeof window.importViewerState_main_viewer === 'function'
);
if (hasInPageViewer) {
// Ensure viewer UI is visible before applying display set state.
var viewerDetails = document.getElementById('dicom-viewer-details');
if (viewerDetails && viewerDetails.tagName === 'DETAILS') {
viewerDetails.open = true;
}
try { window.importAnnotations_main_viewer(JSON.parse(annotations || '{}')); } catch (e) { console.warn('displayset annotations import error', e); }
try { window.importViewerState_main_viewer(JSON.parse(viewerstate || '{}')); } catch (e) { console.warn('displayset viewerstate import error', e); }
try { window.dispatchEvent(new Event('resize')); } catch (e) {}
if (typeof window.resizeDicomViewers === 'function') {
try { window.resizeDicomViewers(); } catch (e) { console.warn('displayset viewer resize error', e); }
}
// Scroll the viewer into view
var viewer = document.getElementById('main_viewer') || document.querySelector('.dicom-viewer-root');
if (viewer) { viewer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
} else if (typeof window.loadDisplaysetModal === 'function') {
window.loadDisplaysetModal(modalUrl, dsId);
} else {
// Ultimate fallback: navigate to the detail page
window.location.href = modalUrl;
}
});
});
}());
</script>
@@ -0,0 +1,114 @@
<div id="{{ thread_dom_id }}" class="case-review-thread-root card border-secondary-subtle mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Case Conversation</strong>
{% if unacknowledged_count %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">{{ unacknowledged_count }} awaiting acknowledgement</span>
{% endif %}
</div>
<span class="small text-muted">Case {{ case.pk }}</span>
</div>
<div class="card-body">
{% if messages %}
<div class="d-flex flex-column gap-2 mb-3">
{% for message in messages %}
<div class="border rounded p-2 {% if message.message_type == 'feedback' %}border-warning-subtle bg-warning-subtle{% elif message.message_type == 'query' %}border-info-subtle bg-info-subtle{% else %}border-secondary-subtle bg-body-tertiary{% endif %}">
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
<div>
<span class="fw-semibold">{{ message.get_author_display }}</span>
<span class="badge text-bg-dark ms-1">{{ message.get_message_type_display }}</span>
{% if message.requires_acknowledgement %}
{% if message.is_acknowledged %}
<span class="badge bg-success-subtle text-success-emphasis ms-1">Acknowledged</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-1">Awaiting acknowledgement</span>
{% endif %}
{% endif %}
</div>
<div class="small text-muted">{{ message.created_at|date:"Y-m-d H:i" }}</div>
</div>
<div class="small">{{ message.message|linebreaksbr }}</div>
{% if message.requires_acknowledgement and not message.is_acknowledged %}
{% if can_acknowledge_query and message.message_type == 'query' %}
<form class="mt-2"
hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="action" value="acknowledge">
<input type="hidden" name="message_id" value="{{ message.id }}">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<button type="submit" class="btn btn-sm btn-outline-success">Acknowledge learner query</button>
</form>
{% elif can_acknowledge_feedback and message.message_type != 'query' %}
<form class="mt-2"
hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="action" value="acknowledge">
<input type="hidden" name="message_id" value="{{ message.id }}">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<button type="submit" class="btn btn-sm btn-outline-success">Acknowledge feedback</button>
</form>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small mb-3">No messages yet for this case.</div>
{% endif %}
{% if can_feedback %}
<form hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML"
class="border rounded p-3 mb-2 bg-body-tertiary">
{% csrf_token %}
<input type="hidden" name="action" value="add_message">
<div class="row g-2 align-items-end">
<div class="col-md-8">
<label class="form-label small fw-semibold">Supervisor response</label>
{{ supervisor_form.message }}
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold">Type</label>
<select name="message_type" class="form-select form-select-sm">
<option value="feedback">Feedback</option>
<option value="reply">Reply</option>
</select>
</div>
<div class="col-md-2">
<div class="form-check mt-4 pt-2">
{{ supervisor_form.requires_acknowledgement }}
<label for="{{ supervisor_form.requires_acknowledgement.id_for_label }}" class="form-check-label small">Needs acknowledgement</label>
</div>
</div>
</div>
<div class="mt-2">
<button type="submit" class="btn btn-sm btn-primary">Send feedback</button>
</div>
</form>
{% endif %}
{% if can_query %}
<form hx-post="{{ thread_url }}"
hx-target="#{{ thread_dom_id }}"
hx-swap="outerHTML"
class="border rounded p-3 bg-body-tertiary">
{% csrf_token %}
<input type="hidden" name="action" value="add_message">
{% if cid %}<input type="hidden" name="cid" value="{{ cid }}">{% endif %}
{% if passcode %}<input type="hidden" name="passcode" value="{{ passcode }}">{% endif %}
<label class="form-label small fw-semibold">Ask a question / raise a query</label>
{{ query_form.message }}
<div class="mt-2">
<button type="submit" class="btn btn-sm btn-outline-info">Send query</button>
</div>
</form>
{% endif %}
</div>
</div>
@@ -28,7 +28,12 @@
</form>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'atlas:case_detail' case.pk %}" target="_blank" rel="noopener">View</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_detail' case.pk %}">View</a>
{% if show_select_button %}
<button type="button" class="btn btn-sm btn-outline-primary case-select-btn" data-case-select="{{ case.pk }}">Select</button>
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'atlas:case_detail' case.pk %}">View</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_detail' case.pk %}">View</a>
{% endif %}
{% endif %}
</div>
</div>
@@ -1,11 +1,20 @@
{% for cd in casedetails %}
{% with idx=forloop.counter0 %}
{% if cid %}
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=idx cid=cid passcode=passcode %}">
{% else %}
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=idx %}">
{% endif %}
Case {{ forloop.counter }}: {{ cd.case.title|default:cd.case.pk|truncatechars:60 }}
</a>
{% endwith %}
{% for item in jump_items %}
<div class="dropdown-item-text {% if not item.has_answer %}bg-danger-subtle border border-danger-subtle rounded{% elif item.idx == current %}bg-primary-subtle rounded{% endif %} py-2 px-2 mb-1">
<div class="d-flex justify-content-between align-items-start gap-2">
<a class="text-decoration-none small fw-semibold"
href="{{ item.case_url }}">
Case {{ item.idx|add:1 }}
</a>
<div class="d-flex gap-1">
{% if item.is_time_expired %}
<span class="badge bg-dark">Time expired</span>
{% endif %}
{% if item.has_self_review %}
<span class="badge bg-info-subtle text-info-emphasis">Self review</span>
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="dropdown-item-text text-muted small px-2 py-2">No cases available.</div>
{% endfor %}
@@ -0,0 +1,116 @@
<article class="card h-100 border-secondary-subtle shadow-sm">
<div class="card-body d-flex flex-column gap-3">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="badge bg-secondary">Case {{ linked_case.pk }}</span>
{% if is_current %}
<span class="badge bg-primary">Current</span>
{% endif %}
{% if linked_case.open_access %}
<span class="badge bg-success">Open access</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
{% if linked_case.verified %}
<span class="badge bg-info text-dark">Verified</span>
{% endif %}
{% if linked_case.archive %}
<span class="badge bg-dark">Archived</span>
{% endif %}
</div>
<h3 class="h5 mb-1">
<a class="link-body-emphasis text-decoration-none" href="{{ linked_case.get_absolute_url }}">
{{ linked_case.title|default:"Untitled case" }}
</a>
</h3>
{% if relation_label %}
<div class="small text-muted">{{ relation_label }}</div>
{% endif %}
</div>
<div class="text-sm-end small text-muted">
{% if linked_case.published_date %}
<div>Published {{ linked_case.published_date|date:"Y-m-d" }}</div>
{% else %}
<div>Created {{ linked_case.created_date|date:"Y-m-d" }}</div>
{% endif %}
{% if linked_case.diagnostic_certainty %}
<div>Certainty: {{ linked_case.get_diagnostic_certainty_display }}</div>
{% endif %}
</div>
</div>
{% if linked_case.description %}
<p class="mb-0 text-body-secondary">{{ linked_case.description|striptags|truncatechars:220 }}</p>
{% elif linked_case.history %}
<p class="mb-0 text-body-secondary">{{ linked_case.history|striptags|truncatechars:220 }}</p>
{% endif %}
<div class="d-flex flex-column gap-2 small">
{% if linked_case.subspecialty.all %}
<div>
<span class="text-muted me-2">Subspecialty</span>
{% for item in linked_case.subspecialty.all %}
<span class="badge text-bg-light border">{{ item }}</span>
{% empty %}{% endfor %}
</div>
{% endif %}
{% if linked_case.condition.all %}
<div>
<span class="text-muted me-2">Condition</span>
{% for item in linked_case.condition.all|slice:":4" %}
<span class="badge text-bg-light border">{{ item }}</span>
{% endfor %}
{% if linked_case.condition.count > 4 %}
<span class="text-muted">+{{ linked_case.condition.count|add:-4 }} more</span>
{% endif %}
</div>
{% endif %}
{% if linked_case.presentation.all %}
<div>
<span class="text-muted me-2">Presentation</span>
{% for item in linked_case.presentation.all|slice:":4" %}
<span class="badge text-bg-light border">{{ item }}</span>
{% endfor %}
{% if linked_case.presentation.count > 4 %}
<span class="text-muted">+{{ linked_case.presentation.count|add:-4 }} more</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="border rounded p-3 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<strong class="small">Series</strong>
<span class="badge bg-dark-subtle text-dark-emphasis">{{ linked_case.get_ordered_series|length }} linked</span>
</div>
{% with ordered_series=linked_case.get_ordered_series %}
{% if ordered_series %}
<div class="d-flex flex-column gap-2 small">
{% for series in ordered_series|slice:":4" %}
<div class="d-flex justify-content-between align-items-start gap-2 border rounded px-2 py-1 bg-body">
<div>
<a class="text-decoration-none" href="{{ series.get_absolute_url }}">
{{ series.description|default:series.examination }}
</a>
<div class="text-muted">{{ series.modality }}{% if series.examination %} · {{ series.examination }}{% endif %}{% if series.plane %} · {{ series.plane }}{% endif %}</div>
</div>
<span class="badge text-bg-light border">#{{ forloop.counter }}</span>
</div>
{% endfor %}
</div>
{% if ordered_series|length > 4 %}
<div class="small text-muted mt-2">Showing 4 of {{ ordered_series|length }} linked series.</div>
{% endif %}
{% else %}
<div class="small text-muted">No series linked to this case.</div>
{% endif %}
{% endwith %}
</div>
<div class="d-flex flex-wrap gap-2 mt-auto">
<a class="btn btn-sm btn-primary" href="{{ linked_case.get_absolute_url }}">Open case</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' linked_case.pk %}">Re-centre overview</a>
</div>
</div>
</article>
@@ -4,8 +4,8 @@
{% if case.normal_case %}
<span class="badge bg-success">Normal</span>
<small class="text-muted">{{ case.normal_case.display_age }}</small>
<span>
{% if can_edit %}
{% if can_edit %}
<span>
<button class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
hx-target="#normal-toggle-block"
@@ -13,17 +13,15 @@
hx-confirm="Unmark this case as normal?">
Unmark normal
</button>
{% endif %}
{% if request.user.is_superuser or can_edit or request.user in case.normal_case.author.all %}
<button class="btn btn-sm btn-outline-secondary ms-2"
hx-get="{% url 'atlas:case_normal_edit' case.normal_case.pk %}"
hx-target="body"
hx-swap="beforeend">
Edit Normal
</button>
{% endif %}
</span>
</span>
{% endif %}
{% else %}
{% if can_edit %}
<button class="btn btn-sm btn-outline-primary"
@@ -33,7 +31,7 @@
Mark as normal
</button>
{% else %}
<span class="text-muted">Normal status only editable by atlas editors or case authors</span>
<span class="badge bg-secondary">Not normal</span>
{% endif %}
{% endif %}
</div>
@@ -0,0 +1,72 @@
<div class="self-review-actions mb-3 js-self-review-panel" data-refresh-key="{{ panel_key|default:'default' }}">
<div class="btn-group btn-group-sm mb-2" role="group" aria-label="Self review actions">
<a class="btn btn-primary" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}">
<i class="bi bi-card-checklist"></i> Add Self Review
</a>
<button type="button"
class="btn btn-outline-info"
onclick="openSelfReviewPopup('{% url 'atlas:add_self_review' cid_user_exam.id case.id %}')">
Popup
</button>
<button type="button"
class="btn btn-outline-secondary"
onclick="openSelfReviewSidebar('{% url 'atlas:add_self_review' cid_user_exam.id case.id %}')">
Sidebar
</button>
</div>
{% if self_review %}
<h4>Self Feedback</h4>
<div class="list-group">
{% for review in self_review %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="small text-muted">
Updated: {{ review.review_update_date|date:"Y-m-d H:i" }}
</div>
<div class="d-flex flex-wrap gap-1" role="group" aria-label="Self review actions">
<a class="btn btn-outline-primary btn-sm"
href="{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}">
View
</a>
<button type="button" class="btn btn-outline-info btn-sm"
onclick="openSelfReviewPopup('{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}')">
View popup
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="openSelfReviewSidebar('{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}')">
View sidebar
</button>
<a class="btn btn-outline-warning btn-sm"
href="{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}">
Edit
</a>
<button type="button" class="btn btn-outline-warning btn-sm"
onclick="openSelfReviewPopup('{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}')">
Edit popup
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
onclick="openSelfReviewSidebar('{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}')">
Edit sidebar
</button>
</div>
</div>
<div class="small mb-1">
<strong>Findings:</strong>
<span class="badge bg-info-subtle text-info-emphasis">{{ review.findings|default:"-" }}/5</span>
</div>
<div class="small mb-2">
<strong>Interpretation:</strong>
<span class="badge bg-warning-subtle text-warning-emphasis">{{ review.interpretation|default:"-" }}/5</span>
</div>
{% if review.comments %}
<div class="small text-body-secondary">{{ review.comments|linebreaksbr }}</div>
{% else %}
<div class="small text-muted">No comments added.</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
@@ -0,0 +1,52 @@
<div class="p-3">
<div class="card border-secondary shadow-sm mb-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h6 mb-0">Self Review</h2>
<span class="badge bg-primary-subtle text-primary-emphasis">{{ user_exam.exam }}</span>
</div>
<div class="card-body">
<div class="small text-muted mb-2">Case: <strong>{{ case }}</strong></div>
<div class="row g-2 mb-3">
<div class="col-6">
<div class="p-2 border rounded">
<div class="small text-muted">Findings</div>
<div class="fw-semibold">{{ review.findings|default:"-" }}/5</div>
</div>
</div>
<div class="col-6">
<div class="p-2 border rounded">
<div class="small text-muted">Interpretation</div>
<div class="fw-semibold">{{ review.interpretation|default:"-" }}/5</div>
</div>
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Comments</div>
<div class="p-2 border rounded bg-body-tertiary">
{% if review.comments %}
{{ review.comments|linebreaksbr }}
{% else %}
<span class="text-muted">No comments added.</span>
{% endif %}
</div>
</div>
<div class="small text-muted mb-3">
Updated: {{ review.review_update_date }}
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-warning btn-sm"
href="{% url 'atlas:self_review_update' user_exam.id case.id review.id %}?embed=1">
Edit in sidebar
</a>
<a class="btn btn-outline-secondary btn-sm"
href="{% url 'atlas:self_review_detail' user_exam.id case.id review.id %}">
Open full page
</a>
</div>
</div>
</div>
</div>
@@ -0,0 +1,83 @@
{% load crispy_forms_tags %}
<div class="p-3">
<div class="card border-secondary shadow-sm mb-2">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h6 mb-0">Self Review</h2>
<span class="badge bg-primary-subtle text-primary-emphasis">{{ user_exam.exam }}</span>
</div>
<div class="card-body">
<p class="text-muted small mb-2">
Record a quick reflection while reviewing this case.
</p>
<div class="small text-muted mb-3">
Case: <strong>{{ case }}</strong>
</div>
<form method="post" class="self-review-embed-form" action="">
{% csrf_token %}
{{ form.non_field_errors }}
{{ form.user_exam }}
{{ form.case }}
<div class="mb-3">
{{ form.comments|as_crispy_field }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold d-block">{{ form.findings.label }}</label>
<div class="btn-group" role="group" aria-label="Findings self rating">
{% for value, label in form.fields.findings.choices %}
<input
type="radio"
class="btn-check"
name="{{ form.findings.html_name }}"
id="id_embed_findings_{{ forloop.counter0 }}"
value="{{ value }}"
{% if form.findings.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
>
<label class="btn btn-outline-info" for="id_embed_findings_{{ forloop.counter0 }}">{{ label }}</label>
{% endfor %}
</div>
{% if form.findings.help_text %}
<div class="form-text">{{ form.findings.help_text }}</div>
{% endif %}
{% if form.findings.errors %}
<div class="text-danger small mt-1">{{ form.findings.errors|striptags }}</div>
{% endif %}
</div>
<div class="mb-4">
<label class="form-label fw-semibold d-block">{{ form.interpretation.label }}</label>
<div class="btn-group" role="group" aria-label="Interpretation self rating">
{% for value, label in form.fields.interpretation.choices %}
<input
type="radio"
class="btn-check"
name="{{ form.interpretation.html_name }}"
id="id_embed_interpretation_{{ forloop.counter0 }}"
value="{{ value }}"
{% if form.interpretation.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
>
<label class="btn btn-outline-warning" for="id_embed_interpretation_{{ forloop.counter0 }}">{{ label }}</label>
{% endfor %}
</div>
{% if form.interpretation.help_text %}
<div class="form-text">{{ form.interpretation.help_text }}</div>
{% endif %}
{% if form.interpretation.errors %}
<div class="text-danger small mt-1">{{ form.interpretation.errors|striptags }}</div>
{% endif %}
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary btn-sm">Save Self Review</button>
{% if review %}
<a href="{% url 'atlas:self_review_delete' user_exam.id case.id review.id %}" class="btn btn-outline-danger btn-sm">Delete</a>
{% endif %}
</div>
</form>
<div class="self-review-embed-message mt-2"></div>
</div>
</div>
</div>
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Self Review Saved</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #111;
color: #f0f0f0;
}
.wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.card {
max-width: 460px;
width: 100%;
border: 1px solid #3a3a3a;
border-radius: 10px;
background: #1a1a1a;
padding: 1rem 1.2rem;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35);
}
h1 {
margin-top: 0;
font-size: 1.15rem;
}
p {
margin-bottom: 0;
color: #d0d0d0;
}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Self review saved</h1>
<p>This window will close shortly and refresh the case page.</p>
</div>
</div>
<script>
(function () {
try { window.alert('Self review saved. This popup will close and refresh the case page.'); } catch (e) {}
try {
if (window.opener && !window.opener.closed) {
try {
window.opener.postMessage({ type: 'self_review_saved' }, '*');
} catch (e) {
window.opener.location.reload();
}
}
} catch (e) {}
setTimeout(function () {
window.close();
}, 700);
})();
</script>
</body>
</html>
@@ -0,0 +1,59 @@
{# Partial: Series Findings for a Case #}
<div>
<details open>
<summary><b>Findings</b></summary>
{% if can_edit %}
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Findings are display sets that can be added to series. Once added they will appear here on linked cases.</p>
<p>To add a finding to a series, select the series and then click the "Add finding" button.</p>
</details>
{% endif %}
<!-- Series Findings -->
<h5>Series Findings</h5>
{% for series in case.get_ordered_series %}
{% for finding in series.findings.all %}
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
<span>
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
<button class="btn btn-primary btn-sm">View</button>
</a>
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
View in Modal
</button>
</span>
{% if finding.findings %}
<span>
Findings: {% for f in finding.findings.all %}
{{f.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.conditions %}
<span>
Conditions: {% for c in finding.conditions.all %}
{{c.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.structures %}
<span>
Structure: {% for s in finding.structures.all %}
{{s.get_link}},&nbsp;
{% endfor %}
</span>
{% endif %}
{% if finding.description %}
<span>
Description: {{finding.description}}
</span>
{% endif %}
</div>
{% endfor %}
{% empty %}
No series associated with case.
{% endfor %}
</details>
</div>
@@ -0,0 +1,46 @@
<li class="series-item {% if series_item_classes %}{{ series_item_classes }}{% endif %}">
<div class="series-left">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
<h5 class="series-title">
{{ tags.SeriesDescription|default:series }}
{% if series_is_partial %}
<span class="badge bg-warning text-dark ms-2">Part imported</span>
{% endif %}
</h5>
<div class="series-meta small text-muted">
<div>UID: <code class="series-uid">{{ series }}</code></div>
{% if tags.SeriesNumber %}<div>Series #: <span class="series-number">{{ tags.SeriesNumber }}</span></div>{% endif %}
<div>Modality: <span class="series-modality">{{ tags.Modality }}</span></div>
<div>Images: <span class="series-images">{{ n }}</span></div>
{% if tags.StudyDate %}<div>Study date: <span class="series-study-date">{{ tags.StudyDate }}</span></div>{% endif %}
<div>Uploaded: <span class="series-date">{{ date }}</span></div>
</div>
</div>
<div class="series-right">
{% if tags.Modality %}
<span class="modality-badge">{{ tags.Modality }}</span>
{% endif %}
<span class="series-block-popup-link">
<a href="#" onclick="return window.create_popup_window('/atlas/uploads/series_id/{{ series }}', 'Series')">Popup</a>
{% if show_tags_link %}
&nbsp;|&nbsp;
<a
hx-get="{% url 'atlas:series_tags_partial' series %}"
hx-target="#seriesTagsModal .modal-body"
hx-swap="innerHTML"
>View tags</a>
{% endif %}
</span>
{% if series_is_partial %}
<button
type="button"
class="btn btn-sm btn-warning mt-2 import-partial-series-btn"
data-series-uids="{{ series }}"
{% if import_case_id %}data-case-id="{{ import_case_id }}"{% endif %}
>Finish import</button>
{% endif %}
</div>
<div class="w-100 mt-2">
<div id="series-tags-{{ series }}" class="series-tags-placeholder"></div>
</div>
</li>
@@ -0,0 +1,41 @@
{% load django_htmx %}
<div id="series-split-by-tag-panel" class="d-flex flex-column gap-2">
<div class="d-flex flex-wrap gap-2">
<button type="button"
class="btn btn-outline-warning btn-sm"
hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1"
hx-target="#series-split-by-tag-panel"
hx-swap="outerHTML">
Analyze split options
</button>
</div>
{% if analyze_requested %}
{% if split_options %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#split-by-tag-result"
hx-swap="innerHTML">
{% csrf_token %}
<div class="input-group input-group-sm">
<select name="dicom_tag" class="form-select form-select-sm">
{% for option in split_options %}
<option value="{{ option.tag_value }}">{{ option.tag_label }} ({{ option.split_count }} series)</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</div>
</form>
<p class="small text-muted mb-0">Only tags that can produce more than one output series are shown.</p>
{% else %}
<div class="alert alert-secondary mb-0 small">
No common split tags were found that can divide this series into multiple groups.
</div>
{% endif %}
{% else %}
<p class="small text-muted mb-0">Run analysis to detect valid split tags and estimated output series count.</p>
{% endif %}
<div id="split-by-tag-result"></div>
</div>
@@ -0,0 +1,194 @@
{% load django_htmx %}
<div id="series-tag-consistency-panel">
<div class="modal-header">
<h5 class="modal-title">Series Tag Consistency</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-flex flex-column gap-3">
<div class="d-flex flex-wrap gap-2 align-items-center">
<button type="button"
class="btn btn-outline-info btn-sm"
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-panel"
hx-swap="outerHTML">
Re-run analysis
</button>
{% if analyze_requested %}
<span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span>
{% endif %}
</div>
<div id="tag-consistency-split-result"></div>
{% if analyze_requested %}
<div class="small d-flex flex-wrap gap-2">
<span class="badge text-bg-success">Same: {{ same_tags|length }}</span>
<span class="badge text-bg-warning">Different: {{ different_tags|length }}</span>
<span class="badge text-bg-secondary">Missing in some: {{ partial_tags|length }}</span>
</div>
<div>
<label for="tag-consistency-filter-{{ series.pk }}" class="form-label form-label-sm mb-1">Filter tags</label>
<input id="tag-consistency-filter-{{ series.pk }}" type="text" class="form-control form-control-sm" placeholder="Type to filter by tag name or value">
</div>
<details class="styled-detail" open>
<summary>Different tags (highlighted)</summary>
<div class="table-responsive mt-2">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Tag</th>
<th>Unique values</th>
<th>Sample values</th>
<th>Split</th>
</tr>
</thead>
<tbody>
{% for row in different_tags %}
<tr class="table-warning" data-filter-row="1">
<td>{{ row.tag_name }}</td>
<td>{{ row.unique_count }}</td>
<td>{{ row.sample_values|join:", " }}</td>
<td>
{% if can_split %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</form>
{% else %}
<span class="text-muted small">No split access</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-muted">No tags vary across all images.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
<details class="styled-detail">
<summary>Same tags</summary>
<div class="table-responsive mt-2">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Tag</th>
<th>Value</th>
<th>Split</th>
</tr>
</thead>
<tbody>
{% for row in same_tags %}
<tr class="table-success" data-filter-row="1">
<td>{{ row.tag_name }}</td>
<td>{{ row.sample_values.0 }}</td>
<td>
{% if can_split %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</form>
{% else %}
<span class="text-muted small">No split access</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-muted">No uniform tags found across all images.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
<details class="styled-detail">
<summary>Missing in some images</summary>
<div class="table-responsive mt-2">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Tag</th>
<th>Present in</th>
<th>Unique values</th>
<th>Split</th>
</tr>
</thead>
<tbody>
{% for row in partial_tags %}
<tr class="table-secondary" data-filter-row="1">
<td>{{ row.tag_name }}</td>
<td>{{ row.present_count }}/{{ image_count }}</td>
<td>{{ row.unique_count }}</td>
<td>
{% if can_split %}
<form method="post"
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</form>
{% else %}
<span class="text-muted small">No split access</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-muted">All scanned tags were present in every image.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% else %}
<p class="small text-muted mb-0">Run analysis to quickly highlight tags that are the same or different across the series.</p>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
<script>
(function() {
const filterInput = document.getElementById("tag-consistency-filter-{{ series.pk }}");
if (!filterInput) {
return;
}
const rows = document.querySelectorAll("#series-tag-consistency-panel tr[data-filter-row='1']");
function applyFilter() {
const query = filterInput.value.trim().toLowerCase();
rows.forEach((row) => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(query) ? "" : "none";
});
}
filterInput.addEventListener("input", applyFilter);
})();
</script>
@@ -0,0 +1,120 @@
{% load django_htmx %}
{# Sharing panel for a CidUserExam attempt.
Context:
cid_user_exam CidUserExam instance
sharing_info dict from cid_user_exam.get_sharing_info()
is_owner bool: current user is the attempt owner
#}
<div id="sharing-panel-{{ cid_user_exam.pk }}" class="card border-secondary mt-3">
<div class="card-header py-2 px-3 d-flex justify-content-between align-items-center">
<span class="fw-semibold small">Result Sharing</span>
<a href="{% url 'atlas:collection_supervision' %}" class="btn btn-sm btn-outline-secondary">Supervision</a>
</div>
<div class="card-body py-2 px-3">
{# Always-visible users #}
<div class="mb-3">
<div class="small text-muted mb-1">Always shared with (authors &amp; markers)</div>
{% if sharing_info.always_visible_to %}
<ul class="list-unstyled mb-0">
{% for u in sharing_info.always_visible_to %}
<li class="small">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<span class="small text-muted fst-italic">No authors or markers assigned.</span>
{% endif %}
</div>
{# Supervisor sharing #}
{% if not sharing_info.exam_supervisor_visible %}
<div class="mb-3">
<div class="small text-muted mb-1">Share with supervisor</div>
{% if is_owner %}
<button class="btn btn-sm {% if sharing_info.supervisor_enabled %}btn-success{% else %}btn-outline-secondary{% endif %}"
hx-post="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-vals='{"action": "toggle_supervisor"}'
hx-target="#sharing-panel-{{ cid_user_exam.pk }}"
hx-swap="outerHTML">
{% if sharing_info.supervisor_enabled %}Supervisor sharing ON{% else %}Supervisor sharing OFF{% endif %}
</button>
{% else %}
<span class="badge {% if sharing_info.supervisor_enabled %}bg-success{% else %}bg-secondary{% endif %}">
{% if sharing_info.supervisor_enabled %}ON{% else %}OFF{% endif %}
</span>
{% endif %}
{% if sharing_info.supervisor %}
<div class="small text-muted mt-1">
Supervisor:
<strong>{{ sharing_info.supervisor.get_full_name|default:sharing_info.supervisor.username }}</strong>
{% if sharing_info.supervisor.email %}&lt;{{ sharing_info.supervisor.email }}&gt;{% endif %}
</div>
{% elif sharing_info.supervisor_enabled %}
<div class="small text-warning mt-1">No supervisor account linked to your profile — results cannot be shared automatically.</div>
{% endif %}
</div>
{% else %}
<div class="mb-3">
<div class="small text-muted mb-1">Supervisor</div>
<span class="badge bg-success">Sharing always enabled (exam setting)</span>
{% if sharing_info.supervisor %}
<div class="small text-muted mt-1">
Supervisor:
<strong>{{ sharing_info.supervisor.get_full_name|default:sharing_info.supervisor.username }}</strong>
{% if sharing_info.supervisor.email %}&lt;{{ sharing_info.supervisor.email }}&gt;{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{# Manual shares — only for registered-user attempts #}
{% if cid_user_exam.user_user_id %}
<div>
<div class="small text-muted mb-1">Also shared with</div>
{% if sharing_info.manual_shares %}
<ul class="list-unstyled mb-2">
{% for u in sharing_info.manual_shares %}
<li class="small d-flex align-items-center gap-2 mb-1">
<span>{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</span>
{% if is_owner %}
<button class="btn btn-sm btn-outline-danger py-0 px-1"
hx-post="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-vals='{"action": "remove_user", "user_id": "{{ u.pk }}"}'
hx-target="#sharing-panel-{{ cid_user_exam.pk }}"
hx-swap="outerHTML">
&times; Remove
</button>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted fst-italic mb-2">No additional users.</div>
{% endif %}
{% if is_owner %}
{# User search input #}
<div id="sharing-user-search-{{ cid_user_exam.pk }}">
<input type="text"
class="form-control form-control-sm"
placeholder="Search user by name or email…"
name="q"
autocomplete="off"
hx-get="{% url 'atlas:collection_exam_user_search' cid_user_exam.pk %}"
hx-trigger="input changed delay:300ms, focus"
hx-target="#sharing-search-results-{{ cid_user_exam.pk }}"
hx-swap="innerHTML">
<div id="sharing-search-results-{{ cid_user_exam.pk }}" class="mt-1"></div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,27 @@
{# HTMX partial: user search results for the sharing panel.
Context:
users list of User objects matching the query
cid_user_exam CidUserExam instance
q search query string
#}
{% if users %}
<ul class="list-group list-group-flush small border rounded">
{% for u in users %}
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-1 px-2">
<span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</span>
<button class="btn btn-sm btn-outline-primary py-0 px-2"
hx-post="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-vals='{"action": "add_user", "user_id": "{{ u.pk }}"}'
hx-target="#sharing-panel-{{ cid_user_exam.pk }}"
hx-swap="outerHTML">
Add
</button>
</li>
{% endfor %}
</ul>
{% elif q|length >= 2 %}
<div class="small text-muted fst-italic">No matching users found.</div>
{% endif %}
@@ -0,0 +1,26 @@
{% if imported_count %}
<div class="alert alert-success mb-2">
<strong>Import complete.</strong>
Imported {{ imported_count }} series{% if requested_count %} from {{ requested_count }} requested{% endif %}
{% if target_case %}
into case <a href="{% url 'atlas:case_detail' target_case.pk %}">{{ target_case }}</a>
{% endif %}.
</div>
<div class="list-group">
{% for row in imported_rows %}
<div class="list-group-item">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<div>
<strong>{{ row.series.description|default:'Imported series' }}</strong>
{% if row.series.series_instance_uid %}
<div class="small text-muted">UID: {{ row.series.series_instance_uid }}</div>
{% endif %}
</div>
<div>{{ row.link|safe }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-warning mb-0">No new series were imported.</div>
{% endif %}
@@ -2,7 +2,7 @@
{% if cases and cases|length > 0 %}
<div class="list-group mb-2">
{% for case in cases %}
{% include 'atlas/partials/_case_search_item.html' with case=case collection=collection %}
{% include 'atlas/partials/_case_search_item.html' with case=case collection=collection show_select_button=show_select_button %}
{% endfor %}
</div>
{% else %}
@@ -15,7 +15,7 @@
<div class="small text-muted mb-1">Recently created</div>
<div class="list-group">
{% for case in recent_cases %}
{% include 'atlas/partials/_case_search_item.html' with case=case collection=collection %}
{% include 'atlas/partials/_case_search_item.html' with case=case collection=collection show_select_button=show_select_button %}
{% endfor %}
</div>
</div>
@@ -1,18 +1,23 @@
<div class="case-search-widget">
{% with input_id=input_id|default:'case-search-input' target_id=target_id|default:'case-search-results' %}
<label for="{{ input_id }}" class="form-label">Search cases</label>
{% if collection %}
<input type="hidden" id="{{ input_id }}-collection" name="collection" value="{{ collection.pk }}">
{% endif %}
{% if show_select_button %}
<input type="hidden" id="{{ input_id }}-show-select" name="show_select" value="1">
{% endif %}
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search cases..."
hx-get="{% url 'atlas:case_search' %}"
hx-include="#{{ input_id }}"
{% if collection %}hx-vals='{"collection": "{{ collection.pk }}"}'{% endif %}
hx-include="closest .case-search-widget"
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
autocomplete="off">
<div id="{{ target_id }}" class="mt-2">
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection %}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection show_select_button=show_select_button %}
</div>
{% endwith %}
</div>
@@ -28,7 +33,7 @@
timer = setTimeout(function(){
try {
var q = encodeURIComponent(input.value || '');
var url = "{% url 'atlas:case_search' %}?q=" + q + ({% if collection %} "&collection={{ collection.pk }}" {% else %} '' {% endif %});
var url = "{% url 'atlas:case_search' %}?q=" + q + ({% if collection %} "&collection={{ collection.pk }}" {% else %} '' {% endif %}) + ({% if show_select_button %} "&show_select=1" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"case-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
@@ -60,8 +65,14 @@
targetEl.addEventListener('click', function(e){
var item = e.target.closest('.list-group-item');
if (!item || !targetEl.contains(item)) return;
// Ignore clicks on interactive elements inside the item (links, buttons, forms, inputs)
if (e.target.closest('a,button,form,input')) return;
// Explicit select action button should always select the row.
if (e.target.closest('.case-select-btn')) {
e.preventDefault();
} else {
// Ignore other interactive elements inside the item (links, buttons, forms, inputs)
if (e.target.closest('a,button,form,input')) return;
}
var casePk = item.getAttribute('data-case-pk');
if (!casePk) {
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="container px-0" style="max-width: 920px;">
<div class="card border-secondary shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h5 mb-0">Self Review</h2>
<span class="badge bg-primary-subtle text-primary-emphasis">{{ user_exam.exam }}</span>
</div>
<div class="card-body">
<div class="small text-muted mb-3">Case: <strong>{{ case }}</strong></div>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<div class="p-2 border rounded">
<div class="small text-muted">Findings</div>
<div class="fw-semibold">{{ review.findings|default:"-" }}/5</div>
</div>
</div>
<div class="col-sm-6">
<div class="p-2 border rounded">
<div class="small text-muted">Interpretation</div>
<div class="fw-semibold">{{ review.interpretation|default:"-" }}/5</div>
</div>
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Comments</div>
<div class="p-2 border rounded bg-body-tertiary">
{% if review.comments %}
{{ review.comments|linebreaksbr }}
{% else %}
<span class="text-muted">No comments added.</span>
{% endif %}
</div>
</div>
<div class="small text-muted mb-3">
Created: {{ review.review_date }}<br>
Updated: {{ review.review_update_date }}
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-warning"
href="{% url 'atlas:self_review_update' user_exam.id case.id review.id %}">
Edit Self Review
</a>
<a class="btn btn-outline-secondary" href="{{ review.get_absolute_url }}">
Back to case
</a>
</div>
</div>
</div>
</div>
{% endblock %}
+80 -30
View File
@@ -1,38 +1,88 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<h2>Add Self Review</h2>
<div class="alert alert-info" role="alert">
Adding self feedback for {{user_exam.exam}}/{{case}}
</div>
This form allows you to record your own feedback for the question / case. This can take the form of a note or a ranking (1-5) of your findings and interpretation.
<form class="highlight" action="" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="container px-0" style="max-width: 920px;">
<div class="card border-secondary shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h2 class="h5 mb-0">Self Review</h2>
<span class="badge bg-primary-subtle text-primary-emphasis">{{ user_exam.exam }}</span>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Reflect on this case and score your own performance. These scores are personal and intended to support learning.
</p>
<div class="small text-muted mb-3">
Case: <strong>{{ case }}</strong>
</div>
{{ form }}
{% comment %} <div class="fieldWrapper">
{{ form.content_type.errors }}
{{ form.content_type }}
<form method="post" class="needs-validation" novalidate>
{% csrf_token %}
{{ form.non_field_errors }}
{{ form.user_exam }}
{{ form.case }}
<div class="mb-3">
{{ form.comments|as_crispy_field }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold d-block">{{ form.findings.label }}</label>
<div class="btn-group" role="group" aria-label="Findings self rating">
{% for value, label in form.fields.findings.choices %}
<input
type="radio"
class="btn-check"
name="{{ form.findings.html_name }}"
id="id_findings_{{ forloop.counter0 }}"
value="{{ value }}"
{% if form.findings.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
>
<label class="btn btn-outline-info" for="id_findings_{{ forloop.counter0 }}">{{ label }}</label>
{% endfor %}
</div>
{% if form.findings.help_text %}
<div class="form-text">{{ form.findings.help_text }}</div>
{% endif %}
{% if form.findings.errors %}
<div class="text-danger small mt-1">{{ form.findings.errors|striptags }}</div>
{% endif %}
</div>
<div class="mb-4">
<label class="form-label fw-semibold d-block">{{ form.interpretation.label }}</label>
<div class="btn-group" role="group" aria-label="Interpretation self rating">
{% for value, label in form.fields.interpretation.choices %}
<input
type="radio"
class="btn-check"
name="{{ form.interpretation.html_name }}"
id="id_interpretation_{{ forloop.counter0 }}"
value="{{ value }}"
{% if form.interpretation.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
>
<label class="btn btn-outline-warning" for="id_interpretation_{{ forloop.counter0 }}">{{ label }}</label>
{% endfor %}
</div>
{% if form.interpretation.help_text %}
<div class="form-text">{{ form.interpretation.help_text }}</div>
{% endif %}
{% if form.interpretation.errors %}
<div class="text-danger small mt-1">{{ form.interpretation.errors|striptags }}</div>
{% endif %}
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary">Save Self Review</button>
<a href="{{ case.get_absolute_url }}" class="btn btn-outline-secondary">Open Case</a>
{% if review %}
<a href="{% url 'atlas:self_review_delete' user_exam.id case.id review.id %}" class="btn btn-outline-danger">Delete Review</a>
{% endif %}
</div>
</form>
</div>
</div>
<div class="fieldWrapper">
{{ form.object_id.errors }}
{{ form.object_id }}
</div>
<div class="fieldWrapper">
{{ form.note_type.errors }}
<label for="{{ form.note_type.id_for_label }}">Note type</label>
{{ form.note_type }}
</div>
<div class="fieldWrapper">
{{ form.note.errors }}
<label for="{{ form.note.id_for_label }}">Additional information</label><br/>
{{ form.note }}
</div> {% endcomment %}
<input type="submit" value="Submit">
</form>
{% if review %}<br/><a href="{% url 'atlas:self_review_delete' user_exam.id case.id review.id %}"><button>Delete review</button></a>{% endif %}
</div>
{% endblock %}
+21 -4
View File
@@ -30,8 +30,7 @@
<div class="card">
<div class="card-body p-2">
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
<div id="root" class="dicom-viewer-root w-100" data-images="{{ image_url_array_and_count.0 }}"
style="height: 600px; box-sizing: border-box; background: #222;" data-auto-cache-stack=false>
<div id="root" class="dicom-viewer-root w-100 viewer-frame-standard" data-images="{{ image_url_array_and_count.0 }}" data-auto-cache-stack=false>
</div>
{% endwith %}
</div>
@@ -190,7 +189,19 @@
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
<a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
<div hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-trigger="load"
hx-swap="outerHTML">
<span class="text-muted small">Loading split controls…</span>
</div>
<button class="btn btn-outline-info btn-sm"
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-modal .modal-content"
hx-swap="innerHTML"
data-bs-toggle="modal"
data-bs-target="#series-tag-consistency-modal">
Analyze tag consistency
</button>
</div>
</div>
@@ -230,7 +241,13 @@
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
<div class="modal-dialog modal-dialog-centered related-finding-modal modal-wide-md">
<div class="modal-content"></div>
</div>
</div>
<div id="series-tag-consistency-modal" class="modal modal-blur fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-xl modal-wide-xl">
<div class="modal-content"></div>
</div>
</div>
+174
View File
@@ -0,0 +1,174 @@
{% extends 'atlas/exams.html' %}
{% load static %}
{% block css %}
{{ block.super }}
<style>
.collapse-chevron { transition: transform .2s ease; }
[aria-expanded="true"] .collapse-chevron { transform: rotate(-180deg); }
</style>
{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">Supervision</h2>
<p class="text-muted small mb-0">
Collection attempts shared with you as author, marker, supervisor or by direct invitation.
<strong>{{ total_attempts }}</strong> attempt{{ total_attempts|pluralize }} found.
</p>
</div>
</div>
{# ---- Filter / group toolbar ---- #}
<form method="get" class="row g-2 align-items-end mb-4">
{# Group by #}
<div class="col-12 col-md-auto">
<label class="form-label small mb-1">Group by</label>
<div class="btn-group d-flex" role="group">
<a href="?group_by=user&date_from={{ date_from }}&date_to={{ date_to }}&q={{ q }}{% if supervisor_only %}&supervisor_only=1{% endif %}"
class="btn btn-sm {% if group_by == 'user' %}btn-primary{% else %}btn-outline-secondary{% endif %}">
By learner
</a>
<a href="?group_by=collection&date_from={{ date_from }}&date_to={{ date_to }}&q={{ q }}{% if supervisor_only %}&supervisor_only=1{% endif %}"
class="btn btn-sm {% if group_by == 'collection' %}btn-primary{% else %}btn-outline-secondary{% endif %}">
By collection
</a>
</div>
</div>
{# Learner search #}
<div class="col-12 col-md-3">
<label class="form-label small mb-1" for="q">Search learner</label>
<input type="text" id="q" name="q" class="form-control form-control-sm"
placeholder="Name, username or email…" value="{{ q }}">
</div>
{# Date range #}
<div class="col-12 col-md-auto">
<label class="form-label small mb-1" for="date_from">From</label>
<input type="date" id="date_from" name="date_from" class="form-control form-control-sm"
value="{{ date_from }}">
</div>
<div class="col-12 col-md-auto">
<label class="form-label small mb-1" for="date_to">To</label>
<input type="date" id="date_to" name="date_to" class="form-control form-control-sm"
value="{{ date_to }}">
</div>
<input type="hidden" name="group_by" value="{{ group_by }}">
{# Supervisor-only toggle (only visible when user is a supervisor) #}
{% if is_supervisor %}
<div class="col-12 col-md-auto d-flex align-items-end">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="supervisor_only" name="supervisor_only" value="1"
{% if supervisor_only %}checked{% endif %}
onchange="this.form.submit()">
<label class="form-check-label small" for="supervisor_only">My trainees only</label>
</div>
</div>
{% endif %}
<div class="col-12 col-md-auto">
<button type="submit" class="btn btn-sm btn-outline-primary">Apply</button>
<a href="{% url 'atlas:collection_supervision' %}" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
</div>
</form>
{# ---- Grouped results ---- #}
{% if groups %}
{% for group_label, attempt_rows in groups %}
<div class="mb-3">
<button class="btn btn-link text-start text-decoration-none w-100 p-0 border-bottom pb-1 mb-0 collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#group-{{ forloop.counter }}"
aria-expanded="false"
aria-controls="group-{{ forloop.counter }}">
<h5 class="h6 fw-semibold d-inline mb-0">
{% if group_by == 'user' %}
<i class="bi bi-person me-1" aria-hidden="true"></i>
{% else %}
<i class="bi bi-collection me-1" aria-hidden="true"></i>
{% endif %}
{{ group_label }}
<span class="badge bg-secondary fw-normal ms-1">{{ attempt_rows|length }} attempt{{ attempt_rows|length|pluralize }}</span>
</h5>
<i class="bi bi-chevron-down ms-2 small collapse-chevron" aria-hidden="true"></i>
</button>
<div class="collapse" id="group-{{ forloop.counter }}">
<div class="table-responsive mt-2">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-dark">
<tr>
{% if group_by == 'user' %}
<th>Collection</th>
{% else %}
<th>Learner</th>
{% endif %}
<th>Started</th>
<th>Ended</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for attempt, collection in attempt_rows %}
<tr>
{% if group_by == 'user' %}
<td>
{% if collection %}{{ collection.name }}{% else %}<span class="text-muted">Unknown</span>{% endif %}
</td>
{% else %}
<td>
{% if attempt.user_user %}
{{ attempt.user_user.get_full_name|default:attempt.user_user.username }}
{% if attempt.user_user.email %}
<br><span class="text-muted small">{{ attempt.user_user.email }}</span>
{% endif %}
{% elif attempt.cid_user %}
CID {{ attempt.cid_user.cid }}
{% if attempt.cid_user.name %}<br><span class="text-muted small">{{ attempt.cid_user.name }}</span>{% endif %}
{% else %}
<span class="text-muted">Unknown</span>
{% endif %}
</td>
{% endif %}
<td class="small text-nowrap">{{ attempt.start_time|default:"-" }}</td>
<td class="small text-nowrap">{{ attempt.end_time|default:"-" }}</td>
<td>
{% if attempt.completed %}
<span class="badge bg-success">Completed</span>
{% else %}
<span class="badge bg-warning text-dark">In progress</span>
{% endif %}
</td>
<td>
{% if attempt.user_user_id and collection %}
<a class="btn btn-sm btn-outline-primary"
href="{% url 'atlas:collection_history_user' exam_id=collection.pk user_pk=attempt.user_user_id %}">
View attempt
</a>
{% else %}
<a class="btn btn-sm btn-outline-primary"
href="{% url 'atlas:collection_shared_attempt_overview' attempt.pk %}">
View attempt
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>{# /collapse #}
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">
No collection attempts found. Try adjusting the filters.
</div>
{% endif %}
{% endblock %}
+113 -23
View File
@@ -1,34 +1,124 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Search uploaded DICOMs by pixel-data hash</h2>
<p class="text-muted">Admin-only tool for searching uncategorised uploads and imported Atlas images by <code>image_blake3_hash</code>.</p>
<h2>Search Uploaded And Imported DICOMs</h2>
<p class="text-muted">Admin-only tool for searching pending uploads and imported Atlas data by hash, series UID, or study UID.</p>
<form method="get" class="mb-3">
<div class="input-group">
<input
type="text"
class="form-control"
name="hash"
value="{{ hash_query }}"
placeholder="Enter full or partial pixel-data hash"
autocomplete="off"
spellcheck="false"
>
<button type="submit" class="btn btn-primary">Search</button>
<div class="row g-2">
<div class="col-lg-4">
<label class="form-label mb-1" for="hash-query-input">Hash</label>
<input
id="hash-query-input"
type="text"
class="form-control"
name="hash"
value="{{ hash_query }}"
placeholder="Pixel-data hash (partial OK)"
autocomplete="off"
spellcheck="false"
>
</div>
<div class="col-lg-4">
<label class="form-label mb-1" for="series-query-input">Series UID</label>
<input
id="series-query-input"
type="text"
class="form-control"
name="series"
value="{{ series_query }}"
placeholder="SeriesInstanceUID"
autocomplete="off"
spellcheck="false"
>
</div>
<div class="col-lg-4">
<label class="form-label mb-1" for="study-query-input">Study UID</label>
<input
id="study-query-input"
type="text"
class="form-control"
name="study"
value="{{ study_query }}"
placeholder="StudyInstanceUID"
autocomplete="off"
spellcheck="false"
>
</div>
</div>
<small class="form-text text-muted">Search is case-insensitive and supports partial matches.</small>
<div class="d-flex align-items-center gap-2 mt-2">
<button type="submit" class="btn btn-primary">Search</button>
<a href="{% url 'atlas:uploads_hash_search' %}" class="btn btn-outline-secondary">Clear</a>
</div>
<small class="form-text text-muted">All searches are case-insensitive and support partial matches. Study UID matching now uses indexed Atlas series data.</small>
</form>
{% if hash_query %}
{% if hash_query or series_query or study_query %}
<p>
Found
<strong>{{ uploads_result_count }}</strong> pending upload{{ uploads_result_count|pluralize }}
and
<strong>{{ imported_result_count }}</strong> imported image{{ imported_result_count|pluralize }}
for <code>{{ hash_query }}</code>.
and
<strong>{{ matched_series_result_count }}</strong> existing Atlas series.
</p>
<h4>Existing Atlas series</h4>
{% if matched_series_page_obj.object_list %}
<div class="table-responsive">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Series</th>
<th>Series UID</th>
<th>Cases</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{% for series in matched_series_page_obj.object_list %}
<tr>
<td>{{ series }}</td>
<td><code>{{ series.series_instance_uid|default:'-' }}</code></td>
<td>{{ series.case.count }}</td>
<td>
<a href="{% url 'atlas:series_detail' series.pk %}">View series</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if matched_series_page_obj.paginator.num_pages > 1 %}
<nav aria-label="Matched series pagination">
<ul class="pagination">
{% if matched_series_page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&series_page={{ matched_series_page_obj.previous_page_number }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.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 {{ matched_series_page_obj.number }} of {{ matched_series_page_obj.paginator.num_pages }}</span>
</li>
{% if matched_series_page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&series_page={{ matched_series_page_obj.next_page_number }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-warning">No existing Atlas series matched the current search.</div>
{% endif %}
<h4>Pending uploads</h4>
{% if uploads_page_obj.object_list %}
<div class="table-responsive">
@@ -67,7 +157,7 @@
<ul class="pagination">
{% if uploads_page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&uploads_page={{ uploads_page_obj.previous_page_number }}&imported_page={{ imported_page_obj.number }}">Previous</a>
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&uploads_page={{ uploads_page_obj.previous_page_number }}&imported_page={{ imported_page_obj.number }}&series_page={{ matched_series_page_obj.number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
@@ -79,7 +169,7 @@
{% if uploads_page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&uploads_page={{ uploads_page_obj.next_page_number }}&imported_page={{ imported_page_obj.number }}">Next</a>
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&uploads_page={{ uploads_page_obj.next_page_number }}&imported_page={{ imported_page_obj.number }}&series_page={{ matched_series_page_obj.number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
@@ -88,7 +178,7 @@
</nav>
{% endif %}
{% else %}
<div class="alert alert-warning">No pending uploads matched that hash.</div>
<div class="alert alert-warning">No pending uploads matched the current search.</div>
{% endif %}
<h4 class="mt-4">Imported Atlas images</h4>
@@ -141,7 +231,7 @@
<ul class="pagination">
{% if imported_page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.previous_page_number }}">Previous</a>
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.previous_page_number }}&series_page={{ matched_series_page_obj.number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
@@ -153,7 +243,7 @@
{% if imported_page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.next_page_number }}">Next</a>
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.next_page_number }}&series_page={{ matched_series_page_obj.number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
@@ -162,9 +252,9 @@
</nav>
{% endif %}
{% else %}
<div class="alert alert-warning">No imported Atlas images matched that hash.</div>
<div class="alert alert-warning">No imported Atlas images matched the current search.</div>
{% endif %}
{% else %}
<div class="alert alert-info">Enter a hash value to search uploads.</div>
<div class="alert alert-info">Enter a hash, series UID, or study UID to search uploads and existing imports.</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,187 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="h4 mb-1">Messages Inbox</h2>
<div class="text-muted small">All accessible conversation threads across collections.</div>
</div>
<div class="card border-secondary summary-card">
<div class="card-body py-2 px-3">
<div class="small text-muted">Pending acknowledgements</div>
<div class="fw-semibold {% if total_needs_your_ack %}text-danger{% endif %}">
{{ total_needs_your_ack }} need your acknowledgement
</div>
<div class="small mt-1 {% if total_waiting_on_other %}text-warning-emphasis{% else %}text-muted{% endif %}">
{{ total_waiting_on_other }} waiting on another user
</div>
<div class="small mt-1 text-muted">
{{ total_outstanding }} unacknowledged message{{ total_outstanding|pluralize }}
</div>
<div class="mt-2">
{% if show_all %}
<a class="btn btn-sm btn-outline-primary" href="?">Show outstanding first</a>
{% else %}
<a class="btn btn-sm btn-primary" href="?show=all">Load full history</a>
{% endif %}
</div>
</div>
</div>
</div>
{% if inbox_rows %}
<div class="d-flex flex-column gap-3">
{% for row in inbox_rows %}
<section class="card {% if row.outstanding_count %}border-danger-subtle{% else %}border-secondary-subtle{% endif %}">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<strong>{{ row.collection.name }}</strong>
<span class="text-muted small ms-2">{{ row.target_label }}</span>
{% if row.viewer_role == 'moderator' %}
<span class="badge bg-dark ms-1">Supervisor</span>
{% else %}
<span class="badge bg-secondary ms-1">Learner</span>
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge {% if row.needs_your_ack_count %}bg-danger{% else %}bg-secondary{% endif %}">
{{ row.needs_your_ack_count }} need your ack
</span>
{% if row.waiting_on_other_count %}
<span class="badge text-bg-warning">{{ row.waiting_on_other_count }} waiting on other</span>
{% endif %}
<span class="badge bg-dark-subtle text-dark-emphasis">{{ row.outstanding_count }} total unacknowledged</span>
<a class="btn btn-sm btn-outline-secondary" href="{{ row.detail_url }}">Open user message page</a>
</div>
</div>
<div class="card-body">
{% if row.outstanding_rows %}
<div class="mb-3">
<div class="small fw-semibold mb-2">Outstanding</div>
<div class="d-flex flex-column gap-2">
{% for case_row in row.outstanding_rows %}
<details open>
<summary class="small fw-semibold d-flex flex-wrap align-items-center gap-2">
<span>Case {{ case_row.casedetail.sort_order|add:1 }}</span>
{% if row.collection.show_title_post %}
<span class="text-muted">{{ case_row.casedetail.case.title|default:case_row.casedetail.case.pk|truncatechars:90 }}</span>
{% endif %}
<span class="badge {% if case_row.stats.needs_your_ack_count %}bg-danger{% else %}bg-secondary{% endif %}">
{{ case_row.stats.needs_your_ack_count }} need your ack
</span>
{% if case_row.stats.waiting_on_other_count %}
<span class="badge text-bg-warning">{{ case_row.stats.waiting_on_other_count }} waiting on other</span>
{% endif %}
<span class="badge bg-dark-subtle text-dark-emphasis">{{ case_row.stats.outstanding_total_count }} total</span>
</summary>
{% if row.viewer_role == 'moderator' %}
<div class="d-flex flex-wrap gap-2 mt-2">
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'atlas:collection_detail' row.collection.id %}">
Collection
</a>
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'atlas:collection_case_view' pk=row.collection.id case_number=case_row.casedetail.sort_order %}">
Case In Collection
</a>
<a class="btn btn-sm btn-outline-primary"
href="{% if row.attempt.user_user_id %}{% url 'atlas:collection_history_user' row.collection.id row.attempt.user_user_id %}{% else %}{% url 'atlas:collection_history_ciduser' row.collection.id row.attempt.cid_user.cid %}{% endif %}#case-history-{{ case_row.casedetail.case.id }}">
User Answer In Context
</a>
</div>
{% elif row.attempt.user_user_id %}
<div class="d-flex flex-wrap gap-2 mt-2">
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=row.collection.id %}">
Collection Overview
</a>
<a class="btn btn-sm btn-outline-primary"
href="{% url 'atlas:collection_case_view_take_user_answers' pk=row.collection.id case_number=case_row.casedetail.sort_order %}">
View My Answer
</a>
</div>
{% endif %}
<div class="mt-2"
hx-get="{{ case_row.thread_url }}{% if case_row.cid %}?cid={{ case_row.cid }}{% endif %}"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Loading conversation...</div>
</div>
</details>
{% endfor %}
</div>
</div>
{% endif %}
{% if show_all and row.history_rows %}
<div>
<div class="small fw-semibold mb-2">History</div>
<div class="accordion" id="history-{{ row.attempt.id }}">
{% for case_row in row.history_rows %}
<div class="accordion-item">
<h2 class="accordion-header" id="history-heading-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#history-collapse-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}" aria-expanded="false" aria-controls="history-collapse-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}">
Case {{ case_row.casedetail.sort_order|add:1 }}
{% if row.collection.show_title_post %}
: {{ case_row.casedetail.case.title|default:case_row.casedetail.case.pk|truncatechars:90 }}
{% endif %}
<span class="badge bg-secondary ms-2">{{ case_row.stats.message_count }} messages</span>
{% if case_row.stats.outstanding_total_count %}
<span class="badge bg-dark-subtle text-dark-emphasis ms-1">{{ case_row.stats.outstanding_total_count }} unacknowledged</span>
{% endif %}
</button>
</h2>
<div id="history-collapse-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}" class="accordion-collapse collapse" aria-labelledby="history-heading-{{ row.attempt.id }}-{{ case_row.casedetail.case.id }}" data-bs-parent="#history-{{ row.attempt.id }}">
<div class="accordion-body pt-0">
{% if row.viewer_role == 'moderator' %}
<div class="d-flex flex-wrap gap-2 mt-2 mb-2">
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'atlas:collection_detail' row.collection.id %}">
Collection
</a>
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'atlas:collection_case_view' pk=row.collection.id case_number=case_row.casedetail.sort_order %}">
Case In Collection
</a>
<a class="btn btn-sm btn-outline-primary"
href="{% if row.attempt.user_user_id %}{% url 'atlas:collection_history_user' row.collection.id row.attempt.user_user_id %}{% else %}{% url 'atlas:collection_history_ciduser' row.collection.id row.attempt.cid_user.cid %}{% endif %}#case-history-{{ case_row.casedetail.case.id }}">
User Answer In Context
</a>
</div>
{% elif row.attempt.user_user_id %}
<div class="d-flex flex-wrap gap-2 mt-2 mb-2">
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=row.collection.id %}">
Collection Overview
</a>
<a class="btn btn-sm btn-outline-primary"
href="{% url 'atlas:collection_case_view_take_user_answers' pk=row.collection.id case_number=case_row.casedetail.sort_order %}">
View My Answer
</a>
</div>
{% endif %}
<div hx-get="{{ case_row.thread_url }}{% if case_row.cid %}?cid={{ case_row.cid }}{% endif %}"
hx-trigger="revealed once"
hx-target="this"
hx-swap="innerHTML">
<div class="small text-muted">Open section to load conversation history...</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</section>
{% endfor %}
</div>
{% else %}
<div class="card border-secondary">
<div class="card-body text-muted small">No message threads available for your account.</div>
</div>
{% endif %}
{% endblock %}
+349 -169
View File
@@ -18,7 +18,7 @@
<div id="series-list">
{% if grouped_series %}
{% for study_uid, study in grouped_series %}
<details class="study-block" open>
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
<summary>
<strong>{{ study.description|default:"(no description)" }}</strong>
<small class="text-muted"> ({{ study_uid }}) — {{ study.series|length }} series</small>
@@ -27,37 +27,66 @@
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" title="Deselect all in study">Deselect all</button>
</span>
</summary>
{% if study.partial_imported_uids %}
<div class="alert alert-warning py-2 mt-2 mb-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>
<strong>Partial import detected:</strong>
{{ study.partial_imported_count }} series in this study already exist.
The highlighted rows can finish importing the remaining images.
{% if study.matched_existing_series %}
<div class="small mt-1">
Existing series in Atlas:
{% for existing_series in study.matched_existing_series %}
<a href="{% url 'atlas:series_detail' existing_series.pk %}">{{ existing_series }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<button
type="button"
class="btn btn-sm btn-warning import-partial-series-btn"
data-series-uids="{{ study.partial_imported_uids|join:',' }}"
{% if study.preferred_case_id %}data-case-id="{{ study.preferred_case_id }}"{% endif %}
>Finish these imports</button>
</div>
{% endif %}
{% if study.import_suggestions %}
<div class="alert alert-info py-2 mt-2 mb-2">
<strong>Study already imported:</strong>
{% for suggestion in study.import_suggestions %}
<div class="d-flex flex-wrap align-items-center gap-2 mt-1">
<span>
Case:
<a href="{% url 'atlas:case_detail' suggestion.case.pk %}">{{ suggestion.case }}</a>
({{ suggestion.already_imported_count }} series already imported)
</span>
<button
type="button"
class="btn btn-sm btn-outline-primary import-missing-series-btn"
data-case-id="{{ suggestion.case.pk }}"
data-series-uids="{{ suggestion.missing_uids|join:',' }}"
>
Import remaining {{ suggestion.missing_uids|length }} series
</button>
</div>
{% endfor %}
</div>
{% endif %}
<ul class="study-series-list">
{% for series, n, tags, date in study.series %}
<li class="series-item">
<div class="series-left">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
<h5 class="series-title">{{ tags.SeriesDescription|default:series }}</h5>
<div class="series-meta small text-muted">
<div>UID: <code class="series-uid">{{ series }}</code></div>
<div>Modality: <span class="series-modality">{{ tags.Modality }}</span></div>
<div>Images: <span class="series-images">{{ n }}</span></div>
<div>Uploaded: <span class="series-date">{{ date }}</span></div>
</div>
</div>
<div class="series-right">
{% if tags.Modality %}
<span class="modality-badge">{{ tags.Modality }}</span>
{% endif %}
<span class="series-block-popup-link">
<a href="#" onclick="return window.create_popup_window('/atlas/uploads/series_id/{{series}}', 'Series')">Popup</a>
&nbsp;|&nbsp;
<a
hx-get="{% url 'atlas:series_tags_partial' series %}"
hx-target="#seriesTagsModal .modal-body"
hx-swap="innerHTML"
>View tags</a>
</span>
</div>
<div class="w-100 mt-2">
<div id="series-tags-{{ series }}" class="series-tags-placeholder"></div>
</div>
</li>
{% if series in study.partial_imported_uids %}
{% if case %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=study.preferred_case_id %}
{% endif %}
{% else %}
{% if case %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' %}
{% endif %}
{% endif %}
{% endfor %}
</ul>
</details>
@@ -65,26 +94,11 @@
{% else %}
<ul>
{% for series, n, tags, date in series_list %}
<li class="series-item">
<div class="series-left">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
<h5 class="series-title">{{ tags.SeriesDescription|default:series }}</h5>
<div class="series-meta small text-muted">
<div>UID: <code class="series-uid">{{ series }}</code></div>
<div>Modality: <span class="series-modality">{{ tags.Modality }}</span></div>
<div>Images: <span class="series-images">{{ n }}</span></div>
<div>Uploaded: <span class="series-date">{{ date }}</span></div>
</div>
</div>
<div class="series-right">
{% if tags.Modality %}
<span class="modality-badge">{{ tags.Modality }}</span>
{% endif %}
<span class="series-block-popup-link">
<a href="#" onclick="return window.create_popup_window('/atlas/uploads/series_id/{{series}}', 'Series')">Popup</a>
</span>
</div>
</li>
{% if case %}
{% include 'atlas/partials/_series_item.html' with series_is_partial=False series_item_classes='' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with series_is_partial=False series_item_classes='' %}
{% endif %}
{% endfor %}
</ul>
{% endif %}
@@ -93,51 +107,79 @@
{% if case %}
<p>Importing into case: <a href='{% url "atlas:case_detail" case.pk %}'>{{case}}</a></p>
{% endif %}
<details><summary>Upload settings</summary>
<form>
<div>
<fieldset><legend>Select how series should be ordered when uploaded</legend>
<div class="helptext">
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.<br/>
If you are not sure, leave this as the default.
</div>
<div>
<input type="radio" id="order-series-slice-location" value="order-series-slice-location" name="order-series" checked>
<label for="order-series-slice-location">Order series by slice location</label><br/>
</div>
<div>
<input type="radio" id="order-series-instance-number" value="order-series-instance-number" name="order-series" >
<label for="order-series-instance-number">Order series by instance number</label><br/>
</div>
<div>
<input type="radio" id="order-series-none" value="order-series-none" name="order-series" >
<label for="order-series-none">None</label><br/>
</div>
</fieldset>
<div class="upload-toolbar sticky-top mb-3">
<div class="upload-toolbar__inner">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-2">
<div>
<div id="selection-count" class="small text-muted">Selected: <span id="selected-count">0</span></div>
<div class="small text-warning-emphasis">Keep this page open while imports are running.</div>
</div>
<details class="upload-settings">
<summary>Upload settings</summary>
<form class="mt-2">
<fieldset>
<legend>Select how series should be ordered when uploaded</legend>
<div class="helptext">
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.
If you are not sure, leave this as the default.
</div>
<div>
<input type="radio" id="order-series-slice-location" value="order-series-slice-location" name="order-series" checked>
<label for="order-series-slice-location">Order series by slice location</label>
</div>
<div>
<input type="radio" id="order-series-instance-number" value="order-series-instance-number" name="order-series">
<label for="order-series-instance-number">Order series by instance number</label>
</div>
<div>
<input type="radio" id="order-series-none" value="order-series-none" name="order-series">
<label for="order-series-none">None</label>
</div>
</fieldset>
</form>
</details>
</div>
</form>
</details>
<div id="selection-actions" class="d-flex align-items-center gap-2 mb-2">
<div id="selection-count" class="small text-muted">Selected: <span id="selected-count">0</span></div>
<button id="delete-uploads-button"
hx-post="{% url 'api-1:clear_dicoms' %}"
hx-include="[name='selection']"
hx-confirm="This will clear selected uploads, Continue?"
title="Deleted selected uploads"
>Delete Uploads</button>
<div id="import-progress" class="alert alert-warning d-none mb-3" role="alert">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<div>
<strong>Import running.</strong>
<span class="d-block">Do not leave this page until the progress bar completes.</span>
</div>
<div id="import-progress-text" class="small text-muted"></div>
</div>
<div class="progress" style="height: 1rem;">
<div id="import-progress-bar"
class="progress-bar progress-bar-striped progress-bar-animated bg-warning text-dark"
role="progressbar"
style="width: 0%"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100">0%</div>
</div>
</div>
<button id="import-dicoms-sequential-button"
type="button"
class="btn btn-primary"
>Import {% if case %}into case{% endif %}</button>
<button id="import-into-case-button" type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#caseSelectModal">Import into case…</button>
<div id="selection-actions" class="d-flex flex-wrap align-items-center gap-2">
<button id="delete-uploads-button"
class="btn btn-outline-danger"
hx-post="{% url 'api-1:clear_dicoms' %}"
hx-include="[name='selection']"
hx-confirm="This will clear selected uploads, Continue?"
title="Delete selected uploads"
>Delete Uploads</button>
<button id="import-dicoms-sequential-button"
type="button"
class="btn btn-primary"
>Import {% if case %}into case{% endif %}</button>
<button id="import-into-case-button" type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#caseSelectModal">Import into case…</button>
</div>
<div id="import-status" class="mt-3"></div>
</div>
</div>
<div id="import-status"></div>
<div class="indicator"><div class="loader"></div>Loading...</div>
<div id="import-status"></div>
<div class="imported">
@@ -179,7 +221,7 @@
</div>
<div class="modal-body">
<div class="mb-2">
{% case_search_widget collection=None input_id='case-search-input-modal' target_id='case-search-results-modal' %}
{% case_search_widget collection=None input_id='case-search-input-modal' target_id='case-search-results-modal' show_select_button=True %}
</div>
</div>
<div class="modal-footer">
@@ -190,6 +232,8 @@
</div>
<script>
const importCaseId = {% if case %}{{ case.pk }}{% else %}null{% endif %};
// Update the selected count shown beside action buttons
function updateSelectionCount() {
const all = Array.from(document.querySelectorAll('input[name="selection"]'));
@@ -205,6 +249,100 @@
if (importIntoCaseBtn) importIntoCaseBtn.disabled = selected === 0;
}
function setImportButtonsDisabled(disabled) {
document.querySelectorAll('#selection-actions button, .import-partial-series-btn, .import-missing-series-btn').forEach((button) => {
button.disabled = disabled;
});
}
function setImportProgress(completed, total, message) {
const progress = document.getElementById('import-progress');
const bar = document.getElementById('import-progress-bar');
const text = document.getElementById('import-progress-text');
if (!progress || !bar || !text) return;
if (total <= 0) {
progress.classList.add('d-none');
bar.style.width = '0%';
bar.setAttribute('aria-valuenow', '0');
bar.textContent = '0%';
text.textContent = '';
return;
}
const percentage = Math.min(100, Math.round((completed / total) * 100));
progress.classList.remove('d-none');
bar.style.width = `${percentage}%`;
bar.setAttribute('aria-valuenow', String(percentage));
bar.textContent = `${percentage}%`;
text.textContent = message || `${completed} of ${total} series imported`;
}
function appendImportStatus(html) {
const statusDiv = document.getElementById('import-status');
if (!statusDiv) return;
const wrapper = document.createElement('div');
wrapper.className = 'mb-2';
wrapper.innerHTML = html;
statusDiv.appendChild(wrapper);
}
function getImportUrl(caseId) {
const targetCaseId = caseId || importCaseId;
if (targetCaseId) {
const importTemplate = "{% url 'atlas:uploads_import_case_htmx' 0 %}";
return importTemplate.replace(/0\/?$/, targetCaseId);
}
return "{% url 'atlas:uploads_import_htmx' %}";
}
function getSelectedOrAllSeries() {
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
const selectableSeries = allCheckboxes.filter(cb => !cb.disabled).map(cb => cb.value);
const selectedSeries = checkboxes.filter(cb => !cb.disabled).map(cb => cb.value);
return selectedSeries.length ? selectedSeries : selectableSeries;
}
async function runImport(selectionList, caseId) {
const statusDiv = document.getElementById('import-status');
if (!statusDiv) return;
if (!selectionList.length) {
alert('Please select at least one series to import.');
return;
}
statusDiv.innerHTML = '';
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
setImportButtonsDisabled(true);
setImportProgress(0, 1, `Importing ${selectionList.length} series. Do not leave this page.`);
try {
await htmx.ajax('POST', getImportUrl(caseId), {
target: '#import-status',
swap: 'innerHTML',
values: {
selection: selectionList,
'order-series': orderSeries,
},
});
selectionList.forEach((seriesId) => {
markSeriesImported(seriesId);
});
setImportProgress(1, 1, `${selectionList.length} series import request completed.`);
} catch (error) {
appendImportStatus(`<div class="alert alert-danger mb-0">Import failed. ${error?.message || ''}</div>`);
} finally {
setTimeout(function () {
setImportProgress(0, 0);
}, 1800);
setImportButtonsDisabled(false);
updateSelectionCount();
}
}
// Keep count up-to-date on checkbox changes and attribute changes
document.body.addEventListener('change', function (e) {
if (e.target && e.target.name === 'selection') updateSelectionCount();
@@ -238,68 +376,47 @@
}
});
function markSeriesImported(seriesId) {
const li = document.querySelector(`#series-list input[value="${seriesId}"]`)?.closest('li');
if (!li || li.classList.contains('imported')) return;
li.classList.add('imported');
li.classList.remove('series-item--partial-import');
const partialBadge = li.querySelector('.badge.bg-warning');
if (partialBadge && partialBadge.textContent.trim() === 'Part imported') {
partialBadge.remove();
}
const partialButton = li.querySelector('.import-partial-series-btn');
if (partialButton) partialButton.remove();
if (!li.querySelector('.badge.bg-success')) {
const badge = document.createElement('span');
badge.className = 'badge bg-success ms-2';
badge.textContent = 'Imported';
li.appendChild(badge);
}
const checkbox = li.querySelector('input[name="selection"]');
if (checkbox) {
checkbox.disabled = true;
checkbox.checked = false;
}
const _sp = li.querySelector('span');
if (_sp) _sp.onclick = null;
li.style.pointerEvents = 'none';
li.style.opacity = '0.7';
}
const importButton = document.getElementById('import-dicoms-sequential-button');
if (importButton) {
importButton.addEventListener('click', async function() {
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
// Only include checkboxes that are not disabled (i.e., not already imported)
const selectableCheckboxes = allCheckboxes.filter(cb => !cb.disabled);
const toImport = checkboxes.length
? checkboxes.filter(cb => !cb.disabled)
: selectableCheckboxes;
if (toImport.length === 0) {
alert("Please select at least one series to import (not already imported).");
const toImport = getSelectedOrAllSeries();
if (!toImport.length) {
alert('Please select at least one series to import (not already imported).');
return;
}
const statusDiv = document.getElementById('import-status');
if (statusDiv) statusDiv.innerHTML = '';
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
const csrfToken = '{{ csrf_token }}';
{% if case %}
const importUrl = "{% url 'api-1:import_dicoms_case' case.id %}";
{% else %}
const importUrl = "{% url 'api-1:import_dicoms' %}";
{% endif %}
for (let i = 0; i < toImport.length; i++) {
const seriesId = toImport[i].value;
statusDiv.innerHTML += `<div id="import-status-${seriesId}">Importing series ${seriesId}...</div>`;
try {
const response = await fetch(importUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": csrfToken,
},
body: `selection=${encodeURIComponent(seriesId)}&order-series=${encodeURIComponent(orderSeries)}`
});
const text = await response.text();
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: ${text}`;
// Mark as imported in the UI and make unselectable
const li = document.querySelector(`#series-list input[value="${seriesId}"]`)?.closest('li');
if (li && !li.classList.contains('imported')) {
li.classList.add('imported');
if (!li.querySelector('.badge.bg-success')) {
const badge = document.createElement('span');
badge.className = 'badge bg-success ms-2';
badge.textContent = 'Imported';
li.appendChild(badge);
}
const checkbox = li.querySelector('input[name="selection"]');
if (checkbox) {
checkbox.disabled = true;
checkbox.checked = false;
}
const _sp = li.querySelector('span');
if (_sp) _sp.onclick = null;
li.style.pointerEvents = "none";
li.style.opacity = "0.7";
}
} catch (e) {
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: <span style="color:red;">Failed</span>`;
}
}
statusDiv.innerHTML += "<div>All done.</div>";
await runImport(toImport, importCaseId);
});
}
@@ -360,39 +477,64 @@
const confirmMsg = `Import ${selectionList.length} series into case "${caseTitle}" (ID ${casePk})?`;
if (!window.confirm(confirmMsg)) return;
// Build import URL and POST
const importTemplate = "{% url 'api-1:import_dicoms_case' 0 %}";
const importUrl = importTemplate.replace(/0\/?$/, casePk );
const form = new URLSearchParams();
for (const s of selectionList) form.append('selection', s);
const orderSeriesInput = document.querySelector('input[name="order-series"]:checked');
if (orderSeriesInput) form.append('order-series', orderSeriesInput.value);
const csrfToken = '{{ csrf_token }}';
const resp = await fetch(importUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: form.toString(),
credentials: 'same-origin'
});
const text = await resp.text();
// Close modal and show result in import-status
const modalEl = document.getElementById('caseSelectModal');
if (modalEl && window.bootstrap && bootstrap.Modal) {
bootstrap.Modal.getInstance(modalEl)?.hide();
}
const statusDiv = document.getElementById('import-status');
if (statusDiv) statusDiv.innerHTML = `<div>Imported into case ${casePk}: ${text}</div>`;
setTimeout(function(){ location.reload(); }, 1200);
await runImport(selectionList, casePk);
} catch (err) {
console.error('Import handler error', err);
alert('Import failed — check console for details.');
}
});
document.body.addEventListener('click', async function (e) {
const trigger = e.target.closest('.import-missing-series-btn');
if (!trigger) return;
const casePk = trigger.dataset.caseId;
const seriesUids = (trigger.dataset.seriesUids || '')
.split(',')
.map(v => v.trim())
.filter(Boolean);
if (!casePk || !seriesUids.length) {
alert('No remaining series found for this study.');
return;
}
const confirmMsg = `Import ${seriesUids.length} remaining series into case ${casePk}?`;
if (!window.confirm(confirmMsg)) return;
await runImport(seriesUids, casePk);
});
document.body.addEventListener('click', async function (e) {
const trigger = e.target.closest('.import-partial-series-btn');
if (!trigger) return;
const seriesUids = (trigger.dataset.seriesUids || '')
.split(',')
.map(v => v.trim())
.filter(Boolean);
if (!seriesUids.length) {
alert('No partially imported series were found.');
return;
}
const casePk = trigger.dataset.caseId || importCaseId;
if (!casePk) {
alert('Could not determine a target case for this partial import. Use an "Import remaining" button linked to a case or open this page in a case context.');
return;
}
const confirmMsg = `Finish importing ${seriesUids.length} series${casePk ? ` into case ${casePk}` : ''}?`;
if (!window.confirm(confirmMsg)) return;
await runImport(seriesUids, casePk);
});
// Row click toggles selection when clicking any non-interactive part of the series card
document.body.addEventListener('click', function (e) {
const item = e.target.closest('.series-item');
@@ -479,6 +621,39 @@
box-shadow: 0 1px 0 rgba(255,255,255,0.02) inset;
}
.study-block--partial-import {
border-color: rgba(245, 158, 11, 0.55);
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.14) inset;
}
.upload-toolbar {
top: 0.75rem;
z-index: 1030;
}
.upload-toolbar__inner {
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(15, 17, 21, 0.94);
backdrop-filter: blur(10px);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.22);
}
.upload-settings summary {
cursor: pointer;
font-weight: 700;
}
.upload-settings fieldset {
margin: 0;
}
.upload-settings legend {
font-size: 0.95rem;
margin-bottom: 0.35rem;
}
.study-block summary { font-size: 1rem; cursor: pointer; padding: 4px 6px; color: #e6eef6; }
.study-series-list { list-style: none; margin: 8px 0 0 0; padding: 0; }
@@ -519,6 +694,11 @@
position: relative;
}
.series-item--partial-import {
border-color: rgba(245, 158, 11, 0.7);
background: linear-gradient(180deg, rgba(245, 158, 11, 0.10), rgba(255, 255, 255, 0.00));
}
.series-item:hover { box-shadow: 0 6px 18px rgba(0,0,0,0.4); transform: translateY(-2px); }
.series-left { display: block; }
+12 -1
View File
@@ -6,7 +6,17 @@ register = template.Library()
@register.inclusion_tag('atlas/partials/case_search_widget.html', takes_context=True)
def case_search_widget(context, collection=None, cases=None, recent_cases=None, input_id='case-search-input', target_id='case-search-results', selection_mode='single', action_url=None):
def case_search_widget(
context,
collection=None,
cases=None,
recent_cases=None,
input_id='case-search-input',
target_id='case-search-results',
selection_mode='single',
action_url=None,
show_select_button=False,
):
"""Render a reusable case search widget.
Parameters:
@@ -35,6 +45,7 @@ def case_search_widget(context, collection=None, cases=None, recent_cases=None,
'target_id': target_id,
'selection_mode': selection_mode,
'action_url': action_url,
'show_select_button': show_select_button,
}
+34
View File
@@ -0,0 +1,34 @@
from types import SimpleNamespace
from django.core.files.uploadedfile import SimpleUploadedFile
import atlas.api as atlas_api
from atlas.models import DuplicateDicom, SeriesImage
def test_upload_dicom_duplicate_series_image_without_series_does_not_500(
monkeypatch,
):
request = SimpleNamespace(user=SimpleNamespace(is_authenticated=True))
class DummyUncategorisedDicom:
def __init__(self, image, user):
self.image = image
self.user = user
self.image_blake3_hash = "hash123"
def save(self, *args, **kwargs):
raise DuplicateDicom(SeriesImage(series=None))
monkeypatch.setattr(atlas_api, "UncategorisedDicom", DummyUncategorisedDicom)
upload = SimpleUploadedFile(
"duplicate-no-series.dcm",
b"dicom-bytes",
content_type="application/dicom",
)
payload = atlas_api.upload_dicom(request, files=[upload])
assert payload["uploaded"] == []
assert payload["failed"] == []
assert payload["duplicate_series"] == []
assert payload["duplicates"] == [("duplicate-no-series.dcm", "hash123")]
@@ -0,0 +1,118 @@
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from atlas.models import CaseCollection, CaseDetail, CaseReviewMessage
@pytest.mark.django_db
def test_collection_case_query_messaging_enabled_default_true():
collection = CaseCollection.objects.create(name="Default messaging collection")
assert collection.case_query_messaging_enabled is True
@pytest.mark.django_db
def test_collection_case_review_thread_returns_404_when_messaging_disabled(create_case, client):
user_model = get_user_model()
author = user_model.objects.create_user(username="author-thread", password="testpass")
collection = CaseCollection.objects.create(
name="Thread disabled",
case_query_messaging_enabled=False,
)
collection.author.add(author)
case = create_case
CaseDetail.objects.create(collection=collection, case=case, sort_order=0)
attempt = collection.get_or_create_cid_user_exam(user_user=author)
client.force_login(author)
url = reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": attempt.pk,
"case_id": case.pk,
},
)
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_collection_user_messages_returns_404_when_messaging_disabled(create_case, client):
user_model = get_user_model()
author = user_model.objects.create_user(username="author-user-messages", password="testpass")
learner = user_model.objects.create_user(username="learner-user-messages", password="testpass")
collection = CaseCollection.objects.create(
name="User messages disabled",
case_query_messaging_enabled=False,
)
collection.author.add(author)
case = create_case
CaseDetail.objects.create(collection=collection, case=case, sort_order=0)
collection.get_or_create_cid_user_exam(user_user=learner)
client.force_login(author)
url = reverse(
"atlas:collection_user_messages",
kwargs={"exam_id": collection.pk, "user_pk": learner.pk},
)
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_user_messages_inbox_ignores_disabled_collection_messages(create_case, client):
user_model = get_user_model()
moderator = user_model.objects.create_user(username="moderator", password="testpass")
learner = user_model.objects.create_user(username="learner", password="testpass")
enabled_collection = CaseCollection.objects.create(
name="Enabled messaging collection",
case_query_messaging_enabled=True,
)
disabled_collection = CaseCollection.objects.create(
name="Disabled messaging collection",
case_query_messaging_enabled=False,
)
enabled_collection.author.add(moderator)
disabled_collection.author.add(moderator)
case = create_case
CaseDetail.objects.create(collection=enabled_collection, case=case, sort_order=0)
CaseDetail.objects.create(collection=disabled_collection, case=case, sort_order=0)
enabled_attempt = enabled_collection.get_or_create_cid_user_exam(user_user=learner)
disabled_attempt = disabled_collection.get_or_create_cid_user_exam(user_user=learner)
CaseReviewMessage.objects.create(
cid_user_exam=enabled_attempt,
case=case,
author=learner,
message_type=CaseReviewMessage.MessageType.QUERY,
message="Enabled message",
requires_acknowledgement=True,
)
CaseReviewMessage.objects.create(
cid_user_exam=disabled_attempt,
case=case,
author=learner,
message_type=CaseReviewMessage.MessageType.QUERY,
message="Disabled message",
requires_acknowledgement=True,
)
client.force_login(moderator)
response = client.get(reverse("atlas:user_messages_inbox"))
assert response.status_code == 200
assert "Enabled messaging collection" in response.content.decode()
assert "Disabled messaging collection" not in response.content.decode()
+86 -2
View File
@@ -10,6 +10,7 @@ app_name = "atlas"
urlpatterns = [
path("", views.index, name="index"),
path("help/", TemplateView.as_view(template_name="atlas/help.html"), name="help"),
path("messages", views.user_messages_inbox, name="user_messages_inbox"),
path(
"create/",
RedirectView.as_view(pattern_name="atlas:case_create", permanent=False),
@@ -25,6 +26,11 @@ urlpatterns = [
views.SelfReviewUpdate.as_view(),
name="self_review_update",
),
path(
"self_review/<int:user_exam_id>/<int:case_id>/view/<int:pk>",
views.SelfReviewDetail.as_view(),
name="self_review_detail",
),
path(
"self_review/<int:user_exam_id>/<int:case_id>/delete/<int:pk>",
views.SelfReviewDelete.as_view(),
@@ -43,6 +49,8 @@ urlpatterns = [
path("uploads/all", views.all_uploads, name="all_uploads"),
path("uploads/hash-search", views.uploads_hash_search, name="uploads_hash_search"),
path("uploads/new", views.new_uploads, name="new_uploads"),
path("uploads/import", views.uploads_import_htmx, name="uploads_import_htmx"),
path("uploads/import/case/<int:case_id>", views.uploads_import_htmx, name="uploads_import_case_htmx"),
path("uploads/case/<int:case_id>", views.user_uploads, name="user_uploads_case"),
path("uploads/<int:user_pk>/user/case/<int:case_id>", views.other_user_uploads, name="other_user_uploads_case"),
path("uploads/series_id/<str:series_instance_uid>", views.user_uploads_series, name="user_uploads_series"),
@@ -159,11 +167,26 @@ urlpatterns = [
views.collection_history_user,
name="collection_history_user",
),
path(
"collection/<int:exam_id>/messages/<int:user_pk>/user",
views.collection_user_messages,
name="collection_user_messages",
),
path(
"collection/<int:exam_id>/history/<int:cid>/ciduser",
views.collection_history_ciduser,
name="collection_history_ciduser",
),
path(
"collection/<int:exam_id>/messages/<int:cid>/ciduser",
views.collection_cid_messages,
name="collection_cid_messages",
),
path(
"collection/<int:exam_id>/attempt/<int:cid_user_exam_id>/case/<int:case_id>/review-thread",
views.collection_case_review_thread,
name="collection_case_review_thread",
),
path(
"collection/<int:pk>/user_status",
@@ -182,15 +205,25 @@ urlpatterns = [
),
#path("collection/<int:pk>/<int:sk>/test", views.test_form, name="test_form"),
path(
"exam/<int:exam_id>/cids/edit",
"collection/<int:exam_id>/cids/edit",
views.GenericExamViews.exam_cids_edit,
name="exam_cids_edit",
),
path(
"exam/<int:exam_id>/users/edit",
"collection/<int:exam_id>/users/edit",
views.GenericExamViews.exam_users_edit,
name="exam_users_edit",
),
path(
"collection/<int:exam_id>/cid_bulk_edit",
views.GenericExamViews.exam_cid_bulk_edit,
name="exam_cid_bulk_edit",
),
path(
"collection/<int:exam_id>/user_bulk_edit",
views.GenericExamViews.exam_user_bulk_edit,
name="exam_user_bulk_edit",
),
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
path(
"collection/<int:exam_id>/case/<int:case_number>/details",
@@ -320,11 +353,21 @@ urlpatterns = [
views.collection_take_overview,
name="collection_take_overview",
),
path(
"collection/<int:pk>/feedback/<int:cid>/<str:passcode>",
views.collection_feedback_overview,
name="collection_feedback_overview",
),
path(
"collection/<int:pk>/take_overview",
views.collection_take_overview_user,
name="collection_take_overview_user",
),
path(
"collection/<int:pk>/feedback",
views.collection_feedback_overview_user,
name="collection_feedback_overview_user",
),
path(
"collection/<int:pk>/<int:case_number>",
views.collection_case_view,
@@ -419,6 +462,11 @@ urlpatterns = [
views.remove_selected_series_from_case,
name="remove_selected_series_from_case",
),
path(
"case/<int:case_pk>/series_move/",
views.move_selected_series_to_case,
name="move_selected_series_to_case",
),
path('case/<int:case_pk>/reorder_series/', views.reorder_series, name='reorder_series'),
path(
"series/<int:pk>/dicom_json",
@@ -568,6 +616,16 @@ urlpatterns = [
views.series_order_upload_filename,
name="series_order_upload_filename",
),
path(
"series/<int:pk>/split_by_tag",
views.series_split_by_tag,
name="series_split_by_tag",
),
path(
"series/<int:pk>/tag_consistency",
views.series_tag_consistency,
name="series_tag_consistency",
),
path(
"series/<int:pk>/delete",
views.SeriesDelete.as_view(),
@@ -706,4 +764,30 @@ urlpatterns = [
name="linked_cases_overview",
),
path("cases/by_size", views.cases_by_size, name="cases_by_size"),
# Sharing
path(
"exam/<int:pk>/sharing",
views.collection_exam_sharing_panel,
name="collection_exam_sharing_panel",
),
path(
"exam/<int:pk>/sharing/user-search",
views.collection_exam_user_search,
name="collection_exam_user_search",
),
path(
"collection/shared-with-me",
views.collection_shared_with_me,
name="collection_shared_with_me",
),
path(
"exam/<int:cid_user_exam_pk>/attempt-overview",
views.collection_shared_attempt_overview,
name="collection_shared_attempt_overview",
),
path(
"supervision/",
views.collection_supervision,
name="collection_supervision",
),
]
+2331 -80
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -14,13 +14,21 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-django}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-rad}
ports:
- "5432:5432"
volumes:
- postgres_test_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL","pg_isready -U ${POSTGRES_USER:-django}" ]
interval: 10s
interval: 5s
timeout: 5s
retries: 5
retries: 10
networks:
- test_net
networks:
test_net:
name: rad_test_net
volumes:
postgres_test_data:
-1
View File
@@ -24,7 +24,6 @@ def check_user_in_group(*groups: Group):
def decorator(function):
@wraps(function)
def wrapper(request, *args, **kwargs):
print(request.user)
if request.user.is_superuser or request.user.groups.filter(
name__in=[group.name for group in groups]
).exists():
+31
View File
@@ -835,6 +835,37 @@ class TraineeForm(Form):
class SupervisorForm(ModelForm):
# UserSearchWidget returns list-like values; this field normalises to a
# single selected user for the Supervisor.user OneToOne relation.
user = ModelChoiceField(
queryset=User.objects.all(),
required=False,
widget=UserSearchWidget(),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
user_field = self.fields.get("user")
if user_field:
user_field.widget = UserSearchWidget(user_field)
def clean_user(self):
value = self.cleaned_data.get("user")
if isinstance(value, (list, tuple)):
value = value[0] if value else None
if value is None:
return None
if isinstance(value, User):
return value
try:
return User.objects.get(pk=value)
except (TypeError, ValueError, User.DoesNotExist):
raise ValidationError("Select a valid user.")
class Meta:
model = Supervisor
exclude = ("",)
@@ -0,0 +1,23 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("generic", "0031_flag"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="ciduserexam",
name="shared_with_users",
field=models.ManyToManyField(
blank=True,
help_text="Users with whom this attempt has been manually shared.",
related_name="shared_cid_user_exams",
to=settings.AUTH_USER_MODEL,
),
),
]
+106 -6
View File
@@ -298,7 +298,7 @@ class SeriesImageBase(models.Model):
# The second is a blake3 hash of the direct pixel data
# This is much faster when using pydicom
image_blake3_hash = models.CharField(max_length=64, null=True, blank=True)
image_blake3_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)
is_dicom = models.BooleanField(default=False)
@@ -654,6 +654,8 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
e.g. user management
"""
class InactiveException(Exception):
pass
# Is this actually used? It is but should be replaced on the inherited class
cid_users = GenericRelation("generic.CidUserExam")
@@ -768,7 +770,9 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
Raises:
Http404: If user does not have access
"""
logger.debug(f"Checking user can take: {cid=}, passcode=****, user={user}, active_only={active_only}")
if user is not None and self.is_author(user):
logger.debug("User is author, allowing access")
return
# If we are limiting by dates check that the current date is within the range
@@ -778,24 +782,32 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
):
# Raise with an explanatory message so the frontend can
# surface the reason to the end user.
logger.debug("Exam is outside of allowed dates, denying access")
raise Http404("Exam not currently available (outside allowed dates)")
# If it is not active no one can take
elif active_only and not self.active:
raise Http404("Exam currently inactive")
logger.debug("Exam is not active, denying access")
raise self.InactiveException("Exam currently inactive")
# If candidates only check they have access
if self.candidates_only:
logger.debug("Exam is candidates only, checking CID/user access")
if not self.check_cid_user(cid, passcode, user):
logger.debug("CID/user access check failed, denying access")
raise Http404("Error accessing exam")
return
# Otherwise check that they are a valid user.
if user is None:
logger.debug("No user provided, denying access")
raise Http404("Error accessing exam")
if not user.is_active:
logger.debug("User is not active, denying access")
raise Http404("Error accessing exam")
logger.debug("User passed all checks, allowing access")
return
def check_cid_user(
@@ -808,7 +820,7 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
allow_authors: bool = True,
):
"""Checks if a user (cid or otherwise) is allowed to access (and take) the exam"""
print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}, {user=}, {user_id=}, {allow_authors=}")
logger.debug(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}, {user=}, {user_id=}, {allow_authors=}")
if user is not None and user.is_superuser:
return True
@@ -829,14 +841,16 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
return True
if not self.valid_cid_users.exists() and not self.valid_user_users.exists():
logger.debug("No valid CID or user users found")
return False
# Start by checking if the logged in user can access
logger.debug(f"Checking user access for user_id={user_id}")
if user_id is not None:
if self.valid_user_users.filter(pk=user_id).exists():
return True
else:
logger.debug("No valid user users found for user_id={user_id}")
# Then test CID data
if self.valid_cid_users.exists():
@@ -1667,6 +1681,92 @@ class CidUserExam(models.Model):
share_with_supervisor = models.BooleanField(default=False, help_text="If true the exam status and results will be available to a users supervisor")
shared_with_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="shared_cid_user_exams",
help_text="Users with whom this attempt has been manually shared.",
)
def get_sharing_info(self):
"""Return a structured dict describing who can view this attempt and why.
Returns a dict with keys:
always_visible_to list of User objects (authors + markers of the exam)
supervisor User or None (the supervisor's Django account, if applicable)
supervisor_enabled bool (CidUserExam.share_with_supervisor value)
exam_supervisor_visible bool (exam-level results_supervisor_visible flag)
manual_shares QuerySet of User objects (shared_with_users)
"""
exam = self.exam
always_visible = []
seen_pks = set()
for attr in ("author", "markers"):
if hasattr(exam, attr):
for u in getattr(exam, attr).all():
if u.pk not in seen_pks:
seen_pks.add(u.pk)
always_visible.append(u)
exam_supervisor_visible = getattr(exam, "results_supervisor_visible", False)
supervisor_user = None
if self.share_with_supervisor or exam_supervisor_visible:
if self.user_user_id:
try:
sup = self.user_user.userprofile.supervisor
if sup and sup.user_id:
supervisor_user = sup.user
except Exception:
pass
elif self.cid_user_id:
sup = self.cid_user.supervisor
if sup and sup.user_id:
supervisor_user = sup.user
return {
"always_visible_to": always_visible,
"supervisor": supervisor_user,
"supervisor_enabled": self.share_with_supervisor,
"exam_supervisor_visible": exam_supervisor_visible,
"manual_shares": self.shared_with_users.all(),
}
def can_user_view(self, user):
"""Check whether a given authenticated User may view this attempt.
Permitted if the user is:
- The attempt owner (user_user)
- An author or marker of the related exam/collection
- The supervisor of the attempt owner (when share_with_supervisor=True or
the exam-level results_supervisor_visible flag is set)
- Manually listed in shared_with_users
"""
if not user or not user.is_authenticated:
return False
if self.user_user_id and self.user_user_id == user.pk:
return True
exam = self.exam
for attr in ("author", "markers"):
if hasattr(exam, attr) and getattr(exam, attr).filter(pk=user.pk).exists():
return True
exam_supervisor_visible = getattr(exam, "results_supervisor_visible", False)
if self.share_with_supervisor or exam_supervisor_visible:
if self.user_user_id:
try:
sup = self.user_user.userprofile.supervisor
if sup and sup.user_id == user.pk:
return True
except Exception:
pass
elif self.cid_user_id:
sup = self.cid_user.supervisor
if sup and sup.user_id == user.pk:
return True
if self.shared_with_users.filter(pk=user.pk).exists():
return True
return False
def __str__(self) -> str:
if self.cid_user is None:
try:
@@ -2284,4 +2384,4 @@ class FindingBase(models.Model):
return get_pretty_json(json.loads(self.annotation_json))
def get_pretty_viewport_json(self):
return get_pretty_json(json.loads(self.viewport_json))
return get_pretty_json(json.loads(self.viewport_json))
@@ -1,4 +1,9 @@
<a href="{% url exam.get_app_name|add:':exam_cids' exam.id %}" title="Candidate overview">Overview</a>
\ <a href="{% url exam.get_app_name|add:':exam_cids_edit' exam.id %}" title="Edit the Exam CID candidates">Edit CIDs</a>
\ <a href="{% url exam.get_app_name|add:':exam_users_edit' exam.id %}" title="Edit the Exam User candidates">Edit User</a>
\ <a href="{% url exam.get_app_name|add:':exam_groups_edit' exam.id %}" title="Edit the Exam Groups">Edit Groups</a>
<nav class="mb-2">
<ul class="nav nav-pills">
<li class="nav-item"><span class="nav-link disabled" aria-disabled="true">Candidate Management</span></li>
<li class="nav-item"><a class="nav-link" href="{% url exam.get_app_name|add:':exam_cids' exam.id %}" title="Candidate overview">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url exam.get_app_name|add:':exam_cids_edit' exam.id %}" title="Edit the Exam CID candidates">Edit CIDs</a></li>
<li class="nav-item"><a class="nav-link" href="{% url exam.get_app_name|add:':exam_users_edit' exam.id %}" title="Edit the Exam User candidates">Edit User</a></li>
<li class="nav-item"><a class="nav-link" href="{% url exam.get_app_name|add:':exam_groups_edit' exam.id %}" title="Edit the Exam Groups">Edit Groups</a></li>
</ul>
</nav>
@@ -1,4 +1,5 @@
{% extends exam.get_app_name|add:'/exams.html' %}
{% load crispy_forms_tags %}
{% block content %}
<h1>Exam: {{exam}}</h1>
@@ -12,10 +13,8 @@
<form action="" method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
<input type="submit" value="Submit">
{{ form|crispy }}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
+35 -5
View File
@@ -11,18 +11,20 @@
{% endfor %}
</div>
{% else %}
<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>
<div class="small text-muted mt-1">This exam is not associated with any user groups. <a href="{% url app_name|add:':exam_groups_edit' exam.pk %}">Edit and add a group</a> to enable user management.</div>
{% endif %}
</div>
<div class="btn-group">
{% if current_user_users or available_user_users %}
<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>
<button class="btn btn-sm btn-outline-light toggle-all-btn" data-bulk-url="{% url app_name|add:':exam_user_bulk_edit' exam.pk %}">Toggle all</button>
{% endif %}
<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="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>
<button class="btn btn-sm btn-outline-success add-all-btn" data-bulk-url="{% url app_name|add:':exam_user_bulk_edit' exam.pk %}">Add all</button>
<button class="btn btn-sm btn-outline-danger remove-all-btn" data-bulk-url="{% url app_name|add:':exam_user_bulk_edit' exam.pk %}">Remove all</button>
</div>
</div>
<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">
@@ -36,6 +38,22 @@
</div>
</div>
<!-- Collapsible panel for adding arbitrary users (collapsed by default) -->
<div class="mt-3">
<p>
<a class="btn btn-sm btn-link" data-bs-toggle="collapse" href="#add-arbitrary-users-collapse" role="button" aria-expanded="false" aria-controls="add-arbitrary-users-collapse">Add arbitrary users</a>
</p>
<div class="collapse" id="add-arbitrary-users-collapse">
<div class="card card-body">
<label class="form-label small mb-1">Add arbitrary users</label>
{{ user_search_widget|default:''|safe }}
<div class="mt-2">
<button type="button" id="add-selected-users-btn" class="btn btn-sm btn-outline-success">Add selected users</button>
</div>
</div>
</div>
</div>
<script>
// Mirror the HTMX-enabled behavior used for CID users.
document.addEventListener('DOMContentLoaded', function(){
@@ -69,12 +87,24 @@
.finally(()=>{ if(triggeringButton) setLoading(triggeringButton, false); });
}
const defaultBulkUrl = "{% url exam.app_name|add:':exam_user_bulk_edit' exam.pk %}";
const defaultBulkUrl = "{% url 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); }); });
// Handler for adding arbitrary selected users from the search widget
const addSelectedBtn = document.getElementById('add-selected-users-btn');
if (addSelectedBtn){
addSelectedBtn.addEventListener('click', function(){
const sel = document.getElementById('id_add_users');
if(!sel) return;
const pks = Array.from(sel.options).map(o => o.value).filter(Boolean).map(Number);
if(!pks.length) return;
submitBulk(defaultBulkUrl, pks, true, addSelectedBtn);
});
}
// 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){} });
@@ -0,0 +1,27 @@
{% if results %}
<div class="card border-secondary bg-light">
<div class="card-body p-2">
<small class="text-muted d-block mb-2">{{ results|length }} result{{ results|pluralize }}</small>
<ul class="list-group list-group-sm">
{% for user in results %}
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<div>
<div class="small fw-semibold">{{ user.get_full_name|default:user.username }}</div>
<div class="text-muted" style="font-size: 0.85rem;">{{ user.email|default:"no email" }}</div>
</div>
<form method="post" action="{% url 'generic:supervisor_trainee_add' supervisor.pk user.pk %}" style="display:inline;">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-success"
hx-post="{% url 'generic:supervisor_trainee_add' supervisor.pk user.pk %}"
hx-target="#trainees-list-content"
hx-swap="outerHTML">
Add
</button>
</form>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
@@ -0,0 +1,55 @@
{% load django_htmx %}
{% if supervisor.trainee.all %}
<ul class="list-group list-group-flush">
{% for trainee in supervisor.trainee.all %}
{% with trainee_user=trainee.user %}
<li class="list-group-item px-0 py-3 trainee-row">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div class="fw-semibold">
{% if trainee_user %}
{{ trainee_user.get_full_name|default:trainee_user.username }}
{% else %}
{{ trainee }}
{% endif %}
</div>
<div class="text-muted small">
{% if trainee_user and trainee_user.email %}
{{ trainee_user.email }}
{% else %}
No email on file
{% endif %}
</div>
</div>
<div class="d-flex gap-1">
{% if trainee_user %}
<a href="{% url 'account_profile' trainee_user.username %}"
class="btn btn-sm btn-outline-secondary">
Profile
</a>
{% endif %}
{% if request.user == supervisor.user or request.user.is_superuser or request.user.groups.filter|length %}
<form method="post" action="{% url 'generic:supervisor_trainee_remove' supervisor.pk trainee_user.pk %}" style="display:inline;">
{% csrf_token %}
<button type="submit"
class="btn btn-sm btn-outline-danger"
hx-post="{% url 'generic:supervisor_trainee_remove' supervisor.pk trainee_user.pk %}"
hx-target="#trainees-list-content"
hx-swap="outerHTML"
hx-confirm="Remove {{ trainee_user.get_full_name|default:trainee_user.username }}?">
Remove
</button>
</form>
{% endif %}
</div>
</div>
</li>
{% endwith %}
{% endfor %}
</ul>
{% else %}
<div class="alert alert-secondary mb-0">
No trainees are currently linked to this supervisor.
</div>
{% endif %}
@@ -1,29 +1,103 @@
{% extends 'generic/supervisor_base.html' %}
{% block content %}
<h2>Supervisor: <span id="name">{{object.name}}</span></h2>
<span id="email">{{object.email}}</span>
<br/>User account: <span id="user">{{object.user}}</span>
<br/>Site: <span id="site">{{object.site}}</span>
<br/>Trainee(s): {% for trainee in object.trainee.all %}
<a href="{% url 'account_profile' trainee.user.username %}">{{trainee.user}}
{% endfor %}
<div><a href="{% url 'generic:supervisor_overview' pk=object.pk %}">Supervisor overview</a></div>
<div>
<a href="{% url 'generic:supervisor_edit' pk=object.pk %}"> Edit</a>
<a href="{% url 'generic:supervisor_delete' pk=object.pk %}"> Delete</a>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<h2 class="mb-1">{{ object.name }}</h2>
<p class="text-muted mb-0">
Supervisor profile and trainee links.
</p>
</div>
<div class="text-md-end">
<span class="badge bg-info-subtle text-info-emphasis rounded-pill px-3 py-2">
{{ object.trainee.count }} trainee{{ object.trainee.count|pluralize }}
</span>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-lg-7">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<h5 class="card-title mb-3">Supervisor Details</h5>
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">Name</dt>
<dd class="col-sm-8" id="name">{{ object.name|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">Email</dt>
<dd class="col-sm-8" id="email">
{% if object.email %}
<a href="mailto:{{ object.email }}" class="link-body-emphasis">{{ object.email }}</a>
{% else %}
-
{% endif %}
</dd>
<dt class="col-sm-4 text-muted">User Account</dt>
<dd class="col-sm-8" id="user">{{ object.user|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">Site</dt>
<dd class="col-sm-8" id="site">{{ object.site|default:"-" }}</dd>
</dl>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<h5 class="card-title mb-3">Actions</h5>
<div class="d-grid gap-2">
<a class="btn btn-outline-primary" href="{% url 'generic:supervisor_overview' pk=object.pk %}">
Supervisor Overview
</a>
{% if request.user.is_superuser or request.user.groups.filter|length %}
<a class="btn btn-outline-secondary" href="{% url 'generic:supervisor_edit' pk=object.pk %}">
Edit Supervisor
</a>
<a class="btn btn-outline-danger" href="{% url 'generic:supervisor_delete' pk=object.pk %}">
Delete Supervisor
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0">Trainees</h5>
<span class="text-muted small">{{ object.trainee.count }} linked</span>
</div>
{% if request.user == object.user or request.user.is_superuser or request.user.groups.filter|length %}
<div class="mb-3">
<label class="form-label small text-muted">Search to add trainee</label>
<input type="search"
class="form-control form-control-sm"
placeholder="Name, email, username…"
hx-get="{% url 'generic:supervisor_trainee_search' object.pk %}"
hx-target="#trainee-search-results"
hx-trigger="input delay:300ms"
name="q">
</div>
<div id="trainee-search-results" class="mb-3"></div>
{% endif %}
<div id="trainees-list-content">
{% include 'generic/partials/supervisor_trainees_list.html' with supervisor=object %}
</div>
</div>
</div>
{% endblock %}
{% block js %}
{% block css %}
{{ block.super }}
<style>
td, th { padding-left: 10px }
.trainee-row:not(:last-child) {
border-bottom: 1px solid var(--bs-border-color);
}
</style>
{% endblock %}
@@ -7,7 +7,7 @@
<form method="post">
{% csrf_token %}
<textarea name="data" rows="18" style="width:100%">{% if request.POST.data %}{{ request.POST.data }}{% endif %}</textarea>
<textarea name="data" rows="18" class="w-100">{% if request.POST.data %}{{ request.POST.data }}{% endif %}</textarea>
<div style="margin-top:8px">
<button type="submit" name="action" value="preview" class="btn btn-primary">Preview</button>
<button type="submit" name="action" value="apply" class="btn btn-danger" onclick="return confirm('This will apply updates to matched trainee profiles. Are you sure?')">Apply updates</button>
@@ -40,9 +40,9 @@
<div>
<span style="color:red">No match</span>
<div>
<div class="d-inline-flex align-items-center" style="gap:6px">
<form hx-get="{% url 'generic:user_search' %}" hx-target="#user-results-{{ r.row_index }}" hx-swap="innerHTML" onsubmit="return false;" class="d-inline-flex align-items-center">
<input type="text" name="q" id="user-search-input-{{ r.row_index }}" placeholder="Search users..." style="width:220px" />
<div class="inline-search-row">
<form hx-get="{% url 'generic:user_search' %}" hx-target="#user-results-{{ r.row_index }}" hx-swap="innerHTML" onsubmit="return false;" class="inline-search-row">
<input type="text" name="q" id="user-search-input-{{ r.row_index }}" class="form-control form-control-sm search-input-compact" placeholder="Search users..." />
<input type="hidden" name="row" value="{{ r.row_index }}" />
<button type="submit" class="btn btn-sm btn-secondary">Search</button>
</form>
@@ -59,9 +59,9 @@
{% else %}
<div>
<span style="color:red">No match</span>
<div class="d-inline-flex align-items-center" style="gap:6px">
<form hx-get="{% url 'generic:supervisor_search' %}" hx-target="#supervisor-results-{{ r.row_index }}" hx-swap="innerHTML" onsubmit="return false;" class="d-inline-flex align-items-center">
<input type="text" name="q" id="supervisor-search-input-{{ r.row_index }}" placeholder="Search supervisors..." style="width:220px" />
<div class="inline-search-row">
<form hx-get="{% url 'generic:supervisor_search' %}" hx-target="#supervisor-results-{{ r.row_index }}" hx-swap="innerHTML" onsubmit="return false;" class="inline-search-row">
<input type="text" name="q" id="supervisor-search-input-{{ r.row_index }}" class="form-control form-control-sm search-input-compact" placeholder="Search supervisors..." />
<input type="hidden" name="row" value="{{ r.row_index }}" />
<button type="submit" class="btn btn-sm btn-secondary">Search</button>
</form>
+15
View File
@@ -256,6 +256,21 @@ urlpatterns = [
views.SupervisorDelete.as_view(),
name="supervisor_delete",
),
path(
"supervisor/<int:pk>/trainee/search",
views.supervisor_trainee_search,
name="supervisor_trainee_search",
),
path(
"supervisor/<int:pk>/trainee/<int:user_id>/add",
views.supervisor_trainee_add,
name="supervisor_trainee_add",
),
path(
"supervisor/<int:pk>/trainee/<int:user_id>/remove",
views.supervisor_trainee_remove,
name="supervisor_trainee_remove",
),
path(
"examcollection/",
views.ExamCollectionList.as_view(),
+183 -52
View File
@@ -12,6 +12,7 @@ from django.db.models.functions import Coalesce, Lower
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required, user_passes_test
from django.views.decorators.http import require_POST
from django.urls import reverse_lazy
from django.urls.base import reverse
from django.utils import timezone
@@ -133,11 +134,14 @@ def cid_user_exam_partial(request, app_name: str, exam_id: int, cid: int):
return render(request, "generic/partials/ciduserexam_detail_partial.html", {"cid_user_exam": cux})
@login_required
def exam_collection_exams_partial(request, collection_id: int, exam_type: str):
"""Return the `exam-list` partial for a given collection and exam type.
"""Return the exam-list partial for a given collection and exam type.
This endpoint is intended for HTMX lazy-loading. It annotates candidate counts
and prefetches authors to avoid per-exam DB queries in the template.
Scope: authenticated users only.
Functionality: HTMX lazy-loading endpoint that returns the exam list partial
for a specific exam type within a collection. Annotates candidate counts and
prefetches authors to avoid N+1 DB queries in the template.
"""
collection = get_object_or_404(ExamCollection, pk=collection_id)
@@ -604,10 +608,16 @@ def examination_merge(request, pk):
)
@csrf_exempt
@login_required
def get_examination_id(request):
if request.accepts("application/json")():
examination_name = request.GET["examination_name"]
"""Return the primary key for an Examination looked up by name.
Scope: authenticated users only (admin popup helper).
Functionality: looks up an Examination by exact name and returns its ID
as JSON; used by inline admin form popups.
"""
if request.accepts("application/json"):
examination_name = request.GET.get("examination_name", "")
examination_id = Examination.objects.get(name=examination_name).id
data = {
"examination_id": examination_id,
@@ -974,7 +984,7 @@ class ExamViews(View, LoginRequiredMixin):
Returns:
[boolean]: True if the user has access
"""
print("****", user, exam_id)
logger.debug("check_user_access called for user={} exam_id={}", user, exam_id)
if user.is_superuser:
return True
@@ -2016,7 +2026,7 @@ class ExamViews(View, LoginRequiredMixin):
if request.htmx.prompt is not None:
additional_email = request.htmx.prompt
print(f"{additional_email=}")
logger.debug("exam_send_email: additional_email={}", additional_email)
# check addition email is valid
if additional_email:
@@ -2101,7 +2111,7 @@ class ExamViews(View, LoginRequiredMixin):
raise PermissionDenied
res = exam.generate_user_report(u)
print(res)
logger.debug("generate_user_report result for exam={}: {}", exam_id, res)
return HttpResponse(res)
@@ -2408,7 +2418,7 @@ class ExamViews(View, LoginRequiredMixin):
current_user_users
)
)
print(available_user_users)
logger.debug("available_user_users count: {}", len(available_user_users))
# available_user_users = User.objects.all()
context = {
@@ -2419,6 +2429,15 @@ class ExamViews(View, LoginRequiredMixin):
"app_name": self.app_name,
}
# Provide a searchable widget to allow adding arbitrary users
try:
from generic.widgets import UserSearchSelectMultipleWidget
widget_html = UserSearchSelectMultipleWidget("add_users", []).render()
context["user_search_widget"] = mark_safe(widget_html)
except Exception:
context["user_search_widget"] = ""
if self.app_name == "atlas":
context["collection"] = exam
@@ -2796,20 +2815,23 @@ class ExamViews(View, LoginRequiredMixin):
return response
@method_decorator(login_required)
def exam_user_bulk_edit(self, request, pk):
def exam_user_bulk_edit(self, request, pk=None, exam_id=None):
"""Bulk add/remove User objects to/from an exam. Expects POST with 'bulk_pks' (JSON list)
and 'add' ('true'/'false'). Returns a rendered user list partial for HTMX swaps.
"""
# Support both `pk` (generic urls) and `exam_id` (app-specific urls like atlas)
exam_pk = pk if pk is not None else exam_id
if request.method != 'POST':
return JsonResponse({'status': 'error, invalid method'}, status=400)
if request.user.groups.filter(name="cid_user_manager").exists():
pass
elif not self.check_user_edit_access(request.user, exam_id=pk):
elif not self.check_user_edit_access(request.user, exam_id=exam_pk):
data = {"status": "invalid permisions"}
return JsonResponse(data, status=403)
exam = get_object_or_404(self.Exam, pk=pk)
exam = get_object_or_404(self.Exam, pk=exam_pk)
bulk_pks_raw = request.POST.get('bulk_pks') or request.POST.get('bulk_pks[]')
items = []
@@ -2858,9 +2880,9 @@ class ExamViews(View, LoginRequiredMixin):
try:
if this_add:
app_exam_map[self.app_name].add(pk)
app_exam_map[self.app_name].add(exam_pk)
else:
app_exam_map[self.app_name].remove(pk)
app_exam_map[self.app_name].remove(exam_pk)
changed.append(user_obj.pk)
except Exception:
continue
@@ -2981,9 +3003,7 @@ class ExamViews(View, LoginRequiredMixin):
):
exams = self.Exam.objects.all()
filter = self.ExtraExamFilter(request.GET, queryset=exams)
print("1")
else:
print("2")
exams = self.Exam.objects.filter(
author__id=request.user.id
) | self.Exam.objects.filter(open_access=True)
@@ -3010,7 +3030,7 @@ class ExamViews(View, LoginRequiredMixin):
else:
raise Http404
print(answer)
logger.debug("exam_question_user_answer result: {}", answer)
return HttpResponse(answer)
@method_decorator(login_required)
@@ -3020,7 +3040,7 @@ class ExamViews(View, LoginRequiredMixin):
cid_user = CidUser.objects.filter(cid=cid)
answer = exam.get_question_cid_user_answer(sk, cid_user)
print(answer)
logger.debug("exam_question_cid_user_answer result: {}", answer)
@method_decorator(login_required)
def exam_question_detail(self, request, pk, sk):
@@ -3143,7 +3163,7 @@ class ExamViews(View, LoginRequiredMixin):
n = 0
for answer in json.loads(request.POST.get("answers")):
print("ANSWER", answer)
logger.debug("Processing answer: {}", answer)
eid = int(answer["eid"].split("/")[1])
uid = False
@@ -3353,19 +3373,17 @@ class ExamViews(View, LoginRequiredMixin):
raise Http404("No available exam")
# TODO: check access for logged in users
print("TEST", cid)
logger.debug("exam_question_json: cid={}", cid)
# Check access for users
if request.user.is_anonymous:
user_id = None
else:
user_id = request.user.pk
print(0)
if exam.exam_mode and not exam.check_cid_user(
cid, passcode, request.user, user_id
):
raise Http404("No available exam")
print(1)
# exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk))
@@ -3490,7 +3508,7 @@ class ExamViews(View, LoginRequiredMixin):
question = get_object_or_404(self.Question, pk=sk)
exam = get_object_or_404(self.Exam, pk=pk)
print("CID", cid)
logger.debug("exam_question_json_unbased: cid={}", cid)
if not exam.active and not self.check_user_access(request.user, pk):
raise Http404("No available exam")
@@ -3501,8 +3519,6 @@ class ExamViews(View, LoginRequiredMixin):
else:
user_id = request.user.pk
print("CID", cid)
if not exam.check_cid_user(cid, passcode, request.user, user_id):
raise Http404("No available exam")
@@ -3639,7 +3655,7 @@ class ExamViews(View, LoginRequiredMixin):
answers.append(ans)
answers_marks.append(answer_score)
print(q.get_questions(), ans, answer_score, correct_answer)
logger.debug("physics answer row: questions={} ans={} score={} correct={}", q.get_questions(), ans, answer_score, correct_answer)
merged_ans = zip(
q.get_questions(), ans, answer_score, correct_answer
)
@@ -4077,8 +4093,7 @@ class GenericViewBase:
"""
exam: ExamBase = get_object_or_404(self.exam_object, pk=pk)
print(request.POST["question_number"])
print(type(request.POST["question_number"]))
logger.debug("question_published_json: question_number={}", request.POST.get("question_number"))
if not exam.publish_results:
raise Http404
@@ -4404,7 +4419,6 @@ class GenericViewBase:
@method_decorator(user_passes_test(lambda u: u.is_superuser))
def user_answer_delete_multiple(self, request):
print(request.POST["answer_ids"])
answer_ids = json.loads(request.POST["answer_ids"])
# We could probably delete them all at once....
@@ -4463,13 +4477,11 @@ class GenericViewBase:
# TODO: improve permissions on these
@method_decorator(login_required)
def question_user_answers_by_compare(self, request, pk, answer_compare):
print(answer_compare)
answer_compare = urllib.parse.unquote(answer_compare)
return self.question_user_answers(request, pk, answer_compare)
@method_decorator(login_required)
def question_user_answers(self, request, pk, answer_compare=None):
print(locals())
question = get_object_or_404(self.question_object, pk=pk)
if answer_compare is None:
@@ -4494,8 +4506,7 @@ class GenericViewBase:
@method_decorator(login_required)
def author_detail(self, request, pk):
# logging.debug(Author.objects.all())
# author = get_object_or_404(Author, pk=pk)
author = User.objects.get(pk=pk)
author = get_object_or_404(User, pk=pk)
questions = self.question_object.objects.filter(author=pk)
@@ -4937,8 +4948,7 @@ def user_not_trainee(request, user_id):
@user_is_cid_user_manager
def users_bulk_edit(request):
print(request.htmx.trigger)
print(request.htmx.trigger_name)
logger.debug("users_bulk_edit: htmx trigger={} name={}", request.htmx.trigger, request.htmx.trigger_name)
try:
match request.htmx.trigger:
@@ -5307,12 +5317,14 @@ def create_cid_email(request):
@user_is_cid_user_manager
def cid_details(request, cid: int):
print(cid)
"""Return a brief display string for a CID user (HTMX partial).
Scope: cid_user_manager and superusers only.
Functionality: returns the CID user's name and email as plain text for
HTMX inline display.
"""
if request.htmx:
cid_user = get_object_or_404(CidUser, cid=cid)
print(cid_user.name)
return HttpResponse(f"{cid_user.name} ({cid_user.email})")
raise PermissionDenied() # or Http404
@@ -5441,7 +5453,7 @@ def manage_cid_users(request):
except ValidationError as e:
invalid_emails.append(f"Invalid email: {email}")
print(f"Email list {email_list}")
logger.debug("bulk_create_cid_users: email_list count={}", len(email_list))
if not email_list:
return JsonResponse({"status": "fail", "details": "No valid emails"})
if invalid_emails or not email_list:
@@ -5616,9 +5628,6 @@ class ExamCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs):
# Call the base implementation first to get a context
context = super().get_context_data(**kwargs)
print(dir(context["view"]))
print(context["view"].template_name_suffix)
print(context["view"].model.app_name)
return context
def get_form_kwargs(self):
@@ -5898,9 +5907,131 @@ def create_user(request, context=None, trainee: bool = False):
return render(request, form_template, {"form": form})
class SupervisorDetail(CidManagerRequiredMixin, DetailView):
class SupervisorDetail(DetailView):
model = Supervisor
def get_queryset(self):
"""Allow supervisors to view their own detail page; managers see all."""
qs = super().get_queryset()
if not self.request.user.is_authenticated:
return qs.none()
if self.request.user.is_superuser or self.request.user.groups.filter(name="cid_user_manager").exists():
return qs
if hasattr(self.request.user, "supervisor"):
return qs.filter(user=self.request.user)
return qs.none()
def get_object(self, queryset=None):
"""Override to check permissions before returning object."""
obj = super().get_object(queryset)
if not (
self.request.user.is_superuser
or self.request.user.groups.filter(name="cid_user_manager").exists()
or (hasattr(self.request.user, "supervisor") and obj.user == self.request.user)
):
raise PermissionDenied
return obj
@user_is_cid_user_manager
def supervisor_trainee_search(request, pk):
"""HTMX endpoint: search for users to add as trainees to a supervisor.
Scope: managers and the supervisor themselves.
"""
supervisor = get_object_or_404(Supervisor, pk=pk)
if not (
request.user.is_superuser
or request.user.groups.filter(name="cid_user_manager").exists()
or supervisor.user == request.user
):
raise PermissionDenied
q = request.GET.get("q", "").strip()
if not q:
return render(
request,
"generic/partials/supervisor_trainee_search_results.html",
{"supervisor": supervisor, "results": []},
)
already_trainees = supervisor.trainee.values_list("user_id", flat=True)
results = (
User.objects.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(username__icontains=q)
| Q(email__icontains=q)
)
.exclude(pk__in=already_trainees)
.order_by("first_name", "last_name", "username")[:20]
)
return render(
request,
"generic/partials/supervisor_trainee_search_results.html",
{"supervisor": supervisor, "results": results},
)
@require_POST
@user_is_cid_user_manager
def supervisor_trainee_add(request, pk, user_id):
"""Add a trainee to a supervisor.
Scope: managers and the supervisor themselves.
"""
supervisor = get_object_or_404(Supervisor, pk=pk)
trainee_user = get_object_or_404(User, pk=user_id)
if not (
request.user.is_superuser
or request.user.groups.filter(name="cid_user_manager").exists()
or supervisor.user == request.user
):
raise PermissionDenied
profile = trainee_user.userprofile
profile.supervisor = supervisor
profile.save()
if request.htmx:
return render(
request,
"generic/partials/supervisor_trainees_list.html",
{"supervisor": supervisor},
)
return redirect(supervisor.get_absolute_url())
@require_POST
@user_is_cid_user_manager
def supervisor_trainee_remove(request, pk, user_id):
"""Remove a trainee from a supervisor.
Scope: managers and the supervisor themselves.
"""
supervisor = get_object_or_404(Supervisor, pk=pk)
trainee_user = get_object_or_404(User, pk=user_id)
if not (
request.user.is_superuser
or request.user.groups.filter(name="cid_user_manager").exists()
or supervisor.user == request.user
):
raise PermissionDenied
profile = trainee_user.userprofile
if profile.supervisor == supervisor:
profile.supervisor = None
profile.save()
if request.htmx:
return render(
request,
"generic/partials/supervisor_trainees_list.html",
{"supervisor": supervisor},
)
return redirect(supervisor.get_absolute_url())
class SupervisorDelete(CidManagerRequiredMixin, DeleteView):
model = Supervisor
@@ -6343,10 +6474,6 @@ def supervisor_trainee(request, pk, trainee_id):
exams = get_user_exams(trainee, supervisor_view=True)
print(exams)
print(trainee)
return render(
request,
"generic/supervisor_trainee.html",
@@ -6407,13 +6534,17 @@ def supervisor_request_account(request):
return render(request, "generic/supervisor_request_account.html", {})
@login_required
def toggle_share_with_supervisor(request, pk):
"""Toggle the share_with_supervisor flag on a CidUserExam record.
Scope: authenticated owner of the CidUserExam record only.
Functionality: HTMX endpoint that toggles whether the exam result is
shared with the user's supervisor.
"""
if request.htmx:
cid_user_exam = get_object_or_404(CidUserExam, pk=pk)
print(request.user)
print(cid_user_exam)
print(cid_user_exam.user_user)
if request.user != cid_user_exam.user_user:
return HttpResponse("Incorrect permissions")
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-05-11 11:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('longs', '0034_exam_created_exam_updated'),
]
operations = [
migrations.AlterField(
model_name='longseriesimage',
name='image_blake3_hash',
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
),
]
+15 -13
View File
@@ -18,10 +18,10 @@
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'longs:index' %}">Longs</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#longsNavbarNav" aria-controls="longsNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<div class="collapse navbar-collapse" id="longsNavbarNav">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
@@ -49,17 +49,19 @@
</div>
</div>
</nav>
Longs:
{% if request.user.is_authenticated %}
<a href="{% url 'longs:exam_list' %}">Exams</a> /
<a href="{% url 'longs:exam_create' %}" title="Create a new exam">Create Exam</a> /
<a href="{% url 'longs:question_view' %}">Cases</a> /
<a href="{% url 'longs:series_view' %}">Series</a> /
<a href="{% url 'longs:question_create' %}" title="Create a new long case">Create Case</a> /
{% endif %}
{% if request.user.is_superuser %}
/ <a href="{% url 'longs:user_answer_table_view' %}" title="User answers">Answers</a>
{% endif %}
<div class="small text-body-secondary d-none d-lg-block mt-1">
Longs:
{% if request.user.is_authenticated %}
<a href="{% url 'longs:exam_list' %}">Exams</a> /
<a href="{% url 'longs:exam_create' %}" title="Create a new exam">Create Exam</a> /
<a href="{% url 'longs:question_view' %}">Cases</a> /
<a href="{% url 'longs:series_view' %}">Series</a> /
<a href="{% url 'longs:question_create' %}" title="Create a new long case">Create Case</a> /
{% endif %}
{% if request.user.is_superuser %}
/ <a href="{% url 'longs:user_answer_table_view' %}" title="User answers">Answers</a>
{% endif %}
</div>
{% comment %} </br>
Questions by:
<span id="authors-link"><a href="{% url 'longs:author_list' %}">author</a></span> {% endcomment %}
+13 -8
View File
@@ -1,6 +1,7 @@
import json
from django.shortcuts import render, get_object_or_404, redirect
from django import forms
from loguru import logger
# from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test
@@ -434,8 +435,7 @@ class LongCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
.prefetch_related("long")
.filter(pk=62)
) # .values()
print("TEST")
print(queryset)
logger.debug("LongCreateBase context: queryset count={}", queryset.count())
# queryset = LongSeries.objects.all()
if self.request.POST:
context["series_formset"] = SeriesFormSet(
@@ -552,7 +552,7 @@ class LongUpdate(
# restore exam orders
for exam in self.object.exams.all():
if exam in exam_orders and self.object in exam_orders[exam]:
print(exam_orders[exam])
logger.debug("Restoring exam order for exam={}", exam)
exam.exam_questions.set(exam_orders[exam])
exam.save()
@@ -614,10 +614,16 @@ def create_examination(request):
)
@csrf_exempt
@login_required
def get_examination_id(request):
if request.accepts("application/json")():
examination_name = request.GET["examination_name"]
"""Return the primary key for an Examination looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up an Examination by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
examination_name = request.GET.get("examination_name", "")
examination_id = Examination.objects.get(name=examination_name).id
data = {
"examination_id": examination_id,
@@ -1001,9 +1007,8 @@ class ExamGroupsUpdate(ExamGroupsUpdateBase):
def question_json_unbased(request, pk):
"""
No (file based) caching is enabled for unbased quesitons
No (file based) caching is enabled for unbased questions.
"""
print("UNBASED")
question = get_object_or_404(Long, pk=pk)
question_json = question.get_question_json(based=False)
-1
View File
@@ -87,7 +87,6 @@ def output(request):
questions = format.format.strip().split("---")
for question in questions:
print(question)
if not question:
continue
+2 -2
View File
@@ -8,10 +8,10 @@
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'physics:index' %}">Physics</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#physicsNavbarNav" aria-controls="physicsNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<div class="collapse navbar-collapse" id="physicsNavbarNav">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
-1
View File
@@ -97,7 +97,6 @@ def question_detail(request, pk):
@login_required
def active_exams(request):
exams = Exam.objects.all()
print(exams)
active_exams = []
+1 -1
View File
@@ -1,4 +1,4 @@
[pytest]
DJANGO_SETTINGS_MODULE=rad.settings
DJANGO_SETTINGS_MODULE=rad.settings_test
python_files = tests.py test_*.py *_tests.py
addopts = --nomigrations
+2 -1
View File
@@ -19,7 +19,8 @@
"/^python -m py_compile /home/ross/penracourses/rad/generic/tables\\.py$/": {
"approve": true,
"matchCommandLine": true
}
},
"/home/ross/penracourses/rad/.venv/bin/python": true
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import os
from .settings import *
# Test environment should be deterministic and isolated from remote services.
DEBUG = False
# Local Postgres defaults align with docker-compose.test.yml.
DATABASES["default"] = {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("TEST_DB_NAME", os.environ.get("POSTGRES_DB", "rad")),
"USER": os.environ.get("TEST_DB_USER", os.environ.get("POSTGRES_USER", "django")),
"PASSWORD": os.environ.get("TEST_DB_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "postgres")),
"HOST": os.environ.get("TEST_DB_HOST", "127.0.0.1"),
"PORT": os.environ.get("TEST_DB_PORT", "5432"),
"TEST": {
"NAME": os.environ.get("TEST_DB_TEST_NAME", "test_rad"),
},
}
# Keep tests fast and avoid external side effects.
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
CACHES["default"] = {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}
+98
View File
@@ -0,0 +1,98 @@
/* Shared responsive helpers for layout, forms, cards, and viewer surfaces. */
.summary-card {
width: 100%;
max-width: 22rem;
}
@media (min-width: 576px) {
.summary-card {
width: auto;
min-width: 17.5rem;
}
}
.form-control-max-300 {
width: 100%;
max-width: 300px;
}
.form-control-max-320 {
width: 100%;
max-width: 320px;
}
.search-input-compact {
width: min(100%, 220px);
}
.inline-search-row {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.viewer-frame-setup {
box-sizing: border-box;
background: #222;
width: 100%;
max-width: 1600px;
height: min(72vh, 800px);
min-height: 420px;
}
.viewer-frame-standard {
box-sizing: border-box;
background: #222;
width: 100%;
height: min(68vh, 600px);
min-height: 360px;
}
.viewer-frame-ohif {
width: 100%;
height: 100%;
border: none;
padding: 0;
min-height: min(70vh, 700px);
}
.viewer-preupload-shell {
position: relative;
min-height: clamp(320px, 55vh, 450px);
}
.viewer-preupload-root {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.modal-wide-md {
max-width: min(700px, 96vw);
}
.modal-wide-xl {
max-width: min(1200px, 96vw);
}
.progress-compact {
height: 8px;
}
.progress-compact-sm {
height: 10px;
}
.flex-max-320 {
flex: 1;
max-width: 320px;
}
@media (max-width: 575.98px) {
.flex-max-320 {
max-width: 100%;
}
}
+30
View File
@@ -318,6 +318,36 @@ p.url {
font-weight: bold;
}
@media (max-width: 767.98px) {
.selector,
.stacked {
width: 100%;
max-width: 100%;
}
.selector-available,
.selector-chosen,
.stacked .selector-available,
.stacked .selector-chosen {
width: 100%;
float: none;
}
.selector select,
.selector .selector-available input,
.stacked select,
.stacked .selector-available input {
width: 100%;
max-width: 100%;
}
.selector ul.selector-chooser,
.stacked ul.selector-chooser {
margin: .5rem auto;
float: none;
}
}
.url a {
font-weight: normal;
}
+188 -188
View File
File diff suppressed because one or more lines are too long
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import pytest
from django.contrib.auth.models import Group
from django.urls import reverse
@pytest.fixture
def cid_manager_user(db, django_user_model):
user = django_user_model.objects.create_user(
username="cid_manager",
email="cid_manager@example.com",
password="password123",
)
group, _ = Group.objects.get_or_create(name="cid_user_manager")
user.groups.add(group)
return user
@pytest.mark.django_db
def test_media_cleanup_requires_login(client):
response = client.get(reverse("media_cleanup"))
assert response.status_code == 302
@pytest.mark.django_db
def test_media_cleanup_requires_manager_group(client, django_user_model):
user = django_user_model.objects.create_user(
username="regular_user",
email="regular@example.com",
password="password123",
)
client.force_login(user)
response = client.get(reverse("media_cleanup"))
assert response.status_code == 403
@pytest.mark.django_db
def test_media_cleanup_page_for_manager(client, cid_manager_user):
client.force_login(cid_manager_user)
response = client.get(reverse("media_cleanup"))
assert response.status_code == 200
assert b"Unused Media Cleanup" in response.content
assert b"Cleanup in progress" in response.content
@pytest.mark.django_db
def test_media_cleanup_preview_uses_dry_run(client, cid_manager_user, monkeypatch):
client.force_login(cid_manager_user)
captured = {}
def fake_call_command(name, **kwargs):
captured["name"] = name
captured["kwargs"] = kwargs
kwargs["stdout"].write("preview ok")
monkeypatch.setattr("rad.views.call_command", fake_call_command)
response = client.post(
reverse("media_cleanup"),
{
"action": "preview",
"minimum_file_age": "30",
"remove_empty_dirs": "on",
"exclude": "thumbnails/*\ncache/*",
},
HTTP_HX_REQUEST="true",
)
assert response.status_code == 200
assert captured["name"] == "cleanup_unused_media"
assert captured["kwargs"]["dry_run"] is True
assert captured["kwargs"]["no_input"] is True
assert captured["kwargs"]["interactive"] is False
assert captured["kwargs"]["minimum_file_age"] == 30
assert captured["kwargs"]["remove_empty_dirs"] is True
assert captured["kwargs"]["exclude"] == ["thumbnails/*", "cache/*"]
assert b"preview ok" in response.content
@pytest.mark.django_db
def test_media_cleanup_live_run_disables_dry_run(client, cid_manager_user, monkeypatch):
client.force_login(cid_manager_user)
captured = {}
def fake_call_command(name, **kwargs):
captured["name"] = name
captured["kwargs"] = kwargs
kwargs["stdout"].write("cleanup ok")
monkeypatch.setattr("rad.views.call_command", fake_call_command)
response = client.post(
reverse("media_cleanup"),
{
"action": "cleanup",
"minimum_file_age": "0",
},
HTTP_HX_REQUEST="true",
)
assert response.status_code == 200
assert captured["name"] == "cleanup_unused_media"
assert captured["kwargs"]["dry_run"] is False
assert captured["kwargs"]["interactive"] is False
assert b"cleanup ok" in response.content
assert b"Recovered space" in response.content
assert b"removed" in response.content
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from uuid import UUID
import pytest
from django.contrib.auth import get_user_model
from django.test import Client
from django.urls import URLPattern, URLResolver, get_resolver, reverse
SAFE_STATUS_CODES = {
200,
201,
202,
204,
301,
302,
303,
304,
307,
308,
400,
401,
403,
404,
405,
}
SKIP_NAMES = {
"admin:index", # admin app has its own dedicated tests and redirects heavily.
}
SKIP_NAMESPACES = {
"admin",
"tinymce",
"cookies",
}
STANDARD_NAME_HINTS = {
"index",
"home",
"help",
"about",
"privacy",
"stats",
"list",
"detail",
"view",
"overview",
"profile",
"inbox",
"messages",
"people",
"server",
"logs",
"trainees",
"normals",
"categories",
"search_page",
}
NON_STANDARD_NAME_HINTS = {
"create",
"update",
"delete",
"remove",
"add",
"edit",
"toggle",
"submit",
"save",
"bulk",
"json",
"dicom",
"viewer",
"download",
"upload",
"anonymise",
"migrate",
"truncate",
"image_size",
"series",
"mark",
"review",
"sync",
"reset",
"api",
}
@dataclass(frozen=True)
class NamedRoute:
name: str
kwargs: dict[str, object]
def _default_for_converter(converter_name: str) -> object:
if converter_name == "int":
return 1
if converter_name == "str":
return "smoke"
if converter_name == "slug":
return "smoke"
if converter_name == "uuid":
return UUID("00000000-0000-0000-0000-000000000001")
if converter_name == "path":
return "smoke/path"
return "smoke"
def _iter_named_patterns(
patterns: Iterable[URLPattern | URLResolver],
namespace: str = "",
) -> Iterable[NamedRoute]:
for pattern in patterns:
if isinstance(pattern, URLResolver):
nested_namespace = namespace
if pattern.namespace:
nested_namespace = f"{namespace}:{pattern.namespace}" if namespace else pattern.namespace
yield from _iter_named_patterns(pattern.url_patterns, nested_namespace)
continue
if not pattern.name:
continue
full_name = f"{namespace}:{pattern.name}" if namespace else pattern.name
converters = getattr(pattern.pattern, "converters", {})
kwargs = {key: _default_for_converter(converter.__class__.__name__.replace("Converter", "").lower()) for key, converter in converters.items()}
yield NamedRoute(name=full_name, kwargs=kwargs)
def _all_named_routes() -> list[NamedRoute]:
resolver = get_resolver()
routes = list(_iter_named_patterns(resolver.url_patterns))
unique_routes: dict[tuple[str, tuple[tuple[str, object], ...]], NamedRoute] = {}
for route in routes:
key = (route.name, tuple(sorted(route.kwargs.items())))
unique_routes[key] = route
return sorted(unique_routes.values(), key=lambda route: (route.name, sorted(route.kwargs)))
def _is_standard_route(route_name: str) -> bool:
parts = route_name.split(":")
namespace = parts[0] if len(parts) > 1 else ""
leaf_name = parts[-1]
if namespace in SKIP_NAMESPACES:
return False
if route_name in SKIP_NAMES:
return False
if any(hint in leaf_name for hint in NON_STANDARD_NAME_HINTS):
return False
return any(hint in leaf_name for hint in STANDARD_NAME_HINTS)
ALL_NAMED_ROUTES = _all_named_routes()
STANDARD_NAMED_ROUTES = [route for route in ALL_NAMED_ROUTES if _is_standard_route(route.name)]
@pytest.fixture
def smoke_client(db) -> Client:
user_model = get_user_model()
user = user_model.objects.create_user(
username="view_smoke_user",
password="testpassword",
email="view_smoke_user@example.com",
)
client = Client()
client.raise_request_exception = False
client.force_login(user)
return client
@pytest.mark.django_db
@pytest.mark.parametrize("route", STANDARD_NAMED_ROUTES, ids=lambda route: route.name)
def test_named_view_routes_do_not_return_server_errors(smoke_client: Client, route: NamedRoute):
if route.name in SKIP_NAMES:
pytest.skip("Route is intentionally excluded from this smoke suite.")
try:
url = reverse(route.name, kwargs=route.kwargs or None)
except Exception as exc: # pragma: no cover
pytest.skip(f"Unable to reverse route {route.name}: {exc}")
response = smoke_client.get(url, follow=False)
assert response.status_code in SAFE_STATUS_CODES, (
f"Unexpected status for {route.name} ({url}): {response.status_code}"
)
@pytest.mark.django_db
@pytest.mark.parametrize("route", STANDARD_NAMED_ROUTES, ids=lambda route: route.name)
def test_named_view_routes_anonymous_do_not_return_server_errors(route: NamedRoute):
if route.name in SKIP_NAMES:
pytest.skip("Route is intentionally excluded from this smoke suite.")
try:
url = reverse(route.name, kwargs=route.kwargs or None)
except Exception as exc: # pragma: no cover
pytest.skip(f"Unable to reverse route {route.name}: {exc}")
client = Client()
client.raise_request_exception = False
response = client.get(url, follow=False)
assert response.status_code in SAFE_STATUS_CODES, (
f"Unexpected anonymous status for {route.name} ({url}): {response.status_code}"
)
+7
View File
@@ -93,12 +93,19 @@ urlpatterns = [
name="account_profile_update",
),
path("server", views.server, name="server"),
path("logs/", views.logs_view, name="logs_view"),
path("management/media-cleanup/", views.media_cleanup, name="media_cleanup"),
path("people", views.people, name="people"),
path("accounts/", views.UserListTableView.as_view(), name="accounts_list"),
#path("accounts/", views.UserListView.as_view(), name="accounts_list"),
path("accounts/", include("django.contrib.auth.urls")),
path("accounts/profile", views.profile, name="profile"),
path("accounts/profile/<str:slug>/", views.account_profile, name="account_profile"),
path(
"accounts/profile/<str:slug>/set-supervisor/",
views.account_set_supervisor,
name="account_set_supervisor",
),
#path("", TemplateView.as_view(template_name="index.html"), name="home"),
path("", views.index, name="home"),
path("cid/results/<int:cid>/", views.cid_results, name="cid_results"),
+367 -24
View File
@@ -1,5 +1,8 @@
import secrets
from typing import Any, Optional
import os
import re
from io import StringIO
from django.conf import settings
from django_tables2 import SingleTableMixin
@@ -45,6 +48,8 @@ from django.urls import reverse_lazy, reverse
from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.views.decorators.http import require_POST
from django.core.management import call_command, CommandError
from physics.models import UserAnswer as PhysicsUserAnswer
from physics.models import Exam as PhysicsExam
@@ -104,6 +109,23 @@ import psutil
def _media_file_inventory(path: str) -> dict[str, int]:
"""Return a mapping of file path to byte size for files under a directory."""
if not path or not os.path.exists(path):
return {}
inventory: dict[str, int] = {}
for root, _, files in os.walk(path):
for filename in files:
file_path = os.path.join(root, filename)
try:
inventory[file_path] = os.path.getsize(file_path)
except OSError:
continue
return inventory
def feedback_checker(user):
return user.groups.filter(name="feedback_checker").exists()
@@ -120,6 +142,12 @@ def people(request):
@login_required
def profile(request):
"""Render the current user's profile page with group management context.
Scope: authenticated user's own profile view.
Functionality: provides user details, available groups, and supervisor
account state for template actions.
"""
user = request.user
available_groups = UserUserGroup.objects.filter(archive=False).exclude(
pk__in=user.user_groups.values_list("pk", flat=True)
@@ -128,6 +156,7 @@ def profile(request):
pk__in=user.groups.values_list("pk", flat=True)
)
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
supervisor_account = Supervisor.objects.filter(user=user).first()
return render(
request,
"profile.html",
@@ -136,6 +165,8 @@ def profile(request):
"available_groups": available_groups,
"available_auth_groups": available_auth_groups,
"is_cid_user_manager": is_cid_user_manager,
"supervisor_account": supervisor_account,
"supervisor_action": request.GET.get("supervisor_action", ""),
},
)
@@ -160,6 +191,12 @@ def index(request):
@user_is_cid_user_manager
def account_profile(request, slug):
"""Render an account profile page for administrators.
Scope: cid_user_manager and superusers viewing another user's profile.
Functionality: exposes profile details, group controls, and supervisor
account creation/link status for admin actions.
"""
user = get_object_or_404(User, username=slug)
available_groups = UserUserGroup.objects.filter(archive=False).exclude(
pk__in=user.user_groups.values_list("pk", flat=True)
@@ -168,6 +205,7 @@ def account_profile(request, slug):
pk__in=user.groups.values_list("pk", flat=True)
)
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
supervisor_account = Supervisor.objects.filter(user=user).first()
return render(
request,
"profile.html",
@@ -176,10 +214,309 @@ def account_profile(request, slug):
"available_groups": available_groups,
"available_auth_groups": available_auth_groups,
"is_cid_user_manager": is_cid_user_manager,
"supervisor_account": supervisor_account,
"supervisor_action": request.GET.get("supervisor_action", ""),
},
)
@require_POST
@user_is_cid_user_manager
def account_set_supervisor(request, slug):
"""Create or link a Supervisor record for an existing user.
Scope: cid_user_manager and superusers only.
Functionality: one-click action from profile pages to assign a user as a
supervisor account, reusing an existing supervisor by email when safe.
"""
user = get_object_or_404(User, username=slug)
next_url = request.POST.get("next") or reverse("account_profile", kwargs={"slug": user.username})
email = (user.email or "").strip().lower()
if not email:
return redirect(f"{next_url}?supervisor_action=no_email")
existing_for_user = Supervisor.objects.filter(user=user).first()
if existing_for_user:
return redirect(f"{next_url}?supervisor_action=already_linked")
supervisor = Supervisor.objects.filter(email__iexact=email).first()
if supervisor:
if supervisor.user and supervisor.user != user:
return redirect(f"{next_url}?supervisor_action=email_in_use")
supervisor.user = user
if not supervisor.name:
supervisor.name = user.get_full_name().strip() or user.username
supervisor.save()
return redirect(f"{next_url}?supervisor_action=linked")
name = user.get_full_name().strip() or user.username
Supervisor.objects.create(email=email, name=name, user=user)
return redirect(f"{next_url}?supervisor_action=created")
@user_passes_test(lambda u: u.is_superuser)
def logs_view(request):
"""Display production logs from log.txt with search filtering.
Scope: superusers only.
Functionality: reads log.txt, displays entries with optional text search filter.
Supports ?all=1 to show all entries without pagination limit.
"""
log_file_path = os.path.join(settings.BASE_DIR, "log.txt")
search_query = request.GET.get("q", "").strip()
level_filter = request.GET.get("level", "").strip().upper()
show_all = request.GET.get("all") == "1"
raw_mode = request.GET.get("raw") == "1"
include_noise = request.GET.get("include_noise") == "1"
if raw_mode:
if not os.path.exists(log_file_path):
return HttpResponse(
f"Log file not found at {log_file_path}\n",
content_type="text/plain; charset=utf-8",
status=404,
)
try:
with open(log_file_path, "r", encoding="utf-8", errors="ignore") as f:
raw_content = f.read()
except Exception as e:
return HttpResponse(
f"Error reading log file: {str(e)}\n",
content_type="text/plain; charset=utf-8",
status=500,
)
return HttpResponse(raw_content, content_type="text/plain; charset=utf-8")
log_header_re = re.compile(
r"^\[(?P<timestamp>[^\]]+)\]\s+(?P<level>[A-Z]+)\s+\[(?P<logger>[^:\]]+):(?P<line>\d+)\]\s+(?P<message>.*)$"
)
parsed_entries: list[dict[str, Any]] = []
if os.path.exists(log_file_path):
try:
with open(log_file_path, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
current_entry = None
for raw_line in lines:
line = raw_line.rstrip("\n")
if not line.strip():
if current_entry is not None:
current_entry["details"].append("")
continue
header_match = log_header_re.match(line)
if header_match:
if current_entry is not None:
parsed_entries.append(current_entry)
current_entry = {
"timestamp": header_match.group("timestamp"),
"level": header_match.group("level"),
"logger": header_match.group("logger"),
"source_line": header_match.group("line"),
"message": header_match.group("message"),
"details": [],
}
continue
if current_entry is not None:
current_entry["details"].append(line)
else:
parsed_entries.append(
{
"timestamp": "",
"level": "RAW",
"logger": "",
"source_line": "",
"message": line,
"details": [],
}
)
if current_entry is not None:
parsed_entries.append(current_entry)
except Exception as e:
parsed_entries = [
{
"timestamp": "",
"level": "ERROR",
"logger": "logs_view",
"source_line": "",
"message": f"Error reading log file: {str(e)}",
"details": [],
}
]
else:
parsed_entries = [
{
"timestamp": "",
"level": "ERROR",
"logger": "logs_view",
"source_line": "",
"message": f"Log file not found at {log_file_path}",
"details": [],
}
]
# Most recent first.
parsed_entries = list(reversed(parsed_entries))
available_levels = sorted({entry["level"] for entry in parsed_entries if entry["level"]})
filtered_entries = []
search_lower = search_query.lower()
noise_suppressed = 0
for entry in parsed_entries:
# Django template debug logs can be very noisy and usually do not indicate
# fatal issues. Hide them by default, with an explicit opt-in toggle.
is_template_debug_noise = (
entry.get("level") == "DEBUG"
and entry.get("logger") == "django.template"
and (
"Exception while resolving variable" in entry.get("message", "")
or any("VariableDoesNotExist" in d for d in entry.get("details", []))
)
)
if is_template_debug_noise and not include_noise:
noise_suppressed += 1
continue
if level_filter and entry["level"] != level_filter:
continue
haystack = "\n".join(
[
entry.get("timestamp", ""),
entry.get("level", ""),
entry.get("logger", ""),
entry.get("message", ""),
"\n".join(entry.get("details", [])),
]
).lower()
if search_lower and search_lower not in haystack:
continue
filtered_entries.append(entry)
total = len(filtered_entries)
limit = None if show_all else 500
displayed_entries = filtered_entries[:limit] if limit else filtered_entries
return render(
request,
"logs_viewer.html",
{
"entries": displayed_entries,
"total_entries": total,
"search_query": search_query,
"level_filter": level_filter,
"available_levels": available_levels,
"log_file_path": log_file_path,
"show_all": show_all,
"limit": limit,
"raw_url": f"{reverse('logs_view')}?raw=1",
"include_noise": include_noise,
"noise_suppressed": noise_suppressed,
},
)
@login_required
@user_is_cid_user_manager
def media_cleanup(request):
"""Run django-unused-media cleanup from a manager-facing maintenance page.
Scope: cid_user_manager group members and superusers.
Functionality: supports safe preview (dry-run) and confirmed deletion runs,
returning HTMX fragments for inline results.
"""
context: dict[str, Any] = {
"ran": False,
"action": "preview",
"minimum_file_age": 60,
"remove_empty_dirs": True,
"exclude": "",
}
if request.method == "POST":
action = request.POST.get("action", "preview")
dry_run = action != "cleanup"
exclude_raw = (request.POST.get("exclude") or "").strip()
exclude = [line.strip() for line in exclude_raw.splitlines() if line.strip()]
remove_empty_dirs = request.POST.get("remove_empty_dirs") == "on"
min_age_raw = (request.POST.get("minimum_file_age") or "60").strip()
try:
minimum_file_age = max(0, int(min_age_raw))
except (TypeError, ValueError):
minimum_file_age = 0
out = StringIO()
success = True
command_kwargs: dict[str, Any] = {
"dry_run": dry_run,
"no_input": True,
"interactive": False,
"minimum_file_age": minimum_file_age,
"remove_empty_dirs": remove_empty_dirs,
"verbosity": 2,
"stdout": out,
}
if exclude:
command_kwargs["exclude"] = exclude
error_message = ""
media_inventory_before = _media_file_inventory(settings.MEDIA_ROOT)
media_inventory_after = media_inventory_before
try:
call_command("cleanup_unused_media", **command_kwargs)
except CommandError as exc:
success = False
error_message = str(exc)
except Exception as exc: # pragma: no cover
success = False
error_message = str(exc)
media_inventory_after = _media_file_inventory(settings.MEDIA_ROOT)
media_size_before = sum(media_inventory_before.values())
media_size_after = sum(media_inventory_after.values())
removed_files = set(media_inventory_before) - set(media_inventory_after)
output = out.getvalue().strip()
recovered_size = sum(media_inventory_before[path] for path in removed_files)
context.update(
{
"ran": True,
"success": success,
"action": action,
"dry_run": dry_run,
"minimum_file_age": minimum_file_age,
"remove_empty_dirs": remove_empty_dirs,
"exclude": exclude_raw,
"output": output,
"error_message": error_message,
"media_size_before": media_size_before,
"media_size_after": media_size_after,
"recovered_size": recovered_size,
"removed_file_count": len(removed_files),
}
)
if request.htmx:
return render(request, "rad/partials/_media_cleanup_result.html", context)
return render(request, "rad/media_cleanup.html", context)
def cid_selector(request):
return render(
request,
@@ -549,36 +886,42 @@ def feedback_mark_complete(request, pk):
return redirect(q.get_object_url())
@csrf_exempt
@login_required
def answer_suggestion_submit(request):
if request.method == "POST":
post_data = json.loads(request.body)
question_type, qid = post_data["qid"].split("/")
answer_string = post_data["answer"]
status = post_data["status"]
"""Submit a proposed answer to a question for review.
if str(status) not in " 012":
return JsonResponse({"success": False, "error": "Invalid status"})
Scope: authenticated users only.
Functionality: accepts a JSON POST containing the question ID, proposed answer
string, and status; creates a new Answer record marked as proposed=True for
moderator review. Only 'anatomy' and 'rapid' question types are supported.
"""
if request.method != "POST":
return JsonResponse({"success": False, "error": "Invalid data"})
if question_type == "anatomy":
AnswerModel = AnatomyAnswer
question = AnatomyQuestion
elif question_type == "rapid":
AnswerModel = RapidAnswer
question = Rapid
else:
return JsonResponse({"success": False, "error": "Invalid question type"})
post_data = json.loads(request.body)
question_type, qid = post_data["qid"].split("/")
answer_string = post_data["answer"]
status = post_data["status"]
q = get_object_or_404(question, pk=qid)
if AnswerModel.objects.filter(answer=answer_string, question=q):
return JsonResponse({"success": False, "error": "Answer already exists"})
if str(status) not in " 012":
return JsonResponse({"success": False, "error": "Invalid status"})
a = AnswerModel(question=q, answer=answer_string, status=status, proposed=True)
a.save()
return JsonResponse({"success": True, "error": "Answer submited"})
if question_type == "anatomy":
AnswerModel = AnatomyAnswer
question = AnatomyQuestion
elif question_type == "rapid":
AnswerModel = RapidAnswer
question = Rapid
else:
return JsonResponse({"success": False, "error": "Invalid question type"})
# post_exam_answers
return JsonResponse({"success": False, "error": "Invalid data"})
q = get_object_or_404(question, pk=qid)
if AnswerModel.objects.filter(answer=answer_string, question=q):
return JsonResponse({"success": False, "error": "Answer already exists"})
a = AnswerModel(question=q, answer=answer_string, status=status, proposed=True)
a.save()
return JsonResponse({"success": True, "error": "Answer submitted"})
@login_required
+11 -2
View File
@@ -22,9 +22,18 @@ try:
# ensure directory exists
log_dir = Path("/var/log/rad")
log_dir.mkdir(parents=True, exist_ok=True)
# Determine stdout level: allow overriding with LOGURU_STDOUT_LEVEL env var,
# otherwise expose DEBUG when DEBUG env var is truthy, fall back to INFO.
raw_debug = os.environ.get("DEBUG", "0")
env_level = os.environ.get("LOGURU_STDOUT_LEVEL")
if env_level:
stdout_level = env_level
else:
stdout_level = "DEBUG" if raw_debug.lower() in ("1", "true", "yes") else "INFO"
# Add stdout sink (if not already added by other code)
logger.remove() # remove default handlers to avoid duplicate lines
logger.add(sys.stdout, level="INFO", enqueue=True, backtrace=False, diagnose=False)
logger.add(sys.stdout, level=stdout_level, enqueue=True, backtrace=False, diagnose=False)
# Add rotating file sink for persistence in container. Use JSON lines
# (serialize=True) to make logs easy to query in Loki/Grafana.
logger.add(
@@ -35,7 +44,7 @@ try:
enqueue=True,
serialize=True,
)
logger.debug("Loguru logging configured with stdout and file sinks")
logger.debug("Loguru logging configured with stdout and file sinks (stdout=%s)", stdout_level)
logging.root.setLevel(logging.DEBUG)
except Exception:
+29 -29
View File
@@ -18,39 +18,39 @@
{% endif %}
{% endblock %}
{% block navigation %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'rapids:index' %}">Rapids</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'rapids:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'rapids:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'rapids:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'rapids:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'rapids:index' %}">Rapids</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#rapidsNavbarNav" aria-controls="rapidsNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="rapidsNavbarNav">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'rapids:user_answer_table_view' %}" title="User answers">Answers</a>
<a class="nav-link" href="{% url 'rapids:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'rapids:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'rapids:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'rapids:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'rapids:user_answer_table_view' %}" title="User answers">Answers</a>
</li>
{% endif %}
{% endif %}
{% endif %}
</ul>
</ul>
</div>
</div>
</div>
</nav>
</nav>
{% comment %} </br>
Questions by:
<span id="authors-link"><a href="{% url 'rapids:author_list' %}">author</a></span> {% endcomment %}
+27 -9
View File
@@ -477,10 +477,16 @@ def create_abnormality(request):
)
@csrf_exempt
@login_required
def get_abnormality_id(request):
if request.accepts("application/json")():
abnormality_name = request.GET["abnormality_name"]
"""Return the primary key for an Abnormality looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up an Abnormality by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
abnormality_name = request.GET.get("abnormality_name", "")
abnormality_id = Abnormality.objects.get(name=abnormality_name).id
data = {
"abnormality_id": abnormality_id,
@@ -503,10 +509,16 @@ def create_examination(request):
)
@csrf_exempt
@login_required
def get_examination_id(request):
if request.accepts("application/json")():
examination_name = request.GET["examination_name"]
"""Return the primary key for an Examination looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up an Examination by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
examination_name = request.GET.get("examination_name", "")
examination_id = Examination.objects.get(name=examination_name).id
data = {
"examination_id": examination_id,
@@ -529,10 +541,16 @@ def create_region(request):
)
@csrf_exempt
@login_required
def get_region_id(request):
if request.accepts("application/json")():
region_name = request.GET["region_name"]
"""Return the primary key for a Region looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up a Region by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
region_name = request.GET.get("region_name", "")
region_id = Region.objects.get(name=region_name).id
data = {
"region_id": region_id,
+2 -7
View File
@@ -17,6 +17,7 @@ from rcr.models import Item, Outcome, RadiologyCategory
from rcr.forms import AssessorAssignmentForm, ItemForm
from atlas.models import Condition
from django.core.exceptions import PermissionDenied
from loguru import logger
# Create your views here.
@@ -160,8 +161,6 @@ def radiology_assessor_assignment(request, assessor_pk: int, specialty=None):
assessor_assignment_form = AssessorAssignmentForm(request.POST)
assessor_assignment_form.fields["items"].queryset = item_filter.qs
print(assessor_assignment_form)
if assessor_assignment_form.is_valid():
items_to_update = assessor_assignment_form.cleaned_data["items"]
@@ -309,16 +308,12 @@ class ItemUpdateNextView(ItemUpdateBase, RCRRequiredMixin):
except IndexError:
raise Http404
print("HELLO", obj)
print(obj)
logger.debug("get_object: retrieving item pk={}", obj)
if not obj:
print("raise")
raise Http404
return obj
print("test")
return super().get_object()
#def get
+7 -1
View File
@@ -225,9 +225,15 @@ class AuthorOrCheckerRequiredMixin(object):
# return render(request, "sbas/question_detail.html", {"question": question})
@login_required
def active_exams(request):
"""List all active SBA exams (candidate-facing).
Scope: authenticated users only.
Functionality: returns all non-archived active exams as a list for candidates
to take.
"""
exams = Exam.objects.all()
print(exams)
active_exams = []
+30 -30
View File
@@ -18,41 +18,41 @@
{% endif %}
{% endblock %}
{% block navigation %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'shorts:index' %}">Shorts</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'shorts:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'shorts:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<nav class="navbar navbar-expand-lg navbar-dark submenu">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'shorts:index' %}">Shorts</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#shortsNavbarNav" aria-controls="shortsNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="shortsNavbarNav">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:user_answer_table_view' %}" title="User answers">Answers</a>
<a class="nav-link" href="{% url 'shorts:exam_list' %}"><i class="bi bi-mortarboard"></i> Exams</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:question_view' %}"><i class="bi bi-question-circle"></i> Questions</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false"><i class="bi bi-file-earmark-plus"></i> Create</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'shorts:exam_create' %}" title="Create a new exam"><i class="bi bi-mortarboard"></i> Exam</a></li>
<li><a class="dropdown-item" href="{% url 'shorts:question_create' %}" title="Create a new question"><i class="bi bi-question-circle"></i> Question</a></li>
</ul>
</li>
{% if request.user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:user_answer_table_view' %}" title="User answers">Answers</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{% url 'shorts:help' %}" title="Help Pages">Help</a>
</li>
{% endif %}
</ul>
{% endif %}
</ul>
</div>
</div>
</div>
</nav>
</nav>
{% endblock %}
-2
View File
@@ -395,7 +395,6 @@ def mark_answer(request, exam_id, question_number, answer_id, override=False):
elif answer_list[-1] == answer_id:
previous_answer_id = answer_list[-2]
else:
print(answer_list.index(answer_id))
next_answer_id = answer_list[answer_list.index(answer_id) + 1]
previous_answer_id = answer_list[answer_list.index(answer_id) - 1]
@@ -955,7 +954,6 @@ def question_findings(request, question_id, finding_pk=None):
@require_POST
def combine_images_side_by_side(request, question_id):
image_ids = request.POST.getlist('combine-image-checkbox')
print(image_ids)
if len(image_ids) != 2:
return render(request, "shorts/combine_error.html", {"error": "Please select exactly two images."})
question = get_object_or_404(Question, pk=question_id)

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