Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a9118faf | ||
|
|
63c6a648d7 | ||
|
|
517c30ba0e | ||
|
|
590e0b76ed | ||
|
|
e9a04e823c | ||
|
|
9ed789ba90 | ||
|
|
fd6e25d55e | ||
|
|
f074e5b086 | ||
|
|
2157b6f781 | ||
|
|
359682a0cd | ||
|
|
78577076c1 | ||
|
|
3635275573 | ||
|
|
0c412dfff6 | ||
|
|
3f00311136 | ||
|
|
e8c35efd83 | ||
|
|
0badca4c4f | ||
|
|
25f4e478b3 | ||
|
|
33cba3d005 | ||
|
|
efca426359 | ||
|
|
f713632fdd | ||
|
|
785638ee23 | ||
|
|
e8020c5d8f | ||
|
|
5ccf755e72 | ||
|
|
6308fbfcd7 | ||
|
|
8a871d6332 | ||
|
|
6eeb4cbb1d | ||
|
|
9d57d03cbb | ||
|
|
21de543313 | ||
|
|
a12a9e38f3 | ||
|
|
81dc5bf48f | ||
|
|
4abb57900d | ||
|
|
08c9138cdb | ||
|
|
fb833a2b90 | ||
|
|
2f1e2f6abe | ||
|
|
ffa717a9ef | ||
|
|
2dd9f4af32 | ||
|
|
feb5a85af2 | ||
|
|
292f81ad74 | ||
|
|
349c46c6db | ||
|
|
681a7560ba | ||
|
|
aa90ae5e09 | ||
|
|
f55bb1b18e | ||
|
|
5e78cc4603 | ||
|
|
a9cbb1303a | ||
|
|
24558f92ce | ||
|
|
0f8bae8a14 | ||
|
|
6e658aca93 | ||
|
|
81cd109449 | ||
|
|
d86f990be2 | ||
|
|
b6478c44dc | ||
|
|
12b7fa6629 | ||
|
|
7f4570417e | ||
|
|
0098fa5dfc | ||
|
|
740d013198 | ||
|
|
b47bb141e8 | ||
|
|
712ecec25d | ||
|
|
0193daf166 | ||
|
|
279d6344f1 | ||
|
|
8c8d484dce | ||
|
|
9760be8f27 | ||
|
|
5e0068d490 | ||
|
|
875744a08a | ||
|
|
c02a46fab1 | ||
|
|
6b73006122 | ||
|
|
7eb478b111 | ||
|
|
10c18bd514 | ||
|
|
1407fe83a2 | ||
|
|
e9716f3caf | ||
|
|
749450b03e | ||
|
|
7e72f731b3 | ||
|
|
e407326c25 | ||
|
|
0d8177f0b4 | ||
|
|
3312f3058a | ||
|
|
478dcb7217 | ||
|
|
a1aba7836a | ||
|
|
80b1ef3817 | ||
|
|
835d0382ba | ||
|
|
8096a36a11 | ||
|
|
97d65f0caa | ||
|
|
b73e89393b | ||
|
|
ec036a6266 | ||
|
|
14e6514b90 | ||
|
|
639e91fc1e | ||
|
|
74765e70a2 | ||
|
|
ca2b7feb5b | ||
|
|
453fbf91be | ||
|
|
a2e9d5e681 | ||
|
|
8da2dd27da | ||
|
|
970242e1fb | ||
|
|
f8be1918f6 | ||
|
|
5ac998454b | ||
|
|
d259d0e8dd | ||
|
|
5fb3c98941 | ||
|
|
a70354c18d | ||
|
|
345977e2ef | ||
|
|
726c7ea401 | ||
|
|
7fa08037f0 | ||
|
|
f786e88de9 | ||
|
|
9441541feb | ||
|
|
5cca1b079a | ||
|
|
2e551d411a | ||
|
|
d8055a509c | ||
|
|
208252a858 | ||
|
|
ea9dca5804 | ||
|
|
da04138861 | ||
|
|
ec0245fe7c | ||
|
|
c39d30c746 | ||
|
|
d781508364 | ||
|
|
06ea87be14 | ||
|
|
ce1b439681 | ||
|
|
c8375a28fa | ||
|
|
6be39ab69d | ||
|
|
e9d0e402be | ||
|
|
dc23dffaf9 |
@@ -0,0 +1,22 @@
|
||||
# Django
|
||||
DEBUG=1
|
||||
SECRET_KEY=w(s0&(_eb058wvmg@44_repv8)r9@5p8fx*g_@c)1dm&d*ew^u
|
||||
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
||||
|
||||
# External DB (you mentioned production uses an external DB)
|
||||
DB_HOST=db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com
|
||||
DB_PORT=25060
|
||||
DB_NAME=testing
|
||||
DB_USER=django
|
||||
DB_PASSWORD=AVNS_NG_s4i7SMMobWLO
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Gunicorn tuning
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_LOGLEVEL=info
|
||||
|
||||
# Nginx host ports for local development (override prod defaults)
|
||||
NGINX_HTTP_PORT=8000
|
||||
NGINX_HTTPS_PORT=8443
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copy this to .env.prod on the server and fill in production values (do NOT commit secrets).
|
||||
|
||||
# Django
|
||||
SECRET_KEY=replace-me-with-a-secure-random-string
|
||||
DEBUG=0
|
||||
ALLOWED_HOSTS=example.com
|
||||
|
||||
# External DB (you mentioned production uses an external DB)
|
||||
DATABASE_HOST=your.db.host
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=rad
|
||||
DATABASE_USER=django
|
||||
DATABASE_PASSWORD=strong-db-password
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Gunicorn tuning
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_LOGLEVEL=info
|
||||
@@ -12,3 +12,7 @@ venv
|
||||
*.log
|
||||
|
||||
temp/
|
||||
|
||||
# Loki runtime data
|
||||
/docker/loki-data/
|
||||
/docker/loki-wal/
|
||||
+15
-16
@@ -19,51 +19,50 @@ from django.contrib.auth import get_user_model
|
||||
router = Router()
|
||||
|
||||
class CidUserSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = CidUser
|
||||
|
||||
model_fields = ["cid", "name", "email"]
|
||||
fields = ["cid", "name", "email"]
|
||||
|
||||
class UserUserSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
model_fields = ["id", "email"]
|
||||
fields = ["id", "email"]
|
||||
|
||||
class CidUserExamSchema(ModelSchema):
|
||||
cid_user : CidUserSchema | None
|
||||
user_user : UserUserSchema | None
|
||||
class Config:
|
||||
class Meta:
|
||||
model = CidUserExam
|
||||
model_fields = ["id"]
|
||||
fields = ["id"]
|
||||
|
||||
class ExamStatusSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = ExamUserStatus
|
||||
model_fields = ["id", "datetime", "status", "extra"]
|
||||
fields = ["id", "datetime", "status", "extra"]
|
||||
|
||||
class ExamUserStatusSchema(ModelSchema):
|
||||
|
||||
#cid_user : CidUserSchema | None
|
||||
#user_user : UserUserSchema | None
|
||||
cid_user_exam : CidUserExamSchema | None
|
||||
class Config:
|
||||
class Meta:
|
||||
model = ExamUserStatus
|
||||
model_fields = ["id", "datetime", "status", "extra"]
|
||||
fields = ["id", "datetime", "status", "extra"]
|
||||
|
||||
class QuestionSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Question
|
||||
|
||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||
model_fields = "__all__"
|
||||
fields = "__all__"
|
||||
|
||||
#model_exclude = ["answers"]
|
||||
#exclude = ["answers"]
|
||||
|
||||
class ExamSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Exam
|
||||
|
||||
model_fields = ["id", "name", "active", "publish_results"]
|
||||
fields = ["id", "name", "active", "publish_results"]
|
||||
|
||||
@router.get('/')
|
||||
def list_questions(request):
|
||||
|
||||
@@ -28,32 +28,60 @@
|
||||
{% if object %}
|
||||
{% include 'anatomy/question_link_header.html' %}
|
||||
{% endif %}
|
||||
<h2>Question Answers</h2>
|
||||
<details class="help-text">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="mb-0">Question Answers</h2>
|
||||
<small class="text-muted">Manage answer keys for auto-marking</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<details class="help-text mb-3">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This page can be used to add and modify answers associated with a question. Answers in here will be used for automarking submitted answers so it is best not to remove. Updating an answer here will not automatically update a submitted answers (scores cache would need to be refreshed)</p>
|
||||
<p class="mb-0">This page can be used to add and modify answers associated with a question. Answers here are used for auto-marking submitted responses; avoid removing entries unless you intend to re-score submissions.</p>
|
||||
</details>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
|
||||
{% csrf_token %}
|
||||
<h3>Answers:</h3>
|
||||
<div id="form_set">
|
||||
|
||||
<h3 class="h5 mt-2">Answers</h3>
|
||||
<div id="form_set" class="mt-3">
|
||||
{% for form in answer_formset %}
|
||||
<ul class="no-error answer-formset">
|
||||
<div class="answer-card mb-3 p-3 border rounded bg-body-secondary">
|
||||
{{form.non_field_errors}}
|
||||
{{form.errors}}
|
||||
<div class="answer-fields">
|
||||
{{ form.as_ul }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ answer_formset.management_form }}
|
||||
<input type="button" value="Add More Answers" id="add_more">
|
||||
<p>
|
||||
<input type="submit" class="submit-button btn btn-primary" value="Submit" name="submit">
|
||||
</p>
|
||||
|
||||
<div class="d-flex gap-2 align-items-center mt-3">
|
||||
<button type="button" id="add_more" class="btn btn-outline-secondary btn-sm">Add another answer</button>
|
||||
<input type="submit" class="submit-button btn btn-primary btn-sm ms-auto" value="Save answers" name="submit">
|
||||
</div>
|
||||
</form>
|
||||
<div id="empty_form" style="display:none">
|
||||
<ul class='no_error answer-formset'>
|
||||
|
||||
<div id="empty_form" class="d-none">
|
||||
<div class='answer-card p-3 mb-3 border rounded bg-body-secondary'>
|
||||
{{ answer_formset.empty_form.as_ul }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Answer card layout */
|
||||
.answer-card label { font-weight: 600; }
|
||||
.answer-card li { list-style: none; margin-bottom: .5rem; }
|
||||
.answer-card input[type="text"], .answer-card textarea, .answer-card select { width: 100%; }
|
||||
.help-text summary { cursor: pointer; font-weight:600; }
|
||||
/* Make the Add button more prominent on small screens */
|
||||
@media (max-width: 576px) {
|
||||
.submit-button { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -2,16 +2,30 @@
|
||||
|
||||
{% block navigation %}
|
||||
{{block.super}}
|
||||
<br/>
|
||||
Exams: {{exam}}->
|
||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
|
||||
<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>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
|
||||
</span>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="examNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a></li>
|
||||
{% if exam.exam_mode %}
|
||||
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
|
||||
<a href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a> /
|
||||
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
||||
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
||||
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||
{% endif %}
|
||||
<a href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a>
|
||||
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||
</ul>
|
||||
|
||||
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">Mark2 — Marking: {{ exam }}</h2>
|
||||
<p class="small text-muted mb-0">Use the improved HTMX marking UI by opening a question below.</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="btn-group me-2" role="group" aria-label="listing actions">
|
||||
|
||||
@@ -1,28 +1,95 @@
|
||||
<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>Question:</strong>
|
||||
{% if question.stem %}
|
||||
{{ question.stem|striptags|truncatechars:80 }}
|
||||
{% else %}
|
||||
#{{ question.pk }}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
||||
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
|
||||
<a href="{% url 'anatomy:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
|
||||
<a href="{% url 'anatomy:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a>
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
|
||||
Add Note</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="questionNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
{% if exam %}
|
||||
<li class="nav-item">
|
||||
{% if previous > -1 %}
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="nav-item align-self-center">
|
||||
Viewing as part of exam: <a class="nav-link d-inline p-0" href="{% url 'anatomy:exam_overview' exam.id %}">{{ exam }}</a>
|
||||
<span class="navbar-text text-muted ms-2">[{{ pos }}/{{ exam_length }}]</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if next %}
|
||||
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="questionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="questionActions">
|
||||
<li>
|
||||
<a class="dropdown-item text-primary" href="{% url 'anatomy:question_detail' question.id %}">
|
||||
<i class="bi bi-eye me-2"></i> View
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
|
||||
<i class="bi bi-pencil-square me-2"></i> Edit
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
|
||||
<i class="bi bi-list-check me-2"></i> Edit Answers
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url 'anatomy:question_clone' question.id %}">
|
||||
<i class="bi bi-files me-2"></i> Clone
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-info" href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
|
||||
<i class="bi bi-chat-left-text me-2"></i> Add Note
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
<li>
|
||||
<a class="dropdown-item text-danger" href="{% url 'anatomy:question_delete' pk=question.pk %}">
|
||||
<i class="bi bi-trash me-2"></i> Delete
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}"
|
||||
title="Edit the Question using the admin interface">Admin Edit</a>
|
||||
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
|
||||
{% endif %}
|
||||
{% if exam %}
|
||||
<div>
|
||||
|
||||
{% if previous > -1 %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
|
||||
<i class="bi bi-tools me-2"></i> Admin Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
|
||||
<i class="bi bi-people me-2"></i> User answers
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -3,6 +3,7 @@ from .models import (
|
||||
Case,
|
||||
CaseDetail,
|
||||
CaseCollection,
|
||||
NormalCase,
|
||||
Differential,
|
||||
Series,
|
||||
SeriesImage,
|
||||
@@ -79,6 +80,22 @@ class CaseCollectionAdmin(VersionAdmin):
|
||||
|
||||
admin.site.register(CaseCollection, CaseCollectionAdmin)
|
||||
|
||||
|
||||
class NormalCaseAdmin(VersionAdmin):
|
||||
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
|
||||
list_filter = ("examination", "modality")
|
||||
search_fields = ("case__title", "case__pk")
|
||||
|
||||
def display_age(self, obj):
|
||||
try:
|
||||
return obj.display_age()
|
||||
except Exception:
|
||||
return ""
|
||||
display_age.short_description = "Age"
|
||||
|
||||
|
||||
admin.site.register(NormalCase, NormalCaseAdmin)
|
||||
|
||||
class AtlasAdminForm(ModelForm):
|
||||
class Meta:
|
||||
model = Case
|
||||
|
||||
+4
-4
@@ -32,9 +32,9 @@ router = Router()
|
||||
class SeriesSchema(ModelSchema):
|
||||
case_id: List[int] = []
|
||||
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Series
|
||||
model_fields = [
|
||||
fields = [
|
||||
"id",
|
||||
"modality",
|
||||
"examination",
|
||||
@@ -48,9 +48,9 @@ class SeriesSchema(ModelSchema):
|
||||
|
||||
|
||||
class CaseSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Case
|
||||
model_fields = ["id", "title"]
|
||||
fields = ["id", "title"]
|
||||
|
||||
|
||||
@router.post("/upload_dicom", auth=django_auth)
|
||||
|
||||
+130
-5
@@ -11,6 +11,9 @@ from .models import (
|
||||
Series,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
NormalCase,
|
||||
Resource,
|
||||
Site,
|
||||
)
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
@@ -161,13 +164,16 @@ class SeriesFilter(django_filters.FilterSet):
|
||||
|
||||
|
||||
class ConditionFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary` filter with `is_canonical` and provide a
|
||||
# `synonym` filter which will filter by the canonical group of the
|
||||
# selected condition.
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Condition
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
"primary": ["exact"],
|
||||
"synonym": ["exact"],
|
||||
#"parent": ["exact"],
|
||||
"subspecialty": ["exact"],
|
||||
"rcr_curriculum": ["exact"],
|
||||
}
|
||||
@@ -186,11 +192,32 @@ class ConditionFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Condition instance selected in the filter. Return all
|
||||
# Conditions that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class FindingFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary`/`synonym` with canonical-based filters
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = ("name", "primary", "synonym")
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -206,11 +233,29 @@ class FindingFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Finding instance selected in the filter. Return all
|
||||
# Findings that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class StructureFilter(django_filters.FilterSet):
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Structure
|
||||
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
|
||||
fields = {"name": ["icontains"]}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -226,6 +271,19 @@ class StructureFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class PresentationFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
@@ -286,6 +344,73 @@ class SubspecialtyFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
class NormalCaseFilter(django_filters.FilterSet):
|
||||
"""FilterSet for the NormalCase model allowing filtering by
|
||||
examination, modality and nearest-year age ranges.
|
||||
"""
|
||||
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||
# Accept min/max as years in the UI but filter against the canonical
|
||||
# `age_days` field by converting years -> days.
|
||||
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
|
||||
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "age_days")
|
||||
|
||||
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
||||
# Use the global Examination and Modality querysets from the Series model
|
||||
from generic.models import Examination, Modality
|
||||
|
||||
# Set up the actual querysets for the filters
|
||||
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||
|
||||
# If not an atlas editor restrict to cases authored by the user or open access cases
|
||||
if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
|
||||
from django.db.models import Q
|
||||
|
||||
if queryset is None:
|
||||
queryset = NormalCase.objects.select_related("case")
|
||||
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
|
||||
|
||||
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||
|
||||
def filter_min_age(self, queryset, name, value):
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__gte=days)
|
||||
|
||||
def filter_max_age(self, queryset, name, value):
|
||||
try:
|
||||
days = int(float(value) * 365.25)
|
||||
except Exception:
|
||||
return queryset
|
||||
return queryset.filter(age_days__lte=days)
|
||||
|
||||
|
||||
class ResourceFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
|
||||
site = django_filters.ModelMultipleChoiceFilter(field_name='sites', queryset=lambda request: Site.objects.all() if True else Site.objects.none())
|
||||
subspecialty = django_filters.ModelMultipleChoiceFilter(field_name='subspecialty', queryset=Subspecialty.objects.all())
|
||||
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label='No author')
|
||||
has_file = django_filters.BooleanFilter(method='filter_has_file', label='Has file')
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = ('name', 'site', 'subspecialty', 'author')
|
||||
|
||||
def filter_has_file(self, queryset, name, value):
|
||||
if value is True:
|
||||
return queryset.exclude(file__isnull=True).exclude(file__exact='')
|
||||
elif value is False:
|
||||
return queryset.filter(file__isnull=True) | queryset.filter(file__exact='')
|
||||
return queryset
|
||||
|
||||
class QuestionSchemaFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = QuestionSchema
|
||||
|
||||
+155
-55
@@ -40,10 +40,13 @@ from atlas.models import (
|
||||
Condition,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
Presentation,
|
||||
PathologicalProcess,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
CaseDisplaySet,
|
||||
)
|
||||
from .models import NormalCase
|
||||
|
||||
from anatomy.models import Modality
|
||||
|
||||
@@ -53,7 +56,8 @@ from generic.models import Examination # , Sign
|
||||
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
|
||||
from django.urls import reverse
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
@@ -115,11 +119,10 @@ class ConditionForm(ModelForm):
|
||||
class Meta:
|
||||
model = Condition
|
||||
exclude = ["rcr_curriculum"]
|
||||
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
# Use an AJAX/autocomplete single-select for canonical so users
|
||||
# can search for existing canonical Conditions by name.
|
||||
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
|
||||
"parent": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
@@ -128,6 +131,32 @@ class ConditionForm(ModelForm):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Crispy helper to render a submit button consistently
|
||||
try:
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = True
|
||||
# Primary submit
|
||||
self.helper.add_input(Submit("submit", "Submit"))
|
||||
# Cancel as a button that redirects back to the condition list
|
||||
try:
|
||||
cancel_url = reverse("atlas:condition_view")
|
||||
except Exception:
|
||||
cancel_url = "#"
|
||||
self.helper.add_input(
|
||||
Button(
|
||||
"cancel",
|
||||
"Cancel",
|
||||
css_class="btn btn-secondary ms-2",
|
||||
onclick=f"window.location='{cancel_url}'",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# If crispy not available or something goes wrong, don't break form
|
||||
pass
|
||||
|
||||
|
||||
class CaseCollectionForm(ModelForm):
|
||||
class Meta:
|
||||
@@ -141,6 +170,25 @@ class CaseCollectionForm(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
super(CaseCollectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Render collection_type as radio buttons and ensure it appears near the top
|
||||
if "collection_type" in self.fields:
|
||||
# Replace the model field with an explicit ChoiceField that uses RadioSelect
|
||||
original = self.fields["collection_type"]
|
||||
choices = getattr(original, "choices", ())
|
||||
initial = original.initial if hasattr(original, "initial") else None
|
||||
required = original.required
|
||||
label = original.label
|
||||
widget = RadioSelect(attrs={"class": "btn-check"})
|
||||
# assign custom template for rendering as button group
|
||||
#widget.template_name = "widgets/radioselect_buttons.html"
|
||||
self.fields["collection_type"] = forms.ChoiceField(
|
||||
choices=choices,
|
||||
widget=widget,
|
||||
initial=initial,
|
||||
required=required,
|
||||
label=label,
|
||||
)
|
||||
|
||||
# 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")]
|
||||
@@ -186,6 +234,7 @@ class CaseCollectionForm(ModelForm):
|
||||
Fieldset(
|
||||
"Collection Details",
|
||||
"name",
|
||||
Field("collection_type", css_class="mb-2"),
|
||||
Fieldset(
|
||||
"Dates",
|
||||
"restrict_to_dates",
|
||||
@@ -213,7 +262,6 @@ class CaseCollectionForm(ModelForm):
|
||||
"exam_mode",
|
||||
"self_review",
|
||||
"feedback_once_collection_complete",
|
||||
"collection_type",
|
||||
"viewer_mode",
|
||||
"question_time_limit",
|
||||
"prerequisites",
|
||||
@@ -236,33 +284,53 @@ class CaseCollectionForm(ModelForm):
|
||||
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
|
||||
class NormalCaseForm(ModelForm):
|
||||
# Keep non-model input fields for age entry so users can provide a
|
||||
# value + unit (e.g. 6 months) which will be converted to canonical
|
||||
# days on save. The model now stores only `age_days`.
|
||||
age_value = forms.IntegerField(required=False, min_value=0)
|
||||
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = NormalCase
|
||||
fields = ("examination", "modality", "notes")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
# Do not render a <form> tag from crispy here because the template
|
||||
# already provides the surrounding form element (we inject this
|
||||
# fragment into a modal that contains the form). Rendering a second
|
||||
# <form> would nest forms and cause fields to be outside the
|
||||
# submitted form (HTMX would only send the outer form's fields).
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Div(
|
||||
Field("age_value", wrapper_class="col-md-3"),
|
||||
Field("age_unit", wrapper_class="col-md-3"),
|
||||
Field("examination", wrapper_class="col-md-3"),
|
||||
Field("modality", wrapper_class="col-md-3"),
|
||||
css_class="row",
|
||||
),
|
||||
required=False,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
)
|
||||
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
),
|
||||
required=False,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
Field("notes"),
|
||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
# Get the unsaved Case instance
|
||||
instance = ModelForm.save(self, False)
|
||||
# Respect commit flag. Compute canonical `age_days` from the
|
||||
# provided age_value/age_unit and set it on the instance.
|
||||
instance = super().save(commit=False)
|
||||
age_value = self.cleaned_data.get("age_value")
|
||||
age_unit = self.cleaned_data.get("age_unit")
|
||||
if age_value is not None and age_unit:
|
||||
instance.age_days = NormalCase.to_days(age_value, age_unit)
|
||||
else:
|
||||
# If the user left fields blank, clear any previous value.
|
||||
instance.age_days = None
|
||||
|
||||
# Do we need to save all changes now?
|
||||
# if commit:
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
@@ -273,9 +341,10 @@ class FindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = Finding
|
||||
exclude = []
|
||||
|
||||
# Use the canonical FK in forms (if users want to mark this Finding
|
||||
# as an alias of another). The old `synonym` M2M has been removed.
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
"canonical": autocomplete.ModelSelect2(
|
||||
url="atlas:finding-autocomplete"
|
||||
),
|
||||
}
|
||||
@@ -285,14 +354,31 @@ class StructureForm(ModelForm):
|
||||
class Meta:
|
||||
model = Structure
|
||||
exclude = []
|
||||
|
||||
widgets = {
|
||||
"synonym": autocomplete.ModelSelect2Multiple(
|
||||
"canonical": autocomplete.ModelSelect2(
|
||||
url="atlas:structure-autocomplete"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SubspecialtyForm(ModelForm):
|
||||
class Meta:
|
||||
model = Subspecialty
|
||||
exclude = []
|
||||
|
||||
|
||||
class PresentationForm(ModelForm):
|
||||
class Meta:
|
||||
model = Presentation
|
||||
exclude = []
|
||||
|
||||
|
||||
class PathologicalProcessForm(ModelForm):
|
||||
class Meta:
|
||||
model = PathologicalProcess
|
||||
exclude = []
|
||||
|
||||
|
||||
class SeriesFindingForm(ModelForm):
|
||||
class Meta:
|
||||
model = SeriesFinding
|
||||
@@ -644,7 +730,7 @@ CaseDifferentialFormSet = inlineformset_factory(
|
||||
class ResourceForm(ModelForm):
|
||||
class Meta:
|
||||
model = Resource
|
||||
exclude = ["author"]
|
||||
exclude = ["author", "created_date"]
|
||||
|
||||
widgets = {
|
||||
"description": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
@@ -791,7 +877,7 @@ class BaseReportAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, casedetail, **kwargs):
|
||||
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
||||
self.fields["answer"].required = False
|
||||
|
||||
@@ -807,12 +893,12 @@ class BaseQuestionAnswerForm(ModelForm):
|
||||
# "answer": "Write your answer in here."
|
||||
# }
|
||||
|
||||
def __init__(self, *args, case_detail, **kwargs):
|
||||
def __init__(self, *args, casedetail, **kwargs):
|
||||
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
||||
# self.fields["json_answer"].schema = case_detail.question_schema
|
||||
if case_detail.question_schema is not None:
|
||||
# self.fields["json_answer"].schema = casedetail.question_schema
|
||||
if casedetail.question_schema is not None:
|
||||
self.fields["json_answer"] = JSONSchemaField(
|
||||
schema=case_detail.question_schema, options="schema/options.json"
|
||||
schema=casedetail.question_schema, options="schema/options.json"
|
||||
)
|
||||
self.fields["json_answer"].label = ""
|
||||
|
||||
@@ -973,6 +1059,37 @@ class ConditionAutocomplete(ModelAutocomplete):
|
||||
model = Condition
|
||||
search_attrs = ["name"]
|
||||
|
||||
@classmethod
|
||||
def get_items_from_keys(cls, keys, context_obj=None):
|
||||
"""Return items for given keys, ignoring empty strings.
|
||||
|
||||
The autocomplete frontend sometimes sends empty string keys which
|
||||
causes the ORM to raise ValueError when filtering numeric PK fields.
|
||||
Filter out falsy/blank keys before querying. If no valid keys remain
|
||||
return an empty queryset.
|
||||
"""
|
||||
# normalize and remove empty/blank values
|
||||
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
|
||||
if not clean_keys:
|
||||
return cls.model.objects.none()
|
||||
|
||||
# try to convert numeric keys to ints where possible
|
||||
parsed_keys = []
|
||||
for k in clean_keys:
|
||||
try:
|
||||
parsed_keys.append(int(k))
|
||||
except Exception:
|
||||
parsed_keys.append(k)
|
||||
|
||||
qs = cls.model.objects.filter(pk__in=parsed_keys)
|
||||
|
||||
# Return a list of dicts matching the autocomplete core expectations
|
||||
# (items subscriptable by keys like 'key' / 'label').
|
||||
return [
|
||||
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
|
||||
for obj in qs
|
||||
]
|
||||
|
||||
|
||||
class ConditionAutocompleteForm(Form):
|
||||
condition = ModelChoiceField(
|
||||
@@ -1113,23 +1230,6 @@ class PriorCaseForm(Form):
|
||||
|
||||
prior_visibility = CharField(max_length=2, required=True)
|
||||
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# self.case_detail = kwargs.pop(
|
||||
# "case_detail"
|
||||
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
|
||||
|
||||
# super(PriorCaseForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# prior_cases = self.case_detail.case.get_all_prior_cases()
|
||||
|
||||
# if not prior_cases:
|
||||
# prior_cases = Case.objects.none()
|
||||
|
||||
# self.fields["case"] = ChoiceField(
|
||||
# required=False,
|
||||
# # widget=Select(verbose_name="Series", is_stacked=False),
|
||||
# )
|
||||
|
||||
|
||||
class CaseSeriesForm(forms.ModelForm):
|
||||
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-13 21:43
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0079_casecollection_prerequisites'),
|
||||
('generic', '0029_add_unique_constraint_supervisor_email'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NormalCase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('age_years', models.PositiveSmallIntegerField(blank=True, help_text='Age of the patient at scan in years (rounded to nearest year).', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(150)])),
|
||||
('added_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('notes', models.TextField(blank=True, null=True)),
|
||||
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_normals', to=settings.AUTH_USER_MODEL)),
|
||||
('case', models.OneToOneField(help_text='The Case that is marked as a normal example.', on_delete=django.db.models.deletion.CASCADE, related_name='normal_case', to='atlas.case')),
|
||||
('examination', models.ForeignKey(blank=True, help_text='Primary examination for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.examination')),
|
||||
('modality', models.ForeignKey(blank=True, help_text='Primary modality for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.modality')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-added_date'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-13 22:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0080_normalcase'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='normalcase',
|
||||
name='age_years',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='normalcase',
|
||||
name='age_days',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Patient age at scan in days (canonical).', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-14 20:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0081_remove_normalcase_age_years_normalcase_age_days'),
|
||||
('generic', '0029_add_unique_constraint_supervisor_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='sites',
|
||||
field=models.ManyToManyField(blank=True, help_text='The site(s) this resource is associated with (if any).', related_name='resources', to='generic.site'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='subspecialty',
|
||||
field=models.ManyToManyField(blank=True, help_text='Subspecialty or subspecialties this resource relates to.', to='atlas.subspecialty'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-14 21:02
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0082_resource_sites_resource_subspecialty'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='created_date',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='modified_date',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-15 22:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0083_resource_created_date_resource_modified_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='open_access',
|
||||
field=models.BooleanField(default=True, help_text='If true the resource is available to all users, otherwise only in the context of cases.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-17 10:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0084_resource_open_access'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='caseprior',
|
||||
old_name='case_detail',
|
||||
new_name='casedetail',
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='caseprior',
|
||||
unique_together={('casedetail', 'prior_case')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Condition = apps.get_model("atlas", "Condition")
|
||||
# Build connected components using existing M2M 'synonym' links
|
||||
visited = set()
|
||||
|
||||
for cond in Condition.objects.all():
|
||||
if cond.pk in visited:
|
||||
continue
|
||||
# BFS/DFS to collect component
|
||||
stack = [cond]
|
||||
comp = []
|
||||
while stack:
|
||||
c = stack.pop()
|
||||
if c.pk in visited:
|
||||
continue
|
||||
visited.add(c.pk)
|
||||
comp.append(c)
|
||||
# access existing M2M relations
|
||||
for s in c.synonym.all():
|
||||
if s.pk not in visited:
|
||||
stack.append(s)
|
||||
|
||||
# choose canonical: prefer primary==True else lowest pk
|
||||
canon = None
|
||||
for c in comp:
|
||||
if getattr(c, "primary", False):
|
||||
canon = c
|
||||
break
|
||||
if not canon:
|
||||
canon = min(comp, key=lambda x: x.pk)
|
||||
|
||||
# set canonical for all non-canonical members
|
||||
for c in comp:
|
||||
if c.pk != canon.pk:
|
||||
c.canonical_id = canon.pk
|
||||
c.save()
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
Condition = apps.get_model("atlas", "Condition")
|
||||
for c in Condition.objects.all():
|
||||
if c.canonical_id:
|
||||
c.canonical_id = None
|
||||
c.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0085_rename_case_detail_caseprior_casedetail_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="condition",
|
||||
name="canonical",
|
||||
field=models.ForeignKey(
|
||||
related_name="aliases",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
to="atlas.condition",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-20 22:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0086_add_canonical'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='condition',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Condition is an alias and points to the canonical Condition.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.condition'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 10:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0087_alter_condition_canonical'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='finding',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='finding',
|
||||
name='synonym',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='structure',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='structure',
|
||||
name='synonym',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='finding',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Finding is an alias and points to the canonical Finding.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.finding'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='structure',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Structure is an alias and points to the canonical Structure.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.structure'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 10:31
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0088_remove_finding_primary_remove_finding_synonym_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='condition',
|
||||
name='primary',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='condition',
|
||||
name='synonym',
|
||||
),
|
||||
]
|
||||
+366
-33
@@ -59,6 +59,7 @@ from generic.models import (
|
||||
SeriesBase,
|
||||
SeriesImageBase,
|
||||
Modality,
|
||||
Site,
|
||||
UserUserGroup,
|
||||
)
|
||||
|
||||
@@ -109,36 +110,121 @@ class SynMixin(object):
|
||||
# abstract = True
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.primary:
|
||||
# Determine whether this record should be displayed as the
|
||||
# canonical/primary entry. Prefer the new canonical FK pattern
|
||||
# (`canonical is None` means this object is the master). Fallback
|
||||
# to legacy `primary` boolean when present.
|
||||
# If the model supports canonical FK, consider canonical==None as
|
||||
# the primary/master entry. For models without canonical (legacy
|
||||
# edge-cases), treat the instance as primary by default.
|
||||
if hasattr(self, "canonical"):
|
||||
try:
|
||||
if self.canonical is None or self.canonical == self:
|
||||
return self.name
|
||||
else:
|
||||
return f"{self.name} [syn]"
|
||||
except Exception:
|
||||
# defensive fallback
|
||||
return f"{self.name} [syn]"
|
||||
|
||||
def get_old_str(self) -> str:
|
||||
if self.primary:
|
||||
if self.synonym.count() == 0:
|
||||
# No canonical support: assume primary
|
||||
return self.name
|
||||
|
||||
def get_old_str(self) -> str:
|
||||
# Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
# If the model supports canonical, only canonical==None entries
|
||||
# should be rendered as the primary name. For models without
|
||||
# canonical, treat instance as primary.
|
||||
if hasattr(self, "canonical"):
|
||||
try:
|
||||
is_primary = self.canonical is None or self.canonical == self
|
||||
except Exception:
|
||||
is_primary = False
|
||||
else:
|
||||
synonyms = ",".join([i.name for i in self.synonym.all()])
|
||||
is_primary = True
|
||||
|
||||
if is_primary:
|
||||
if syns is None:
|
||||
synonyms_field = getattr(self, "synonym", None)
|
||||
if synonyms_field is None or (hasattr(synonyms_field, "count") and synonyms_field.count() == 0):
|
||||
return self.name
|
||||
synonyms = ",".join([i.name for i in synonyms_field.all()])
|
||||
return f"{self.name} ({synonyms})"
|
||||
else:
|
||||
if not syns:
|
||||
return self.name
|
||||
synonyms = ",".join([i.name for i in syns])
|
||||
return f"{self.name} ({synonyms})"
|
||||
|
||||
return f"{self.name} [syn]"
|
||||
|
||||
def get_synonym(self):
|
||||
if self.primary:
|
||||
# Return a readable synonym string. For models that implement
|
||||
# get_synonyms (Condition with canonical/aliases), use that.
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
# If canonical is present and this instance is the master, mark Primary
|
||||
if hasattr(self, "canonical"):
|
||||
try:
|
||||
if self.canonical is None or self.canonical == self:
|
||||
return "[Primary]"
|
||||
else:
|
||||
s = self.synonym.filter(primary=True).values_list("name", flat=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if syns is None:
|
||||
synonyms_field = getattr(self, "synonym", None)
|
||||
if synonyms_field is None:
|
||||
return ""
|
||||
# If the old M2M exists, prefer those marked primary if such a
|
||||
# attribute exists on the related objects.
|
||||
try:
|
||||
# Select all synonym names; legacy `primary` flag removed.
|
||||
s = synonyms_field.values_list("name", flat=True)
|
||||
except Exception:
|
||||
s = []
|
||||
return ", ".join(s)
|
||||
|
||||
# prefer primary names among synonyms if present
|
||||
primary_names = []
|
||||
for i in syns:
|
||||
# Prefer items that are canonical/master in the group
|
||||
if getattr(i, "canonical", None) is None:
|
||||
primary_names.append(i.name)
|
||||
if primary_names:
|
||||
return ", ".join(primary_names)
|
||||
return ", ".join([i.name for i in syns])
|
||||
|
||||
def get_synonym_link(self):
|
||||
if self.primary:
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
# Prefer canonical/master check
|
||||
if hasattr(self, "canonical"):
|
||||
try:
|
||||
if self.canonical is None or self.canonical == self:
|
||||
return "[Primary]"
|
||||
else:
|
||||
syns = self.synonym.filter(primary=True)
|
||||
return ", ".join(
|
||||
[f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if syns is None:
|
||||
# fall back to all M2M synonyms (not just primary) for models
|
||||
# that still use the old synonym field (e.g. Finding, Structure)
|
||||
synonyms_field = getattr(self, "synonym", None)
|
||||
if synonyms_field is None:
|
||||
return ""
|
||||
syns_qs = synonyms_field.all()
|
||||
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
|
||||
return ", ".join(items)
|
||||
|
||||
# render links for synonyms/canonical group
|
||||
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
||||
return ", ".join(items)
|
||||
|
||||
def get_link(self):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||
@@ -146,21 +232,49 @@ class SynMixin(object):
|
||||
|
||||
class Finding(SynMixin, models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
synonym = models.ManyToManyField("self", blank=True)
|
||||
|
||||
primary = models.BooleanField(default="True")
|
||||
# New canonical/alias field: if set, this Finding is an alias and points
|
||||
# to the canonical/master Finding. We remove the old M2M `synonym` and
|
||||
# `primary` boolean in favour of this single canonical FK.
|
||||
canonical = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="aliases",
|
||||
help_text="If set, this Finding is an alias and points to the canonical Finding.",
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:finding_detail", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def canonical_finding(self):
|
||||
return self.canonical if self.canonical else self
|
||||
|
||||
def get_synonyms(self):
|
||||
"""Return other Findings that are aliases/synonyms for this concept.
|
||||
|
||||
Behaviour mirrors Condition.get_synonyms: if this Finding is an alias
|
||||
(has canonical set) return the canonical and other aliases (excluding
|
||||
self). If this Finding is canonical, return all aliases (excluding
|
||||
self).
|
||||
"""
|
||||
master = self.canonical_finding
|
||||
qs = Finding.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||
return qs
|
||||
|
||||
|
||||
class Condition(SynMixin, models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
|
||||
synonym = models.ManyToManyField(
|
||||
# New canonical/alias field (Option B): if set, this Condition is an alias
|
||||
# and points to the canonical/master Condition.
|
||||
canonical = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Use if a direct synonym for the condition exists, e.g. 'Wegener granulomatosis' and 'Granulomatosis with Polyangitis'.",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="aliases",
|
||||
help_text="If set, this Condition is an alias and points to the canonical Condition.",
|
||||
)
|
||||
parent = models.ManyToManyField(
|
||||
"self",
|
||||
@@ -171,10 +285,10 @@ class Condition(SynMixin, models.Model):
|
||||
help_text="Use if the condition could be considered a subset of another, e.g. 'Alzheimer disease' and 'Dementia'.",
|
||||
)
|
||||
|
||||
primary = models.BooleanField(
|
||||
default="True",
|
||||
help_text="Sets if this should be the canonical item, all other synonyms will then redirect to this by default.",
|
||||
)
|
||||
# Legacy fields `synonym` (M2M) and `primary` (boolean) have been removed.
|
||||
# Canonical/aliases are represented by the `canonical` ForeignKey and
|
||||
# the reverse `aliases` related_name. Use `get_synonyms()` /
|
||||
# `canonical_condition` to access canonical groups.
|
||||
|
||||
subspecialty = models.ManyToManyField(
|
||||
"subspecialty",
|
||||
@@ -197,6 +311,23 @@ class Condition(SynMixin, models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:condition_detail", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def canonical_condition(self):
|
||||
"""Return the canonical/master condition for this Condition (self if none)."""
|
||||
return self.canonical if self.canonical else self
|
||||
|
||||
def get_synonyms(self):
|
||||
"""Return other Conditions that are synonyms/aliases for this concept.
|
||||
|
||||
Behaviour:
|
||||
- If this Condition is an alias (canonical set), return the canonical and
|
||||
any other aliases (excluding self).
|
||||
- If this Condition is canonical, return all aliases (and exclude self).
|
||||
"""
|
||||
master = self.canonical_condition
|
||||
qs = Condition.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||
return qs
|
||||
|
||||
def get_children(self):
|
||||
return self.child.all()
|
||||
|
||||
@@ -275,14 +406,28 @@ class Differential(models.Model):
|
||||
|
||||
class Structure(SynMixin, models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
|
||||
synonym = models.ManyToManyField("self", blank=True)
|
||||
|
||||
primary = models.BooleanField(default="True")
|
||||
# Migrate to canonical/aliases model like Condition and Finding
|
||||
canonical = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="aliases",
|
||||
help_text="If set, this Structure is an alias and points to the canonical Structure.",
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:structure_detail", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def canonical_structure(self):
|
||||
return self.canonical if self.canonical else self
|
||||
|
||||
def get_synonyms(self):
|
||||
master = self.canonical_structure
|
||||
qs = Structure.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||
return qs
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
@@ -611,6 +756,155 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
return json.dumps(results)
|
||||
|
||||
|
||||
class NormalCase(models.Model):
|
||||
"""Marks a Case as a 'normal' example used in the Atlas normals section.
|
||||
|
||||
This version stores a canonical `age_days` integer (patient age in days).
|
||||
We keep the examination/modality metadata and authored fields. The
|
||||
age fields previously stored (`age_years`, `age_value`, `age_unit`) have
|
||||
been removed in favour of the single canonical `age_days` value.
|
||||
"""
|
||||
case = models.OneToOneField(
|
||||
Case,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="normal_case",
|
||||
help_text="The Case that is marked as a normal example.",
|
||||
)
|
||||
|
||||
# Canonical internal representation: patient age in days
|
||||
age_days = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Patient age at scan in days (canonical).",
|
||||
)
|
||||
|
||||
# Helpful constants for converting/displaying ages. These are not
|
||||
# stored directly on the model but are used by forms/views/templates.
|
||||
AGE_UNIT_YEARS = "years"
|
||||
AGE_UNIT_MONTHS = "months"
|
||||
AGE_UNIT_WEEKS = "weeks"
|
||||
AGE_UNIT_DAYS = "days"
|
||||
AGE_UNIT_CHOICES = [
|
||||
(AGE_UNIT_YEARS, "years"),
|
||||
(AGE_UNIT_MONTHS, "months"),
|
||||
(AGE_UNIT_WEEKS, "weeks"),
|
||||
(AGE_UNIT_DAYS, "days"),
|
||||
]
|
||||
|
||||
examination = models.ForeignKey(
|
||||
Examination,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="normal_cases",
|
||||
help_text="Primary examination for this normal case (optional).",
|
||||
)
|
||||
|
||||
modality = models.ForeignKey(
|
||||
Modality,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="normal_cases",
|
||||
help_text="Primary modality for this normal case (optional).",
|
||||
)
|
||||
|
||||
added_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="added_normals",
|
||||
)
|
||||
|
||||
added_date = models.DateTimeField(default=timezone.now)
|
||||
|
||||
notes = models.TextField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-added_date"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Normal: {self.case} ({self.display_age()})"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.case.get_absolute_url()
|
||||
|
||||
@staticmethod
|
||||
def to_days(value: int | float, unit: str) -> int | None:
|
||||
"""Convert a numeric value + unit into integer days (rounded).
|
||||
|
||||
Examples:
|
||||
to_days(2, 'years') -> ~730
|
||||
to_days(6, 'months') -> ~183
|
||||
"""
|
||||
try:
|
||||
if value is None or unit is None:
|
||||
return None
|
||||
v = float(value)
|
||||
if unit == NormalCase.AGE_UNIT_YEARS:
|
||||
return int(round(v * 365.25))
|
||||
if unit == NormalCase.AGE_UNIT_MONTHS:
|
||||
return int(round(v * 30.4375))
|
||||
if unit == NormalCase.AGE_UNIT_WEEKS:
|
||||
return int(round(v * 7))
|
||||
if unit == NormalCase.AGE_UNIT_DAYS:
|
||||
return int(round(v))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def days_to_value_unit(days: int | None) -> tuple[int | None, str | None]:
|
||||
"""Convert canonical days into a (value, unit) tuple for display.
|
||||
|
||||
Prefer years for ages >= 365 days, months for ages >= 30 days,
|
||||
weeks for >=7 days, else days.
|
||||
"""
|
||||
if days is None:
|
||||
return (None, None)
|
||||
try:
|
||||
d = int(days)
|
||||
if d >= 365:
|
||||
yrs = int(round(d / 365.25))
|
||||
return (yrs, NormalCase.AGE_UNIT_YEARS)
|
||||
if d >= 30:
|
||||
months = int(round(d / 30.4375))
|
||||
return (months, NormalCase.AGE_UNIT_MONTHS)
|
||||
if d >= 7:
|
||||
weeks = int(round(d / 7))
|
||||
return (weeks, NormalCase.AGE_UNIT_WEEKS)
|
||||
return (d, NormalCase.AGE_UNIT_DAYS)
|
||||
except Exception:
|
||||
return (None, None)
|
||||
|
||||
def display_age(self) -> str:
|
||||
"""Return a human friendly age string derived from age_days."""
|
||||
if self.age_days is None:
|
||||
return "age unknown"
|
||||
val, unit = NormalCase.days_to_value_unit(self.age_days)
|
||||
if val is None or unit is None:
|
||||
return "age unknown"
|
||||
# abbreviate years to 'y' for backwards compatibility where helpful
|
||||
if unit == NormalCase.AGE_UNIT_YEARS:
|
||||
return f"{val}y"
|
||||
return f"{val} {unit}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# If examination/modality not specified, try to infer from the first series
|
||||
try:
|
||||
if (self.examination is None or self.modality is None) and self.case.series.exists():
|
||||
first_series = self.case.series.first()
|
||||
if self.examination is None and getattr(first_series, 'examination', None) is not None:
|
||||
self.examination = first_series.examination
|
||||
if self.modality is None and getattr(first_series, 'modality', None) is not None:
|
||||
self.modality = first_series.modality
|
||||
except Exception:
|
||||
# Be defensive: if anything goes wrong whilst inferring, continue and save as-is
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
||||
to_keep = [
|
||||
"Columns",
|
||||
@@ -1293,6 +1587,13 @@ class CaseDetail(models.Model):
|
||||
def __str__(self) -> str:
|
||||
return f"{self.case} -> {self.collection}"
|
||||
|
||||
|
||||
def get_case_index(self):
|
||||
"""Returns the question index (number) in the collection (0 indexed)"""
|
||||
case_details = list(self.collection.casedetail_set.all().order_by("sort_order"))
|
||||
|
||||
return case_details.index(self)
|
||||
|
||||
def get_question_schema(self):
|
||||
return json.dumps(self.question_schema)
|
||||
|
||||
@@ -1395,7 +1696,7 @@ class CaseDetail(models.Model):
|
||||
return mark_safe(html)
|
||||
|
||||
class CasePrior(models.Model):
|
||||
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
casedetail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
|
||||
|
||||
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
|
||||
@@ -1414,10 +1715,10 @@ class CasePrior(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("case_detail", "prior_case")
|
||||
unique_together = ("casedetail", "prior_case")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.case_detail.case} -> {self.prior_case}"
|
||||
return f"{self.casedetail.case} -> {self.prior_case}"
|
||||
|
||||
class BaseReportAnswer(models.Model):
|
||||
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
@@ -1726,6 +2027,27 @@ class Resource(models.Model, AuthorMixin):
|
||||
help_text="Author of the resource",
|
||||
related_name="resources",
|
||||
)
|
||||
created_date = models.DateTimeField(default=timezone.now)
|
||||
modified_date = models.DateTimeField(auto_now=True)
|
||||
# Link resources to one or more Sites (from generic.Site)
|
||||
sites = models.ManyToManyField(
|
||||
'generic.Site',
|
||||
blank=True,
|
||||
help_text='The site(s) this resource is associated with (if any).',
|
||||
related_name='resources',
|
||||
)
|
||||
|
||||
# Categorise resources by subspecialty
|
||||
subspecialty = models.ManyToManyField(
|
||||
Subspecialty,
|
||||
blank=True,
|
||||
help_text='Subspecialty or subspecialties this resource relates to.',
|
||||
)
|
||||
|
||||
open_access = models.BooleanField(
|
||||
default=True,
|
||||
help_text="If true the resource is available to all users, otherwise only in the context of cases."
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
@@ -1753,7 +2075,18 @@ class Resource(models.Model, AuthorMixin):
|
||||
else:
|
||||
html = self.name
|
||||
|
||||
return format_html("<details class='resource-block'><summary>{}</summary>{}</details>", self.name, mark_safe(html))
|
||||
meta = ''
|
||||
if self.sites.exists():
|
||||
meta += ' Sites: ' + ', '.join([s.short_code or s.full_name for s in self.sites.all()])
|
||||
if self.subspecialty.exists():
|
||||
meta += ' Subspecialty: ' + ', '.join([str(s) for s in self.subspecialty.all()])
|
||||
|
||||
return format_html(
|
||||
"<details class='resource-block'><summary>{}</summary><div>{}</div><div class='text-muted small'>{}</div></details>",
|
||||
self.name,
|
||||
mark_safe(html),
|
||||
meta,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
+16
-3
@@ -14,6 +14,7 @@ from .models import (
|
||||
Structure,
|
||||
Finding,
|
||||
Subspecialty,
|
||||
Resource,
|
||||
)
|
||||
|
||||
from django.utils.html import format_html
|
||||
@@ -275,7 +276,7 @@ class ConditionTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Condition
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
@@ -311,7 +312,7 @@ class FindingTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Finding
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
@@ -338,7 +339,7 @@ class StructureTable(SelectionTable):
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Structure
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
|
||||
|
||||
@@ -414,6 +415,18 @@ class SubspecialtyTable(SelectionTable):
|
||||
return format_html(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
class ResourceTable(SelectionTable):
|
||||
name = tables.Column(linkify=("atlas:resource_detail", {"pk": tables.A("pk")}), verbose_name="Resource")
|
||||
|
||||
edit = tables.LinkColumn("atlas:resource_update", text="Edit", args=[A("pk")], orderable=False)
|
||||
delete = tables.LinkColumn("atlas:resource_delete", text="Delete", args=[A("pk")], orderable=False)
|
||||
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Resource
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "description")
|
||||
|
||||
class CaseCollectionTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<span id="add-case-form">
|
||||
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
|
||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case">Select Case to add:</label>
|
||||
<select name="case" id="case">
|
||||
{% for case in cases %}
|
||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
<button _="on click remove #add-case-form">Cancel</button>
|
||||
</form>
|
||||
</span
|
||||
<div id="add-case-form" class="p-2 border rounded">
|
||||
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
|
||||
|
||||
{% include 'atlas/partials/case_search_widget.html' %}
|
||||
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,6 +46,9 @@
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -63,12 +66,48 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</a>
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:categories_list' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>All categories</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:condition_view' %}">Conditions</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:finding_view' %}">Findings</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:structure_view' %}">Structures</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
|
||||
{% if request.user.is_staff %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li class="dropdown-item-text small text-muted px-3">Create</li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:condition_create' %}">New condition</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:finding_create' %}">New finding</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:structure_create' %}">New structure</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:resource_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Resources</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:resource_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Resource</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
||||
</li>
|
||||
|
||||
@@ -147,6 +147,10 @@
|
||||
|
||||
{# Diagnostic certainty as a badge #}
|
||||
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
|
||||
{# Normal toggle HTMX block #}
|
||||
<div class="mt-1">
|
||||
{% include "atlas/partials/_normal_toggle.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -460,7 +464,7 @@
|
||||
|
||||
|
||||
{% if casedetail %}
|
||||
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail can_edit=can_edit %}
|
||||
{% include 'atlas/partials/collection_question_block.html' with can_edit=can_edit %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -3,6 +3,40 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* Turn the existing form-check radio markup into horizontal button-like radios */
|
||||
#div_id_collection_type .form-check { display: inline-block; margin-right: .5rem; }
|
||||
#div_id_collection_type .form-check-input {
|
||||
/* visually hide the native radio but keep it focusable */
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
#div_id_collection_type .form-check-label {
|
||||
display: inline-block;
|
||||
padding: .375rem .75rem;
|
||||
border: 1px solid #0d6efd;
|
||||
border-radius: .375rem;
|
||||
color: #0d6efd;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
#div_id_collection_type .form-check-input:focus + .form-check-label {
|
||||
box-shadow: 0 0 0 .25rem rgba(13,110,253,.25);
|
||||
}
|
||||
#div_id_collection_type .form-check-input:checked + .form-check-label {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
/* small spacing tweak when legend present */
|
||||
#div_id_collection_type fieldset { border: 0; padding: 0; margin: 0 0 1rem 0; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_details" %}
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
<p>This page allows you to configure how the case is displayed as part of the collection.</p>
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_displaysetup" %}
|
||||
<h2>Setup default display for case</h2>
|
||||
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}"
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{casedetail.case.get_series_images_nested}}"
|
||||
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
|
||||
data-auto-cache-stack="true"
|
||||
{% if case_detail.default_viewerstate %}
|
||||
data-viewerstate={{ case_detail.default_viewerstate }}
|
||||
{% if casedetail.default_viewerstate %}
|
||||
data-viewerstate={{ casedetail.default_viewerstate }}
|
||||
{% endif %}
|
||||
|
||||
></div>
|
||||
@@ -29,7 +30,7 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
|
||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||
return;
|
||||
}
|
||||
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -46,7 +47,7 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
|
||||
});
|
||||
});
|
||||
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
||||
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -4,106 +4,28 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_priors" %}
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
<h2 class="mb-3">Managing Priors for Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}" class="text-decoration-none">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<p class="mb-1">This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
|
||||
<p class="mb-0">It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3">Available priors</h3>
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
|
||||
<p>This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
|
||||
<p>It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
|
||||
|
||||
<h3>Available priors</h3>
|
||||
|
||||
<ul>
|
||||
<div class="row g-3">
|
||||
{% for case, added, relation, visibility in available_priors %}
|
||||
|
||||
<li class='
|
||||
{% if added %}
|
||||
added
|
||||
{% else %}
|
||||
not-added
|
||||
{% endif %}
|
||||
' data-case={{case.id}}>
|
||||
{{case.title}} / {{added}} / {{relation}} / <a href="{% url 'atlas:case_detail' case.id %}">View case</a>
|
||||
<br/>{{case.get_series_blocks|safe}}
|
||||
|
||||
<br/>
|
||||
|
||||
{% if not added %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
|
||||
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
|
||||
<br />
|
||||
<label for="prior_visibility_{{case.id}}">Visibility:</label>
|
||||
<select name="prior_visibility" id="prior_visibility_{{case.id}}">
|
||||
<option value="AL"
|
||||
{% if visibility == 'AL' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>Always</option>
|
||||
<option value="RE"
|
||||
{% if visibility == 'RE' %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>Review</option>
|
||||
<option value="NO"
|
||||
{% if visibility == 'NO' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>None</option>
|
||||
</select>
|
||||
<br />
|
||||
<button class="btn primary">Add to case</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
|
||||
hx-target="#htmx-response-{{case.id}}"
|
||||
_="on htmx:afterRequest log 'test' then remove me">
|
||||
Case available: {{relation}}
|
||||
<input type="hidden" name="remove" value="{{case.id}}" />
|
||||
<br />
|
||||
<label for="prior_visibility_{{case.id}}">Visibility:</label>
|
||||
<select name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
|
||||
<option value="AL"
|
||||
{% if visibility == 'AL' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>Always</option>
|
||||
<option value="RE"
|
||||
{% if visibility == 'RE' %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>Review</option>
|
||||
<option value="NO"
|
||||
{% if visibility == 'NO' %}
|
||||
selected
|
||||
{% endif %}
|
||||
>None</option>
|
||||
</select>
|
||||
<br />
|
||||
<button class="btn primary">Remove from case</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<span id="htmx-response-{{case.id}}"></span>
|
||||
|
||||
</li>
|
||||
|
||||
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">No available prior cases found for the case.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,21 +4,11 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
|
||||
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
|
||||
|
||||
|
||||
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
|
||||
|
||||
{% if case_detail.question_schema is not None and not case_detail.question_answers %}
|
||||
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
|
||||
|
||||
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@@ -42,7 +32,7 @@
|
||||
|
||||
Import schema from:
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
hx-get="{% url 'atlas:collection_question_schemas' case_detail.collection.id %}"
|
||||
hx-get="{% url 'atlas:collection_question_schemas' casedetail.collection.id %}"
|
||||
hx-target=".offcanvas-body"
|
||||
>Collection questions</button>
|
||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
||||
@@ -54,7 +44,7 @@
|
||||
Question Block Title
|
||||
</summary>
|
||||
<p>Title of the question block.</p>
|
||||
<input id="question_block_title_input" type="text" name="title" value="{{case_detail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
|
||||
<input id="question_block_title_input" type="text" name="title" value="{{casedetail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
|
||||
</details>
|
||||
<details><summary>Modify Questions</summary>
|
||||
<div id="questions-container" style="margin-top: 20px;">
|
||||
@@ -123,7 +113,7 @@
|
||||
</form> {% endcomment %}
|
||||
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
{% if casedetail.question_schema %}
|
||||
<h2>Question display</h2>
|
||||
This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below.
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<span class="collection-name-blend">Collection: {{collection}}</span>
|
||||
<h2>Case {{case_number|add:1}}
|
||||
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h4 mb-0">Case {{case_number|add:1}}
|
||||
|
||||
{% if show_title %}
|
||||
: {{case.title}}
|
||||
@@ -11,19 +14,36 @@
|
||||
|
||||
|
||||
{% if question_completed %}
|
||||
<span class="stamp-white">REVIEW</span>
|
||||
<span class="badge bg-warning text-dark ms-2 small">REVIEW</span>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center ms-3">
|
||||
<div class="dropdown">
|
||||
<a class="btn btn-outline-secondary btn-sm dropdown-toggle" href="#" role="button" id="caseJump" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
hx-get="{% url 'atlas:collection_case_jump_partial' pk=collection.pk %}?current={{case_number}}{% if cid %}&cid={{cid}}&passcode={{passcode}}{% endif %}"
|
||||
hx-target="#case-jump-menu"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="click">
|
||||
Jump
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end" id="case-jump-menu">
|
||||
<div class="px-3 py-2 text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not question_completed and collection.question_time_limit is not None %}
|
||||
<div id="question-timer-block" style="margin-top:8px; margin-bottom:8px;" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
|
||||
<strong>Time remaining:</strong>
|
||||
<span id="question-timer" aria-live="polite"> </span>
|
||||
<div id="question-timer-progress" style="display:inline-block; vertical-align: middle; width: 200px; margin-left:12px;">
|
||||
<div id="question-timer-progress-outer" style="background:#e9ecef; border-radius:6px; height:10px; overflow:hidden;">
|
||||
<div id="question-timer-progress-inner" style="width:100%; height:100%; background:var(--timer-color, #28a745);"></div>
|
||||
<div id="question-timer-block" class="mb-3" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite"> </span></div>
|
||||
<div style="flex:1; max-width:320px;">
|
||||
<div class="progress" style="height:10px;">
|
||||
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="timer-htmx-target" style="display:none;"></div>
|
||||
@@ -38,12 +58,10 @@
|
||||
|
||||
{% if not question_completed %}
|
||||
{% if resources %}
|
||||
<h5>Resources</h4>
|
||||
<ul class="no-list-style">
|
||||
<h5 class="mt-3">Resources</h5>
|
||||
<ul class="list-group list-group-flush mb-3">
|
||||
{% for caseresource in resources %}
|
||||
<li>
|
||||
{{caseresource.resource.get_display}}
|
||||
</li>
|
||||
<li class="list-group-item py-1 small">{{caseresource.resource.get_display}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -51,14 +69,21 @@
|
||||
|
||||
|
||||
{% if show_description and case.description %}
|
||||
<div>
|
||||
Description: {{case.description}}
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-1">Description</h6>
|
||||
<p class="mb-0">{{case.description}}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if show_history %}
|
||||
<div>
|
||||
History: {{case_detail.get_history_pre|linebreaks}}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-2 d-flex justify-content-between align-items-start">
|
||||
<div class="me-3 small text-muted">History: {{casedetail.get_history_pre|linebreaks}}</div>
|
||||
{% if has_priors %}
|
||||
<div class="ms-2">
|
||||
<span title="This case has priors available for comparison" class="badge bg-secondary text-white rounded-pill small">Priors{% if prior_count %} ({{ prior_count }}){% endif %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -66,9 +91,9 @@
|
||||
<div>
|
||||
|
||||
{% if question_completed %}
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe>
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
||||
{% else %}
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe>
|
||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -78,9 +103,9 @@
|
||||
{% if collection.show_ohif_viewer_link %}
|
||||
<div>
|
||||
{% if question_completed %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% else %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -92,7 +117,7 @@
|
||||
<div id="main_viewer" class="dicom-viewer-root"
|
||||
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||
data-auto-cache-stack="false"
|
||||
data-named-stacks='{{case_detail.get_case_named_stacks}}'
|
||||
data-named-stacks='{{casedetail.get_case_named_stacks}}'
|
||||
></div>
|
||||
</details>
|
||||
|
||||
@@ -131,28 +156,24 @@
|
||||
{% endif %}
|
||||
|
||||
{% if show_discussion and case.discussion %}
|
||||
<p>
|
||||
<details>
|
||||
<summary>
|
||||
Discussion:
|
||||
<details class="card mb-3">
|
||||
<summary class="card-header p-2" style="cursor: pointer;">
|
||||
<strong>Discussion</strong>
|
||||
</summary>
|
||||
<div>
|
||||
<div class="card-body p-2 small">
|
||||
{{case.discussion|linebreaks}}
|
||||
</div>
|
||||
</details>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if show_report and case.report %}
|
||||
<p>
|
||||
<details>
|
||||
<summary>
|
||||
Report:
|
||||
<details class="card mb-3">
|
||||
<summary class="card-header p-2" style="cursor: pointer;">
|
||||
<strong>Report</strong>
|
||||
</summary>
|
||||
<div>
|
||||
<div class="card-body p-2 small">
|
||||
{{case.report|linebreaks}}
|
||||
</div>
|
||||
</details>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if question_completed %}
|
||||
@@ -246,25 +267,28 @@
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
<div class="mb-2">
|
||||
{% if previous %}
|
||||
<button type="submit" name="previous" class="save btn btn-default">Previous</button>
|
||||
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
|
||||
{% endif %}
|
||||
{% if next %}
|
||||
<button type="submit" name="next" class="save btn btn-default">Next</button>
|
||||
<button type="submit" name="next" class="btn btn-primary btn-sm me-2">Next</button>
|
||||
{% elif collection.collection_type == "REP" %}
|
||||
{% if not exam.publish_results %}
|
||||
<button type="submit" name="save" class="save btn btn-default">Save</button>
|
||||
<button type="submit" name="save" class="btn btn-primary btn-sm me-2">Save</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if collection.self_review and not collection.feedback_once_collection_complete and not question_completed %}
|
||||
<button type="submit" name="complete_case" class="save btn btn-default">Finish Case</button>
|
||||
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<br />
|
||||
<button type="submit" name="finish" class="save btn btn-default">Overview</button>
|
||||
<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>
|
||||
</form>
|
||||
<style>
|
||||
label {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block content %}
|
||||
|
||||
<h2>Case: {{ case.title }}</h2>
|
||||
{{ case_detail.question_schema }}
|
||||
{{ casedetail.question_schema }}
|
||||
|
||||
|
||||
{{ case.question_answers }}
|
||||
|
||||
@@ -4,139 +4,154 @@
|
||||
|
||||
{% partialdef casedetails-management-links %}
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
|
||||
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
|
||||
{% endif %}
|
||||
)
|
||||
|
||||
(<a href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-info-square" title="Case details"></i></a>)
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
(<a href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
)
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
|
||||
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
|
||||
{% endif %}
|
||||
{% endpartialdef %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{collection.name}}</h2>
|
||||
|
||||
<div class="container">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
<div>
|
||||
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
|
||||
<small class="text-muted">(open access: {{ collection.exam_open_access }})</small>
|
||||
</p>
|
||||
|
||||
<p class="mb-2">
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
||||
Self review: {{collection.self_review}}<br />
|
||||
Open access: {{collection.open_access}}<br />
|
||||
</p>
|
||||
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
|
||||
<span class="badge bg-light text-dark me-2">Self review: {{ collection.self_review }}</span>
|
||||
{% if collection.open_access %}
|
||||
<span class="badge bg-success">Open</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Restricted</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
Prerequisite collections:
|
||||
<ul>
|
||||
<div class="mt-3 mb-2">
|
||||
<strong>Prerequisites</strong>
|
||||
<ul class="mb-0">
|
||||
{% for prereq in collection.prerequisites.all %}
|
||||
<li>
|
||||
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
|
||||
|
||||
</li>
|
||||
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
|
||||
<div class="mb-2 w-100 text-md-end">
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
|
||||
{% else %}
|
||||
<div class="d-inline-block">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
|
||||
<button
|
||||
_="on click
|
||||
writeText(#take-url-link's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy link' into me"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if collection.prerequisites.exists %}
|
||||
<div class="w-100 text-md-end">
|
||||
<button
|
||||
title="Sync users from all prerequisite collections into this collection"
|
||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>Sync prerequisite users</button>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h3 class="mb-3">Cases</h3>
|
||||
|
||||
{% if collection.collection_type == "VIV" %}
|
||||
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
|
||||
{% else %}
|
||||
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
|
||||
_="on click
|
||||
writeText(#take-url's href) on navigator.clipboard
|
||||
put 'copied url!' into me
|
||||
wait 1s
|
||||
put 'copy url' into me"
|
||||
class="btn btn-sm">copy link</button>
|
||||
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
<h3>Cases</h3>
|
||||
<ol id="full-question-list" class="sortable">
|
||||
<ol id="full-question-list" class="sortable list-group list-group-numbered">
|
||||
{% for casedetail in casesdetails %}
|
||||
<li data-question_pk={{casedetail.case.pk}}>
|
||||
|
||||
|
||||
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||
:
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>
|
||||
{% endif %}
|
||||
{{casedetail.case.title}}
|
||||
|
||||
{% partial casedetails-management-links %}
|
||||
|
||||
</li>
|
||||
|
||||
{% include 'atlas/partials/case_list_item.html' %}
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Remove cases</button></p>
|
||||
<p><button id='button-add-case'
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button id='button-edit-order'
|
||||
title='click and drag questions to change order'
|
||||
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
Edit case order / Remove cases
|
||||
</button>
|
||||
|
||||
<button id='button-add-case'
|
||||
title='click to add a case to this collection'
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
|
||||
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
|
||||
class="btn btn-sm btn-secondary">
|
||||
Add case
|
||||
</button>
|
||||
<div id='case-list'></div>
|
||||
</p>
|
||||
|
||||
<div id='case-list'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<details>
|
||||
<summary>Answer management</summary>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<p>Manage answers for this collection. </p>
|
||||
|
||||
Please note these are permanant and cannot be undone.
|
||||
<div class="alert alert-danger mt-3">
|
||||
<p class="mb-1">Manage answers for this collection.</p>
|
||||
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button title="This will clear all user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
||||
class="btn btn-danger btn-sm"
|
||||
>Reset all answers</button>
|
||||
|
||||
<button title="This will specific user answers and attempts"
|
||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#user-list"
|
||||
>Reset user answers</button>
|
||||
<div id="user-list"></div>
|
||||
class="btn btn-warning btn-sm">
|
||||
Reset user answers</button>
|
||||
</div>
|
||||
|
||||
<div id="user-list" class="mt-3"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'exam_overview_js.html' %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
{% if request.user.is_authenticated %}
|
||||
Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
|
||||
<a href="{% url 'atlas:collection_case_update' collection.pk %}">Cases</a> /
|
||||
<a href="{% url 'atlas:collection_history' collection.pk %}">History</a> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
|
||||
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
|
||||
<a href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a> /
|
||||
<a href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a> /
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:exam_update' collection.id %}" title="Edit the Collection">Edit</a>
|
||||
\ <a href="{% url 'atlas:exam_deleted' collection.id %}" title="Delete the Collection">Delete</a>
|
||||
\ <a href="{% url 'atlas:exam_clone' collection.id %}" title="Clone the Collection">Clone</a>
|
||||
\ <a href="{% url 'atlas:collection_authors' collection.pk %}" title="Edit Authors">Authors</a>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
{% if request.user.is_superuser %}
|
||||
\ <a href="{% url 'admin:atlas_casecollection_change' collection.id %}" title="Edit the Collection using the admin interface">Admin Edit</a>
|
||||
<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>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="vstack gap-3">
|
||||
{% for case_detail, answers in case_map.items %}
|
||||
{% for casedetail, answers in case_map.items %}
|
||||
<div class="border border-dark">
|
||||
<h4>Case: {{case_detail.case.title}}</h4>
|
||||
<h4>Case: {{casedetail.case.title}}</h4>
|
||||
Answers
|
||||
<ul>
|
||||
{% for answer in answers %}
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
{% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||
<ol>
|
||||
{% for case_detail in case_details %}
|
||||
{% for casedetail in casedetails %}
|
||||
|
||||
<li id="case-detail-{{case_detail.pk}}"{% if case_detail.question_schema %} class="cases"{% endif %}>
|
||||
<h2>Case: {{ case_detail.case.title }}</h2>
|
||||
<li id="case-detail-{{casedetail.pk}}"{% if casedetail.question_schema %} class="cases"{% endif %}>
|
||||
<h2>Case: {{ casedetail.case.title }}</h2>
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
{% if casedetail.question_schema %}
|
||||
<button class="btn btn-sm btn-secondary use-layout">Use schema</button>
|
||||
<details><summary>Schema:</summary>
|
||||
<div class="schema" data-schema='{{ case_detail.get_question_schema }}'>{{ case_detail.question_schema }}</div>
|
||||
<div class="schema" data-schema='{{ casedetail.get_question_schema }}'>{{ casedetail.question_schema }}</div>
|
||||
</details>
|
||||
<div class="form"></div>
|
||||
{% else %}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||
<h2 class="mb-0">Collections</h2>
|
||||
{% if request.user.is_staff %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="mb-3">Manage and browse case collections. Use filters to narrow results.</p>
|
||||
|
||||
{% render_table table %}
|
||||
|
||||
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
{# Hidden per-case question snippet to inject when case is loaded #}
|
||||
<div class="question-block-snippet" style="display:none;">
|
||||
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail %}
|
||||
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
|
||||
</div>
|
||||
{% if case.display_sets.all %}
|
||||
<details class="displayset-detail"><summary>Display Sets:</summary>
|
||||
@@ -212,14 +212,14 @@
|
||||
let c = this.closest(".case-item");
|
||||
console.log('clicked local', c)
|
||||
console.log('open local', c.dataset.case)
|
||||
case_details = JSON.parse(c.dataset.casejson);
|
||||
casedetails = JSON.parse(c.dataset.casejson);
|
||||
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
|
||||
$('#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> "+case_details['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
||||
$("#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": c.dataset.images,
|
||||
@@ -239,15 +239,15 @@
|
||||
let c = this;
|
||||
console.log('clicked local', c)
|
||||
console.log('open local', c.dataset.case)
|
||||
case_details = JSON.parse(c.dataset.casejson);
|
||||
casedetails = JSON.parse(c.dataset.casejson);
|
||||
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
|
||||
let annotations = JSON.parse(c.dataset.annotations) || {};
|
||||
$('#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> "+case_details['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
||||
$("#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": c.dataset.images,
|
||||
@@ -302,15 +302,15 @@
|
||||
|
||||
let c = $(this).closest(".case-item");
|
||||
|
||||
case_details = c.data('casejson');
|
||||
console.log(case_details);
|
||||
casedetails = c.data('casejson');
|
||||
console.log(casedetails);
|
||||
|
||||
$('#loading-case').show()
|
||||
$("#open-viewer").addClass("flash-button");
|
||||
$('#current-case-title').html("<span class='title'>Case:</span> "+c.data('title'));
|
||||
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
|
||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
||||
$("#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']);
|
||||
|
||||
console.log($(this).data('type'))
|
||||
if ($(this).data('type') == 'case') {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<h5 class="mb-2">Details</h5>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Primary name</dt>
|
||||
<dd class="col-sm-8">{{ condition.primary }}</dd>
|
||||
<dd class="col-sm-8">{% if condition.canonical %}No (alias of <a href="{{ condition.canonical.get_absolute_url }}">{{ condition.canonical.name }}</a>){% else %}Yes{% endif %}</dd>
|
||||
|
||||
<dt class="col-sm-4">Subspecialty</dt>
|
||||
<dd class="col-sm-8">
|
||||
@@ -31,11 +31,9 @@
|
||||
|
||||
<dt class="col-sm-4">Synonyms</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for syn in condition.synonym.all %}
|
||||
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
<div id="synonyms-container">
|
||||
{% include 'atlas/partials/_synonyms_list.html' %}
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Parent</dt>
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block css %}{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
||||
{{ form.media }}
|
||||
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Add / Edit Condition</h2>
|
||||
<p>Use this form to create or edit a condition.</p>
|
||||
<p>Please check if it already <a href='{% url "atlas:condition_view" %}'>exists</a> before doing so!</p>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
||||
{% csrf_token %}
|
||||
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
{% crispy form %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
{% if collection %}
|
||||
{% include "atlas/collection_headers.html" %}
|
||||
{% endif %}
|
||||
{% if exam %}
|
||||
<a href="{% url 'atlas:exam_review_start' pk=exam.pk %}">Review</a> /
|
||||
{% endif %}
|
||||
{% comment %} Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=exam.pk %}">Overview</a> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' exam.pk %}">Mark</a> /
|
||||
<a href="{% url 'atlas:collection_scores_cid' exam.pk %}">Scores</a> /
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3>Name: {{finding.name}}</h3>
|
||||
Primary name: {{finding.primary}}<br />
|
||||
Synonyms: {{finding.synonym.all|join:", "}}<br />
|
||||
Primary name: {% if finding.canonical %}No (alias of <a href="{{ finding.canonical.get_absolute_url }}">{{ finding.canonical.name }}</a>){% else %}Yes{% endif %}<br />
|
||||
Synonyms: {{ finding.get_synonym_link|safe }}<br />
|
||||
</div>
|
||||
{% if finding.seriesfinding_set.all %}
|
||||
<h4>Associated Cases</h4>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>Normals</h1>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<form method="get" class="form-inline">
|
||||
{% for field in filter.form.visible_fields %}
|
||||
<div class="mb-2">
|
||||
{{ field.label_tag }}
|
||||
{{ field }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-primary" type="submit">Filter</button>
|
||||
<a class="btn btn-link" href="?">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if page_obj.object_list %}
|
||||
<div class="list-group">
|
||||
{% for normal in page_obj.object_list %}
|
||||
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">{{ normal.case.title }}</h5>
|
||||
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<p class="mb-1">
|
||||
Age: {{ normal.display_age }}
|
||||
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
|
||||
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
|
||||
</p>
|
||||
{% if normal.notes %}
|
||||
<p class="mb-0 text-muted">{{ normal.notes }}</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-group-item">No normal cases found.</div>
|
||||
{% endif %}
|
||||
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination mt-3">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% for cd in casedetails %}
|
||||
{% with idx=forloop.counter0 %}
|
||||
{% if cid %}
|
||||
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=idx cid=cid passcode=passcode %}">
|
||||
{% else %}
|
||||
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=idx %}">
|
||||
{% endif %}
|
||||
Case {{ forloop.counter }}: {{ cd.case.title|default:cd.case.pk|truncatechars:60 }}
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div id="normal-modal-{{ case.pk }}" class="modal modal-blur fade" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<form method="post" hx-post="{% url 'atlas:case_create_normal' case.pk %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Mark case {{ case.pk }} as Normal</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% load crispy_forms_tags %}
|
||||
{{ form.media }}
|
||||
{% crispy form %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Normal</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show the modal after HTMX injects it
|
||||
(function(){
|
||||
try {
|
||||
var el = document.getElementById('normal-modal-{{ case.pk }}');
|
||||
if(el){
|
||||
var m = new bootstrap.Modal(el);
|
||||
m.show();
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to show normal modal', e);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1"><a href="{{ normal.case.get_absolute_url }}">{{ normal.case.title }}</a></h5>
|
||||
<small>{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||
</div>
|
||||
<p class="mb-1">Age: {{ normal.display_age }}</p>
|
||||
<p class="mb-1">Examination: {{ normal.examination }} | Modality: {{ normal.modality }}</p>
|
||||
{% if normal.notes %}
|
||||
<p class="mb-0">Notes: {{ normal.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
{% load static %}
|
||||
|
||||
<div id="normal-toggle-block">
|
||||
{% if case.normal_case %}
|
||||
<span class="badge bg-success">Normal</span>
|
||||
<small class="text-muted">{{ case.normal_case.display_age }}</small>
|
||||
{% if can_mark_normal %}
|
||||
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||
hx-target="#normal-toggle-block"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Unmark this case as normal?">
|
||||
Unmark normal
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if can_mark_normal %}
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Mark as normal
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-muted">Normal status only editable by atlas editors or case authors</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
<div id="prior-card-{{case.id}}" class="col-12 col-md-6 col-lg-4" data-case="{{case.id}}">
|
||||
<div class="card h-100 shadow-sm {% if added %}border-success{% else %}border-secondary{% endif %}">
|
||||
<div class="card-body d-flex flex-column">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-1 mb-2 small">{{ error }}</div>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="card-title mb-0">{{case.title}}</h5>
|
||||
<div>
|
||||
{% if added %}
|
||||
<span class="badge bg-success">Added</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Available</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
|
||||
|
||||
<div class="mb-3 small">{{case.get_series_blocks|safe}}</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<a href="{% url 'atlas:case_detail' case.id %}" class="btn btn-sm btn-outline-primary">View case</a>
|
||||
</div>
|
||||
|
||||
{% if not added %}
|
||||
<form class="row g-2"
|
||||
{% if case_number %}
|
||||
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number %}"
|
||||
{% else %}
|
||||
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
|
||||
{% endif %}
|
||||
hx-target="#prior-card-{{case.id}}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
|
||||
<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-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>
|
||||
<option value="RE" {% if visibility == 'RE' %}selected{% endif %}>Review</option>
|
||||
<option value="NO" {% if visibility == 'NO' %}selected{% endif %}>None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-4 d-grid">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<form
|
||||
{% if case_number %}
|
||||
hx-post="{% url 'atlas:collection_case_priors' collection.id case_number %}"
|
||||
{% else %}
|
||||
hx-post="{% url 'atlas:collection_case_priors_legacy' collection.id casedetail.case.pk %}"
|
||||
{% endif %}
|
||||
hx-target="#prior-card-{{case.id}}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="remove" value="{{case.id}}" />
|
||||
<div class="mb-2 small text-muted">Current relation: <strong>{{relation}}</strong></div>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select form-select-sm w-50" name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
|
||||
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
|
||||
<option value="RE" {% if visibility == 'RE' %}selected{% endif %}>Review</option>
|
||||
<option value="NO" {% if visibility == 'NO' %}selected{% endif %}>None</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-danger" type="submit">Remove</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent">
|
||||
<small class="text-muted">Case ID: {{ case.id }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
{% comment %}Partial that renders the synonyms list + add form. Used by condition_detail and HTMX responses.{% endcomment %}
|
||||
{% load static %}
|
||||
|
||||
{% with syns=condition.get_synonyms %}
|
||||
{% if syns %}
|
||||
{% for syn in syns %}
|
||||
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if request.user.is_authenticated and can_merge %}
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<!-- Button to add an existing condition as a synonym -->
|
||||
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#addSynonymCollapse" role="button" aria-expanded="false" aria-controls="addSynonymCollapse">Add synonym</a>
|
||||
|
||||
<!-- Button to create a new condition and add it as a synonym -->
|
||||
<a class="btn btn-sm btn-outline-success" data-bs-toggle="collapse" href="#createSynonymCollapse" role="button" aria-expanded="false" aria-controls="createSynonymCollapse">Create synonym</a>
|
||||
</div>
|
||||
|
||||
<!-- Collapse: add existing condition -->
|
||||
<div class="collapse mt-2" id="addSynonymCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST"
|
||||
hx-post="{% url 'atlas:condition_detail' condition.pk %}"
|
||||
hx-target="#synonyms-container" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_synonym" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Select existing condition</label>
|
||||
{{ synonym_form.condition }}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapse: create new condition -->
|
||||
<div class="collapse mt-2" id="createSynonymCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST"
|
||||
hx-post="{% url 'atlas:condition_detail' condition.pk %}"
|
||||
hx-target="#synonyms-container" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_synonym" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Create new synonym name</label>
|
||||
<input class="form-control" type="text" name="synonym_name" placeholder="e.g. Granulomatosis with Polyangitis" required />
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
{% if previous %}
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url nav_link_view collection.id case_number|add:-1 %}">← Previous</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h6 mb-0">Viewing as part of a collection</div>
|
||||
<div>
|
||||
<a href="{% url 'atlas:collection_detail' collection.id %}" class="fw-bold">{{collection.name}}</a>
|
||||
<small class="text-muted">[{{case_number|add:1}}/{{collection_length}}]</small>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{% if next %}
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url nav_link_view collection.id case_number|add:1 %}">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
{% include "atlas/collection_detail.html#casedetails-management-links" %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<li class="list-group-item d-flex gap-3 align-items-start" data-question_pk="{{ casedetail.case.pk }}">
|
||||
|
||||
<div class="flex-fill">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<a class="h6 mb-0 text-decoration-none case-link"
|
||||
href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}"
|
||||
data-case-base-href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=0 %}">
|
||||
{{ casedetail.case.title }}
|
||||
</a>
|
||||
<div class="small text-muted"><span class="case-number">Case {{ forloop.counter }}</span> — <span class="text-truncate">{{ casedetail.case.description|default_if_none:""|truncatechars:80 }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 small text-muted">
|
||||
{% if casedetail.case.series.count == 0 %}
|
||||
<span class="text-warning">This case has no series / stacks attached</span>
|
||||
{% else %}
|
||||
{{ casedetail.case.series.count }} series
|
||||
{% endif %}
|
||||
{% if casedetail.question_schema %}
|
||||
• <span class="text-success">Questions defined</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center ms-3">
|
||||
{% include 'atlas/partials/casedetails_management_links.html' %}
|
||||
</div>
|
||||
|
||||
</li>
|
||||
@@ -0,0 +1,45 @@
|
||||
{# Search results #}
|
||||
{% if q %}
|
||||
{% if cases and cases.exists %}
|
||||
<div class="list-group mb-2">
|
||||
{% for case in cases %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
|
||||
<div class="flex-fill me-3">
|
||||
<div class="fw-semibold">{{ case.title }}</div>
|
||||
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="case" value="{{ case.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small mb-2">No cases found.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Recent cases quick-add (rendered below search results) #}
|
||||
{% if recent_cases and recent_cases|length > 0 %}
|
||||
<div>
|
||||
<div class="small text-muted mb-1">Recently created</div>
|
||||
<div class="list-group">
|
||||
{% for case in recent_cases %}
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
|
||||
<div class="flex-fill me-3">
|
||||
<div class="fw-semibold">{{ case.title }}</div>
|
||||
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
|
||||
</div>
|
||||
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="case" value="{{ case.pk }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="case-search-widget">
|
||||
<label for="case-search-input" class="form-label">Search cases</label>
|
||||
<input id="case-search-input" name="q" class="form-control" type="search"
|
||||
placeholder="Type to search cases..."
|
||||
hx-get="{% url 'atlas:case_search' %}"
|
||||
hx-include="#case-search-input"
|
||||
hx-vals='{"collection": "{{ collection.pk }}"}'
|
||||
hx-target="#case-search-results"
|
||||
hx-trigger="keyup changed delay:400ms"
|
||||
autocomplete="off">
|
||||
|
||||
<div id="case-search-results" 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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// Robust fallback: ensure repeated searches always work by using htmx.ajax when available.
|
||||
try {
|
||||
var input = document.getElementById('case-search-input');
|
||||
if (!input) return;
|
||||
var timer = null;
|
||||
input.addEventListener('input', function(){
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){
|
||||
try {
|
||||
var q = encodeURIComponent(input.value || '');
|
||||
var url = "{% url 'atlas:case_search' %}?q=" + q + "&collection={{ collection.pk }}";
|
||||
if (window.htmx && typeof window.htmx.ajax === 'function') {
|
||||
window.htmx.ajax('GET', url, { target: '#case-search-results', swap: 'innerHTML' });
|
||||
} else {
|
||||
// fallback to fetch
|
||||
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
|
||||
var el = document.getElementById('case-search-results');
|
||||
if (el) el.innerHTML = html;
|
||||
}).catch(function(e){ console.error('case search fetch error', e); });
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
}, 400);
|
||||
});
|
||||
} catch(e) { console.error('case search widget init error', e); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
|
||||
<i class="bi bi-display"></i>
|
||||
</a>
|
||||
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details">
|
||||
<i class="bi bi-info-square"></i>
|
||||
</a>
|
||||
|
||||
{% if collection.collection_type == "QUE" %}
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions">
|
||||
{% if casedetail.question_schema %}
|
||||
<i class="bi bi-question-square text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-question-square text-danger"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if casedetail.case.previous_case %}
|
||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
|
||||
{% if can_edit %}
|
||||
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk casedetail.collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -1,10 +1,10 @@
|
||||
{# Partial: render questions and answers as HTML. #}
|
||||
|
||||
{% if case_detail.question_schema %}
|
||||
{% if casedetail.question_schema %}
|
||||
<div class="collection-question-block">
|
||||
<h4>Questions</h4>
|
||||
<dl class="row">
|
||||
{% for name, prop in case_detail.question_schema.properties.items %}
|
||||
{% for name, prop in casedetail.question_schema.properties.items %}
|
||||
<dt class="col-sm-4">{{ prop.title|default:name }}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if prop.description %}
|
||||
@@ -18,8 +18,8 @@
|
||||
{% endif %}
|
||||
|
||||
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
|
||||
{% if case_detail.question_answers %}
|
||||
{% with correct=case_detail.question_answers|get_item:name %}
|
||||
{% if casedetail.question_answers %}
|
||||
{% with correct=casedetail.question_answers|get_item:name %}
|
||||
<div class="mt-2">
|
||||
<strong>Example / Correct:</strong>
|
||||
{% if correct %}
|
||||
@@ -37,7 +37,7 @@
|
||||
{% if user_answer %}
|
||||
{% if user_answer|get_item:name %}
|
||||
{% with ua=user_answer|get_item:name %}
|
||||
{% with correct=case_detail.question_answers|get_item:name %}
|
||||
{% with correct=casedetail.question_answers|get_item:name %}
|
||||
<div class="mt-1">
|
||||
<strong>Your answer:</strong>
|
||||
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{# Partial: series cases list. Expects `series` and `can_edit` in context. Returns the <ul> that is inserted into #cases-list. #}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for case in series.case.all %}
|
||||
<li id="case-link-{{ case.pk }}" class="list-group-item d-flex justify-content-between align-items-center case-item">
|
||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
|
||||
{% if can_edit %}
|
||||
<button hx-post="{% url 'atlas:remove_series_from_case' series.pk case.pk %}"
|
||||
hx-target="#cases-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to remove this series from the case?"
|
||||
class="btn btn-link btn-sm text-danger remove-button">Remove</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item">This series is not associated with any cases.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -0,0 +1,31 @@
|
||||
{% load static %}
|
||||
|
||||
<div class="series-images-list">
|
||||
{% for image in images %}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if image.image %}
|
||||
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" target="_blank" class="me-2">
|
||||
<img src="{{ image.image.url }}" alt="image-{{ image.pk }}" style="width:96px;height:auto;object-fit:cover;border-radius:.25rem;" class="img-thumbnail">
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="me-2 bg-secondary" style="width:96px;height:72px;border-radius:.25rem;"></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div><strong>{{ image.image.name|default:"(no file)" }}</strong></div>
|
||||
{% if image.image %}
|
||||
<div class="small text-muted">{{ image.image.size|filesizeformat }}</div>
|
||||
{% endif %}
|
||||
{% if image.image_blake3_hash %}
|
||||
<div class="small text-muted">hash: {{ image.image_blake3_hash|truncatechars:12 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="ms-auto text-end">
|
||||
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" class="btn btn-sm btn-outline-secondary">View DICOM</a>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted">No images found for this series.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block js %}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Add / Edit Pathological process</h2>
|
||||
<p>Use this form to create or edit a pathological process.</p>
|
||||
<p>Please check if it already <a href='{% url "atlas:pathological_process_view" %}'>exists</a> before doing so!</p>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="pathological-process-form">
|
||||
{% csrf_token %}
|
||||
{% crispy form %}
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
<a class="btn btn-secondary ms-2" href="{% url 'atlas:pathological_process_view' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block js %}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Add / Edit Presentation</h2>
|
||||
<p>Use this form to create or edit a presentation.</p>
|
||||
<p>Please check if it already <a href='{% url "atlas:presentation_view" %}'>exists</a> before doing so!</p>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="presentation-form">
|
||||
{% csrf_token %}
|
||||
{% crispy form %}
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
<a class="btn btn-secondary ms-2" href="{% url 'atlas:presentation_view' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -18,22 +18,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if collection %}
|
||||
<div class="text-end mb-2">
|
||||
<div class="small text-muted">Collection</div>
|
||||
<div class="mb-1">
|
||||
{% include "atlas/collection_detail.html#casedetails-management-links" %}
|
||||
</div>
|
||||
<div class="small">
|
||||
{% if previous %}
|
||||
<a class="me-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous</a>
|
||||
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_view" %}
|
||||
{% endif %}
|
||||
Viewing question as part of a collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{ collection.name }}</a>
|
||||
[{{ case_number|add:1 }} / {{ collection_length }}]
|
||||
{% if next %}
|
||||
<a class="ms-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -5,32 +5,100 @@
|
||||
{% include "atlas/resource_link_header.html" %}
|
||||
{% endif %}
|
||||
|
||||
{{resource}}
|
||||
<div class="container mt-3">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">{{ resource.name }}</h5>
|
||||
<small class="text-muted">by {{ resource.get_authors|default:"Unknown" }}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if resource.description %}
|
||||
<p class="card-text">{{ resource.description|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
|
||||
Name: {{resource.name}}<br>
|
||||
Description: {{resource.description}}<br>
|
||||
URL: {{resource.url}}<br>
|
||||
file: {{resource.file}}<br>
|
||||
author: {{resource.get_authors}}<br>
|
||||
<dl class="row">
|
||||
{% if resource.url %}
|
||||
<dt class="col-sm-3">URL</dt>
|
||||
<dd class="col-sm-9"><a href="{{ resource.url }}" target="_blank" rel="noopener">{{ resource.url }}</a></dd>
|
||||
{% endif %}
|
||||
|
||||
{{resource.case}}
|
||||
{% if resource.file %}
|
||||
<dt class="col-sm-3">File</dt>
|
||||
<dd class="col-sm-9">
|
||||
<a href="{{ resource.file.url }}" target="_blank" rel="noopener">Download</a>
|
||||
{% if resource.file.name %}
|
||||
<small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<a href="{% url 'atlas:resource_view' resource.pk %}" target="_blank">View Resource</a>
|
||||
<dt class="col-sm-3">Source</dt>
|
||||
<dd class="col-sm-9">{{ resource.get_display }}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="mt-3">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:resource_view' resource.pk %}" target="_blank">Open resource page</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Used in cases</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if resource.case_set.all %}
|
||||
<h3>Cases</h3>
|
||||
This resource is used in the following cases:
|
||||
|
||||
<ul>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for case in resource.case_set.all %}
|
||||
<li><a href="{% url 'atlas:case_detail' case.id %}">{{ case.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>This resource is not used in any cases.</p>
|
||||
<p class="mb-0 text-muted">This resource is not used in any cases.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<aside class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Details</h6>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li><strong>Author:</strong> {{ resource.get_authors|default:"-" }}</li>
|
||||
<li><strong>Sites:</strong>
|
||||
{% if resource.sites.all %}
|
||||
{% for s in resource.sites.all %}
|
||||
<div><a href="{% url 'generic:site_detail' s.pk %}">{{ s.name }}</a></div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="mt-2"><strong>Subspecialty:</strong>
|
||||
{% if resource.subspecialty.all %}
|
||||
{% for sp in resource.subspecialty.all %}
|
||||
<div>{{ sp.name }}</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="mt-2"><strong>Created:</strong> {{ resource.created_date|date:"Y-m-d H:i" }}</li>
|
||||
<li><strong>Updated:</strong> {{ resource.modified_date|date:"Y-m-d H:i" }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a class="btn btn-primary" href="{% url 'atlas:resource_update' resource.pk %}">Edit</a>
|
||||
<a class="btn btn-outline-danger" href="{% url 'atlas:resource_delete' resource.pk %}">Delete</a>
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:resource_view' %}">Back to resources</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content %}
|
||||
<a href="{% url 'atlas:resource_create' %}">Create a new resource</a>
|
||||
|
||||
<h2>Resources</h2>
|
||||
|
||||
<ul id="resource-list">
|
||||
{% for resource in object_list %}
|
||||
<li class="">
|
||||
<a href="{% url 'atlas:resource_detail' resource.pk %}">{{resource}}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form>
|
||||
{% render_table table %}
|
||||
</form>
|
||||
|
||||
{% include "generic/partials/page_size_form.html" %}
|
||||
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,225 +1,223 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<div>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div>
|
||||
<div>Description: {{series.description}}</div>
|
||||
|
||||
Associated case:
|
||||
{% for case in series.case.all %}
|
||||
<span id="case-link-{{ case.pk }}" class="case-item">
|
||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
|
||||
{% if can_edit %}
|
||||
<button hx-post="{% url 'atlas:remove_series_from_case' series.pk case.pk %}"
|
||||
hx-target="#case-link-{{ case.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to remove this series from the case?"
|
||||
class="btn btn-link btn-sm button-unset remove-button">
|
||||
Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% empty %}
|
||||
This series is not associated with any cases.
|
||||
{% if can_edit %}
|
||||
<details>
|
||||
<summary>Add Series to Case</summary>
|
||||
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}"
|
||||
hx-target="#add-series-to-case-result"
|
||||
hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<label for="case-select">Select Case:</label>
|
||||
<select name="case_id" id="case-select" required>
|
||||
{% for case in available_cases %}
|
||||
<option value="{{ case.pk }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Add to Case</button>
|
||||
</form>
|
||||
<div id="add-series-to-case-result"></div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div>Author: {{ series.get_author_display }}</div>
|
||||
|
||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
|
||||
</div> {% endcomment %}
|
||||
|
||||
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
|
||||
style="max-width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
|
||||
data-auto-cache-stack=false
|
||||
></div>
|
||||
|
||||
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
||||
<div class="text-muted small">{{ series.plane }}, {{ series.contrast }}</div>
|
||||
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
|
||||
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
||||
</div>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
|
||||
{% if can_edit %}
|
||||
{% if editing_finding < 1 %}
|
||||
<button id="add-finding-button">Add finding</button>
|
||||
<button id="clone-finding-button" title="Click to copy the details of a finding that is already associated with the series/case"
|
||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||
{% else %}
|
||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||
<div id="root" class="dicom-viewer-root w-100" data-images="{{ image_url_array_and_count.0 }}"
|
||||
style="height: 600px; box-sizing: border-box; background: #222;" data-auto-cache-stack=false>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="card-footer bg-white d-flex flex-wrap gap-2 align-items-center">
|
||||
{% if can_edit and editing_finding < 1 %}
|
||||
<button id="add-finding-button" class="btn btn-primary btn-sm">Add finding</button>
|
||||
<button id="clone-finding-button" class="btn btn-outline-primary btn-sm"
|
||||
title="Clone an existing finding"
|
||||
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
|
||||
hx-target="#clone-findings-modal"
|
||||
hx-trigger="click"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#clone-findings-modal"
|
||||
>Clone existing finding</button>
|
||||
data-bs-target="#clone-findings-modal">Clone finding</button>
|
||||
{% endif %}
|
||||
<button id="reset-viewport-button" class="btn btn-secondary">Reset viewport</button>
|
||||
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<!-- Finding form moved below the viewport -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">Finding form</div>
|
||||
<div class="card-body">
|
||||
<div id="finding-form">
|
||||
<div class="hide" id="hidden-form">
|
||||
{% if editing_finding > 0 %}
|
||||
<h3>Editing Finding</h3>
|
||||
<p>Editing finding with ID: {{editing_finding}}</p>
|
||||
<h5>Editing Finding</h5>
|
||||
<p class="small">Editing finding with ID: {{editing_finding}}</p>
|
||||
{% else %}
|
||||
<h3>Add Finding</h3>
|
||||
<p>Click the button below to add a new finding.</p>
|
||||
<h5>Add Finding</h5>
|
||||
<p class="small">Click the button below to add a new finding.</p>
|
||||
{% endif %}
|
||||
<form method="post" id="series_finding_form">
|
||||
{% csrf_token %}
|
||||
{{series_finding_form|crispy}}
|
||||
<input type="submit" value="Submit" class="btn btn-lg btn-success fw-bold px-4 py-2" style="font-size: 1.2rem;">
|
||||
<button id="cancel-add-finding-button">Cancel</button>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<input type="submit" value="Submit" class="btn btn-success btn-sm">
|
||||
<button id="cancel-add-finding-button" class="btn btn-secondary btn-sm">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<button id="reset-viewport-button">Reset viewport</button>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<details class="styled-detail open">
|
||||
<summary>Findings</summary>
|
||||
|
||||
<div class="mt-2">
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="finding-box">
|
||||
<button id="finding-{{finding.pk}}-3d" class="view-finding-button-3d" data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>Click to view</button>
|
||||
|
||||
<span class="view-finding-details">
|
||||
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
|
||||
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
|
||||
Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
|
||||
Description: {{finding.description}}<br />
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between gap-2">
|
||||
<div>
|
||||
<button id="finding-{{finding.pk}}-3d" class="btn btn-sm btn-outline-secondary view-finding-button-3d me-2"
|
||||
data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>View 3D</button>
|
||||
<strong>Finding(s):</strong>
|
||||
{% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
|
||||
<strong>Structure(s):</strong> {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
|
||||
<strong>Condition(s):</strong> {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
|
||||
<div class="mt-2">{{finding.description}}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{% if request.user.is_superuser %}
|
||||
<span _="on click toggle .hidden on next .extra-details">+</span>
|
||||
<div class="hidden extra-details">
|
||||
<h4>Annotation JSON</h4>
|
||||
<pre>{{finding.annotation_json}}</pre>
|
||||
<h4>Viewport JSON</h4>
|
||||
<pre>{{finding.viewport_json}}</pre>
|
||||
<h4>Image ID</h4>
|
||||
<pre>{{finding.current_image_id_index}}</pre>
|
||||
|
||||
<h4>3D Annotation JSON</h4>
|
||||
<pre>{{finding.annotation_json_3d}}</pre>
|
||||
<h4>Viewer State</h4>
|
||||
<pre>{{finding.viewer_state_3d}}</pre>
|
||||
<button class="btn btn-sm btn-outline-dark mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#extra-{{finding.pk}}">Raw JSON</button>
|
||||
<div class="collapse" id="extra-{{finding.pk}}">
|
||||
<pre class="small bg-light p-2">{{finding.annotation_json}}</pre>
|
||||
<pre class="small bg-light p-2">{{finding.viewport_json}}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if can_edit %}
|
||||
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a>
|
||||
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
|
||||
<div class="d-flex flex-column">
|
||||
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="btn btn-sm btn-outline-primary mb-1">Edit</a>
|
||||
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="btn btn-sm btn-outline-danger">Delete</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>No findings available.</p>
|
||||
<p class="text-muted">No findings available.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if can_edit %}
|
||||
<details class="styled-detail">
|
||||
<summary>Truncate series</summary>
|
||||
<p>
|
||||
This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first.
|
||||
</p>
|
||||
<div role="alert" class="alert alert-warning">
|
||||
<strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted.
|
||||
</div>
|
||||
|
||||
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
|
||||
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
|
||||
<button id="truncate-test-button">Test truncate</button>
|
||||
<button id="truncate-button">Trucate series</button>
|
||||
<div id="truncate-output"></div>
|
||||
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
|
||||
|
||||
{% if can_edit %}
|
||||
<details class="styled-detail">
|
||||
<summary>Series info</summary>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_anonymise_dicom' pk=series.pk %}'" title="Anonymise dicom images">
|
||||
Anonymise dicoms
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom' pk=series.pk %}'" title="orders dicom by slice location">
|
||||
Order by slice location
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_instance' pk=series.pk %}'" title="orders dicom by instance number">
|
||||
Order by instance number
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}'" title="orders dicom by SeriesInstanceUID">
|
||||
Order by SeriesInstanceUID
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_upload_filename' pk=series.pk %}'" title="orders dicom by uploaded filename">
|
||||
Order by uploaded filename
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm" onclick="window.location.href='{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}'" title="split series by dicom tag ImageType">
|
||||
Split by ImageType tag
|
||||
<!-- Images (loaded on demand via HTMX) -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Images</span>
|
||||
<div>
|
||||
<button id="load-images-button" class="btn btn-sm btn-outline-primary"
|
||||
hx-get="{% url 'atlas:series_images' series.pk %}"
|
||||
hx-target="#series-images-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="click"
|
||||
hx-on="htmx:beforeRequest: document.getElementById('images-loading-indicator').style.display='inline-block'; htmx:afterSwap: document.getElementById('images-loading-indicator').style.display='none'; document.getElementById('load-images-button').disabled = true">
|
||||
Load images
|
||||
</button>
|
||||
<a href="{% url 'atlas:series_download' series.pk %}" class="btn btn-sm btn-outline-success ms-2" target="_blank" rel="noopener">Download</a>
|
||||
<span id="images-loading-indicator" class="spinner-border spinner-border-sm ms-2" role="status" aria-hidden="true" style="display:none"></span>
|
||||
</div>
|
||||
<details>
|
||||
<summary>
|
||||
Image details
|
||||
</summary>
|
||||
<form hx-post="{% url 'atlas:image_diff' %}"
|
||||
hx-target="#temp"
|
||||
>
|
||||
{% for image in series.get_images %}
|
||||
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
|
||||
{% if image.image %}
|
||||
[{{image.get_file_size|filesizeformat}}]
|
||||
{% endif %}
|
||||
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
|
||||
<input type="checkbox" name="image-id" value="{{image.id}}">
|
||||
<br />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="series-images-container">
|
||||
<p class="text-muted small">Click 'Load images' to fetch.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Cases</div>
|
||||
<div class="card-body">
|
||||
<div id="cases-list">
|
||||
{% include 'atlas/partials/series_cases_list.html' %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<details id="add-series-to-case-details" class="mt-2">
|
||||
<summary>Add Series to Case</summary>
|
||||
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}" hx-target="#cases-list" hx-swap="innerHTML" class="mt-2">
|
||||
{% csrf_token %}
|
||||
<div class="mb-2">
|
||||
<label for="case-select" class="form-label">Select Case</label>
|
||||
<select name="case_id" id="case-select" class="form-select" required>
|
||||
{% for case in available_cases %}
|
||||
<option value="{{ case.pk }}">{{ case.title }}</option>
|
||||
{% endfor %}
|
||||
<button>Diff files</button>
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add to Case</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="document.getElementById('add-series-to-case-details').open = false">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
<span id="temp"></span>
|
||||
|
||||
<span id="diff-controls">show <span id="diff-show-all" _="on click
|
||||
log '1'
|
||||
for row in .diff.rows
|
||||
show row
|
||||
end">all</span> <span id="diff-show-diff" _="
|
||||
on click
|
||||
log 'go'
|
||||
for row in <.diff tr/>
|
||||
log row.children[0].innerHTML
|
||||
if row.children[0].innerHTML is empty
|
||||
hide row
|
||||
end
|
||||
end
|
||||
">diff</span></span>
|
||||
|
||||
</details>
|
||||
|
||||
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
|
||||
<p>
|
||||
<button type="button" class="btn btn-outline-success btn-sm" onclick="window.location.href='{% url 'atlas:series_download' pk=series.pk %}'">
|
||||
Download images
|
||||
</button>
|
||||
</p>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<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>
|
||||
<a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="clone-findings-modal"
|
||||
class="modal modal-blur fade"
|
||||
style="display: none"
|
||||
aria-hidden="false"
|
||||
tabindex="-1">
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card">
|
||||
<div class="card-header">Truncate series</div>
|
||||
<div class="card-body">
|
||||
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p>
|
||||
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Start</label>
|
||||
<div class="input-group">
|
||||
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
|
||||
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">End</label>
|
||||
<div class="input-group">
|
||||
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
|
||||
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
|
||||
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
|
||||
</div>
|
||||
<div id="truncate-output" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
@@ -359,6 +357,8 @@ Associated case:
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endwith %}
|
||||
|
||||
<style>
|
||||
.case-item {
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3>Name: {{structure.name}}</h3>
|
||||
Primary name: {{structure.primary}}<br />
|
||||
Synonyms: {{structure.synonym.all|join:", "}}<br />
|
||||
Primary name: {% if structure.canonical %}No (alias of <a href="{{ structure.canonical.get_absolute_url }}">{{ structure.canonical.name }}</a>){% else %}Yes{% endif %}<br />
|
||||
Synonyms: {{ structure.get_synonym_link|safe }}<br />
|
||||
</div>
|
||||
<h4>Associated Cases</h4>
|
||||
<ul>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "atlas/base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block js %}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Add / Edit Subspecialty</h2>
|
||||
<p>Use this form to create or edit a subspecialty.</p>
|
||||
<p>Please check if it already <a href='{% url "atlas:subspecialty_view" %}'>exists</a> before doing so!</p>
|
||||
|
||||
<form action="" method="post" enctype="multipart/form-data" id="subspecialty-form">
|
||||
{% csrf_token %}
|
||||
{% crispy form %}
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
<a class="btn btn-secondary ms-2" href="{% url 'atlas:subspecialty_view' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,14 @@
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<h2>My Collections</h2>
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<h2 class="mb-0">{{ model_verbose_name_plural|capfirst }}</h2>
|
||||
{% if request.user.is_staff and create_url %}
|
||||
<div>
|
||||
<a class="btn btn-sm btn-primary" href="{{ create_url }}">Add new {{ model_verbose_name }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% comment %} <details>
|
||||
<summary>
|
||||
<h4>Filter</h4>
|
||||
@@ -19,7 +26,6 @@
|
||||
</form>
|
||||
</div>
|
||||
</details> {% endcomment %}
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||
{% render_table table %}
|
||||
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% load i18n %}
|
||||
<div class="btn-group" role="group" aria-label="{{ widget.name }}">
|
||||
{% for val, label in widget.choices %}
|
||||
{% with forloop.counter0 as idx %}
|
||||
{% with id=widget.name|add:"_"|add:idx %}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="{{ widget.name }}"
|
||||
id="{{ widget.name }}_{{ idx }}"
|
||||
value="{{ val }}"
|
||||
{% if val|stringformat:"s" == widget.value|stringformat:"s" %}checked{% endif %}
|
||||
autocomplete="off"
|
||||
>
|
||||
<label
|
||||
class="btn btn-outline-primary {% if val|stringformat:"s" == widget.value|stringformat:"s" %}active{% endif %}"
|
||||
for="{{ widget.name }}_{{ idx }}"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
+72
-6
@@ -34,6 +34,7 @@ urlpatterns = [
|
||||
path("author/", views.author_list, name="author_list"),
|
||||
path("case/", views.CaseView.as_view(), name="case_view"),
|
||||
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"),
|
||||
path("collection/user", views.user_collections, name="user_collections"),
|
||||
path("uploads", views.user_uploads, name="user_uploads"),
|
||||
@@ -111,6 +112,11 @@ urlpatterns = [
|
||||
views.CollectionCaseUpdate.as_view(),
|
||||
name="collection_case_update",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/case_jump",
|
||||
views.collection_case_jump_partial,
|
||||
name="collection_case_jump_partial",
|
||||
),
|
||||
path(
|
||||
"collection/add_cases",
|
||||
views.add_cases_to_collection,
|
||||
@@ -167,30 +173,56 @@ urlpatterns = [
|
||||
),
|
||||
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/details",
|
||||
"collection/<int:exam_id>/case/<int:case_number>/details",
|
||||
views.collection_case_details,
|
||||
name="collection_case_details",
|
||||
),
|
||||
# Backwards-compatible redirect from legacy case_id-style URLs
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/questions",
|
||||
"collection/<int:exam_id>/case/id/<int:case_id>/details",
|
||||
views.redirect_collection_case_details_by_id,
|
||||
name="collection_case_details_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_number>/questions",
|
||||
views.collection_case_questions,
|
||||
name="collection_case_questions",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/priors",
|
||||
"collection/<int:exam_id>/case/id/<int:case_id>/questions",
|
||||
views.redirect_collection_case_questions_by_id,
|
||||
name="collection_case_questions_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_number>/priors",
|
||||
views.collection_case_priors,
|
||||
name="collection_case_priors",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/dicom_json",
|
||||
"collection/<int:exam_id>/case/id/<int:case_id>/priors",
|
||||
views.redirect_collection_case_priors_by_id,
|
||||
name="collection_case_priors_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_number>/dicom_json",
|
||||
views.collection_case_dicom_json,
|
||||
name="collection_case_dicom_json",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_id>/dicom_json/review",
|
||||
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json",
|
||||
views.redirect_collection_case_dicom_json_by_id,
|
||||
name="collection_case_dicom_json_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/<int:case_number>/dicom_json/review",
|
||||
views.collection_case_dicom_json_review,
|
||||
name="collection_case_dicom_json_review",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json/review",
|
||||
views.redirect_collection_case_dicom_json_review_by_id,
|
||||
name="collection_case_dicom_json_review_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:exam_id>/question_schemas",
|
||||
views.collection_question_schemas,
|
||||
@@ -271,26 +303,52 @@ urlpatterns = [
|
||||
views.collection_case_view,
|
||||
name="collection_case_view",
|
||||
),
|
||||
# legacy path that used case PK; redirect to canonical case_number URL
|
||||
path(
|
||||
"collection/<int:collection_id>/<int:case_id>/display_setup",
|
||||
"collection/<int:pk>/id/<int:case_id>",
|
||||
views.redirect_collection_case_view_by_id,
|
||||
name="collection_case_view_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:collection_id>/<int:case_number>/display_setup",
|
||||
views.collection_case_displaysetup,
|
||||
name="collection_case_displaysetup",
|
||||
),
|
||||
path(
|
||||
"collection/<int:collection_id>/id/<int:case_id>/display_setup",
|
||||
views.redirect_collection_case_displaysetup_by_id,
|
||||
name="collection_case_displaysetup_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
|
||||
views.collection_case_view_take,
|
||||
name="collection_case_view_take",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/id/<int:case_id>/take/<int:cid>/<str:passcode>",
|
||||
views.redirect_collection_case_view_take_by_id,
|
||||
name="collection_case_view_take_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/<int:case_number>/take/",
|
||||
views.collection_case_view_take_user,
|
||||
name="collection_case_view_take_user",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/id/<int:case_id>/take/",
|
||||
views.redirect_collection_case_view_take_user_by_id,
|
||||
name="collection_case_view_take_user_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/<int:case_number>/take/answers",
|
||||
views.collection_case_view_take_user_answers,
|
||||
name="collection_case_view_take_user_answers",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/id/<int:case_id>/take/answers",
|
||||
views.redirect_collection_case_view_take_user_answers_by_id,
|
||||
name="collection_case_view_take_user_answers_legacy",
|
||||
),
|
||||
path(
|
||||
"collection/<int:pk>/json_edit",
|
||||
views.GenericExamViews.exam_json_edit,
|
||||
@@ -349,6 +407,7 @@ urlpatterns = [
|
||||
path("categories/", views.categories_list, name="categories_list"),
|
||||
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
|
||||
path("search/cases/", views.case_search_partial, name="case_search_partial"),
|
||||
path("normals/", views.NormalCaseList.as_view(), name="normals_list"),
|
||||
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
|
||||
path(
|
||||
"condition/<int:pk>/delete",
|
||||
@@ -369,6 +428,7 @@ urlpatterns = [
|
||||
path(
|
||||
"subspecialty/<int:pk>", views.subspecialty_detail, name="subspecialty_detail"
|
||||
),
|
||||
path("subspecialty/create", views.SubspecialtyCreate.as_view(), name="subspecialty_create"),
|
||||
path("condition/create", views.ConditionCreate.as_view(), name="condition_create"),
|
||||
path("finding/", views.FindingView.as_view(), name="finding_view"),
|
||||
path("finding/<int:pk>", views.finding_detail, name="finding_detail"),
|
||||
@@ -397,6 +457,7 @@ 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:pk>/images/", views.series_images_partial, name="series_images"),
|
||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||
path("series/", views.SeriesView.as_view(), name="series_view"),
|
||||
@@ -454,6 +515,9 @@ urlpatterns = [
|
||||
# path("unchecked/", views.unchecked_list, name="unchecked_list"),
|
||||
# path("verified/<int:pk>/", views.verified_detail, name="verified_detail"),
|
||||
path("case/<int:pk>/", views.case_detail, name="case_detail"),
|
||||
path("case/<int:pk>/toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"),
|
||||
path("case/<int:pk>/normal_form/", views.case_normal_form, name="case_normal_form"),
|
||||
path("case/<int:pk>/create_normal/", views.create_case_normal, name="case_create_normal"),
|
||||
# path("case/<int:pk>/collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"),
|
||||
path(
|
||||
"case/<int:case_id>/collection-form",
|
||||
@@ -539,6 +603,7 @@ urlpatterns = [
|
||||
path(
|
||||
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
|
||||
),
|
||||
path("presentation/create", views.PresentationCreate.as_view(), name="presentation_create"),
|
||||
path(
|
||||
"process/",
|
||||
views.PathologicalProcessView.as_view(),
|
||||
@@ -549,6 +614,7 @@ urlpatterns = [
|
||||
views.pathological_process_detail,
|
||||
name="pathological_process_detail",
|
||||
),
|
||||
path("process/create", views.PathologicalProcessCreate.as_view(), name="pathological_process_create"),
|
||||
path("combine_series/", views.combine_series, name="combine_series"),
|
||||
path(
|
||||
"case/<int:case_id>/linked/",
|
||||
|
||||
+973
-142
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
upstream app_server {
|
||||
server web:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
location /static/ {
|
||||
alias /usr/src/app/static/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
location /media {
|
||||
alias /usr/src/app/media;
|
||||
}
|
||||
|
||||
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_redirect off;
|
||||
proxy_buffering off;
|
||||
proxy_pass http://app_server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
upstream app_server {
|
||||
# In production we proxy to the 'web' service (gunicorn) listening on 8000
|
||||
server web:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Serve static files from the static volume
|
||||
location /static/ {
|
||||
alias /usr/src/app/static/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Serve media files from the media volume. CORS handling preserved.
|
||||
set $cors "";
|
||||
if ($http_origin ~* (https?://(localhost:5173|viewer\.penracourses\.org\.uk))) {
|
||||
set $cors $http_origin;
|
||||
}
|
||||
|
||||
location /media {
|
||||
if ($request_method = OPTIONS ) {
|
||||
add_header 'Access-Control-Allow-Origin' $cors always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Allow-Headers' "Origin, X-Requested-With, Content-Type, Accept" always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 200;
|
||||
}
|
||||
add_header 'Access-Control-Allow-Origin' $cors always;
|
||||
alias /usr/src/app/media;
|
||||
}
|
||||
|
||||
# Viewer / OHIF / other static frontends - mount these into the nginx container at deploy time
|
||||
location /viewer {
|
||||
alias /srv/www/viewer;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /viewer/index.html;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header 'Cross-Origin-Resource-Policy' 'cross-origin';
|
||||
}
|
||||
|
||||
location /ohif {
|
||||
add_header 'Cross-Origin-Opener-Policy' 'same-origin' always;
|
||||
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
|
||||
add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
|
||||
alias /srv/www/ohif;
|
||||
try_files $uri /ohif/index.html;
|
||||
}
|
||||
|
||||
# rts and other host-mounted folders
|
||||
location /rts {
|
||||
alias /srv/www/rts;
|
||||
}
|
||||
|
||||
location /rota {
|
||||
alias /srv/proc/rota;
|
||||
autoindex on;
|
||||
}
|
||||
|
||||
location /uploader {
|
||||
alias /srv/uploader;
|
||||
autoindex on;
|
||||
}
|
||||
|
||||
# Reporter proxy - if you run a reporter service, set it up on the 'reporter' network name
|
||||
location ~ ^/reporter/(.*)$ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_pass_header Authorization;
|
||||
|
||||
proxy_pass http://reporter:5129/$1?$args;
|
||||
proxy_set_header X-Forwarded-Prefix /reporter;
|
||||
proxy_redirect http://reporter:5129/ /reporter/;
|
||||
}
|
||||
|
||||
# Default proxy to the Django/gunicorn upstream
|
||||
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_redirect off;
|
||||
proxy_buffering off;
|
||||
add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
|
||||
add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
|
||||
|
||||
proxy_pass http://app_server;
|
||||
}
|
||||
|
||||
# Default dev / health server block; keep 443 managed outside of docker unless you mount certs
|
||||
}
|
||||
|
||||
# A minimal HTTPS server block that expects certs to be mounted at /etc/letsencrypt
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name www.penracourses.org.uk penracourses.org.uk viewer.penracourses.org.uk;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/www.penracourses.org.uk/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/www.penracourses.org.uk/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
client_max_body_size 4G;
|
||||
|
||||
# Same static/media handling as above
|
||||
location /static/ { alias /usr/src/app/static/; }
|
||||
location /media { alias /usr/src/app/media; }
|
||||
|
||||
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_redirect off;
|
||||
proxy_buffering off;
|
||||
proxy_pass http://app_server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Example `settings_local.py` derived from the production server file.
|
||||
This file is a template only — DO NOT commit real secrets. Copy it to
|
||||
`rad/deploy/settings_local.py` on your server (or provide your own) and
|
||||
ensure the real file is excluded from git.
|
||||
"""
|
||||
import os
|
||||
|
||||
DEBUG = True
|
||||
|
||||
INTERNAL_IPS = ["82.69.88.125", "217.155.198.96", "localhost", "127.0.0.1"]
|
||||
|
||||
# When running in Docker (dev), requests will often come from an internal
|
||||
# container/network address. Add any discovered host/container IPs (and a
|
||||
# likely gateway address) to INTERNAL_IPS so the debug toolbar can render.
|
||||
if DEBUG:
|
||||
try:
|
||||
import socket
|
||||
|
||||
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
|
||||
for ip in ips:
|
||||
if ip not in INTERNAL_IPS:
|
||||
INTERNAL_IPS.append(ip)
|
||||
# common docker gateway pattern: replace last octet with '1'
|
||||
parts = ip.split('.')
|
||||
if len(parts) == 4:
|
||||
parts[-1] = '1'
|
||||
gw = '.'.join(parts)
|
||||
if gw not in INTERNAL_IPS:
|
||||
INTERNAL_IPS.append(gw)
|
||||
except Exception:
|
||||
# best-effort only; don't fail startup if this lookup doesn't work
|
||||
pass
|
||||
|
||||
# In local/dev enable the toolbar unconditionally to avoid IP/proxy
|
||||
# headaches while developing inside Docker/behind nginx.
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
||||
}
|
||||
|
||||
# Paths inside the nginx container / host-mounted deploy directories.
|
||||
# On the server, these paths are mounted into nginx as described in the
|
||||
# deployment README. Adjust as needed.
|
||||
STATIC_ROOT = '/usr/src/app/static/'
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
|
||||
if not DEBUG:
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
|
||||
'datefmt': "%d/%b/%Y %H:%M:%S",
|
||||
},
|
||||
'simple': {
|
||||
'format': '%(levelname)s %(message)s',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'file': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': 'log.txt',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['file'],
|
||||
'propagate': True,
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
'atlas': {
|
||||
'handlers': ['file'],
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# External service credentials (placeholders)
|
||||
CIMAR_USERNAME = "your.username@example.com"
|
||||
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"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": os.environ.get("DB_NAME", "django"),
|
||||
"USER": os.environ.get("DB_USER", "django"),
|
||||
"PASSWORD": os.environ.get("DB_PASSWORD", "AVNS_NG_s4i7SMMobWLO"),
|
||||
#"HOST": os.environ.get("DB_HOST", "db-postgresql-lon1-05515-do-user-8165014-0.b.db.ondigitalocean.com"),
|
||||
"HOST": os.environ.get("DB_HOST", "db-postgresql-lon1-05515-jan-22-backup-do-user-8165014-0.c.db.ondigitalocean.com"),
|
||||
"PORT": os.environ.get("DB_PORT", "25060"),
|
||||
#"NAME": os.environ.get("DB_NAME", "django"),
|
||||
#"USER": os.environ.get("DB_USER", "django"),
|
||||
#"PASSWORD": os.environ.get("DB_PASSWORD", "f7bf31dc9bda1256ea827953480d1917"),
|
||||
#"HOST": os.environ.get("DB_HOST", "161.35.163.87"),
|
||||
#"PORT": os.environ.get("DB_PORT", "5432"),
|
||||
}
|
||||
}
|
||||
|
||||
# Development overrides to avoid forcing HTTPS and secure cookies in local dev
|
||||
# (production should keep these True).
|
||||
SECURE_SSL_REDIRECT = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
CSRF_COOKIE_SECURE = False
|
||||
SECURE_HSTS_SECONDS = 0
|
||||
|
||||
# Allow requests from the local dev nginx/dev server and direct runserver
|
||||
# (include scheme+host+port for Django's origin checking).
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://127.0.0.1:8000",
|
||||
"http://localhost:8000",
|
||||
]
|
||||
|
||||
REMOTE_URL = "http://127.0.0.1:8000"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Example `settings_local.py` derived from the production server file.
|
||||
This file is a template only — DO NOT commit real secrets. Copy it to
|
||||
`rad/deploy/settings_local.py` on your server (or provide your own) and
|
||||
ensure the real file is excluded from git.
|
||||
"""
|
||||
|
||||
DEBUG = False
|
||||
|
||||
INTERNAL_IPS = ["82.69.88.125", "217.155.198.96"]
|
||||
|
||||
# Paths inside the nginx container / host-mounted deploy directories.
|
||||
# On the server, these paths are mounted into nginx as described in the
|
||||
# deployment README. Adjust as needed.
|
||||
STATIC_ROOT = '/usr/src/app/static/'
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
|
||||
if not DEBUG:
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
|
||||
'datefmt': "%d/%b/%Y %H:%M:%S",
|
||||
},
|
||||
'simple': {
|
||||
'format': '%(levelname)s %(message)s',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'file': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': 'log.txt',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['file'],
|
||||
'propagate': True,
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
'atlas': {
|
||||
'handlers': ['file'],
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# External service credentials (placeholders)
|
||||
CIMAR_USERNAME = "your.username@example.com"
|
||||
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"
|
||||
@@ -0,0 +1,69 @@
|
||||
# Development override: point the web service to rad/.env.dev and map nginx to
|
||||
# non-privileged host ports so you can run without sudo.
|
||||
|
||||
services:
|
||||
web:
|
||||
env_file:
|
||||
- ../.env.dev
|
||||
# Development: run Django's autoreloading development server instead of
|
||||
# the production entrypoint/gunicorn. Mount the repository into the
|
||||
# container so code edits on the host trigger Django's autoreload.
|
||||
entrypoint: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
# Clear any command set by the prod compose file (which would be passed
|
||||
# as an extra argument to manage.py). Setting an empty command prevents
|
||||
# the image `command` from being appended to the entrypoint.
|
||||
command: []
|
||||
volumes:
|
||||
- ../:/usr/src/app:cached
|
||||
# Mount the app log directory so logs are visible on the host at ./logs
|
||||
- ../logs:/var/log/rad
|
||||
nginx:
|
||||
# Development nginx override: mount a simplified config that does not
|
||||
# reference LetsEncrypt certs so the container can start without real
|
||||
# certificates present on the host.
|
||||
image: nginx:1.25-alpine
|
||||
volumes:
|
||||
- ../deploy/nginx/dev.conf:/etc/nginx/conf.d/default.conf: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:
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
- 8000:8000
|
||||
- 3459:3459
|
||||
env_file:
|
||||
- ./.env.dev
|
||||
- ./.env.dev.local
|
||||
db:
|
||||
image: postgres:14.2-alpine
|
||||
volumes:
|
||||
@@ -0,0 +1,119 @@
|
||||
services:
|
||||
redis:
|
||||
image: redis:7.4.2-alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD","redis-cli","ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build:
|
||||
# build context is the repository root (one level up from this file)
|
||||
context: ..
|
||||
dockerfile: rad/Dockerfile.prod
|
||||
restart: always
|
||||
# Use COMPOSE_ENV to select which env file to load (defaults to 'prod').
|
||||
# Example: COMPOSE_ENV=dev docker compose -f rad/docker/docker-compose.prod.yml -f rad/docker/docker-compose.dev.yml up -d
|
||||
env_file:
|
||||
- ../.env.${COMPOSE_ENV:-prod}
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=rad.settings
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- static_volume:/usr/src/app/static
|
||||
- media_volume:/usr/src/app/media
|
||||
# Mount the server-specific local settings if present (DO NOT commit secrets).
|
||||
# On the server create: rad/deploy/settings_local.py and it will be mounted into the container.
|
||||
- ../deploy/settings_local.py:/usr/src/app/rad/settings_local.py:ro
|
||||
expose:
|
||||
- "8000"
|
||||
command: ["/usr/src/app/entrypoint.sh"]
|
||||
|
||||
nginx:
|
||||
image: nginx:stable-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "${NGINX_HTTP_PORT:-80}:80"
|
||||
- "${NGINX_HTTPS_PORT:-443}:443"
|
||||
depends_on:
|
||||
- web
|
||||
volumes:
|
||||
- ../deploy/nginx/prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- static_volume:/usr/src/app/static:ro
|
||||
- media_volume:/usr/src/app/media:ro
|
||||
- ../deploy/nginx/certs:/etc/letsencrypt:ro
|
||||
# Host-provided front-end bundles and static folders. Populate these under rad/deploy on the server
|
||||
- ../deploy/www/viewer:/srv/www/viewer:ro
|
||||
- ../deploy/www/ohif:/srv/www/ohif:ro
|
||||
- ../deploy/www/rts:/srv/www/rts:ro
|
||||
- ../deploy/nicereporter:/srv/nicereporter:rw
|
||||
- ../deploy/proc-rota:/srv/proc/rota:ro
|
||||
- ../deploy/uploader:/srv/uploader:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL","nginx -t" ]
|
||||
interval: 30s
|
||||
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
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
static_volume:
|
||||
media_volume:
|
||||
grafana-data:
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,26 @@
|
||||
version: "3.8"
|
||||
|
||||
# Local test compose overlay — adds a Postgres service so you can run the
|
||||
# full stack locally without an external DB. Use it together with
|
||||
# docker-compose.prod.yml:
|
||||
#
|
||||
# docker compose -f rad/docker/docker-compose.prod.yml -f rad/docker/docker-compose.test.yml up -d --build
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-django}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-rad}
|
||||
volumes:
|
||||
- postgres_test_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL","pg_isready -U ${POSTGRES_USER:-django}" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_test_data:
|
||||
@@ -0,0 +1,43 @@
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
grpc_listen_port: 9095
|
||||
|
||||
ingester:
|
||||
lifecycler:
|
||||
address: 127.0.0.1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
replication_factor: 1
|
||||
chunk_idle_period: 5m
|
||||
chunk_retain_period: 30s
|
||||
max_transfer_retries: 0
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2020-10-24
|
||||
store: boltdb-shipper
|
||||
object_store: filesystem
|
||||
schema: v11
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
storage_config:
|
||||
boltdb_shipper:
|
||||
active_index_directory: /loki/index
|
||||
cache_location: /loki/cache
|
||||
shared_store: filesystem
|
||||
filesystem:
|
||||
directory: /loki/chunks
|
||||
|
||||
# Compactor settings: ensure compactor working directory is inside the mounted
|
||||
# `/loki` path so the process (running as UID 10001) can create files.
|
||||
compactor:
|
||||
working_directory: /loki/compactor
|
||||
shared_store: filesystem
|
||||
|
||||
limits_config:
|
||||
enforce_metric_name: false
|
||||
|
||||
auth_enabled: false
|
||||
@@ -0,0 +1 @@
|
||||
{"UID":"f8cade59-3cf9-4ae3-965d-8b0a57007d58","created_at":"2025-12-01T17:14:05.101735276Z","version":{"version":"2.8.2","revision":"9f809eda7","branch":"HEAD","buildUser":"root@e401cfcb874f","buildDate":"2023-05-03T11:07:54Z","goVersion":"go1.20.4"}}
|
||||
@@ -0,0 +1,18 @@
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
grpc_listen_port: 0
|
||||
|
||||
positions:
|
||||
filename: /tmp/positions.yaml
|
||||
|
||||
clients:
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
|
||||
scrape_configs:
|
||||
- job_name: rad_app_logs
|
||||
static_configs:
|
||||
- targets:
|
||||
- localhost
|
||||
labels:
|
||||
job: rad_app
|
||||
__path__: /var/log/rad/*.log
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Lightweight wait-for-postgres using nc (expects DATABASE_HOST and DATABASE_PORT env vars)
|
||||
if [ -n "${DATABASE_HOST}" ]; then
|
||||
host="$DATABASE_HOST"
|
||||
port="${DATABASE_PORT:-5432}"
|
||||
echo "Waiting for database at ${host}:${port}..."
|
||||
while ! nc -z "$host" "$port"; do
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Applying database migrations..."
|
||||
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}
|
||||
+351
-1
@@ -2,6 +2,7 @@ from django.forms import (
|
||||
Form,
|
||||
ModelForm,
|
||||
ModelMultipleChoiceField,
|
||||
MultipleChoiceField,
|
||||
ModelChoiceField,
|
||||
ChoiceField,
|
||||
CharField,
|
||||
@@ -77,6 +78,9 @@ from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
|
||||
from dal import autocomplete
|
||||
from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
||||
@@ -291,6 +295,70 @@ class ExamGroupsFormMixin(ModelForm):
|
||||
self.fields["user_user_groups"].widget.attrs["size"] = 8
|
||||
|
||||
|
||||
class ExamCollectionBulkAddGroupsForm(Form):
|
||||
"""Form used by the HTMX modal to bulk-add CID/User groups to an ExamCollection.
|
||||
|
||||
Accepts `user` kwarg in __init__ to limit available groups based on permissions
|
||||
(mirrors behaviour of `ExamGroupsFormMixin`).
|
||||
"""
|
||||
cid_user_groups = ModelMultipleChoiceField(required=False, queryset=CidUserGroup.objects.none(), label="CID User Groups")
|
||||
user_user_groups = ModelMultipleChoiceField(required=False, queryset=UserUserGroup.objects.none(), label="User Groups")
|
||||
EXAM_TYPE_CHOICES = [
|
||||
("all", "All"),
|
||||
("anatomy", "Anatomy"),
|
||||
("longs", "Longs"),
|
||||
("rapids", "Rapids"),
|
||||
("shorts", "Shorts"),
|
||||
("physics", "Physics"),
|
||||
("sbas", "SBAs"),
|
||||
]
|
||||
exam_types = MultipleChoiceField(required=False, choices=EXAM_TYPE_CHOICES, label="Exam types (leave empty for all)")
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
# keep compatibility with other forms that pass user as kwarg
|
||||
# This is a plain Form (not a ModelForm) so call Form.__init__
|
||||
Form.__init__(self, *args, **kwargs)
|
||||
# choose queryset depending on permissions
|
||||
if user and (user.is_superuser or user.groups.filter(name="cid_user_manager").exists()):
|
||||
cid_qs = CidUserGroup.objects.filter(archive=False)
|
||||
user_qs = UserUserGroup.objects.filter(archive=False)
|
||||
else:
|
||||
cid_qs = CidUserGroup.objects.filter(archive=False, open_access=True)
|
||||
user_qs = UserUserGroup.objects.filter(archive=False, open_access=True)
|
||||
|
||||
self.fields["cid_user_groups"].queryset = cid_qs
|
||||
self.fields["user_user_groups"].queryset = user_qs
|
||||
self.fields["cid_user_groups"].widget.attrs["size"] = 8
|
||||
self.fields["user_user_groups"].widget.attrs["size"] = 8
|
||||
|
||||
# Crispy helper: render fields prettily in templates. We set form_tag=False
|
||||
# because the HTMX partial includes the <form> tag with hx attributes.
|
||||
try:
|
||||
self.helper = FormHelper()
|
||||
# render only the fields (the template contains the form element)
|
||||
self.helper.form_tag = False
|
||||
# use bootstrap5 templates if available in the project
|
||||
self.helper.template_pack = "bootstrap5"
|
||||
self.helper.form_class = "form-vertical"
|
||||
self.helper.layout = Layout(
|
||||
Div(
|
||||
Field("cid_user_groups", css_class="form-select", template="bootstrap5/layout/multi_select.html"),
|
||||
css_class="mb-3",
|
||||
),
|
||||
Div(
|
||||
Field("user_user_groups", css_class="form-select", template="bootstrap5/layout/multi_select.html"),
|
||||
css_class="mb-3",
|
||||
),
|
||||
Div(
|
||||
Field("exam_types", css_class="form-select"),
|
||||
css_class="mb-3",
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# If crispy isn't available for some reason, degrade gracefully.
|
||||
self.helper = None
|
||||
|
||||
|
||||
class ExaminationForm(ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Where is this coming from?
|
||||
@@ -488,13 +556,295 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
|
||||
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
||||
|
||||
|
||||
class UserSearchSelectMultipleWidget:
|
||||
"""Reusable lightweight widget renderer for a searchable multi-user selector.
|
||||
|
||||
Usage: instantiate with (name, value) where value is an iterable of user ids
|
||||
and call .render() to get the HTML fragment. This mirrors the previous
|
||||
nested widget but is available module-wide for reuse.
|
||||
"""
|
||||
|
||||
def __init__(self, name, value):
|
||||
self.name = name
|
||||
self.value = value or []
|
||||
|
||||
def render(self):
|
||||
field_id = f"id_{self.name}"
|
||||
search_id = f"user_search_{self.name}"
|
||||
results_id = f"user_search_results_{self.name}"
|
||||
selected_list_id = f"selected_users_{self.name}"
|
||||
|
||||
users = User.objects.filter(pk__in=self.value) if self.value else []
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
for u in users:
|
||||
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
||||
grade_text = ""
|
||||
try:
|
||||
up = getattr(u, "userprofile", None)
|
||||
if up and getattr(up, "grade", None):
|
||||
g = up.grade
|
||||
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
||||
except Exception:
|
||||
grade_text = ""
|
||||
|
||||
selected_html += (
|
||||
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
||||
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
||||
+ '</span>'
|
||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
||||
f'</li>'
|
||||
)
|
||||
|
||||
url = reverse("generic:user_search_widget")
|
||||
|
||||
grades_options = '<option value="">All grades</option>'
|
||||
try:
|
||||
from generic.models import UserGrades
|
||||
|
||||
grades_qs = UserGrades.objects.all()
|
||||
for g in grades_qs:
|
||||
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
||||
except Exception:
|
||||
grades_options = '<option value="">All grades</option>'
|
||||
|
||||
html = f"""
|
||||
<div class="user-search-widget">
|
||||
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
||||
{options_html}
|
||||
</select>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
|
||||
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
||||
{grades_options}
|
||||
</select>
|
||||
<!-- Help button for advanced search features -->
|
||||
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
|
||||
</div>
|
||||
|
||||
<div id="{results_id}" class="mt-2"></div>
|
||||
|
||||
<!-- Collapsible help panel (hidden by default) -->
|
||||
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
|
||||
<strong>Advanced search tips</strong>
|
||||
<ul class="mb-0 mt-1">
|
||||
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
|
||||
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
|
||||
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
|
||||
<ul class="mb-0 mt-1">
|
||||
<li><code>name:</code> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
|
||||
<li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
|
||||
<li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
|
||||
<li><code>email:</code> — matches email address.</li>
|
||||
<li><code>username:</code> — matches username.</li>
|
||||
<li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
|
||||
<li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
|
||||
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
|
||||
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label small">Selected users</label>
|
||||
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
||||
{selected_html}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
const search = document.getElementById('{search_id}');
|
||||
const results = document.getElementById('{results_id}');
|
||||
const select = document.getElementById('{field_id}');
|
||||
const selectedList = document.getElementById('{selected_list_id}');
|
||||
const gradeSelect = document.getElementById('grade_filter_{self.name}');
|
||||
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
|
||||
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
|
||||
|
||||
let timeout = null;
|
||||
function fetchResults(q) {{
|
||||
const grade = gradeSelect ? gradeSelect.value : '';
|
||||
if (!q || q.trim().length === 0) {{
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}}
|
||||
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
|
||||
fetch(url)
|
||||
.then(r => r.text())
|
||||
.then(html => {{ results.innerHTML = html; }});
|
||||
}}
|
||||
|
||||
search.addEventListener('keyup', function(e) {{
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||
}});
|
||||
|
||||
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
||||
// re-run search with same query when grade changes
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fetchResults(search.value), 50);
|
||||
}});
|
||||
|
||||
// Toggle help panel visibility
|
||||
if (helpBtn && helpPanel) {{
|
||||
helpBtn.addEventListener('click', function(e) {{
|
||||
e.preventDefault();
|
||||
helpPanel.classList.toggle('d-none');
|
||||
const expanded = !helpPanel.classList.contains('d-none');
|
||||
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
}});
|
||||
}}
|
||||
|
||||
// fieldName for this widget instance
|
||||
const widgetFieldName = '{self.name}';
|
||||
|
||||
// Add button click delegation: results list buttons have data attributes
|
||||
results.addEventListener('click', function(e) {{
|
||||
const btn = e.target.closest('.user-add-btn');
|
||||
if (!btn) return;
|
||||
const id = btn.getAttribute('data-user-id');
|
||||
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
|
||||
const grade = btn.getAttribute('data-user-grade') || '';
|
||||
addUserToField(widgetFieldName, id, text, grade);
|
||||
}});
|
||||
|
||||
// Wire up the "Add all results" button if present
|
||||
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
|
||||
if (addAllBtn) {{
|
||||
addAllBtn.addEventListener('click', function(e) {{
|
||||
e.preventDefault();
|
||||
addAllFromResults(widgetFieldName);
|
||||
}});
|
||||
}}
|
||||
|
||||
window.addUserToField = function(fieldName, id, text, grade) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
// prevent duplicate
|
||||
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
||||
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
||||
sel.appendChild(opt);
|
||||
|
||||
// add to visible list (include grade if provided)
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = 'selected_user_' + fieldName + '_' + id;
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
||||
li.appendChild(span); li.appendChild(btn);
|
||||
selectedList.appendChild(li);
|
||||
}}
|
||||
|
||||
window.removeUserFromField = function(fieldName, id) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
||||
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
|
||||
if (li && li.parentNode) li.parentNode.removeChild(li);
|
||||
}}
|
||||
|
||||
window.addAllFromResults = function(fieldName) {{
|
||||
const list = document.getElementById('user_search_results_list_' + fieldName);
|
||||
if (!list) return;
|
||||
const items = list.querySelectorAll('li[data-user-id]');
|
||||
items.forEach(function(it) {{
|
||||
const id = it.getAttribute('data-user-id');
|
||||
const text = it.getAttribute('data-user-text') || it.textContent.trim();
|
||||
const grade = it.getAttribute('data-user-grade') || '';
|
||||
addUserToField(fieldName, id, text, grade);
|
||||
}});
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
|
||||
class UserUserGroupForm(ModelForm):
|
||||
# Use module-level UserSearchSelectMultipleWidget for rendering the users field
|
||||
|
||||
users = UserUserGroupModelChoiceField(
|
||||
required=False,
|
||||
queryset=User.objects.all(),
|
||||
widget=FilteredSelectMultiple(verbose_name="Users", is_stacked=False),
|
||||
# use our lightweight widget rendering
|
||||
widget=None,
|
||||
)
|
||||
|
||||
# monkey-patch the field's widget rendering when the form is instantiated
|
||||
def __init__(self, *args, **kwargs):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
# replace widget with callable that renders our HTML
|
||||
if 'users' in self.fields:
|
||||
orig_field = self.fields['users']
|
||||
|
||||
class RenderWrapper:
|
||||
def __init__(self, field):
|
||||
self.field = field
|
||||
|
||||
# Minimal widget-compatible wrapper used by Django templates and
|
||||
# form machinery. We implement the small subset of the Widget
|
||||
# API that the templates/checks expect: `is_hidden`,
|
||||
# `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`.
|
||||
is_hidden = False
|
||||
needs_multipart_form = False
|
||||
use_fieldset = False
|
||||
# attrs is expected by BoundField.id_for_label (widget.attrs.get('id'))
|
||||
attrs = {}
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
# store attrs so template helpers can inspect them
|
||||
self.attrs = attrs or {}
|
||||
value = value or []
|
||||
widget = UserSearchSelectMultipleWidget(name, value)
|
||||
return widget.render()
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
# data is usually QueryDict with getlist
|
||||
if hasattr(data, 'getlist'):
|
||||
return data.getlist(name)
|
||||
# fallback
|
||||
val = data.get(name)
|
||||
if val is None:
|
||||
return []
|
||||
return [val]
|
||||
|
||||
def id_for_label(self, id_):
|
||||
# Called by BoundField.id_for_label; prefer explicit id in attrs
|
||||
try:
|
||||
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
|
||||
return self.attrs.get('id')
|
||||
except Exception:
|
||||
pass
|
||||
return id_
|
||||
|
||||
def use_required_attribute(self, initial):
|
||||
# Called by Django to decide whether to add the required HTML attribute.
|
||||
# We defer to the underlying field/widget if available; otherwise
|
||||
# return False to avoid unexpected 'required' attributes.
|
||||
try:
|
||||
if hasattr(self, 'field') and getattr(self, 'field') is not None:
|
||||
# If original field/widget had such method, prefer it.
|
||||
orig_widget = getattr(self.field, 'widget', None)
|
||||
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
|
||||
return orig_widget.use_required_attribute(initial)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
self.fields['users'].widget = RenderWrapper(orig_field)
|
||||
|
||||
class Meta:
|
||||
model = UserUserGroup
|
||||
fields = ["name", "archive", "open_access", "users"]
|
||||
|
||||
@@ -1946,6 +1946,138 @@ class ExamCollection(models.Model, AuthorMixin):
|
||||
exam.add_markers(users)
|
||||
#return True
|
||||
|
||||
def bulk_add_user_user_groups(self, groups, exam_types: None | list[str] = None) -> int:
|
||||
"""Bulk-add one or more UserUserGroup objects (or PKs) to all exams in this
|
||||
collection.
|
||||
|
||||
Args:
|
||||
groups: iterable of UserUserGroup instances or an iterable of PKs (ints),
|
||||
or a Django QuerySet of UserUserGroup.
|
||||
exam_types: optional list of exam type keys to limit the operation. Keys
|
||||
are the same as returned by `get_exams()` (e.g. 'physics', 'anatomy').
|
||||
|
||||
Returns:
|
||||
Number of exams that were updated (int).
|
||||
|
||||
Note: permission checks are not performed here; callers should ensure the
|
||||
current user is allowed to modify these exams.
|
||||
"""
|
||||
# import locally to avoid circular import surprises when module is imported
|
||||
UserUserGroup = globals().get("UserUserGroup")
|
||||
if UserUserGroup is None:
|
||||
raise RuntimeError("UserUserGroup model not available")
|
||||
|
||||
# Debug: log what we received
|
||||
try:
|
||||
group_pks = [g.pk for g in groups] if groups is not None else []
|
||||
except Exception:
|
||||
group_pks = []
|
||||
logger.debug("bulk_add_user_user_groups called on collection=%s with groups=%s exam_types=%s", self.pk, group_pks, exam_types)
|
||||
|
||||
# Normalize groups to a list of model instances
|
||||
if groups is None:
|
||||
return 0
|
||||
|
||||
# If groups is a queryset or list of model instances, materialize it
|
||||
try:
|
||||
# QuerySet-ish
|
||||
groups_qs = list(groups)
|
||||
except TypeError:
|
||||
# single value provided
|
||||
groups_qs = [groups]
|
||||
|
||||
# If provided values were ints (PKs), resolve them to model instances
|
||||
if groups_qs and all(isinstance(g, int) for g in groups_qs):
|
||||
groups_qs = list(UserUserGroup.objects.filter(pk__in=groups_qs))
|
||||
|
||||
if not groups_qs:
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
for exam_type, exams in self.get_exams().items():
|
||||
if exam_types is not None and exam_type not in exam_types:
|
||||
continue
|
||||
|
||||
for exam in exams:
|
||||
# Each exam model exposes a `user_user_groups` ManyToManyField
|
||||
try:
|
||||
before_pks = set(exam.user_user_groups.values_list('pk', flat=True))
|
||||
exam.user_user_groups.add(*groups_qs)
|
||||
# Ensure any downstream signals / caches see the change
|
||||
exam.save()
|
||||
after_pks = set(exam.user_user_groups.values_list('pk', flat=True))
|
||||
# log if expected group pks are not present after add
|
||||
try:
|
||||
expected_pks = {g.pk for g in groups_qs}
|
||||
except Exception:
|
||||
expected_pks = set()
|
||||
if not expected_pks.issubset(after_pks):
|
||||
logger.warning(
|
||||
"After add, exam %s missing expected user_user_groups; before=%s after=%s expected=%s",
|
||||
getattr(exam, 'pk', exam), sorted(before_pks), sorted(after_pks), sorted(expected_pks),
|
||||
)
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.exception("Failed to add user_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e)
|
||||
|
||||
return updated
|
||||
|
||||
def bulk_add_cid_user_groups(self, groups, exam_types: None | list[str] = None) -> int:
|
||||
"""Bulk-add one or more CidUserGroup objects (or PKs) to all exams in this
|
||||
collection. Returns number of exams updated.
|
||||
"""
|
||||
CidUserGroup = globals().get("CidUserGroup")
|
||||
if CidUserGroup is None:
|
||||
raise RuntimeError("CidUserGroup model not available")
|
||||
|
||||
# Debug: log what we received
|
||||
try:
|
||||
group_pks = [g.pk for g in groups] if groups is not None else []
|
||||
except Exception:
|
||||
group_pks = []
|
||||
logger.debug("bulk_add_cid_user_groups called on collection=%s with groups=%s exam_types=%s", self.pk, group_pks, exam_types)
|
||||
|
||||
if groups is None:
|
||||
return 0
|
||||
|
||||
try:
|
||||
groups_qs = list(groups)
|
||||
except TypeError:
|
||||
groups_qs = [groups]
|
||||
|
||||
if groups_qs and all(isinstance(g, int) for g in groups_qs):
|
||||
groups_qs = list(CidUserGroup.objects.filter(pk__in=groups_qs))
|
||||
|
||||
if not groups_qs:
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
for exam_type, exams in self.get_exams().items():
|
||||
if exam_types is not None and exam_type not in exam_types:
|
||||
continue
|
||||
|
||||
for exam in exams:
|
||||
# Each exam model exposes a `cid_user_groups` ManyToManyField
|
||||
try:
|
||||
before_pks = set(exam.cid_user_groups.values_list('pk', flat=True))
|
||||
exam.cid_user_groups.add(*groups_qs)
|
||||
exam.save()
|
||||
after_pks = set(exam.cid_user_groups.values_list('pk', flat=True))
|
||||
try:
|
||||
expected_pks = {g.pk for g in groups_qs}
|
||||
except Exception:
|
||||
expected_pks = set()
|
||||
if not expected_pks.issubset(after_pks):
|
||||
logger.warning(
|
||||
"After add, exam %s missing expected cid_user_groups; before=%s after=%s expected=%s",
|
||||
getattr(exam, 'pk', exam), sorted(before_pks), sorted(after_pks), sorted(expected_pks),
|
||||
)
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.exception("Failed to add cid_user_groups to exam %s: %s", getattr(exam, 'pk', exam), e)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
#class Presentation(models.Model, AuthorMixin):
|
||||
#
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-toggle-btn">Bulk edit</button>
|
||||
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-2">
|
||||
<button id="toggle-archive-btn" type="button" class="btn btn-sm btn-outline-info">Show archived</button>
|
||||
</div>
|
||||
<div id="bulk-controls" class="d-none">
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="select-all-btn">Select all</button>
|
||||
@@ -31,6 +35,16 @@
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm btn-danger" type="submit" onclick="return confirm('Delete selected exams? This action is irreversible.')">Delete selected</button>
|
||||
</form>
|
||||
<form id="bulk-archive-form" class="mt-2" hx-post="{% url app_name|add:':exam_bulk_archive' %}" hx-target="#exam-list-container" hx-include="#exam-list-container input[name='exam_ids']" hx-indicator="#bulk-action-indicator">
|
||||
{% for k, v in request.GET.items %}
|
||||
<input type="hidden" name="{{ k }}" value="{{ v }}" />
|
||||
{% endfor %}
|
||||
{% csrf_token %}
|
||||
<div class="btn-group" role="group">
|
||||
<button name="archive" value="true" class="btn btn-sm btn-warning" type="submit" onclick="return confirm('Archive selected exams?')">Archive selected</button>
|
||||
<button name="archive" value="false" class="btn btn-sm btn-outline-warning" type="submit" onclick="return confirm('Unarchive selected exams?')">Unarchive selected</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Bulk action indicator shown while HTMX requests are running -->
|
||||
<span id="bulk-action-indicator" class="ms-2" aria-hidden="true">
|
||||
@@ -67,6 +81,8 @@
|
||||
/* Defensive: hide bulk-controls unless bulk-mode is enabled */
|
||||
#exam-list-wrapper:not(.bulk-mode) #bulk-controls { display: none !important; }
|
||||
|
||||
/* (bulk-only top-level controls removed) */
|
||||
|
||||
/* Bulk action indicator - hidden by default, shown while HTMX request is active */
|
||||
#bulk-action-indicator { display: none; }
|
||||
#bulk-action-indicator.htmx-request { display: inline-block; }
|
||||
@@ -106,6 +122,58 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Top-level archive/unarchive buttons: make visible and usable without hunting inside bulk panel
|
||||
const topArchiveBtn = document.getElementById('top-archive-btn');
|
||||
const topUnarchiveBtn = document.getElementById('top-unarchive-btn');
|
||||
function ensureBulkModeVisible(){
|
||||
if(!examListWrapper.classList.contains('bulk-mode')){
|
||||
examListWrapper.classList.add('bulk-mode');
|
||||
bulkControls.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function submitArchiveAction(isArchive){
|
||||
// find the bulk-archive form already in the DOM
|
||||
const form = document.getElementById('bulk-archive-form');
|
||||
if(!form){
|
||||
alert('Bulk archive form not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// if no checkboxes selected, open bulk-mode and focus user to select
|
||||
const checked = Array.from(document.querySelectorAll('#exam-list-container input.exam-select-checkbox:checked'));
|
||||
if(checked.length === 0){
|
||||
ensureBulkModeVisible();
|
||||
// scroll to controls so user can pick
|
||||
bulkControls.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
// highlight controls briefly
|
||||
bulkControls.classList.add('border', 'border-3', 'border-primary');
|
||||
setTimeout(()=> bulkControls.classList.remove('border','border-3','border-primary'), 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// set the archive input value on the appropriate button and submit
|
||||
const btn = form.querySelector('button[name="archive"][value="' + (isArchive ? 'true' : 'false') + '"]');
|
||||
if(btn){
|
||||
btn.click();
|
||||
} else {
|
||||
// fallback: add hidden input and submit
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'archive';
|
||||
hidden.value = isArchive ? 'true' : 'false';
|
||||
form.appendChild(hidden);
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
if(topArchiveBtn){
|
||||
topArchiveBtn.addEventListener('click', function(){ submitArchiveAction(true); });
|
||||
}
|
||||
if(topUnarchiveBtn){
|
||||
topUnarchiveBtn.addEventListener('click', function(){ submitArchiveAction(false); });
|
||||
}
|
||||
|
||||
// Make clicking an exam row toggle its checkbox when bulk-mode is enabled.
|
||||
document.addEventListener('click', function(e){
|
||||
if(!examListWrapper.classList.contains('bulk-mode')) return;
|
||||
@@ -123,5 +191,49 @@
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Archive toggle button: toggle ?archive=1 while preserving other query params
|
||||
(function(){
|
||||
function getQueryParams(){
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
function updateButton(){
|
||||
const btn = document.getElementById('toggle-archive-btn');
|
||||
if(!btn) return;
|
||||
const params = getQueryParams();
|
||||
if(params.get('archive') && params.get('archive') !== '0'){
|
||||
btn.textContent = 'Hide archived';
|
||||
btn.classList.remove('btn-outline-info');
|
||||
btn.classList.add('btn-outline-secondary');
|
||||
} else {
|
||||
btn.textContent = 'Show archived';
|
||||
btn.classList.remove('btn-outline-secondary');
|
||||
btn.classList.add('btn-outline-info');
|
||||
}
|
||||
}
|
||||
|
||||
// handle toggle button click
|
||||
document.addEventListener('click', function(e){
|
||||
if(e.target && e.target.id === 'toggle-archive-btn'){
|
||||
const params = getQueryParams();
|
||||
if(params.get('archive') && params.get('archive') !== '0'){
|
||||
params.delete('archive');
|
||||
} else {
|
||||
params.set('archive','1');
|
||||
}
|
||||
const q = params.toString();
|
||||
const newUrl = window.location.pathname + (q ? ('?' + q) : '');
|
||||
window.location.href = newUrl;
|
||||
}
|
||||
});
|
||||
|
||||
// init on load
|
||||
document.addEventListener('DOMContentLoaded', updateButton);
|
||||
// also update button when HTMX swaps content
|
||||
document.addEventListener('htmx:afterSwap', updateButton);
|
||||
document.addEventListener('htmx:afterOnLoad', updateButton);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<div>
|
||||
<h1 class="h4 mb-1">Exam: {{ exam }}</h1>
|
||||
</div>
|
||||
<div class="mt-2 mt-md-0">
|
||||
{% comment %} <div class="mt-2 mt-md-0">
|
||||
{% include "generic/exam_link_headers.html" %}
|
||||
</div>
|
||||
</div> {% endcomment %}
|
||||
</div>
|
||||
{% include 'exam_notes.html' %}
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">per question</span>)
|
||||
</div>
|
||||
|
||||
<div class="mt-2 small text-muted">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
|
||||
|
||||
<div class="mt-2">
|
||||
Exam mode: <strong>{{ exam.exam_mode }}</strong>
|
||||
{% if exam.exam_mode %}
|
||||
@@ -178,17 +180,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
{% if exam.examcollection %}
|
||||
<div class="small">Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
|
||||
{% endif %}
|
||||
<div class="small mt-1">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 text-md-end small mt-2 mt-md-0">
|
||||
<span title="If true the results from this exam will be visible to supervisors automatically.">Supervisor visible: {{ exam.results_supervisor_visible }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{# Authors and supervisor visibility moved earlier in the header; duplicate block removed #}
|
||||
</div>
|
||||
|
||||
{% block css %}
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
{% endif %}
|
||||
|
||||
<br/>
|
||||
<div id="bulk-add-groups">
|
||||
<button class="btn-sm" hx-get="{% url 'generic:exam_collection_bulk_add_groups' object.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups to exams</button>
|
||||
</div>
|
||||
|
||||
<div id="add-user">
|
||||
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
<h1 class="mb-4">Exam Collections</h1>
|
||||
|
||||
<form method="get" class="mb-3" id="archive-filter-form">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="showArchived" name="show_archived" value="1" onchange="this.form.submit()"
|
||||
{% if request.GET.show_archived %}checked{% endif %}>
|
||||
<label class="form-check-label" for="showArchived">Show archived collections</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if object_list %}
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||
{% for collection in object_list %}
|
||||
@@ -30,12 +38,13 @@
|
||||
{# Use annotated counts provided by the view to avoid per-card COUNT queries #}
|
||||
<span class="badge bg-info text-dark ms-1" title="Total exams">{{ collection.total_count }} exams</span>
|
||||
{# Per-type badges (only show non-zero) #}
|
||||
{% with a=collection.anatomy_count l=collection.longs_count p=collection.physics_count r=collection.rapids_count s=collection.sbas_count %}
|
||||
{% with a=collection.anatomy_count l=collection.longs_count p=collection.physics_count r=collection.rapids_count s=collection.sbas_count sh=collection.shorts_count %}
|
||||
{% if a %}<span class="badge bg-primary ms-1">Anatomy {{ a }}</span>{% endif %}
|
||||
{% if l %}<span class="badge bg-primary ms-1">Longs {{ l }}</span>{% endif %}
|
||||
{% if p %}<span class="badge bg-primary ms-1">Physics {{ p }}</span>{% endif %}
|
||||
{% if r %}<span class="badge bg-primary ms-1">Rapids {{ r }}</span>{% endif %}
|
||||
{% if s %}<span class="badge bg-primary ms-1">SBAs {{ s }}</span>{% endif %}
|
||||
{% if sh %}<span class="badge bg-primary ms-1">Shorts {{ sh }}</span>{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<a href="{% url app_name|add:':exam_overview' pk=exam.pk %}">
|
||||
{% if exam.exam_mode %}Exam:{% else %}Packet:{% endif %} {{ exam }}
|
||||
</a>
|
||||
{% if exam.archive %}
|
||||
<span class="badge bg-warning text-dark ms-2" title="This exam is archived">Archived</span>
|
||||
{% endif %}
|
||||
<small class="ms-2 text-muted">
|
||||
<span class="badge bg-secondary">Questions: {{ exam.get_questions|length }}</span>
|
||||
<span class="badge bg-secondary ms-1">Candidates: {{ exam.get_candidate_count }}</span>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% load crispy_forms_tags %}
|
||||
<form method="post" action="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-post="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML" class="bulk-add-groups-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form|crispy }}
|
||||
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" hx-post="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-include="input[name='csrfmiddlewaretoken'], select[name='cid_user_groups'], select[name='user_user_groups'], select[name='exam_types']" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add groups</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" hx-get="{% url 'generic:examcollection_detail' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="bulk-add-result">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">Error: {{ error }}</div>
|
||||
{% else %}
|
||||
<p>Added user groups to {{ total_user }} exams.</p>
|
||||
<p>Added CID groups to {{ total_cid }} exams.</p>
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-outline-primary" hx-get="{% url 'generic:exam_collection_bulk_add_groups' collection.pk %}" hx-target="#bulk-add-groups" hx-swap="innerHTML">Add more</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="examActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="examActions">
|
||||
{# Primary action: add question #}
|
||||
<li>
|
||||
<a class="dropdown-item text-primary" href="{% url exam.get_app_name|add:':question_create' %}">
|
||||
<i class="bi bi-plus-lg me-2"></i> Add Question
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{# Edit / Clone group #}
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url exam.get_app_name|add:':exam_update' exam.pk %}">
|
||||
<i class="bi bi-pencil-square me-2"></i> Edit Exam
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-white" href="{% url exam.get_app_name|add:':exam_clone' exam.pk %}">
|
||||
<i class="bi bi-files me-2"></i> Clone
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
{# Authors / Markers (informational) #}
|
||||
<li>
|
||||
<a class="dropdown-item text-info" href="{% url exam.get_app_name|add:':exam_authors' exam.id %}">
|
||||
<i class="bi bi-people me-2"></i> Authors
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item text-info" href="{% url exam.get_app_name|add:':exam_markers' exam.id %}">
|
||||
<i class="bi bi-person-badge me-2"></i> Markers
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
{# Delete is destructive - show in red #}
|
||||
<li>
|
||||
<a class="dropdown-item text-danger" href="{% url exam.get_app_name|add:':exam_delete' exam.id %}">
|
||||
<i class="bi bi-trash me-2"></i> Delete
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_exam_change' exam.pk %}">
|
||||
<i class="bi bi-tools me-2"></i> Admin Edit
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,24 @@
|
||||
{% if results %}
|
||||
<ul class="list-unstyled mb-0" id="user_search_results_list_{{ field }}">
|
||||
{% for item in results %}
|
||||
<li class="py-1 d-flex justify-content-between align-items-center" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">
|
||||
<div class="flex-grow-1">
|
||||
{{ item.text|escape }}
|
||||
{% if item.grade %}
|
||||
<small class="text-muted ms-2">{{ item.grade|escape }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary user-add-btn" data-user-id="{{ item.id }}" data-user-text="{{ item.text|escapejs }}" data-user-grade="{{ item.grade|default_if_none:''|escapejs }}">Add</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if results %}
|
||||
<div class="mt-2 text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-success user-add-all-btn" data-field="{{ field }}">Add all results</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div><em>No users found</em></div>
|
||||
{% endif %}
|
||||
@@ -58,6 +58,7 @@ urlpatterns = [
|
||||
name="supervisor-autocomplete",
|
||||
),
|
||||
path("user-search/", views.user_search, name="user_search"),
|
||||
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
|
||||
path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
|
||||
path(
|
||||
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
|
||||
@@ -255,6 +256,7 @@ urlpatterns = [
|
||||
name="examcollection_delete",
|
||||
),
|
||||
path("collection/<int:collection_id>/add_author", views.exam_collection_add_author, name="exam_collection_add_author"),
|
||||
path("collection/<int:collection_id>/bulk_add_groups", views.exam_collection_bulk_add_groups, name="exam_collection_bulk_add_groups"),
|
||||
path("collection/<int:collection_id>/add_marker/<str:exam_type>", views.exam_collection_add_marker, name="exam_collection_add_marker"),
|
||||
path(
|
||||
"share_with_supervisor_toggle/<int:pk>/",
|
||||
@@ -338,6 +340,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
||||
generic_exam_view.exam_bulk_update,
|
||||
name="exam_bulk_update",
|
||||
),
|
||||
path(
|
||||
"exam/bulk_archive/",
|
||||
generic_exam_view.exam_bulk_archive,
|
||||
name="exam_bulk_archive",
|
||||
),
|
||||
path(
|
||||
"exam/bulk_delete/",
|
||||
generic_exam_view.exam_bulk_delete,
|
||||
|
||||
+291
-8
@@ -86,6 +86,7 @@ from .forms import (
|
||||
UserUserForm,
|
||||
UserUserGroupForm,
|
||||
UsersAutocompleteForm, # Added import
|
||||
ExamCollectionBulkAddGroupsForm,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
@@ -144,6 +145,7 @@ from shorts.models import Question as ShortsQuestion, ExamQuestionDetail as Shor
|
||||
|
||||
from django.db.models import Case, When
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
@@ -378,6 +380,72 @@ def create_examination(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser or u.groups.filter(name="cid_user_manager").exists())
|
||||
def exam_collection_bulk_add_groups(request, collection_id):
|
||||
"""HTMX endpoint: show form (GET) and process bulk-adding of CID/User groups (POST).
|
||||
|
||||
Renders small partials suitable for HTMX swaps.
|
||||
"""
|
||||
collection = get_object_or_404(ExamCollection, pk=collection_id)
|
||||
|
||||
logger.debug(f"exam_collection_bulk_add_groups called with method={request.method} by user={request.user.username}")
|
||||
logger.debug(request)
|
||||
|
||||
|
||||
if request.method == "GET":
|
||||
logger.debug(f"exam_collection_bulk_add_groups GET request by user={request.user.username}")
|
||||
form = ExamCollectionBulkAddGroupsForm(user=request.user)
|
||||
return render(request, "generic/partials/examcollection_bulk_add_groups_form.html", {"form": form, "collection": collection})
|
||||
|
||||
# POST
|
||||
form = ExamCollectionBulkAddGroupsForm(request.POST, user=request.user)
|
||||
if not form.is_valid():
|
||||
logger.debug(f"exam_collection_bulk_add_groups form invalid: {form.errors}")
|
||||
return render(request, "generic/partials/examcollection_bulk_add_groups_form.html", {"form": form, "collection": collection})
|
||||
|
||||
cid_groups = form.cleaned_data.get("cid_user_groups")
|
||||
user_groups = form.cleaned_data.get("user_user_groups")
|
||||
exam_types = form.cleaned_data.get("exam_types")
|
||||
# If 'all' is selected (or no selection), treat as None -> all exams
|
||||
if exam_types:
|
||||
if "all" in exam_types:
|
||||
exam_types = None
|
||||
else:
|
||||
exam_types = None
|
||||
|
||||
# Debug: log what was submitted so we can trace why groups may not be applied
|
||||
try:
|
||||
cid_pks = [g.pk for g in cid_groups] if cid_groups is not None else []
|
||||
except Exception:
|
||||
cid_pks = []
|
||||
try:
|
||||
user_pks = [g.pk for g in user_groups] if user_groups is not None else []
|
||||
except Exception:
|
||||
user_pks = []
|
||||
logger.debug(
|
||||
"ExamCollection bulk-add POST: collection=%s cid_groups=%s user_groups=%s exam_types=%s",
|
||||
collection.pk,
|
||||
cid_pks,
|
||||
user_pks,
|
||||
exam_types,
|
||||
)
|
||||
|
||||
total_user = 0
|
||||
total_cid = 0
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if user_groups:
|
||||
total_user = collection.bulk_add_user_user_groups(user_groups, exam_types=exam_types)
|
||||
if cid_groups:
|
||||
total_cid = collection.bulk_add_cid_user_groups(cid_groups, exam_types=exam_types)
|
||||
except Exception as e:
|
||||
# render an error fragment
|
||||
return render(request, "generic/partials/examcollection_bulk_add_groups_result.html", {"error": str(e), "collection": collection})
|
||||
|
||||
return render(request, "generic/partials/examcollection_bulk_add_groups_result.html", {"total_user": total_user, "total_cid": total_cid, "collection": collection})
|
||||
|
||||
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
def examination_detail(request, pk):
|
||||
@@ -1117,6 +1185,58 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
|
||||
return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
|
||||
|
||||
@method_decorator(login_required)
|
||||
def exam_bulk_archive(self, request):
|
||||
"""HTMX endpoint to bulk-archive or unarchive selected exams.
|
||||
|
||||
Expects POST with 'exam_ids' (multiple) and 'archive' ('true' or 'false').
|
||||
Returns the updated exam list partial.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponse(status=405)
|
||||
|
||||
exam_ids = request.POST.getlist("exam_ids")
|
||||
archive_val = request.POST.get("archive", "true")
|
||||
set_archive = True if archive_val == "true" else False
|
||||
|
||||
if (
|
||||
request.user.is_superuser
|
||||
or (
|
||||
self.app_name == "rapids"
|
||||
and request.user.groups.filter(name="rapid_checker").exists()
|
||||
)
|
||||
or (
|
||||
self.app_name == "anatomy"
|
||||
and request.user.groups.filter(name="anatomy_checker").exists()
|
||||
)
|
||||
or (
|
||||
self.app_name == "longs"
|
||||
and request.user.groups.filter(name="long_checker").exists()
|
||||
)
|
||||
):
|
||||
exams_qs = self.Exam.objects.all()
|
||||
filter = self.ExtraExamFilter(request.GET, queryset=exams_qs)
|
||||
else:
|
||||
exams_qs = self.Exam.objects.filter(author__id=request.user.id) | self.Exam.objects.filter(open_access=True)
|
||||
filter = self.BasicExamFilter(request.GET, queryset=exams_qs)
|
||||
|
||||
for eid in exam_ids:
|
||||
try:
|
||||
exam = self.Exam.objects.get(pk=eid)
|
||||
except self.Exam.DoesNotExist:
|
||||
continue
|
||||
|
||||
if not self.check_user_edit_access(request.user, exam_id=exam.pk):
|
||||
continue
|
||||
|
||||
try:
|
||||
exam.archive = set_archive
|
||||
exam.save()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
|
||||
|
||||
@method_decorator(login_required)
|
||||
def exam_toggle_results_published(self, request, pk):
|
||||
if request.method == "POST":
|
||||
@@ -5290,21 +5410,70 @@ class ExamCollectionList(ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Annotate exam counts per collection and prefetch authors to avoid N+1 queries in templates."""
|
||||
# allow toggling whether archived collections are included via ?show_archived=1
|
||||
show_archived = self.request.GET.get("show_archived")
|
||||
|
||||
base_qs = ExamCollection.objects.all()
|
||||
if not show_archived:
|
||||
base_qs = base_qs.filter(archive=False)
|
||||
|
||||
# Build annotation kwargs dynamically so counts respect the show_archived toggle.
|
||||
# Count exams regardless of exam_mode; include archived depending on the toggle.
|
||||
if show_archived:
|
||||
# include archived exams in counts (count all exams for each app)
|
||||
counts = {
|
||||
"anatomy_count": Count("anatomy_exams", distinct=True),
|
||||
"longs_count": Count("longs_exams", distinct=True),
|
||||
"physics_count": Count("physics_exams", distinct=True),
|
||||
"rapids_count": Count("rapids_exams", distinct=True),
|
||||
"sbas_count": Count("sbas_exams", distinct=True),
|
||||
"shorts_count": Count("shorts_exams", distinct=True),
|
||||
}
|
||||
else:
|
||||
# only count non-archived exams
|
||||
counts = {
|
||||
"anatomy_count": Count(
|
||||
"anatomy_exams",
|
||||
filter=Q(anatomy_exams__archive=False),
|
||||
distinct=True,
|
||||
),
|
||||
"longs_count": Count(
|
||||
"longs_exams",
|
||||
filter=Q(longs_exams__archive=False),
|
||||
distinct=True,
|
||||
),
|
||||
"physics_count": Count(
|
||||
"physics_exams",
|
||||
filter=Q(physics_exams__archive=False),
|
||||
distinct=True,
|
||||
),
|
||||
"rapids_count": Count(
|
||||
"rapids_exams",
|
||||
filter=Q(rapids_exams__archive=False),
|
||||
distinct=True,
|
||||
),
|
||||
"sbas_count": Count(
|
||||
"sbas_exams",
|
||||
filter=Q(sbas_exams__archive=False),
|
||||
distinct=True,
|
||||
),
|
||||
"shorts_count": Count(
|
||||
"shorts_exams",
|
||||
filter=Q(shorts_exams__archive=False),
|
||||
distinct=True,
|
||||
),
|
||||
}
|
||||
|
||||
qs = (
|
||||
ExamCollection.objects.all()
|
||||
.annotate(
|
||||
anatomy_count=Count("anatomy_exams", filter=Q(anatomy_exams__archive=False)),
|
||||
longs_count=Count("longs_exams", filter=Q(longs_exams__archive=False)),
|
||||
physics_count=Count("physics_exams", filter=Q(physics_exams__archive=False)),
|
||||
rapids_count=Count("rapids_exams", filter=Q(rapids_exams__archive=False)),
|
||||
sbas_count=Count("sbas_exams", filter=Q(sbas_exams__archive=False)),
|
||||
)
|
||||
base_qs
|
||||
.annotate(**counts)
|
||||
.annotate(
|
||||
total_count=F("anatomy_count")
|
||||
+ F("longs_count")
|
||||
+ F("physics_count")
|
||||
+ F("rapids_count")
|
||||
+ F("sbas_count")
|
||||
+ F("shorts_count")
|
||||
)
|
||||
.prefetch_related("author")
|
||||
.order_by("name")
|
||||
@@ -5762,6 +5931,120 @@ def user_search(request):
|
||||
return render(request, "generic/partials/user_search_results.html", {"results": results, "row": row, "q": q})
|
||||
|
||||
|
||||
@login_required
|
||||
def user_search_widget(request):
|
||||
"""HTMX/JS endpoint to search users for generic selectors.
|
||||
|
||||
Accepts GET params:
|
||||
- q: query string
|
||||
- field: the form field name to return with the results (so the
|
||||
template can call addUserToField(field, id, text)).
|
||||
|
||||
Returns a small HTML fragment listing up to 10 matching users. Each
|
||||
result will call `addUserToField(field, id, text)` when clicked.
|
||||
"""
|
||||
import re
|
||||
|
||||
q_raw = (request.GET.get("q") or "").strip()
|
||||
field = request.GET.get("field") or request.GET.get("row")
|
||||
if not q_raw:
|
||||
return HttpResponse("")
|
||||
|
||||
# Support wildcard '*' (return many results) and field-scoped queries like 'name:smith' or 'email:foo'
|
||||
q = q_raw
|
||||
|
||||
# If user explicitly asks for wildcard, return an unfiltered queryset (limited)
|
||||
if q in ("*", "%"):
|
||||
users_qs = User.objects.all()
|
||||
else:
|
||||
# field-specific search syntax: field:term
|
||||
m = re.match(r"^(?P<field>\w+):(?P<term>.+)$", q)
|
||||
if m:
|
||||
f = m.group("field").lower()
|
||||
term = m.group("term").strip()
|
||||
if term in ("*", "%"):
|
||||
users_qs = User.objects.all()
|
||||
else:
|
||||
if f in ("name", "fullname", "full_name"):
|
||||
users_qs = User.objects.filter(
|
||||
Q(first_name__icontains=term) | Q(last_name__icontains=term)
|
||||
)
|
||||
elif f in ("first", "first_name"):
|
||||
users_qs = User.objects.filter(first_name__icontains=term)
|
||||
elif f in ("last", "last_name"):
|
||||
users_qs = User.objects.filter(last_name__icontains=term)
|
||||
elif f == "email":
|
||||
users_qs = User.objects.filter(email__icontains=term)
|
||||
elif f == "username":
|
||||
users_qs = User.objects.filter(username__icontains=term)
|
||||
elif f in ("id", "pk"):
|
||||
try:
|
||||
users_qs = User.objects.filter(pk=int(term))
|
||||
except Exception:
|
||||
users_qs = User.objects.none()
|
||||
elif f == "grade":
|
||||
# allow grade name or id
|
||||
try:
|
||||
gid = int(term)
|
||||
users_qs = User.objects.filter(userprofile__grade_id=gid)
|
||||
except Exception:
|
||||
users_qs = User.objects.filter(userprofile__grade__name__icontains=term)
|
||||
else:
|
||||
# unknown field: fallback to general search
|
||||
users_qs = User.objects.filter(
|
||||
Q(first_name__icontains=term)
|
||||
| Q(last_name__icontains=term)
|
||||
| Q(email__icontains=term)
|
||||
| Q(username__icontains=term)
|
||||
)
|
||||
else:
|
||||
# default substring search (icontains)
|
||||
users_qs = User.objects.filter(
|
||||
Q(first_name__icontains=q)
|
||||
| Q(last_name__icontains=q)
|
||||
| Q(email__icontains=q)
|
||||
| Q(username__icontains=q)
|
||||
)
|
||||
|
||||
# optional grade filter (UserProfile.grade FK) still applies and will narrow results
|
||||
grade = request.GET.get("grade")
|
||||
if grade:
|
||||
try:
|
||||
users_qs = users_qs.filter(userprofile__grade_id=int(grade))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Limit results to avoid returning too many rows; wildcard returns up to 50
|
||||
limit = 50 if q in ("*", "%") or q_raw in ("*", "%") else 10
|
||||
users_qs = users_qs.order_by("last_name", "first_name")[:limit]
|
||||
|
||||
results = []
|
||||
for u in users_qs:
|
||||
grade = ""
|
||||
try:
|
||||
up = getattr(u, "userprofile", None)
|
||||
if up and getattr(up, "grade", None):
|
||||
# grade may be a FK object or a simple string depending on model
|
||||
g = up.grade
|
||||
grade = getattr(g, "name", str(g)) if g is not None else ""
|
||||
except Exception:
|
||||
grade = ""
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": u.pk,
|
||||
"text": f"{u.get_full_name() or u.username} - {u.email or ''}",
|
||||
"grade": grade,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"generic/partials/user_search_widget_results.html",
|
||||
{"results": results, "field": field, "q": q_raw},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def supervisor_search(request):
|
||||
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
||||
|
||||
+7
-6
@@ -14,19 +14,20 @@ router = Router()
|
||||
|
||||
|
||||
class QuestionSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Question
|
||||
|
||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||
model_fields = "__all__"
|
||||
# Use `fields` per newer ninja ModelSchema API
|
||||
#fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||
fields = "__all__"
|
||||
|
||||
#model_exclude = ["answers"]
|
||||
#exclude = ["answers"]
|
||||
|
||||
class ExamSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Exam
|
||||
|
||||
model_fields = ["id", "name", "active", "publish_results"]
|
||||
fields = ["id", "name", "active", "publish_results"]
|
||||
|
||||
@router.get('/')
|
||||
def list_questions(request):
|
||||
|
||||
@@ -2,12 +2,27 @@
|
||||
|
||||
{% block navigation %}
|
||||
{{block.super}}
|
||||
<br/>
|
||||
Exams: {{exam}}->
|
||||
<a href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a> /
|
||||
<a href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a> /
|
||||
<a href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
||||
<a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
||||
<a href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
<a href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a> /
|
||||
<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>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
|
||||
</span>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="examNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||
</ul>
|
||||
|
||||
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
+6
-6
@@ -15,19 +15,19 @@ router = Router()
|
||||
|
||||
|
||||
class QuestionSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Question
|
||||
|
||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||
model_fields = "__all__"
|
||||
# Use `fields` per newer ninja ModelSchema API
|
||||
fields = "__all__"
|
||||
|
||||
#model_exclude = ["answers"]
|
||||
#exclude = ["answers"]
|
||||
|
||||
class ExamSchema(ModelSchema):
|
||||
class Config:
|
||||
class Meta:
|
||||
model = Exam
|
||||
|
||||
model_fields = ["id", "name", "active", "publish_results"]
|
||||
fields = ["id", "name", "active", "publish_results"]
|
||||
|
||||
@router.get('/')
|
||||
def list_questions(request):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user