Compare commits

...

107 Commits

Author SHA1 Message Date
Ross cdcdeb761b up dv3d 2026-07-13 16:18:43 +01:00
Ross 8e1d6fdd60 up dv3d 2026-07-13 16:13:20 +01:00
Ross 479e37f346 up dv3d 2026-07-13 16:05:22 +01:00
Ross 0afed5a983 refactor: rename context variable from 'collection' to 'case' in CaseAuthorUpdate 2026-07-13 12:54:47 +01:00
Ross f51faff534 up dv3d 2026-07-13 12:41:28 +01:00
Ross f27936b2fb up dv3d 2026-07-13 12:19:20 +01:00
Ross 685f66854e remove the multiuser sync support 2026-07-13 11:52:33 +01:00
Ross 8ae33e32ee refactor: handle multi-frame DICOM files by extracting the middle frame in get_PIL_image functions 2026-07-13 11:23:04 +01:00
Ross d322806b80 refactor: comment out get_case_named_stacks method for future reference 2026-07-13 10:58:19 +01:00
Ross 2c56fcd4e7 refactor: enhance candidate scores table layout and improve status display 2026-07-13 10:56:36 +01:00
Ross 66cca1a512 refactor: improve score distribution display and enhance HTMX handling in exam views 2026-07-13 10:15:42 +01:00
Ross 3a3ec75b1d . 2026-07-13 10:03:33 +01:00
Ross 89bf6eedc0 refactor: reorganize navigation menu and enhance dropdown for additional features 2026-07-13 09:43:05 +01:00
Ross fa98541e31 . 2026-07-06 22:06:58 +01:00
Ross 50b80fd0c7 try to improve the scores pages 2026-07-06 12:33:30 +01:00
Ross 7bd1475daf refactor: streamline question navigation and loading logic in exam templates 2026-07-06 12:05:28 +01:00
Ross b13261c302 feat: implement HTMX support for exam question navigation and flagging functionality 2026-07-06 11:34:58 +01:00
Ross 94b08c5c21 refactor: simplify submitted answers extraction in ExamTester class 2026-07-06 10:47:27 +01:00
Ross 8f7ec905a6 fix: update exam test logic to handle CSRF token extraction and unanswered question counts 2026-07-06 10:45:41 +01:00
Ross 5139346d28 refactor: rework scores pages for improved usability and performance 2026-07-06 10:40:16 +01:00
Ross c6fe4df763 feat: add exam mode take URL method to Exam and CaseCollection models 2026-07-06 10:25:13 +01:00
Ross 5414a5b4f9 fix: add return button to exam overview in user score header 2026-07-06 10:04:26 +01:00
Ross 5a73409aad fix: update visibility message for exam results based on supervisor access 2026-07-06 09:59:10 +01:00
Ross 1b2715642a Add time answered 2026-07-06 09:56:51 +01:00
Ross 5ae51a7e71 . 2026-07-06 09:49:50 +01:00
Ross edb4e071f4 fix callstate label 2026-07-06 09:49:13 +01:00
Ross e56c1f2d0f feat: restrict user answer compaction to admin users and update related tests 2026-07-06 09:43:01 +01:00
Ross daebdc1844 feat: add exam mode URL handling and update user scores template 2026-07-06 09:29:15 +01:00
Ross 5a2baaeab8 Add user answer viewing and compaction 2026-07-06 09:07:48 +01:00
Ross 5ce4a67b31 feat: implement import exam answers feature with preview and confirmation steps 2026-07-05 22:47:50 +01:00
Ross 89963d6257 feat: add import exam answers functionality with UI and backend handling 2026-07-05 22:37:55 +01:00
Ross 2744758aa8 feat: enhance exam answer submission security and add tests for user authentication 2026-07-05 21:59:02 +01:00
Ross bb6b4c625c max improvement 2026-07-04 23:43:18 +01:00
Ross 55fcf71431 Add refresh buttons and optimize case series update
- Implemented refresh buttons for each exam/packet section and a global refresh button.
- Enhanced resource management in the atlas by allowing inline creation and attachment of resources.
- Redirected users back to the case within a case collection upon saving or cancelling edits.
- Optimized the case series update view to prevent hanging issues on production.
- Fixed an issue with the local-up script not shutting down running containers properly.

Add marker feedback field to useranswer model

