Compare commits
493 Commits
dockerprod
...
93e24bf855
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e24bf855 | |||
| 1c76e0696c | |||
| ff00efecf7 | |||
| a9946961f1 | |||
| d5e473d692 | |||
| 75e3bca503 | |||
| 1775b11e40 | |||
| d77c5149f0 | |||
| c2d8376e22 | |||
| ab9847e22e | |||
| bbf7542451 | |||
| e6a20bb851 | |||
| 7ed0a8f378 | |||
| c8d05818a4 | |||
| b6716c9ae1 | |||
| e368d0c7d1 | |||
| b9cbfd20c1 | |||
| 5ce337c7c4 | |||
| f4d990676a | |||
| 4f61e43b8c | |||
| c947cba5a7 | |||
| 0cf655325c | |||
| 89e89cef73 | |||
| c94d2fb1a7 | |||
| 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 | |||
| 5a050fb36d | |||
| 2d48a6bad6 | |||
| d4f0c05341 | |||
| 7b52cab180 | |||
| ffb730e9c8 | |||
| 1b199150e5 | |||
| bf7f88ac6e | |||
| 1d19afa72d | |||
| bd0c71140c | |||
| d6487eae88 | |||
| 06fe73e2a3 | |||
| ec0bad02c6 | |||
| de5b6c36f3 | |||
| 90e22435a5 | |||
| a82e797bae | |||
| 97db951c79 | |||
| f70ecb4648 | |||
| a792f0c7cd | |||
| 13d73020ee | |||
| 4d6d535ca7 | |||
| fbb2a2cc18 | |||
| 20b4d6b6af | |||
| d7ed8881d8 | |||
| 3ae6ce53bc | |||
| 5a5eaa04a2 | |||
| d75d2be547 | |||
| f65e78b959 | |||
| 7526f6a902 | |||
| b17774aa7c | |||
| 077bd3ed87 | |||
| 73a364fd44 | |||
| 8733f59882 | |||
| 64d4958744 | |||
| 9e0e66bcd9 | |||
| ec4dcdba2b | |||
| 4512b799a6 | |||
| d313cd083c | |||
| 8bbb6b741d | |||
| d00a7f9cf6 | |||
| 70602e6f71 | |||
| b35057d059 | |||
| 1cadbab742 | |||
| 3ea51f976d | |||
| d0686dbe17 | |||
| e391f4f943 | |||
| 65208ae7ad | |||
| 82bea5ff05 | |||
| 71c5cc6c24 | |||
| 6c7a37ca4a | |||
| 4788d56dec | |||
| 42bffc0c07 | |||
| 996ef4877c | |||
| b87db68329 | |||
| b87c0a776c | |||
| af9bfabeed | |||
| 2c482216c1 | |||
| 61c768b80d | |||
| 5d0ff3373d | |||
| c14ad2dcf7 | |||
| b503558ee4 | |||
| c177d21688 | |||
| 2a1db54a17 | |||
| b1ec4b173e | |||
| 4c1e0c3d2a | |||
| 4e6f72e045 | |||
| ed3bd3433b | |||
| 2c6d0f2613 | |||
| 7cac5aa60f | |||
| a5a4084994 | |||
| 7caaa425ed | |||
| 904c023bf1 | |||
| e2e0e374e6 | |||
| af2dcd95b2 | |||
| 6ff8706fa4 | |||
| 12e3d47600 | |||
| b3f5684e69 | |||
| 166b0a0282 | |||
| ed6418efad | |||
| 0c6d33e26a | |||
| b43d441da1 | |||
| b7cf022622 | |||
| 419063fdeb | |||
| a6205c95cb | |||
| c00e08d63e | |||
| a3ac91437f | |||
| d7f3ebf950 | |||
| 83d1b48213 | |||
| 65db1220d8 | |||
| 3cdfba0acf | |||
| d899d0d86a | |||
| 44d23801b2 | |||
| 6e3ab7de41 | |||
| a51a5c8d54 | |||
| c61f92ae9e | |||
| 6e30fcc19f | |||
| 36da045d71 | |||
| 0b6661c018 | |||
| 64103cac1e | |||
| 990f4534bd | |||
| 4382c6070e | |||
| 8ba9543c89 | |||
| f2eaf6197e | |||
| b19eefa60d | |||
| 0718ea82b2 | |||
| fb9952e7aa | |||
| ed1a33ac89 | |||
| eddfb85d7b | |||
| f133fc3fd0 | |||
| 2f90a21abe | |||
| 98ce450f11 | |||
| 6734036477 | |||
| 5243100d7e | |||
| fa82d81a96 | |||
| 726057b51e | |||
| 647081a7d6 | |||
| 57b9c712c2 | |||
| 4d78a6b63a | |||
| 941c599d24 | |||
| 2df789735c | |||
| 37812921b8 | |||
| 4cdddcf288 | |||
| 17d3b4206a | |||
| ec03def164 | |||
| f72ec82525 | |||
| 90c96333a1 | |||
| 19eb10c089 | |||
| 2ab5a8e3b5 | |||
| c2f12f384a | |||
| 33f32dab8b | |||
| a676d437bb | |||
| ba5b11cce2 | |||
| a6c5b99620 | |||
| 666f49f2c2 | |||
| 46608a0758 | |||
| cfdf33b9ce | |||
| bdc97922af | |||
| 3626f3918e | |||
| 49fbff76e9 | |||
| 7ec81133d8 | |||
| c67db4a39e | |||
| d2f4c21883 | |||
| 1f5931aab2 | |||
| 08c3558861 | |||
| add7a1cf38 | |||
| 65f08cce6c | |||
| 3c8238d737 | |||
| 23f33ac7ae | |||
| ab130a08ed | |||
| 45e01a78c1 | |||
| 25e5ef78ed | |||
| 13dd79b508 | |||
| 89986ba2e9 | |||
| f9e69e1206 | |||
| e7de6c8970 | |||
| ea139456a8 | |||
| 035da12e96 | |||
| 3b9942054d | |||
| 5761117e68 | |||
| f316dc9bcb | |||
| bdb7b653b6 | |||
| 1eeb0d89d9 | |||
| f3702db5f6 | |||
| a6a7d75144 | |||
| 14bba93c4d | |||
| f29df840c6 | |||
| 10d30cdaa6 | |||
| dc4cf0c3a2 | |||
| 99b951b32b | |||
| c2c8ec5b13 | |||
| 6d5303cd0d | |||
| f6b990a00a | |||
| 3c0deec140 | |||
| e3241b503d | |||
| 04174484b1 | |||
| 168a4126d3 | |||
| a0850c0030 | |||
| a882a3592b | |||
| 8074b56a80 | |||
| 2e79df1eae | |||
| 9bf68cd136 | |||
| 42163448bf | |||
| 1066a3fb9f | |||
| 3ade162b28 | |||
| f4186bf923 | |||
| 5bb2aea8ef | |||
| 8911c09c0e | |||
| 7342b11b27 | |||
| 953295cc4d | |||
| 374ee17911 | |||
| 18aef751ca | |||
| f362be43ef | |||
| 00680c8b9e | |||
| eea3433042 | |||
| 8dad4a5a5c | |||
| 75d23c0ba9 | |||
| ff2b6f4c65 | |||
| 22e5f9a496 | |||
| 16865fab03 | |||
| 5e14df4836 | |||
| 625f6017a6 | |||
| c45e3c8765 | |||
| ee33f62def | |||
| 3a5fe9a85c | |||
| efc47bb2e8 | |||
| 38d70f8ff0 | |||
| 2159b045cd | |||
| 4628f39a49 | |||
| 90ceff1b02 | |||
| a512c2ba09 | |||
| a61c8529ae | |||
| a667566db6 | |||
| d070adc326 | |||
| 048168e87d | |||
| c881da244b | |||
| 797eb9c54a | |||
| 0a1b21cec6 | |||
| d3d3638c1c | |||
| 81e22dfc88 | |||
| 7ea43a6f44 | |||
| eb8fe58cf7 | |||
| f149a88bd6 | |||
| d4d747517c | |||
| 0ee378484b | |||
| ea583744c9 | |||
| e0d589f4d0 | |||
| 1c95526ff9 | |||
| 662fc4e06e | |||
| a333ff492d | |||
| bb9741b786 | |||
| 75be7c1739 | |||
| 015a5d18b5 | |||
| d16041026f | |||
| 774a1b8c8a | |||
| 8fc42169f3 | |||
| 834d91e84d | |||
| ef03a97e9e | |||
| dd7437bc66 | |||
| 21d3130fa5 | |||
| 89e7e49e31 | |||
| 8bceb10a13 | |||
| 13a98a1e66 | |||
| 9c16f93820 | |||
| 1c27d48a36 | |||
| 562ad1c0a4 | |||
| fcd77fd799 | |||
| d8f8ef7326 | |||
| f551d071f0 | |||
| 9b6fd31cee | |||
| 8b7da1554a | |||
| d4247adb13 | |||
| b89ca918fa | |||
| 569ddb4343 | |||
| b040dfcd82 | |||
| db51ef23be | |||
| 8c796c3048 | |||
| b837c406cf | |||
| 42d209af5a | |||
| 3d1b9a57f9 | |||
| 8d638cc08b | |||
| 20a06323d5 | |||
| 3928df6d75 | |||
| bf344f20e1 | |||
| e4cfb1f782 | |||
| f44f3bbcfe | |||
| ba0c380f18 | |||
| 459b7f9b93 | |||
| 378d784435 | |||
| 96e88c05a4 | |||
| 457fb06dc0 | |||
| debe093bc9 | |||
| 086bd8f589 | |||
| 71dc50b307 | |||
| c0c12ea9f9 | |||
| 429adfc376 | |||
| 36a809ea40 | |||
| 443d93c35f | |||
| d7682ab31d | |||
| e600b8eb91 | |||
| 3e691d774c | |||
| c3a70b079d | |||
| 4ffadd3914 | |||
| fa18c33e8a | |||
| 057c391995 | |||
| 94fbd38634 | |||
| 4ecc32f837 | |||
| 768e1bf8c5 | |||
| 32c4305754 | |||
| 465f819973 | |||
| 3e1cb4b54f | |||
| b9d86a7639 | |||
| e5fd99f491 | |||
| efeed2a8b1 | |||
| f14ede86d3 | |||
| 517c2ebe06 | |||
| c5f9b830eb | |||
| 2ab85357a9 | |||
| 91cf2cab29 | |||
| 34c4c0d830 | |||
| 98cef9ea57 | |||
| df0b26de36 | |||
| 17330681a2 | |||
| 112c96dc00 | |||
| 0bea34d406 | |||
| 06ceb9c2ce | |||
| 8ac292eb0c | |||
| ff6b8a8a48 | |||
| 46313cf5bb | |||
| 13a23eee12 | |||
| a1bc96f99a | |||
| 9fd5e56356 | |||
| a02f274a4e | |||
| d3d0e865c3 | |||
| 7163e51335 | |||
| 7b47a75657 | |||
| 54e1cf4ce0 | |||
| c071f38920 | |||
| 03b23b9178 | |||
| 00b736d8d6 | |||
| 885ab1aad4 | |||
| 77edc17732 | |||
| d98305caff | |||
| 12bd66d4d8 | |||
| 508f8a22a2 | |||
| 158db133b3 | |||
| 83a009e31b | |||
| 00dfaf9888 | |||
| 7e4e66a74f | |||
| 7a4cf3627d | |||
| 4db1f2ca1a | |||
| cbff1317d0 | |||
| 0644ed99f4 | |||
| c01000f06e | |||
| 2f014ce1a5 | |||
| 654d0ae686 | |||
| 18dbb27a3e | |||
| 4bec329aff | |||
| 1e2300bd70 | |||
| 7674f29124 | |||
| d39879bbca | |||
| 012c86340d | |||
| 6f50c53519 | |||
| 53aefa29df | |||
| 4617a643d2 | |||
| d7afab15e1 | |||
| ad5e7aeed8 | |||
| b4cf5b9d70 | |||
| f156f4c85e | |||
| 4ab2e3a15e | |||
| 6daa643df8 | |||
| f8874a2556 | |||
| b4f5992986 | |||
| 9a3a8b932a | |||
| eb06e63f77 | |||
| a4f4ba464a | |||
| 6fafccc907 | |||
| d96dd77cdc | |||
| 4e47083fcd | |||
| bf895e516e | |||
| 49229b0408 | |||
| 997b5374c2 | |||
| 9d434ec80d | |||
| af616a9f4a | |||
| d29c23a799 | |||
| 0917947a98 |
@@ -18,5 +18,13 @@ GUNICORN_WORKERS=3
|
||||
GUNICORN_LOGLEVEL=info
|
||||
|
||||
# Nginx host ports for local development (override prod defaults)
|
||||
NGINX_HTTP_PORT=8000
|
||||
NGINX_HTTPS_PORT=8443
|
||||
NGINX_HTTP_PORT=8080
|
||||
# Pick a non-privileged HTTPS port to avoid conflicts with host installs
|
||||
NGINX_HTTPS_PORT=8444
|
||||
|
||||
# Use Django's file-based email backend for local development so messages
|
||||
# are written to files under the mounted `./logs/emails` directory and are
|
||||
# easy to inspect on the host.
|
||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
#EMAIL_BACKEND=django.core.mail.backends.filebased.EmailBackend
|
||||
EMAIL_FILE_PATH=/var/log/rad/emails
|
||||
+4
-2
@@ -14,5 +14,7 @@ venv
|
||||
temp/
|
||||
|
||||
# Loki runtime data
|
||||
/docker/loki-data/
|
||||
/docker/loki-wal/
|
||||
docker/loki-data/
|
||||
docker/loki-wal/
|
||||
# Local runtime logs collected by Promtail
|
||||
/logs/
|
||||
|
||||
@@ -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,118 @@
|
||||
README
|
||||
======
|
||||
|
||||
Quick notes for running this repository (development & observability)
|
||||
|
||||
Running locally (development)
|
||||
- Use the included compose files. To run the dev stack (overrides) set `COMPOSE_ENV=dev` so the correct env file is loaded:
|
||||
|
||||
```sh
|
||||
# run nginx on non-privileged ports (see .env.dev)
|
||||
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Background task workers (automatic)
|
||||
- The compose stack now includes a dedicated `worker` service which runs:
|
||||
|
||||
```sh
|
||||
python manage.py db_worker
|
||||
```
|
||||
|
||||
- This means workers start automatically with `docker compose up` and restart on failure.
|
||||
- To inspect worker logs:
|
||||
|
||||
```sh
|
||||
docker compose logs -f worker
|
||||
```
|
||||
|
||||
- To scale workers (for higher throughput):
|
||||
|
||||
```sh
|
||||
docker compose up -d --scale worker=2
|
||||
```
|
||||
|
||||
Troubleshooting `SIGKILL` when running manually
|
||||
- A `SIGKILL` on `db_worker` is usually the kernel OOM killer (out-of-memory), not a Django exception.
|
||||
- Check kernel OOM events:
|
||||
|
||||
```sh
|
||||
dmesg -T | grep -i -E "killed process|out of memory|oom"
|
||||
```
|
||||
|
||||
- If OOM is confirmed, prefer running worker inside compose (managed restart + container limits), reduce concurrency/parallel services, or add memory/swap.
|
||||
|
||||
Manual worker startup (non-Docker production)
|
||||
- If you are not yet running production in Docker, start the worker using the restart wrapper script:
|
||||
|
||||
```sh
|
||||
source .venv/bin/activate
|
||||
DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh
|
||||
```
|
||||
|
||||
- This script restarts `db_worker` if it exits unexpectedly.
|
||||
|
||||
- To run it in the background and keep logs:
|
||||
|
||||
```sh
|
||||
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
|
||||
```
|
||||
|
||||
- In fish shell, follow with `disown` so the shell does not keep the job attached:
|
||||
|
||||
```sh
|
||||
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
|
||||
disown
|
||||
```
|
||||
|
||||
- For server reliability, prefer running the same script under `systemd` (auto-start on reboot, restart-on-failure).
|
||||
- A starter unit file is provided at `scripts/db-worker.service.example`.
|
||||
|
||||
- To verify it is still running:
|
||||
|
||||
```sh
|
||||
ps aux | grep "manage.py db_worker" | grep -v grep
|
||||
tail -f logs/db_worker.log
|
||||
```
|
||||
|
||||
- By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are:
|
||||
- `NGINX_HTTP_PORT=8080`
|
||||
- `NGINX_HTTPS_PORT=8444`
|
||||
|
||||
Logging & observability (Loki + Promtail + Grafana)
|
||||
- Promtail is configured to scrape `/var/log/rad/*.log` and push to Loki. Promtail's config is at `docker/promtail-config.yml` and the Loki config is at `docker/loki-config/local-config.yaml`.
|
||||
- The nginx configs (both `deploy/nginx/prod.conf` and `deploy/nginx/dev.conf`) are configured to write access and error logs to `/var/log/rad/nginx.access.log` and `/var/log/rad/nginx.error.log` respectively.
|
||||
- The compose files mount a host `logs/` folder into `/var/log/rad` so both nginx (write) and promtail (read) can access the same files:
|
||||
- `../logs:/var/log/rad:rw` (nginx)
|
||||
- `../logs:/var/log/rad:ro` (promtail)
|
||||
|
||||
Host setup for logs
|
||||
- Create the host logs folder at the repo root (this repo's `logs/`). In development you can use permissive permissions so containers can write to it quickly:
|
||||
|
||||
```sh
|
||||
mkdir -p logs
|
||||
# development: make writable by all (change this for production)
|
||||
sudo chown $USER:$USER logs
|
||||
chmod 0777 logs
|
||||
```
|
||||
|
||||
- In production prefer setting the folder owner to the nginx worker UID (or run nginx under a specific user) and use `0755` or `0750` as appropriate. Example:
|
||||
|
||||
```sh
|
||||
# run on the host (determine nginx UID as needed)
|
||||
sudo chown -R 101:101 /srv/rad/logs
|
||||
sudo chmod 0755 /srv/rad/logs
|
||||
```
|
||||
|
||||
Viewing logs in Grafana
|
||||
- Grafana is available on the port exposed by compose (default `3000`).
|
||||
- The Loki datasource is configured to use `http://loki:3100` (see compose). In Grafana Explore choose the Loki datasource and run queries such as:
|
||||
|
||||
- `{job="rad_app"}` (all collected logs)
|
||||
- `{job="rad_app", filename="/var/log/rad/nginx.access.log"}`
|
||||
|
||||
Notes & recommendations
|
||||
- In development the `logs/` folder is ignored by git (`/.gitignore` updated). Do not commit runtime logs.
|
||||
- Consider a log rotation/retention policy for production (e.g., `logrotate` or use Loki retention policies).
|
||||
- If you need nginx to re-resolve the backend service IP without restarting, consider using `resolver` and variable-based upstreams in the nginx config. For many development workflows restarting nginx after web restarts is the simplest approach.
|
||||
|
||||
If you want, I can also add a short `docs/OBSERVABILITY.md` with more details and recommended production settings (TLS/auth for Loki, retention, log rotation).
|
||||
@@ -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).
|
||||
+51
-3
@@ -34,7 +34,9 @@ from dal import autocomplete
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Submit
|
||||
from crispy_forms.layout import Submit, Layout, Div
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
from generic.widgets import ExamSearchWidget
|
||||
|
||||
class AnatomyAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -127,7 +129,38 @@ class AnatomyQuestionForm(ModelForm):
|
||||
self.fields["exams"] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=exam_queryset,
|
||||
widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=Exam),
|
||||
)
|
||||
|
||||
# Now that all fields have been created, set the helper layout so
|
||||
# Crispy can find the referenced field names (including
|
||||
# `answer_suggest_incorrect`). Placing the layout here ensures the
|
||||
# collapse/accordion renders server-side.
|
||||
self.helper.layout = Layout(
|
||||
"question_type",
|
||||
"image",
|
||||
"description",
|
||||
# Place modality → body_part fields in a horizontal flex row
|
||||
Div(
|
||||
Div("modality", css_class="flex-fill me-2"),
|
||||
Div("region", css_class="flex-fill me-2"),
|
||||
Div("structure", css_class="flex-fill me-2"),
|
||||
Div("examination", css_class="flex-fill me-2"),
|
||||
Div("body_part", css_class="flex-fill"),
|
||||
css_class="d-flex flex-wrap align-items-start mb-2",
|
||||
),
|
||||
"answer_help",
|
||||
Accordion(
|
||||
AccordionGroup(
|
||||
"Suggest incorrect answers",
|
||||
"answer_suggest_incorrect",
|
||||
active=False,
|
||||
)
|
||||
),
|
||||
"feedback",
|
||||
"open_access",
|
||||
"exams",
|
||||
Div(Submit("submit", "Submit"), css_class="form-group"),
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
@@ -257,4 +290,19 @@ class ExamGroupsForm(ExamGroupsFormMixin):
|
||||
class SuggestIncorrectAnswerForm(ModelForm):
|
||||
class Meta:
|
||||
model = AnatomyQuestion
|
||||
fields = ["answer_suggest_incorrect"]
|
||||
fields = ["answer_suggest_incorrect"]
|
||||
|
||||
|
||||
class PrimaryAnswerForm(Form):
|
||||
existing_answer = ChoiceField(required=False, choices=[], label="Use existing answer")
|
||||
new_answer = CharField(required=False, widget=Textarea(attrs={"rows":3}), label="Or create new primary answer")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
question = kwargs.pop("question", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
choices = [("", "-- choose existing --")]
|
||||
if question is not None:
|
||||
# Only include existing non-proposed CORRECT answers, ordered by answer text
|
||||
for a in question.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).order_by('answer'):
|
||||
choices.append((str(a.pk), a.answer))
|
||||
self.fields["existing_answer"].choices = choices
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 11:05
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0024_exam_results_supervisor_visible'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='exam',
|
||||
name='updated',
|
||||
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Add primary_answer FK to AnatomyQuestion and backfill existing values.
|
||||
|
||||
This migration adds a nullable FK `primary_answer` on AnatomyQuestion
|
||||
pointing to Answer, then backfills it using the current logic of
|
||||
selecting the first non-proposed CORRECT answer (status '2').
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
AnatomyQuestion = apps.get_model('anatomy', 'AnatomyQuestion')
|
||||
Answer = apps.get_model('anatomy', 'Answer')
|
||||
|
||||
# Select first non-proposed CORRECT answer (status '2') per question
|
||||
for q in AnatomyQuestion.objects.all():
|
||||
ans = Answer.objects.filter(question_id=q.pk, proposed=False, status='2').first()
|
||||
if ans:
|
||||
q.primary_answer_id = ans.pk
|
||||
q.save(update_fields=['primary_answer'])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
AnatomyQuestion = apps.get_model('anatomy', 'AnatomyQuestion')
|
||||
for q in AnatomyQuestion.objects.all():
|
||||
q.primary_answer = None
|
||||
q.save(update_fields=['primary_answer'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0025_exam_created_exam_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='anatomyquestion',
|
||||
name='primary_answer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for_questions', to='anatomy.answer'),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 09:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('anatomy', '0026_add_primary_answer'),
|
||||
('atlas', '0090_casecollection_created_casecollection_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='anatomyquestion',
|
||||
name='primary_answer',
|
||||
field=models.ForeignKey(blank=True, help_text='Optional explicit primary answer for this question', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for_questions', to='anatomy.answer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='anatomyquestion',
|
||||
name='structure',
|
||||
field=models.ForeignKey(blank=True, help_text='The structure that is being asked about in the question, this is used for categorisation and search purposes but is not required to be set for a question to be valid.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='atlas.structure'),
|
||||
),
|
||||
]
|
||||
+37
-27
@@ -116,6 +116,8 @@ class AnatomyQuestion(QuestionBase):
|
||||
)
|
||||
structure = models.ForeignKey(
|
||||
Structure, on_delete=models.SET_NULL, null=True, blank=True
|
||||
, help_text="The structure that is being asked about in the question, this is used for categorisation and search purposes but is not required to be set for a question to be valid."
|
||||
|
||||
)
|
||||
|
||||
author = models.ManyToManyField(
|
||||
@@ -125,6 +127,15 @@ class AnatomyQuestion(QuestionBase):
|
||||
related_name="anatomy_authored_questions",
|
||||
)
|
||||
|
||||
primary_answer = models.ForeignKey(
|
||||
'Answer',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='primary_for_questions',
|
||||
help_text="Optional explicit primary answer for this question",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
permissions = ()
|
||||
|
||||
@@ -149,33 +160,32 @@ class AnatomyQuestion(QuestionBase):
|
||||
def get_absolute_url(self):
|
||||
return reverse("anatomy:question_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_primary_answer(self):
|
||||
if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer:
|
||||
ans = self.prefetched_primary_answer[0]
|
||||
else:
|
||||
ans = self.answers.filter(
|
||||
proposed=False, status=Answer.MarkOptions.CORRECT
|
||||
).first()
|
||||
# ans = self.answers.first()
|
||||
def get_primary_answer_obj(self):
|
||||
"""Return the Answer instance considered the primary answer, or None.
|
||||
|
||||
Preference order:
|
||||
- explicit `primary_answer` FK if set
|
||||
- `prefetched_primary_answer` if provided by a queryset
|
||||
- first non-proposed Answer with status CORRECT
|
||||
"""
|
||||
if getattr(self, "primary_answer", None):
|
||||
return self.primary_answer
|
||||
|
||||
if hasattr(self, "prefetched_primary_answer") and self.prefetched_primary_answer:
|
||||
return self.prefetched_primary_answer[0]
|
||||
|
||||
return self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT).first()
|
||||
|
||||
def get_primary_answer(self):
|
||||
"""Return the primary answer text (string) for templates and display.
|
||||
|
||||
Keeps backward compatibility by returning the answer string when
|
||||
available, or "None yet..." if no primary answer is defined.
|
||||
"""
|
||||
ans = self.get_primary_answer_obj()
|
||||
if ans is None:
|
||||
return "None yet..."
|
||||
else:
|
||||
return ans.answer
|
||||
|
||||
if (
|
||||
self.answers.filter(
|
||||
proposed=False, status=Answer.MarkOptions.CORRECT
|
||||
).count()
|
||||
> 0
|
||||
):
|
||||
return (
|
||||
self.answers.filter(proposed=False, status=Answer.MarkOptions.CORRECT)
|
||||
.first()
|
||||
.answer
|
||||
)
|
||||
else:
|
||||
return "None yet..."
|
||||
return ans.answer
|
||||
|
||||
def get_exams(self):
|
||||
e = self.exams.all().values_list("name", flat=True)
|
||||
@@ -191,9 +201,9 @@ class AnatomyQuestion(QuestionBase):
|
||||
if not unmarked_answers:
|
||||
return "No answers to mark"
|
||||
return format_html(
|
||||
"<span class='warn'>{} answer unmarked:</span> {}".format(
|
||||
len(unmarked_answers), ", ".join(unmarked_answers)
|
||||
)
|
||||
"<span class='warn'>{} answer unmarked:</span> {}",
|
||||
len(unmarked_answers),
|
||||
", ".join(unmarked_answers),
|
||||
)
|
||||
|
||||
def get_unmarked_user_answers(self, exam_pk: int|None = None):
|
||||
|
||||
@@ -20,6 +20,11 @@ $(document).ready(function () {
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function(e) {
|
||||
let el = document.getElementById("htmx-error");
|
||||
if (!el) {
|
||||
// No global error container available; log and return to avoid JS errors
|
||||
console.error('htmx:responseError but no #htmx-error element present', e.detail);
|
||||
return;
|
||||
}
|
||||
var error;
|
||||
if (e.detail.pathInfo.requestPath.includes("cimar") && e.detail.xhr.status == 403) {
|
||||
error = "Cimar permission error, please login and refresh the page.";
|
||||
@@ -207,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);
|
||||
|
||||
@@ -245,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) {
|
||||
|
||||
+3
-5
@@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import A
|
||||
|
||||
from generic.tables import SingleImageColumn
|
||||
from generic.tables import SingleImageColumn, SelectionTable
|
||||
|
||||
from .models import AnatomyQuestion, UserAnswer
|
||||
|
||||
@@ -13,7 +13,7 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
|
||||
#from generic.tables import UserAnswerTable
|
||||
|
||||
class AnatomyQuestionTable(tables.Table):
|
||||
class AnatomyQuestionTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"anatomy:anatomy_question_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -30,9 +30,7 @@ class AnatomyQuestionTable(tables.Table):
|
||||
)
|
||||
exams = tables.ManyToManyColumn(verbose_name="Exams")
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = AnatomyQuestion
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
|
||||
@@ -228,11 +228,19 @@
|
||||
|
||||
<h2>{{object|yesno:"Update,Create"}} Question</h2>
|
||||
{% help "Help" %}
|
||||
<p>This form can be used to create an Anatomy question.</p>
|
||||
<p>This form can be used to create an Anatomy question. Once created, the question can be edited or deleted as needed.</p>
|
||||
|
||||
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
|
||||
|
||||
<p>Answers can be defined once the question has been created (and labelled).</p>
|
||||
|
||||
<p>Not all fields are required.</p>
|
||||
|
||||
<p>Correct and incorrect answers can be defined once the question has been created.</p>
|
||||
|
||||
<p>Images can be selected either directly via the browse button or by dragging and dropping them into the designated area below.</p>
|
||||
|
||||
<p>Supported formats include JPEG, PNG, and GIF. It is also possible to use DICOM images.</p>
|
||||
{% endhelp %}
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
@@ -241,10 +249,9 @@
|
||||
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
|
||||
onclick="return showAddPopup(this);"><img src="{% static 'img/icon-addlink.svg' %}"></a>
|
||||
|
||||
{{ form|crispy }}
|
||||
{% crispy form %}
|
||||
<div id="drop-container" class="drop-target">Drop image here
|
||||
<div id="drop-filenames"></div>
|
||||
</div>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,81 +1,155 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="question-display-block">
|
||||
<div class="hide-show">HIDE/SHOW</div>
|
||||
<div class="inner-display-block">
|
||||
Question <span class="question-number">No question loaded</span><br />
|
||||
<span id="question-title"></span><br />
|
||||
<span id="question-question"></span>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer" data-images="" data-annotations=''>
|
||||
<div class="container anatomy my-3">
|
||||
{% include 'user_score_header.html' %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-2 d-flex align-items-center justify-content-between">
|
||||
<div class="small text-muted">Filter questions by marks</div>
|
||||
<div>
|
||||
<select id="filter-score" class="form-select form-select-sm" style="min-width:120px;">
|
||||
<option value="all">All scores</option>
|
||||
<option value="2">2 (full)</option>
|
||||
<option value="1">1</option>
|
||||
<option value="0">0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for ans, score, correct_answer in answers_and_marks %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start" data-score="{{ score }}">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="me-3">
|
||||
<span class="badge bg-secondary">{{ forloop.counter }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">Question {{ forloop.counter }}</div>
|
||||
<div class="small text-muted">Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{ correct_answer }}</span>{% else %}—{% endif %}</div>
|
||||
<div class="mt-2"><pre class="mb-0">{{ ans }}</pre></div>
|
||||
{% if exam.publish_results or view_all_results %}
|
||||
<div class="small mt-2">
|
||||
Marks:
|
||||
{% if score == 2 %}
|
||||
<span class="badge bg-success ms-1">{{ score }}</span>
|
||||
{% elif score == 1 %}
|
||||
<span class="badge bg-warning text-dark ms-1">{{ score }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary ms-1">{{ score }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end ms-3">
|
||||
{% if exam.publish_results %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary view-question-button" data-qn={{forloop.counter0}}>View</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled>View</button>
|
||||
{% endif %}
|
||||
{% if request.user.is_staff %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2 admin-edit-button" data-qn="{{ forloop.counter0 }}">Edit</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'user_scores_footer.html' %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
{% if exam.publish_results %}
|
||||
<div class="card sticky-top" style="top:1rem;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Viewer</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" id="hide-viewer">Hide</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="viewer-block">
|
||||
<div class="viewer-body overflow-auto p-3" style="max-height: calc(100vh - 160px);">
|
||||
<div>Question <span class="question-number">No question loaded</span></div>
|
||||
<div id="question-title" class="fw-bold my-2"></div>
|
||||
<div id="question-question" class="mb-2"></div>
|
||||
<div id="single-dicom-viewer" class="dicom-viewer mb-2" data-images="" data-annotations=''></div>
|
||||
<div class="small text-muted">Answers:</div>
|
||||
<div class="answers mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="answers">Answers: </div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="anatomy">
|
||||
{% include 'user_score_header.html' %}
|
||||
<ul class="score-answer-list">
|
||||
{% for ans, score, correct_answer in answers_and_marks %}
|
||||
<li class="user-answer-li" data-question-number="{{forloop.counter}}">Question {{forloop.counter}} - Correct answer: {% if exam.publish_results %}<span class="correct-answer">{{correct_answer}}</span>{% endif %}
|
||||
<br/>
|
||||
<span class="user-answer-score user-answer-score-{{score}}">
|
||||
<span class="submitted-user-answer">
|
||||
<pre>{{ans}}</pre>
|
||||
</span>
|
||||
{% if exam.publish_results or view_all_results%}
|
||||
Marks: <span class="answer-score">{{score}}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="view-question-link" data-qn={{forloop.counter0}}>View</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% include 'user_scores_footer.html' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Ensure the sticky viewer stays within its column and the canvas scales */
|
||||
.anatomy .card.sticky-top { max-width: 100%; }
|
||||
/* Keep viewer usable by enforcing a minimum width on larger viewports */
|
||||
@media (min-width: 768px) {
|
||||
.anatomy .card.sticky-top { min-width: 600px; }
|
||||
}
|
||||
.anatomy .viewer-block .viewer-body { overflow: auto; }
|
||||
.viewer-block .answers { background: #f8f9fa; padding: .5rem; border-radius: .25rem; color: #212529; }
|
||||
.viewer-block .answers, .viewer-block .answers * { color: #212529 !important; }
|
||||
.viewer-block .answers pre { margin: 0; white-space: pre-wrap; }
|
||||
.dicom-viewer, .dicom-viewer .canvas-panel, .dicom-viewer .cornerstone-element { max-width: 100%; box-sizing: border-box; }
|
||||
.dicom-viewer canvas { max-width: 100% !important; width: 100% !important; height: auto !important; display: block; }
|
||||
#single-dicom-viewer { width: 100%; }
|
||||
/* hide horizontal overflow from the card body so the page doesn't scroll sideways */
|
||||
.anatomy .card .card-body { overflow-x: hidden; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$(".hide-show").click((evt, el) => {
|
||||
$(".inner-display-block").toggle();
|
||||
})
|
||||
{% if exam.publish_results %}
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$("#hide-viewer").on('click', function(){ $('.viewer-block .viewer-body').toggle(); });
|
||||
|
||||
$(".view-question-link").on("click", function (e) {
|
||||
console.log("click", e)
|
||||
question_number = e.currentTarget.dataset.qn;
|
||||
$("#single-dicom-viewer").empty();
|
||||
$.ajax({
|
||||
url: `{% url 'anatomy:question_review' pk=exam.pk %}`,
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{ csrf_token }}",
|
||||
question_number: question_number
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
|
||||
.done(function (data) {
|
||||
console.log(data);
|
||||
|
||||
let event = new CustomEvent('loadDicomViewerUrls', {
|
||||
"detail": { "images": data.images, "annotations": data.annotations }
|
||||
// Filter questions by score using Bootstrap's d-none for consistent layout
|
||||
$("#filter-score").on('change', function(){
|
||||
const val = $(this).val();
|
||||
$(".list-group-item[data-score]").each(function(){
|
||||
const s = $(this).data('score');
|
||||
if(val === 'all' || String(s) === String(val)) $(this).removeClass('d-none'); else $(this).addClass('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
window.dispatchEvent(event);
|
||||
$(".question-display-block .answers").empty().append(data.answers.toString());
|
||||
$("#question-title").empty().append(data.title);
|
||||
$("#question-question").empty().append(data.question);
|
||||
n = parseInt(question_number) + 1
|
||||
$(".question-display-block .question-number").empty().append(n);
|
||||
$(".inner-display-block").show();
|
||||
})
|
||||
.always(function () {
|
||||
console.log('[Done]');
|
||||
})
|
||||
})
|
||||
$(".view-question-button").on("click", function (e) {
|
||||
question_number = e.currentTarget.dataset.qn;
|
||||
$("#single-dicom-viewer").empty();
|
||||
$.ajax({
|
||||
url: `{% url 'anatomy:question_review' pk=exam.pk %}`,
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{ csrf_token }}",
|
||||
question_number: question_number
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
.done(function (data) {
|
||||
let event = new CustomEvent('loadDicomViewerUrls', { "detail": { "images": data.images, "annotations": data.annotations } });
|
||||
window.dispatchEvent(event);
|
||||
$(".viewer-block .answers").empty().append(data.answers.toString());
|
||||
$("#question-title").empty().append(data.title);
|
||||
$("#question-question").empty().append(data.question);
|
||||
n = parseInt(question_number) + 1
|
||||
$(".viewer-block .question-number").empty().append(n);
|
||||
$(".viewer-block .viewer-body").show();
|
||||
})
|
||||
.always(function () { console.log('[Done]'); });
|
||||
});
|
||||
|
||||
$(".view-question-link").first().click();
|
||||
|
||||
});
|
||||
</script>
|
||||
// auto-click first view button to populate viewer
|
||||
const firstView = $(".view-question-button:visible").first();
|
||||
if(firstView.length){ firstView.click(); }
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% load static %}
|
||||
<div id="exam-list">
|
||||
<form id="exam-edit-form">
|
||||
{% csrf_token %}
|
||||
{{ form.media }}
|
||||
<div class="input-group mb-2">
|
||||
{{ form.exam }}
|
||||
<button class="btn btn-sm btn-primary" type="button" hx-post="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-include="#id_exam" hx-target="#exam-list" hx-swap="innerHTML">Save</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('exam-list').innerHTML='';">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mb-2 small">Current exams:
|
||||
{% for exam in question.exams.all %}
|
||||
<form class="d-inline me-1" hx-post="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exam_id" value="{{ exam.id }}">
|
||||
<input type="hidden" name="remove" value="true">
|
||||
<button class="btn btn-sm btn-outline-danger mb-1" type="submit" hx-confirm="Are you sure you want to remove this exam?">{{ exam }}</button>
|
||||
</form>
|
||||
{% empty %}
|
||||
<span class="text-muted">No exams</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div id="exam-links" class="small" hx-swap-oob="outerHTML">
|
||||
{% if question.exams.all %}
|
||||
{% for exam in question.exams.all %}
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
No exams
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div id="primary-answer-container" class="position-relative primary-answer-wrap py-1">
|
||||
<strong>{{ question.get_primary_answer }}</strong>
|
||||
{% if can_edit %}
|
||||
<button
|
||||
class="edit-primary-btn btn btn-sm btn-outline-secondary position-absolute"
|
||||
style="right:0; top:0;"
|
||||
hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}"
|
||||
hx-target="#primary-answer-container"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="Edit primary answer"
|
||||
>
|
||||
Edit primary answer
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
/* Scoped styles for primary answer edit control: match outline-secondary look */
|
||||
.primary-answer-wrap .edit-primary-btn {
|
||||
opacity: 0.9;
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.primary-answer-wrap:hover .edit-primary-btn {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div id="primary-answer-container">
|
||||
<span class="small text-muted">No primary answer set</span>
|
||||
<button class="btn btn-sm btn-link" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">Add primary answer</button>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<div id="primary-answer-container">
|
||||
<form hx-post="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-target="#primary-answer-container" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="mb-2">
|
||||
<label for="id_existing_answer" class="form-label">Select existing answer</label>
|
||||
<select name="existing_answer" id="id_existing_answer" class="form-select">
|
||||
{% for val, text in form.existing_answer.field.choices %}
|
||||
<option value="{{ val }}" {% if val == form.initial.existing_answer %}selected{% endif %}>{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="id_new_answer" class="form-label">Or add a new primary answer</label>
|
||||
<textarea name="new_answer" id="id_new_answer" class="form-control" rows="3"></textarea>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const sel = document.getElementById('id_existing_answer');
|
||||
const ta = document.getElementById('id_new_answer');
|
||||
if(!sel || !ta) return;
|
||||
|
||||
function update() {
|
||||
try {
|
||||
if (ta.value && ta.value.trim().length > 0) {
|
||||
sel.disabled = true;
|
||||
} else {
|
||||
sel.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// initial state
|
||||
update();
|
||||
|
||||
// listeners
|
||||
sel.addEventListener('change', function(){
|
||||
if(this.value && this.value !== '') {
|
||||
ta.disabled = true;
|
||||
} else {
|
||||
ta.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
ta.addEventListener('input', update);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save primary answer</button>
|
||||
<button class="btn btn-secondary btn-sm" type="button" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}?cancel=1" hx-target="#primary-answer-container" hx-swap="outerHTML">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -9,8 +9,9 @@
|
||||
<span class="small text-muted">No suggestions</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<div class="mt-1">
|
||||
<button id="answer-suggest-incorrect-button" class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_suggest_incorrect_answers' question.pk %}" hx-target="#incorrect-answers" hx-swap="innerHTML">Edit suggestions</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'anatomy/base.html' %}
|
||||
{% load partials %}
|
||||
|
||||
{% block content %}
|
||||
{% load static %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
@@ -8,16 +8,30 @@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation=true style="min-height:300px">
|
||||
</div>
|
||||
<div class="mt-3 d-flex justify-content-between align-items-center">
|
||||
<details class="mb-0">
|
||||
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image help</summary>
|
||||
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist.</div>
|
||||
</details>
|
||||
|
||||
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
|
||||
<div id="anatomy-dicom-image" class="dicom-image-legacy w-100" data-url="{{ question.get_image_url }}" data-annotations='{{question.image_annotations}}' data-edit_annotation="{{ can_edit|yesno:'true,false' }}" style="min-height:300px">
|
||||
</div>
|
||||
{% if not question.image_annotations %}
|
||||
<div class="alert alert-warning mt-3" role="alert">
|
||||
<strong>No image annotations defined.</strong>
|
||||
<div class="small">If important markers or arrows are not burned onto the image, please add annotations using the image annotation tool and click <em>Save Annotations</em>. Please note not all questions required image annotations in which case you can ignore this message.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_edit %}
|
||||
<div class="mt-3 align-items-start">
|
||||
<div class="col" style="min-width:0;">
|
||||
<details class="mb-0">
|
||||
<summary class="small text-muted"><i class="bi bi-info-circle"></i> Image annotation help</summary>
|
||||
<div class="small text-muted mt-2">Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. Click <strong>Save Annotations</strong> to persist. If required use the middle button to zoom and the left button to pan.</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="d-flex gap-2">
|
||||
<button id="save-annotations" class="btn btn-sm btn-primary">Save Annotations</button>
|
||||
<button id="delete-annotations" class="btn btn-sm btn-outline-danger">Delete Annotations</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,9 +50,29 @@
|
||||
</div>
|
||||
|
||||
<h6 class="mt-2">Primary answer</h6>
|
||||
<div class="mb-2"><strong>{{ question.get_primary_answer }}</strong></div>
|
||||
{% if question.primary_answer %}
|
||||
{% include 'anatomy/partials/primary_answer_display.html' %}
|
||||
{% else %}
|
||||
<div id="primary-answer-container" hx-get="{% url 'anatomy:primary_answer_htmx' pk=question.pk %}" hx-trigger="load" hx-swap="outerHTML"></div>
|
||||
{% endif %}
|
||||
|
||||
<h6>Answers</h6>
|
||||
<style>
|
||||
.answer.correct { color: #155724; }
|
||||
.answer.half-correct { color: #856404; }
|
||||
.answer.incorrect { color: #721c24; }
|
||||
.marked-correct { border-left: 4px solid #2a9d3f; }
|
||||
.marked-half-correct { border-left: 4px solid #ffb020; }
|
||||
.marked-incorrect { border-left: 4px solid #d3413a; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6 class="mb-0">Answers</h6>
|
||||
<div>
|
||||
{% if can_edit %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">Edit answers</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<details class="mb-3">
|
||||
<summary class="small">Show answers</summary>
|
||||
<table class="table table-sm mt-2">
|
||||
@@ -47,7 +81,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for answer in question.answers.all|dictsortreversed:"status" %}
|
||||
<tr>
|
||||
{% comment %}Add visual mark state classes consistent with mark2{% endcomment %}
|
||||
<tr class="{% if answer.status == '2' %}marked-correct{% elif answer.status == '1' %}marked-half-correct{% elif answer.status == '0' %}marked-incorrect{% endif %}">
|
||||
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"{% endif %}>
|
||||
{% if answer.status == "0" %}
|
||||
<div class="d-flex align-items-start">
|
||||
@@ -63,9 +98,19 @@
|
||||
<pre class="mb-0">{{ answer.answer }}</pre>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ answer.get_status_display|default:answer.status }}</td>
|
||||
<td>
|
||||
{% if answer.proposed %}
|
||||
{% if answer.status == '2' %}
|
||||
<span class="answer correct">{{ answer.get_status_display|default:'Correct' }}</span>
|
||||
{% elif answer.status == '1' %}
|
||||
<span class="answer half-correct">{{ answer.get_status_display|default:'Half mark' }}</span>
|
||||
{% elif answer.status == '0' %}
|
||||
<span class="answer incorrect">{{ answer.get_status_display|default:'Incorrect' }}</span>
|
||||
{% else %}
|
||||
<span class="answer not-marked">{{ answer.get_status_display|default:'Unmarked' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if answer.proposed and can_edit %}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-success accept-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:confirm_answer' answer.id %}" title="Accept">Accept</button>
|
||||
<button class="btn btn-outline-danger delete-button" data-aid={{answer.id}} hx-get="{% url 'anatomy:delete_answer' answer.id %}" title="Delete">Delete</button>
|
||||
@@ -111,15 +156,12 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<h6>Exams</h6>
|
||||
<div class="small">
|
||||
{% for exam in question.exams.all %}
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}" class="me-2">{{ exam }}</a>
|
||||
{% empty %}
|
||||
No exams
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
|
||||
<span id="exam-list"></span></div>
|
||||
{% include 'anatomy/partials/exam_links.html' %}
|
||||
{% if can_edit %}
|
||||
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}" hx-target="#exam-list" hx-swap="innerHTML">Edit exam(s)</button>
|
||||
<span id="exam-list"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include 'question_notes.html' %}
|
||||
@@ -127,7 +169,13 @@
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="small">Annotation JSON</summary>
|
||||
<div class="mt-2 small">{% if question.image_annotations %}<pre class="mb-0">{{ question.image_annotations }}</pre>{% else %}No saved annotations.{% endif %}</div>
|
||||
<div class="mt-2 small">
|
||||
{% if question.image_annotations %}
|
||||
{% include 'atlas/partials/json_pretty.html' with json_value=question.image_annotations title='Image annotations' %}
|
||||
{% else %}
|
||||
No saved annotations.
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -174,6 +222,29 @@
|
||||
console.log('[Done]');
|
||||
})
|
||||
})
|
||||
$("#delete-annotations").click(function () {
|
||||
if (!confirm('Clear saved annotations for this question? This cannot be undone.')) return;
|
||||
$.ajax({
|
||||
url: "{% url 'anatomy:question_clear_annotation' pk=question.pk %}",
|
||||
data: {
|
||||
csrfmiddlewaretoken: "{{ csrf_token }}",
|
||||
},
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
})
|
||||
.done(function (data) {
|
||||
if (data.status == "success") {
|
||||
toastr.info('Annotations cleared');
|
||||
// Optionally reload part of the page or remove the annotation overlay
|
||||
location.reload();
|
||||
} else {
|
||||
toastr.warning('Error clearing annotations');
|
||||
}
|
||||
})
|
||||
.fail(function () {
|
||||
toastr.warning('Error clearing annotations');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -42,18 +42,19 @@
|
||||
<i class="bi bi-eye me-2"></i> View
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
|
||||
<i class="bi bi-pencil-square me-2"></i> Edit
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
|
||||
<i class="bi bi-list-check me-2"></i> Edit Answers
|
||||
</a>
|
||||
</li>
|
||||
{% if can_edit %}
|
||||
<li>
|
||||
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
|
||||
<i class="bi bi-pencil-square me-2"></i> Edit
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
|
||||
<i class="bi bi-list-check me-2"></i> Edit Answers
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url 'anatomy:question_clone' question.id %}">
|
||||
<i class="bi bi-files me-2"></i> Clone
|
||||
@@ -66,6 +67,7 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if can_edit %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
<li>
|
||||
@@ -73,6 +75,7 @@
|
||||
<i class="bi bi-trash me-2"></i> Delete
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
@@ -33,6 +33,11 @@ urlpatterns = [
|
||||
views.question_save_annotation,
|
||||
name="question_save_annotation",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/clear_annotation",
|
||||
views.question_clear_annotation,
|
||||
name="question_clear_annotation",
|
||||
),
|
||||
path(
|
||||
"question/<int:pk>/suggest_incorrect_answers",
|
||||
views.question_suggest_incorrect_answers,
|
||||
@@ -114,6 +119,7 @@ urlpatterns = [
|
||||
path("exam/<int:exam_pk>/<int:sk>/mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"),
|
||||
path("question/<int:pk>/mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"),
|
||||
path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"),
|
||||
path("question/<int:pk>/primary_answer/", views.primary_answer_htmx, name="primary_answer_htmx"),
|
||||
]
|
||||
|
||||
urlpatterns.extend(generic_view_urls(views.GenericViews))
|
||||
|
||||
+166
-137
@@ -30,6 +30,7 @@ from dal import autocomplete
|
||||
from django.conf import settings
|
||||
from django.utils.html import format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
from .forms import (
|
||||
@@ -46,6 +47,7 @@ from .forms import (
|
||||
AnatomyQuestionForm,
|
||||
ExamForm,
|
||||
SuggestIncorrectAnswerForm,
|
||||
PrimaryAnswerForm,
|
||||
)
|
||||
from .models import (
|
||||
AnatomyQuestion,
|
||||
@@ -58,6 +60,21 @@ from .models import (
|
||||
# HalfMarkAnswers,
|
||||
# IncorrectAnswers,
|
||||
)
|
||||
from generic.widgets import ExamSearchWidget
|
||||
|
||||
|
||||
class AddExamForm(forms.Form):
|
||||
exam = forms.ModelChoiceField(queryset=Exam.objects.none(), required=True, widget=ExamSearchWidget(exam_model=Exam), label="Exam to add")
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if user is None:
|
||||
qs = Exam.objects.none()
|
||||
elif user.groups.filter(name="anatomy_checker").exists():
|
||||
qs = Exam.objects.all()
|
||||
else:
|
||||
qs = Exam.objects.filter(author__id=user.id) | Exam.objects.filter(open_access=True)
|
||||
self.fields["exam"].queryset = qs.distinct()
|
||||
from generic.models import CidUser, Examination
|
||||
from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamCreateBase, ExamDeleteBase, ExamGroupsUpdateBase, ExamUpdateBase, ExamViews, GenericViewBase, UpdateQuestionMixin
|
||||
from reversion.views import RevisionMixin
|
||||
@@ -786,6 +803,9 @@ def mark2_overview(request, pk):
|
||||
"""Overview page for mark2 workflow: lists questions and unmarked counts and links into mark2."""
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
if not GenericExamViews.check_user_marker_access(request.user, pk):
|
||||
raise PermissionDenied
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
@@ -1045,102 +1065,6 @@ def mark2_question_marked(request, pk):
|
||||
return render(request, 'anatomy/partials/mark2_question_marked_fragment.html', context)
|
||||
|
||||
|
||||
|
||||
#@login_required
|
||||
#def exam_scores_refresh(request, pk):
|
||||
# exam = get_object_or_404(Exam, pk=pk)
|
||||
#
|
||||
# if not exam.exam_mode:
|
||||
# raise Http404("Packet not in exam mode")
|
||||
#
|
||||
# questions = exam.exam_questions.all() # Don't worry about order here
|
||||
#
|
||||
# cids = UserAnswer.objects.filter(question__in=questions, exam__id=pk)
|
||||
#
|
||||
# # Force a score update
|
||||
# for c in cids:
|
||||
# c.get_answer_score(cached=False)
|
||||
#
|
||||
# return render(
|
||||
# request,
|
||||
# "anatomy/base.html",
|
||||
# {
|
||||
# "simple_content": "Answer scores updated",
|
||||
# },
|
||||
# )
|
||||
|
||||
|
||||
def exam_scores_cid_user(request, pk, cid, passcode):
|
||||
exam = get_object_or_404(Exam, pk=pk)
|
||||
|
||||
if not exam.exam_mode:
|
||||
raise Http404("Packet not in exam mode")
|
||||
|
||||
if not exam.check_cid_user(cid, passcode, request.user):
|
||||
raise Http404("Error accessing exam")
|
||||
|
||||
questions = exam.get_questions().prefetch_related("answers")
|
||||
|
||||
answers_and_marks = []
|
||||
answers_marks = []
|
||||
answers = []
|
||||
|
||||
view_all_results = False
|
||||
if request.user.groups.filter(name="view_all_results").exists():
|
||||
view_all_results = True
|
||||
|
||||
for q in questions:
|
||||
# Get user answer
|
||||
user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first()
|
||||
if not user_answer:
|
||||
# skip if no answer
|
||||
answers_marks.append(0)
|
||||
answers.append("")
|
||||
answer_score = 0
|
||||
ans = "Not answered"
|
||||
else:
|
||||
ans = user_answer.answer
|
||||
|
||||
answer_score = user_answer.get_answer_score()
|
||||
|
||||
correct_answer = q.get_primary_answer()
|
||||
|
||||
if not exam.publish_results and not view_all_results:
|
||||
correct_answer = "*****"
|
||||
answer_score = 0
|
||||
answers.append(ans)
|
||||
answers_marks.append(answer_score)
|
||||
answers_and_marks.append((ans, answer_score, correct_answer))
|
||||
|
||||
if "unmarked" in answers_marks:
|
||||
answered = [i for i in answers_marks if type(i) == int]
|
||||
total_score = sum(answered)
|
||||
unmarked_number = len(answers_marks) - len(answered)
|
||||
total_score = "{} ({} unmarked)".format(total_score, unmarked_number)
|
||||
else:
|
||||
total_score = sum(answers_marks)
|
||||
|
||||
max_score = len(questions) * 2
|
||||
|
||||
return render(
|
||||
request,
|
||||
"anatomy/exam_scores_user.html",
|
||||
{
|
||||
"exam": exam,
|
||||
"cid": cid,
|
||||
"passcode": passcode,
|
||||
"questions": questions,
|
||||
"answers": answers,
|
||||
"answers_marks": answers_marks,
|
||||
"total_score": total_score,
|
||||
"max_score": max_score,
|
||||
"answers_and_marks": answers_and_marks,
|
||||
"view_all_results": view_all_results,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||
model = AnatomyQuestion
|
||||
form_class = AnatomyQuestionForm
|
||||
@@ -1318,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,
|
||||
@@ -1344,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,
|
||||
@@ -1358,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,
|
||||
@@ -1596,6 +1538,17 @@ def question_save_annotation(request, pk):
|
||||
return JsonResponse(data, status=400)
|
||||
|
||||
|
||||
@user_is_author_or_anatomy_checker
|
||||
def question_clear_annotation(request, pk):
|
||||
"""Clear the stored image annotations for a question."""
|
||||
if request.method == "POST":
|
||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||
question.image_annotations = ""
|
||||
question.save(update_fields=["image_annotations"])
|
||||
return JsonResponse({"status": "success"}, status=200)
|
||||
return JsonResponse({"status": "error"}, status=400)
|
||||
|
||||
|
||||
GenericExamViews = ExamViews(
|
||||
Exam, AnatomyQuestion, Answer, UserAnswer, "anatomy", "anatomy"
|
||||
)
|
||||
@@ -1708,44 +1661,120 @@ def question_add_exam(request, question_id: int):
|
||||
return HttpResponse("Invalid permissions", status=403)
|
||||
|
||||
|
||||
if request.POST:
|
||||
# POST: handle add/remove; if exam_id provided use existing behaviour
|
||||
if request.method == "POST":
|
||||
exam_id = request.POST.get("exam_id", None)
|
||||
|
||||
if exam_id is None:
|
||||
return HttpResponse("No exam id provided", status=400)
|
||||
if exam_id:
|
||||
exam = get_object_or_404(Exam, pk=exam_id)
|
||||
if request.POST.get("remove", False) == "true":
|
||||
question.exams.remove(exam)
|
||||
else:
|
||||
question.exams.add(exam)
|
||||
|
||||
exam = get_object_or_404(Exam, pk=exam_id)
|
||||
# return out-of-band swap: update exam links and remove the editor
|
||||
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
|
||||
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
|
||||
return HttpResponse(mark_safe(links_html + oob_remove))
|
||||
|
||||
if request.POST.get("remove", False) == "true":
|
||||
question.exams.remove(exam)
|
||||
return HttpResponse("Question removed from exam.")
|
||||
# else try to process from the search form or widget-provided values
|
||||
# The ExamSearchWidget may submit a list; accept first value if present.
|
||||
exam_vals = request.POST.getlist('exam') if hasattr(request.POST, 'getlist') else []
|
||||
if not exam_vals:
|
||||
# also accept single 'exam' param
|
||||
single = request.POST.get('exam')
|
||||
if single:
|
||||
exam_vals = [single]
|
||||
|
||||
if exam_vals:
|
||||
try:
|
||||
exam = get_object_or_404(Exam, pk=int(exam_vals[0]))
|
||||
except (ValueError, TypeError):
|
||||
exam = None
|
||||
|
||||
if exam:
|
||||
if request.POST.get("remove", False) == "true":
|
||||
question.exams.remove(exam)
|
||||
else:
|
||||
question.exams.add(exam)
|
||||
|
||||
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
|
||||
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
|
||||
return HttpResponse(mark_safe(links_html + oob_remove))
|
||||
|
||||
# else try to process via bound form (fallback)
|
||||
form = AddExamForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
exam = form.cleaned_data.get("exam")
|
||||
if request.POST.get("remove", False) == "true":
|
||||
question.exams.remove(exam)
|
||||
else:
|
||||
question.exams.add(exam)
|
||||
|
||||
links_html = render_to_string("anatomy/partials/exam_links.html", {"question": question}, request=request)
|
||||
oob_remove = '<div id="exam-list" hx-swap-oob="outerHTML"></div>'
|
||||
return HttpResponse(mark_safe(links_html + oob_remove))
|
||||
else:
|
||||
question.exams.add(exam)
|
||||
# render form with errors
|
||||
return render(request, "anatomy/partials/exam_edit_fragment.html", {"form": form, "question": question})
|
||||
|
||||
return HttpResponse("Question added to exam.")
|
||||
# GET: render the fragment with the add form and current exams
|
||||
form = AddExamForm(user=request.user)
|
||||
return render(request, "anatomy/partials/exam_edit_fragment.html", {"form": form, "question": question})
|
||||
|
||||
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
@login_required
|
||||
def primary_answer_htmx(request, pk: int):
|
||||
"""HTMX endpoint to render or update a question's primary answer.
|
||||
|
||||
GET: render the form for selecting/creating primary answer.
|
||||
POST: create/select answer and set `question.primary_answer`, then
|
||||
return the display fragment.
|
||||
"""
|
||||
if not request.htmx:
|
||||
raise PermissionDenied()
|
||||
|
||||
question = get_object_or_404(AnatomyQuestion, pk=pk)
|
||||
|
||||
if not question.can_edit(request.user):
|
||||
return HttpResponse("Invalid permissions", status=403)
|
||||
|
||||
if request.method == "POST":
|
||||
form = PrimaryAnswerForm(request.POST, question=question)
|
||||
if form.is_valid():
|
||||
new_text = form.cleaned_data.get("new_answer", "").strip()
|
||||
existing = form.cleaned_data.get("existing_answer", "")
|
||||
|
||||
if new_text:
|
||||
# create a new Answer and mark it CORRECT
|
||||
ans = Answer(question=question, answer=new_text, proposed=False, status=Answer.MarkOptions.CORRECT)
|
||||
ans.save()
|
||||
question.primary_answer = ans
|
||||
question.save(update_fields=["primary_answer"])
|
||||
elif existing:
|
||||
try:
|
||||
aid = int(existing)
|
||||
ans = Answer.objects.get(pk=aid, question=question)
|
||||
question.primary_answer = ans
|
||||
question.save(update_fields=["primary_answer"])
|
||||
except (ValueError, Answer.DoesNotExist):
|
||||
return HttpResponse("Invalid answer choice", status=400)
|
||||
|
||||
# return display fragment
|
||||
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question, "can_edit": question.can_edit(request.user)})
|
||||
else:
|
||||
# Return a list of exams that we can add to the question
|
||||
return HttpResponse("Invalid form", status=400)
|
||||
|
||||
exams = Exam.objects.filter(author=request.user)
|
||||
exams = exams.difference(question.exams.all())
|
||||
# Handle cancel request to restore original state
|
||||
if request.method == "GET" and request.GET.get("cancel"):
|
||||
if question.primary_answer:
|
||||
return render(request, "anatomy/partials/primary_answer_display.html", {"question": question, "can_edit": question.can_edit(request.user)})
|
||||
else:
|
||||
return render(request, "anatomy/partials/primary_answer_empty.html", {"question": question, "can_edit": question.can_edit(request.user)})
|
||||
|
||||
if not exams:
|
||||
return HttpResponse("No exams available to add")
|
||||
|
||||
html = "Exams to add to question: <br>"
|
||||
for exam in exams:
|
||||
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><button hx-post=\"{request.path}\""
|
||||
f">{exam.name}: {exam.id}</button></form>"
|
||||
)
|
||||
html = html + "<br>Exams to remove from question: <br>"
|
||||
for exam in question.exams.all():
|
||||
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><input name='remove' value='true' type='hidden'><button hx-post=\"{request.path}\""
|
||||
f"hx-confirm='Are you sure you want to remove this exam from the question?'"
|
||||
f">{exam.name}: {exam.id}</button></form>"
|
||||
)
|
||||
html = html + "<button _='on click remove #htmx-exam-list'>Cancel</button>"
|
||||
|
||||
return HttpResponse(mark_safe(html))
|
||||
|
||||
|
||||
raise PermissionDenied()
|
||||
# GET - render form
|
||||
form = PrimaryAnswerForm(question=question)
|
||||
return render(request, "anatomy/partials/primary_answer_form.html", {"form": form, "question": question, "can_edit": question.can_edit(request.user)})
|
||||
@@ -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`
|
||||
+203
-1
@@ -1,4 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from .models import (
|
||||
Case,
|
||||
CaseDetail,
|
||||
@@ -15,24 +16,33 @@ from .models import (
|
||||
Structure,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
Procedure,
|
||||
UncategorisedDicom,
|
||||
SeriesDetail,
|
||||
Resource,
|
||||
CaseDisplaySet,
|
||||
UserReportAnswer,
|
||||
CidReportAnswer,
|
||||
APIToken,
|
||||
)
|
||||
|
||||
from django.forms import ModelForm
|
||||
|
||||
from reversion.admin import VersionAdmin
|
||||
from django.forms.widgets import RadioSelect
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
# from generic.models import Examination, Sign, Site, Condition
|
||||
from generic.models import Examination
|
||||
|
||||
try:
|
||||
from django_tasks_db.models import DBTaskResult
|
||||
except ImportError:
|
||||
DBTaskResult = None
|
||||
|
||||
# Register your models here.
|
||||
# admin.site.register(Examination)
|
||||
# admin.site.register(Site)
|
||||
@@ -51,6 +61,7 @@ admin.site.register(UncategorisedDicom)
|
||||
admin.site.register(SeriesDetail)
|
||||
admin.site.register(Resource)
|
||||
admin.site.register(CaseDisplaySet)
|
||||
admin.site.register(Procedure)
|
||||
admin.site.register(UserReportAnswer)
|
||||
admin.site.register(CidReportAnswer)
|
||||
|
||||
@@ -82,7 +93,7 @@ admin.site.register(CaseCollection, CaseCollectionAdmin)
|
||||
|
||||
|
||||
class NormalCaseAdmin(VersionAdmin):
|
||||
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
|
||||
list_display = ("case", "display_age", "examination", "modality", "added_date")
|
||||
list_filter = ("examination", "modality")
|
||||
search_fields = ("case__title", "case__pk")
|
||||
|
||||
@@ -140,3 +151,194 @@ class SeriesAdmin(VersionAdmin):
|
||||
|
||||
|
||||
admin.site.register(Series, SeriesAdmin)
|
||||
|
||||
|
||||
class APITokenAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "name", "revoked", "created", "expires", "last_used")
|
||||
search_fields = ("user__username", "name")
|
||||
readonly_fields = ("token_hash", "created", "last_used")
|
||||
|
||||
|
||||
admin.site.register(APIToken, APITokenAdmin)
|
||||
|
||||
|
||||
if DBTaskResult is not None:
|
||||
try:
|
||||
# Ensure package admin registration has run before we customize it.
|
||||
import django_tasks_db.admin # noqa: F401
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def traceback_link(self, obj):
|
||||
if not obj.traceback:
|
||||
return "-"
|
||||
url = reverse(
|
||||
f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
|
||||
args=[obj.pk],
|
||||
)
|
||||
return format_html('<a href="{}">View full traceback</a>', url)
|
||||
|
||||
traceback_link.short_description = "Traceback"
|
||||
|
||||
def traceback_pretty(self, obj):
|
||||
if not obj.traceback:
|
||||
return ""
|
||||
return format_html(
|
||||
'<pre style="white-space: pre-wrap; margin: 0;">{}</pre>',
|
||||
obj.traceback,
|
||||
)
|
||||
|
||||
traceback_pretty.short_description = "Traceback"
|
||||
|
||||
@admin.action(description="Reset FAILED tasks to READY")
|
||||
def reset_failed_to_ready(self, request, queryset):
|
||||
failed_qs = queryset.filter(status="FAILED")
|
||||
updated = failed_qs.update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f"Reset {updated} failed task(s) to READY.",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Reset selected tasks to READY (except RUNNING)")
|
||||
def reset_selected_to_ready(self, request, queryset):
|
||||
blocked = queryset.filter(status="RUNNING").count()
|
||||
reset_qs = queryset.exclude(status="RUNNING")
|
||||
updated = reset_qs.update(
|
||||
status="READY",
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
worker_ids="",
|
||||
exception_class_path="",
|
||||
traceback="",
|
||||
return_value=None,
|
||||
)
|
||||
msg = f"Reset {updated} task(s) to READY."
|
||||
if blocked:
|
||||
msg += f" Skipped {blocked} running task(s)."
|
||||
self.message_user(request, msg, level=messages.SUCCESS)
|
||||
|
||||
@admin.action(description="Delete selected COMPLETED tasks")
|
||||
def delete_selected_completed(self, request, queryset):
|
||||
completed_qs = queryset.filter(status="SUCCESSFUL")
|
||||
deleted, _ = completed_qs.delete()
|
||||
self.message_user(
|
||||
request,
|
||||
f"Deleted {deleted} completed task record(s).",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Delete selected FAILED tasks")
|
||||
def delete_selected_failed(self, request, queryset):
|
||||
failed_qs = queryset.filter(status="FAILED")
|
||||
deleted, _ = failed_qs.delete()
|
||||
self.message_user(
|
||||
request,
|
||||
f"Deleted {deleted} failed task record(s).",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
task_admin = admin.site._registry.get(DBTaskResult)
|
||||
if task_admin is not None:
|
||||
admin_cls = task_admin.__class__
|
||||
admin_cls.list_display = (
|
||||
"id",
|
||||
"task_path",
|
||||
"status",
|
||||
"queue_name",
|
||||
"priority",
|
||||
"enqueued_at",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"worker_ids",
|
||||
"exception_class_path",
|
||||
"traceback_link",
|
||||
)
|
||||
admin_cls.list_filter = ("status", "queue_name", "task_path", "backend_name")
|
||||
admin_cls.search_fields = (
|
||||
"id",
|
||||
"task_path",
|
||||
"queue_name",
|
||||
"worker_ids",
|
||||
"exception_class_path",
|
||||
"traceback",
|
||||
)
|
||||
admin_cls.ordering = ("-enqueued_at",)
|
||||
admin_cls.actions = (
|
||||
"reset_failed_to_ready",
|
||||
"reset_selected_to_ready",
|
||||
"delete_selected_completed",
|
||||
"delete_selected_failed",
|
||||
)
|
||||
admin_cls.readonly_fields = (
|
||||
"id",
|
||||
"status",
|
||||
"enqueued_at",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"args_kwargs",
|
||||
"priority",
|
||||
"task_path",
|
||||
"worker_ids",
|
||||
"queue_name",
|
||||
"backend_name",
|
||||
"run_after",
|
||||
"return_value",
|
||||
"exception_class_path",
|
||||
"traceback_pretty",
|
||||
)
|
||||
admin_cls.fieldsets = (
|
||||
(
|
||||
"Task",
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"task_path",
|
||||
"status",
|
||||
"queue_name",
|
||||
"priority",
|
||||
"backend_name",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timing",
|
||||
{
|
||||
"fields": (
|
||||
"enqueued_at",
|
||||
"run_after",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"worker_ids",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Payload",
|
||||
{"fields": ("args_kwargs", "return_value")},
|
||||
),
|
||||
(
|
||||
"Error",
|
||||
{
|
||||
"fields": ("exception_class_path", "traceback_pretty"),
|
||||
},
|
||||
),
|
||||
)
|
||||
admin_cls.traceback_link = traceback_link
|
||||
admin_cls.traceback_pretty = traceback_pretty
|
||||
admin_cls.reset_failed_to_ready = reset_failed_to_ready
|
||||
admin_cls.reset_selected_to_ready = reset_selected_to_ready
|
||||
admin_cls.delete_selected_completed = delete_selected_completed
|
||||
admin_cls.delete_selected_failed = delete_selected_failed
|
||||
admin_cls.has_add_permission = has_add_permission
|
||||
|
||||
+383
-74
@@ -2,6 +2,8 @@ from collections import defaultdict
|
||||
from typing import List, Tuple
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
import json
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from ninja import ModelSchema, Router, Schema, Field
|
||||
|
||||
from django.db import transaction, IntegrityError
|
||||
@@ -14,14 +16,22 @@ from pydicom.errors import InvalidDicomError
|
||||
from generic.decorators import check_user_in_group
|
||||
from generic.constants import Group
|
||||
|
||||
from ninja.security import django_auth
|
||||
from ninja.security import django_auth, HttpBearer
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from ninja import NinjaAPI, File
|
||||
from ninja.files import UploadedFile
|
||||
import hashlib
|
||||
import secrets
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import authenticate
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.middleware.csrf import CsrfViewMiddleware
|
||||
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom
|
||||
from .models import Case, DuplicateDicom, Series, SeriesImage, UncategorisedDicom, APIToken
|
||||
from atlas.helpers import get_cases_available_to_user
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -29,6 +39,72 @@ from loguru import logger
|
||||
router = Router()
|
||||
|
||||
|
||||
class BearerAuth(HttpBearer):
|
||||
"""Ninja HttpBearer auth that validates APIToken entries.
|
||||
|
||||
Returns the token owner user on success or raises HttpError(401) on failure.
|
||||
"""
|
||||
def authenticate(self, request, token: str):
|
||||
if not token:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
api_token = APIToken.objects.get(token_hash=token_hash)
|
||||
except APIToken.DoesNotExist:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
if api_token.revoked:
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
if api_token.expires and api_token.expires < timezone.now():
|
||||
raise HttpError(status_code=401)
|
||||
|
||||
try:
|
||||
api_token.mark_used()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attach for downstream handlers
|
||||
request.api_token = api_token
|
||||
# Also set `request.user` so code that expects Django's user works
|
||||
try:
|
||||
request.user = api_token.user
|
||||
# Some middleware caches the user on `_cached_user`
|
||||
setattr(request, "_cached_user", api_token.user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return api_token.user
|
||||
|
||||
|
||||
class SessionOrBearer:
|
||||
"""Composite auth: prefer Django session (request.user), fall back to bearer token.
|
||||
|
||||
Use as `auth=SessionOrBearer()` on Ninja router endpoints.
|
||||
"""
|
||||
def __call__(self, request):
|
||||
# Session-backed auth already populated by Django middleware
|
||||
if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
|
||||
return request.user
|
||||
|
||||
# Try bearer token from header
|
||||
auth = request.META.get("HTTP_AUTHORIZATION")
|
||||
if not auth:
|
||||
raise HttpError(status_code=401)
|
||||
parts = auth.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HttpError(status_code=401)
|
||||
token = parts[1]
|
||||
# reuse BearerAuth logic
|
||||
bearer = BearerAuth()
|
||||
return bearer.authenticate(request, token)
|
||||
|
||||
|
||||
# Backwards-compatible alias used in existing decorators
|
||||
TokenAuth = SessionOrBearer
|
||||
|
||||
|
||||
class SeriesSchema(ModelSchema):
|
||||
case_id: List[int] = []
|
||||
|
||||
@@ -53,7 +129,7 @@ class CaseSchema(ModelSchema):
|
||||
fields = ["id", "title"]
|
||||
|
||||
|
||||
@router.post("/upload_dicom", auth=django_auth)
|
||||
@router.post("/upload_dicom", auth=TokenAuth())
|
||||
def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
uploaded = []
|
||||
duplicate = []
|
||||
@@ -70,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)
|
||||
|
||||
@@ -86,7 +159,7 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate_image_hash", auth=django_auth)
|
||||
@router.post("/generate_image_hash", auth=TokenAuth())
|
||||
def generate_image_hash(request, id: int):
|
||||
s = SeriesImage.objects.get(pk=id)
|
||||
s.generate_hashes()
|
||||
@@ -98,7 +171,7 @@ def generate_image_hash(request, id: int):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/clear_dicoms", auth=django_auth)
|
||||
@router.post("/clear_dicoms", auth=TokenAuth())
|
||||
def clear_dicoms(request):
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
@@ -113,7 +186,7 @@ def clear_dicoms(request):
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/uncategorised_dicoms", auth=django_auth)
|
||||
@router.get("/uncategorised_dicoms", auth=TokenAuth())
|
||||
def uncategorised_dicoms(request):
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
@@ -127,13 +200,15 @@ def uncategorised_dicoms(request):
|
||||
return data
|
||||
|
||||
|
||||
def import_dicoms_helper(request, case_id: int | None = None):
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
series_instance_uid__in=request.POST.getlist("selection")
|
||||
)
|
||||
else:
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
|
||||
# Allow callers to pass an explicit queryset of UncategorisedDicom objects
|
||||
if dicoms is None:
|
||||
if "selection" in request.POST:
|
||||
dicoms = UncategorisedDicom.objects.filter(
|
||||
series_instance_uid__in=request.POST.getlist("selection")
|
||||
)
|
||||
else:
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
if "order-series" in request.POST:
|
||||
order = request.POST["order-series"]
|
||||
@@ -145,6 +220,7 @@ def import_dicoms_helper(request, case_id: int | None = 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
|
||||
@@ -161,6 +237,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
defaults={
|
||||
"modality": modality,
|
||||
"description": tags.get("SeriesDescription", ""),
|
||||
"study_instance_uid": study_uid,
|
||||
}
|
||||
)
|
||||
if created and tags.get("StudyDescription"):
|
||||
@@ -175,7 +252,12 @@ def import_dicoms_helper(request, case_id: int | None = 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)
|
||||
if case_id is not None:
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
@@ -212,7 +294,7 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import_dicoms", auth=django_auth, response=List[Tuple[SeriesSchema, str]]
|
||||
"/import_dicoms", auth=TokenAuth(), response=List[Tuple[SeriesSchema, str]]
|
||||
)
|
||||
def import_dicoms(request):
|
||||
return import_dicoms_helper(request)
|
||||
@@ -220,19 +302,73 @@ def import_dicoms(request):
|
||||
|
||||
@router.post(
|
||||
"/import_dicoms/{case_id}",
|
||||
auth=django_auth,
|
||||
auth=TokenAuth(),
|
||||
response=List[Tuple[SeriesSchema, str]],
|
||||
)
|
||||
def import_dicoms_case(request, case_id: int):
|
||||
return import_dicoms_helper(request, case_id=case_id)
|
||||
|
||||
|
||||
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
|
||||
@router.post("/upload_dicom_case/{case_id}", auth=TokenAuth())
|
||||
def upload_dicom_case(request, case_id: int, files: List[UploadedFile] = File(...)):
|
||||
"""Upload DICOM files and immediately import them into the given case.
|
||||
|
||||
This saves each uploaded file as an `UncategorisedDicom` owned by the user,
|
||||
then calls the existing import helper which will create Series/SeriesImage
|
||||
objects and attach them to the specified case.
|
||||
"""
|
||||
uploaded = []
|
||||
duplicate = []
|
||||
failed = []
|
||||
duplicate_series = set()
|
||||
|
||||
created_ids = []
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
ud = UncategorisedDicom(image=file, user=request.user)
|
||||
ud.save()
|
||||
|
||||
uploaded.append((file.name, ud.image_blake3_hash))
|
||||
created_ids.append(ud.pk)
|
||||
except DuplicateDicom as e:
|
||||
duplicate.append((file.name, getattr(ud, "image_blake3_hash", None)))
|
||||
|
||||
dup_obj = getattr(e, "duplicate", None)
|
||||
if isinstance(dup_obj, SeriesImage):
|
||||
try:
|
||||
if dup_obj.series is not None:
|
||||
duplicate_series.add(dup_obj.series.get_absolute_url())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
continue
|
||||
except InvalidDicomError:
|
||||
failed.append(file.name)
|
||||
|
||||
# Import into the target case (consume only the uploaded UncategorisedDicom objects)
|
||||
if created_ids:
|
||||
try:
|
||||
dicom_qs = UncategorisedDicom.objects.filter(pk__in=created_ids)
|
||||
import_dicoms_helper(request, case_id=case_id, dicoms=dicom_qs)
|
||||
except Exception:
|
||||
# don't let import errors prevent returning upload status
|
||||
logger.error("Error importing dicoms after upload", exc_info=True)
|
||||
|
||||
return {
|
||||
"uploaded": uploaded,
|
||||
"duplicates": duplicate,
|
||||
"failed": failed,
|
||||
"duplicate_series": list(duplicate_series),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/orphan_series", auth=TokenAuth(), response=List[SeriesSchema])
|
||||
def orphan_series(request):
|
||||
return request.user.series.filter(case=None)
|
||||
|
||||
|
||||
@router.get("/series_remove_duplicate_images", auth=django_auth)
|
||||
@router.get("/series_remove_duplicate_images", auth=TokenAuth())
|
||||
def series_remove_duplicate_images(request, series_id: int):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
@@ -249,34 +385,188 @@ def series_remove_duplicate_images(request, series_id: int):
|
||||
return len(dupes)
|
||||
|
||||
|
||||
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
|
||||
def series_truncate(request, series_id: int, start: int, end: int):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
return {"status": "permission denied"}
|
||||
|
||||
images_removed = []
|
||||
for n, image in enumerate(series.get_images()):
|
||||
if n >= start and n <= end:
|
||||
continue
|
||||
else:
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
images_removed.append(image.pk)
|
||||
|
||||
series.get_total_image_size(refresh=True)
|
||||
|
||||
return {"status": "success", "images_removed": images_removed}
|
||||
|
||||
|
||||
@router.get("/get_cases_user", auth=django_auth, response=List[CaseSchema])
|
||||
@router.get("/get_cases_user", auth=TokenAuth(), response=List[CaseSchema])
|
||||
def get_cases_user(request):
|
||||
return Case.objects.filter(author=request.user)
|
||||
|
||||
|
||||
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
||||
@router.get("/get_cases_available", auth=TokenAuth(), response=List[CaseSchema])
|
||||
def get_cases_available(request):
|
||||
"""Return cases available to the authenticated user (via get_cases_available_to_user)."""
|
||||
logger.error(f"Getting cases available to user {request.user}")
|
||||
qs = get_cases_available_to_user(request.user)
|
||||
return qs.order_by('-created_date')[:200]
|
||||
|
||||
|
||||
class APITokenOut(Schema):
|
||||
id: int
|
||||
name: str
|
||||
scopes: str = ""
|
||||
created: str
|
||||
expires: str | None = None
|
||||
revoked: bool = False
|
||||
last_used: str | None = None
|
||||
|
||||
|
||||
@router.get("/api_tokens", auth=TokenAuth(), response=List[APITokenOut])
|
||||
def list_api_tokens(request):
|
||||
tokens = APIToken.objects.filter(user=request.user).order_by("-created")
|
||||
out = []
|
||||
for t in tokens:
|
||||
out.append(
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"scopes": t.scopes,
|
||||
"created": t.created.isoformat(),
|
||||
"expires": t.expires.isoformat() if t.expires else None,
|
||||
"revoked": t.revoked,
|
||||
"last_used": t.last_used.isoformat() if t.last_used else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class CreateTokenIn(Schema):
|
||||
name: str = Field("", description="Friendly name for token")
|
||||
scopes: str = Field("", description="Space-separated scopes")
|
||||
expires_days: int | None = Field(None, description="Expire after N days")
|
||||
|
||||
|
||||
class TokenAuthIn(Schema):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@router.post("/create_api_token")
|
||||
def token_auth(request, payload: TokenAuthIn):
|
||||
"""Authenticate username/password and return a new API token.
|
||||
|
||||
Returns JSON: {"token": "<raw-token>", "id": <token_id>}
|
||||
"""
|
||||
user = authenticate(request, username=payload.username, password=payload.password)
|
||||
if user is None:
|
||||
return HttpResponse(status=401, content=json.dumps({"detail": "Invalid credentials"}), content_type="application/json")
|
||||
|
||||
# Create a new token for this login (no scopes by default)
|
||||
token, obj = APIToken.create_token(user, name=f"login-{timezone.now().isoformat()}")
|
||||
|
||||
return {"token": token, "id": obj.id, "username": user.username}
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@router.post("/token_check")
|
||||
def token_check(request):
|
||||
"""Check whether a token is valid.
|
||||
|
||||
Accepts either `Authorization: Bearer <token>` header or POST JSON
|
||||
body `{ "token": "<token>" }`.
|
||||
|
||||
Returns JSON with keys: `valid` (bool), and when valid: `id`, `username`,
|
||||
`scopes`, `expires`, `revoked`.
|
||||
"""
|
||||
auth = request.META.get("HTTP_AUTHORIZATION")
|
||||
token_obj = None
|
||||
|
||||
# If header present, validate bearer token explicitly
|
||||
if auth:
|
||||
parts = auth.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
token = parts[1]
|
||||
try:
|
||||
bearer = BearerAuth()
|
||||
user = bearer.authenticate(request, token)
|
||||
token_obj = getattr(request, "api_token", None)
|
||||
except HttpError:
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
else:
|
||||
# try JSON body
|
||||
try:
|
||||
data = json.loads(request.body.decode("utf-8")) if request.body else {}
|
||||
except Exception:
|
||||
data = {}
|
||||
token = data.get("token")
|
||||
if token:
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
try:
|
||||
t = APIToken.objects.get(token_hash=token_hash)
|
||||
token_obj = t
|
||||
except APIToken.DoesNotExist:
|
||||
token_obj = None
|
||||
|
||||
if not token_obj:
|
||||
return JsonResponse({"valid": False}, status=401)
|
||||
|
||||
# Validate token state
|
||||
valid = True
|
||||
if token_obj.revoked:
|
||||
valid = False
|
||||
if token_obj.expires and token_obj.expires < timezone.now():
|
||||
valid = False
|
||||
|
||||
if not valid:
|
||||
return JsonResponse({"valid": False, "revoked": token_obj.revoked}, status=401)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"valid": True,
|
||||
"id": token_obj.id,
|
||||
"username": token_obj.user.username,
|
||||
"scopes": token_obj.scopes,
|
||||
"expires": token_obj.expires.isoformat() if token_obj.expires else None,
|
||||
"revoked": token_obj.revoked,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
|
||||
#@csrf_exempt
|
||||
#@router.post("/create_api_token")
|
||||
#def create_api_token(request, payload: CreateTokenIn):
|
||||
# """Create an API token.
|
||||
#
|
||||
# This endpoint accepts either normal session auth (`request.user`) or
|
||||
# a bearer token. If no authenticated user is found a 403 is returned.
|
||||
# """
|
||||
# # If caller is not using Authorization header, enforce CSRF via middleware
|
||||
# if not request.META.get("HTTP_AUTHORIZATION"):
|
||||
# mw = CsrfViewMiddleware()
|
||||
# resp = mw.process_view(request, create_api_token, (), {})
|
||||
# if resp is not None:
|
||||
# return resp
|
||||
# # Determine authenticated user: prefer session auth, fall back to token auth
|
||||
# user = None
|
||||
# if getattr(request, "user", None) and getattr(request.user, "is_authenticated", False):
|
||||
# user = request.user
|
||||
# else:
|
||||
# user = TokenAuth()(request)
|
||||
#
|
||||
# if not user:
|
||||
# raise PermissionDenied()
|
||||
#
|
||||
# expires = None
|
||||
# if payload.expires_days:
|
||||
# expires = timezone.now() + timezone.timedelta(days=payload.expires_days)
|
||||
#
|
||||
# token, obj = APIToken.create_token(user, name=payload.name, scopes=payload.scopes, expires=expires)
|
||||
# # Return raw token once — callers must store it
|
||||
# return {"token": token, "id": obj.id}
|
||||
|
||||
|
||||
@router.post("/api_tokens/{token_id}/revoke", auth=TokenAuth())
|
||||
def revoke_api_token(request, token_id: int):
|
||||
try:
|
||||
t = APIToken.objects.get(pk=token_id, user=request.user)
|
||||
except APIToken.DoesNotExist:
|
||||
return {"status": "not found"}
|
||||
t.revoked = True
|
||||
t.save(update_fields=["revoked"])
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
@router.get("/check_image_hash/{hash}", auth=TokenAuth())
|
||||
def check_image_hash(request, hash: str):
|
||||
try:
|
||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
@@ -291,41 +581,61 @@ def check_image_hash(request, hash: str):
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/check_image_hashes/", auth=django_auth)
|
||||
@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"}, ...}
|
||||
"""
|
||||
|
||||
hash_status = {}
|
||||
try:
|
||||
hash_status: dict = {h: {"id": False, "url": False} for h in hashes}
|
||||
|
||||
for hash in hashes:
|
||||
try:
|
||||
# TOOD also check against uncategorised dicoms?
|
||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
data = {
|
||||
"id": series_image.pk,
|
||||
"url": series_image.series.get_absolute_url(),
|
||||
"type": "series",
|
||||
}
|
||||
except SeriesImage.DoesNotExist:
|
||||
# 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:
|
||||
uncategorised_dicom = UncategorisedDicom.objects.get(
|
||||
image_blake3_hash=hash
|
||||
)
|
||||
data = {
|
||||
"id": uncategorised_dicom.pk,
|
||||
"url": reverse("atlas:user_uploads"),
|
||||
"type": "uncategorised",
|
||||
}
|
||||
except UncategorisedDicom.DoesNotExist:
|
||||
data = {"id": False, "url": False}
|
||||
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)
|
||||
|
||||
hash_status[hash] = data
|
||||
# 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
|
||||
return hash_status
|
||||
except Exception:
|
||||
logger.exception("check_images_hashes failed")
|
||||
return JsonResponse({"detail": "Internal server error"}, status=500)
|
||||
|
||||
|
||||
# @router.get("/generate_image_hash/{id}", auth=django_auth)
|
||||
@@ -339,16 +649,15 @@ def check_images_hashes(request, hashes: List[str]):
|
||||
# print(series_image)
|
||||
|
||||
|
||||
@router.get("/view_dicom_tags/{hash}", auth=django_auth)
|
||||
@router.get("/view_dicom_tags/{hash}", auth=TokenAuth())
|
||||
def view_dicom_tags(request, hash: str):
|
||||
item = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
return item.get_dicom_json()
|
||||
|
||||
|
||||
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
|
||||
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
|
||||
def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
if not series.check_user_can_edit(request.user):
|
||||
return {"status": "permission denied"}
|
||||
|
||||
@@ -393,7 +702,7 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||
return new_series
|
||||
|
||||
|
||||
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
|
||||
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=TokenAuth())
|
||||
def series_order_by_tag(request, series_id: int, dicom_tag: str):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
|
||||
+198
-11
@@ -1,4 +1,8 @@
|
||||
import django_filters
|
||||
from django import forms
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Div, Field
|
||||
from collections import OrderedDict
|
||||
|
||||
from .models import (
|
||||
Case,
|
||||
@@ -7,6 +11,7 @@ from .models import (
|
||||
Finding,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
Procedure,
|
||||
QuestionSchema,
|
||||
Series,
|
||||
Structure,
|
||||
@@ -17,6 +22,7 @@ from .models import (
|
||||
)
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
|
||||
|
||||
def get_authors(request):
|
||||
@@ -81,6 +87,22 @@ class CaseCollectionFilter(django_filters.FilterSet):
|
||||
user=None,
|
||||
request=None,
|
||||
):
|
||||
# Default the archive filter to False when not provided so collections
|
||||
# are not archived by default in listings.
|
||||
if data is None:
|
||||
data = QueryDict('', mutable=True)
|
||||
data['archive'] = 'false'
|
||||
else:
|
||||
# QueryDict instances need to be copied to be mutable
|
||||
try:
|
||||
if 'archive' not in data:
|
||||
data = data.copy()
|
||||
data['archive'] = 'false'
|
||||
except Exception:
|
||||
# Fallback for non-QueryDict mappings
|
||||
if isinstance(data, dict) and 'archive' not in data:
|
||||
data = dict(data)
|
||||
data['archive'] = 'false'
|
||||
if not request.user.groups.filter(name="atlas_editor").exists():
|
||||
queryset = queryset.prefetch_related("author").filter(
|
||||
author__id=request.user.id
|
||||
@@ -95,9 +117,22 @@ class CaseFilter(django_filters.FilterSet):
|
||||
author = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_authors, null_label="No author"
|
||||
)
|
||||
# Show a distinct list of `Series.examination` values instead of raw Series
|
||||
# entries so examinations that differ only by plane/description appear once.
|
||||
series = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=get_series, null_label="No series"
|
||||
field_name='series__examination',
|
||||
queryset=lambda request: __import__('generic.models', fromlist=['Examination']).Examination.objects.filter(atlas_series_examination__isnull=False).distinct(),
|
||||
label='Examination',
|
||||
)
|
||||
# Free-text search to help find the correct examination when there are
|
||||
# many similarly-named Series/Examination entries.
|
||||
examination_text = django_filters.CharFilter(method='filter_examination_text', label='Examination (text)')
|
||||
# Full list of conditions and a text search to find conditions
|
||||
condition = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Condition.objects.all(),
|
||||
label='Condition',
|
||||
)
|
||||
condition_text = django_filters.CharFilter(method='filter_condition_text', label='Condition (text)')
|
||||
differential_case = django_filters.ModelMultipleChoiceFilter(
|
||||
label="Differential",
|
||||
field_name="differentialcase__condition",
|
||||
@@ -132,6 +167,58 @@ class CaseFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_examination_text(self, queryset, name, value):
|
||||
"""Filter Cases by a text search on related Examination records.
|
||||
|
||||
Matches against the Examination string representation and any
|
||||
common identifying fields (name/code). Uses a case-insensitive
|
||||
contains lookup and filters Cases which have a Series linked to
|
||||
a matching Examination.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
from generic.models import Examination
|
||||
|
||||
# Only attempt to match PK when the provided value looks like an integer
|
||||
try:
|
||||
pk_val = int(value)
|
||||
except Exception:
|
||||
pk_val = None
|
||||
|
||||
if pk_val is not None:
|
||||
matches = Examination.objects.filter(
|
||||
Q(examination__icontains=value) | Q(pk=pk_val)
|
||||
).distinct()
|
||||
else:
|
||||
matches = Examination.objects.filter(
|
||||
Q(examination__icontains=value)
|
||||
).distinct()
|
||||
|
||||
return queryset.filter(series__examination__in=matches).distinct()
|
||||
|
||||
def filter_condition_text(self, queryset, name, value):
|
||||
"""Filter Cases by a text search on related Condition records.
|
||||
|
||||
Matches both direct `condition` M2M and `differential` entries.
|
||||
"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# Only try PK match when numeric
|
||||
try:
|
||||
pk_val = int(value)
|
||||
except Exception:
|
||||
pk_val = None
|
||||
|
||||
if pk_val is not None:
|
||||
matches = Condition.objects.filter(Q(name__icontains=value) | Q(pk=pk_val)).distinct()
|
||||
else:
|
||||
matches = Condition.objects.filter(Q(name__icontains=value)).distinct()
|
||||
|
||||
# Match either direct condition M2M or differential (through Differential)
|
||||
return queryset.filter(Q(condition__in=matches) | Q(differential__in=matches)).distinct()
|
||||
|
||||
|
||||
class SeriesFilter(django_filters.FilterSet):
|
||||
case = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -351,22 +438,32 @@ class NormalCaseFilter(django_filters.FilterSet):
|
||||
"""
|
||||
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
procedures = django_filters.ModelMultipleChoiceFilter(field_name="case__procedures", queryset=Procedure.objects.all(), label="Procedure")
|
||||
# Accept min/max as years in the UI but filter against the canonical
|
||||
# `age_days` field by converting years -> days.
|
||||
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
|
||||
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
|
||||
# Numeric min/max values entered by the user (interpreted via `age_unit`)
|
||||
min_value = django_filters.NumberFilter(label="Min", method="filter_min_value")
|
||||
max_value = django_filters.NumberFilter(label="Max", method="filter_max_value")
|
||||
AGE_UNIT_CHOICES = (
|
||||
("days", "Days"),
|
||||
("weeks", "Weeks"),
|
||||
("months", "Months"),
|
||||
("years", "Years"),
|
||||
)
|
||||
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES, method="filter_age_unit")
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "age_days")
|
||||
fields = ("examination", "modality", "procedures", "age_days")
|
||||
|
||||
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
||||
# Use the global Examination and Modality querysets from the Series model
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
# Set up the actual querysets for the filters
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
# We'll set the actual querysets for the filters after applying
|
||||
# any NormalCase queryset restrictions for the current user so
|
||||
# that the choices only include examinations/modalities that
|
||||
# actually have NormalCase records available.
|
||||
|
||||
# If not an atlas editor restrict to cases authored by the user or open access cases
|
||||
if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
|
||||
@@ -376,22 +473,112 @@ class NormalCaseFilter(django_filters.FilterSet):
|
||||
queryset = NormalCase.objects.select_related("case")
|
||||
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
|
||||
|
||||
# Determine the NormalCase queryset we'll use to build choices
|
||||
nc_qs = queryset if queryset is not None else NormalCase.objects.all()
|
||||
|
||||
# Limit examination and modality choices to those present on the
|
||||
# available NormalCase records (respecting user restrictions).
|
||||
try:
|
||||
exam_ids = nc_qs.values_list("examination_id", flat=True)
|
||||
mod_ids = nc_qs.values_list("modality_id", flat=True)
|
||||
proc_ids = nc_qs.values_list("case__procedures", flat=True)
|
||||
self.base_filters.get("examination").queryset = Examination.objects.filter(id__in=exam_ids).distinct()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.filter(id__in=mod_ids).distinct()
|
||||
self.base_filters.get("procedures").queryset = Procedure.objects.filter(id__in=proc_ids).distinct()
|
||||
except Exception:
|
||||
# Fallback to all if something goes wrong
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
|
||||
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||
|
||||
def filter_min_age(self, queryset, name, value):
|
||||
# Attach a Crispy FormHelper to render the filter form as a
|
||||
# horizontal Bootstrap grid. We set `form_tag=False` because the
|
||||
# template already provides the <form> element.
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
helper = FormHelper()
|
||||
helper.form_method = "get"
|
||||
helper.form_tag = False
|
||||
helper.form_class = "row g-2"
|
||||
helper.layout = Layout(
|
||||
Div(
|
||||
Div("examination", css_class="col-md-3"),
|
||||
Div("modality", css_class="col-md-3"),
|
||||
Div("procedures", css_class="col-md-3"),
|
||||
css_class="row g-2",
|
||||
),
|
||||
Div(
|
||||
Div(
|
||||
Field("min_value", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
Field("max_value", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
Field("age_unit", wrapper_class="mb-0 d-flex align-items-center"),
|
||||
css_class="col-12 d-flex gap-2 align-items-center",
|
||||
),
|
||||
css_class="row g-2",
|
||||
),
|
||||
)
|
||||
# Assign helper to the generated form instance and tweak widgets
|
||||
if hasattr(self, 'form') and self.form is not None:
|
||||
# Wider numeric inputs and default unit
|
||||
try:
|
||||
form = self.form
|
||||
if "min_value" in form.fields:
|
||||
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
|
||||
if "max_value" in form.fields:
|
||||
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
|
||||
if "age_unit" in form.fields:
|
||||
form.fields["age_unit"].initial = "days"
|
||||
form.fields["age_unit"].widget.attrs.update({"class": "form-select w-auto", "style": "min-width:220px;"})
|
||||
form.helper = helper
|
||||
except Exception:
|
||||
# don't crash if form structure differs
|
||||
self.form.helper = helper
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _convert_to_days(self, value, unit: str) -> int:
|
||||
try:
|
||||
v = float(value)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
if unit == "days":
|
||||
return int(v)
|
||||
if unit == "weeks":
|
||||
return int(v * 7)
|
||||
if unit == "months":
|
||||
# approximate month length
|
||||
return int(v * (365.25 / 12.0))
|
||||
# default: years
|
||||
return int(v * 365.25)
|
||||
|
||||
def filter_min_value(self, queryset, name, value):
|
||||
try:
|
||||
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
|
||||
except Exception:
|
||||
unit = "days"
|
||||
try:
|
||||
days = self._convert_to_days(value, unit)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__gte=days)
|
||||
|
||||
def filter_max_age(self, queryset, name, value):
|
||||
def filter_max_value(self, queryset, name, value):
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
|
||||
except Exception:
|
||||
unit = "days"
|
||||
try:
|
||||
days = self._convert_to_days(value, unit)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__lte=days)
|
||||
|
||||
def filter_age_unit(self, queryset, name, value):
|
||||
# `age_unit` is a helper input used by min/max conversion and does not
|
||||
# map to a database field on NormalCase.
|
||||
return queryset
|
||||
|
||||
|
||||
class ResourceFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
|
||||
|
||||
+176
-3
@@ -42,9 +42,11 @@ from atlas.models import (
|
||||
Subspecialty,
|
||||
Presentation,
|
||||
PathologicalProcess,
|
||||
Procedure,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
CaseDisplaySet,
|
||||
CaseReviewMessage,
|
||||
)
|
||||
from .models import NormalCase
|
||||
|
||||
@@ -115,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
|
||||
@@ -226,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
|
||||
]
|
||||
@@ -262,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(
|
||||
@@ -298,6 +337,22 @@ class NormalCaseForm(ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# If editing an existing NormalCase, prepopulate age_value/age_unit
|
||||
try:
|
||||
instance = kwargs.get('instance')
|
||||
except Exception:
|
||||
instance = None
|
||||
if instance is None:
|
||||
# also support self.instance when ModelForm sets it
|
||||
instance = getattr(self, 'instance', None)
|
||||
if instance is not None and getattr(instance, 'age_days', None) is not None:
|
||||
try:
|
||||
val, unit = NormalCase.days_to_value_unit(instance.age_days)
|
||||
if val is not None and unit is not None:
|
||||
self.fields['age_value'].initial = val
|
||||
self.fields['age_unit'].initial = unit
|
||||
except Exception:
|
||||
pass
|
||||
self.helper = FormHelper()
|
||||
# Do not render a <form> tag from crispy here because the template
|
||||
# already provides the surrounding form element (we inject this
|
||||
@@ -315,7 +370,7 @@ class NormalCaseForm(ModelForm):
|
||||
css_class="row",
|
||||
),
|
||||
Field("notes"),
|
||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||
# Submit button rendered in modal footer; avoid duplicate button here
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
@@ -379,6 +434,22 @@ class PathologicalProcessForm(ModelForm):
|
||||
exclude = []
|
||||
|
||||
|
||||
class ProcedureForm(ModelForm):
|
||||
class Meta:
|
||||
model = Procedure
|
||||
exclude = []
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
try:
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
# Prevent crispy from rendering its own <form> tag so our
|
||||
# template-level form wrapper contains the submit buttons.
|
||||
self.helper.form_tag = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class SeriesFindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = SeriesFinding
|
||||
@@ -601,6 +672,7 @@ class CaseForm(ModelForm):
|
||||
"presentation",
|
||||
"discussion",
|
||||
"report",
|
||||
"procedures",
|
||||
"condition",
|
||||
"pathological_process",
|
||||
"open_access",
|
||||
@@ -632,6 +704,7 @@ class CaseForm(ModelForm):
|
||||
"history",
|
||||
"presentation",
|
||||
"discussion",
|
||||
"procedures",
|
||||
"condition",
|
||||
"pathological_process",
|
||||
"report",
|
||||
@@ -661,6 +734,9 @@ class CaseForm(ModelForm):
|
||||
"subspecialty": CheckboxSelectMultiple(),
|
||||
"pathological_process": CheckboxSelectMultiple(),
|
||||
"previous_case": CaseSelect(),
|
||||
"procedures": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:procedure-autocomplete"
|
||||
),
|
||||
}
|
||||
|
||||
def clean_cimar_uuid(self):
|
||||
@@ -1020,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"]
|
||||
@@ -1034,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:
|
||||
@@ -1144,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):
|
||||
"""
|
||||
@@ -1167,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
|
||||
@@ -1204,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,25 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-26 11:05
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0089_remove_condition_primary_remove_condition_synonym'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Creation timestamp'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='updated',
|
||||
field=models.DateTimeField(auto_now=True, help_text='Last updated timestamp'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 13:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0090_casecollection_created_casecollection_updated'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='normalcase',
|
||||
name='added_by',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 13:29
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0091_remove_normalcase_added_by'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='normalcase',
|
||||
name='author',
|
||||
field=models.ManyToManyField(blank=True, related_name='normal_cases', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-16 14:01
|
||||
|
||||
import atlas.models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0092_normalcase_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Procedure',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('canonical', models.ForeignKey(blank=True, help_text='If set, this Procedure is an alias and points to the canonical Procedure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.procedure')),
|
||||
('subspecialty', models.ManyToManyField(blank=True, to='atlas.subspecialty')),
|
||||
],
|
||||
bases=(atlas.models.SynMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='case',
|
||||
name='procedures',
|
||||
field=models.ManyToManyField(blank=True, help_text='Procedure(s) or operations the patient has undergone.', to='atlas.procedure'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-23 11:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0093_procedure_case_procedures'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='APIToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, help_text='Friendly name for this token', max_length=200)),
|
||||
('token_hash', models.CharField(db_index=True, max_length=64)),
|
||||
('scopes', models.CharField(blank=True, help_text='Space-separated scopes', max_length=255)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('expires', models.DateTimeField(blank=True, null=True)),
|
||||
('revoked', models.BooleanField(default=False)),
|
||||
('last_used', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'API token',
|
||||
'verbose_name_plural': 'API tokens',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.0.1 on 2026-05-12 20:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0102_alter_seriesimage_image_blake3_hash_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='auto_apply_question_template',
|
||||
field=models.BooleanField(default=False, help_text='If enabled, the case_question_template is automatically applied to new cases added to this collection that do not have an existing question_schema.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='case_question_max_score',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Maximum score per case used for normalisation. Defaults to 10 when not set.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='case_question_template',
|
||||
field=models.JSONField(blank=True, help_text='JSON schema applied as a default question template for all cases in this collection. Individual cases can override via CaseDetail.question_schema.', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='marking_guidance',
|
||||
field=models.TextField(blank=True, help_text='Plain-language marking guidance displayed to markers.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='casecollection',
|
||||
name='marking_scheme_name',
|
||||
field=models.CharField(blank=True, help_text='Human-readable mark scheme name shown to markers and in exports.', max_length=255),
|
||||
),
|
||||
]
|
||||
+352
-18
@@ -230,6 +230,44 @@ class SynMixin(object):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||
|
||||
|
||||
class APIToken(models.Model):
|
||||
"""Personal Access Token for API access.
|
||||
|
||||
Stores only a hash of the token. The raw token is returned once at
|
||||
creation and never stored in plaintext.
|
||||
"""
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens")
|
||||
name = models.CharField(max_length=200, blank=True, help_text="Friendly name for this token")
|
||||
token_hash = models.CharField(max_length=64, db_index=True)
|
||||
scopes = models.CharField(max_length=255, blank=True, help_text="Space-separated scopes")
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
expires = models.DateTimeField(null=True, blank=True)
|
||||
revoked = models.BooleanField(default=False)
|
||||
last_used = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "API token"
|
||||
verbose_name_plural = "API tokens"
|
||||
|
||||
def __str__(self):
|
||||
return f"APIToken(user={self.user}, name={self.name})"
|
||||
|
||||
@classmethod
|
||||
def create_token(cls, user, name="", scopes="", expires=None):
|
||||
import secrets, hashlib
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
obj = cls.objects.create(user=user, name=name, token_hash=token_hash, scopes=scopes, expires=expires)
|
||||
return token, obj
|
||||
|
||||
def mark_used(self):
|
||||
from django.utils import timezone
|
||||
|
||||
self.last_used = timezone.now()
|
||||
self.save(update_fields=["last_used"])
|
||||
|
||||
|
||||
class Finding(SynMixin, models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
# New canonical/alias field: if set, this Finding is an alias and points
|
||||
@@ -396,6 +434,35 @@ class PathologicalProcess(models.Model):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||
|
||||
|
||||
class Procedure(SynMixin, models.Model):
|
||||
"""A procedure or operation performed on the patient (attachable to Cases).
|
||||
|
||||
This mirrors the lightweight structure used by `Presentation` / `PathologicalProcess`.
|
||||
"""
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
|
||||
# Optional canonical/alias pointer for future synonym handling
|
||||
canonical = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="aliases",
|
||||
help_text="If set, this Procedure is an alias and points to the canonical Procedure.",
|
||||
)
|
||||
|
||||
subspecialty = models.ManyToManyField("subspecialty", blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:procedure_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_link(self):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||
|
||||
|
||||
class Differential(models.Model):
|
||||
condition = models.ForeignKey(Condition, on_delete=models.CASCADE)
|
||||
case = models.ForeignKey(
|
||||
@@ -484,6 +551,13 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
help_text="The presentation(s) the case is associated with.",
|
||||
)
|
||||
|
||||
# Procedures performed on the patient (e.g. operations, interventions)
|
||||
procedures = models.ManyToManyField(
|
||||
"Procedure",
|
||||
blank=True,
|
||||
help_text="Procedure(s) or operations the patient has undergone.",
|
||||
)
|
||||
|
||||
pathological_process = models.ManyToManyField(PathologicalProcess, blank=True)
|
||||
|
||||
differential = models.ManyToManyField(
|
||||
@@ -720,11 +794,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
series_qs = self.series.all()
|
||||
if exclude_series_ids is not None:
|
||||
series_qs = self.series.exclude(id__in=exclude_series_ids)
|
||||
images = [
|
||||
[image.image.url for image in series.images.all()]
|
||||
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images')
|
||||
]
|
||||
series_qs = series_qs.exclude(id__in=exclude_series_ids)
|
||||
|
||||
# Ensure series are ordered by the through model sort_order
|
||||
series_list = list(series_qs.order_by('seriesdetail__sort_order').prefetch_related('images'))
|
||||
|
||||
images = []
|
||||
for series in series_list:
|
||||
# If images were prefetched, use the cached list and filter in Python
|
||||
prefetched = getattr(series, '_prefetched_objects_cache', {}).get('images')
|
||||
if prefetched is not None:
|
||||
imgs = [img.image.url for img in prefetched if not getattr(img, 'removed', False)]
|
||||
else:
|
||||
imgs = [image.image.url for image in series.images.filter(removed=False)]
|
||||
images.append(imgs)
|
||||
|
||||
if as_json:
|
||||
return json.dumps(images)
|
||||
@@ -738,7 +821,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
for series in case_obj.get_ordered_series():
|
||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
|
||||
name = f"{prefix}: {series}" if prefix else str(series)
|
||||
stacks.append({"name": name, "imageIds": images})
|
||||
stacks.append({"name": name, "imageIds": images, "seriesId": series.pk})
|
||||
return stacks
|
||||
|
||||
results = []
|
||||
@@ -757,7 +840,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
return json.dumps(results)
|
||||
|
||||
|
||||
class NormalCase(models.Model):
|
||||
class NormalCase(models.Model, AuthorMixin):
|
||||
"""Marks a Case as a 'normal' example used in the Atlas normals section.
|
||||
|
||||
This version stores a canonical `age_days` integer (patient age in days).
|
||||
@@ -810,18 +893,18 @@ class NormalCase(models.Model):
|
||||
help_text="Primary modality for this normal case (optional).",
|
||||
)
|
||||
|
||||
added_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="added_normals",
|
||||
)
|
||||
|
||||
added_date = models.DateTimeField(default=timezone.now)
|
||||
|
||||
|
||||
author = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="normal_cases",
|
||||
)
|
||||
|
||||
notes = models.TextField(null=True, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ["-added_date"]
|
||||
|
||||
@@ -1031,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:
|
||||
@@ -1038,6 +1122,27 @@ class Series(SeriesBase):
|
||||
return self.description
|
||||
else:
|
||||
return f"{self.examination} ({self.plane})"
|
||||
|
||||
def user_can_edit(self, user, allow_if_can_edit_cases=True) -> bool:
|
||||
"""Check if the user can edit the series.
|
||||
|
||||
Args:
|
||||
user (User): The user to check.
|
||||
Returns:
|
||||
bool: True if the user can edit the series, False otherwise.
|
||||
"""
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
if self.author.filter(id=user.id).exists():
|
||||
return True
|
||||
|
||||
if allow_if_can_edit_cases:
|
||||
# Check if user can edit any of the cases this series is in
|
||||
for case in self.case.all():
|
||||
if case.check_user_can_edit(user):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_full_str(self):
|
||||
if self.case:
|
||||
@@ -1152,6 +1257,7 @@ class Series(SeriesBase):
|
||||
|
||||
|
||||
class CaseCollection(ExamOrCollectionGenericBase):
|
||||
|
||||
app_name = "atlas"
|
||||
|
||||
cases = models.ManyToManyField(Case, through="CaseDetail")
|
||||
@@ -1188,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,
|
||||
@@ -1236,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,
|
||||
@@ -1243,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",
|
||||
@@ -1296,6 +1458,47 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
default=VIEWER_MODE_CHOICES.BUILT_IN,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Marking configuration
|
||||
# These fields can be defined here so that the same CaseCollection can be
|
||||
# reused in multiple research studies or standalone with consistent marking.
|
||||
# -----------------------------------------------------------------------
|
||||
marking_scheme_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Human-readable mark scheme name shown to markers and in exports.",
|
||||
)
|
||||
marking_guidance = models.TextField(
|
||||
blank=True,
|
||||
help_text="Plain-language marking guidance displayed to markers.",
|
||||
)
|
||||
case_question_max_score = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Maximum score per case used for normalisation. Defaults to 10 when not set.",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Case question template
|
||||
# When set, this JSON schema is used as a default for all CaseDetail entries
|
||||
# in the collection that do not have their own question_schema.
|
||||
# -----------------------------------------------------------------------
|
||||
case_question_template = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=(
|
||||
"JSON schema applied as a default question template for all cases in this collection. "
|
||||
"Individual cases can override via CaseDetail.question_schema."
|
||||
),
|
||||
)
|
||||
auto_apply_question_template = models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"If enabled, the case_question_template is automatically applied to new cases "
|
||||
"added to this collection that do not have an existing question_schema."
|
||||
),
|
||||
)
|
||||
|
||||
def get_app_name(self):
|
||||
return "atlas"
|
||||
|
||||
@@ -1356,6 +1559,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())
|
||||
|
||||
@@ -1374,6 +1587,38 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
||||
else:
|
||||
return cases[new_index]
|
||||
|
||||
def apply_question_template_to_cases(self, overwrite_existing=False):
|
||||
"""
|
||||
Apply case_question_template to all CaseDetail entries in this collection.
|
||||
|
||||
Args:
|
||||
overwrite_existing: If True, overwrite existing question_schema on CaseDetails.
|
||||
If False (default), only apply to cases without a schema.
|
||||
|
||||
Returns:
|
||||
int: Number of CaseDetail entries updated.
|
||||
"""
|
||||
if not self.case_question_template:
|
||||
return 0
|
||||
|
||||
casedetails = self.casedetail_set.all()
|
||||
updated = 0
|
||||
for cd in casedetails:
|
||||
if overwrite_existing or not cd.question_schema:
|
||||
cd.question_schema = self.case_question_template
|
||||
cd.save(update_fields=["question_schema"])
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
def get_effective_question_schema(self, casedetail):
|
||||
"""
|
||||
Return the effective question schema for a CaseDetail.
|
||||
Uses the individual CaseDetail schema if set, otherwise falls back to the collection template.
|
||||
"""
|
||||
if casedetail.question_schema:
|
||||
return casedetail.question_schema
|
||||
return self.case_question_template
|
||||
|
||||
|
||||
def get_index_of_case(self, case, case_count=False):
|
||||
cases = list(self.get_cases())
|
||||
@@ -1414,7 +1659,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
|
||||
@@ -1581,6 +1828,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",)
|
||||
|
||||
@@ -1627,6 +1886,13 @@ class CaseDetail(models.Model):
|
||||
return self.case.history or "No history provided"
|
||||
return ""
|
||||
|
||||
def get_history(self):
|
||||
if self.redact_history:
|
||||
return "[Redacted]"
|
||||
if self.override_history and self.override_history != "":
|
||||
return self.override_history
|
||||
return self.case.history or "No history provided"
|
||||
|
||||
def get_case_series_nested(self, include_priors=True):
|
||||
case_series_images = self.case.get_series_images_nested(as_json=False)
|
||||
|
||||
@@ -1646,7 +1912,7 @@ class CaseDetail(models.Model):
|
||||
for series in case_obj.get_ordered_series():
|
||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
|
||||
name = f"{prefix}: {series}" if prefix else str(series)
|
||||
stacks.append({"name": name, "imageIds": images})
|
||||
stacks.append({"name": name, "imageIds": images, "seriesId": series.pk})
|
||||
return stacks
|
||||
|
||||
results = []
|
||||
@@ -1895,6 +2161,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)
|
||||
@@ -1915,7 +2246,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)
|
||||
|
||||
@@ -1981,6 +2312,7 @@ class UncategorisedDicom(models.Model):
|
||||
if dataset is None:
|
||||
dataset = pydicom.dcmread(self.image)
|
||||
to_include = (
|
||||
"StudyInstanceUID",
|
||||
"StudyDescription",
|
||||
"Modality",
|
||||
"ProtocolName",
|
||||
@@ -1988,6 +2320,8 @@ class UncategorisedDicom(models.Model):
|
||||
"SeriesInstanceUID",
|
||||
"BodyPartExamined",
|
||||
"SliceThickness",
|
||||
"SeriesNumber",
|
||||
"StudyDate",
|
||||
)
|
||||
|
||||
tags = {}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
// Simple markup inserter module
|
||||
(function(global){
|
||||
console.log('markup_inserter.js loaded');
|
||||
function insertAtCursor(el, text){
|
||||
if(!el) return;
|
||||
const start = el.selectionStart || 0;
|
||||
const end = el.selectionEnd || 0;
|
||||
const val = el.value || '';
|
||||
el.value = val.slice(0, start) + text + val.slice(end);
|
||||
el.selectionStart = el.selectionEnd = start + text.length;
|
||||
el.focus();
|
||||
}
|
||||
|
||||
function createToolbar(){
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'd-flex gap-2 mb-2 markup-inserter-toolbar';
|
||||
const group = document.createElement('div');
|
||||
group.className = 'btn-group';
|
||||
const makeBtn = function(type, text){
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'btn btn-sm btn-outline-secondary markup-insert-button';
|
||||
b.dataset.type = type; b.textContent = text; return b;
|
||||
};
|
||||
group.appendChild(makeBtn('case','Insert case'));
|
||||
group.appendChild(makeBtn('finding','Insert finding'));
|
||||
group.appendChild(makeBtn('displayset','Insert displayset'));
|
||||
const hint = document.createElement('div'); hint.className='flex-fill text-muted small align-self-center'; hint.textContent='Use the buttons to insert markup like [[case:123]]';
|
||||
wrap.appendChild(group); wrap.appendChild(hint);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function attachToolbarToTextarea(textarea){
|
||||
if(!textarea) return null;
|
||||
// avoid double attaching
|
||||
if(textarea._markupToolbarAttached) return null;
|
||||
console.log('Attaching markup toolbar to textarea', textarea);
|
||||
const toolbar = createToolbar();
|
||||
textarea.parentNode.insertBefore(toolbar, textarea);
|
||||
toolbar.querySelectorAll('.markup-insert-button').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
const t = btn.dataset.type;
|
||||
// Respect explicit dataset provided by the widget or page meta only
|
||||
var detectedCase = textarea && textarea.dataset && textarea.dataset.currentCase ? textarea.dataset.currentCase : (document.getElementById('current-case-meta') ? document.getElementById('current-case-meta').getAttribute('data-current-case') : null);
|
||||
if(t === 'case'){
|
||||
openCasePicker(textarea);
|
||||
} else if(t === 'finding'){
|
||||
openFindingPicker(textarea, detectedCase);
|
||||
} else if(t === 'displayset'){
|
||||
openDisplaysetPicker(textarea, detectedCase);
|
||||
}
|
||||
});
|
||||
});
|
||||
textarea._markupToolbarAttached = true;
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
function initForNames(names){
|
||||
if(!Array.isArray(names)) return;
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
console.log('markupInserter.initForNames DOMContentLoaded handler running for', names);
|
||||
names.forEach(function(name){
|
||||
const el = document.querySelector('textarea[name="'+name+'"]');
|
||||
if(el) attachToolbarToTextarea(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachToSelector(selector){
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.querySelectorAll(selector).forEach(function(el){
|
||||
if(el.tagName === 'TEXTAREA') attachToolbarToTextarea(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-attach to placeholders rendered in HTMX responses or server-side includes.
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
console.log('markupInserter DOMContentLoaded auto-attach running');
|
||||
// Attach to explicit placeholders
|
||||
document.querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
||||
let ta = ph.nextElementSibling;
|
||||
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
||||
});
|
||||
|
||||
// Also auto-init for common case textarea names to cover full-page forms
|
||||
try{ initForNames(['description','history','discussion','report','notes']); }catch(e){}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function(evt){
|
||||
console.log('markupInserter htmx:afterSwap event', evt && evt.detail && evt.detail.target);
|
||||
try{
|
||||
const target = evt.detail && evt.detail.target ? evt.detail.target : document;
|
||||
(target.querySelectorAll ? target : document).querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
||||
let ta = ph.nextElementSibling;
|
||||
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
||||
});
|
||||
|
||||
// Also attach to any textareas within the swapped target that match our common names
|
||||
var ctx = evt.detail && evt.detail.target ? evt.detail.target : document;
|
||||
['description','history','discussion','report','notes'].forEach(function(name){
|
||||
ctx.querySelectorAll && ctx.querySelectorAll('textarea[name="'+name+'"]') && ctx.querySelectorAll('textarea[name="'+name+'"]').forEach(function(ta){ attachToolbarToTextarea(ta); });
|
||||
});
|
||||
}catch(e){/* ignore */}
|
||||
});
|
||||
|
||||
// Fallback: attach toolbar when a textarea receives focus (covers dynamically created editors)
|
||||
document.addEventListener('focusin', function(e){
|
||||
console.log('markupInserter focusin event for', e && e.target);
|
||||
try{
|
||||
var el = e.target;
|
||||
if(!el || el.tagName !== 'TEXTAREA') return;
|
||||
// Attach for common names or if a placeholder is present
|
||||
var name = el.getAttribute('name') || '';
|
||||
if(['description','history','discussion','report','notes'].includes(name) || (el.previousElementSibling && el.previousElementSibling.classList && el.previousElementSibling.classList.contains('markup-toolbar-placeholder'))){
|
||||
attachToolbarToTextarea(el);
|
||||
}
|
||||
}catch(err){/* ignore */}
|
||||
}, true);
|
||||
|
||||
// Expose API
|
||||
global.markupInserter = {
|
||||
attachToolbarToTextarea: attachToolbarToTextarea,
|
||||
initForNames: initForNames,
|
||||
attachToSelector: attachToSelector
|
||||
};
|
||||
|
||||
// --- Case picker modal ---
|
||||
function openCasePicker(textarea){
|
||||
try{
|
||||
console.log('Opening case picker for', textarea);
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'markup-case-picker-dialog card';
|
||||
Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
header.innerHTML = '<strong>Select case</strong>';
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close');
|
||||
closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); });
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search cases...';
|
||||
const results = document.createElement('div'); results.className='case-search-results';
|
||||
body.appendChild(input); body.appendChild(results);
|
||||
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){
|
||||
if(timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){
|
||||
var q = encodeURIComponent(input.value || '');
|
||||
// Fetch server-side partial that renders result list-group
|
||||
var url = window.location.origin + '/atlas/search/cases/?q=' + q;
|
||||
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){
|
||||
results.innerHTML = html;
|
||||
}).catch(function(e){ console.error('case picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; });
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Delegate click on result items
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var pk = item.getAttribute('data-case-pk');
|
||||
if(!pk){
|
||||
var a = item.querySelector('a');
|
||||
if(a){ var m = a.getAttribute('href').match(/case\/(\d+)\/?/); if(m) pk = m[1]; }
|
||||
}
|
||||
if(!pk) return;
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('h6') ? item.querySelector('h6').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
var token = '[[' + 'case:' + pk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
// remove modal
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
|
||||
// Allow ESC to close
|
||||
overlay.addEventListener('keydown', function(ev){ if(ev.key === 'Escape'){ if(overlay.parentNode) overlay.parentNode.removeChild(overlay); } });
|
||||
// make overlay focusable so keydown works
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
// load initial recent results (empty query)
|
||||
fetch(window.location.origin + '/atlas/search/cases/').then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){});
|
||||
}catch(err){ console.error('openCasePicker error', err); }
|
||||
}
|
||||
|
||||
function openFindingPicker(textarea, caseId){
|
||||
try{
|
||||
console.log('Opening finding picker for', textarea);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center'; header.innerHTML = '<strong>Select finding</strong>';
|
||||
// Determine case filter from explicit dataset on the textarea or provided caseId
|
||||
var detectedF = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
|
||||
var currentCaseF = caseId || detectedF.id || null;
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search findings...';
|
||||
const results = document.createElement('div'); results.className='finding-search-results'; body.appendChild(input); body.appendChild(results);
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/findings/?q=' + q + (currentCaseF ? '&case=' + currentCaseF : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('finding picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
|
||||
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var sfpk = item.getAttribute('data-seriesfinding-pk');
|
||||
var fpk = item.getAttribute('data-finding-pk');
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
if(sfpk){
|
||||
var token = '[['+'seriesfinding:'+sfpk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
} else if(fpk){
|
||||
var token = '[['+'finding:'+fpk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
function fetchInitialFindings(){ var initFindingsUrl = window.location.origin + '/atlas/search/findings/' + (currentCaseF ? '?case=' + currentCaseF : ''); fetch(initFindingsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
|
||||
fetchInitialFindings();
|
||||
}catch(err){ console.error('openFindingPicker error', err); }
|
||||
}
|
||||
|
||||
function openDisplaysetPicker(textarea, caseId){
|
||||
try{
|
||||
console.log('Opening displayset picker for', textarea);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'markup-case-picker-overlay';
|
||||
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
||||
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
||||
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
header.innerHTML = '<strong>Select display set</strong>';
|
||||
// Determine case filter from explicit dataset on the textarea or provided caseId
|
||||
var detectedD = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
|
||||
var currentCaseD = caseId || detectedD.id || null;
|
||||
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
|
||||
const body = document.createElement('div'); body.className='card-body';
|
||||
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search display sets...';
|
||||
const results = document.createElement('div'); results.className='displayset-search-results'; body.appendChild(input); body.appendChild(results);
|
||||
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
|
||||
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/displaysets/?q=' + q + (currentCaseD ? '&case=' + currentCaseD : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('displayset picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
|
||||
|
||||
results.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
if(!item) return;
|
||||
var pk = item.getAttribute('data-ds-pk');
|
||||
if(!pk) return;
|
||||
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
|
||||
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
||||
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
||||
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
||||
var token = '[['+'displayset:'+pk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
||||
insertAtCursor(textarea, token);
|
||||
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
});
|
||||
overlay.tabIndex = -1; overlay.focus();
|
||||
function fetchInitialDisplaysets(){ var initDisplaysetsUrl = window.location.origin + '/atlas/search/displaysets/' + (currentCaseD ? '?case=' + currentCaseD : ''); fetch(initDisplaysetsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
|
||||
fetchInitialDisplaysets();
|
||||
}catch(err){ console.error('openDisplaysetPicker error', err); }
|
||||
}
|
||||
|
||||
// Simplified detection: rely on explicit dataset attributes set by the widget
|
||||
// When a textarea includes `data-current-case` and optionally `data-current-case-title`, that will be used as the default filter.
|
||||
|
||||
// --- Render inserted markup tokens as styled items with popup/modal actions ---
|
||||
function injectMarkupStyles(){
|
||||
if(document.getElementById('markup-inserter-styles')) return;
|
||||
var s = document.createElement('style'); s.id = 'markup-inserter-styles';
|
||||
s.textContent = '\n.markup-token{cursor:pointer;display:inline-block;margin:0 .25rem .25rem 0;padding:.25rem .5rem;border-radius:.375rem;font-size:0.85em;}\n.markup-popup{position:fixed;z-index:1060;background:#fff;border:1px solid rgba(0,0,0,.12);box-shadow:0 6px 18px rgba(0,0,0,.12);padding:.5rem;border-radius:.25rem;}\n.markup-modal-backdrop{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(0,0,0,.45);z-index:1080;display:flex;align-items:flex-start;justify-content:center;padding-top:4vh;}\n.markup-modal{background:#fff;border-radius:.375rem;max-width:95%;max-height:85vh;overflow:auto;}\n';
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function getDetailUrl(type, pk){
|
||||
if(!type || !pk) return null;
|
||||
if(type === 'seriesfinding') return window.location.origin + '/atlas/series_finding/' + pk + '/preview/';
|
||||
if(type === 'finding') return window.location.origin + '/atlas/finding/' + pk;
|
||||
if(type === 'displayset') return window.location.origin + '/atlas/displayset/' + pk + '/preview/';
|
||||
if(type === 'case') return window.location.origin + '/atlas/case/' + pk + '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reusable modal element
|
||||
var __markup_modal = null;
|
||||
function ensureModal(){
|
||||
if(__markup_modal) return __markup_modal;
|
||||
var backdrop = document.createElement('div'); backdrop.className = 'markup-modal-backdrop'; backdrop.style.display='none';
|
||||
var modal = document.createElement('div'); modal.className = 'markup-modal card'; modal.style.marginTop='1rem'; modal.style.maxHeight='80vh'; modal.style.overflow='auto';
|
||||
var header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
||||
var h = document.createElement('strong'); h.textContent = 'Detail';
|
||||
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.addEventListener('click', function(){ backdrop.style.display='none'; modal.querySelector('.card-body') && (modal.querySelector('.card-body').innerHTML=''); });
|
||||
header.appendChild(h); header.appendChild(closeBtn);
|
||||
var body = document.createElement('div'); body.className='card-body'; body.style.maxHeight='70vh'; body.style.overflow='auto';
|
||||
modal.appendChild(header); modal.appendChild(body); backdrop.appendChild(modal); document.body.appendChild(backdrop);
|
||||
__markup_modal = { backdrop: backdrop, modal: modal, body: body };
|
||||
return __markup_modal;
|
||||
}
|
||||
|
||||
function showModalForUrl(url, title){
|
||||
var m = ensureModal();
|
||||
m.backdrop.style.display = 'flex';
|
||||
if(title){ var h = m.modal.querySelector('strong'); if(h) h.textContent = title; }
|
||||
m.body.innerHTML = '<div class="p-3 text-muted">Loading…</div>';
|
||||
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ m.body.innerHTML = html; }).catch(function(e){ m.body.innerHTML = '<div class="p-3 text-danger">Failed to load</div>'; console.error('modal fetch error', e); });
|
||||
}
|
||||
|
||||
function showPopupFor(element){
|
||||
// remove existing
|
||||
var existing = document.getElementById('markup-popup'); if(existing) existing.remove();
|
||||
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
|
||||
var popup = document.createElement('div'); popup.id = 'markup-popup'; popup.className = 'markup-popup';
|
||||
popup.innerHTML = '<div style="font-weight:600;margin-bottom:.25rem;">'+ (label||type+':'+pk) +'</div>';
|
||||
var openBtn = document.createElement('button'); openBtn.type='button'; openBtn.className='btn btn-sm btn-outline-primary me-2'; openBtn.textContent='Open';
|
||||
openBtn.addEventListener('click', function(){
|
||||
var url = getDetailUrl(type, pk);
|
||||
try{
|
||||
// Prefer page-specific modal loaders if present so we reuse existing Bootstrap modals/viewers
|
||||
if(type === 'displayset'){
|
||||
if(typeof window.loadDisplaysetModal === 'function'){
|
||||
window.loadDisplaysetModal(url, pk);
|
||||
popup.remove();
|
||||
return;
|
||||
}
|
||||
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
|
||||
if(dsBtn){ dsBtn.click(); popup.remove(); return; }
|
||||
}
|
||||
if(type === 'seriesfinding' || type === 'finding'){
|
||||
if(typeof window.loadFindingModal === 'function'){
|
||||
window.loadFindingModal(pk, url);
|
||||
popup.remove();
|
||||
return;
|
||||
}
|
||||
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
|
||||
if(fBtn){ fBtn.click(); popup.remove(); return; }
|
||||
}
|
||||
}catch(e){ console.warn('error invoking page modal loader', e); }
|
||||
if(url) showModalForUrl(url, label);
|
||||
popup.remove();
|
||||
});
|
||||
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn btn-sm btn-outline-secondary'; closeBtn.textContent='Close'; closeBtn.addEventListener('click', function(){ popup.remove(); });
|
||||
popup.appendChild(openBtn); popup.appendChild(closeBtn);
|
||||
document.body.appendChild(popup);
|
||||
// position near element
|
||||
try{
|
||||
var r = element.getBoundingClientRect();
|
||||
popup.style.top = (window.scrollY + r.bottom + 6) + 'px';
|
||||
popup.style.left = Math.min(window.innerWidth - 240, window.scrollX + r.left) + 'px';
|
||||
popup.style.minWidth = '160px';
|
||||
}catch(e){}
|
||||
// clicking outside closes it
|
||||
setTimeout(function(){
|
||||
var onDoc = function(ev){ if(!popup.contains(ev.target) && ev.target !== element) { popup.remove(); document.removeEventListener('click', onDoc); } };
|
||||
document.addEventListener('click', onDoc);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Try to open a modal for a token (prefer page-specific modal loaders)
|
||||
function openModalForToken(element){
|
||||
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
|
||||
var url = getDetailUrl(type, pk);
|
||||
try{
|
||||
if(type === 'displayset'){
|
||||
if(typeof window.loadDisplaysetModal === 'function'){
|
||||
window.loadDisplaysetModal(url, pk); return;
|
||||
}
|
||||
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
|
||||
if(dsBtn){ dsBtn.click(); return; }
|
||||
}
|
||||
if(type === 'seriesfinding' || type === 'finding'){
|
||||
if(typeof window.loadFindingModal === 'function'){
|
||||
window.loadFindingModal(pk, url); return;
|
||||
}
|
||||
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
|
||||
if(fBtn){ fBtn.click(); return; }
|
||||
}
|
||||
}catch(e){ console.warn('error invoking page modal loader', e); }
|
||||
if(url) showModalForUrl(url, label);
|
||||
}
|
||||
|
||||
// Replace tokens in text nodes with styled spans
|
||||
function renderMarkupTokens(root){
|
||||
injectMarkupStyles();
|
||||
root = root || document.body;
|
||||
// Convert server-rendered anchor tokens into our interactive spans
|
||||
try{
|
||||
if(root.querySelectorAll){
|
||||
root.querySelectorAll('a.markup-link').forEach(function(a){
|
||||
try{
|
||||
var type = a.getAttribute('data-type') || '';
|
||||
var args = [];
|
||||
try{ args = JSON.parse(a.getAttribute('data-args') || '[]'); }catch(e){ args = []; }
|
||||
var pk = (args && args.length) ? (args[0] + '') : (a.getAttribute('data-pk') || '');
|
||||
var label = a.textContent ? a.textContent.trim() : null;
|
||||
var span = document.createElement('span');
|
||||
span.className = 'markup-token';
|
||||
if(type) span.setAttribute('data-type', type);
|
||||
if(pk) span.setAttribute('data-pk', pk);
|
||||
if(label) span.setAttribute('data-label', label);
|
||||
if(type === 'case') span.className += ' badge bg-primary text-white';
|
||||
else if(type === 'displayset') span.className += ' badge bg-success text-white';
|
||||
else span.className += ' badge bg-info text-dark';
|
||||
span.textContent = label ? label : (type + ':' + pk);
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
a.parentNode.replaceChild(span, a);
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
||||
var nodes = [];
|
||||
while(walker.nextNode()) nodes.push(walker.currentNode);
|
||||
var re = /\[\[(seriesfinding|finding|displayset|case):(\d+)(?:\|([^\]]+))?\]\]/gi;
|
||||
nodes.forEach(function(textNode){
|
||||
if(!textNode.nodeValue || !re.test(textNode.nodeValue)) return;
|
||||
var parentTag = textNode.parentElement && textNode.parentElement.tagName ? textNode.parentElement.tagName.toLowerCase() : '';
|
||||
if(['script','style','textarea','code','pre','a','button'].includes(parentTag)) return;
|
||||
// rebuild the fragment
|
||||
var frag = document.createDocumentFragment();
|
||||
var txt = textNode.nodeValue;
|
||||
var lastIndex = 0; var m;
|
||||
re.lastIndex = 0;
|
||||
while((m = re.exec(txt)) !== null){
|
||||
var before = txt.slice(lastIndex, m.index);
|
||||
if(before) frag.appendChild(document.createTextNode(before));
|
||||
var type = m[1].toLowerCase(); var pk = m[2]; var label = m[3] ? m[3] : null;
|
||||
var span = document.createElement('span'); span.className = 'markup-token'; span.setAttribute('data-type', type); span.setAttribute('data-pk', pk); if(label) span.setAttribute('data-label', label);
|
||||
// style by type
|
||||
if(type === 'case') span.className += ' badge bg-primary text-white';
|
||||
else if(type === 'displayset') span.className += ' badge bg-success text-white';
|
||||
else span.className += ' badge bg-info text-dark';
|
||||
span.textContent = label ? label : (type + ':' + pk);
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
frag.appendChild(span);
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
var rest = txt.slice(lastIndex); if(rest) frag.appendChild(document.createTextNode(rest));
|
||||
textNode.parentNode.replaceChild(frag, textNode);
|
||||
});
|
||||
|
||||
// Ensure any server-rendered span.markup-token elements have click handlers
|
||||
try{
|
||||
if(root.querySelectorAll){
|
||||
root.querySelectorAll('span.markup-token').forEach(function(span){
|
||||
try{
|
||||
if(span._markupBound) return;
|
||||
span._markupBound = true;
|
||||
// ensure data attributes are present
|
||||
var type = span.getAttribute('data-type') || '';
|
||||
var pk = span.getAttribute('data-pk') || '';
|
||||
var label = span.getAttribute('data-label') || span.textContent || '';
|
||||
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
||||
}catch(e){}
|
||||
});
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// Run rendering on DOMContentLoaded and after HTMX swaps
|
||||
document.addEventListener('DOMContentLoaded', function(){ try{ renderMarkupTokens(document.body); }catch(e){console.error(e);} });
|
||||
document.addEventListener('htmx:afterSwap', function(evt){ try{ var target = evt.detail && evt.detail.target ? evt.detail.target : document; renderMarkupTokens(target); }catch(e){/*ignore*/} });
|
||||
|
||||
})(window);
|
||||
|
||||
+65
-16
@@ -15,10 +15,13 @@ from .models import (
|
||||
Finding,
|
||||
Subspecialty,
|
||||
Resource,
|
||||
Procedure,
|
||||
)
|
||||
|
||||
from django.utils.html import format_html
|
||||
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
@@ -83,12 +86,14 @@ class CaseTable(SelectionTable):
|
||||
|
||||
def get_view_cell(record):
|
||||
if record.open_access:
|
||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>
|
||||
<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>
|
||||
</svg> View""")
|
||||
return mark_safe(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">'
|
||||
'<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>'
|
||||
'<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>'
|
||||
'</svg> View'
|
||||
)
|
||||
else:
|
||||
return f'View'
|
||||
return 'View'
|
||||
|
||||
def render_author(self, value):
|
||||
max_length = 15 # Set the maximum length for truncating individual author names
|
||||
@@ -108,7 +113,7 @@ class CaseTable(SelectionTable):
|
||||
)
|
||||
)
|
||||
# Join all authors with commas
|
||||
return format_html(", ".join(authors))
|
||||
return mark_safe(", ".join(authors))
|
||||
|
||||
def render_associated_cases(self, record):
|
||||
links = []
|
||||
@@ -130,7 +135,7 @@ class CaseTable(SelectionTable):
|
||||
except Case.DoesNotExist:
|
||||
pass
|
||||
if links:
|
||||
return format_html(", ".join(links))
|
||||
return mark_safe(", ".join(links))
|
||||
return "No associated cases"
|
||||
|
||||
def render_collections(self, record):
|
||||
@@ -151,7 +156,7 @@ class CaseTable(SelectionTable):
|
||||
)
|
||||
for collection in collections
|
||||
]
|
||||
return format_html(", ".join(links))
|
||||
return mark_safe(", ".join(links))
|
||||
return "No collections"
|
||||
|
||||
associated_cases = tables.Column(
|
||||
@@ -166,7 +171,7 @@ class CaseTable(SelectionTable):
|
||||
"atlas:case_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:case_detail", text=get_view_cell, args=[A("pk")], orderable=False
|
||||
"atlas:case_detail", text=get_view_cell, args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
clone = tables.LinkColumn(
|
||||
"atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
|
||||
@@ -199,7 +204,7 @@ class PopupLinkColumn(tables.Column):
|
||||
obj = value.first()
|
||||
|
||||
if obj is None:
|
||||
return format_html("<span>No image<span>")
|
||||
return mark_safe("<span>No image</span>")
|
||||
|
||||
return obj.get_thumbnail()[0]
|
||||
|
||||
@@ -209,7 +214,7 @@ class SeriesTable(SelectionTable):
|
||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:series_detail", text="View", args=[A("pk")], orderable=False
|
||||
"atlas:series_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
popup = tables.Column(empty_values=(), orderable=False)
|
||||
|
||||
@@ -290,7 +295,7 @@ class ConditionTable(SelectionTable):
|
||||
)
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
@@ -317,7 +322,7 @@ class FindingTable(SelectionTable):
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
|
||||
|
||||
class StructureTable(SelectionTable):
|
||||
@@ -344,7 +349,7 @@ class StructureTable(SelectionTable):
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
|
||||
|
||||
class PresentationTable(SelectionTable):
|
||||
@@ -391,6 +396,26 @@ class PathologicalProcessTable(SelectionTable):
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
class ProcedureTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:procedure_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Procedure",
|
||||
)
|
||||
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:procedure_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:procedure_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Procedure
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
class SubspecialtyTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
||||
@@ -412,7 +437,7 @@ class SubspecialtyTable(SelectionTable):
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return mark_safe(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
@@ -432,8 +457,11 @@ class CaseCollectionTable(SelectionTable):
|
||||
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
view = tables.LinkColumn(
|
||||
"atlas:collection_detail", text="View", args=[A("pk")], orderable=False
|
||||
"atlas:collection_detail", text="View", args=[A("pk")], accessor="pk", orderable=False
|
||||
)
|
||||
|
||||
created = tables.DateTimeColumn(verbose_name="Created", accessor="created", orderable=True)
|
||||
updated = tables.DateTimeColumn(verbose_name="Updated", accessor="updated", orderable=True)
|
||||
#clone = tables.LinkColumn(
|
||||
# "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False
|
||||
#)
|
||||
@@ -449,6 +477,8 @@ class CaseCollectionTable(SelectionTable):
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
"name",
|
||||
"created",
|
||||
"updated",
|
||||
"collection_type",
|
||||
"publish_results",
|
||||
"active",
|
||||
@@ -466,6 +496,25 @@ class CaseCollectionTable(SelectionTable):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def render_created(self, value):
|
||||
if not value:
|
||||
return ""
|
||||
# compact date for display, full datetime in tooltip
|
||||
return format_html(
|
||||
'<span class="small text-muted" title="{}">{}</span>',
|
||||
value.strftime("%Y-%m-%d %H:%M"),
|
||||
value.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
def render_updated(self, value):
|
||||
if not value:
|
||||
return ""
|
||||
return format_html(
|
||||
'<span class="small text-muted" title="{}">{}</span>',
|
||||
value.strftime("%Y-%m-%d %H:%M"),
|
||||
value.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
class QuestionSchemaTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||
|
||||
+436
-4
@@ -2,14 +2,97 @@ from time import sleep
|
||||
from django.core.mail import send_mail
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from celery import shared_task
|
||||
from atlas.models import Case
|
||||
from enum import Enum
|
||||
|
||||
try:
|
||||
from django_tasks import task
|
||||
HAS_DJANGO_TASKS = True
|
||||
except ImportError:
|
||||
from celery import shared_task
|
||||
from celery.result import AsyncResult
|
||||
|
||||
HAS_DJANGO_TASKS = False
|
||||
|
||||
class _CompatTaskResultStatus(Enum):
|
||||
READY = "READY"
|
||||
RUNNING = "RUNNING"
|
||||
FAILED = "FAILED"
|
||||
SUCCESSFUL = "SUCCESSFUL"
|
||||
|
||||
class _CompatError:
|
||||
def __init__(self, traceback):
|
||||
self.traceback = traceback
|
||||
|
||||
class _CompatTaskResult:
|
||||
def __init__(self, async_result):
|
||||
self._async_result = async_result
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._async_result.id
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
state = self._async_result.state
|
||||
if state in ("PENDING",):
|
||||
return _CompatTaskResultStatus.READY
|
||||
if state in ("STARTED", "RETRY"):
|
||||
return _CompatTaskResultStatus.RUNNING
|
||||
if state == "SUCCESS":
|
||||
return _CompatTaskResultStatus.SUCCESSFUL
|
||||
if state == "FAILURE":
|
||||
return _CompatTaskResultStatus.FAILED
|
||||
return _CompatTaskResultStatus.RUNNING
|
||||
|
||||
def refresh(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def return_value(self):
|
||||
return self._async_result.result
|
||||
|
||||
@property
|
||||
def errors(self):
|
||||
if self._async_result.state == "FAILURE" and self._async_result.traceback:
|
||||
return [_CompatError(self._async_result.traceback)]
|
||||
return []
|
||||
|
||||
def task(func=None, *, takes_context=False, **kwargs):
|
||||
def decorator(inner_func):
|
||||
if takes_context:
|
||||
@shared_task(bind=True)
|
||||
def wrapped(self, *args, **inner_kwargs):
|
||||
class _Ctx:
|
||||
class _TaskResultRef:
|
||||
id = self.request.id
|
||||
|
||||
task_result = _TaskResultRef()
|
||||
attempt = 1
|
||||
|
||||
return inner_func(_Ctx(), *args, **inner_kwargs)
|
||||
else:
|
||||
wrapped = shared_task(inner_func)
|
||||
|
||||
wrapped.enqueue = wrapped.delay
|
||||
wrapped.get_result = lambda result_id: _CompatTaskResult(AsyncResult(result_id))
|
||||
return wrapped
|
||||
|
||||
if func is not None:
|
||||
return decorator(func)
|
||||
return decorator
|
||||
|
||||
from atlas.models import Case, Series, SeriesImage
|
||||
from generic.models import CimarCase
|
||||
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
from helpers.cimar import CimarAPI, NotFoundError
|
||||
from pydicom.uid import generate_uid
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.cache import cache
|
||||
import copy
|
||||
import io
|
||||
|
||||
@shared_task()
|
||||
@task
|
||||
def push_case_to_cimar_task(case_id):
|
||||
"""Sends an email when the feedback form has been submitted."""
|
||||
case = get_object_or_404(Case, pk=case_id)
|
||||
@@ -49,4 +132,353 @@ def push_case_to_cimar_task(case_id):
|
||||
|
||||
cimar_case.refresh_study()
|
||||
|
||||
return 10
|
||||
return 10
|
||||
|
||||
|
||||
@task(takes_context=True)
|
||||
def series_reconstruct_task(
|
||||
context,
|
||||
series_id,
|
||||
user_id,
|
||||
recon_planes,
|
||||
slice_thickness_val=None,
|
||||
slice_spacing_val=None,
|
||||
recon_thickness_mode="mean",
|
||||
):
|
||||
"""Generate reconstructions asynchronously for a series with progress updates."""
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from atlas import views as atlas_views
|
||||
|
||||
progress_key = f"series_reconstruct_progress:{context.task_result.id}"
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": 0, "total": 0, "message": "Preparing reconstruction..."},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
|
||||
if not series.check_user_can_edit(user):
|
||||
raise PermissionError("Permission denied")
|
||||
|
||||
images = list(series.get_images())
|
||||
dicom_items = []
|
||||
for image in images:
|
||||
ds = atlas_views._read_series_image_dataset(image)
|
||||
if ds is None:
|
||||
continue
|
||||
try:
|
||||
arr = ds.pixel_array
|
||||
if arr.ndim != 2:
|
||||
continue
|
||||
dicom_items.append((image, ds, arr))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if len(dicom_items) < 2:
|
||||
raise ValueError("Need at least 2 valid DICOM images in series for reconstruction")
|
||||
|
||||
base_shape = dicom_items[0][2].shape
|
||||
dicom_items = [item for item in dicom_items if item[2].shape == base_shape]
|
||||
if len(dicom_items) < 2:
|
||||
raise ValueError("Not enough consistently-sized slices for reconstruction")
|
||||
|
||||
geom = atlas_views._extract_recon_geometry(dicom_items)
|
||||
sorted_items = geom["sorted_items"]
|
||||
volume = np.stack([item[2] for item in sorted_items], axis=0)
|
||||
|
||||
template_ds = sorted_items[0][1]
|
||||
origin_ipp = geom["origin_ipp"]
|
||||
row_dir = geom["row_dir"]
|
||||
col_dir = geom["col_dir"]
|
||||
normal_dir = geom["normal_dir"]
|
||||
native_row_spacing = geom["row_spacing"]
|
||||
native_col_spacing = geom["col_spacing"]
|
||||
native_z_spacing = geom["native_z_spacing"]
|
||||
|
||||
requested_slice_spacing = float(slice_spacing_val) if slice_spacing_val is not None else None
|
||||
requested_slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else None
|
||||
|
||||
# Determine source acquisition plane from normal vector.
|
||||
dominant_normal_axis = int(np.argmax(np.abs(normal_dir)))
|
||||
source_plane = {0: "sagittal", 1: "coronal", 2: "axial"}.get(dominant_normal_axis, "unknown")
|
||||
|
||||
# Reorient the reconstructed volume into patient axes order [z, y, x] so
|
||||
# generated plane names are anatomically correct regardless of source plane.
|
||||
# NOTE: DICOM stores ImageOrientationPatient as:
|
||||
# - first triplet: direction across columns (x-axis in pixel grid)
|
||||
# - second triplet: direction across rows (y-axis in pixel grid)
|
||||
# In _extract_recon_geometry names are historically row_dir/col_dir, so we
|
||||
# map axis spacing/indexing explicitly to avoid directional mixups.
|
||||
source_axis_dirs = [normal_dir, col_dir, row_dir]
|
||||
source_axis_spacings = [float(native_z_spacing), float(native_row_spacing), float(native_col_spacing)]
|
||||
source_axis_sizes = [int(volume.shape[0]), int(volume.shape[1]), int(volume.shape[2])]
|
||||
|
||||
source_for_patient_axis = {}
|
||||
sign_for_patient_axis = {}
|
||||
patient_axes_claimed = set()
|
||||
|
||||
for src_axis, vec in enumerate(source_axis_dirs):
|
||||
patient_axis = int(np.argmax(np.abs(vec)))
|
||||
if patient_axis in patient_axes_claimed:
|
||||
raise ValueError("Could not infer unique source orientation axes for reconstruction")
|
||||
patient_axes_claimed.add(patient_axis)
|
||||
source_for_patient_axis[patient_axis] = src_axis
|
||||
sign_for_patient_axis[patient_axis] = 1 if float(vec[patient_axis]) >= 0 else -1
|
||||
|
||||
src_x = source_for_patient_axis[0]
|
||||
src_y = source_for_patient_axis[1]
|
||||
src_z = source_for_patient_axis[2]
|
||||
|
||||
spacing_x = source_axis_spacings[src_x]
|
||||
spacing_y = source_axis_spacings[src_y]
|
||||
spacing_z = source_axis_spacings[src_z]
|
||||
|
||||
vol_zyx = np.transpose(volume, (src_z, src_y, src_x))
|
||||
|
||||
if sign_for_patient_axis[2] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=0)
|
||||
if sign_for_patient_axis[1] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=1)
|
||||
if sign_for_patient_axis[0] < 0:
|
||||
vol_zyx = np.flip(vol_zyx, axis=2)
|
||||
|
||||
def source_indices_from_patient_zyx(iz, iy, ix):
|
||||
src_indices = [0, 0, 0]
|
||||
|
||||
z_index = float(iz)
|
||||
y_index = float(iy)
|
||||
x_index = float(ix)
|
||||
|
||||
if sign_for_patient_axis[2] < 0:
|
||||
z_index = source_axis_sizes[src_z] - 1 - z_index
|
||||
if sign_for_patient_axis[1] < 0:
|
||||
y_index = source_axis_sizes[src_y] - 1 - y_index
|
||||
if sign_for_patient_axis[0] < 0:
|
||||
x_index = source_axis_sizes[src_x] - 1 - x_index
|
||||
|
||||
src_indices[src_z] = z_index
|
||||
src_indices[src_y] = y_index
|
||||
src_indices[src_x] = x_index
|
||||
return src_indices
|
||||
|
||||
def position_from_patient_zyx(iz, iy, ix):
|
||||
src_k, src_r, src_c = source_indices_from_patient_zyx(iz, iy, ix)
|
||||
pos = (
|
||||
np.asarray(origin_ipp, dtype=float)
|
||||
+ (np.asarray(normal_dir, dtype=float) * (float(src_k) * float(native_z_spacing)))
|
||||
+ (np.asarray(col_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
|
||||
+ (np.asarray(row_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
|
||||
)
|
||||
return [float(pos[0]), float(pos[1]), float(pos[2])]
|
||||
|
||||
spacing_x = source_axis_spacings[src_x]
|
||||
spacing_y = source_axis_spacings[src_y]
|
||||
spacing_z = source_axis_spacings[src_z]
|
||||
|
||||
def _reduce_slab(slab, reduce_axis=0):
|
||||
if recon_thickness_mode == "max":
|
||||
out = np.max(slab, axis=reduce_axis)
|
||||
elif recon_thickness_mode == "min":
|
||||
out = np.min(slab, axis=reduce_axis)
|
||||
else:
|
||||
out = np.mean(slab, axis=reduce_axis)
|
||||
|
||||
if np.issubdtype(volume.dtype, np.integer):
|
||||
info = np.iinfo(volume.dtype)
|
||||
out = np.clip(np.rint(out), info.min, info.max).astype(volume.dtype)
|
||||
else:
|
||||
out = out.astype(volume.dtype, copy=False)
|
||||
return out
|
||||
|
||||
def _slice_indices_for_axis(axis_length, axis_spacing, output_spacing, slab_thickness):
|
||||
if output_spacing <= 0:
|
||||
raise ValueError("Slice spacing must be greater than 0")
|
||||
if slab_thickness <= 0:
|
||||
raise ValueError("Slice thickness must be greater than 0")
|
||||
|
||||
axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing)
|
||||
end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0
|
||||
start_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
|
||||
if start_positions_mm.size == 0:
|
||||
start_positions_mm = np.array([0.0], dtype=float)
|
||||
|
||||
slabs = []
|
||||
centers_idx = []
|
||||
for start_mm in start_positions_mm:
|
||||
end_mm = start_mm + slab_thickness
|
||||
mask = (axis_positions_mm >= (start_mm - 1e-6)) & (axis_positions_mm < (end_mm - 1e-6))
|
||||
if not mask.any():
|
||||
nearest = int(np.argmin(np.abs(axis_positions_mm - start_mm)))
|
||||
idxs = np.array([nearest], dtype=int)
|
||||
else:
|
||||
idxs = np.where(mask)[0].astype(int)
|
||||
|
||||
slabs.append(idxs)
|
||||
center_mm = start_mm + (slab_thickness / 2.0)
|
||||
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
|
||||
|
||||
return slabs, centers_idx
|
||||
|
||||
plane_builders = {}
|
||||
for plane in recon_planes:
|
||||
plane_norm = plane.lower()
|
||||
if plane_norm == "axial":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_z)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[0]), float(spacing_z), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[slabs[idx], :, :]),
|
||||
"pixel_spacing_out": [float(spacing_y), float(spacing_x)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||
"plane_normal_out": [0.0, 0.0, 1.0],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(centers[idx], 0.0, 0.0),
|
||||
}
|
||||
elif plane_norm == "coronal":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_y)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[1]), float(spacing_y), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: np.flipud(_reduce_slab(vol_zyx[:, slabs[idx], :], reduce_axis=1)),
|
||||
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 0.0, -1.0],
|
||||
"plane_normal_out": [0.0, 1.0, 0.0],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), centers[idx], 0.0),
|
||||
}
|
||||
elif plane_norm == "sagittal":
|
||||
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_x)
|
||||
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
|
||||
slab_indices, slab_centers_idx = _slice_indices_for_axis(
|
||||
int(vol_zyx.shape[2]), float(spacing_x), plane_spacing, plane_thickness
|
||||
)
|
||||
plane_builders[plane_norm] = {
|
||||
"count": int(len(slab_indices)),
|
||||
"slice_fn": lambda idx, slabs=slab_indices: np.flipud(_reduce_slab(vol_zyx[:, :, slabs[idx]], reduce_axis=2)),
|
||||
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
|
||||
"spacing_between_slices_out": float(plane_spacing),
|
||||
"slice_thickness_out": float(plane_thickness),
|
||||
"image_orientation_out": [0.0, 1.0, 0.0, 0.0, 0.0, -1.0],
|
||||
"plane_normal_out": [1.0, 0.0, 0.0],
|
||||
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), 0.0, centers[idx]),
|
||||
}
|
||||
|
||||
if not plane_builders:
|
||||
raise ValueError("No valid reconstruction planes selected")
|
||||
|
||||
total_slices = sum(v["count"] for v in plane_builders.values())
|
||||
cache.set(
|
||||
progress_key,
|
||||
{"current": 0, "total": total_slices, "message": "Reconstruction started"},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
processed = 0
|
||||
created_series = []
|
||||
|
||||
for plane_norm, cfg in plane_builders.items():
|
||||
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
|
||||
recon_series.series_instance_uid = generate_uid()
|
||||
recon_series.save(update_fields=["series_instance_uid"])
|
||||
|
||||
for idx in range(int(cfg["count"])):
|
||||
arr2d = cfg["slice_fn"](idx)
|
||||
ds_new = copy.deepcopy(template_ds)
|
||||
arr2d = np.asarray(arr2d, dtype=dicom_items[0][2].dtype)
|
||||
|
||||
ds_new.Rows = int(arr2d.shape[0])
|
||||
ds_new.Columns = int(arr2d.shape[1])
|
||||
ds_new.InstanceNumber = idx + 1
|
||||
atlas_views._stamp_derived_dataset(
|
||||
ds_new,
|
||||
series_instance_uid=recon_series.series_instance_uid,
|
||||
series_description=recon_series.description or f"Recon {plane_norm.title()}",
|
||||
derivation_description=(
|
||||
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
|
||||
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
|
||||
),
|
||||
image_type=["DERIVED", "SECONDARY", "MPR"],
|
||||
)
|
||||
ds_new.PixelData = arr2d.tobytes()
|
||||
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
|
||||
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
|
||||
ipp = cfg["position_fn"](idx)
|
||||
ds_new.ImagePositionPatient = ipp
|
||||
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
|
||||
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
|
||||
try:
|
||||
normal = np.asarray(cfg["plane_normal_out"], dtype=float)
|
||||
ds_new.SliceLocation = float(np.dot(np.asarray(ipp, dtype=float), normal))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out_io = io.BytesIO()
|
||||
ds_new.save_as(out_io, write_like_original=False)
|
||||
out_io.seek(0)
|
||||
|
||||
recon_image = SeriesImage(
|
||||
series=recon_series,
|
||||
position=idx + 1,
|
||||
upload_filename=f"recon_{plane_norm}_{idx + 1}.dcm",
|
||||
)
|
||||
recon_image.image.save(
|
||||
f"recon_{plane_norm}_{recon_series.pk}_{idx + 1}.dcm",
|
||||
ContentFile(out_io.getvalue()),
|
||||
save=False,
|
||||
)
|
||||
recon_image.save()
|
||||
|
||||
processed += 1
|
||||
cache.set(
|
||||
progress_key,
|
||||
{
|
||||
"current": processed,
|
||||
"total": total_slices,
|
||||
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
|
||||
},
|
||||
timeout=60 * 60,
|
||||
)
|
||||
|
||||
created_series.append(
|
||||
{
|
||||
"id": recon_series.pk,
|
||||
"url": recon_series.get_absolute_url(),
|
||||
"description": recon_series.description or str(recon_series.pk),
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Reconstruction task complete for series {} with {} outputs",
|
||||
series.pk,
|
||||
len(created_series),
|
||||
)
|
||||
|
||||
cache.set(
|
||||
progress_key,
|
||||
{
|
||||
"current": total_slices,
|
||||
"total": total_slices,
|
||||
"message": "Reconstruction complete",
|
||||
},
|
||||
timeout=10 * 60,
|
||||
)
|
||||
|
||||
return {
|
||||
"series_id": series.pk,
|
||||
"created_series": created_series,
|
||||
"target_spacing": float(requested_slice_spacing) if requested_slice_spacing is not None else None,
|
||||
"slab_thickness": float(requested_slab_thickness) if requested_slab_thickness is not None else None,
|
||||
"mode": recon_thickness_mode,
|
||||
"source_plane": source_plane,
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
<a href="{% url 'atlas:subspecialty_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'presentations' %}
|
||||
<a href="{% url 'atlas:presentation_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'procedures' %}
|
||||
<a href="{% url 'atlas:procedure_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% else %}
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
@@ -27,7 +29,7 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists %}
|
||||
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists and not results.procedures.exists %}
|
||||
<p class="text-muted">No matches found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<div id="add-case-form" class="p-2 border rounded">
|
||||
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
|
||||
|
||||
{% include 'atlas/partials/case_search_widget.html' %}
|
||||
{% load case_widgets %}
|
||||
{% url 'atlas:add_case_to_collection' collection.pk as add_case_url %}
|
||||
{% case_search_widget collection=collection cases=cases recent_cases=recent_cases selection_mode='multiple' action_url=add_case_url input_id='case-search-input-collection-'|add:collection.pk|stringformat:'s' target_id='case-search-results-collection-'|add:collection.pk|stringformat:'s' %}
|
||||
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<h3>API Tokens</h3>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input name="name" class="form-control" placeholder="Name (e.g. CLI)" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input name="scopes" class="form-control" placeholder="Scopes (space separated)" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input name="expires_days" type="number" min="0" class="form-control" placeholder="Days" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary w-100" type="submit" name="action" value="create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if created_token %}
|
||||
<div class="alert alert-info">Created token. Save it now — it won't be shown again.
|
||||
<pre class="mt-2">{{ created_token }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Your tokens</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Expires</th><th>Revoked</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in tokens %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ t.scopes }}</td>
|
||||
<td>{{ t.created }}</td>
|
||||
<td>{{ t.expires }}</td>
|
||||
<td>{{ t.revoked }}</td>
|
||||
<td>
|
||||
{% if not t.revoked %}
|
||||
<form method="post" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="revoke_id" value="{{ t.id }}" />
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" name="action" value="revoke">Revoke</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-muted">No tokens</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -49,6 +49,15 @@
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_search_page' %}"><i class="bi bi-search me-1" aria-hidden="true"></i>Search Cases</a>
|
||||
</li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:cases_by_size' %}"><i class="bi bi-hDD-stack me-1" aria-hidden="true"></i>Large cases</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -81,6 +90,7 @@
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:procedure_view' %}">Procedures</a></li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li class="dropdown-item-text small text-muted px-3">Create</li>
|
||||
@@ -90,6 +100,7 @@
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:procedure_create' %}">New procedure</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
@@ -107,9 +118,26 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
||||
|
||||
<li 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>
|
||||
<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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
<div class="case-displayset-modal">
|
||||
<div class="mb-3">
|
||||
<h5>{{ ds.name }}</h5>
|
||||
{% if can_edit %}
|
||||
<a href="{% url 'atlas:case_displaysets' ds.case.pk %}?displayset={{ ds.pk }}&edit=true" class="btn btn-sm btn-outline-secondary ms-2">Edit display set</a>
|
||||
{% endif %}
|
||||
{% if ds.description %}
|
||||
<div class="text-muted">{{ ds.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Viewer area for the modal - mounted by rad/static/dv3d bundle #}
|
||||
<div class="mb-3">
|
||||
<div id="modal_viewer_{{ ds.pk }}" class="dicom-viewer-root" style="height:420px;" data-auto-cache-stack="false" data-annotationjson="{{ ds.annotations }}" data-viewerstate="{{ ds.viewerstate }}"></div>
|
||||
</div>
|
||||
|
||||
{% if ds.viewerstate and ds.annotations %}
|
||||
<div class="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick='(window.importAnnotations_modal_viewer_{{ ds.pk }}||window.importAnnotations_modal_viewer||window.importAnnotations)({{ ds.annotations|safe }}); (window.importViewerState_modal_viewer_{{ ds.pk }}||window.importViewerState_modal_viewer||window.importViewerState)({{ ds.viewerstate|safe }});'
|
||||
>Reload display set</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<strong>Findings:</strong>
|
||||
{% if ds.findings.all %}
|
||||
<ul>
|
||||
{% for finding in ds.findings.all %}
|
||||
<li>{{ finding }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Structures:</strong>
|
||||
{% if ds.structures.all %}
|
||||
<ul>
|
||||
{% for structure in ds.structures.all %}
|
||||
<li>{{ structure }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Conditions:</strong>
|
||||
{% if ds.conditions.all %}
|
||||
<ul>
|
||||
{% for condition in ds.conditions.all %}
|
||||
<li>{{ condition }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||
<div>
|
||||
{% 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>
|
||||
</div>
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<ul class="list-group mb-4" id="display-set-list">
|
||||
{% for ds in case.display_sets.all %}
|
||||
{% include 'atlas/partials/displayset_row.html' %}
|
||||
{% include 'atlas/partials/displayset_row.html' %}
|
||||
{% empty %}
|
||||
<li class="list-group-item text-muted">No display sets for this case.</li>
|
||||
{% endfor %}
|
||||
@@ -38,20 +38,49 @@
|
||||
{% block js %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.mountDicomViewers();
|
||||
// Helper: wait for mount function and mount the viewers safely
|
||||
function waitForMountFn(timeout = 5000) {
|
||||
if (window.mountDicomViewers) return Promise.resolve(window.mountDicomViewers);
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
function poll() {
|
||||
if (window.mountDicomViewers) return resolve(window.mountDicomViewers);
|
||||
if (Date.now() - start > timeout) return reject(new Error('mountDicomViewers not available'));
|
||||
requestAnimationFrame(poll);
|
||||
}
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
async function mountDicomViewersSafely(timeout = 5000) {
|
||||
try {
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const mountFn = await waitForMountFn(timeout);
|
||||
try { mountFn(); } catch (e) { console.warn('mountDicomViewers failed', e); }
|
||||
requestAnimationFrame(() => { try { window.dispatchEvent(new Event('resize')); } catch (e) {} });
|
||||
} catch (err) {
|
||||
console.warn('Viewer initialization deferred/fallback:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize viewers deterministically
|
||||
mountDicomViewersSafely();
|
||||
|
||||
|
||||
{% if selected_displayset %}
|
||||
setTimeout(function() {
|
||||
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
|
||||
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
|
||||
// Highlight the selected displayset row
|
||||
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
|
||||
if (selectedRow) {
|
||||
selectedRow.style.border = "2px solid #ffc107";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
(async function(){
|
||||
try {
|
||||
await mountDicomViewersSafely();
|
||||
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
|
||||
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
|
||||
// Highlight the selected displayset row
|
||||
var selectedRow = document.getElementById("displayset-{{ selected_displayset.pk }}");
|
||||
if (selectedRow) {
|
||||
selectedRow.style.border = "2px solid #ffc107";
|
||||
}
|
||||
} catch (e) { console.warn('failed to import selected displayset state', e); }
|
||||
})();
|
||||
|
||||
{% if edit %}
|
||||
var editButton = document.getElementById("displayset-{{ selected_displayset.pk }}-edit-btn");
|
||||
if (editButton) {
|
||||
|
||||
@@ -146,58 +146,58 @@
|
||||
|
||||
|
||||
<!-- Differential Collapsible Panel -->
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
|
||||
<h3 class="d-inline">Differential</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="differentialPanel">
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#differentialPanel" role="button" aria-expanded="false" aria-controls="differentialPanel">
|
||||
<h3 class="d-inline">Differential</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="differentialPanel">
|
||||
<input type="button" value="Add Another Differential" id="add_more_differential" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="casedifferential_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in casedifferential_formset %}
|
||||
<li class="no-error casedifferential_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ casedifferential_formset.management_form | crispy }}
|
||||
</div>
|
||||
</div>
|
||||
{{ casedifferential_formset.management_form | crispy }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Collapsible Panel -->
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
|
||||
<h3 class="d-inline">Resource</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="resourcePanel">
|
||||
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="caseresource_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in caseresource_formset %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<div class="card my-3 bg-dark text-white">
|
||||
<div class="card-header">
|
||||
<a class="collapsed text-white" data-bs-toggle="collapse" href="#resourcePanel" role="button" aria-expanded="false" aria-controls="resourcePanel">
|
||||
<h3 class="d-inline">Resource</h3>
|
||||
<span class="ms-2">(click to expand/collapse)</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="resourcePanel">
|
||||
<input type="button" value="Add Another Resource" id="add_more_resource" class="btn btn-sm btn-primary float-end">
|
||||
<div class="card-body">
|
||||
<div id="caseresource_formset" class="list_formset">
|
||||
<ol>
|
||||
{% for form in caseresource_formset %}
|
||||
<li class="no-error caseresource_formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form | crispy}}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form | crispy}}
|
||||
</div>
|
||||
</div>
|
||||
{{ caseresource_formset.management_form | crispy}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
</form>
|
||||
@@ -216,4 +216,11 @@
|
||||
{{ caseresource_formset.empty_form | crispy}}
|
||||
</li>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
if(window.markupInserter && typeof markupInserter.initForNames === 'function'){
|
||||
markupInserter.initForNames(['description','history','discussion','report','notes']);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Search Cases</h2>
|
||||
|
||||
<form method="get" class="row g-2 mb-3" id="case-search-form">
|
||||
<div class="col-md-6">
|
||||
<input type="search" name="q" value="{{ q }}" class="form-control" placeholder="Search cases...">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="field" class="form-select">
|
||||
<option value="all" {% if field == 'all' %}selected{% endif %}>All fields</option>
|
||||
<option value="title" {% if field == 'title' %}selected{% endif %}>Title</option>
|
||||
<option value="description" {% if field == 'description' %}selected{% endif %}>Description</option>
|
||||
<option value="history" {% if field == 'history' %}selected{% endif %}>History</option>
|
||||
<option value="author" {% if field == 'author' %}selected{% endif %}>Author</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<select name="subspecialty" class="form-select">
|
||||
<option value="">Any subspecialty</option>
|
||||
{% for s in subspecialty_queryset %}
|
||||
<option value="{{ s.pk }}" {% if selected_subspecialty and selected_subspecialty|stringformat:"s" == s.pk|stringformat:"s" %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<label class="form-check-label me-2"><input type="checkbox" name="my" value="1" class="form-check-input" {% if my %}checked{% endif %}> My cases</label>
|
||||
<label class="form-check-label me-2"><input type="checkbox" name="open" value="1" class="form-check-input" {% if open %}checked{% endif %}> Open access</label>
|
||||
<label class="form-check-label"><input type="checkbox" name="has_images" value="1" class="form-check-input" {% if has_images %}checked{% endif %}> Has images</label>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<a href="{% url 'atlas:case_search_page' %}" class="btn btn-outline-secondary ms-2">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="case-search-results">
|
||||
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=None q=q %}
|
||||
|
||||
{# pagination controls #}
|
||||
{% if cases.has_other_pages %}
|
||||
<nav aria-label="Case results pagination">
|
||||
<ul class="pagination mt-3">
|
||||
{% if cases.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&{% endif %}{% if field %}field={{ field }}&{% endif %}{% if my %}my=1&{% endif %}{% if open %}open=1&{% endif %}{% if has_images %}has_images=1&{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&{% endif %}page={{ cases.previous_page_number }}">Previous</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">Previous</span></li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ cases.number }} of {{ cases.paginator.num_pages }}</span></li>
|
||||
|
||||
{% if cases.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?{% if q %}q={{ q|urlencode }}&{% endif %}{% if field %}field={{ field }}&{% endif %}{% if my %}my=1&{% endif %}{% if open %}open=1&{% endif %}{% if has_images %}has_images=1&{% endif %}{% if selected_subspecialty %}subspecialty={{ selected_subspecialty }}&{% endif %}page={{ cases.next_page_number }}">Next</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">Next</span></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -72,29 +72,6 @@
|
||||
{% csrf_token %}
|
||||
|
||||
{% crispy form form.helper %}
|
||||
{% comment %} <h3>Cases:</h3>
|
||||
Add cases here. These can only be added once created (they can also be added to cases on creation). Click and drag to change order.
|
||||
<input type="button" value="Add More Cases" id="add_more_case">
|
||||
<input type=button id="case-order-button" title="click and drag to update case order" value="Update case order" />
|
||||
<div id="case_formset" class="sortable list_formset">
|
||||
<ol>
|
||||
{% for form in case_formset %}
|
||||
<li class="no-error case-formset">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
{{ form }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
{{ case_formset.management_form }}
|
||||
</form>
|
||||
<div id="empty_case_form" style="display:none">
|
||||
<li class='no_error case-formset'>
|
||||
{{ case_formset.empty_form }}
|
||||
</li>
|
||||
</div> {% endcomment %}
|
||||
{% comment %} <input type="submit" class="submit-button" value="Submit" name="submit"> {% endcomment %}
|
||||
</form>
|
||||
<script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Categories</h1>
|
||||
<p>The platform has numberous different ways to tag and categorise content. These help with organsing cases and series, given the ability to find cases by a particuarly property (such as a finding) or find similar cases.</p><p>These can be browsed below.</p>
|
||||
<h1>Categories</h1>
|
||||
<p>The platform has numberous different ways to tag and categorise content. These help with organsing cases and series, given the ability to find cases by a particuarly property (such as a finding) or find similar cases.</p><p>These can be browsed below.</p>
|
||||
<ul>
|
||||
<li><a href="{% url 'atlas:condition_view' %}">Conditions</a></li>
|
||||
<li><a href="{% url 'atlas:finding_view' %}">Findings</a></li>
|
||||
@@ -10,113 +10,123 @@
|
||||
<li><a href="{% url 'atlas:subspecialty_view' %}">Subspecialties</li></a>
|
||||
<li><a href="{% url 'atlas:presentation_view' %}">Presentations</li></a>
|
||||
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
|
||||
<li><a href="{% url 'atlas:procedure_view' %}">Procedures</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 mb-3">
|
||||
<div class="mt-4 mb-3">
|
||||
{# HTMX loading indicator styles: hide by default, show when HTMX adds the .htmx-request class #}
|
||||
<style>
|
||||
<style>
|
||||
#category-search-loading { display: none; }
|
||||
#category-search-loading.htmx-request { display: inline-block; }
|
||||
</style>
|
||||
</style>
|
||||
|
||||
<p>Alternatively you can use the search function below</p>
|
||||
<p>Alternatively you can use the search function below</p>
|
||||
|
||||
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
|
||||
<div class="d-flex align-items-center">
|
||||
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
|
||||
<div class="d-flex align-items-center">
|
||||
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-trigger="keyup delay:500ms"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary ms-2" type="submit">Search</button>
|
||||
</div>
|
||||
|
||||
{# Collapsed advanced filters block - defaults to collapsed #}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
|
||||
Advanced filters ▾
|
||||
</button>
|
||||
|
||||
<div class="collapse mt-2" id="category-filters">
|
||||
<div class="card card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-conditions">Conditions</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-findings">Findings</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-structures">Structures</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-presentations">Presentations</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary ms-2" type="submit">Search</button>
|
||||
</div>
|
||||
|
||||
{# Collapsed advanced filters block - defaults to collapsed #}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
|
||||
Advanced filters ▾
|
||||
</button>
|
||||
|
||||
<div class="collapse mt-2" id="category-filters">
|
||||
<div class="card card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-conditions">Conditions</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-findings">Findings</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-structures">Structures</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-presentations">Presentations</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-procedures" name="types" value="procedures" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-procedures">Procedures</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
{% comment %} <button class="btn btn-primary ms-2" type="submit">Search</button> {% endcomment %}
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="category-search-results">
|
||||
{% if results is not None %}
|
||||
{% include 'atlas/_categories_search_results.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
||||
</p>
|
||||
<div id="category-search-results">
|
||||
{% if results is not None %}
|
||||
{% include 'atlas/_categories_search_results.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,157 +1,334 @@
|
||||
{% extends 'atlas/exams.html' %}
|
||||
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef casedetails-management-links %}
|
||||
|
||||
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
{% endpartialdef %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||
<div class="container">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
|
||||
<small class="text-muted">(open access: {{ collection.exam_open_access }})</small>
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
</p>
|
||||
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
|
||||
<span class="badge bg-light text-dark me-2">Self review: {{ collection.self_review }}</span>
|
||||
{% if collection.open_access %}
|
||||
<span class="badge bg-success">Open</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Restricted</span>
|
||||
{% include 'exam_notes.html' %}
|
||||
{% if casesdetails|length > 20 %}
|
||||
<div id="collection-warning-{{ collection.pk }}" class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<strong>Performance warning:</strong> This collection contains {{ casesdetails|length }} cases — in some situations this may adversely affect performance, all features will still work but page loading may be slower. Consider splitting into smaller collections if performance is an issue.
|
||||
<button id="collection-warning-close-{{ collection.pk }}" type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="mt-3 mb-2">
|
||||
<strong>Prerequisites</strong>
|
||||
<ul class="mb-0">
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="mb-1">
|
||||
</p>
|
||||
|
||||
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
|
||||
<div class="mb-2 w-100 text-md-end">
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
|
||||
{% else %}
|
||||
<div class="d-inline-block">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
|
||||
<button
|
||||
_="on click
|
||||
writeText(#take-url-link's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy link' into me"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
|
||||
<p class="mb-2">
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %} {% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
</p>
|
||||
|
||||
<p class="mb-1">
|
||||
Collection type: <span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
|
||||
Self review: <span class="badge bg-light text-dark me-2">{{ collection.self_review }}</span>
|
||||
Open access:
|
||||
{% if collection.open_access %}
|
||||
<span class="badge bg-success">Open</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Restricted</span>
|
||||
{% endif %}
|
||||
Exam mode: <span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
|
||||
</p>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="mt-3 mb-2">
|
||||
<strong>Prerequisites</strong>
|
||||
<ul class="mb-0">
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="w-100 text-md-end">
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
|
||||
<div class="mb-2 w-100 text-md-end">
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
|
||||
{% else %}
|
||||
<div class="d-inline-block">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
|
||||
<button
|
||||
_="on click
|
||||
writeText(#take-url-link's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy link' into me"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="w-100 text-md-end">
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h3 class="mb-3">Cases</h3>
|
||||
|
||||
<ol id="full-question-list" class="sortable list-group list-group-numbered">
|
||||
{% for casedetail in casesdetails %}
|
||||
{% include 'atlas/partials/case_list_item.html' %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button id='button-edit-order'
|
||||
title='click and drag questions to change order'
|
||||
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
Edit case order / Remove cases
|
||||
</button>
|
||||
|
||||
<button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
|
||||
class="btn btn-sm btn-secondary">
|
||||
Add case
|
||||
</button>
|
||||
|
||||
<div id='case-list'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
<h3 class="mb-3">Cases</h3>
|
||||
|
||||
<div class="alert alert-danger mt-3">
|
||||
<p class="mb-1">Manage answers for this collection.</p>
|
||||
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
class="btn btn-danger btn-sm"
|
||||
>Reset all answers</button>
|
||||
|
||||
<button title="This will specific user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#user-list"
|
||||
class="btn btn-warning btn-sm">
|
||||
Reset user answers</button>
|
||||
</div>
|
||||
|
||||
<div id="user-list" class="mt-3"></div>
|
||||
</details>
|
||||
<ol id="full-question-list" class="sortable list-group list-group-numbered">
|
||||
{% for casedetail in casesdetails %}
|
||||
{% include 'atlas/partials/case_list_item.html' %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button id='button-edit-order'
|
||||
title='click and drag questions to change order'
|
||||
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
Edit case order / Remove cases
|
||||
</button>
|
||||
|
||||
<button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
|
||||
class="btn btn-sm btn-secondary">
|
||||
Add case
|
||||
</button>
|
||||
|
||||
<div id='case-list'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<div class="alert alert-danger mt-3">
|
||||
<p class="mb-1">Manage answers for this collection.</p>
|
||||
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
class="btn btn-danger btn-sm"
|
||||
>Reset all answers</button>
|
||||
|
||||
<button title="This will specific user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#user-list"
|
||||
class="btn btn-warning btn-sm">
|
||||
Reset user answers</button>
|
||||
</div>
|
||||
|
||||
<div id="user-list" class="mt-3"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
<!-- Modal for case fields (history / discussion / report) -->
|
||||
<div id="case-field-modal" class="modal fade" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
try {
|
||||
var pk = '{{ collection.pk }}';
|
||||
var key = 'collection_warning_dismissed_' + pk;
|
||||
var alertEl = document.getElementById('collection-warning-' + pk);
|
||||
if (!alertEl) return;
|
||||
if (localStorage.getItem(key) === '1') {
|
||||
alertEl.remove();
|
||||
return;
|
||||
}
|
||||
var btn = document.getElementById('collection-warning-close-' + pk);
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
try { localStorage.setItem(key, '1'); } catch (e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
} catch (e) { console.warn('collection warning persistence error', e); }
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Listen for case selection events from a case-search-widget included in this page/modal
|
||||
document.body.addEventListener('case:selected', async function(e) {
|
||||
try {
|
||||
const detail = e.detail || {};
|
||||
const casePk = detail.casePk;
|
||||
const caseTitle = detail.caseTitle || ('#' + casePk);
|
||||
if (!casePk) return;
|
||||
|
||||
if (!window.confirm(`Add case ${caseTitle} to collection {{ collection.name }}?`)) return;
|
||||
|
||||
const url = "{% url 'atlas:add_case_to_collection' collection.pk %}";
|
||||
const form = new URLSearchParams();
|
||||
form.append('case', casePk);
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: form.toString(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const html = await resp.text();
|
||||
// Append the returned case item into the list
|
||||
const list = document.getElementById('full-question-list');
|
||||
if (list) list.insertAdjacentHTML('beforeend', html);
|
||||
} catch (err) {
|
||||
console.error('Add case to collection failed', err);
|
||||
alert('Failed to add case to collection — check console.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Global handler for markup links inserted by render_markup
|
||||
document.body.addEventListener('click', function(ev){
|
||||
var el = ev.target.closest && ev.target.closest('.markup-link');
|
||||
if(!el) return;
|
||||
ev.preventDefault();
|
||||
try{
|
||||
var typ = el.getAttribute('data-type');
|
||||
var args = el.getAttribute('data-args');
|
||||
var href = el.getAttribute('href') || '#';
|
||||
|
||||
if(typ === 'case'){
|
||||
// open case — give option to open in new tab
|
||||
if(window.confirm('Open case in new tab?')){
|
||||
window.open(href, '_blank');
|
||||
} else {
|
||||
window.location = href;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For finding/displayset open modal with basic options — endpoints can be wired later
|
||||
var modal = document.getElementById('case-field-modal');
|
||||
var content = modal && modal.querySelector('.modal-content');
|
||||
var parsedArgs = [];
|
||||
try{ parsedArgs = JSON.parse(args || '[]'); }catch(e){ parsedArgs = []; }
|
||||
|
||||
if(typ === 'finding'){
|
||||
var findingId = parsedArgs[0] || '';
|
||||
var html = '<div class="modal-header"><h5 class="modal-title">Finding '+findingId+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
|
||||
html += '<div class="modal-body">';
|
||||
html += '<p>This will open the finding viewer for ID '+findingId+'.</p>';
|
||||
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/finding/'+findingId+'" target="_blank">Open in new tab</a> ';
|
||||
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
|
||||
html += '</div>';
|
||||
if(content) content.innerHTML = html;
|
||||
var bs = new bootstrap.Modal(modal);
|
||||
bs.show();
|
||||
return;
|
||||
}
|
||||
|
||||
if(typ === 'displayset'){
|
||||
var caseId = parsedArgs[0] || '';
|
||||
var setName = parsedArgs[1] || '';
|
||||
var html = '<div class="modal-header"><h5 class="modal-title">Display set '+setName+'</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>';
|
||||
html += '<div class="modal-body">';
|
||||
html += '<p>Display set <strong>'+setName+'</strong> from case #'+caseId+'.</p>';
|
||||
html += '<p><a class="btn btn-sm btn-primary" href="/atlas/collection/'+caseId+'" target="_blank">Open case in new tab</a> ';
|
||||
html += '<button class="btn btn-sm btn-secondary" type="button" data-bs-dismiss="modal">Close</button></p>';
|
||||
html += '</div>';
|
||||
if(content) content.innerHTML = html;
|
||||
var bs2 = new bootstrap.Modal(modal);
|
||||
bs2.show();
|
||||
return;
|
||||
}
|
||||
|
||||
}catch(err){ console.error('markup link handler error', err); }
|
||||
}, false);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -28,6 +28,8 @@
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,28 +1,143 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Collection: {{exam}}</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;
|
||||
}
|
||||
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</ul> {% endcomment %}
|
||||
.take-start-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: .75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
User: {{request.user}}<br/>
|
||||
.take-start-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 9rem 1fr;
|
||||
gap: .35rem .75rem;
|
||||
}
|
||||
|
||||
{% if cid_exam %}
|
||||
Started: {{cid_exam.start_time}} <br/>
|
||||
{% if cid_exam.completed %}
|
||||
Ended: {{cid_exam.end_time}} <br/>
|
||||
{% endif %}
|
||||
.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">Review: {{ collection.name }}</h2>
|
||||
<span class="badge bg-light text-dark">Review Collection</span>
|
||||
</div>
|
||||
<p class="mb-0 mt-2 text-body-secondary">Open the collection and browse completed cases.</p>
|
||||
</section>
|
||||
|
||||
{% if collection.start_screen_content %}
|
||||
<section class="take-start-card mb-3">
|
||||
{{ collection.start_screen_content|safe }}
|
||||
</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>
|
||||
{% 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 %}
|
||||
<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.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 %}">
|
||||
Browse
|
||||
</a>
|
||||
|
||||
{% 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>
|
||||
|
||||
{% 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 do not 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>
|
||||
{% else %}
|
||||
<section class="take-start-card">
|
||||
<p class="mb-0">
|
||||
Please sign in to access this review collection.
|
||||
<a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">Log in here</a>.
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}: {{ 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 and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}
|
||||
<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 %}
|
||||
@@ -71,6 +71,22 @@
|
||||
<div class="question-block-snippet" style="display:none;">
|
||||
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
|
||||
</div>
|
||||
{# Attached resources for this case: allow loading into the shared viewer #}
|
||||
{% if case.caseresource_set.exists %}
|
||||
<div class="mt-2">
|
||||
<strong>Resources:</strong>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
{% for cr in case.caseresource_set.all %}
|
||||
{% with res=cr.resource %}
|
||||
<div>
|
||||
<span class="me-2">{{ res.name }}</span>
|
||||
<button class="open-resource btn btn-sm btn-outline-primary ms-1" data-src="{% if res.file %}{{ res.file.url }}{% else %}{{ res.url }}{% endif %}" data-title="{{ res.name|escapejs }}">Load</button>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if case.display_sets.all %}
|
||||
<details class="displayset-detail"><summary>Display Sets:</summary>
|
||||
{% for ds in case.display_sets.all %}
|
||||
@@ -335,6 +351,19 @@
|
||||
//}
|
||||
//win2 = openSecondaryWindow(url);
|
||||
})
|
||||
// Load resource into linked viewer
|
||||
$('.open-resource').click(function() {
|
||||
var src = $(this).data('src');
|
||||
var title = $(this).data('title') || 'Resource';
|
||||
$('#loading-case').show();
|
||||
$("#open-viewer-local").addClass("flash-button");
|
||||
$('#current-case-title').html("<span class='title'>Resource:</span> "+title);
|
||||
$('#current-case-history').html('');
|
||||
$('#current-case-discussion').html('');
|
||||
$('#current-case-report').html('');
|
||||
|
||||
bc.postMessage({"type": "open", "url": src});
|
||||
});
|
||||
$('.send-message').click(function() {
|
||||
var url = $(this).data('target');
|
||||
bc.postMessage(url);
|
||||
|
||||
@@ -17,127 +17,141 @@
|
||||
<section class="mb-3">
|
||||
<h5 class="mb-2">Details</h5>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Primary name</dt>
|
||||
<dd class="col-sm-8">{% if condition.canonical %}No (alias of <a href="{{ condition.canonical.get_absolute_url }}">{{ condition.canonical.name }}</a>){% else %}Yes{% endif %}</dd>
|
||||
<dt class="col-sm-4">Primary name</dt>
|
||||
<dd class="col-sm-8">{% if condition.canonical %}No (alias of <a href="{{ condition.canonical.get_absolute_url }}">{{ condition.canonical.name }}</a>){% else %}Yes{% endif %}</dd>
|
||||
|
||||
<dt class="col-sm-4">Subspecialty</dt>
|
||||
<dt class="col-sm-4">Subspecialty</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for sub in condition.subspecialty.all %}
|
||||
<a class="badge bg-dark text-white me-1 text-decoration-none" href="{{ sub.get_absolute_url }}">{{ sub }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Synonyms</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div id="synonyms-container">
|
||||
{% include 'atlas/partials/_synonyms_list.html' %}
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Parent</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for parent in condition.parent.all %}
|
||||
<a class="badge bg-primary text-white text-decoration-none me-1" href="{{ parent.get_absolute_url }}">{{ parent }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Children</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for child in condition.child.all %}
|
||||
<a class="badge bg-info text-dark text-decoration-none me-1" href="{{ child.get_absolute_url }}">{{ child }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">RCR condition</dt>
|
||||
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
|
||||
|
||||
{% if not condition.rcr_curriculum %}
|
||||
<dt class="col-sm-4">RCR condition map</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for sub in condition.subspecialty.all %}
|
||||
<a class="badge bg-dark text-white me-1 text-decoration-none" href="{{ sub.get_absolute_url }}">{{ sub }}</a>
|
||||
{% for map in condition.rcr_curriculum_map.all %}
|
||||
<a class="badge bg-success text-white text-decoration-none me-1" href="{{ map.get_absolute_url }}">{{ map }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Synonyms</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div id="synonyms-container">
|
||||
{% include 'atlas/partials/_synonyms_list.html' %}
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Parent</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for parent in condition.parent.all %}
|
||||
<a class="badge bg-primary text-white text-decoration-none me-1" href="{{ parent.get_absolute_url }}">{{ parent }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Children</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for child in condition.child.all %}
|
||||
<a class="badge bg-info text-dark text-decoration-none me-1" href="{{ child.get_absolute_url }}">{{ child }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">RCR condition</dt>
|
||||
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
|
||||
|
||||
{% if not condition.rcr_curriculum %}
|
||||
<dt class="col-sm-4">RCR condition map</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for map in condition.rcr_curriculum_map.all %}
|
||||
<a class="badge bg-success text-white text-decoration-none me-1" href="{{ map.get_absolute_url }}">{{ map }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{% if condition.case_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Associated Cases</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for case in condition.case_set.all %}
|
||||
<li class="mb-1">{{ case.get_link }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Associated Cases</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for case in condition.case_set.all %}
|
||||
<li class="mb-1">{{ case.get_link }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Also include cases that reference this condition via CaseDisplaySet #}
|
||||
{% if condition.casedisplayset_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Cases via Display Sets</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for ds in condition.casedisplayset_set.all %}
|
||||
<li class="mb-1">{{ ds.case.get_link }} <small class="text-muted">[Display set: {{ ds.name }}]</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if condition.differential_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Associated Case Differentials</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for diff in condition.differential_set.all %}
|
||||
<li class="mb-1">{{ diff.case.get_link }} <small class="text-muted">[{{ diff.text }}]</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Associated Case Differentials</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for diff in condition.differential_set.all %}
|
||||
<li class="mb-1">{{ diff.case.get_link }} <small class="text-muted">[{{ diff.text }}]</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if condition.sbas_questions.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Related SBA Questions</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for q in condition.sbas_questions.all %}
|
||||
<li class="mb-1"><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Related SBA Questions</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for q in condition.sbas_questions.all %}
|
||||
<li class="mb-1"><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not condition.rcr_curriculum and can_merge %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Merge</h5>
|
||||
<p class="small text-muted">Merge this condition into another. This will transfer associated cases and differentials but will not transfer synonyms, parents or children.</p>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Merge</h5>
|
||||
<p class="small text-muted">Merge this condition into another. This will transfer associated cases and differentials but will not transfer synonyms, parents or children.</p>
|
||||
|
||||
<p>
|
||||
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#mergeCollapse" role="button" aria-expanded="false" aria-controls="mergeCollapse">Show merge form</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#mergeCollapse" role="button" aria-expanded="false" aria-controls="mergeCollapse">Show merge form</a>
|
||||
</p>
|
||||
|
||||
<div class="collapse" id="mergeCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST" id="condition-merge-form">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
<button class="btn btn-danger"
|
||||
hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||
hx-target="#merge-options" hx-swap="innerHTML"
|
||||
hx-confirm="This will merge {{condition.name}} into the selected condition, this cannot be undone. Continue?">
|
||||
Merge
|
||||
</button>
|
||||
</form>
|
||||
<div id="merge-options" class="mt-3"></div>
|
||||
<div class="collapse" id="mergeCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST" id="condition-merge-form">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
<button class="btn btn-danger"
|
||||
hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||
hx-target="#merge-options" hx-swap="innerHTML"
|
||||
hx-confirm="This will merge {{condition.name}} into the selected condition, this cannot be undone. Continue?">
|
||||
Merge
|
||||
</button>
|
||||
</form>
|
||||
<div id="merge-options" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3>Name: {{finding.name}}</h3>
|
||||
Primary name: {% if finding.canonical %}No (alias of <a href="{{ finding.canonical.get_absolute_url }}">{{ finding.canonical.name }}</a>){% else %}Yes{% endif %}<br />
|
||||
Synonyms: {{ finding.get_synonym_link|safe }}<br />
|
||||
Primary name: {% if finding.canonical %}No (alias of <a href="{{ finding.canonical.get_absolute_url }}">{{ finding.canonical.name }}</a>){% else %}Yes{% endif %}<br />
|
||||
Synonyms: {{ finding.get_synonym_link|safe }}<br />
|
||||
</div>
|
||||
{% if finding.seriesfinding_set.all %}
|
||||
<h4>Associated Cases</h4>
|
||||
@@ -27,6 +27,16 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# Also include cases referenced via CaseDisplaySet that list this finding #}
|
||||
{% if finding.casedisplayset_set.all %}
|
||||
<h4>Cases via Display Sets</h4>
|
||||
<ul>
|
||||
{% for ds in finding.casedisplayset_set.all %}
|
||||
<li>Case: {{ ds.case.get_link }} (Display set: {{ ds.name }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if finding.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
|
||||
@@ -1,76 +1,92 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2 class="mb-3">Atlas</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Collections</h5>
|
||||
<p class="card-text">Quick access to collections you can view or take.</p>
|
||||
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">My cases / collections</h5>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
|
||||
<p>
|
||||
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2 class="mb-3">Atlas</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
|
||||
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
|
||||
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
|
||||
</ul>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Collections</h5>
|
||||
<p class="card-text">Quick access to collections you can view or take.</p>
|
||||
<button class="btn btn-primary"
|
||||
hx-get="{% url 'atlas:user_collections' %}"
|
||||
hx-target="#user-collections-placeholder"
|
||||
hx-swap="innerHTML">
|
||||
Collections to view / take
|
||||
</button>
|
||||
<button class="btn btn-outline-primary ms-2"
|
||||
hx-get="{% url 'atlas:user_related_collections' %}"
|
||||
hx-target="#user-related-collections-placeholder"
|
||||
hx-swap="innerHTML">
|
||||
My collections
|
||||
</button>
|
||||
<div id="user-collections-placeholder" class="mt-3"></div>
|
||||
<div id="user-related-collections-placeholder" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">My cases / collections</h5>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a> or <a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">my collections</a>.</p>
|
||||
<p>
|
||||
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- user collections will load into the Collections card when requested -->
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Search cases</h5>
|
||||
<div class="mb-2">
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="case-search-input" name="q" type="search"
|
||||
placeholder="Search cases by title or description"
|
||||
hx-get="{% url 'atlas:case_search_partial' %}"
|
||||
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
|
||||
hx-target="#case-search-results"
|
||||
hx-indicator="#case-search-indicator" />
|
||||
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading</span>
|
||||
</span>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
|
||||
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
|
||||
<li><a href="{% url 'atlas:collection_view' %}?author={{ request.user.id }}">My collections</a></li>
|
||||
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Search cases</h5>
|
||||
<div class="mb-2">
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="case-search-input" name="q" type="search"
|
||||
placeholder="Search cases by title or description"
|
||||
hx-get="{% url 'atlas:case_search_partial' %}"
|
||||
hx-trigger="input delay:500ms, keyup[key=='Enter']"
|
||||
hx-target="#case-search-results"
|
||||
hx-indicator="#case-search-indicator" />
|
||||
<a class="btn btn-outline-secondary ms-2" href="{% url 'atlas:case_search_page' %}" title="Open advanced case search">Advanced</a>
|
||||
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
|
||||
#case-search-indicator { display: none; }
|
||||
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
|
||||
</style>
|
||||
<div id="case-search-results">
|
||||
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
|
||||
#case-search-indicator { display: none; }
|
||||
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
|
||||
</style>
|
||||
<div id="case-search-results">
|
||||
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,39 +1,119 @@
|
||||
<!-- filepath: /home/ross/rad/rad/atlas/templates/atlas/linked_cases_overview.html -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load partials %}
|
||||
|
||||
{% 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 %}
|
||||
+1670
-538
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,31 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load help_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>Normals</h1>
|
||||
{% help "Help" %}
|
||||
<p>This page lists all normal cases available in the atlas.</p>
|
||||
<p>You can use the filter form to narrow down the list based on criteria such as age, modality, or examination type.</p>
|
||||
<p>Click on a case title to view the case.</p>
|
||||
|
||||
<p>See a case that isn't normal? Please add feedback to the case so it can be reviewed.</p>
|
||||
|
||||
{% endhelp%}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="col-12">
|
||||
<form method="get" class="form-inline">
|
||||
{% for field in filter.form.visible_fields %}
|
||||
<div class="mb-2">
|
||||
{{ field.label_tag }}
|
||||
{{ field }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="mb-2">
|
||||
{% crispy filter.form %}
|
||||
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
|
||||
<label class="mb-0" for="id_sort">Sort by</label>
|
||||
<select class="form-select w-auto" id="id_sort" name="sort">
|
||||
{% for value, label in sort_choices %}
|
||||
<option value="{{ value }}" {% if selected_sort == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">Filter</button>
|
||||
<a class="btn btn-link" href="?">Clear</a>
|
||||
</div>
|
||||
@@ -24,22 +34,44 @@
|
||||
</div>
|
||||
|
||||
{% if page_obj.object_list %}
|
||||
<div class="mb-2">
|
||||
<span class="text-muted me-2">Quick sort:</span>
|
||||
{% for value, label in sort_choices %}
|
||||
{% if selected_sort == value %}
|
||||
<span class="badge text-bg-primary">{{ label }}</span>
|
||||
{% else %}
|
||||
<a class="badge text-bg-light text-decoration-none" href="?{% if querystring_without_page_sort %}{{ querystring_without_page_sort }}&{% endif %}sort={{ value }}">{{ label }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="list-group">
|
||||
{% for normal in page_obj.object_list %}
|
||||
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">{{ normal.case.title }}</h5>
|
||||
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||
<div class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<a class="flex-grow-1 text-decoration-none text-reset" href="{% url 'atlas:case_detail' normal.case.pk %}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">{{ normal.case.title }}</h5>
|
||||
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<p class="mb-1">
|
||||
Age: {{ normal.display_age }}
|
||||
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
|
||||
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
|
||||
</p>
|
||||
{% if normal.notes %}
|
||||
<p class="mb-0 text-muted">{{ normal.notes }}</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="ms-2">
|
||||
{% if request.user.is_superuser or request.user in normal.author.all %}
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
hx-get="{% url 'atlas:case_normal_edit' normal.pk %}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="mb-1">
|
||||
Age: {{ normal.display_age }}
|
||||
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
|
||||
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
|
||||
</p>
|
||||
{% if normal.notes %}
|
||||
<p class="mb-0 text-muted">{{ normal.notes }}</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -49,13 +81,15 @@
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination mt-3">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?{% if querystring_without_page %}{{ querystring_without_page }}&{% endif %}page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?{% if querystring_without_page %}{{ querystring_without_page }}&{% endif %}page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}{% endblock %}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,39 @@
|
||||
{# Unified single case item used by search results and recent-created lists #}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
|
||||
<div class="flex-fill me-3">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">{{ case.title }}</h6>
|
||||
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
|
||||
{% for cond in case.condition.all %}
|
||||
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for pres in case.presentation.all %}
|
||||
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for ss in case.subspecialty.all %}
|
||||
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="ms-2 d-flex align-items-center">
|
||||
{% if collection %}
|
||||
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="case" value="{{ case.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
|
||||
</form>
|
||||
<a class="btn btn-sm btn-outline-secondary ms-2" href="{% url 'atlas:case_detail' case.pk %}" target="_blank" rel="noopener">View</a>
|
||||
{% else %}
|
||||
{% 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,35 +1,56 @@
|
||||
{# partial: list of cases for HTMX search results #}
|
||||
<div class="list-group">
|
||||
{% if cases %}
|
||||
{% for case in cases %}
|
||||
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">{{ case.title }}</h6>
|
||||
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
|
||||
{% if case.condition %}<span class="badge bg-secondary me-1">{{ case.condition.name }}</span>{% endif %}
|
||||
{% if case.presentation %}<span class="badge bg-secondary me-1">{{ case.presentation.name }}</span>{% endif %}
|
||||
{% if case.subspecialty %}<span class="badge bg-secondary">{{ case.subspecialty.name }}</span>{% endif %}
|
||||
</div>
|
||||
{% if cases %}
|
||||
{% for case in cases %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}" data-label="{{ case.title|escape }}">
|
||||
<div class="flex-fill me-3">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">{{ case.title }}</h6>
|
||||
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
|
||||
{% for cond in case.condition.all %}
|
||||
{% if cond.name %}<span class="badge bg-secondary me-1" title="Condition: {{ cond.name }}">{{ cond.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for pres in case.presentation.all %}
|
||||
{% if pres.name %}<span class="badge bg-secondary me-1" title="Presentation: {{ pres.name }}">{{ pres.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% for ss in case.subspecialty.all %}
|
||||
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
{# show up to three series modalities/examinations if present #}
|
||||
{% for s in case.series.all|slice:":3" %}
|
||||
<span class="badge bg-light text-dark me-1">
|
||||
{% if s.modality %}{{ s.modality.modality }}{% endif %}
|
||||
{% if s.examination %} / {{ s.examination.examination }}{% endif %}
|
||||
</span>
|
||||
{% empty %}
|
||||
<small class="text-muted">No series</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
{# show up to three series modalities/examinations if present #}
|
||||
{% for s in case.series.all|slice:":3" %}
|
||||
{% if s.modality and s.modality.modality or s.examination and s.examination.examination %}
|
||||
<span class="badge bg-light text-dark me-1" title="Series: {% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %} / {{ s.examination.examination }}{% endif %}">
|
||||
{% if s.modality and s.modality.modality %}{{ s.modality.modality }}{% endif %}{% if s.examination and s.examination.examination %}{% if s.modality and s.modality.modality %} / {% endif %}{{ s.examination.examination }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<small class="text-muted">No series</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<p class="mb-1 text-truncate">{{ case.description }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="list-group-item">No cases found</div>
|
||||
{% endif %}
|
||||
<p class="mb-1 text-truncate">{{ case.description }}</p>
|
||||
</div>
|
||||
<div class="ms-2 d-flex align-items-center">
|
||||
{% if collection %}
|
||||
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="case" value="{{ case.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
|
||||
</form>
|
||||
<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_displaysets' case.pk %}">View</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="list-group-item">No cases found</div>
|
||||
{% endif %}
|
||||
</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,47 @@
|
||||
<div class="modal fade" id="displaysetModal" tabindex="-1" aria-labelledby="displaysetModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="displaysetModalLabel">Display Set Details</h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-2 displayset-fullscreen-btn" title="Open fullscreen">⤢</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="displaysetModalBody">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen displayset modal: viewer left, details right -->
|
||||
<div class="modal fade" id="displaysetFullscreenModal" tabindex="-1" aria-labelledby="displaysetFullscreenModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen">
|
||||
<div class="modal-content" style="height:100vh;">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="displaysetFullscreenModalLabel">Display Set — Fullscreen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0" id="displaysetFullscreenBody">
|
||||
<div class="container-fluid h-100">
|
||||
<div class="row g-0 h-100">
|
||||
<div class="col-8" id="displaysetFullscreenLeft" style="height:100%; overflow:auto; background:#000; display:flex;">
|
||||
</div>
|
||||
<div class="col-4" id="displaysetFullscreenRight" style="height:100%; overflow:auto; padding:1rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Ensure fullscreen modal and viewer stretch vertically */
|
||||
#displaysetFullscreenModal .modal-content { height: 100vh; }
|
||||
#displaysetFullscreenModal .modal-body { height: calc(100vh - 56px); padding: 0; }
|
||||
#displaysetFullscreenModal .modal-content .container-fluid, #displaysetFullscreenModal .modal-content .row { height: 100%; }
|
||||
#displaysetFullscreenLeft { display:flex; align-items:stretch; }
|
||||
#displaysetFullscreenLeft .dicom-viewer-root { height:100% !important; min-height:100% !important; width:100% !important; flex:1 1 auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="displayset-preview p-2">
|
||||
<h5 class="mb-1">Display Set</h5>
|
||||
<div class="small text-muted mb-2">Case: <a href="{% url 'atlas:case_displaysets' ds.case.pk %}">{{ ds.case.title }}</a></div>
|
||||
<div class="mb-2">Name: {{ ds.name|default:'(unnamed)' }}</div>
|
||||
{% if ds.description %}
|
||||
<div class="mb-2">{{ ds.description }}</div>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'atlas:case_displaysets_detail' ds.pk %}">Open full</a>
|
||||
<button class="btn btn-sm btn-outline-secondary view-displayset-modal" data-ds-id="{{ ds.pk }}" data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">View in modal</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="list-group">
|
||||
{% if displaysets %}
|
||||
{% for ds in displaysets %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-ds-pk="{{ ds.pk }}" data-label="{{ ds.name|escape }}">
|
||||
<div class="flex-fill">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<div>
|
||||
<strong class="mb-1">{{ ds.name }}</strong>
|
||||
{% if ds.case %}
|
||||
<div class="small text-muted">Case: <a href="{% url 'atlas:case_detail' ds.case.pk %}">{{ ds.case.title }}</a> (#{% if ds.case.pk %}{{ ds.case.pk }}{% endif %})</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">{{ ds.pk }}</small>
|
||||
</div>
|
||||
<p class="mb-1 text-truncate">{{ ds.description }}</p>
|
||||
</div>
|
||||
<div class="ms-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="list-group-item">No display sets found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="modal fade" id="findingModal" tabindex="-1" aria-labelledby="findingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="findingModalLabel">Finding Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="findingModalBody">
|
||||
<!-- Finding details will be loaded here dynamically -->
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<div class="list-group">
|
||||
{% if findings %}
|
||||
{% for f in findings %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-finding-pk="{{ f.pk }}" data-seriesfinding-pk="{{ f.pk }}" data-label="{% if f.findings.all %}{% for sub in f.findings.all %}{{ sub.name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% elif f.description %}{{ f.description|truncatechars:80|escape }}{% elif f.series %}Series {{ f.series.id }}{% else %}Finding {{ f.pk }}{% endif %}">
|
||||
<div class="flex-fill">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<strong class="mb-1">{% if f.series %}{{ f.series.id }}{% else %}Series{% endif %}{% if f.description %}: {{ f.description|truncatechars:80 }}{% endif %}</strong>
|
||||
<small class="text-muted">{{ f.pk }}</small>
|
||||
</div>
|
||||
<p class="mb-1 text-truncate">
|
||||
{% if f.findings.all %}
|
||||
{% for sub in f.findings.all %}
|
||||
<span class="badge bg-light text-dark me-1">{{ sub.name }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{{ f.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ms-2">
|
||||
{% if f.series %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:series_detail' pk=f.series.pk %}">View series</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="list-group-item">No findings found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -1,10 +1,10 @@
|
||||
<div id="normal-modal-{{ case.pk }}" class="modal modal-blur fade" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<form method="post" hx-post="{% url 'atlas:case_create_normal' case.pk %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
|
||||
<form method="post" hx-post="{% if normal %}{% url 'atlas:case_normal_edit' normal.pk %}{% else %}{% url 'atlas:case_create_normal' case.pk %}{% endif %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mark case {{ case.pk }} as Normal</h5>
|
||||
<h5 class="modal-title">{% if normal %}Edit normal for case {{ case.pk }}{% else %}Mark case {{ case.pk }} as Normal{% endif %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
@@ -4,17 +4,26 @@
|
||||
{% if case.normal_case %}
|
||||
<span class="badge bg-success">Normal</span>
|
||||
<small class="text-muted">{{ case.normal_case.display_age }}</small>
|
||||
{% if can_mark_normal %}
|
||||
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||
hx-target="#normal-toggle-block"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Unmark this case as normal?">
|
||||
Unmark normal
|
||||
</button>
|
||||
{% 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"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Unmark this case as normal?">
|
||||
Unmark normal
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if can_mark_normal %}
|
||||
{% if can_edit %}
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
|
||||
hx-target="body"
|
||||
@@ -22,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,13 @@
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<div class="small text-muted">Total images size</div>
|
||||
<div class="fw-bold">{{ size_human }}</div>
|
||||
<div class="text-muted small">({{ size_bytes }} bytes)</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
hx-get="{% url 'atlas:series_image_size' series.pk %}?refresh=1"
|
||||
hx-target="#series-image-size"
|
||||
hx-swap="innerHTML">Recalculate</button>
|
||||
</div>
|
||||
</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,45 @@
|
||||
{% 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"
|
||||
hx-indicator="#series-split-analysis-indicator">
|
||||
Analyze split options
|
||||
</button>
|
||||
<span id="series-split-analysis-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
|
||||
</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"
|
||||
hx-indicator="#series-split-submit-indicator">
|
||||
{% 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>
|
||||
<span id="series-split-submit-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
|
||||
<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,200 @@
|
||||
{% 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"
|
||||
hx-indicator="#series-tag-consistency-indicator">
|
||||
Re-run analysis
|
||||
</button>
|
||||
<span id="series-tag-consistency-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
|
||||
{% 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"
|
||||
hx-indicator="#tag-consistency-split-indicator">
|
||||
{% 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"
|
||||
hx-indicator="#tag-consistency-split-indicator">
|
||||
{% 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"
|
||||
hx-indicator="#tag-consistency-split-indicator">
|
||||
{% 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">
|
||||
<span id="tag-consistency-split-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator me-auto" role="status" aria-hidden="true"></span>
|
||||
<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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user