Compare commits
105
Commits
5a050fb36d
...
e28bf641b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e28bf641b7 | ||
|
|
59956670df | ||
|
|
d2a4ee9f70 | ||
|
|
c7fd99f6f3 | ||
|
|
cf4cb32c5e | ||
|
|
f4c2c4bdaf | ||
|
|
8da8f27433 | ||
|
|
c3131564e6 | ||
|
|
0bda5e2c41 | ||
|
|
6b9c7be633 | ||
|
|
35887fbcaf | ||
|
|
f9a9b9bb38 | ||
|
|
916ac3b69c | ||
|
|
7ac8517062 | ||
|
|
22c5576fc9 | ||
|
|
52e6e79c1b | ||
|
|
3455dc3855 | ||
|
|
2b9f3e3b68 | ||
|
|
e55a3bf36a | ||
|
|
b24eac862b | ||
|
|
84b032addb | ||
|
|
d046b7a151 | ||
|
|
aaa3ee6313 | ||
|
|
c1e351c7db | ||
|
|
863d65b4b3 | ||
|
|
26a73d1d3b | ||
|
|
d12933e62a | ||
|
|
771abf62e2 | ||
|
|
859042bf96 | ||
|
|
6574c29a70 | ||
|
|
9edac288f5 | ||
|
|
ab7dcaed76 | ||
|
|
2aff9516ac | ||
|
|
5d37cad48d | ||
|
|
6575c50507 | ||
|
|
74cdf14af3 | ||
|
|
9ef4b2dac1 | ||
|
|
2e35fa7036 | ||
|
|
6be7398b42 | ||
|
|
2478de6b98 | ||
|
|
7479b44f53 | ||
|
|
89c8cd565f | ||
|
|
f63f3b3687 | ||
|
|
8201f91697 | ||
|
|
082a260757 | ||
|
|
5bd1338b5e | ||
|
|
7062723433 | ||
|
|
cc100dba89 | ||
|
|
64aa2974ee | ||
|
|
47cfa79083 | ||
|
|
5c217266c4 | ||
|
|
68dfcdfa7e | ||
|
|
e894e02445 | ||
|
|
f7cc51c221 | ||
|
|
56a644c5c1 | ||
|
|
4a787aa3eb | ||
|
|
7031942198 | ||
|
|
585a19013a | ||
|
|
61a83ed836 | ||
|
|
bf12204c51 | ||
|
|
29165811d1 | ||
|
|
02ad8c61e5 | ||
|
|
70ea3788ca | ||
|
|
3772846e55 | ||
|
|
1876a7f845 | ||
|
|
d4f25de9d3 | ||
|
|
0b1f842e31 | ||
|
|
29f9b724b0 | ||
|
|
1f1b324cfe | ||
|
|
474ad205f6 | ||
|
|
1f806d2e60 | ||
|
|
20987274af | ||
|
|
d82551674b | ||
|
|
0d1318564c | ||
|
|
9a8a1a1b57 | ||
|
|
9df74edc8c | ||
|
|
3db2c1a768 | ||
|
|
dc479cafd0 | ||
|
|
b02120d5e7 | ||
|
|
be02269af6 | ||
|
|
5843547a05 | ||
|
|
36a77981c8 | ||
|
|
95793c78c9 | ||
|
|
37e1bf29a3 | ||
|
|
b864f3d3fd | ||
|
|
e82b353ad9 | ||
|
|
854f265623 | ||
|
|
bebac18625 | ||
|
|
068dd3df80 | ||
|
|
9bae94a595 | ||
|
|
df7f56f34c | ||
|
|
f47f408cce | ||
|
|
c0870a8765 | ||
|
|
113424d1e4 | ||
|
|
e007e35f4f | ||
|
|
e162e60f44 | ||
|
|
5c50ee75da | ||
|
|
90c8f0b60d | ||
|
|
463a741185 | ||
|
|
f7b6ec1447 | ||
|
|
3e03413047 | ||
|
|
126d186ad1 | ||
|
|
a749373a21 | ||
|
|
d9331fe279 | ||
|
|
3e936e494a |
@@ -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
|
||||
@@ -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).
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.conditions %}
|
||||
<span>
|
||||
Conditions: {% for c in finding.conditions.all %}
|
||||
{{c.get_link}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.structures %}
|
||||
<span>
|
||||
Structure: {% for s in finding.structures.all %}
|
||||
{{s.get_link}},
|
||||
{% 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"> </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"> </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 %}
|
||||
@@ -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 %}
|
||||
(<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 %}
|
||||
|
||||
|
||||
|
||||
@@ -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"><{{ u.email }}></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 %}
|
||||
@@ -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 %}
|
||||
+1668
-615
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}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.conditions %}
|
||||
<span>
|
||||
Conditions: {% for c in finding.conditions.all %}
|
||||
{{c.get_link}},
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if finding.structures %}
|
||||
<span>
|
||||
Structure: {% for s in finding.structures.all %}
|
||||
{{s.get_link}},
|
||||
{% 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 %}
|
||||
|
|
||||
<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 & 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"><{{ u.email }}></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 %}<{{ sharing_info.supervisor.email }}>{% 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 %}<{{ sharing_info.supervisor.email }}>{% 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"><{{ u.email }}></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">
|
||||
× 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"><{{ u.email }}></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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
|
||||
<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; }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -87,7 +87,6 @@ def output(request):
|
||||
questions = format.format.strip().split("---")
|
||||
|
||||
for question in questions:
|
||||
print(question)
|
||||
if not question:
|
||||
continue
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user