Compare commits
107 Commits
797c002e8b
...
cdcdeb761b
| Author | SHA1 | Date | |
|---|---|---|---|
| cdcdeb761b | |||
| 8e1d6fdd60 | |||
| 479e37f346 | |||
| 0afed5a983 | |||
| f51faff534 | |||
| f27936b2fb | |||
| 685f66854e | |||
| 8ae33e32ee | |||
| d322806b80 | |||
| 2c56fcd4e7 | |||
| 66cca1a512 | |||
| 3a3ec75b1d | |||
| 89bf6eedc0 | |||
| fa98541e31 | |||
| 50b80fd0c7 | |||
| 7bd1475daf | |||
| b13261c302 | |||
| 94b08c5c21 | |||
| 8f7ec905a6 | |||
| 5139346d28 | |||
| c6fe4df763 | |||
| 5414a5b4f9 | |||
| 5a73409aad | |||
| 1b2715642a | |||
| 5ae51a7e71 | |||
| edb4e071f4 | |||
| e56c1f2d0f | |||
| daebdc1844 | |||
| 5a2baaeab8 | |||
| 5ce4a67b31 | |||
| 89963d6257 | |||
| 2744758aa8 | |||
| bb6b4c625c | |||
| 55fcf71431 | |||
| 277322059c | |||
| d8bacfefa9 | |||
| 856fb0bf08 | |||
| ce323c4973 | |||
| a60982c30a | |||
| 1296089607 | |||
| 077c858024 | |||
| 12014fbb23 | |||
| 17043722e1 | |||
| 1317d6a438 | |||
| 2425e62caf | |||
| c34dcdfd5e | |||
| 79ba4db4bf | |||
| d07d88bdbf | |||
| d32e2a5231 | |||
| 5ad66cb951 | |||
| 4e29941ae4 | |||
| 2e11e40b7c | |||
| ed77af215f | |||
| 28204d7ec1 | |||
| 0110a6c4d4 | |||
| 24d9594446 | |||
| 56cd257474 | |||
| 86a1a935e1 | |||
| dbb762bb09 | |||
| 6ebe74d785 | |||
| 21eabcf3ec | |||
| 1ad87d8b31 | |||
| 8003dcd714 | |||
| b5d844e3aa | |||
| 1b3b21a6f5 | |||
| a573ed78a4 | |||
| c52cf46d65 | |||
| 1f0877964b | |||
| c50ace771e | |||
| 35ab7443d2 | |||
| fc9f2ed6af | |||
| afd4cc3b87 | |||
| aaee3577ad | |||
| 96d5fa4a2e | |||
| 6bb9441748 | |||
| e670adf2d0 | |||
| 145aa442f0 | |||
| ddfb591921 | |||
| e78a69367f | |||
| d89df3d0a6 | |||
| 56bc17d1c7 | |||
| 8c0d97d36d | |||
| 161da980cf | |||
| 9919af2b20 | |||
| 390ccc0234 | |||
| a9c417d993 | |||
| 0f11f0800e | |||
| 3d445fe12e | |||
| 2e854c43b6 | |||
| 46518a45c1 | |||
| 213ee17328 | |||
| 92d4ece061 | |||
| 74f1ad4b2c | |||
| 0412aa82e4 | |||
| f2a52bed23 | |||
| 1d85666127 | |||
| 40cea43578 | |||
| 6aada9f991 | |||
| 01d44bc8e4 | |||
| 98df8ec176 | |||
| 4cc758bc66 | |||
| 060333c006 | |||
| d74cfe73e0 | |||
| c1005712e0 | |||
| 9592568dbb | |||
| ee4fe2b85b | |||
| 6368f525e1 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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> — Viewing <strong>{{ target_user.get_full_name|default:target_user.username }}</strong>’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>
|
||||
|
||||
@@ -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"><{{ u.email }}></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"><{{ u.email }}></span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if sharing_summary.supervisor_always %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-info text-dark me-1">Supervisor</span>
|
||||
{% if sharing_summary.supervisor %}
|
||||
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
|
||||
(always shared – collection setting)
|
||||
{% else %}
|
||||
Your supervisor, if one is linked to your profile (collection setting always on).
|
||||
{% endif %}
|
||||
</li>
|
||||
{% elif sharing_summary.supervisor_opt_in %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
|
||||
{% if sharing_summary.supervisor %}
|
||||
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
|
||||
— you can choose to share with them after starting.
|
||||
{% else %}
|
||||
You have a supervisor linked but they don't have a login account yet.
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
|
||||
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% 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"><{{ u.email }}></span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if sharing_summary.supervisor_always %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-info text-dark me-1">Supervisor</span>
|
||||
{% if sharing_summary.supervisor %}
|
||||
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
|
||||
(always shared – collection setting)
|
||||
{% else %}
|
||||
Your supervisor, if one is linked to your profile (collection setting always on).
|
||||
{% endif %}
|
||||
</li>
|
||||
{% elif sharing_summary.supervisor_opt_in %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
|
||||
{% if sharing_summary.supervisor %}
|
||||
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
|
||||
— you can choose to share with them after starting.
|
||||
{% else %}
|
||||
You have a supervisor linked but they don't have a login account yet.
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
|
||||
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% include "atlas/collection_review_start.html#sharing-info" %}
|
||||
{% else %}
|
||||
<section class="take-start-card">
|
||||
<p class="mb-3">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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/<id>/</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"><{{ u.email }}></span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if sharing_summary.supervisor_always %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-info text-dark me-1">Supervisor</span>
|
||||
{% if sharing_summary.supervisor %}
|
||||
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
|
||||
(always shared - collection setting)
|
||||
{% else %}
|
||||
Your supervisor, if one is linked to your profile (collection setting always on).
|
||||
{% endif %}
|
||||
</li>
|
||||
{% elif sharing_summary.supervisor_opt_in %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
|
||||
{% if sharing_summary.supervisor %}
|
||||
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
|
||||
- you can choose to share with them after starting.
|
||||
{% else %}
|
||||
You have a supervisor linked but they do not have a login account yet.
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
|
||||
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -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,4 +1,4 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% extends base_template|default:"atlas/base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
# Atlas tests package
|
||||
+278
-6
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+23
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user