- Created migrations to add a 'marker_feedback' field to the 'useranswer' model in both 'longs' and 'shorts' apps.
- The new field is a TextField that allows optional feedback for the marker, which will not be shown to the candidate.
2026-07-04 23:11:19 +01:00
Ross 277322059c feat: add marker feedback field to UserAnswer model and update forms accordingly 2026-07-04 22:49:11 +01:00
Ross d8bacfefa9 feat: add new endpoints for fetching active exams by type and cid; enhance exam handling in views 2026-07-04 22:40:48 +01:00
Ross 856fb0bf08 refactor: remove unnecessary authentication check in SupervisorDetail view 2026-06-30 10:56:34 +01:00
Ross ce323c4973 refactor: update README for observability and add test running instructions; update PostgreSQL port in Docker Compose; bump daphne version in requirements 2026-06-29 17:42:33 +01:00
Ross a60982c30a feat: add sorting to exam user statuses and improve history log filtering 2026-06-29 12:23:20 +01:00
Ross 1296089607 refactor: remove Loki, Promtail, and Grafana services from Docker Compose files 2026-06-29 11:57:58 +01:00
Ross 077c858024 feat: enhance exam user status handling and add autosave verification in tests 2026-06-29 11:49:43 +01:00
Ross 12014fbb23 fix: update Host header to use http_host variable in Nginx config 2026-06-29 11:08:52 +01:00
Ross 17043722e1 fix docker loading 2026-06-29 10:55:30 +01:00
Ross 1317d6a438 start better rts inegration 2026-06-29 10:44:39 +01:00
Ross 2425e62caf feat: add Feedback/Surveys link to dropdown menu 2026-06-29 09:36:50 +01:00
Ross c34dcdfd5e improve surverys 2026-06-29 09:35:12 +01:00
Ross 79ba4db4bf add simple survey support 2026-06-29 09:04:28 +01:00
Ross d07d88bdbf fix a few multisync issues 2026-06-28 21:51:39 +01:00
Ross d32e2a5231 refactor: update Redis configuration for caching and Celery broker 2026-06-22 11:44:45 +01:00
Ross 5ad66cb951 start fixing local dev env 2026-06-22 11:12:44 +01:00
Ross 4e29941ae4 try to fix multiuser sync 2026-06-22 09:36:56 +01:00
Ross 2e11e40b7c and again 2026-06-17 22:43:38 +01:00
Ross ed77af215f keep tring 2026-06-17 22:36:45 +01:00
Ross 28204d7ec1 interate on multiuser view 2026-06-17 22:29:37 +01:00
Ross 0110a6c4d4 Update Celery settings to use dynamic Redis URL extraction 2026-06-17 22:23:04 +01:00
Ross 24d9594446 Refactor Celery settings to use dynamic Redis host configuration 2026-06-17 22:17:23 +01:00
Ross 56cd257474 Fix clearable text area setup and update routing for multiuser websocket 2026-06-17 22:04:20 +01:00
Ross 86a1a935e1 fix a few bugs with the liveview 2026-06-16 22:17:17 +01:00
Ross dbb762bb09 add more multiuser support 2026-06-16 13:14:24 +01:00
Ross 6ebe74d785 start multiuser support 2026-06-16 12:46:33 +01:00
Ross 21eabcf3ec Enhance Select2 integration in caseSeriesModal to ensure proper dropdown behavior 2026-06-15 17:00:35 +01:00
Ross 1ad87d8b31 Add data-bs-focus attribute to caseSeriesModal and prevent Bootstrap from stealing focus from Select2 inputs 2026-06-15 16:57:28 +01:00
Ross 8003dcd714 Add patient grouping and selection functionality to user uploads 2026-06-15 16:48:15 +01:00
Ross b5d844e3aa Update navbar brand name from "Candidates" to "People" 2026-06-15 15:43:16 +01:00
Ross 1b3b21a6f5 Refactor exam image creation and enhance supervisor management tests 2026-06-15 13:13:14 +01:00
Ross a573ed78a4 Enhance Supervisor Dashboard and Trainee Feedback Features
- Redesigned the supervisor overview page to include a header card with supervisor details and trainee statistics.
- Implemented a tabbed interface for viewing trainees and their recent attempts.
- Improved the trainee detail page with a profile card and a table for displaying attempt history.
- Added a new view for supervisors to provide feedback on case collections, including outstanding feedback items and previous conversations.
- Updated URL routing to support new feedback functionality.
- Enhanced backend logic to ensure proper sharing permissions for exam attempts and case collections.
- Added comprehensive tests for supervisor access to trainee data and feedback functionalities.
2026-06-15 12:28:00 +01:00
Ross c52cf46d65 Add functionality to create linked supervisor account when creating a user 2026-06-15 11:32:21 +01:00
Ross 1f0877964b Enhance user view and list UI with improved layout and functionality 2026-06-15 11:28:57 +01:00
Ross c50ace771e Implement hiding and showing functionality for user exams and collections 2026-06-15 11:23:00 +01:00
Ross 35ab7443d2 Implement user search functionality and enhance supervisor management UI 2026-06-15 10:28:32 +01:00
Ross fc9f2ed6af Enhance trainee management UI and functionality
- Improved bulk edit options for user groups with success alerts.
- Updated trainee selection count display.
- Refined HTMX responses for user actions, providing better feedback.
- Enhanced checkbox toggle functionality for trainee selection.
2026-06-15 09:53:21 +01:00
Ross afd4cc3b87 . 2026-06-15 09:26:17 +01:00
Ross aaee3577ad Imrpvoe the case links 2026-06-12 22:55:50 +01:00
Ross 96d5fa4a2e add future relations to cases in collection 2026-06-12 21:15:43 +01:00
Ross 6bb9441748 smoke test the rest of the apps 2026-06-10 22:05:12 +01:00
Ross e670adf2d0 test: add smoke tests for URL endpoints with different user roles 2026-06-10 21:31:06 +01:00
Ross 145aa442f0 refactor: update prerequisites queryset assignment in CaseCollectionForm and improve test assertions 2026-06-10 21:20:30 +01:00
Ross ddfb591921 bump dv3d 2026-06-10 21:06:34 +01:00
Ross e78a69367f try to fix recons 2026-06-08 11:41:46 +01:00
Ross d89df3d0a6 . 2026-06-08 11:20:24 +01:00
Ross 56bc17d1c7 . 2026-06-08 11:18:39 +01:00
Ross 8c0d97d36d remove post save for seriesimage 2026-06-08 11:08:10 +01:00
Ross 161da980cf fix a few things 2026-06-08 10:57:01 +01:00
Ross 9919af2b20 fix some templates 2026-06-08 10:36:28 +01:00
Ross 390ccc0234 . 2026-06-08 09:59:12 +01:00
Ross a9c417d993 fix comment 2026-06-08 09:58:00 +01:00
Ross 0f11f0800e fix logs (and import) 2026-06-03 21:00:05 +01:00
Ross 3d445fe12e try to fix logging 2026-06-03 20:48:00 +01:00
Ross 2e854c43b6 fix many issues 2026-06-03 20:38:29 +01:00
Ross 46518a45c1 add log when improt fails 2026-06-03 20:26:12 +01:00
Ross 213ee17328 improve cases series handling in collection 2026-06-01 12:57:35 +01:00
Ross 92d4ece061 fix via view page 2026-06-01 12:05:39 +01:00
Ross 74f1ad4b2c update viva cases view 2026-06-01 11:54:50 +01:00
Ross 0412aa82e4 tidy formating 2026-06-01 11:29:25 +01:00
Ross f2a52bed23 start new viva view and add window/level sensitivy to dv3d 2026-06-01 11:11:02 +01:00
Ross 1d85666127 improve the linked cases editor 2026-06-01 10:43:19 +01:00
Ross 40cea43578 start working on case_study bulk editiing and add relative dates to the model 2026-06-01 10:23:57 +01:00
Ross 6aada9f991 Enhance styling for multi-select in case series modal for improved user experience 2026-06-01 09:27:44 +01:00
Ross 01d44bc8e4 remove case case 2026-05-29 09:07:43 +01:00
Ross 98df8ec176 remove case size from the table 2026-05-28 23:23:52 +01:00
Ross 4cc758bc66 Refactor case size rendering logic for efficiency and update user uploads field choices 2026-05-28 23:22:07 +01:00
Ross 060333c006 Refactor case size rendering logic and update user uploads template for improved UI 2026-05-28 23:15:01 +01:00
Ross d74cfe73e0 Enhance case size rendering and update form field attributes for better UI integration 2026-05-28 23:10:27 +01:00
Ross c1005712e0 . 2026-05-28 22:57:48 +01:00
Ross 9592568dbb and some fixes 2026-05-28 22:57:42 +01:00
Ross ee4fe2b85b many changes 2026-05-28 22:53:04 +01:00
Ross 6368f525e1 update todo 2026-05-28 22:14:32 +01:00
509 changed files with 99306 additions and 2637 deletions
+31
View File
@@ -0,0 +1,31 @@
# Ignore git and local environments
.git
.venv
venv
ENV
.vscode
.idea
# Ignore Python caches and test artifacts
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
# Ignore local media and database files
media/
media_testanatomy/
backups/
db.sqlite3
*.log
logs/
# Ignore frontend dependencies and test suites in build context
**/node_modules/
rts/node_modules/
rts/tests/
package-lock.json
+7
View File
@@ -10,11 +10,18 @@ venv
.env
*.log
log.txt
temp/
media_test/*
# Loki runtime data
docker/loki-data/
docker/loki-wal/
# Local runtime logs collected by Promtail
/logs/
# Compiled assets for DV3D
**/dv3d/assets/
/static/dv3d/index.js
+49 -6
View File
@@ -17,6 +17,13 @@ Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL +
- Partials must be standalone fragments — no `{% extends %}`.
- HTMX detection via `django_htmx` middleware (`request.htmx`).
#### HTMX input
- When using htmx input for autoupdating search/other triggers triggers do not use changed in the hx-trigger. Using input is sufficient.
e.g. instead of
hx-trigger="input changed delay:300ms, focus"
use
hx-trigger="input delay:300ms, focus"
### 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).
@@ -48,7 +55,13 @@ Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL +
- **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.
- **Multi line comment** NEVER use {# ... #} style comments for multiline comments as it will render into the html, it is designed for single line comments only. Use
{% comment "Optional note: You can add a label here if you want" %}
This is a multiline comment.
None of this text or any {% tags %} inside here
will be rendered or visible in the browser's "View Source".
{% endcomment %}
Instead
## Forms
@@ -65,6 +78,10 @@ Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL +
- Do not use `TestCase` unless necessary — prefer plain `pytest` functions with `@pytest.mark.django_db`.
- Run tests: `pytest` or `pytest {app}/tests/`.
## Exceptions
- Avoid unnecessary try: except blocks.
- We should not generate empty excepty blocks, if there is an error or bug this should be surfaced (even if just to the logs).
## Celery / Tasks
- Tasks autodiscovered from `{app}/tasks.py`.
@@ -85,13 +102,39 @@ Django-based radiology education platform. Multi-app, HTMX-driven, PostgreSQL +
## Docker (Dev)
### Start Dev Environment
Start the development server with local configurations:
```bash
# Start dev environment
COMPOSE_ENV=dev docker compose -f docker/docker-compose.dev.yml up -d --build
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -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
### Run Django Management Commands inside the Container
Since PostgreSQL and Redis run in docker, run all database/management commands within the web container:
```bash
# Run database migrations
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py migrate
# Generate new migrations
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py makemigrations
# Check if migrations are missing (useful for CI/pre-commit)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py makemigrations --check
# Run system checks
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py check
# Create a Django superuser
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml exec web python manage.py createsuperuser
```
### Runserver and service commands
To restart the web server/runserver or tail the container output:
```bash
# Restart the runserver/web service container
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml restart web
# View container logs (interactive)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml logs -f web
```
Dev server runs on `http://localhost:8080` (nginx → gunicorn/runserver).
+5 -6
View File
@@ -103,12 +103,6 @@ Host setup for 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.
@@ -116,3 +110,8 @@ Notes & recommendations
- 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).
## Running tests
Ensure the development Docker environment is running (COMPOSE_ENV=dev docker compose -f docker/docker-compose.local.yml -f docker/docker-compose.dev.yml up -d).
+3 -2
View File
@@ -61,8 +61,9 @@ class AnatomyUserAnswerFilter(django_filters.FilterSet):
)
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
if not request.user.groups.filter(name="anatomy_checker").exists():
queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id)
if not request.user.is_superuser:
if not request.user.groups.filter(name="anatomy_checker").exists():
queryset = queryset.filter(exam__open_access=True) | queryset.filter(exam__author__id=request.user.id)
#queryset = queryset.prefetch_related("question", "question__question_type", "exam")
super(AnatomyUserAnswerFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
pass
@@ -0,0 +1,25 @@
# Generated by Django 6.0.1 on 2026-06-28 20:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0027_alter_anatomyquestion_primary_answer_and_more'),
('survey', '__first__'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey',
field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'),
),
migrations.AddField(
model_name='exam',
name='pre_survey',
field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0028_exam_post_survey_exam_pre_survey'),
]
operations = [
migrations.AddField(
model_name='exam',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='exam',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-29 09:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0029_exam_post_survey_mandatory_exam_pre_survey_mandatory'),
]
operations = [
migrations.AddField(
model_name='useranswer',
name='time_answered',
field=models.DateTimeField(blank=True, help_text='Time the question was answered (client-side)', null=True),
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-07-13 08:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0030_useranswer_time_answered'),
]
operations = [
migrations.AddField(
model_name='exam',
name='pass_mark',
field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True),
),
]
+53 -34
View File
@@ -338,7 +338,21 @@ class Answer(models.Model):
def save(self, *args, **kwargs):
self.clean()
return super(Answer, self).save(*args, **kwargs)
res = super(Answer, self).save(*args, **kwargs)
# Update matching UserAnswer objects in real-time
UserAnswer.objects.filter(
question=self.question,
answer_compare__iexact=self.answer_compare
).update(score=self.status)
return res
def delete(self, *args, **kwargs):
# Update matching UserAnswer objects back to UNMARKED in real-time
UserAnswer.objects.filter(
question=self.question,
answer_compare__iexact=self.answer_compare
).update(score=Answer.MarkOptions.UNMARKED)
return super(Answer, self).delete(*args, **kwargs)
def clean(self):
if self.answer:
@@ -551,11 +565,30 @@ class UserAnswer(UserAnswerBase):
def save(self, *args, **kwargs):
self.clean()
# Auto-mark on save from existing template answers
ans = self.answer_compare
if ans == "":
self.score = Answer.MarkOptions.INCORRECT
else:
marked_ans = Answer.objects.filter(
question_id=self.question_id,
answer_compare__iexact=ans
).first()
if marked_ans is not None:
self.score = marked_ans.status
else:
self.score = Answer.MarkOptions.UNMARKED
return super(UserAnswer, self).save(*args, **kwargs)
def clean(self):
if self.answer:
self.answer = self.answer.strip()
self.answer_compare = get_answer_compare(self.answer)
else:
self.answer = ""
self.answer_compare = ""
# def get_compare_string(self):
# # strip here should be unneccasry (providing clean is now working)
@@ -569,30 +602,34 @@ class UserAnswer(UserAnswerBase):
return self.answer
def get_answer_score(self, cached=True):
if cached and self.score:
if cached and self.score and self.score != Answer.MarkOptions.UNMARKED:
if self.score == Answer.MarkOptions.CORRECT:
mark = 2
return 2
elif self.score == Answer.MarkOptions.HALF_MARK:
mark = 1
return 1
elif self.score == Answer.MarkOptions.INCORRECT:
mark = 0
return mark
return 0
q = self.question
ans = self.answer_compare
if ans == "":
self.score == Answer.MarkOptions.INCORRECT
self.save()
if self.score != Answer.MarkOptions.INCORRECT:
self.score = Answer.MarkOptions.INCORRECT
self.save()
return 0
marked_ans = q.answers.filter(answer_compare__iexact=ans).first()
marked_ans = None
for a in q.answers.all():
if a.answer_compare.lower() == ans.lower():
marked_ans = a
break
mark = "unmarked"
if marked_ans is not None:
self.score = marked_ans.status
self.save()
if self.score != marked_ans.status:
self.score = marked_ans.status
self.save()
if marked_ans.status == Answer.MarkOptions.CORRECT:
mark = 2
@@ -600,27 +637,9 @@ class UserAnswer(UserAnswerBase):
mark = 1
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
mark = 0
return mark
if (
q.answers.filter(
answer_compare__iexact=ans, status=Answer.MarkOptions.CORRECT
).first()
is not None
):
mark = 2
elif (
q.answers.filter(
answer_compare__iexact=ans, status=Answer.MarkOptions.HALF_MARK
).first()
is not None
):
mark = 1
elif q.answers.filter(
answer_compare__iexact=ans, status=Answer.MarkOptions.INCORRECT
).first():
mark = 0
else:
mark = "unmarked"
if self.score != Answer.MarkOptions.UNMARKED:
self.score = Answer.MarkOptions.UNMARKED
self.save()
return mark
+2
View File
@@ -226,7 +226,9 @@ function clearableTextAreaSetup() {
textFields.forEach((field) => {
const wrapper = field.closest(".position-relative");
if (!wrapper) return;
const clearButton = wrapper.querySelector(".clear-input-button");
if (!clearButton) return;
// Function to toggle the visibility of the clear button
const toggleClearButton = () => {
+4 -2
View File
@@ -1,4 +1,4 @@
{% extends 'generic/exam_scores_base.html' %}
{% extends base_template|default:'generic/exam_scores_base.html' %}
{% block table_answers %}
{% for question in questions %}
@@ -10,10 +10,12 @@
{% endfor %} {% endcomment %}
{% for cid in cids %}
{% with by_question|get_item:question|get_item:cid as ans_score %}
<td class="anatomy-ans user-answer-score-{{ans_score.1}}"
<td class="anatomy-ans user-answer-score-{{ans_score.1}} col-cid-cell col-cid-{{ cid|slugify }}"
title="answer score: {{ans_score.1}}"
data-examquestionindex={{forloop.parentloop.counter0}}
data-cid={{cid}}
data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}"
data-cid-val="{{ cid|lower }}"
>{{ans_score.0}}<a class="show-on-hover" href="{% url 'anatomy:exam_question_user_answer' exam.pk forloop.parentloop.counter0 cid|slice:':1' cid|slice:'2:' %}"><i class="bi bi-pen"></i>
</a></td>
{% endwith %}
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="container anatomy my-3">
@@ -18,7 +19,7 @@
</select>
</div>
</div>
<ul class="list-group list-group-flush">
<ul class="list-group list-group-flush score-answer-list">
{% 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">
@@ -52,6 +53,15 @@
{% 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 %}
{% if request.user.is_staff or view_all_results or request.user in exam.author.all %}
{% with q=questions|get_item:forloop.counter0 %}
{% with ua=user_answers_map|get_item:q.pk %}
{% if ua %}
<a href="{% url 'generic:view_answer_history' app_name='anatomy' model_name='UserAnswer' pk=ua.pk %}" class="btn btn-sm btn-outline-info ms-2">History</a>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
</div>
</li>
{% endfor %}
+2 -3
View File
@@ -240,12 +240,11 @@ def test_exams(db, client):
res = client.post(reverse("global_exam_answers_submit"), data=post_json)
json_res = json.loads(res.content)
assert json_res["success"] == False
assert json_res["error"] == "Invalid data"
assert json_res["error"] in ("Invalid data", "user / cid mismatch")
valid_answers = []
for questions in exam_json["questions"]:
for qid in questions:
for qid in exam_json["questions"]:
post_data = {
"eid": exam_json["eid"],
"cid": post_cid,
+122
View File
@@ -0,0 +1,122 @@
import pytest
from django.urls import reverse
from django.contrib.auth.models import Group, User
from django.test import Client
SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405}
@pytest.fixture
def candidate_client(db):
user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com")
client = Client()
client.force_login(user)
return client
@pytest.fixture
def superuser_client(db):
user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com")
# Add to editor and manager groups to satisfy all custom mixins
for group_name in ["anatomy_editor", "cid_user_manager"]:
g, _ = Group.objects.get_or_create(name=group_name)
user.groups.add(g)
client = Client()
client.force_login(user)
return client
@pytest.fixture
def anonymous_client(db):
return Client()
@pytest.fixture
def smoke_data(db, settings, tmp_path):
from anatomy.models import AnatomyQuestion, Exam, UserAnswer, QuestionType, Structure, Answer
import tempfile
settings.MEDIA_ROOT = str(tmp_path)
create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com")
qtype, _ = QuestionType.objects.get_or_create(text="Test QType")
structure, _ = Structure.objects.get_or_create(name="Test Structure")
image_file = tempfile.NamedTemporaryFile(dir=settings.MEDIA_ROOT, suffix=".jpg", delete=False)
question = AnatomyQuestion.objects.create(
question_type=qtype,
structure=structure,
image=image_file.name
)
exam = Exam.objects.create(
name="Test Anatomy Exam",
exam_mode=True,
active=True
)
exam.author.add(create_user)
answer = Answer.objects.create(
question=question,
answer="testanswer",
status=Answer.MarkOptions.CORRECT
)
user_answer = UserAnswer.objects.create(
user=create_user,
exam=exam,
question=question
)
return {
"question": question,
"exam": exam,
"user_answer": user_answer,
}
URL_SPECS = [
("anatomy:question_view", None),
("anatomy:question_create", None),
("anatomy:anatomy_question_update", lambda d: {"pk": d["question"].pk}),
("anatomy:question_answer_update", lambda d: {"pk": d["question"].pk}),
("anatomy:question_clone", lambda d: {"pk": d["question"].pk}),
("anatomy:question_delete", lambda d: {"pk": d["question"].pk}),
("anatomy:exam_markers", lambda d: {"pk": d["exam"].pk}),
("anatomy:exam_groups_edit", lambda d: {"pk": d["exam"].pk}),
("anatomy:exam_authors", lambda d: {"pk": d["exam"].pk}),
("anatomy:exam_create", None),
("anatomy:exam_clone", lambda d: {"exam_id": d["exam"].pk}),
("anatomy:exam_update", lambda d: {"pk": d["exam"].pk}),
("anatomy:exam_delete", lambda d: {"pk": d["exam"].pk}),
("anatomy:user_answer_table_view", None),
("anatomy:user_answer_view", lambda d: {"pk": d["user_answer"].pk}),
("anatomy:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}),
("anatomy:mark2_overview", lambda d: {"pk": d["exam"].pk}),
("anatomy:index", None),
("anatomy:help", None),
]
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = anonymous_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous"
assert response.status_code in {200, 302, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous"
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = candidate_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate"
assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate"
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = superuser_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser"
assert response.status_code in {200, 301, 302, 303}, f"Unexpected status code {response.status_code} on {url_name} for Superuser"
+2
View File
@@ -104,6 +104,8 @@ from django.db.models import Count
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
if self.request.user.is_superuser:
return obj
if self.request.user.groups.filter(name="anatomy_checker").exists():
return obj
if self.request.user not in obj.author.all():
+45 -6
View File
@@ -3,8 +3,17 @@ from .models import Case, CaseCollection, Series
def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(function):
"""Decorator to check if user is author of the series,
an atlas editor, an atlas marker, or if the series is open access.
Used for series-level permissions.
Requires the decorated view to have a "pk" or "series_id" kwarg to identify the series.
"""
def wrap(request, *args, **kwargs):
series = Series.objects.get(pk=kwargs["pk"])
if "pk" in kwargs:
series = Series.objects.get(pk=kwargs["pk"])
elif "series_id" in kwargs:
series = Series.objects.get(pk=kwargs["series_id"])
if (
request.user in series.get_author_objects()
or series.open_access
@@ -23,7 +32,10 @@ def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(functi
def user_is_author_or_atlas_series_checker(function):
def wrap(request, *args, **kwargs):
series = Series.objects.get(pk=kwargs["pk"])
if "pk" in kwargs:
series = Series.objects.get(pk=kwargs["pk"])
elif "series_id" in kwargs:
series = Series.objects.get(pk=kwargs["series_id"])
if (
request.user in series.get_author_objects()
or request.user.groups.filter(name="atlas_editor").exists()
@@ -40,7 +52,10 @@ def user_is_author_or_atlas_series_checker(function):
def user_has_case_view_access(function):
def wrap(request, *args, **kwargs):
atlas = Case.objects.get(pk=kwargs["pk"])
if "pk" in kwargs:
atlas = Case.objects.get(pk=kwargs["pk"])
elif "case_id" in kwargs:
atlas = Case.objects.get(pk=kwargs["case_id"])
# If open access everyone can view
if atlas.open_access:
@@ -62,7 +77,10 @@ def user_has_case_view_access(function):
def user_is_author_or_atlas_editor(function):
def wrap(request, *args, **kwargs):
atlas = Case.objects.get(pk=kwargs["pk"])
if "pk" in kwargs:
atlas = Case.objects.get(pk=kwargs["pk"])
elif "case_id" in kwargs:
atlas = Case.objects.get(pk=kwargs["case_id"])
if (
request.user in atlas.author.all()
or request.user.groups.filter(name="atlas_editor").exists()
@@ -90,8 +108,29 @@ def user_is_collection_author_or_atlas_editor(function):
or request.user.is_superuser
):
return function(request, *args, **kwargs)
else:
raise PermissionDenied
# Allow access if request.user is the supervisor of the target user and sharing is enabled
user_pk = kwargs.get("user_pk") or kwargs.get("user_id")
if user_pk:
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from generic.models import CidUserExam
try:
target_user = get_user_model().objects.get(pk=user_pk)
sup = target_user.userprofile.supervisor
if sup and sup.user_id == request.user.id:
ct = ContentType.objects.get_for_model(CaseCollection)
cid_user_exam = CidUserExam.objects.filter(
content_type=ct,
object_id=atlas.pk,
user_user=target_user
).first()
if (cid_user_exam and cid_user_exam.share_with_supervisor) or atlas.results_supervisor_visible:
return function(request, *args, **kwargs)
except Exception:
pass
raise PermissionDenied
wrap.__doc__ = function.__doc__
wrap.__name__ = function.__name__
+117 -19
View File
@@ -180,6 +180,60 @@ class ConditionForm(ModelForm):
pass
class ConditionRelationshipForm(Form):
parents = ModelMultipleChoiceField(
required=False,
queryset=Condition.objects.none(),
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
)
children = ModelMultipleChoiceField(
required=False,
queryset=Condition.objects.none(),
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
condition_qs = Condition.objects.all().order_by("name")
if self.instance is not None and getattr(self.instance, "pk", None):
condition_qs = condition_qs.exclude(pk=self.instance.pk)
self.fields["parents"].queryset = condition_qs
self.fields["children"].queryset = condition_qs
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Fieldset(
"Condition relationships",
"parents",
"children",
)
)
def save(self, commit=True):
if self.instance is None:
raise ValueError("ConditionRelationshipForm requires an instance")
parents = list(self.cleaned_data.get("parents", []))
children = list(self.cleaned_data.get("children", []))
self.instance.parent.set(parents)
current_children = list(self.instance.child.all())
for child in current_children:
if child not in children:
self.instance.child.remove(child)
for child in children:
if child not in current_children:
self.instance.child.add(child)
return self.instance
class CaseCollectionForm(ModelForm):
class Meta:
model = CaseCollection
@@ -211,6 +265,19 @@ class CaseCollectionForm(ModelForm):
label=label,
)
if "prerequisites" in self.fields:
self.fields["prerequisites"].widget = autocomplete.ModelSelect2Multiple(
url="atlas:casecollection-prerequisite-autocomplete",
attrs={
"data-placeholder": "Search authored collections...",
"data-minimum-input-length": 0,
},
)
authored_collections = CaseCollection.objects.filter(author=self.user).distinct().order_by("name")
if getattr(self.instance, "pk", None):
authored_collections = authored_collections.exclude(pk=self.instance.pk)
self.fields["prerequisites"].queryset = authored_collections
# Identify show_ fields
show_fields = [f for f in self.fields if f.startswith("show_")]
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
@@ -294,6 +361,10 @@ class CaseCollectionForm(ModelForm):
"question_time_limit",
"answer_entry_grace_period",
"prerequisites",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
Fieldset(
"Custom Start Screen",
"start_screen_content",
@@ -630,8 +701,9 @@ class CaseForm(ModelForm):
# This is populated if we create the case from an exam/collection
self.collection = None
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
collections = kwargs["initial"].pop("exams")
initial = kwargs.setdefault("initial", {})
if initial is not None and "exams" in initial:
collections = initial.pop("exams")
logger.debug(collections)
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
@@ -678,7 +750,8 @@ class CaseForm(ModelForm):
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
#cimar_uuid",
"study_date",
),
)
@@ -711,7 +784,8 @@ class CaseForm(ModelForm):
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
#cimar_uuid",
"study_date",
]
# fields = ['question', 'findings', 'subspecialty', 'references']
widgets = {
@@ -768,23 +842,23 @@ class CaseForm(ModelForm):
self.save_m2m = save_m2m
# Do we need to save all changes now?
# if commit:
instance.save()
self.save_m2m()
if commit:
instance.save()
self.save_m2m()
logger.debug(f"{self.collection=}")
if self.collection is not None:
logger.debug(f"{self.collection=}")
case_no = self.collection.cases.count() + 1
logger.debug(f"{case_no=}")
logger.debug(f"{instance=}")
# Create through model
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
self.collection.cases.add(
instance, through_defaults={"sort_order": case_no}
)
if self.collection is not None:
logger.debug(f"{self.collection=}")
case_no = self.collection.cases.count() + 1
logger.debug(f"{case_no=}")
logger.debug(f"{instance=}")
# Create through model
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
self.collection.cases.add(
instance, through_defaults={"sort_order": case_no}
)
return instance
@@ -883,7 +957,13 @@ CaseCollectionCaseFormSet = inlineformset_factory(
CaseCollection,
Case.casecollection_set.through,
form=CaseCollectionCaseForm,
exclude=["question_schema", "question_answers"],
exclude=[
"question_schema",
"question_answers",
"series_visibility_config",
"learner_comment",
"learner_review_comment",
],
can_delete=True,
extra=0,
max_num=50,
@@ -1300,6 +1380,8 @@ class CaseDetailForm(ModelForm):
fields = [
"redact_history",
"override_history",
"learner_comment",
"learner_review_comment",
"question_time_limit_override",
"answer_entry_grace_period_override",
]
@@ -1373,6 +1455,11 @@ class CaseDetailForm(ModelForm):
HTML(history_block),
Field("override_history"),
Field("redact_history"),
Fieldset(
"Learner Comments",
Field("learner_comment"),
Field("learner_review_comment"),
),
Fieldset(
"Case Timing Overrides",
Field("question_time_limit_override"),
@@ -1408,10 +1495,21 @@ class CaseSeriesForm(forms.ModelForm):
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
super().__init__(*args, **kwargs)
self.fields["series"].queryset = queryset
self.fields["series"].label_from_instance = lambda obj: f"Series {obj.pk}: {obj.description or ''} ({obj.modality or ''})"
class Meta:
model = SeriesDetail # through model for Case.series
fields = ["series", "sort_order", "feedback"]
widgets = {
"series": autocomplete.ModelSelect2(
url="atlas:series-autocomplete",
attrs={
"data-placeholder": "Search by Series ID or description...",
"data-minimum-input-length": 0,
}
)
}
class CaseUpdateSeriesForm(forms.ModelForm):
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-01 09:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0104_series_source_series_instance_uid'),
]
operations = [
migrations.AddField(
model_name='case',
name='study_date',
field=models.DateField(blank=True, help_text='Optional manually overridden study date for linked case series.', null=True),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.1 on 2026-06-01 11:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0105_case_study_date'),
]
operations = [
migrations.AddField(
model_name='casedetail',
name='learner_comment',
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner while taking the case and on review.'),
),
migrations.AddField(
model_name='casedetail',
name='learner_review_comment',
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner only on review.'),
),
migrations.AddField(
model_name='casedetail',
name='series_visibility_config',
field=models.JSONField(blank=True, default=dict, help_text='Per-series visibility map for this case in this collection.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-11 20:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0106_casedetail_learner_comment_and_more'),
]
operations = [
migrations.AddField(
model_name='caseprior',
name='relation_type',
field=models.CharField(choices=[('PR', 'Prior'), ('FU', 'Future')], default='PR', help_text='Defines whether the related case is a prior or a future exam', max_length=2),
),
]
+27
View File
@@ -0,0 +1,27 @@
# Generated by Django 6.0.1 on 2026-06-16 11:53
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0107_caseprior_relation_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MultiuserSession',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('active_case', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='atlas.case')),
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_multiuser_sessions', to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,25 @@
# Generated by Django 6.0.1 on 2026-06-28 20:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0108_multiusersession'),
('survey', '__first__'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='post_survey',
field=models.ForeignKey(blank=True, help_text='Survey to be filled out AFTER completing this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_post_surveys', to='survey.survey'),
),
migrations.AddField(
model_name='casecollection',
name='pre_survey',
field=models.ForeignKey(blank=True, help_text='Survey to be filled out BEFORE taking this collection/exam.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_pre_surveys', to='survey.survey'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-06-29 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0109_casecollection_post_survey_casecollection_pre_survey'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='post_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the post-take survey to view results.'),
),
migrations.AddField(
model_name='casecollection',
name='pre_survey_mandatory',
field=models.BooleanField(default=False, help_text='If checked, candidates must complete the pre-take survey to start.'),
),
]
@@ -0,0 +1,16 @@
# Generated by Django 6.0.1 on 2026-07-13 10:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('atlas', '0110_casecollection_post_survey_mandatory_and_more'),
]
operations = [
migrations.DeleteModel(
name='MultiuserSession',
),
]
+317 -46
View File
@@ -3,6 +3,7 @@ import json
import os
import pathlib
from typing import Tuple
import uuid
from django.http import Http404, HttpRequest
from generic.mixins import AuthorMixin, QuestionMixin
@@ -138,7 +139,10 @@ def _series_declared_groups_cache_key(series_id):
def invalidate_series_declared_groups_cache(series_id):
if not series_id:
return
cache.delete(_series_declared_groups_cache_key(series_id))
try:
cache.delete(_series_declared_groups_cache_key(series_id))
except Exception:
pass
def _build_declared_series_groups(series, series_images_with_urls):
@@ -155,7 +159,10 @@ def _build_declared_series_groups(series, series_images_with_urls):
for image_obj, image_url in series_images_with_urls
],
}
cached = cache.get(cache_key)
try:
cached = cache.get(cache_key)
except Exception:
cached = None
if isinstance(cached, dict):
if cached.get("signature") == signature:
return cached.get("groups", [])
@@ -257,7 +264,10 @@ def _build_declared_series_groups(series, series_images_with_urls):
"groupValues": {},
}
]
cache.set(cache_key, {"signature": signature, "groups": result}, timeout=60 * 60)
try:
cache.set(cache_key, {"signature": signature, "groups": result}, timeout=60 * 60)
except Exception:
pass
return result
grouped = {}
@@ -313,7 +323,10 @@ def _build_declared_series_groups(series, series_images_with_urls):
}
)
cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
try:
cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
except Exception:
pass
return ordered_groups
@@ -828,6 +841,56 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
study_date = models.DateField(
null=True,
blank=True,
help_text="Optional manually overridden study date for linked case series.",
)
def get_series_info(self):
try:
has_next = hasattr(self, 'next_case') and self.next_case is not None
except Case.DoesNotExist:
has_next = False
if not self.previous_case_id and not has_next:
return {"in_series": False}
# Find the start of the chain (the first case)
first_case = self
visited = {self.pk}
while first_case.previous_case:
if first_case.previous_case.pk in visited:
break
first_case = first_case.previous_case
visited.add(first_case.pk)
# Traverse the chain from the first case to find positions and count
chain = []
current = first_case
visited_forward = set()
while current:
if current.pk in visited_forward:
break
chain.append(current)
visited_forward.add(current.pk)
try:
current = current.next_case if hasattr(current, 'next_case') else None
except Case.DoesNotExist:
current = None
total = len(chain)
try:
position = chain.index(self) + 1
except ValueError:
position = 1
return {
"in_series": True,
"position": position,
"total": total,
}
def get_ordered_series_details(self):
cached = getattr(self, "_ordered_series_details_cache", None)
if cached is not None:
@@ -861,6 +924,87 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
self._ordered_series_cache = ordered
return ordered
def get_case_named_stacks(self):
stacks = []
for series in self.get_ordered_series():
series_images = list(series.get_images())
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
images = [url for _, url in series_images_with_urls]
declared_groups = _build_declared_series_groups(series, series_images_with_urls)
stacks.append(
{
"name": str(series),
"imageIds": images,
"seriesId": series.pk,
"series": declared_groups,
}
)
results = [
{
"caseId": f"CASE-{self.pk}",
"studyId": "Current Case",
"stacks": stacks,
}
]
return json.dumps(results)
@staticmethod
def _parse_dicom_date(value):
if not value:
return None
try:
return datetime.datetime.strptime(str(value), "%Y%m%d").date()
except (TypeError, ValueError):
return None
def _get_series_study_date(self):
for series in self.get_ordered_series():
image = series.images.filter(removed=False).first()
if not image:
continue
tags = getattr(image, "basic_dicom_tags", None) or {}
raw_date = (
tags.get("StudyDate")
or tags.get("AcquisitionDate")
or tags.get("SeriesDate")
)
parsed = self._parse_dicom_date(raw_date)
if parsed:
return parsed
try:
ds = image.get_dicom_data()
except Exception:
ds = None
if not ds:
continue
raw_date = (
getattr(ds, "StudyDate", None)
or getattr(ds, "AcquisitionDate", None)
or getattr(ds, "SeriesDate", None)
)
parsed = self._parse_dicom_date(raw_date)
if parsed:
return parsed
return None
def get_effective_study_date(self):
if self.study_date:
return self.study_date
return self._get_series_study_date()
def save(self, *args, **kwargs):
if self.pk and not self.study_date:
derived_study_date = self._get_series_study_date()
if derived_study_date:
self.study_date = derived_study_date
super().save(*args, **kwargs)
def get_app_name(self):
return "atlas"
@@ -979,6 +1123,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return prior_cases
def get_all_future_cases(self):
from django.core.exceptions import ObjectDoesNotExist
future_cases = []
current = self
while True:
try:
next_c = current.next_case
except ObjectDoesNotExist:
break
if next_c is None:
break
future_cases.append(next_c)
current = next_c
return future_cases
def get_series(self):
return self.series.all().prefetch_related("images", "examination", "plane")
@@ -1044,40 +1203,40 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
else:
return images
def get_case_named_stacks(self):
def build_stacks_for(case_obj, prefix=None):
logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
stacks = []
for series in case_obj.get_ordered_series():
series_images = list(series.get_images())
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
images = [url for _, url in series_images_with_urls]
declared_groups = _build_declared_series_groups(series, series_images_with_urls)
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append(
{
"name": name,
"imageIds": images,
"seriesId": series.pk,
"series": declared_groups,
}
)
return stacks
#def get_case_named_stacks(self):
# def build_stacks_for(case_obj, prefix=None):
# logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
# stacks = []
# for series in case_obj.get_ordered_series():
# series_images = list(series.get_images())
# series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
# images = [url for _, url in series_images_with_urls]
# declared_groups = _build_declared_series_groups(series, series_images_with_urls)
# name = f"{prefix}: {series}" if prefix else str(series)
# stacks.append(
# {
# "name": name,
# "imageIds": images,
# "seriesId": series.pk,
# "series": declared_groups,
# }
# )
# return stacks
results = []
# results = []
logger.debug(f"Building stacks for case {self.pk}")
# logger.debug(f"Building stacks for case {self.pk}")
# main case entry
results.append(
{
"caseId": f"",
"studyId": f"Current Case",
"stacks": build_stacks_for(self, prefix=None),
}
)
# # main case entry
# results.append(
# {
# "caseId": f"",
# "studyId": f"Current Case",
# "stacks": build_stacks_for(self, prefix=None),
# }
# )
return json.dumps(results)
# return json.dumps(results)
class NormalCase(models.Model, AuthorMixin):
@@ -1298,7 +1457,6 @@ class SeriesImage(SeriesImageBase):
# "SOPInstanceUID" : f"1.2.840.1111.{image_index}"
# }}
@receiver(pre_save, sender=SeriesImage)
def _capture_previous_series_for_series_image(sender, instance, **kwargs):
if not getattr(instance, "pk", None):
@@ -1431,6 +1589,14 @@ class Series(SeriesBase):
self.pk, self.get_examination_full(), self.description, case_id
)
def get_viewer_stack_description(self) -> str:
description = self.description or str(self.examination or "Series")
if self.plane:
description = f"{description} ({self.plane})"
if self.contrast:
description = f"{description} / {self.contrast}"
return description
def get_absolute_url(self):
return reverse("atlas:series_detail", kwargs={"pk": self.pk})
@@ -1469,10 +1635,7 @@ class Series(SeriesBase):
# else:
# instances.append(image.get_image_dicom_json(image_index))
description = f"{self.examination} ({self.plane})"
if self.contrast and self.contrast is not None:
description = f"{description} / {self.contrast}"
series_json["SeriesDescription"] = description
series_json["SeriesDescription"] = self.get_viewer_stack_description()
series_json["instances"] = instances
return series_json
@@ -1790,6 +1953,10 @@ class CaseCollection(ExamOrCollectionGenericBase):
def get_take_url(self):
return reverse("atlas:collection_take_start", kwargs={"pk": self.pk})
def get_take_url_exam_mode(self):
return reverse("atlas:collection_take_start", kwargs={"pk": self.pk})
def __str__(self) -> str:
return self.name
@@ -2031,6 +2198,17 @@ class SeriesDetail(models.Model):
class Meta:
ordering = ("sort_order",)
@receiver(post_save, sender=SeriesDetail)
def _populate_case_study_date_on_series_link(sender, instance, created, **kwargs):
case = instance.case
if case.study_date:
return
derived_study_date = case._get_series_study_date()
if derived_study_date:
Case.objects.filter(pk=case.pk, study_date__isnull=True).update(study_date=derived_study_date)
class CaseDisplaySet(models.Model, AuthorMixin):
"""
This is analogous to a SeriesFinding but for a Case (it has
@@ -2117,6 +2295,29 @@ class CaseDetail(models.Model):
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
)
class SeriesVisibility(models.TextChoices):
ALWAYS = "AL", _("Show all the time")
REVIEW = "RE", _("Show on review")
HIDDEN = "NO", _("Do not show")
series_visibility_config = models.JSONField(
default=dict,
blank=True,
help_text="Per-series visibility map for this case in this collection.",
)
learner_comment = models.TextField(
blank=True,
default="",
help_text="Comment shown to the learner while taking the case and on review.",
)
learner_review_comment = models.TextField(
blank=True,
default="",
help_text="Comment shown to the learner only on review.",
)
class Meta:
ordering = ("sort_order",)
@@ -2170,23 +2371,72 @@ class CaseDetail(models.Model):
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)
def get_series_visibility_map(self) -> dict[str, str]:
raw_map = self.series_visibility_config or {}
if not isinstance(raw_map, dict):
return {}
valid_values = {
self.SeriesVisibility.ALWAYS,
self.SeriesVisibility.REVIEW,
self.SeriesVisibility.HIDDEN,
}
cleaned = {}
for key, value in raw_map.items():
key_str = str(key)
value_str = str(value)
if value_str in valid_values:
cleaned[key_str] = value_str
return cleaned
def get_series_visibility_for(self, series, review_mode: bool = False) -> str:
visibility = self.get_series_visibility_map().get(
str(series.pk), self.SeriesVisibility.ALWAYS
)
if visibility == self.SeriesVisibility.REVIEW and not review_mode:
return self.SeriesVisibility.HIDDEN
return visibility
def get_visible_case_series(self, review_mode: bool = False):
visible = []
for series in self.case.get_ordered_series():
visibility = self.get_series_visibility_for(series, review_mode=review_mode)
if visibility != self.SeriesVisibility.HIDDEN:
visible.append(series)
return visible
def get_case_series_nested(self, include_priors=True, review_mode: bool = False):
case_series_images = []
for series in self.get_visible_case_series(review_mode=review_mode):
case_series_images.append([
image.image.url
for image in series.images.filter(removed=False)
if image.image
])
if include_priors:
logger.debug(f"Checking for prior cases for case {self.case}")
logger.debug(f"Found {self.case.prior_case.count()} prior cases for case {self.case}")
for prior in self.caseprior_set.all():
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
continue
logger.debug(f"Adding prior case {prior.prior_case} to case {self.case}")
case_series_images.extend(prior.prior_case.get_series_images_nested(as_json=False))
return json.dumps(case_series_images)
def get_case_named_stacks(self, include_priors=True):
def get_case_named_stacks(self, include_priors=True, review_mode: bool = False):
def build_stacks_for(case_obj, prefix=None):
stacks = []
for series in case_obj.get_ordered_series():
if case_obj == self.case:
series_iterable = self.get_visible_case_series(review_mode=review_mode)
else:
series_iterable = case_obj.get_ordered_series()
for series in series_iterable:
series_images = list(series.get_images())
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
images = [url for _, url in series_images_with_urls]
@@ -2216,12 +2466,17 @@ class CaseDetail(models.Model):
# include priors as separate entries
if include_priors:
for prior in self.caseprior_set.all():
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
continue
prior_case = prior.prior_case
label = "Prior" if prior.relation_type == CasePrior.RelationType.PRIOR else "Future"
results.append(
{
"caseId": f"CASE-{prior_case.pk}",
"studyId": f"Prior: {prior.relation_text}",
"stacks": build_stacks_for(prior_case, prefix="Prior"),
"studyId": f"{label}: {prior.relation_text}",
"stacks": build_stacks_for(prior_case, prefix=label),
}
)
@@ -2254,6 +2509,17 @@ class CasePrior(models.Model):
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
class RelationType(models.TextChoices):
PRIOR = "PR", _("Prior")
FUTURE = "FU", _("Future")
relation_type = models.CharField(
max_length=2,
choices=RelationType.choices,
default=RelationType.PRIOR,
help_text="Defines whether the related case is a prior or a future exam",
)
class PriorVisibility(models.TextChoices):
NONE = "NO", _("None")
ALWAYS = "AL", _("Always")
@@ -2374,6 +2640,7 @@ class BaseReportAnswer(models.Model):
abstract = True
@reversion.register
class CidReportAnswer(BaseReportAnswer):
cid = models.BigIntegerField(
blank=False,
@@ -2381,6 +2648,7 @@ class CidReportAnswer(BaseReportAnswer):
)
@reversion.register
class UserReportAnswer(BaseReportAnswer):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@@ -2609,6 +2877,7 @@ class UncategorisedDicom(models.Model):
"SliceThickness",
"SeriesNumber",
"StudyDate",
"PatientID",
)
tags = {}
@@ -2755,4 +3024,6 @@ class PrerequisiteRequired(Exception):
def __init__(self, message=None, *, prereq=None):
super().__init__(message or "Prerequisite required")
self.prereq = prereq
self.prereq = prereq
+2
View File
@@ -21,6 +21,7 @@ from .models import (
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.template.defaultfilters import filesizeformat
from easy_thumbnails.files import get_thumbnailer
@@ -159,6 +160,7 @@ class CaseTable(SelectionTable):
return mark_safe(", ".join(links))
return "No collections"
associated_cases = tables.Column(
verbose_name="Associated Cases", accessor="pk", orderable=False
)
+48 -23
View File
@@ -173,6 +173,10 @@ def series_downsample_task(context, series_id, user_id, downsample_pct):
series.source_series_instance_uid = source_series_uid
series.series_instance_uid = replacement_series_uid
series.save(update_fields=["modified", "source_series_instance_uid", "series_instance_uid"])
try:
series.get_total_image_size(refresh=True)
except Exception as exc:
logger.warning("Failed to refresh total_image_size for series {}: {}", series.pk, exc)
cache.set(
progress_key,
@@ -332,6 +336,32 @@ def series_reconstruct_task(
src_y = source_for_patient_axis[1]
src_z = source_for_patient_axis[2]
# Build patient-axis basis vectors expressed in source/world space.
# This preserves source world-frame orientation (including obliquity/sign)
# for derived series instead of hardcoding cardinal axes.
patient_x_vec = np.asarray(source_axis_dirs[src_x], dtype=float) * float(sign_for_patient_axis[0])
patient_y_vec = np.asarray(source_axis_dirs[src_y], dtype=float) * float(sign_for_patient_axis[1])
patient_z_vec = np.asarray(source_axis_dirs[src_z], dtype=float) * float(sign_for_patient_axis[2])
def _norm_vec(vec):
nrm = float(np.linalg.norm(vec))
if nrm == 0:
return np.asarray(vec, dtype=float)
return np.asarray(vec, dtype=float) / nrm
patient_x_vec = _norm_vec(patient_x_vec)
patient_y_vec = _norm_vec(patient_y_vec)
patient_z_vec = _norm_vec(patient_z_vec)
def _orientation_pair(col_vec, row_vec, desired_normal):
col = _norm_vec(col_vec)
row = _norm_vec(row_vec)
desired = _norm_vec(desired_normal)
normal = np.cross(col, row)
if float(np.dot(normal, desired)) < 0:
row = -row
return [float(col[0]), float(col[1]), float(col[2]), float(row[0]), float(row[1]), float(row[2])]
spacing_x = source_axis_spacings[src_x]
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
@@ -346,7 +376,7 @@ def series_reconstruct_task(
vol_zyx = np.flip(vol_zyx, axis=2)
def source_indices_from_patient_zyx(iz, iy, ix):
src_indices = [0, 0, 0]
src_indices = [0.0, 0.0, 0.0]
z_index = float(iz)
y_index = float(iy)
@@ -374,10 +404,6 @@ def series_reconstruct_task(
)
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)
@@ -401,23 +427,22 @@ def series_reconstruct_task(
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)
center_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
if center_positions_mm.size == 0:
center_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))
half_thickness = slab_thickness / 2.0
for center_mm in center_positions_mm:
mask = np.abs(axis_positions_mm - center_mm) <= (half_thickness + 1e-6)
if not mask.any():
nearest = int(np.argmin(np.abs(axis_positions_mm - start_mm)))
nearest = int(np.argmin(np.abs(axis_positions_mm - center_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
@@ -437,8 +462,8 @@ def series_reconstruct_task(
"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],
"image_orientation_out": _orientation_pair(patient_x_vec, patient_y_vec, patient_z_vec),
"plane_normal_out": [float(patient_z_vec[0]), float(patient_z_vec[1]), float(patient_z_vec[2])],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(centers[idx], 0.0, 0.0),
}
elif plane_norm == "coronal":
@@ -449,13 +474,13 @@ def series_reconstruct_task(
)
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)),
"slice_fn": lambda idx, slabs=slab_indices: _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),
"image_orientation_out": _orientation_pair(patient_x_vec, patient_z_vec, patient_y_vec),
"plane_normal_out": [float(patient_y_vec[0]), float(patient_y_vec[1]), float(patient_y_vec[2])],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, 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)
@@ -465,13 +490,13 @@ def series_reconstruct_task(
)
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)),
"slice_fn": lambda idx, slabs=slab_indices: _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]),
"image_orientation_out": _orientation_pair(patient_y_vec, patient_z_vec, patient_x_vec),
"plane_normal_out": [float(patient_x_vec[0]), float(patient_x_vec[1]), float(patient_x_vec[2])],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(0.0, 0.0, centers[idx]),
}
if not plane_builders:
+11 -5
View File
@@ -54,6 +54,9 @@
</li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'atlas:case_admin_overview' %}"><i class="bi bi-speedometer2 me-1" aria-hidden="true"></i>Case Admin Overview</a>
</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>
@@ -139,12 +142,15 @@
{% 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>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" title="Additional Features"><i class="bi bi-three-dots me-1" aria-hidden="true"></i>Other</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<ul class="dropdown-menu">
<a class="dropdown-item" 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>
<a class="dropdown-item" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
</ul>
</li>
</ul>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE HTML>
{% load static %}
{% load django_htmx %}
{% load crispy_forms_tags %}
<html data-bs-theme="dark">
<head>
<title>Create Resource</title>
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'css/widgets.css' %}" type="text/css" media="all" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/bootstrap-icons.css' %}">
<script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
{% block js %}
{% endblock %}
<link rel="stylesheet" href="{% static 'css/forms.css' %}">
</head>
<body class="p-3">
{% block content %}
{% endblock %}
</body>
</html>
@@ -0,0 +1,116 @@
{% extends 'atlas/base.html' %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h3 class="mb-0">Case Admin Overview</h3>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_view' %}">Back to case list</a>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Total cases</div>
<div class="fs-4 fw-semibold">{{ totals.total_cases }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Authors with cases</div>
<div class="fs-4 fw-semibold">{{ totals.total_authors }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Open-access cases</div>
<div class="fs-4 fw-semibold">{{ totals.open_access_cases }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Archived cases</div>
<div class="fs-4 fw-semibold">{{ totals.archived_cases }}</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">Cases per user</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>User</th>
<th class="text-end">Case count</th>
</tr>
</thead>
<tbody>
{% for row in author_rows %}
<tr>
<td>
<a href="{% url 'atlas:case_view' %}?author={{ row.pk }}">
{% if row.get_full_name %}{{ row.get_full_name }}{% else %}{{ row.username }}{% endif %}
</a>
<div class="text-muted small">{{ row.username }}</div>
</td>
<td class="text-end fw-semibold">
<a href="{% url 'atlas:case_view' %}?author={{ row.pk }}">{{ row.case_count }}</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-muted">No case authors found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-7 d-flex flex-column gap-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Recently added cases</span>
<button class="btn btn-sm btn-outline-info"
hx-get="{% url 'atlas:case_admin_recent_cases_partial' %}"
hx-target="#admin-recent-cases"
hx-swap="innerHTML">
Load
</button>
</div>
<div id="admin-recent-cases" class="card-body text-muted small">
Click Load to fetch recently added cases.
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Largest cases</span>
<button class="btn btn-sm btn-outline-warning"
hx-get="{% url 'atlas:case_admin_largest_cases_partial' %}"
hx-target="#admin-largest-cases"
hx-swap="innerHTML">
Load
</button>
</div>
<div id="admin-largest-cases" class="card-body text-muted small">
Click Load to fetch the largest cases by image footprint.
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+48 -17
View File
@@ -6,7 +6,7 @@
id="series-block-{{ series.pk }}"
data-series-id="{{series.pk}}"
data-series-label="Series {{ forloop.counter }}"
data-series-name="{{ series|escape }}"
data-series-name="{{ series.get_viewer_stack_description|escape }}"
data-image-ids='{{ series.get_image_url_array_and_count.0 }}'
data-series-detail-url="{% url 'atlas:series_detail' pk=series.pk %}"
data-series-popup-url="/atlas/series/{{series.pk}}"
@@ -43,7 +43,7 @@
class="btn btn-link btn-sm text-muted series-open-primary-btn"
title="View series in viewer"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}">
data-series-name="{{ series.get_viewer_stack_description|escape }}">
<i class="bi bi-eye"></i> View
</button>
<div class="series-card-inline-actions">
@@ -57,7 +57,7 @@
class="btn btn-outline-info btn-sm series-open-new-viewport-btn d-none"
title="View series in a new pane"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}"
data-series-name="{{ series.get_viewer_stack_description|escape }}"
tabindex="-1"
aria-hidden="true">
<i class="bi bi-layout-split"></i> View in new pane
@@ -426,21 +426,40 @@
</div>
</h2>
{% if case.previous_case or case.next_case %}
<div class="alert alert-info" role="alert">
This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/>
<div class="row">
{% if case.previous_case %}
<div class="col-sm-6 mb-3 mb-sm-0">
<h5 class="card-title">Previous Case</h5>
{{ case.previous_case.get_link }}
<div class="card border-info bg-info-subtle mb-3">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center border-bottom border-info-subtle pb-2 mb-2">
<span class="fw-semibold text-info-emphasis"><i class="bi bi-layers-half me-1"></i> Part of a Case Series</span>
<a href='{% url "atlas:linked_cases_overview" case.pk %}' class="btn btn-sm btn-outline-info text-decoration-none py-0 px-2" style="font-size: 0.8rem;">View Series Overview</a>
</div>
<div class="row text-center text-sm-start g-2">
<div class="col-6 border-end border-info-subtle d-flex flex-column justify-content-center">
{% if case.previous_case %}
<div>
<a href="{{ case.previous_case.get_absolute_url }}" class="text-decoration-none fw-semibold link-info" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-delay='{"show":500,"hide":100}' title="{{ case.previous_case.description|striptags|default:'No description' }}">
<i class="bi bi-arrow-left me-1"></i> Previous Case: {{ case.previous_case.title|default:'Untitled Case' }}
</a>
</div>
{% else %}
<div class="small text-muted italic">
<i class="bi bi-arrow-left me-1"></i> No previous case
</div>
{% endif %}
</div>
{% endif %}
{% if case.next_case %}
<div class="col-sm-6">
<h5 class="card-title">Next Case</h5>
{{ case.next_case.get_link }}
<div class="col-6 d-flex flex-column justify-content-center text-sm-end align-items-sm-end">
{% if case.next_case %}
<div>
<a href="{{ case.next_case.get_absolute_url }}" class="text-decoration-none fw-semibold link-info" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-delay='{"show":500,"hide":100}' title="{{ case.next_case.description|striptags|default:'No description' }}">
Next Case: {{ case.next_case.title|default:'Untitled Case' }} <i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
{% else %}
<div class="small text-muted italic">
No follow-up case <i class="bi bi-arrow-right ms-1"></i>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
@@ -1123,6 +1142,16 @@
}
document.addEventListener("DOMContentLoaded", function () {
// Initialize tooltips
function initAlertTooltips() {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el) {
try { new bootstrap.Tooltip(el); } catch (e) {}
});
}
initAlertTooltips();
document.body.addEventListener('htmx:afterSwap', function() {
initAlertTooltips();
});
const dicomDetails = document.getElementById("dicom-viewer-details");
let dicomViewerLoaded = false;
@@ -1357,7 +1386,9 @@
}
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
btn.addEventListener("click", function(event) {
event.preventDefault();
event.stopPropagation();
const parent = btn.closest(".series-block");
toggleSeriesSelection(parent);
});
@@ -14,13 +14,54 @@
<form method="POST" class="post-form">
{% csrf_token %}
{% crispy form form.helper %}
<div class="card mb-3">
<div class="card-header">
<strong>Series Visibility</strong>
</div>
<div class="card-body">
<p class="small text-muted mb-3">Choose how each series is shown for this case in this collection.</p>
{% if series_visibility_rows %}
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Series</th>
<th>Visibility</th>
</tr>
</thead>
<tbody>
{% for row in series_visibility_rows %}
<tr>
<td>
<div class="fw-semibold">Series {{ forloop.counter }}</div>
<div class="small text-muted">{{ row.series }}</div>
</td>
<td>
<select class="form-select form-select-sm" name="series_visibility_{{ row.series.pk }}">
{% for value, label in visibility_choices %}
<option value="{{ value }}" {% if row.value == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted">No series linked to this case.</div>
{% endif %}
</div>
</div>
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
</form>
{% endblock %}
{% block js %}
<script>
</script>
<style></style>
<script>
</script>
<style></style>
{% endblock %}
@@ -15,14 +15,26 @@
</div>
</div>
<h3 class="mb-3">Available priors</h3>
<h3 class="mb-3">Available Prior Exams</h3>
<div class="row g-3">
{% for case, added, relation, visibility in available_priors %}
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
<div class="row g-3 mb-4">
{% for case, added, relation, visibility, relation_type in available_priors %}
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility relation_type=relation_type collection=collection %}
{% empty %}
<div class="col-12">
<div class="alert alert-info">No available prior cases found for the case.</div>
<div class="alert alert-info">No available prior exams found for the case.</div>
</div>
{% endfor %}
</div>
<h3 class="mb-3">Available Future Exams</h3>
<div class="row g-3 mb-4">
{% for case, added, relation, visibility, relation_type in available_futures %}
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility relation_type=relation_type collection=collection %}
{% empty %}
<div class="col-12">
<div class="alert alert-info">No available future exams found for the case.</div>
</div>
{% endfor %}
</div>
@@ -1,9 +1,18 @@
{% extends 'atlas/base.html' %}
{% load crispy_forms_tags %}
{% load django_htmx %}
{% block content %}
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
{% if viewing_as_user %}
<div class="alert alert-info d-flex align-items-center gap-2 py-2 px-3 mb-3" role="alert">
<i class="bi bi-eye-fill me-1"></i>
<span><strong>Reviewer mode</strong> &mdash; Viewing <strong>{{ target_user.get_full_name|default:target_user.username }}</strong>&rsquo;s answers. This screen is read-only.</span>
<a href="{% url 'atlas:collection_history_user' exam_id=collection.id user_pk=target_user.pk %}" class="btn btn-outline-info btn-sm ms-auto">Back to overview</a>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h4 mb-0">Case {{case_number|add:1}}
@@ -101,6 +110,18 @@
</div>
{% endif %}
{% if casedetail.learner_comment %}
<div class="alert alert-info small">
<strong>Collection note:</strong> {{ casedetail.learner_comment|linebreaks }}
</div>
{% endif %}
{% if question_completed and casedetail.learner_review_comment %}
<div class="alert alert-secondary small">
<strong>Review note:</strong> {{ casedetail.learner_review_comment|linebreaks }}
</div>
{% endif %}
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
<div id="viewer-panel" class="mb-3">
{% if collection.show_built_in_viewer %}
@@ -116,10 +137,10 @@
<div id="viewer-moveable-home">
<div id="viewer-moveable">
<div id="viewer-container" class="viewer-resizable-container">
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{ named_stacks_json }}'
></div>
</div>
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
<div class="viewer-resize-grip-inner"></div>
@@ -294,6 +315,7 @@
{% endif %}
{% endif %}
{% if not viewing_as_user %}
<div class="mb-2">
{% if previous %}
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
@@ -311,11 +333,24 @@
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
{% endif %}
<div class="mt-3">
<button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div>
{% else %}
{# Read-only navigation for reviewer mode — uses anchor links to avoid mutating POST #}
<div class="mb-2 d-flex gap-2 flex-wrap">
{% if previous %}
<a href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=case_number|add:'-1' user_pk=target_user.pk %}" class="btn btn-outline-secondary btn-sm">Previous</a>
{% endif %}
{% if next %}
<a href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=case_number|add:'1' user_pk=target_user.pk %}" class="btn btn-primary btn-sm">Next</a>
{% endif %}
</div>
<div class="mt-3">
<a href="{% url 'atlas:collection_history_user' exam_id=collection.id user_pk=target_user.pk %}" class="btn btn-outline-secondary btn-sm">Overview</a>
</div>
{% endif %}
</form>
{% if question_completed and collection.case_query_messaging_enabled %}
@@ -4,6 +4,16 @@
{% block content %}
<h2>Case: {{ case.title }}</h2>
{% if casedetail.learner_comment %}
<div class="alert alert-info small">
<strong>Collection note:</strong> {{ casedetail.learner_comment|linebreaks }}
</div>
{% endif %}
{% if casedetail.learner_review_comment %}
<div class="alert alert-secondary small">
<strong>Review note:</strong> {{ casedetail.learner_review_comment|linebreaks }}
</div>
{% endif %}
{{ casedetail.question_schema }}
@@ -61,6 +61,7 @@
<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>
<a href="{% url 'atlas:collection_viva_local' collection.pk %}" class="btn btn-outline-primary mb-2">Start Viva (Local)</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>
+39 -38
View File
@@ -1,45 +1,46 @@
{% if request.user.is_authenticated and collection %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Collection:</strong> {{ collection.name }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collectionNav" aria-controls="collectionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Collection:</strong> {{ collection.name }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collectionNav" aria-controls="collectionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="collectionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_history' collection.pk %}">History</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
</ul>
<div class="collapse navbar-collapse" id="collectionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_history' collection.pk %}">History</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="collectionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="collectionActions">
<li><a class="dropdown-item" href="{% url 'atlas:collection_case_update' collection.pk %}">Edit Cases</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_update' collection.id %}">Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_clone' collection.id %}">Clone</a></li>
<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 %}
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="collectionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="collectionActions">
<li><a class="dropdown-item" href="{% url 'atlas:collection_case_update' collection.pk %}">Edit Cases</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_update' collection.id %}">Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:exam_clone' collection.id %}">Clone</a></li>
<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" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
{% endif %}
</ul>
</li>
</ul>
<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>
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva_local' collection.pk %}">View as viva (local)</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>
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
{% endif %}
</ul>
</li>
</ul>
</div>
</div>
</div>
</nav>
</nav>
{% endif %}
@@ -45,7 +45,7 @@
</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 %}">
href="{% url 'atlas:collection_case_view_take_review_user' pk=collection.id case_number=forloop.counter0 user_pk=user.pk %}">
Open Case
</a>
{% if request.user.is_superuser and row.user_answer %}
@@ -89,48 +89,51 @@
</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 %}
{% partialdef sharing-info inline %}
{% 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">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</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 %}
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
— you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they don't have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
{% endpartialdef %}
{% else %}
<section class="take-start-card">
<p class="mb-0">
@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
@@ -51,6 +52,18 @@
</div>
</div>
{% if post_survey_url %}
<div class="alert alert-info border-info bg-dark bg-opacity-25 my-3 d-flex align-items-center justify-content-between" role="alert">
<div>
<h5 class="alert-heading text-info mb-1"><i class="bi bi-patch-question me-2"></i>Post-Collection Feedback</h5>
<p class="mb-0 small text-muted">Please take a moment to complete the feedback survey for this case collection.</p>
</div>
<a href="{{ post_survey_url }}" class="btn btn-info btn-sm text-dark fw-semibold px-4">
Take Survey <i class="bi bi-arrow-right ms-1"></i>
</a>
</div>
{% endif %}
<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 %}">
@@ -86,6 +99,19 @@
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2">
{% if request.user.is_staff or request.user.is_superuser or request.user in collection.author.all %}
{% if answer %}
{% if answer.user %}
<a href="{% url 'generic:view_answer_history' app_name='atlas' model_name='UserReportAnswer' pk=answer.pk %}" class="btn btn-sm btn-outline-info">
History
</a>
{% else %}
<a href="{% url 'generic:view_answer_history' app_name='atlas' model_name='CidReportAnswer' pk=answer.pk %}" class="btn btn-sm btn-outline-info">
History
</a>
{% endif %}
{% endif %}
{% endif %}
<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
@@ -91,49 +91,7 @@
</div>
</section>
{# ---- Who can see your results ---- #}
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
— you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they don't have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
{% include "atlas/collection_review_start.html#sharing-info" %}
{% else %}
<section class="take-start-card">
<p class="mb-3">
+1 -1
View File
@@ -35,7 +35,7 @@
<a href="{% url 'atlas:case_detail' case.pk %}" style="color: inherit; text-decoration: none;">View Case</a>
</button>
<button class="edit-link-case btn btn-outline-warning btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:case_update' case.pk %}" style="color: inherit; text-decoration: none;">Edit Case</a>
<a href="{% url 'atlas:case_update' case.pk %}?next={{ request.get_full_path|urlencode }}" style="color: inherit; text-decoration: none;">Edit Case</a>
</button>
<br />
<div class="pre-whitespace multi-image-block">
@@ -0,0 +1,449 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<div class="container py-3 viva-local-page">
<div class="card border-secondary-subtle shadow-sm mb-3">
<div class="card-body d-flex flex-wrap gap-2 justify-content-between align-items-center">
<div>
<h1 class="h4 mb-1">Viva Cases (Local Viewer)</h1>
<p class="text-muted mb-0">Load full cases, single series, resources, or display sets into a linked local DV3D window.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<button id="open-viewer-local" class="btn btn-primary">Open Viewer Local</button>
<button id="view-series" class="btn btn-outline-secondary">Toggle series</button>
<button id="toggle-edit-mode" class="btn btn-outline-warning">Enable Edit Mode</button>
</div>
</div>
</div>
<details class="help-text card border-secondary-subtle shadow-sm mb-3">
<summary class="card-header bg-body-secondary d-flex align-items-center gap-2"><i class="bi bi-info-circle"></i> Quick help</summary>
<div class="card-body">
<p class="mb-2">Open the linked viewer first, then use the Load controls on this page. Loading a new case/series replaces the current viewer content.</p>
<p class="mb-0">Use Edit Mode to reveal direct links for case, series, and display-set editing without leaving the viva workflow.</p>
</div>
</details>
<div class="row g-3">
<div class="col-lg-7">
<h2 class="h5 mb-2">Cases</h2>
<ol class="sortable list-unstyled d-flex flex-column gap-3">
{% for casedetail in casedetails %}
{% with case=casedetail.case %}
<li data-question_pk="{{ case.pk }}">
<article class="card border-secondary-subtle shadow-sm case-item"
data-title="{{ case.title }}"
data-case="{{ case.pk }}"
data-casejson="{{ case.viva_details_json|urlencode }}"
data-images="{{ case.series_images_nested_json|urlencode }}"
data-viewerstate="{{ casedetail.default_viewerstate_string|urlencode }}">
<header class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<span class="badge bg-primary me-2">Case {{ forloop.counter }}</span>
<span class="fw-semibold">{{ case.title }}</span>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="open-case-local btn btn-sm btn-success" data-target="{{ case.pk }}" data-type="case">Load Case</button>
<button class="view-link-case btn btn-outline-primary btn-sm" style="display:none;">
<a href="{% url 'atlas:case_detail' case.pk %}" style="color: inherit; text-decoration: none;">View Case</a>
</button>
<button class="edit-link-case btn btn-outline-warning btn-sm" style="display:none;">
<a href="{% url 'atlas:case_update' case.pk %}?next={{ request.get_full_path|urlencode }}" style="color: inherit; text-decoration: none;">Edit Case</a>
</button>
</div>
</header>
<div class="card-body d-flex flex-column gap-3">
<details class="series-detail">
<summary class="fw-semibold">Series</summary>
<div class="mt-2 d-flex flex-column gap-2">
{% for series in case.visible_prefetched_series %}
<div class="border rounded p-2 bg-body-tertiary">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="small">
<div class="fw-semibold">Series {{ forloop.counter }}</div>
<div class="text-muted">
{% if series.modality %}{{ series.modality }}{% endif %}
{% if series.examination %} · {{ series.examination }}{% endif %}
{% if series.plane %} · {{ series.plane }}{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="open-series-local btn btn-sm btn-outline-success"
data-type="series"
data-series="{{ forloop.counter }}"
data-case="{{ case.pk }}"
data-target="{{ series.pk }}">
Load Series
</button>
<button class="edit-link-series btn btn-outline-warning btn-sm" style="display:none;">
<a href="{% url 'atlas:series_update' series.pk %}" style="color: inherit; text-decoration: none;">Edit Series</a>
</button>
</div>
</div>
</div>
{% empty %}
<div class="text-muted small">No series linked.</div>
{% endfor %}
</div>
</details>
{% if case.prefetched_case_resources %}
<div>
<div class="fw-semibold mb-1">Resources</div>
<div class="d-flex flex-column gap-1">
{% for cr in case.prefetched_case_resources %}
{% with res=cr.resource %}
<div class="d-flex flex-wrap align-items-center gap-2 small">
<span>{{ res.name }}</span>
<button class="open-resource btn btn-sm btn-outline-primary" 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.prefetched_display_sets %}
<details class="displayset-detail">
<summary class="fw-semibold">Display Sets</summary>
<div class="mt-2 d-flex flex-column gap-2">
{% for ds in case.prefetched_display_sets %}
<div class="border rounded p-2 bg-body-tertiary d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="small">
<div class="fw-semibold">{{ ds.name }}</div>
{% if ds.description %}<div class="text-muted">{{ ds.description }}</div>{% endif %}
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<button class="open-displayset btn btn-sm btn-outline-info"
data-type="displayset"
data-displayset="{{ ds.pk }}"
data-case="{{ case.pk }}"
data-title="{{ ds.name }}"
data-casejson="{{ case.viva_details_json|urlencode }}"
data-images="{{ case.series_images_nested_json|urlencode }}"
data-viewerstate="{{ ds.viewerstate_string|urlencode }}"
data-annotations="{{ ds.annotations_string|urlencode }}">
Load Display Set
</button>
<button class="edit-link-displayset btn btn-outline-warning btn-sm" style="display:none;">
<a href="{% url 'atlas:case_displaysets' case.pk %}?displayset={{ ds.pk }}&edit=true" style="color: inherit; text-decoration: none;">Edit Display Set</a>
</button>
</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
<div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div>
</div>
</article>
</li>
{% endwith %}
{% endfor %}
</ol>
</div>
<div class="col-lg-5">
<div class="sticky-top pt-2">
<div class="card border-secondary-subtle shadow-sm">
<div class="card-header"><h2 class="h5 mb-0">Current Case</h2></div>
<div class="card-body" id="case-details">
<div id="user-message" class="alert py-2" style="display:none;"></div>
<div id="loading-case" class="alert alert-warning py-2" style="display:none;">Waiting for case to load in linked window.</div>
<div id="current-case-title" class="mb-2">None</div>
<div id="current-case-series" class="mb-2"></div>
<div id="current-case-history" class="mb-2"></div>
<div id="current-case-discussion" class="mb-2"></div>
<div id="current-case-report" class="mb-2"></div>
<div id="current-case-questions" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script type="text/javascript">
var win2 = false;
let editMode = false;
let viewerConnected = false;
$(document).ready(function() {
const showUserMessage = function(message, level = 'warning') {
const $msg = $('#user-message');
$msg
.removeClass('alert-warning alert-danger alert-success alert-info')
.addClass('alert-' + level)
.text(message)
.show();
};
const clearUserMessage = function() {
$('#user-message').hide().text('');
};
const parseEncodedJson = function(raw, label) {
if (!raw) {
return {};
}
try {
return JSON.parse(decodeURIComponent(raw));
} catch (err) {
throw new Error('Unable to parse ' + label + ' payload.');
}
};
const ensureViewerWindowReady = function() {
if (!win2 || win2.closed) {
showUserMessage('Viewer is not open. Click "Open Viewer Local" before loading a case or series.', 'warning');
return false;
}
clearUserMessage();
return true;
};
// Edit mode toggle
$('#toggle-edit-mode').click(function() {
editMode = !editMode;
if (editMode) {
$('.edit-link-case').show();
$('.view-link-case').show();
$('.edit-link-series').show();
$('.edit-link-displayset').show();
$(this).text('Disable Edit Mode').removeClass('btn-warning').addClass('btn-danger');
} else {
$('.edit-link-case').hide();
$('.view-link-case').hide();
$('.edit-link-series').hide();
$('.edit-link-displayset').hide();
$(this).text('Enable Edit Mode').removeClass('btn-danger').addClass('btn-warning');
}
});
const bc = new BroadcastChannel("viva_load");
bc.onmessage = (event) => {
console.log("message received");
console.log(event);
if (event.data.type == "open-complete-local") {
viewerConnected = true;
$("#loading-case").hide();
$("#open-viewer-local").removeClass("flash-button");
clearUserMessage();
//bc.postMessage({"type": "open-complete-local"});
}
};
console.log('ready')
$('.show-collection, .show-case, .show-series').click(function() {
var url = $(this).data('target');
console.log('clicked', url)
$('#case-title').text($(this).data('title'));
$('#viewer').attr('src', url)
});
$('.open-case-local').click(function() {
try {
if (!ensureViewerWindowReady()) {
return;
}
let c = this.closest(".case-item");
console.log('clicked local', c)
console.log('open local', c.dataset.case)
casedetails = parseEncodedJson(c.dataset.casejson, 'case details');
let viewerstate = parseEncodedJson(c.dataset.viewerstate, 'viewer state') || {};
let images = decodeURIComponent(c.dataset.images || '[]');
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+(casedetails['history'] || ''));
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+(casedetails['discussion'] || ''));
$("#current-case-report").html("<span class='title'>Report:</span> "+(casedetails['report'] || ''));
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": images,
"viewerstate": viewerstate
});
// Inject the per-case question snippet into the current case panel
var snippet = $(c).find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.error('Case load failed', e);
showUserMessage('Case load failed: ' + (e.message || 'Unexpected error while parsing case data.'), 'danger');
}
});
$('.open-displayset').click(function() {
try {
if (!ensureViewerWindowReady()) {
return;
}
let c = this;
console.log('clicked local', c)
console.log('open local', c.dataset.case)
casedetails = parseEncodedJson(c.dataset.casejson, 'display set case details');
let viewerstate = parseEncodedJson(c.dataset.viewerstate, 'display set viewer state') || {};
let annotations = parseEncodedJson(c.dataset.annotations, 'display set annotations') || {};
let images = decodeURIComponent(c.dataset.images || '[]');
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+(casedetails['history'] || ''));
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+(casedetails['discussion'] || ''));
$("#current-case-report").html("<span class='title'>Report:</span> "+(casedetails['report'] || ''));
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": images,
"viewerstate": viewerstate,
"annotations": annotations
});
// Inject the per-case question snippet
var caseItem = $(this).closest('.case-item');
var snippet = caseItem.find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.error('Display set load failed', e);
showUserMessage('Display set load failed: ' + (e.message || 'Unexpected error while parsing display set data.'), 'danger');
}
});
$('.open-series-local').click(function() {
try {
if (!ensureViewerWindowReady()) {
return;
}
let c = $(this).closest(".case-item")[0];
let seriesNumber = $(this).data('series');
let seriesPk = $(this).data('target');
let caseDetails = parseEncodedJson(c.dataset.casejson, 'series case details');
let allSeriesImages = parseEncodedJson(c.dataset.images, 'series images');
let images = allSeriesImages[seriesNumber - 1] || [];
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> " + c.dataset.title);
$('#current-case-series').html("<span class='title'>Series:</span> " + seriesNumber);
$("#current-case-history").html("<span class='title'>History:</span> " + (caseDetails['history'] || ''));
$("#current-case-discussion").html("<span class='title'>Discussion:</span> " + (caseDetails['discussion'] || ''));
$("#current-case-report").html("<span class='title'>Report:</span> " + (caseDetails['report'] || ''));
// Send only the images for this series
bc.postMessage({
"type": "open",
"case": c.dataset.case,
"series": seriesPk,
"images": JSON.stringify(images),
});
// Inject the per-case question snippet
var snippet = $(c).find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.error('Series load failed', e);
showUserMessage('Series load failed: ' + (e.message || 'Unexpected error while parsing series data.'), 'danger');
}
});
// Load resource into linked viewer
$('.open-resource').click(function() {
try {
if (!ensureViewerWindowReady()) {
return;
}
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});
} catch (e) {
console.error('Resource load failed', e);
showUserMessage('Resource load failed: ' + (e.message || 'Unexpected error.'), 'danger');
}
});
$('.send-message').click(function() {
var url = $(this).data('target');
bc.postMessage(url);
})
$('#open-viewer-local').click(function() {
$("#open-viewer-local").removeClass("flash-button");
viewerConnected = false;
try {
localStorage.removeItem('rad_local_viewer_popout_state');
} catch (e) {
console.warn('Unable to clear previous viewer state', e);
}
const opened = openSecondaryWindow("{% url 'atlas:collection_viewer_local_progressive' %}");
if (!opened) {
showUserMessage('Unable to open viewer window. Please allow pop-ups for this site and try again.', 'danger');
return;
}
showUserMessage('Viewer window opened. Load a case to begin. If nothing appears, check pop-up blockers and re-open the viewer.', 'info');
//bc.postMessage(url);
})
$('#view-series').click(function() {
$(".series-detail").attr("open", (_, attr) => !attr);
//document.getElementsByClassName("series-detail").toggleAttribute("open");
//bc.postMessage(url);
})
});
function openSecondaryWindow(url) {
//return win2 = window.open(url,'secondary','width=300,height=100');
win2 = window.open(
url,
'secondary',
'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=1200,height=800'
);
return win2;
}
</script>
{{ form.media }}
{% endblock %}
{% block css %}
<style type="text/css">
#loading-case {
display: none;
color: red;
}
@keyframes glowing {
0% {
box-shadow: 0 0 20px #a600ff;
}
50% {
box-shadow: 0 0 40px #52057b;
}
100% {
box-shadow: 0 0 20px #a600ff;
}
}
.flash-button {
animation: glowing 1300ms infinite;
}
#case-details {
padding-bottom: 20px;
}
.title {
font-weight: bold;
}
.edit-link-case, .edit-link-series, .edit-link-displayset {
vertical-align: middle;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
{% endblock css %}
@@ -54,6 +54,20 @@
{% endfor %}
</dd>
{% if can_merge %}
<dt class="col-sm-4">Edit relationships</dt>
<dd class="col-sm-8">
<p>
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#relationshipCollapse" role="button" aria-expanded="false" aria-controls="relationshipCollapse">Show relationship editor</a>
</p>
<div class="collapse" id="relationshipCollapse">
<div class="card card-body">
{% include 'atlas/partials/_condition_relationships.html' %}
</div>
</div>
</dd>
{% endif %}
<dt class="col-sm-4">RCR condition</dt>
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
+46 -10
View File
@@ -38,6 +38,11 @@
<h5>Viva</h5>
<p>A Viva collection is intended for oral examination practice. Cases in this collection can be used to simulate viva-style assessments. These are designed to be presented by the "examiner" and features a number of features to make this as easy as possible.</p>
<h5>Viva (Local Viewer)</h5>
<p>The local viva view opens a linked DV3D viewer in a separate window and lets you load complete cases, individual series, resources, and display sets without leaving the control page.</p>
<p>Use "Open Viewer Local" first, then use the Load buttons in the cases panel. The right-hand current-case panel tracks the active case details and injected question block for discussion.</p>
<p>Use "Enable Edit Mode" in the local viva page when you need quick access to case, series, or display-set edit links during preparation.</p>
<h2>Uploading/creating cases</h2>
<p>If set up, the easiest way to upload cases is to use the uploader tool. This can export, anonnyimize and upload cases from Insignia Insight, however it may need setting up manually. If this is not possible you can upload files directly to the site.</p>
<div class="alert alert-warning" role="alert">
@@ -56,19 +61,50 @@
<p>To do so you will have to first export the files from the PACs system.</p>
<p>Once uploaded the series will be available to be imported into cases
<h2>Cases</h2>
<h3>Ordering dicoms within a series</h3>
<p>Depending on how dicoms are exported images within a stack may appear in the wrong order. This is apparent when viewing the stacks/series as jumping of images.</p>
<p>In most cases this can be fixed by re-ordering based on series metadata (usually slice location).</p>
<p>It is possible to reorder all the stacks in a case at once, or to reorder individual stacks.</p>
<h4>Reorder all stacks in a case</h4>
<p>Go to the case page, click "Manage Series" then "Order dicoms by slice location". This will reorder all the stacks in the case based on slice location.</p>
<h4>Reorder individual stacks</h4>
<p>More ordering options (if the above does not work) are available on the series page.</p>
<p>These can be accessed by opening the "Series info" section and selecting the appropriate button.</p>
<h2>Cases</h2>
<h3>Ordering dicoms within a series</h3>
<p>Depending on how dicoms are exported images within a stack may appear in the wrong order. This is apparent when viewing the stacks/series as jumping of images.</p>
<p>In most cases this can be fixed by re-ordering based on series metadata (usually slice location).</p>
<p>It is possible to reorder all the stacks in a case at once, or to reorder individual stacks.</p>
<h4>Reorder all stacks in a case</h4>
<p>Go to the case page, click "Manage Series" then "Order dicoms by slice location". This will reorder all the stacks in the case based on slice location.</p>
<h4>Reorder individual stacks</h4>
<p>More ordering options (if the above does not work) are available on the series page.</p>
<p>These can be accessed by opening the "Series info" section and selecting the appropriate button.</p>
<h3>Surveys & Feedback</h3>
<p>The platform supports reusable, shareable, and mandatory feedback forms and surveys that can be configured by administrators to assess educational improvement and collect feedback.</p>
<h5>Managing Surveys (Staff/Admins)</h5>
<ul>
<li><strong>Create Surveys:</strong> Navigate to the <em>Survey Dashboard</em> to create a survey, customize its name, description, decide if responses should be anonymous, and specify:
<ul>
<li><em>Before Text:</em> Custom instructions shown to candidates before they begin.</li>
<li><em>After Text:</em> Custom thank-you message shown upon submission.</li>
</ul>
</li>
<li><strong>Question Types & Validation:</strong> You can add questions of multiple types:
<ul>
<li><em>Free Text:</em> For qualitative comments.</li>
<li><em>Multiple Choice:</em> For single-select options (entered one option per line).</li>
<li><em>Rating Scale:</em> 1-to-5 Likert scale ratings.</li>
<li><em>Integer / Number:</em> Enforces numerical input. You can set custom minimum/maximum validation rules.</li>
<li><em>Date:</em> Enforces date input using a calendar picker. You can configure custom minimum/maximum date range limits.</li>
</ul>
</li>
<li><strong>Mandatory & Optional Flows:</strong> You can assign surveys as <em>Pre-take</em> or <em>Post-take</em> surveys in collection/exam edit screens:
<ul>
<li><em>Mandatory:</em> Candidates must fill out the pre-survey to start, and must fill out the post-survey before they can view their results/scores.</li>
<li><em>Optional:</em> Candidates will be prompted to fill out the surveys, but can choose to skip them using the "Skip Survey" option.</li>
</ul>
</li>
<li><strong>Analytics & CSV Export:</strong> The <em>Analytics Dashboard</em> displays rating averages, progress bar breakdowns, integer statistics, and text answers. You can filter results by survey source (Pre-take, Post-take, or Standalone) and target exams/collections. Results can be exported as a filtered CSV.</li>
</ul>
<h5>Taking Standalone/General Surveys</h5>
<p>Surveys can also be distributed as standalone links (e.g. <code>/survey/take/&lt;id&gt;/</code>) for general platform feedback or course evaluation not tied to a specific exam.</p>
{% endblock %}
@@ -1,4 +1,5 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
@@ -15,9 +16,204 @@
{% if direct_next %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_next.pk %}">Next in chain</a>
{% endif %}
{% if can_edit_chain and not show_case_editor %}
<a class="btn btn-outline-info" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_editor=1">Open case series editor</a>
{% endif %}
{% if show_case_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide case series editor</a>
{% endif %}
{% if can_edit_chain and not show_study_dates_editor %}
<a class="btn btn-outline-primary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_dates=1">Open study dates editor</a>
{% endif %}
{% if show_study_dates_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide study dates editor</a>
{% endif %}
{% if can_edit_chain and not show_reorder_editor %}
<a class="btn btn-outline-warning" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_reorder=1">Open case reorder editor</a>
{% endif %}
{% if show_reorder_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide case reorder editor</a>
{% endif %}
</div>
</div>
{% if can_edit_chain and show_study_dates_editor %}
<div class="card border-secondary-subtle shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h5 mb-1">Study Dates</h2>
<p class="text-muted mb-0">Edit each case date directly, or use the copy action to apply one case date to the rest of the linked chain.</p>
</div>
{% if anchor_date %}
<div class="small text-muted">Relative offsets are measured from {{ anchor_date|date:"Y-m-d" }}.</div>
{% endif %}
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="form_action" value="study_dates">
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">Case</th>
<th scope="col" class="text-nowrap">Study date</th>
<th scope="col" class="text-nowrap">Relative</th>
<th scope="col" class="text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{% for row in linked_case_rows %}
<tr class="{% if row.is_current %}table-primary{% endif %}">
<td>
<div class="fw-semibold">{{ row.case.title|default:"Untitled case" }}</div>
<div class="small text-muted">Case {{ row.case.pk }}{% if row.is_current %} · current{% endif %}</div>
</td>
<td style="min-width: 14rem;">
<input
type="date"
class="form-control form-control-sm"
name="study_date_{{ row.case.pk }}"
value="{% if row.study_date %}{{ row.study_date|date:'Y-m-d' }}{% endif %}"
{% if not row.can_edit %}disabled{% endif %}
>
</td>
<td class="text-nowrap">
{% if row.relative_label %}
{{ row.relative_label.text }}
{% else %}
<span class="text-muted">No anchor</span>
{% endif %}
</td>
<td class="text-nowrap">
<button
type="submit"
class="btn btn-sm btn-outline-info"
name="apply_to_all_from"
value="{{ row.case.pk }}"
{% if not row.can_edit %}disabled{% endif %}
>
Copy to others
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2 mt-3">
<button type="submit" class="btn btn-primary">Save study dates</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_edit_chain and show_reorder_editor %}
<div class="card border-warning-subtle shadow-sm mb-4">
<div class="card-body">
<h2 class="h5 mb-1 text-warning-emphasis">Reorder Cases</h2>
<p class="text-muted mb-3">Drag and drop cases to reorder them, or use the Up/Down buttons, then click Save.</p>
<form method="post" id="reorder-cases-form">
{% csrf_token %}
<input type="hidden" name="form_action" value="reorder_cases">
<ul id="cases-reorder-list" class="list-group mb-3">
{% for linked_case in chain_cases %}
<li class="list-group-item d-flex justify-content-between align-items-center {% if linked_case.pk == current_case.pk %}border-primary{% endif %}" data-case-id="{{ linked_case.pk }}">
<span>
<i class="bi bi-grip-vertical me-2 text-muted drag-handle" style="cursor: grab;"></i>
<span class="fw-semibold">Case {{ linked_case.pk }}:</span> {{ linked_case.title|default:"Untitled case" }}
{% if linked_case.pk == current_case.pk %}<span class="badge bg-primary ms-1">Current</span>{% endif %}
</span>
<span>
<button type="button" class="btn btn-sm btn-outline-secondary move-up"></button>
<button type="button" class="btn btn-sm btn-outline-secondary move-down"></button>
</span>
<input type="hidden" name="case_order" value="{{ linked_case.pk }}">
</li>
{% endfor %}
</ul>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-warning">Save Case Order</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if show_case_editor and case_editor_formset %}
<div class="card border-info-subtle shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h5 mb-1">Case Series Editor</h2>
<p class="text-muted mb-0">Edit full case details across this linked chain in one pass, then optionally copy a single field from one case to every other case.</p>
</div>
<div class="small text-muted">
Editable: {{ editable_case_count }}
{% if locked_case_count %} · Read-only: {{ locked_case_count }}{% endif %}
</div>
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="form_action" value="case_series_editor">
{{ case_editor_formset.management_form }}
<div class="row g-2 align-items-end mb-3">
<div class="col-md-4">
<label class="form-label form-label-sm mb-1">Copy field from case</label>
<select class="form-select form-select-sm" name="apply_from_case">
<option value="">No field propagation</option>
{% for row in case_editor_rows %}
<option value="{{ row.case.pk }}" {% if case_editor_selected_source == row.case.pk|stringformat:'s' %}selected{% endif %}>
{{ row.case.pk }} · {{ row.case.title|default:"Untitled case" }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<label class="form-label form-label-sm mb-1">Field to copy to all linked cases</label>
<select class="form-select form-select-sm" name="apply_field">
<option value="">Choose a field</option>
{% for value, label in case_editor_apply_field_choices %}
<option value="{{ value }}" {% if case_editor_selected_field == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<div class="small text-muted">Apply runs after saving any edits you make below.</div>
</div>
</div>
<div class="accordion" id="linked-case-editor-accordion">
{% for row in case_editor_rows %}
<div class="accordion-item mb-2 border-secondary-subtle">
<h2 class="accordion-header" id="editor-heading-{{ row.case.pk }}">
<button class="accordion-button {% if not row.is_current %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#editor-body-{{ row.case.pk }}" aria-expanded="{% if row.is_current %}true{% else %}false{% endif %}" aria-controls="editor-body-{{ row.case.pk }}">
<span class="fw-semibold">Case {{ row.case.pk }} · {{ row.case.title|default:"Untitled case" }}</span>
{% if row.is_current %}<span class="badge text-bg-primary ms-2">Current</span>{% endif %}
</button>
</h2>
<div id="editor-body-{{ row.case.pk }}" class="accordion-collapse collapse {% if row.is_current %}show{% endif %}" aria-labelledby="editor-heading-{{ row.case.pk }}" data-bs-parent="#linked-case-editor-accordion">
<div class="accordion-body">
{{ row.form|crispy }}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-info">Save case series editor changes</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="row g-3 mb-4">
<div class="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100">
@@ -87,7 +283,7 @@
{% 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 %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Earlier in the linked chain" relative_label=linked_case.relative_label is_current=False %}
{% endfor %}
</div>
{% else %}
@@ -105,7 +301,7 @@
{% 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 %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Later in the linked chain" relative_label=linked_case.relative_label is_current=False %}
{% endfor %}
</div>
{% else %}
@@ -116,4 +312,92 @@
</div>
</div>
</div>
{% endblock %}
{% block css %}
<style>
#cases-reorder-list .dragging {
opacity: 0.55;
box-shadow: 0 4px 16px rgba(255, 193, 7, 0.35);
}
#cases-reorder-list .drag-over {
border: 2px dashed #ffc107 !important;
}
</style>
{% endblock %}
{% block js %}
<script>
document.addEventListener("DOMContentLoaded", function() {
// Move up/down logic
document.querySelectorAll("#cases-reorder-list .move-up, #cases-reorder-list .move-down").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.preventDefault();
const li = btn.closest("li");
if (btn.classList.contains("move-up") && li.previousElementSibling) {
li.parentNode.insertBefore(li, li.previousElementSibling);
}
if (btn.classList.contains("move-down") && li.nextElementSibling) {
li.parentNode.insertBefore(li.nextElementSibling, li);
}
});
});
// On submit, update the hidden inputs to match the new order
const reorderCasesForm = document.getElementById("reorder-cases-form");
if (reorderCasesForm) {
reorderCasesForm.addEventListener("submit", function(e) {
const lis = document.querySelectorAll("#cases-reorder-list li");
lis.forEach((li, idx) => {
const input = li.querySelector("input[name='case_order']");
if (input) {
input.setAttribute("name", "case_order_" + idx);
}
});
});
}
// Drag and drop reordering for cases list
const reorderList = document.getElementById("cases-reorder-list");
let draggedItem = null;
if (reorderList) {
reorderList.querySelectorAll("li").forEach(function(li) {
li.setAttribute("draggable", "true");
li.addEventListener("dragstart", function(e) {
draggedItem = li;
li.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
li.addEventListener("dragend", function() {
draggedItem = null;
li.classList.remove("dragging");
reorderList.querySelectorAll("li").forEach(el => el.classList.remove("drag-over"));
});
li.addEventListener("dragover", function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
li.classList.add("drag-over");
});
li.addEventListener("dragleave", function() {
li.classList.remove("drag-over");
});
li.addEventListener("drop", function(e) {
e.preventDefault();
li.classList.remove("drag-over");
if (draggedItem && draggedItem !== li) {
const rect = li.getBoundingClientRect();
const next = (e.clientY - rect.top) / (rect.bottom - rect.top) > 0.5;
li.parentNode.insertBefore(draggedItem, next ? li.nextElementSibling : li);
}
});
});
}
});
</script>
{% endblock %}
@@ -0,0 +1,27 @@
{% if largest_rows %}
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>Case</th>
<th class="text-end">Series</th>
<th class="text-end">Total size</th>
</tr>
</thead>
<tbody>
{% for row in largest_rows %}
<tr>
<td>
<a href="{% url 'atlas:case_detail' row.case.pk %}">{{ row.case.title }}</a>
<div class="text-muted small">#{{ row.case.pk }}</div>
</td>
<td class="text-end">{{ row.series_count }}</td>
<td class="text-end fw-semibold">{{ row.human_total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small">No case-size data available.</div>
{% endif %}
@@ -0,0 +1,33 @@
{% if recent_cases %}
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>Case</th>
<th>Created</th>
<th>Authors</th>
</tr>
</thead>
<tbody>
{% for case in recent_cases %}
<tr>
<td>
<a href="{% url 'atlas:case_detail' case.pk %}">{{ case.title }}</a>
<div class="text-muted small">#{{ case.pk }}</div>
</td>
<td class="small">{{ case.created_date|date:"Y-m-d H:i" }}</td>
<td class="small">
{% for author in case.author.all %}
<a class="badge text-bg-secondary me-1 text-decoration-none" href="{% url 'atlas:case_view' %}?author={{ author.pk }}">{{ author.username }}</a>
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small">No recent cases found.</div>
{% endif %}
@@ -16,6 +16,13 @@
{% for ss in case.subspecialty.all %}
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
{% endfor %}
{% with case.get_series_info as series_info %}
{% if series_info.in_series %}
<span class="badge bg-info text-dark ms-1" title="Linked case series position: {{ series_info.position }} of {{ series_info.total }}">
Series ({{ series_info.position }}/{{ series_info.total }})
</span>
{% endif %}
{% endwith %}
</div>
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
</div>
@@ -0,0 +1,13 @@
<form method="post" class="condition-relationship-form">
{% csrf_token %}
<input type="hidden" name="action" value="update_relationships">
<div class="mb-3">
<label class="form-label" for="id_parents">Parents</label>
{{ relationship_form.parents }}
</div>
<div class="mb-3">
<label class="form-label" for="id_children">Children</label>
{{ relationship_form.children }}
</div>
<button class="btn btn-sm btn-primary" type="submit">Save relationships</button>
</form>
@@ -29,7 +29,11 @@
{% endif %}
</div>
<div class="text-sm-end small text-muted">
{% if linked_case.published_date %}
{% if relative_label %}
<div>Timeline {{ relative_label.text }}</div>
{% elif linked_case.get_effective_study_date %}
<div>Study date {{ linked_case.get_effective_study_date|date:"Y-m-d" }}</div>
{% elif 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>
@@ -6,7 +6,8 @@
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">{{case.title}}</h5>
<div>
<div class="d-flex gap-1">
<span class="badge {% if relation_type == 'PR' %}bg-info{% else %}bg-warning text-dark{% endif %}">{% if relation_type == 'PR' %}Prior{% else %}Future{% endif %}</span>
{% if added %}
<span class="badge bg-success">Added</span>
{% else %}
@@ -15,7 +16,17 @@
</div>
</div>
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
{% if relation %}
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
{% endif %}
<p class="card-text small text-muted mb-2">
Study date:
{% if case.study_date %}
<span class="fw-medium">{{ case.study_date }}</span>
{% else %}
<span class="fw-medium">Unknown</span>
{% endif %}
</p>
<div class="mb-3 small">{{case.get_series_blocks|safe}}</div>
@@ -37,6 +48,12 @@
<div class="col-12">
<input class="form-control form-control-sm" type="text" name="relation" value="{{relation}}" placeholder="Relation to case" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="use_study_date_relation" value="1" id="prior_use_date_{{case.id}}" />
<label class="form-check-label small" for="prior_use_date_{{case.id}}">Use study dates to generate relation text</label>
</div>
</div>
<div class="col-8">
<select class="form-select form-select-sm" name="prior_visibility" id="prior_visibility_{{case.id}}">
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
@@ -1,6 +1,6 @@
<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 }}">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}"{% if tags.StudyInstanceUID %} data-study-uid="{{ tags.StudyInstanceUID }}"{% elif tags.StudyID %} data-study-uid="{{ tags.StudyID }}"{% endif %}>
<h5 class="series-title">
{{ tags.SeriesDescription|default:series }}
{% if series_is_partial %}
@@ -1,10 +1,10 @@
{% load django_htmx %}
{# Sharing panel for a CidUserExam attempt.
{% comment %} Sharing panel for a CidUserExam attempt.
Context:
cid_user_exam CidUserExam instance
sharing_info dict from cid_user_exam.get_sharing_info()
is_owner bool: current user is the attempt owner
#}
{% endcomment %}
<div id="sharing-panel-{{ cid_user_exam.pk }}" class="card border-secondary mt-3">
<div class="card-header py-2 px-3 d-flex justify-content-between align-items-center">
<span class="fw-semibold small">Result Sharing</span>
@@ -107,7 +107,7 @@
name="q"
autocomplete="off"
hx-get="{% url 'atlas:collection_exam_user_search' cid_user_exam.pk %}"
hx-trigger="input changed delay:300ms, focus"
hx-trigger="input delay:300ms, focus"
hx-target="#sharing-search-results-{{ cid_user_exam.pk }}"
hx-swap="innerHTML">
<div id="sharing-search-results-{{ cid_user_exam.pk }}" class="mt-1"></div>
@@ -0,0 +1,42 @@
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared - collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
- you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they 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 %}
@@ -1,9 +1,10 @@
{# HTMX partial: user search results for the sharing panel.
{% comment %}
HTMX partial: user search results for the sharing panel.
Context:
users list of User objects matching the query
cid_user_exam CidUserExam instance
q search query string
#}
{% endcomment %}
{% if users %}
<ul class="list-group list-group-flush small border rounded">
{% for u in users %}
@@ -24,4 +25,6 @@
</ul>
{% elif q|length >= 2 %}
<div class="small text-muted fst-italic">No matching users found.</div>
{% else %}
<div class="small text-muted fst-italic">Please enter at least 2 characters to search.</div>
{% endif %}
@@ -5,40 +5,73 @@
<h5 class="card-title">Your collections</h5>
<h6 class="mt-3">Available to start</h6>
<ul class="list-unstyled">
<ul class="list-group list-group-sm mb-3">
{% for collection in available_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
{% if collection.description %}<small class="text-muted">{{ collection.description }}</small>{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
<div>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
</div>
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
{% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
<input type="hidden" name="object_id" value="{{ collection.pk }}">
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
</button>
</form>
</li>
{% empty %}
<li class="text-muted">No collections available to start.</li>
<li class="list-group-item text-muted">No collections available to start.</li>
{% endfor %}
</ul>
<h6 class="mt-3">In progress</h6>
<ul class="list-unstyled">
<ul class="list-group list-group-sm mb-3">
{% for collection in in_progress_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-warning text-dark ms-2">In progress</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
<div>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-warning text-dark ms-2">In progress</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
</div>
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
{% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
<input type="hidden" name="object_id" value="{{ collection.pk }}">
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
</button>
</form>
</li>
{% empty %}
<li class="text-muted">No in-progress collections.</li>
<li class="list-group-item text-muted">No in-progress collections.</li>
{% endfor %}
</ul>
<h6 class="mt-3">Finished</h6>
<ul class="list-unstyled">
<ul class="list-group list-group-sm">
{% for collection in finished_collections %}
<li>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-success ms-2">Completed</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center {% if collection.is_hidden %}opacity-50 bg-secondary bg-opacity-10{% endif %}">
<div>
<a href="{% url 'atlas:collection_take_start' collection.pk %}">{{ collection.name }}</a>
<span class="badge bg-success ms-2">Completed</span>
{% if collection.description %}<small class="text-muted"> — {{ collection.description }}</small>{% endif %}
{% if collection.is_hidden %}<span class="badge bg-warning text-dark ms-2">Hidden</span>{% endif %}
</div>
<form method="post" action="{% url 'generic:toggle_hide_item' %}" hx-post="{% url 'generic:toggle_hide_item' %}" class="d-inline">
{% csrf_token %}
<input type="hidden" name="content_type_id" value="{{ collection.content_type_id }}">
<input type="hidden" name="object_id" value="{{ collection.pk }}">
<button type="submit" class="btn btn-sm btn-link text-muted p-0" title="{% if collection.is_hidden %}Unhide collection{% else %}Hide collection{% endif %}">
<i class="bi {% if collection.is_hidden %}bi-eye-slash-fill text-warning{% else %}bi-eye{% endif %}" style="font-size: 1.15rem;"></i>
</button>
</form>
</li>
{% empty %}
<li class="text-muted">No finished collections.</li>
<li class="list-group-item text-muted">No finished collections.</li>
{% endfor %}
</ul>
@@ -0,0 +1,21 @@
{% load crispy_forms_tags %}
<form hx-post="{% url 'atlas:case_create_resource_inline' pk=case.pk %}"
hx-encoding="multipart/form-data"
hx-target="#case-resources-list"
hx-swap="innerHTML"
class="bg-dark p-3 rounded border border-secondary text-white my-3">
{% csrf_token %}
<h5>Create New Resource</h5>
{{ form|crispy }}
<div class="form-check mb-3">
<input type="checkbox" name="pre_review" class="form-check-input" id="id_pre_review">
<label class="form-check-label" for="id_pre_review">Pre-review (show before case is taken)</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-sm btn-success">Save and Attach</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
hx-get="{% url 'atlas:resource_search_panel' %}?case_id={{ case.pk }}"
hx-target="#resource-search-panel-placeholder"
hx-swap="innerHTML">Cancel</button>
</div>
</form>
@@ -1,5 +1,13 @@
<div class="case-search-widget">
{% with input_id=input_id|default:'case-search-input' target_id=target_id|default:'case-search-results' %}
<style>
.case-search-loading {
display: none;
}
.case-search-widget.is-loading .case-search-loading {
display: inline-flex;
}
</style>
<label for="{{ input_id }}" class="form-label">Search cases</label>
{% if collection %}
<input type="hidden" id="{{ input_id }}-collection" name="collection" value="{{ collection.pk }}">
@@ -12,9 +20,15 @@
hx-get="{% url 'atlas:case_search' %}"
hx-include="closest .case-search-widget"
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
hx-trigger="input delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="case-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching cases...</span>
</div>
<div id="{{ target_id }}" class="mt-2">
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection show_select_button=show_select_button %}
@@ -27,7 +41,14 @@
try {
var input = document.getElementById('{{ input_id|default:"case-search-input" }}');
if (!input) return;
var widget = input.closest('.case-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
@@ -36,12 +57,13 @@
var url = "{% url 'atlas:case_search' %}?q=" + q + ({% if collection %} "&collection={{ collection.pk }}" {% else %} '' {% endif %}) + ({% if show_select_button %} "&show_select=1" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"case-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"case-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('case search fetch error', e); });
}).catch(function(e){ console.error('case search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
@@ -61,17 +83,29 @@
var targetEl = document.getElementById(targetId);
if (!container || !targetEl) return;
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === inputEl) {
container.classList.add('is-loading');
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === inputEl) {
container.classList.remove('is-loading');
}
});
// Delegate clicks on result items and dispatch a scoped custom event
targetEl.addEventListener('click', function(e){
var item = e.target.closest('.list-group-item');
if (!item || !targetEl.contains(item)) return;
// Explicit select action button should always select the row.
if (e.target.closest('.case-select-btn')) {
var selectBtn = e.target.closest('.case-select-btn');
if (selectBtn) {
e.preventDefault();
} else {
// Ignore other interactive elements inside the item (links, buttons, forms, inputs)
if (e.target.closest('a,button,form,input')) return;
// Ignore all non-select interactions so rows are passive unless
// explicit selection controls are present.
return;
}
var casePk = item.getAttribute('data-case-pk');
@@ -3,6 +3,10 @@
<div id="current-case-meta" data-current-case="{{ case.pk }}" data-current-case-title="{{ case.title|escape }}" style="display:none;"></div>
{% endif %}
{% with input_id=input_id|default:'displayset-search-input' target_id=target_id|default:'displayset-search-results' %}
<style>
.displayset-search-loading { display: none; }
.displayset-search-widget.is-loading .displayset-search-loading { display: inline-flex; }
</style>
<label for="{{ input_id }}" class="form-label">Search display sets</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search display sets..."
@@ -11,8 +15,14 @@
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="displayset-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching display sets...</span>
</div>
<div class="mt-1">
<span id="{{ input_id }}-case-badge" class="badge bg-secondary" style="display: {% if case %}inline-block{% else %}none{% endif %};">
{% if case %}Filtering: {{ case.title|escape }}{% endif %}
@@ -31,7 +41,14 @@
try {
var input = document.getElementById('{{ input_id|default:"displayset-search-input" }}');
if (!input) return;
var widget = input.closest('.displayset-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
@@ -40,16 +57,28 @@
var url = "{% url 'atlas:displayset_search' %}?q=" + q + ({% if case %} "&case={{ case.pk }}" {% else %} '' {% endif %});
var target = '#{{ target_id|default:"displayset-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"displayset-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('displayset search fetch error', e); });
}).catch(function(e){ console.error('displayset search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === input) {
setLoading(true);
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === input) {
setLoading(false);
}
});
} catch(e) { console.error('displayset search widget init error', e); }
})();
</script>
@@ -1,5 +1,9 @@
<div class="finding-search-widget">
{% with input_id=input_id|default:'finding-search-input' target_id=target_id|default:'finding-search-results' %}
<style>
.finding-search-loading { display: none; }
.finding-search-widget.is-loading .finding-search-loading { display: inline-flex; }
</style>
<label for="{{ input_id }}" class="form-label">Search findings</label>
<input id="{{ input_id }}" name="q" class="form-control" type="search"
placeholder="Type to search findings..."
@@ -8,8 +12,14 @@
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
hx-target="#{{ target_id }}"
hx-trigger="keyup changed delay:400ms"
hx-indicator="#{{ input_id }}-loading"
autocomplete="off">
<div id="{{ input_id }}-loading" class="finding-search-loading align-items-center gap-2 mt-2 text-muted small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Searching findings...</span>
</div>
<div class="mt-1">
<span id="{{ input_id }}-case-badge" class="badge bg-secondary" style="display: {% if case %}inline-block{% else %}none{% endif %};">
{% if case %}Filtering: {{ case.title|escape }}{% endif %}
@@ -28,8 +38,14 @@
try {
var input = document.getElementById('{{ input_id|default:"finding-search-input" }}');
if (!input) return;
var widget = input.closest('.finding-search-widget');
var timer = null;
function setLoading(on) {
if (!widget) return;
widget.classList.toggle('is-loading', !!on);
}
// If server provided a case via inclusion, ensure widget sets dataset and nearby textarea dataset
var meta = document.getElementById('current-case-meta');
var casePk = meta ? meta.getAttribute('data-current-case') : null;
@@ -70,16 +86,28 @@
var url = "{% url 'atlas:finding_search' %}?q=" + q + caseParam;
var target = '#{{ target_id|default:"finding-search-results" }}';
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"finding-search-input" }}-loading' });
} else {
setLoading(true);
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.querySelector(target);
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('finding search fetch error', e); });
}).catch(function(e){ console.error('finding search fetch error', e); }).finally(function(){ setLoading(false); });
}
} catch(e) { console.error(e); }
}, 400);
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
if (event.target === input) {
setLoading(true);
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.target === input) {
setLoading(false);
}
});
} catch(e) { console.error('finding search widget init error', e); }
})();
</script>
@@ -35,6 +35,14 @@
<label class="form-check-label small" for="resource-open-access-{{ case.pk }}">Open</label>
</div>
</div>
<div class="col-auto ms-auto">
<button type="button" class="btn btn-sm btn-outline-success"
hx-get="{% url 'atlas:case_create_resource_inline' pk=case.pk %}"
hx-target="#resource-search-panel"
hx-swap="outerHTML">
+ New Resource
</button>
</div>
</div>
<div id="resource-search-results" class="mt-2"></div>
@@ -2,7 +2,7 @@
<div class="btn-toolbar" role="toolbar" aria-label="Case actions">
<div class="btn-group me-2 mb-2" role="group" aria-label="Primary actions">
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_update' pk=case.pk %}?next={{ request.get_full_path|urlencode }}" title="Edit the Case">Edit</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' pk=case.pk %}" title="View and edit case display sets">Display sets</a>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
{% extends "atlas/base.html" %}
{% extends base_template|default:"atlas/base.html" %}
<!-- {% load static from static %} -->
{% load crispy_forms_tags %}
+9
View File
@@ -5,6 +5,15 @@
<h2>Resources</h2>
<button class="btn btn-primary mb-3">
<a href="{% url 'atlas:resource_create' %}">Create New Resource</a>
</button>
{% comment %} <details>
<summary>Search Resources</summary>
{% include "atlas/partials/resource_search_panel.html" %}
</details>
{% endcomment %}
<form>
{% render_table table %}
</form>
@@ -0,0 +1,320 @@
{% extends 'base.html' %}
{% load static %}
{% block css %}
{{ block.super }}
<style>
.recon-advanced-viewer {
width: 100%;
min-height: 62vh;
box-sizing: border-box;
background: #111;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow: hidden;
height: 62vh;
}
.recon-advanced-help {
font-size: 0.9rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="mb-1">Advanced Series Reconstruction</h2>
<p class="text-muted mb-0">Plan reconstruction from interactive DV3D MPR viewports with crosshair-linked navigation.</p>
</div>
<a class="btn btn-outline-secondary btn-sm" href="{{ series.get_absolute_url }}">
<i class="bi bi-arrow-left me-1"></i> Back to series
</a>
</div>
<div class="alert alert-info recon-advanced-help">
<div><strong>Workflow:</strong> the page auto-configures a 3-volume MPR layout on load and attempts to enable crosshair-linked navigation. Adjust crosshairs as needed, then generate reconstructions.</div>
</div>
<div id="advanced_recon_root" class="dicom-viewer-root recon-advanced-viewer" data-images='{{ image_ids_json|safe }}'></div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-body">
<form
id="advanced-recon-form"
class="row g-3"
method="post"
action="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-post="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-target="#advanced-recon-feedback"
hx-swap="innerHTML"
hx-indicator="#advanced-recon-indicator"
>
{% csrf_token %}
<input type="hidden" name="operation" value="reconstruct">
<input type="hidden" name="recon_viewerstate_json" id="id_recon_viewerstate_json">
<div class="col-12 col-lg-4">
<div class="fw-semibold mb-2">Output planes</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-axial" name="recon_planes" value="axial" checked>
<label class="form-check-label" for="adv-recon-axial">Axial</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-coronal" name="recon_planes" value="coronal" checked>
<label class="form-check-label" for="adv-recon-coronal">Coronal</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="adv-recon-sagittal" name="recon_planes" value="sagittal" checked>
<label class="form-check-label" for="adv-recon-sagittal">Sagittal</label>
</div>
</div>
<div class="col-12 col-lg-4">
<label class="form-label" for="adv-recon-thickness">Slab thickness (mm)</label>
<input id="adv-recon-thickness" type="number" step="0.1" min="0.1" name="recon_slice_thickness" class="form-control" value="3.0">
<label class="form-label mt-2" for="adv-recon-spacing">Slice spacing (mm)</label>
<input id="adv-recon-spacing" type="number" step="0.1" min="3" name="recon_slice_spacing" class="form-control" value="3.0">
<small class="text-muted d-block mt-1">Spacing is user-controlled and defaults to 3.0 mm minimum to reduce generated stack size.</small>
</div>
<div class="col-12 col-lg-4">
<label class="form-label" for="adv-recon-mode">Projection mode</label>
<select id="adv-recon-mode" name="recon_thickness_mode" class="form-select">
<option value="mean" selected>Average intensity</option>
<option value="max">Maximum intensity</option>
<option value="min">Minimum intensity</option>
</select>
<div class="d-flex flex-wrap gap-2 mt-3">
<button type="button" id="apply-mpr-layout" class="btn btn-outline-primary btn-sm">Apply MPR layout</button>
<button type="submit" class="btn btn-primary btn-sm">Generate recon series</button>
</div>
</div>
</form>
</div>
<div class="card-footer d-flex align-items-center gap-2">
<span id="advanced-recon-indicator" class="spinner-border spinner-border-sm text-primary htmx-indicator" role="status" aria-hidden="true"></span>
<small class="text-muted">The current viewer state is captured at submit and attached to the reconstruction request.</small>
</div>
</div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h6 class="mb-0">Live Reconstruction Plan</h6>
<small id="advanced-plan-status" class="text-muted">Waiting for viewer telemetry...</small>
</div>
<div class="row g-2 small">
<div class="col-12 col-md-4"><strong>Viewport 1:</strong> <span id="advanced-plan-vp0">-</span></div>
<div class="col-12 col-md-4"><strong>Viewport 2:</strong> <span id="advanced-plan-vp1">-</span></div>
<div class="col-12 col-md-4"><strong>Viewport 3:</strong> <span id="advanced-plan-vp2">-</span></div>
</div>
<div class="row g-2 small mt-1">
<div class="col-12 col-md-6"><strong>Slab thickness:</strong> <span id="advanced-plan-thickness">3.0 mm</span></div>
<div class="col-12 col-md-6"><strong>Slice spacing:</strong> <span id="advanced-plan-spacing">3.0 mm</span></div>
</div>
</div>
</div>
<div id="advanced-recon-feedback" class="small mt-3"></div>
</div>
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer"></script>
<script>
(function () {
var ORIENTATION_LABELS = {
axial: 'Axial',
coronal: 'Coronal',
sagittal: 'Sagittal',
};
function getViewerApi(name) {
return window[name + '_advanced_recon_root'] || window[name + '_advanced_recon'] || window[name + '_root'] || window[name];
}
function parseViewerState(raw) {
if (!raw) {
return null;
}
if (typeof raw === 'string') {
try {
return JSON.parse(raw);
} catch (err) {
return null;
}
}
return raw;
}
function getDisplayOrientation(value) {
if (!value) {
return 'Unknown';
}
return ORIENTATION_LABELS[value] || String(value).charAt(0).toUpperCase() + String(value).slice(1);
}
function ensureMprLayout() {
var exportFn = getViewerApi('exportViewerState');
var importFn = getViewerApi('importViewerState');
if (typeof importFn !== 'function') {
return false;
}
var existing = parseViewerState(typeof exportFn === 'function' ? exportFn() : null) || {};
var stackIdx = Array.isArray(existing.stackIdx) ? existing.stackIdx : [];
var stacks = Array.isArray(existing.stacks) ? existing.stacks : [];
var baseStack = typeof stackIdx[0] === 'number' ? stackIdx[0] : (stacks.length > 0 ? 0 : null);
var nextState = {
grid: { rows: 1, cols: 3 },
modes: ['volume', 'volume', 'volume'],
orientations: ['axial', 'coronal', 'sagittal'],
volumeSliceIndices: [null, null, null],
};
if (stacks.length > 0 && baseStack !== null) {
nextState.stacks = stacks;
nextState.stackIdx = [baseStack, baseStack, baseStack];
}
try {
importFn(nextState);
return true;
} catch (err) {
console.warn('Failed to apply automatic MPR layout', err);
return false;
}
}
function tryEnableCrosshairs() {
var labels = ['Enable Cross Reference Lines', 'Enable Crosshairs'];
var root = document.getElementById('advanced_recon_root');
var scope = root ? root.parentElement || document : document;
var buttons = scope.querySelectorAll('button');
var clicked = false;
buttons.forEach(function (btn) {
if (clicked) {
return;
}
var text = (btn.textContent || '').trim();
if (!text) {
return;
}
for (var i = 0; i < labels.length; i++) {
if (text.indexOf(labels[i]) !== -1) {
btn.click();
clicked = true;
break;
}
}
});
return clicked;
}
function formatViewportPlan(state, idx) {
if (!state) {
return '-';
}
var modes = Array.isArray(state.modes) ? state.modes : [];
var orientations = Array.isArray(state.orientations) ? state.orientations : [];
var slices = Array.isArray(state.volumeSliceIndices) ? state.volumeSliceIndices : [];
var mode = modes[idx] || 'unknown';
var orientation = getDisplayOrientation(orientations[idx]);
var slice = slices[idx];
var sliceText = typeof slice === 'number' ? ('slice ' + slice) : 'slice n/a';
return orientation + ' (' + mode + ', ' + sliceText + ')';
}
function updateLivePlan() {
var exportFn = getViewerApi('exportViewerState');
var thicknessInput = document.getElementById('adv-recon-thickness');
var spacingInput = document.getElementById('adv-recon-spacing');
var state = parseViewerState(typeof exportFn === 'function' ? exportFn() : null);
var statusEl = document.getElementById('advanced-plan-status');
var vp0 = document.getElementById('advanced-plan-vp0');
var vp1 = document.getElementById('advanced-plan-vp1');
var vp2 = document.getElementById('advanced-plan-vp2');
var thicknessEl = document.getElementById('advanced-plan-thickness');
var spacingEl = document.getElementById('advanced-plan-spacing');
if (statusEl) {
statusEl.textContent = state ? 'Viewer telemetry active' : 'Waiting for viewer telemetry...';
}
if (vp0) {
vp0.textContent = formatViewportPlan(state, 0);
}
if (vp1) {
vp1.textContent = formatViewportPlan(state, 1);
}
if (vp2) {
vp2.textContent = formatViewportPlan(state, 2);
}
if (thicknessEl) {
var thicknessValue = thicknessInput && thicknessInput.value ? thicknessInput.value : '3.0';
thicknessEl.textContent = thicknessValue + ' mm';
}
if (spacingEl) {
var spacingValue = spacingInput && spacingInput.value ? spacingInput.value : '3.0';
spacingEl.textContent = spacingValue + ' mm';
}
}
var applyBtn = document.getElementById('apply-mpr-layout');
if (applyBtn) {
applyBtn.addEventListener('click', function () {
ensureMprLayout();
setTimeout(updateLivePlan, 400);
});
}
var form = document.getElementById('advanced-recon-form');
var hidden = document.getElementById('id_recon_viewerstate_json');
if (form && hidden) {
form.addEventListener('submit', function () {
var exportFn = getViewerApi('exportViewerState');
if (typeof exportFn !== 'function') {
hidden.value = '';
return;
}
try {
var state = exportFn();
hidden.value = typeof state === 'string' ? state : JSON.stringify(state || {});
} catch (err) {
hidden.value = '';
console.warn('Failed to capture viewer state', err);
}
});
}
var thicknessInput = document.getElementById('adv-recon-thickness');
var spacingInput = document.getElementById('adv-recon-spacing');
if (thicknessInput) {
thicknessInput.addEventListener('input', updateLivePlan);
}
if (spacingInput) {
spacingInput.addEventListener('input', updateLivePlan);
}
var autoAttempts = 0;
var autoTimer = setInterval(function () {
autoAttempts += 1;
var mprApplied = ensureMprLayout();
var crosshairEnabled = tryEnableCrosshairs();
updateLivePlan();
if ((mprApplied && crosshairEnabled) || autoAttempts >= 12) {
clearInterval(autoTimer);
}
}, 600);
setInterval(updateLivePlan, 700);
setTimeout(updateLivePlan, 1500);
})();
</script>
{% endblock %}
+14 -4
View File
@@ -216,10 +216,17 @@
<div class="card-header">Actions</div>
<div class="card-body d-flex flex-column gap-2">
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
<div class="dropdown">
<button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Order Images
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">By slice location</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">By instance number</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">By SeriesInstanceUID</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">By uploaded filename</a></li>
</ul>
</div>
<div hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-trigger="load"
hx-swap="outerHTML">
@@ -409,6 +416,9 @@
<div class="mt-2">
<button class="btn btn-sm btn-outline-primary" type="submit" name="operation" value="reconstruct">Generate recon series</button>
<a class="btn btn-sm btn-outline-info ms-1" href="{% url 'atlas:series_reconstruct_advanced' series_id=series.pk %}" target="_blank" rel="noopener">
Advanced reconstruction page
</a>
</div>
</div>
</form>
+276 -8
View File
@@ -1,7 +1,23 @@
{% extends 'atlas/base.html' %}
{% load case_widgets %}
{% load crispy_forms_tags %}
{% block content %}
<script>
(function($) {
if ($ && $.fn && $.fn.select2) {
var originalSelect2 = $.fn.select2;
$.fn.select2 = function(options) {
if (options && typeof options === 'object') {
if (this.closest('#caseSeriesModal').length > 0) {
options.dropdownParent = $('#caseSeriesModal');
}
}
return originalSelect2.apply(this, arguments);
};
}
})(window.jQuery);
</script>
<h2>Uploaded dicoms</h2>
User: {{user}}<br/>
<a href="{% url 'atlas:new_uploads' %}">Upload more dicoms</a><br/>
@@ -16,7 +32,97 @@
{% if series_list %}
<div id="series-list">
{% if grouped_series %}
{% if grouped_patients %}
{% for patient in grouped_patients %}
<div class="patient-block mb-4 p-3 border rounded" style="background: rgba(255,255,255,0.015); border-color: rgba(255,255,255,0.08) !important;">
<div class="patient-header d-flex flex-wrap align-items-center justify-content-between pb-2 mb-3 border-bottom border-secondary">
<span>
<i class="bi bi-person-badge me-2 text-info"></i>
<strong>Patient ID:</strong> <span class="badge bg-secondary font-monospace">{{ patient.patient_id }}</span>
</span>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary select-all-patient-btn" title="Select all series for this patient">Select Patient Series</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-patient-btn" title="Deselect all series for this patient">Deselect all</button>
</div>
</div>
{% for study_uid, study in patient.studies %}
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
<summary>
<strong>{{ study.description|default:"(no description)" }}</strong>
<small class="text-muted"> ({{ study_uid }}) — {{ study.series|length }} series</small>
<span class="ms-2">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" title="Select all in study">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" title="Deselect all in study">Deselect all</button>
</span>
</summary>
{% if study.partial_imported_uids %}
<div class="alert alert-warning py-2 mt-2 mb-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>
<strong>Partial import detected:</strong>
{{ study.partial_imported_count }} series in this study already exist.
The highlighted rows can finish importing the remaining images.
{% if study.matched_existing_series %}
<div class="small mt-1">
Existing series in Atlas:
{% for existing_series in study.matched_existing_series %}
<a href="{% url 'atlas:series_detail' existing_series.pk %}">{{ existing_series }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<button
type="button"
class="btn btn-sm btn-warning import-partial-series-btn"
data-series-uids="{{ study.partial_imported_uids|join:',' }}"
{% if study.preferred_case_id %}data-case-id="{{ study.preferred_case_id }}"{% endif %}
>Finish these imports</button>
</div>
{% endif %}
{% if study.import_suggestions %}
<div class="alert alert-info py-2 mt-2 mb-2">
<strong>Study already imported:</strong>
{% for suggestion in study.import_suggestions %}
<div class="d-flex flex-wrap align-items-center gap-2 mt-1">
<span>
Case:
<a href="{% url 'atlas:case_detail' suggestion.case.pk %}">{{ suggestion.case }}</a>
({{ suggestion.already_imported_count }} series already imported)
</span>
<button
type="button"
class="btn btn-sm btn-outline-primary import-missing-series-btn"
data-case-id="{{ suggestion.case.pk }}"
data-series-uids="{{ suggestion.missing_uids|join:',' }}"
>
Import remaining {{ suggestion.missing_uids|length }} series
</button>
</div>
{% endfor %}
</div>
{% endif %}
<ul class="study-series-list">
{% for series, n, tags, date in study.series %}
{% if series in study.partial_imported_uids %}
{% if case %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=study.preferred_case_id %}
{% endif %}
{% else %}
{% if case %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' %}
{% endif %}
{% endif %}
{% endfor %}
</ul>
</details>
{% endfor %}
</div>
{% endfor %}
{% elif grouped_series %}
{% for study_uid, study in grouped_series %}
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
<summary>
@@ -173,6 +279,7 @@
class="btn btn-primary"
>Import {% if case %}into case{% endif %}</button>
<button id="import-into-case-button" type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#caseSelectModal">Import into case…</button>
<button id="import-case-series-button" type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#caseSeriesModal" disabled>Import as case series…</button>
</div>
<div id="import-status" class="mt-3"></div>
@@ -231,6 +338,34 @@
</div>
</div>
<div class="modal fade" id="caseSeriesModal" aria-hidden="true" data-bs-focus="false">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import as linked case series</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="case-series-form">
{% csrf_token %}
<div class="modal-body">
<div class="alert alert-info small">
This creates one case per selected study, copies the same case details to each case, and links them with <em>previous case</em>.
</div>
{% crispy case_series_form %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<div id="case-series-submit-loading" class="d-none text-muted small me-2">
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
Creating linked cases...
</div>
<button id="case-series-submit-button" type="submit" class="btn btn-success">Create linked cases</button>
</div>
</form>
</div>
</div>
</div>
<script>
const importCaseId = {% if case %}{{ case.pk }}{% else %}null{% endif %};
@@ -238,15 +373,18 @@
function updateSelectionCount() {
const all = Array.from(document.querySelectorAll('input[name="selection"]'));
const selected = all.filter(cb => cb.checked && !cb.disabled).length;
const selectedStudies = new Set(all.filter(cb => cb.checked && !cb.disabled).map(cb => cb.dataset.studyUid).filter(Boolean));
const el = document.getElementById('selected-count');
if (el) el.textContent = selected;
if (el) el.textContent = `${selected} series across ${selectedStudies.size} studies`;
// disable action buttons if none selected
const deleteBtn = document.getElementById('delete-uploads-button');
const importBtn = document.getElementById('import-dicoms-sequential-button');
const importIntoCaseBtn = document.getElementById('import-into-case-button');
const importCaseSeriesBtn = document.getElementById('import-case-series-button');
if (deleteBtn) deleteBtn.disabled = selected === 0;
if (importBtn) importBtn.disabled = selected === 0;
if (importIntoCaseBtn) importIntoCaseBtn.disabled = selected === 0;
if (importCaseSeriesBtn) importCaseSeriesBtn.disabled = selected === 0 || selectedStudies.size < 2;
}
function setImportButtonsDisabled(disabled) {
@@ -304,6 +442,21 @@
return selectedSeries.length ? selectedSeries : selectableSeries;
}
function getSelectedSeriesUids() {
return Array.from(document.querySelectorAll('input[name="selection"]:checked'))
.filter(cb => !cb.disabled)
.map(cb => cb.value);
}
function getSelectedStudyUids() {
return new Set(
Array.from(document.querySelectorAll('input[name="selection"]:checked'))
.filter(cb => !cb.disabled)
.map(cb => cb.dataset.studyUid)
.filter(Boolean)
);
}
async function runImport(selectionList, caseId) {
const statusDiv = document.getElementById('import-status');
if (!statusDiv) return;
@@ -420,7 +573,70 @@
});
}
// Select / Deselect all series within a study block
const caseSeriesForm = document.getElementById('case-series-form');
if (caseSeriesForm) {
caseSeriesForm.addEventListener('submit', async function (event) {
event.preventDefault();
const submitBtn = document.getElementById('case-series-submit-button');
const submitLoading = document.getElementById('case-series-submit-loading');
const selectedSeries = getSelectedSeriesUids();
const selectedStudies = getSelectedStudyUids();
if (!selectedSeries.length || selectedStudies.size < 2) {
alert('Select series from at least two studies before creating a linked case series.');
return;
}
const formData = new FormData(caseSeriesForm);
formData.delete('previous_case');
selectedSeries.forEach((seriesUid) => formData.append('selection', seriesUid));
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
if (orderSeries) {
formData.set('order-series', orderSeries);
}
setImportButtonsDisabled(true);
if (submitBtn) {
submitBtn.classList.add('d-none');
submitBtn.disabled = true;
}
if (submitLoading) {
submitLoading.classList.remove('d-none');
}
try {
const response = await fetch("{% url 'atlas:uploads_import_case_series_htmx' %}", {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
const html = await response.text();
appendImportStatus(html);
if (response.ok) {
selectedSeries.forEach((seriesUid) => markSeriesImported(seriesUid));
const modalEl = document.getElementById('caseSeriesModal');
if (modalEl && window.bootstrap && bootstrap.Modal) {
bootstrap.Modal.getInstance(modalEl)?.hide();
}
}
} catch (error) {
appendImportStatus(`<div class="alert alert-danger mb-0">Linked case import failed. ${error?.message || ''}</div>`);
} finally {
if (submitLoading) {
submitLoading.classList.add('d-none');
}
if (submitBtn) {
submitBtn.classList.remove('d-none');
submitBtn.disabled = false;
}
setImportButtonsDisabled(false);
updateSelectionCount();
}
});
}
// Select / Deselect all series within a study block or patient block
document.addEventListener('click', function (e) {
const target = e.target;
if (target.matches('.select-all-btn')) {
@@ -451,6 +667,34 @@
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
if (target.matches('.select-all-patient-btn')) {
const patientBlock = target.closest('.patient-block');
if (!patientBlock) return;
const inputs = patientBlock.querySelectorAll('input[name="selection"]');
inputs.forEach(cb => {
if (!cb.disabled) {
cb.checked = true;
const li = cb.closest('.series-item');
if (li) li.classList.add('selected');
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
if (target.matches('.deselect-all-patient-btn')) {
const patientBlock = target.closest('.patient-block');
if (!patientBlock) return;
const inputs = patientBlock.querySelectorAll('input[name="selection"]');
inputs.forEach(cb => {
if (!cb.disabled) {
cb.checked = false;
const li = cb.closest('.series-item');
if (li) li.classList.remove('selected');
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
});
// Listen for `case:selected` events dispatched by the case-search widget and perform the import
@@ -525,11 +769,9 @@
}
const casePk = trigger.dataset.caseId || importCaseId;
if (!casePk) {
alert('Could not determine a target case for this partial import. Use an "Import remaining" button linked to a case or open this page in a case context.');
return;
}
const confirmMsg = `Finish importing ${seriesUids.length} series${casePk ? ` into case ${casePk}` : ''}?`;
const confirmMsg = casePk
? `Finish importing ${seriesUids.length} series into case ${casePk}?`
: `Finish importing ${seriesUids.length} series without linking to a case?`;
if (!window.confirm(confirmMsg)) return;
await runImport(seriesUids, casePk);
@@ -753,6 +995,32 @@
/* small helpers */
.text-muted { color: var(--muted) !important; }
#caseSeriesModal .modal-body {
max-height: min(72vh, 760px);
overflow-y: auto;
}
#caseSeriesModal select[multiple] {
min-height: 14rem;
background: #ffffff;
color: #111827;
border: 1px solid #ced4da;
}
#caseSeriesModal select[multiple] option {
background: #ffffff;
color: #111827;
}
#caseSeriesModal select[multiple] option:checked {
background: #0d6efd;
color: #ffffff;
}
.select2-container {
width: 100% !important;
}
#series-list li {
}
#series-list .selected {
@@ -0,0 +1,273 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>Viewer</title>
<style type="text/css">
body, html {
margin: 0; padding: 0; height: 100%; overflow: hidden;
}
#viewer {
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
}
/* Modal styles */
.center-modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100vw; height: 100vh;
background: rgba(20,20,30,0.96);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.center-modal-content {
background: #23232b;
color: #eee;
padding: 2em 2.5em;
border-radius: 12px;
box-shadow: 0 4px 32px rgba(0,0,0,0.4);
max-width: 90vw;
max-height: 80vh;
text-align: center;
font-size: 1.2em;
}
.highlight-action {
display: inline-block;
background: #0d6efd;
color: #fff;
font-weight: bold;
padding: 0.4em 1em;
border-radius: 6px;
margin: 0.7em 0 1.2em 0;
font-size: 1.3em;
box-shadow: 0 2px 8px rgba(13,110,253,0.15);
letter-spacing: 0.03em;
}
.center-modal-content ul {
color: #bdbdbd;
}
.center-modal-content h2 {
color: #fff;
}
</style>
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer"></script>
</head>
<body>
<!-- Centered modal for instructions -->
<div id="awaiting-modal" class="center-modal">
<div class="center-modal-content">
<span class="highlight-action">Share this screen</span>
<h2>Awaiting Load Request</h2>
<p>This page will display a DICOM series when loaded from another page.</p>
<ul style="text-align:left; margin: 1em auto; max-width: 30em;">
<li>Return to the previous page and select a series to view.</li>
<li>This viewer will update automatically when a series is loaded.</li>
<li>Keep this tab open to receive data.</li>
</ul>
</div>
</div>
<div id="root" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100vw; height: 100vh;"
data-auto-cache-stack="false"
></div>
<!-- Overlay for non-DICOM resources (images/videos/embeds) -->
<div id="resourceDisplay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.96); z-index:100000; align-items:center; justify-content:center;">
<div id="resourceInner" style="max-width:95vw; max-height:95vh; display:flex; align-items:center; justify-content:center;"></div>
<button id="resourceClose" style="position:fixed; top:12px; right:12px; z-index:2001;" class="btn btn-sm btn-light">Close</button>
</div>
<script type="text/javascript">
var win2 = false;
document.addEventListener("DOMContentLoaded", function() {
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
const POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
// Centralized hide helper and delegated close handling for local viewer
function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; }
(function(){ const disp=document.getElementById('resourceDisplay'); if(!disp) return; disp.addEventListener('click', function(e){ const close = e.target.closest && e.target.closest('#resourceClose'); if (close) hideResourceLocal(); }); })();
// Helper to safely mount the DICOM viewers (waits for bundle and layout)
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); }
}
function applyViewerPayload(payload) {
if (!payload || payload.type !== 'open') {
return;
}
var root = document.getElementById('root');
var awaiting = document.getElementById('awaiting-modal');
if (!root) {
return;
}
if (awaiting) {
awaiting.style.display = 'none';
}
if (payload.namedStacks) {
root.dataset.namedStacks = payload.namedStacks;
} else {
delete root.dataset.namedStacks;
}
if (payload.images) {
root.dataset.images = payload.images;
} else {
delete root.dataset.images;
}
if (payload.viewerstate && Object.keys(payload.viewerstate).length > 0) {
root.dataset.viewerstate = payload.viewerstate;
} else {
delete root.dataset.viewerstate;
}
if (payload.annotations && Object.keys(payload.annotations).length > 0) {
root.dataset.annotationjson = payload.annotations;
} else {
delete root.dataset.annotationjson;
}
mountDicomViewersSafely();
}
function persistViewerPayload(payload) {
try {
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
} catch (e) {
console.warn('Could not persist viewer payload', e);
}
}
function readPersistedViewerPayload() {
try {
var saved = localStorage.getItem(POPOUT_STATE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (e) {
console.warn('Could not read persisted viewer payload', e);
return null;
}
}
// Intentionally do not auto-load persisted payload on startup.
// The local viva launcher should open to an empty state and wait
// for an explicit "open" message from the controller page.
window.addEventListener('storage', function (event) {
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
return;
}
try {
applyViewerPayload(JSON.parse(event.newValue));
} catch (e) {
console.warn('Could not parse updated viewer payload', e);
}
});
bc.onmessage = (event) => {
// Ensure any existing overlay is closed on new load
try { hideResourceLocal(); } catch(_) {}
console.log("message received");
console.log(event);
if (event.data.type == "open") {
// If a resource URL is provided (image/video/embed), show it in overlay
function isMediaUrl(url) {
if (!url) return false;
const lower = url.toLowerCase();
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
return false;
}
function showResource(url) {
const type = isMediaUrl(url);
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
inner.innerHTML = '';
if (!type) return false;
const transformWrap = document.createElement('div');
transformWrap.style.display = 'flex'; transformWrap.style.alignItems = 'center'; transformWrap.style.justifyContent = 'center';
transformWrap.style.width = '100%'; transformWrap.style.height = '100%'; transformWrap.style.overflow = 'hidden'; transformWrap.style.touchAction = 'none';
if (type === 'image') {
const img = document.createElement('img'); img.src = url; img.style.maxWidth = '95%'; img.style.maxHeight = '95%'; img.style.objectFit = 'contain'; img.style.cursor='grab'; img.draggable=false; transformWrap.appendChild(img);
} else if (type === 'video') {
const v = document.createElement('video'); v.controls=true; v.autoplay=false; v.style.maxWidth='95%'; v.style.maxHeight='95%'; v.style.objectFit='contain'; const s=document.createElement('source'); s.src=url; v.appendChild(s); transformWrap.appendChild(v);
} else if (type === 'embed') {
let embed = url; try{ const m=url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); if (m&&m[1]) embed='https://www.youtube.com/embed/'+m[1]; else if (url.indexOf('vimeo.com')!==-1){ const m2=url.match(/vimeo\.com\/(\d+)/); if(m2&&m2[1]) embed='https://player.vimeo.com/video/'+m2[1]; } }catch(e){}
const iframe=document.createElement('iframe'); iframe.src=embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen',''); iframe.style.width='95%'; iframe.style.height='85%'; transformWrap.appendChild(iframe);
}
inner.appendChild(transformWrap);
// pan/zoom
(function enablePanZoom(target){
let scale=1, minScale=0.5, maxScale=6, startX=0, startY=0, translateX=0, translateY=0, dragging=false;
const content=target.firstElementChild;
function update(){ if(content) content.style.transform=`translate(${translateX}px, ${translateY}px) scale(${scale})`; }
target.addEventListener('wheel', function(e){
const dispEl = document.getElementById('resourceDisplay');
if (!dispEl || dispEl.style.display === 'none') return;
e.preventDefault();
const delta=-e.deltaY||e.wheelDelta; const zoom=delta>0?1.12:0.9; const newScale=Math.min(maxScale, Math.max(minScale, scale*zoom)); const rect=content.getBoundingClientRect(); const mx=e.clientX-rect.left, my=e.clientY-rect.top; const ratio=newScale/scale-1; translateX -= (mx-rect.width/2)*ratio; translateY -= (my-rect.height/2)*ratio; scale=newScale; update();
}, {passive:false});
target.addEventListener('pointerdown', function(e){ dragging=true; startX=e.clientX; startY=e.clientY; target.setPointerCapture(e.pointerId); if(content) content.style.cursor='grabbing'; });
target.addEventListener('pointermove', function(e){ if(!dragging) return; e.preventDefault(); const dx=e.clientX-startX, dy=e.clientY-startY; startX=e.clientX; startY=e.clientY; translateX+=dx; translateY+=dy; update(); });
target.addEventListener('pointerup', function(e){ dragging=false; try{target.releasePointerCapture(e.pointerId)}catch(_){} if(content) content.style.cursor='grab'; });
target.addEventListener('dblclick', function(){ scale=1; translateX=0; translateY=0; update(); });
document.addEventListener('keydown', function(ev){ if(ev.key==='Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
})(transformWrap);
disp.style.display='flex'; return true;
}
if (event.data.url && isMediaUrl(event.data.url)) {
// show resource overlay and do not attempt to mount DICOM viewer
if (showResource(event.data.url)) {
persistViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
return;
}
}
persistViewerPayload(event.data);
applyViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
}
};
});
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
# Atlas tests package
+278 -6
View File
@@ -1,15 +1,287 @@
import json
import pytest
import datetime
# from django.contrib.auth.models import User
from django.http import HttpResponse
from django.urls import reverse
import pytest
from atlas.models import Case, CaseCollection, Finding, Condition
#from atlas.views import GenericExamViews
from pytest_django.asserts import assertRedirects, assertContains, assertQuerySetEqual
from django.test import TestCase
from rich.pretty import pprint
from bs4 import BeautifulSoup
#@pytest.mark.django_db
def test_create_condition(db):
abnormality = Condition.objects.create(name="Abnorm")
assert abnormality.name == "Abnorm"
from django.contrib.auth.models import Group
def test_condition_tree_structure(db):
parent = Condition.objects.create(name="parent")
child1 = Condition.objects.create(name="child1")
child2 = Condition.objects.create(name="child2")
parent.child.set([child1, child2])
child1_1 = Condition.objects.create(name="child1_1")
child1_2 = Condition.objects.create(name="child1_2")
child1.child.set([child1_1, child1_2])
assert len(parent.get_children()) == 2
assert len(child1.get_children()) == 2
assert len(child1.get_parents()) == 1
assert parent in child1.get_parents()
assert len(child2.get_parents()) == 1
assert parent in child2.get_parents()
#@pytest.fixture
#def create_basic_user(db, django_user_model):
# return django_user_model.objects.create_superuser("basicuser", "ross@xkjq.uk", "password")
#
#@pytest.fixture
#def create_exam(db):
# return Exam.objects.create(name="test exam", exam_mode=True)
#
#def test_exam_creation(create_exam):
# exams = Exam.objects.filter(name="test exam")
# assert exams.exists()
#
# exam = exams.first()
#
# assert exam.time_limit == 35 * 60
#
#def test_exam_list(rf, admin_user, create_exam):
# request = rf.get('/rapids/exam/')
# # Remember that when using RequestFactory, the request does not pass
# # through middleware. If your view expects fields such as request.user to be
# # set, you need to set them explicitly.
# # The following line sets request.user to an admin user.
# request.user = admin_user
# response = GenericExamViews.exam_list(request)
# #print(response)
# assert response.status_code == 200
#
#def test_exam_active_admin(rf, admin_user, create_exam, client):
# request = rf.get(reverse('rapids:active_exams'))
# request.user = admin_user
# response = GenericExamViews.active_exams(request)
# print(response.content)
# assert response.status_code == 200
#
#def test_exam_active_basic_user(create_basic_user, create_exam, client):
# client.login(username="basicuser", password="password")
# response = client.get(reverse('rapids:active_exams'))
# print(response.content)
# assert response.status_code == 200
def test_create_case(db, create_case):
case = create_case
assert case.title == "Sample Case"
def test_create_series(db, create_series):
series = create_series
assert series
def test_collection(db, create_case_collection, create_case, make_case, client, create_user):
collection: CaseCollection = create_case_collection
# create some cases
case1 = create_case
case2 = make_case()
case3 = make_case(title="Case 3")
case4 = make_case(title="Case 4")
collection.cases.set([case1, case2, case3, case4])
case5 = make_case(title="Case 5")
assert collection.cases.all().count() == 4
collection.add_case(case5)
assert collection.cases.all().count() == 5
assert collection.get_case_by_index(0) == case1
assert collection.get_case_by_index(1) == case2
assert collection.get_case_by_index(2) == case3
assert collection.get_case_by_index(3) == case4
assert collection.get_case_by_index(4) == case5
assert collection.get_absolute_url() == reverse("atlas:collection_detail", kwargs={"pk": collection.pk})
assert collection.get_take_url() == reverse("atlas:collection_take_start", kwargs={"pk": collection.pk})
start_order = collection.get_cases()
new_order = [
case5.pk,
case1.pk,
case2.pk,
case3.pk,
case4.pk
]
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), json=new_order, follow=True)
# Check that sorting the exam works
# First attempt is not logged in
from urllib.parse import quote
expected_next = quote(reverse("atlas:exam_json_edit", kwargs={"pk": collection.pk}))
assertRedirects(response, f"{reverse('login')}?next={expected_next}")
client.force_login(create_user)
# Random user cannot modify order
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), json=new_order, follow=True)
assert response.status_code == 403
assertQuerySetEqual(start_order , collection.get_cases())
# Once the user has been made an author, they can modify the order
collection.add_author(create_user)
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), data={"set_exam_order" : json.dumps(new_order)}, follow=True)
assert response.status_code == 200
assert collection.get_cases().count() == 5
assert collection.get_case_by_index(0) == case5
assert collection.get_case_by_index(1) == case1
@pytest.mark.django_db
def test_linked_cases_overview_can_copy_study_date_to_chain(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response = client.post(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}),
data={
f"study_date_{case1.pk}": "2026-01-10",
f"study_date_{case2.pk}": "2026-01-12",
"apply_to_all_from": str(case1.pk),
},
follow=True,
)
assert response.status_code == 200
case1.refresh_from_db()
case2.refresh_from_db()
assert str(case1.study_date) == "2026-01-10"
assert str(case2.study_date) == "2026-01-10"
@pytest.mark.django_db
def test_case_save_auto_populates_study_date_when_missing(monkeypatch, make_case):
expected_date = datetime.date(2026, 2, 14)
monkeypatch.setattr(Case, "_get_series_study_date", lambda self: expected_date)
case = make_case(title="Autodate Case")
case.study_date = None
case.save(update_fields=["study_date"])
case.refresh_from_db()
assert case.study_date == expected_date
@pytest.mark.django_db
def test_case_save_preserves_manual_study_date(monkeypatch, make_case):
manual_date = datetime.date(2026, 3, 1)
monkeypatch.setattr(Case, "_get_series_study_date", lambda self: datetime.date(2026, 4, 1))
case = make_case(title="Manual Date Case")
case.study_date = manual_date
case.save(update_fields=["study_date"])
case.refresh_from_db()
assert case.study_date == manual_date
@pytest.mark.django_db
def test_linked_cases_editor_is_hidden_until_requested(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response_default = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk})
)
assert response_default.status_code == 200
assertContains(response_default, "Open case series editor")
assert "Case Series Editor" not in response_default.content.decode("utf-8")
response_with_editor = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}) + "?show_editor=1"
)
assert response_with_editor.status_code == 200
assertContains(response_with_editor, "Case Series Editor")
@pytest.mark.django_db
def test_linked_cases_study_dates_editor_is_hidden_until_requested(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response_default = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk})
)
assert response_default.status_code == 200
assertContains(response_default, "Open study dates editor")
assert "Study Dates" not in response_default.content.decode("utf-8")
response_with_editor = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}) + "?show_dates=1"
)
assert response_with_editor.status_code == 200
assertContains(response_with_editor, "Study Dates")
@pytest.mark.django_db
def test_linked_case_card_shows_scaled_relative_timeline_label(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.study_date = datetime.date(2028, 1, 1)
case2.save(update_fields=["previous_case", "study_date"])
case1.study_date = datetime.date(2026, 1, 1)
case1.save(update_fields=["study_date"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk})
)
assert response.status_code == 200
assertContains(response, "Timeline +2 years")
+188
View File
@@ -0,0 +1,188 @@
import pytest
import datetime
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.test import Client
from atlas.models import CaseCollection, Case, CasePrior, CaseDetail
@pytest.mark.django_db
def test_case_future_priors_chain(make_case):
# Create a chain of cases: case1 -> case2 -> case3
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case3 = make_case(title="Case 3")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case3.previous_case = case2
case3.save(update_fields=["previous_case"])
# Test get_all_prior_cases
assert case1.get_all_prior_cases() == []
assert case2.get_all_prior_cases() == [case1]
assert case3.get_all_prior_cases() == [case2, case1]
# Test get_all_future_cases
assert case1.get_all_future_cases() == [case2, case3]
assert case2.get_all_future_cases() == [case3]
assert case3.get_all_future_cases() == []
@pytest.mark.django_db
def test_case_prior_and_future_relation_post(client, make_case):
# Setup user and collection
user = get_user_model().objects.create_user(
username="author_user", password="password", email="author@test.com"
)
collection = CaseCollection.objects.create(name="Test Prior Future Collection")
collection.author.add(user)
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case3 = make_case(title="Case 3")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case3.previous_case = case2
case3.save(update_fields=["previous_case"])
# Add case2 (the index case) to the collection
casedetail = CaseDetail.objects.create(case=case2, collection=collection)
client.force_login(user)
# 1. Post to link case1 (should be auto-detected as a PRIOR relation)
url = reverse(
"atlas:collection_case_priors",
kwargs={"exam_id": collection.pk, "case_number": 0},
)
response = client.post(
url,
data={
"prior_case_id": case1.pk,
"relation": "Baseline exam",
"prior_visibility": "AL",
},
HTTP_HX_REQUEST="true",
)
assert response.status_code == 200
assert "Baseline exam" in response.content.decode("utf-8")
assert "Prior" in response.content.decode("utf-8")
# Verify in DB
p1 = CasePrior.objects.get(casedetail=casedetail, prior_case=case1)
assert p1.relation_type == CasePrior.RelationType.PRIOR
assert p1.relation_text == "Baseline exam"
# 2. Post to link case3 (should be auto-detected as a FUTURE relation)
response = client.post(
url,
data={
"prior_case_id": case3.pk,
"relation": "Follow-up exam",
"prior_visibility": "AL",
},
HTTP_HX_REQUEST="true",
)
assert response.status_code == 200
assert "Follow-up exam" in response.content.decode("utf-8")
assert "Future" in response.content.decode("utf-8")
# Verify in DB
p3 = CasePrior.objects.get(casedetail=casedetail, prior_case=case3)
assert p3.relation_type == CasePrior.RelationType.FUTURE
assert p3.relation_text == "Follow-up exam"
# Verify GET returns both sections and shows the related cases
response_get = client.get(url)
assert response_get.status_code == 200
content = response_get.content.decode("utf-8")
assert "Available Prior Exams" in content
assert "Available Future Exams" in content
assert "Baseline exam" in content
assert "Follow-up exam" in content
# Test named stacks JSON payload
stacks_json = casedetail.get_case_named_stacks()
assert "Prior: Baseline exam" in stacks_json
assert "Future: Follow-up exam" in stacks_json
# Test DICOM JSON endpoint
dicom_url = reverse(
"atlas:collection_case_dicom_json",
kwargs={"exam_id": collection.pk, "case_number": 0},
)
response_dicom = client.get(dicom_url)
assert response_dicom.status_code == 200
dicom_data = response_dicom.json()
descriptions = [study.get("StudyDescription") for study in dicom_data["studies"] if "StudyDescription" in study]
assert "Prior: Baseline exam" in descriptions
assert "Future: Follow-up exam" in descriptions
# 3. Post to remove case3
response_remove = client.post(
url,
data={
"remove": case3.pk,
},
HTTP_HX_REQUEST="true",
)
assert response_remove.status_code == 200
assert not CasePrior.objects.filter(casedetail=casedetail, prior_case=case3).exists()
@pytest.mark.django_db
def test_linked_cases_reordering(client, make_case):
# Setup user
user = get_user_model().objects.create_user(
username="author_user_reorder", password="password", email="author_reorder@test.com"
)
case1 = make_case(title="Case A")
case2 = make_case(title="Case B")
case3 = make_case(title="Case C")
# Author permissions
case1.author.add(user)
case2.author.add(user)
case3.author.add(user)
# Initial order: case1 -> case2 -> case3
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case3.previous_case = case2
case3.save(update_fields=["previous_case"])
client.force_login(user)
url = reverse("atlas:linked_cases_overview", kwargs={"case_id": case2.pk})
# Verify GET works and shows reorder editor when requested
response_get = client.get(url + "?show_reorder=1")
assert response_get.status_code == 200
assert "Reorder Cases" in response_get.content.decode("utf-8")
# Post new order: case3 -> case1 -> case2
response_post = client.post(
url,
data={
"form_action": "reorder_cases",
"case_order_0": case3.pk,
"case_order_1": case1.pk,
"case_order_2": case2.pk,
},
follow=True,
)
assert response_post.status_code == 200
# Refresh cases from database
case1.refresh_from_db()
case2.refresh_from_db()
case3.refresh_from_db()
# Verify new order in DB: case3 -> case1 -> case2
assert case3.previous_case is None
assert case1.previous_case == case3
assert case2.previous_case == case1
@@ -0,0 +1,13 @@
from __future__ import annotations
import pytest
from atlas.models import SeriesImage
@pytest.mark.django_db
def test_series_viewer_stack_description_includes_plane_and_contrast(create_series):
series = create_series
assert series.get_viewer_stack_description() == "Test Examination (Test Plane) / Test Contrast"
assert series.get_series_dicom_json()["SeriesDescription"] == "Test Examination (Test Plane) / Test Contrast"
+121
View File
@@ -0,0 +1,121 @@
import pytest
from django.urls import reverse
from django.contrib.auth.models import Group, User
from django.test import Client
from atlas.models import CaseCollection, Case, Series, Condition, Finding, Structure, Resource
SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405}
# Fixtures for clients
@pytest.fixture
def candidate_client(db):
user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com")
client = Client()
client.force_login(user)
return client
@pytest.fixture
def editor_client(db):
user = User.objects.create_user(username="editor", password="password", email="editor@test.com")
g, _ = Group.objects.get_or_create(name="atlas_editor")
user.groups.add(g)
client = Client()
client.force_login(user)
return client
@pytest.fixture
def anonymous_client(db):
return Client()
# Fixture to prepare smoke test DB records
@pytest.fixture
def smoke_data(db, create_case, create_series, create_condition, create_finding, create_structure, create_user):
# CaseCollection
collection = CaseCollection.objects.create(name="Smoke Collection")
collection.cases.add(create_case)
collection.author.add(create_user)
# Resource
resource = Resource.objects.create(name="Smoke Resource")
resource.author.add(create_user)
# Make sure condition/finding/structure/series are authored/accessible
create_series.author.add(create_user)
return {
"collection": collection,
"case": create_case,
"series": create_series,
"condition": create_condition,
"finding": create_finding,
"structure": create_structure,
"resource": resource,
}
URL_SPECS = [
("atlas:index", None),
("atlas:help", None),
("atlas:case_view", None),
("atlas:case_create", None),
("atlas:case_detail", lambda d: {"pk": d["case"].pk}),
("atlas:case_update", lambda d: {"pk": d["case"].pk}),
("atlas:case_delete", lambda d: {"pk": d["case"].pk}),
("atlas:collection_view", None),
("atlas:exam_create", None),
("atlas:collection_detail", lambda d: {"pk": d["collection"].pk}),
("atlas:exam_update", lambda d: {"pk": d["collection"].pk}),
("atlas:exam_deleted", lambda d: {"pk": d["collection"].pk}),
("atlas:series_view", None),
("atlas:series_create", None),
("atlas:series_detail", lambda d: {"pk": d["series"].pk}),
("atlas:series_update", lambda d: {"pk": d["series"].pk}),
("atlas:series_delete", lambda d: {"pk": d["series"].pk}),
("atlas:resource_view", None),
("atlas:resource_create", None),
("atlas:resource_detail", lambda d: {"pk": d["resource"].pk}),
("atlas:resource_update", lambda d: {"pk": d["resource"].pk}),
("atlas:resource_delete", lambda d: {"pk": d["resource"].pk}),
("atlas:condition_view", None),
("atlas:condition_create", None),
("atlas:condition_detail", lambda d: {"pk": d["condition"].pk}),
("atlas:condition_update", lambda d: {"pk": d["condition"].pk}),
("atlas:condition_delete", lambda d: {"pk": d["condition"].pk}),
("atlas:finding_view", None),
("atlas:finding_create", None),
("atlas:finding_detail", lambda d: {"pk": d["finding"].pk}),
("atlas:finding_update", lambda d: {"pk": d["finding"].pk}),
("atlas:finding_delete", lambda d: {"pk": d["finding"].pk}),
("atlas:structure_view", None),
("atlas:structure_create", None),
("atlas:structure_detail", lambda d: {"pk": d["structure"].pk}),
("atlas:structure_update", lambda d: {"pk": d["structure"].pk}),
("atlas:structure_delete", lambda d: {"pk": d["structure"].pk}),
]
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = anonymous_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous"
assert response.status_code in {200, 302, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous"
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = candidate_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate"
assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate"
@pytest.mark.django_db
@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS)
def test_url_smoke_editor(editor_client, smoke_data, url_name, kwargs_lambda):
kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None
url = reverse(url_name, kwargs=kwargs)
response = editor_client.get(url, follow=False)
assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Editor"
# Editor should get a success status code (200 OK or 302 redirect), never forbidden or not found for these valid resources
assert response.status_code in {200, 301, 302, 303}, f"Unexpected status code {response.status_code} on {url_name} for Editor"
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import pytest
from django.urls import reverse
from atlas.models import SeriesImage, UncategorisedDicom
@pytest.mark.django_db
def test_user_uploads_partial_import_button_prefers_case_for_hash_matched_series(
client,
admin_user,
create_series,
create_case,
make_case,
):
client.force_login(admin_user)
primary_case = create_case
primary_case.author.add(admin_user)
secondary_case = make_case(title="Secondary Case")
secondary_case.author.add(admin_user)
existing_series = create_series
existing_series.series_instance_uid = "atlas-series-1"
existing_series.study_instance_uid = "study-1"
existing_series.save(update_fields=["series_instance_uid", "study_instance_uid"])
primary_case.series.add(existing_series)
secondary_case.series.add(existing_series)
SeriesImage.objects.create(series=existing_series, image_blake3_hash="shared-hash")
UncategorisedDicom.objects.bulk_create(
[
UncategorisedDicom(
image="pending-upload.dcm",
user=admin_user,
series_instance_uid="uploaded-series-1",
image_blake3_hash="shared-hash",
basic_dicom_tags={
"SeriesInstanceUID": "uploaded-series-1",
"StudyInstanceUID": "study-1",
"StudyDescription": "Shared study",
"SeriesDescription": "Uploaded series",
"Modality": "CT",
},
)
]
)
response = client.get(reverse("atlas:user_uploads"))
assert response.status_code == 200
content = response.content.decode("utf-8")
assert 'class="btn btn-sm btn-warning import-partial-series-btn"' in content
assert f'data-case-id="{primary_case.pk}"' in content
@pytest.mark.django_db
def test_user_uploads_patient_grouping_and_select2(client, admin_user):
client.force_login(admin_user)
# Create UncategorisedDicom with PatientID tag using bulk_create to avoid reading non-existent file
UncategorisedDicom.objects.bulk_create(
[
UncategorisedDicom(
image="pending-upload-patient.dcm",
user=admin_user,
series_instance_uid="uploaded-series-patient-1",
basic_dicom_tags={
"SeriesInstanceUID": "uploaded-series-patient-1",
"StudyInstanceUID": "study-patient-1",
"StudyDescription": "Patient Study 1",
"SeriesDescription": "Uploaded series",
"Modality": "CT",
"PatientID": "PAT-999-XYZ",
},
)
]
)
response = client.get(reverse("atlas:user_uploads"))
assert response.status_code == 200
content = response.content.decode("utf-8")
# Verify patient grouping is rendered
assert "PAT-999-XYZ" in content
assert "Patient ID:" in content
assert "select-all-patient-btn" in content
# Verify form autocomplete widgets are in context
form = response.context["case_series_form"]
from dal import autocomplete
assert isinstance(form.fields["condition"].widget, autocomplete.ModelSelect2Multiple)
assert isinstance(form.fields["presentation"].widget, autocomplete.ModelSelect2Multiple)
assert isinstance(form.fields["procedures"].widget, autocomplete.ModelSelect2Multiple)
+36 -1
View File
@@ -2,7 +2,7 @@ from django.urls import path, include
from django.views.generic import RedirectView, TemplateView
from atlas.models import Condition, Finding, Presentation, Structure, Subspecialty, Procedure
from atlas.models import CaseCollection, Condition, Finding, Presentation, Structure, Subspecialty, Procedure, Series
from . import views
app_name = "atlas"
@@ -39,6 +39,17 @@ urlpatterns = [
path("author/<int:pk>/", views.author_detail, name="author_detail"),
path("author/", views.author_list, name="author_list"),
path("case/", views.CaseView.as_view(), name="case_view"),
path("case/admin-overview", views.case_admin_overview, name="case_admin_overview"),
path(
"case/admin-overview/recent",
views.case_admin_recent_cases_partial,
name="case_admin_recent_cases_partial",
),
path(
"case/admin-overview/largest",
views.case_admin_largest_cases_partial,
name="case_admin_largest_cases_partial",
),
path("collection/<int:collection_id>/add_case", views.add_case_to_collection, name="add_case_to_collection"),
path("collection/case_search", views.case_search, name="case_search"),
path("collection/", views.CollectionView.as_view(), name="collection_view"),
@@ -51,6 +62,7 @@ urlpatterns = [
path("uploads/new", views.new_uploads, name="new_uploads"),
path("uploads/import", views.uploads_import_htmx, name="uploads_import_htmx"),
path("uploads/import/case/<int:case_id>", views.uploads_import_htmx, name="uploads_import_case_htmx"),
path("uploads/import/case-series", views.uploads_import_case_series_htmx, name="uploads_import_case_series_htmx"),
path("uploads/case/<int:case_id>", views.user_uploads, name="user_uploads_case"),
path("uploads/<int:user_pk>/user/case/<int:case_id>", views.other_user_uploads, name="other_user_uploads_case"),
path("uploads/series_id/<str:series_instance_uid>", views.user_uploads_series, name="user_uploads_series"),
@@ -85,8 +97,10 @@ urlpatterns = [
),
path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"),
path("collection/viewer_local", TemplateView.as_view(template_name="atlas/viewer_local.html"), name="collection_viewer_local"),
path("collection/viewer_local_progressive", TemplateView.as_view(template_name="atlas/viewer_local_progressive.html"), name="collection_viewer_local_progressive"),
path("collection/<int:pk>", views.collection_detail, name="collection_detail"),
path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"),
path("collection/<int:pk>/viva-local", views.collection_viva_local, name="collection_viva_local"),
path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"),
path("collection/<int:pk>/viva/<int:series_id>/series", views.collection_viva_series, name="collection_viva_series"),
path(
@@ -240,6 +254,7 @@ urlpatterns = [
path("case/<int:pk>/attach-resource/", views.case_attach_resource, name="case_attach_resource"),
path("case/<int:pk>/detach-resource/", views.case_detach_resource, name="case_detach_resource"),
path("case/<int:pk>/toggle-resource-pre/", views.case_toggle_resource_pre, name="case_toggle_resource_pre"),
path("case/<int:pk>/create-resource-inline/", views.case_create_resource_inline, name="case_create_resource_inline"),
# Backwards-compatible redirect from legacy case_id-style URLs
path(
"collection/<int:exam_id>/case/id/<int:case_id>/details",
@@ -424,6 +439,11 @@ urlpatterns = [
views.redirect_collection_case_view_take_user_answers_by_id,
name="collection_case_view_take_user_answers_legacy",
),
path(
"collection/<int:pk>/<int:case_number>/take/review/<int:user_pk>/",
views.collection_case_view_take_review_user,
name="collection_case_view_take_review_user",
),
path(
"collection/<int:pk>/json_edit",
views.GenericExamViews.exam_json_edit,
@@ -556,6 +576,11 @@ urlpatterns = [
# TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
path(
"series/<int:series_id>/reconstruct/advanced/",
views.series_reconstruct_advanced,
name="series_reconstruct_advanced",
),
path(
"series/<int:series_id>/truncate/<int:start>/<int:end>/",
views.series_truncate_htmx,
@@ -751,11 +776,21 @@ urlpatterns = [
views.ProcedureAutocomplete.as_view(model=Procedure, create_field="name"),
name="procedure-autocomplete",
),
path(
"casecollection-prerequisite-autocomplete",
views.CaseCollectionPrerequisiteAutocomplete.as_view(model=CaseCollection),
name="casecollection-prerequisite-autocomplete",
),
path(
"subspecialty-autocomplete",
views.SubspecialtyAutocomplete.as_view(model=Subspecialty, create_field="name"),
name="subspecialty-autocomplete",
),
path(
"series-autocomplete",
views.SeriesAutocomplete.as_view(model=Series),
name="series-autocomplete",
),
path("presentation/", views.PresentationView.as_view(), name="presentation_view"),
path(
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
+2830 -703
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
import os
import shutil
import pytest
from django.conf import settings
@pytest.fixture(scope="session", autouse=True)
def cleanup_test_media():
"""
Session-scoped fixture to automatically clean up temporary media files
generated during the test run.
"""
yield
# Clean up settings.MEDIA_ROOT
media_root = getattr(settings, "MEDIA_ROOT", None)
if media_root and os.path.exists(media_root) and "media_test" in media_root:
shutil.rmtree(media_root, ignore_errors=True)
# Also clean up any legacy directories created due to missing trailing slash
base_dir = getattr(settings, "BASE_DIR", None)
if base_dir:
legacy_dir = os.path.join(base_dir, "media_testlongs")
if os.path.exists(legacy_dir):
shutil.rmtree(legacy_dir, ignore_errors=True)
+20 -1
View File
@@ -13,6 +13,15 @@ server {
client_max_body_size 100M;
location /rts/ {
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#proxy_set_header X-Forwarded-Proto $scheme;
#proxy_set_header Host $host;
#proxy_redirect off;
#proxy_buffering off;
alias /usr/src/app/rts/;
}
location /static/ {
alias /usr/src/app/static/;
expires 30d;
@@ -23,10 +32,20 @@ server {
alias /usr/src/app/media;
}
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app_server;
}
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_buffering off;
proxy_pass http://app_server;
+20
View File
@@ -87,6 +87,16 @@ server {
proxy_redirect http://reporter:5129/ /reporter/;
}
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app_server;
}
# Default proxy to the Django/gunicorn upstream
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -124,6 +134,16 @@ server {
location /static/ { alias /usr/src/app/static/; }
location /media { alias /usr/src/app/media; }
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app_server;
}
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+5 -4
View File
@@ -66,7 +66,7 @@ if not DEBUG:
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': 'log.txt',
'filename': 'logs/app.log',
'formatter': 'verbose',
},
},
@@ -89,8 +89,9 @@ CIMAR_PASSWORD = "<REPLACE-WITH-SECRET>"
# Celery settings — when running in docker the redis service is
# reachable at the `redis` hostname provided by docker compose.
CELERY_BROKER_URL = "redis://redis:6379"
CELERY_RESULT_BACKEND = "redis://redis:6379"
REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
CELERY_BROKER_URL = f"redis://{REDIS_HOST}:6379"
CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:6379"
DATABASES = {
"default": {
@@ -127,4 +128,4 @@ CSRF_TRUSTED_ORIGINS = [
"http://localhost:8080/",
]
REMOTE_URL = "http://127.0.0.1:8000"
REMOTE_URL = "http://127.0.0.1:8080"
+4 -3
View File
@@ -33,7 +33,7 @@ if not DEBUG:
'file': {
'level': 'DEBUG',
'class': 'logging.FileHandler',
'filename': 'log.txt',
'filename': 'logs/app.log',
'formatter': 'verbose',
},
},
@@ -56,5 +56,6 @@ CIMAR_PASSWORD = "<REPLACE-WITH-SECRET>"
# Celery settings — when running in docker the redis service is
# reachable at the `redis` hostname provided by docker compose.
CELERY_BROKER_URL = "redis://redis:6379"
CELERY_RESULT_BACKEND = "redis://redis:6379"
REDIS_HOST = os.environ.get("REDIS_HOST", "redis")
CELERY_BROKER_URL = f"redis://{REDIS_HOST}:6379"
CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:6379"
+10 -41
View File
@@ -17,6 +17,9 @@ services:
- ../:/usr/src/app:cached
# Mount the app log directory so logs are visible on the host at ./logs
- ../logs:/var/log/rad
- ../../media:/usr/src/app/media
- ../../static:/usr/src/app/static
- /home/ross/rts:/usr/src/app/rts
worker:
env_file:
@@ -24,6 +27,9 @@ services:
volumes:
- ../:/usr/src/app:cached
- ../logs:/var/log/rad
- ../../media:/usr/src/app/media
- ../../static:/usr/src/app/static
- /home/ross/rts:/usr/src/app/rts
entrypoint:
[
"sh",
@@ -39,46 +45,9 @@ services:
volumes:
- ../deploy/nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro
- ../logs:/var/log/rad:rw
- ../../media:/usr/src/app/media:ro
- ../../static:/usr/src/app/static:ro
- /home/ross/rts:/usr/src/app/rts:ro
loki:
image: grafana/loki:2.8.2
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
volumes:
- ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
- ./loki-data:/loki
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
interval: 5s
timeout: 3s
retries: 6
promtail:
image: grafana/promtail:2.8.2
volumes:
- ../logs:/var/log/rad:ro
- ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
command: -config.file=/etc/promtail/promtail-config.yml
depends_on:
- loki
restart: unless-stopped
grafana:
image: grafana/grafana:10.2.0
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
restart: unless-stopped
volumes:
grafana-data:
loki-data:
# No additional volumes required for local dev
+7 -4
View File
@@ -23,8 +23,9 @@ services:
# (Optional) keep explicit mounts for media/static/backups if you need
# to override them separately; otherwise they are covered by the repo mount.
- ../backups:/usr/src/app/backups
- ../media:/usr/src/app/media
- ../static:/usr/src/app/static
- ../../media:/usr/src/app/media
- ../../static:/usr/src/app/static
- /home/ross/rts:/usr/src/app/rts
ports:
- 8000:8000
- 3459:3459
@@ -40,8 +41,8 @@ services:
volumes:
- ../:/usr/src/app
- ../backups:/usr/src/app/backups
- ../media:/usr/src/app/media
- ../static:/usr/src/app/static
- ../../media:/usr/src/app/media
- ../../static:/usr/src/app/static
env_file:
- ./.env.dev.local
depends_on:
@@ -55,6 +56,8 @@ services:
- POSTGRES_USER=django
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=rad
ports:
- "5432:5432"
pgadmin:
image: dpage/pgadmin4:7.2
environment:
+46 -46
View File
@@ -86,58 +86,58 @@ services:
timeout: 10s
retries: 3
# Optional local observability stack (can be enabled on servers that
# should run Loki/Grafana). These services use configs found under
# the repository `docker/` directory so the same config works in dev.
loki:
image: grafana/loki:2.8.2
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
volumes:
- ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
- ../docker/loki-data:/loki
# WAL directory: map a host folder so Loki (running as UID 10001)
# can create the write-ahead log. Host folder created under
# `docker/loki-wal` and owned by UID 10001.
- ../docker/loki-wal:/wal
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
interval: 10s
timeout: 5s
retries: 6
promtail:
image: grafana/promtail:2.8.2
volumes:
# In prod you may want to point this at the host system log directory.
- ../logs:/var/log/rad:ro
- ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
command: -config.file=/etc/promtail/promtail-config.yml
depends_on:
- loki
restart: unless-stopped
grafana:
image: grafana/grafana:10.2.0
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
restart: unless-stopped
# # Optional local observability stack (can be enabled on servers that
# # should run Loki/Grafana). These services use configs found under
# # the repository `docker/` directory so the same config works in dev.
# loki:
# image: grafana/loki:2.8.2
# command: -config.file=/etc/loki/local-config.yaml
# ports:
# - "3100:3100"
# volumes:
# - ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
# - ../docker/loki-data:/loki
# # WAL directory: map a host folder so Loki (running as UID 10001)
# # can create the write-ahead log. Host folder created under
# # `docker/loki-wal` and owned by UID 10001.
# - ../docker/loki-wal:/wal
# restart: unless-stopped
# healthcheck:
# test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
# interval: 10s
# timeout: 5s
# retries: 6
#
# promtail:
# image: grafana/promtail:2.8.2
# volumes:
# # In prod you may want to point this at the host system log directory.
# - ../logs:/var/log/rad:ro
# - ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
# command: -config.file=/etc/promtail/promtail-config.yml
# depends_on:
# - loki
# restart: unless-stopped
#
# grafana:
# image: grafana/grafana:10.2.0
# ports:
# - "3000:3000"
# environment:
# - GF_SECURITY_ADMIN_PASSWORD=admin
# volumes:
# - grafana-data:/var/lib/grafana
# depends_on:
# loki:
# condition: service_healthy
# restart: unless-stopped
volumes:
postgres_data:
redis_data:
static_volume:
media_volume:
grafana-data:
#grafana-data:
networks:
default:
+2
View File
@@ -12,6 +12,8 @@ ingester:
chunk_idle_period: 5m
chunk_retain_period: 30s
max_transfer_retries: 0
wal:
dir: /loki/wal
schema_config:
configs:
+11 -6
View File
@@ -17,9 +17,14 @@ python manage.py migrate --noinput
echo "Collecting static files..."
python manage.py collectstatic --noinput
echo "Starting gunicorn..."
exec gunicorn rad.wsgi:application \
--name rad_gunicorn \
--bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \
--log-level ${GUNICORN_LOGLEVEL:-info}
if [ "${SERVER_TYPE}" = "daphne" ] || [ "${SERVER_TYPE}" = "asgi" ]; then
echo "Starting daphne..."
exec daphne -b 0.0.0.0 -p 8000 rad.asgi:application
else
echo "Starting gunicorn..."
exec gunicorn rad.wsgi:application \
--name rad_gunicorn \
--bind 0.0.0.0:8000 \
--workers ${GUNICORN_WORKERS:-3} \
--log-level ${GUNICORN_LOGLEVEL:-info}
fi
+10
View File
@@ -144,6 +144,16 @@ class SupervisorFilter(django_filters.FilterSet):
"site": ["exact"],
}
@property
def qs(self):
parent = super().qs
if "active" not in self.request.GET:
return parent.filter(active=True)
return parent
def __init__(
self,
data=None,
+16 -2
View File
@@ -12,7 +12,7 @@ from django.forms import (
DateField,
SplitDateTimeField,
SplitDateTimeWidget,
BooleanField,
)
from django.forms import inlineformset_factory
from atlas.models import CaseCollection
@@ -146,6 +146,10 @@ class ExamFormMixin:
"active",
"publish_results",
"archive",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
Accordion(
AccordionGroup("Date restrictions",
"restrict_to_dates",
@@ -214,7 +218,11 @@ class ExamFormMixin:
"active",
"publish_results",
"archive",
"results_supervisor_visible"
"results_supervisor_visible",
"pre_survey",
"pre_survey_mandatory",
"post_survey",
"post_survey_mandatory",
# "cid_user_groups",
# "user_user_groups",
# "author",
@@ -812,6 +820,12 @@ class UserUserForm(Form):
first_name = CharField(max_length=255, required=True)
last_name = CharField(max_length=255, required=True)
grade = ModelChoiceField(UserGrades.objects.all(), required=False)
create_supervisor = BooleanField(
required=False,
label="Create linked supervisor account",
help_text="If checked, a supervisor profile with this user's name and email will be automatically created.",
initial=False,
)
class TraineeForm(Form):
+30
View File
@@ -0,0 +1,30 @@
# Generated by Django 6.0.1 on 2026-06-15 09:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('generic', '0032_ciduserexam_shared_with_users'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserHiddenItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hidden_items', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(fields=('user', 'content_type', 'object_id'), name='unique_user_hidden_item')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-07-13 08:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0033_userhiddenitem'),
]
operations = [
migrations.AddField(
model_name='ciduserexam',
name='exclude_from_stats',
field=models.BooleanField(default=False, help_text='Exclude this candidate from exam statistics.'),
),
]
+98 -18
View File
@@ -323,7 +323,7 @@ class SeriesImageBase(models.Model):
try:
with pydicom.dcmread(self.image) as d:
return d
except pydicom.errors.InvalidDicomError:
except (pydicom.errors.InvalidDicomError, FileNotFoundError, OSError):
return {}
def get_full_url(self):
@@ -335,14 +335,14 @@ class SeriesImageBase(models.Model):
def get_dicom_json(self):
try:
json = pydicom.dcmread(self.image).to_json()
except pydicom.errors.InvalidDicomError:
except (pydicom.errors.InvalidDicomError, FileNotFoundError, OSError):
return {}
return json
def get_dicom_info(self):
try:
info = pretty_print_dicom(pydicom.dcmread(self.image))
except pydicom.errors.InvalidDicomError:
except (pydicom.errors.InvalidDicomError, FileNotFoundError, OSError):
info = "File is not a dicom."
return info
@@ -698,6 +698,12 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
default=True,
)
@property
def content_type_id(self) -> int:
from django.contrib.contenttypes.models import ContentType
return ContentType.objects.get_for_model(self).id
authors_only = models.BooleanField(
help_text="If true only exam/collection authors will be able to view.",
default=False,
@@ -710,6 +716,31 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
exam_open_access = models.BooleanField(default=False, help_text="Set to true if you want any registered user to be able to take the exam.")
pre_survey = models.ForeignKey(
"survey.Survey",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="%(app_label)s_%(class)s_pre_surveys",
help_text="Survey to be filled out BEFORE taking this collection/exam."
)
post_survey = models.ForeignKey(
"survey.Survey",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="%(app_label)s_%(class)s_post_surveys",
help_text="Survey to be filled out AFTER completing this collection/exam."
)
pre_survey_mandatory = models.BooleanField(
default=False,
help_text="If checked, candidates must complete the pre-take survey to start."
)
post_survey_mandatory = models.BooleanField(
default=False,
help_text="If checked, candidates must complete the post-take survey to view results."
)
class Meta:
abstract = True
@@ -971,26 +1002,21 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
def clone_model(self):
M2M_fields = (
"exam_questions",
"author",
"valid_cid_users",
"valid_user_users",
"cid_user_groups",
"user_user_groups",
)
m2m_fields = [f.name for f in self._meta.many_to_many]
FK_fields = ("examcollection",)
model_dict = model_to_dict(self)
m2m_to_update = {}
for field in M2M_fields:
m2m_to_update[field] = model_dict[field]
del model_dict[field]
for field in m2m_fields:
if field in model_dict:
m2m_to_update[field] = model_dict[field]
del model_dict[field]
fk_to_update = {}
for field in FK_fields:
fk_to_update[field] = model_dict[field]
del model_dict[field]
if field in model_dict:
fk_to_update[field] = model_dict[field]
del model_dict[field]
cloned_model = self.__class__(**model_dict)
cloned_model.pk = None
@@ -1024,6 +1050,12 @@ class ExamBase(ExamOrCollectionGenericBase):
default=False,
)
pass_mark = models.FloatField(
null=True,
blank=True,
help_text="Pass mark for the exam. If unset, defaults to 50% of the maximum score.",
)
# randomise_question_order = models.BooleanField(
# help_text="If an exam should randomise the order of questions in RTS.",
# )
@@ -1123,6 +1155,9 @@ class ExamBase(ExamOrCollectionGenericBase):
def get_take_url(self):
return f"{settings.REMOTE_URL}/rts"
def get_take_url_exam_mode(self):
return f"{settings.REMOTE_URL}/rts?exam_mode=true"
def get_exam_name(self):
return str(self)
@@ -1300,6 +1335,11 @@ class UserAnswerBase(models.Model):
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
time_answered = models.DateTimeField(
null=True,
blank=True,
help_text="Time the question was answered (client-side)",
)
class Meta:
abstract = True
@@ -1676,6 +1716,11 @@ class CidUserExam(models.Model):
manual_submission = models.BooleanField(default=False)
exclude_from_stats = models.BooleanField(
default=False,
help_text="Exclude this candidate from exam statistics."
)
# TODO switch to json field?
results_emailed_status = models.CharField(max_length=255, blank=True)
@@ -2381,7 +2426,42 @@ class FindingBase(models.Model):
return json.dumps(self.annotation_json)
def get_pretty_annotation_json(self):
return get_pretty_json(json.loads(self.annotation_json))
if not self.annotation_json:
return ""
try:
return get_pretty_json(json.loads(self.annotation_json))
except Exception:
return ""
def get_pretty_viewport_json(self):
return get_pretty_json(json.loads(self.viewport_json))
if not self.viewport_json:
return ""
try:
return get_pretty_json(json.loads(self.viewport_json))
except Exception:
return ""
class UserHiddenItem(models.Model):
"""Model to track which exams or collections have been hidden by a user.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="hidden_items",
)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey("content_type", "object_id")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "content_type", "object_id"],
name="unique_user_hidden_item",
)
]
def __str__(self):
return f"{self.user} hid {self.content_object}"
@@ -0,0 +1,110 @@
{% extends 'generic/base.html' %}
{% load help_tags %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-lg-10">
<div class="card shadow mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<h3 class="card-title mb-0">Answer Revision History</h3>
<span class="badge bg-secondary fs-6">{{ history|length }} version{{ history|pluralize }}</span>
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col-md-6">
<strong>App/Model:</strong> <span class="text-primary">{{ app_name }}.{{ model_name }}</span><br>
<strong>Answer ID:</strong> <span class="text-primary">{{ answer.pk }}</span>
</div>
<div class="col-md-6 text-md-end">
{% if exam %}
<strong>Exam:</strong> <span class="text-info">{{ exam }}</span>
{% elif collection %}
<strong>Case Collection:</strong> <span class="text-info">{{ collection }}</span>
{% endif %}
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle">
<thead class="table-dark">
<tr>
<th style="width: 80px;">Rev ID</th>
<th style="width: 180px;">Date / Time</th>
<th style="width: 150px;">Editor</th>
<th>Answer Value</th>
<th style="width: 100px;">Score</th>
<th>Comment</th>
<th>Time answered</th>
</tr>
</thead>
<tbody>
{% for rev in history %}
<tr>
<td><code>#{{ rev.revision_id }}</code></td>
<td>{{ rev.date }}</td>
<td>
{% if rev.user %}
<span class="badge bg-light text-dark">{{ rev.user.username }}</span>
{% else %}
<span class="text-muted italic">System / Anon</span>
{% endif %}
</td>
<td>
<pre class="mb-0 bg-light text-dark p-2 rounded" style="white-space: pre-wrap; font-size: 0.85rem;">{{ rev.answer_text }}</pre>
</td>
<td>
{% if rev.score is not None and rev.score != "" %}
<span class="badge bg-primary">{{ rev.score }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td><small class="text-muted">{{ rev.comment|default:"-" }}</small></td>
<td>
{% if rev.time_answered %}
{{ rev.time_answered }}
{% else %}
<span class="text-muted
">-</span>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if request.user.is_superuser or request.user.is_staff %}
<!-- Compacting tools panel -->
<div class="card mt-4 border-warning">
<div class="card-header bg-warning-subtle text-warning-emphasis fw-bold">
<i class="bi bi-scissors me-1"></i> Compact History Settings
</div>
<div class="card-body">
<p class="text-muted small">
Compacting history deletes all older versions and their associated revision logs, retaining only the final answer database state. This is permanent and cannot be undone.
</p>
<div class="d-flex flex-wrap gap-3">
{% if exam or collection %}
<form method="post" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="action" value="compact_bulk">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete all historical revisions for ALL candidate answers in this entire exam/collection?');">
Compact Entire {% if exam %}Exam{% else %}Collection{% endif %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-secondary" onclick="window.history.back();">Back</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+3 -1
View File
@@ -17,7 +17,7 @@
{% block navigation %}
<nav class="navbar navbar-expand-lg navbar-dark submenu mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Candidates</a>
<a class="navbar-brand" href="#">People</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#genericNavbar" aria-controls="genericNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"><i class="bi bi-text-indent-right"></i></span>
</button>
@@ -29,6 +29,8 @@
<ul class="dropdown-menu" aria-labelledby="navCids">
<li><a class="dropdown-item" href="{% url 'generic:manage_cids' %}"><i class="bi bi-file-earmark-text me-1"></i> All Cids</a></li>
<li><a class="dropdown-item" href="{% url 'generic:manage_cid_exams' %}"><i class="bi bi-mortarboard me-1"></i> Cids (exams)</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'generic:import_exam_answers' %}"><i class="bi bi-upload me-1"></i> Import Exam Answers</a></li>
</ul>
</li>
+7
View File
@@ -8,6 +8,13 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Manage Candidates</h2>
<a href="{% url 'generic:import_exam_answers' %}" class="btn btn-primary btn-sm">
<i class="bi bi-upload me-1"></i> Import Exam Answers
</a>
</div>
<div>
<span id="manage-span">
{% render_table table %}
+132 -96
View File
@@ -3,9 +3,6 @@
{% block content %}
<div class="">
<h2>{{ exam }}</h2>
{% if cached_scores %}
User answer scores are cached, if you update or change an answer you will need to <a href="{% url exam.app_name|add:':exam_scores_refresh' exam.pk %}">refresh the scores</a>.
{% endif %}
{% if missing_cids or missing_users %}
<details>
@@ -38,113 +35,123 @@
{% endif %}
</div>
<div id="stats-plot">{{plot|safe}}</div>
<div id="stats-block" title="low scores (<20% max) are excluded from statistics" class="mb-2 small">
<h5 class="mb-1">Stats</h5>
<div>Candidates: <span class="fw-semibold">{{cids|length}}</span></div>
<div>Questions: <span id="question-number">{{question_number}}</span></div>
<div title="Max score">Available marks: {{max_score}}</div>
<div>Mean: {{mean}}, Median {{median}}, Mode {{mode}}</div>
<div>Top score: {{exam.stats_max}}</div>
{% if can_edit %}
<div class="card mb-3 bg-dark text-light border-secondary shadow-sm col-md-6 col-lg-4">
<div class="card-body py-2 px-3">
<form hx-post="{% url exam.app_name|add:':update_pass_mark' exam.pk %}" class="row g-2 align-items-center">
{% csrf_token %}
<div class="col-auto">
<label for="pass_mark_input" class="col-form-label col-form-label-sm">Pass Mark:</label>
</div>
<div class="col">
<input type="number" step="any" id="pass_mark_input" name="pass_mark" class="form-control form-control-sm bg-dark text-light border-secondary" placeholder="Default: {{ default_pass_mark }}" value="{{ exam.pass_mark|default_if_none:'' }}">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-outline-secondary">Set</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Stats Container Lazy-Loader -->
<div id="stats-container"
hx-get="{% url exam.app_name|add:':exam_scores_all' exam.pk %}?stats_only=true"
hx-trigger="load, refreshStats from:body"
hx-swap="outerHTML">
<div class="card mb-3 bg-dark text-light border-secondary shadow-sm">
<div class="card-body text-center py-4">
<div class="spinner-border text-info" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="text-muted mt-2">Loading statistics...</div>
</div>
</div>
</div>
<!-- Candidate Search/Filter Input -->
<div class="row mb-3">
<div class="col-md-6 col-lg-4">
<input type="text" id="candidate-filter" class="form-control form-control-sm bg-dark text-light border-secondary" placeholder="Filter by candidate name or ID..." oninput="filterCandidates(this.value)">
</div>
</div>
<div class="mb-3">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable small">
<tr>
<th title="click to sort by candidate / user id">Candidate ID</th>
<th title="click to sort by score">Score</th>
{% if exam.app_name == "longs" or exam.app_name == "rapids" %}
<th>Normalised Score</th>
{% endif %}
</tr>
{% for cid in cids %}
<tr class="candidate-row" data-answer-count={{user_answer_count|get_item:cid}}>
{% if cid|slice:":1" == "u" %}
<td class="cid" >{{cids_user_id_map|get_item:cid}}</td>
{% else %}
<td class="cid" data-cid="{{cid|slice:'2:'}}"><a href="{% url 'cid_scores_admin' cid|slice:'2:' %}">{{cid}}</a>
<span title="Click to show candidate details"
hx-get="{% url 'generic:cid_details' cid|slice:'2:' %}"
hx-target="this"
>
<i class="bi bi-binoculars search-cid" ></i>
</span>
</td>
{% endif %}
<td>{{user_scores|get_item:cid}}</td>
<table class="table table-dark table-striped table-hover table-sm cid-score-table sortable small" id="candidate-scores-list-table">
<tr>
<th title="click to sort by candidate / user id">Candidate ID</th>
<th title="click to sort by score">Score</th>
{% if exam.app_name == "longs" or exam.app_name == "rapids" %}
<td>{{user_scores_normalised|get_item:cid}}</td>
<th>Normalised Score</th>
{% endif %}
<th>Status</th>
{% if can_edit %}
<th class="sorttable_nosort" title="Include in stats calculations">Stats</th>
{% endif %}
</tr>
{% endfor %}
</table>
{% for cid in cids %}
<tr class="candidate-row" data-answer-count={{user_answer_count|get_item:cid}} data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">
{% if cid|slice:":1" == "u" %}
<td class="cid" >{{cids_user_id_map|get_item:cid}}</td>
{% else %}
<td class="cid" data-cid="{{cid|slice:'2:'}}"><a href="{% url 'cid_scores_admin' cid|slice:'2:' %}">{{cid}}</a>
<span title="Click to show candidate details"
hx-get="{% url 'generic:cid_details' cid|slice:'2:' %}"
hx-target="this"
>
<i class="bi bi-binoculars search-cid" ></i>
</span>
</td>
{% endif %}
<td>{{user_scores|get_item:cid}}</td>
{% if exam.app_name == "longs" or exam.app_name == "rapids" %}
<td>{{user_scores_normalised|get_item:cid}}</td>
{% endif %}
<td>
{% if pass_status|get_item:cid == 'Pass' %}
<span class="badge bg-success">Pass</span>
{% else %}
<span class="badge bg-danger">Fail</span>
{% endif %}
</td>
{% if can_edit %}
<td>
{% include "generic/partials/_candidate_stats_checkbox.html" with cid=cid is_excluded=excluded_map|get_item:cid exam=exam %}
</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<h5 class="mb-0">Answers as a table</h5>
<div>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#answers-table" aria-expanded="true">Toggle answers</button>
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#answers-table-container" aria-expanded="true">Toggle answers</button>
</div>
</div>
<div class="collapse show" id="answers-table">
<div class="table-responsive">
<table class="table table-dark table-striped table-hover table-sm col-sm small">
<thead class="thead-dark">
<tr>
<th>Candidate</th>
{% for cid in cids %}
{% comment %} <th><a href="{% url exam.app_name|add:':exam_scores_cid_user' exam.pk cid %}">{{cid}}</a></th> {% endcomment %}
<th>
{% if cid|slice:":1" == "u" %}
<a href="{% url exam.app_name|add:':exam_scores_user_admin' exam.pk cid|slice:'2:' %}">
{% else %}
<a href="{% url exam.app_name|add:':exam_scores_cid_user_admin' exam.pk cid|slice:'2:' %}">
{% endif %}
{{cids_user_id_map|get_item:cid}}</a></th>
{% endfor %}
</tr>
</thead>
{% block table_answers %}
{% endblock table_answers %}
{% comment %} {% for question in questions %}
<tr>
<td><a href="{% url exam.app_name|add:':mark' exam_pk=exam.pk sk=forloop.counter0 %}">Question {{forloop.counter}}</a>
{% if question.normal %}
[N]
{% else %}
[A]
{% endif %}
</td>
{% for cid in cids %}
{% with by_question|get_item:question|get_item:cid as ans_score %}
<td class="user-answer-score-{{ans_score.1}}" title="answer score: {{ans_score.1}} [{{ans_score.2}}]">{{ans_score.0}}</td>
{% endwith %}
{% endfor %}
</tr>
{% endfor %} {% endcomment %}
<tr>
<td>Score:</td>
{% for cid in cids %}
<td>{{user_scores|get_item:cid}}</td>
{% endfor %}
</tr>
</table>
<div class="collapse show" id="answers-table-container">
<div hx-get="{% url exam.app_name|add:':exam_scores_all' exam.pk %}?table_only=true"
hx-trigger="load"
hx-swap="outerHTML">
<div class="text-center py-4">
<div class="spinner-border text-info" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="text-muted mt-2">Loading answers table...</div>
</div>
</div>
</div>
{% block breakdown %}{% endblock breakdown %}
<details
hx-get="{% url exam.app_name|add:':exam_report_email_status' exam_id=exam.pk %}"
hx-trigger="revealed"
hx-target="#user-details">
<details
hx-get="{% url exam.app_name|add:':exam_report_email_status' exam_id=exam.pk %}"
hx-trigger="revealed"
hx-target="#user-details">
<summary>Email results</summary>
User results emailed: {{exam.exam_results_emailed|default:"Never"}}<br/>
<button id="email-results-button"
@@ -174,19 +181,48 @@
{% block js %}
<script>
function filterCandidates(query) {
const q = query.toLowerCase().trim();
// Filter candidate list rows
document.querySelectorAll('.candidate-row').forEach(row => {
const name = row.getAttribute('data-cid-name') || '';
const val = row.getAttribute('data-cid-val') || '';
if (name.includes(q) || val.includes(q)) {
row.classList.remove('d-none');
} else {
row.classList.add('d-none');
}
});
// Filter columns in answers table
document.querySelectorAll('.col-cid-cell').forEach(cell => {
const name = cell.getAttribute('data-cid-name') || '';
const val = cell.getAttribute('data-cid-val') || '';
if (name.includes(q) || val.includes(q)) {
cell.classList.remove('d-none');
} else {
cell.classList.add('d-none');
}
});
}
$(document).ready(() => {
let question_number = document.getElementById("question-number").innerHTML;
console.log(question_number)
const question_number = {{ question_number }};
$(".candidate-row").each((index, el) => {
console.log(index, el)
if (el.dataset.answerCount != question_number) {
$(el).addClass("missing-answers")
}
});
});
// Re-run filter after HTMX content is swapped in
document.addEventListener('htmx:afterSwap', function(evt) {
const filterInput = document.getElementById('candidate-filter');
if (filterInput && filterInput.value) {
filterCandidates(filterInput.value);
}
});
</script>
<style>
.missing-answers td:first-child:before {
@@ -0,0 +1,27 @@
<div class="table-responsive" id="answers-table">
<table class="table table-dark table-striped table-hover table-sm col-sm small">
<thead class="thead-dark">
<tr>
<th>Candidate</th>
{% for cid in cids %}
<th class="col-cid-cell col-cid-{{ cid|slugify }}" data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">
{% if cid|slice:":1" == "u" %}
<a href="{% url exam.app_name|add:':exam_scores_user_admin' exam.pk cid|slice:'2:' %}">
{% else %}
<a href="{% url exam.app_name|add:':exam_scores_cid_user_admin' exam.pk cid|slice:'2:' %}">
{% endif %}
{{cids_user_id_map|get_item:cid}}</a>
</th>
{% endfor %}
</tr>
</thead>
{% block table_answers %}
{% endblock table_answers %}
<tr>
<td>Score:</td>
{% for cid in cids %}
<td class="col-cid-cell col-cid-{{ cid|slugify }}" data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">{{user_scores|get_item:cid}}</td>
{% endfor %}
</tr>
</table>
</div>
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="container my-5">
<div class="card bg-dark text-white border-secondary">
<div class="card-body">
<h1 class="card-title mb-4">{{ app_name|title }} Help</h1>
<p class="card-text">Welcome to the radiology education platform. There is no custom help document for the <strong>{{ app_name }}</strong> application yet.</p>
<hr class="border-secondary my-4">
<a href="javascript:history.back()" class="btn btn-secondary">Go Back</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends 'generic/base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h3 class="card-title mb-0">Import Exam Answers</h3>
</div>
<div class="card-body">
<p class="text-muted">
Upload a JSON file containing exam answers manually exported from RTS.
</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<label for="answers_file" class="form-label">Answers JSON File</label>
<input type="file" class="form-control" id="answers_file" name="answers_file" accept=".json" required>
</div>
<div class="d-flex justify-content-between align-items-center mt-4">
<a href="{% url 'generic:manage_cids' %}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Import Answers</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

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