Compare commits
70
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 |
@@ -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
|
*.log
|
||||||
|
|
||||||
temp/
|
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()
|
router = Router()
|
||||||
|
|
||||||
class CidUserSchema(ModelSchema):
|
class CidUserSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = CidUser
|
model = CidUser
|
||||||
|
|
||||||
model_fields = ["cid", "name", "email"]
|
fields = ["cid", "name", "email"]
|
||||||
|
|
||||||
class UserUserSchema(ModelSchema):
|
class UserUserSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = get_user_model()
|
model = get_user_model()
|
||||||
model_fields = ["id", "email"]
|
fields = ["id", "email"]
|
||||||
|
|
||||||
class CidUserExamSchema(ModelSchema):
|
class CidUserExamSchema(ModelSchema):
|
||||||
cid_user : CidUserSchema | None
|
cid_user : CidUserSchema | None
|
||||||
user_user : UserUserSchema | None
|
user_user : UserUserSchema | None
|
||||||
class Config:
|
class Meta:
|
||||||
model = CidUserExam
|
model = CidUserExam
|
||||||
model_fields = ["id"]
|
fields = ["id"]
|
||||||
|
|
||||||
class ExamStatusSchema(ModelSchema):
|
class ExamStatusSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = ExamUserStatus
|
model = ExamUserStatus
|
||||||
model_fields = ["id", "datetime", "status", "extra"]
|
fields = ["id", "datetime", "status", "extra"]
|
||||||
|
|
||||||
class ExamUserStatusSchema(ModelSchema):
|
class ExamUserStatusSchema(ModelSchema):
|
||||||
|
|
||||||
#cid_user : CidUserSchema | None
|
#cid_user : CidUserSchema | None
|
||||||
#user_user : UserUserSchema | None
|
#user_user : UserUserSchema | None
|
||||||
cid_user_exam : CidUserExamSchema | None
|
cid_user_exam : CidUserExamSchema | None
|
||||||
class Config:
|
class Meta:
|
||||||
model = ExamUserStatus
|
model = ExamUserStatus
|
||||||
model_fields = ["id", "datetime", "status", "extra"]
|
fields = ["id", "datetime", "status", "extra"]
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
fields = "__all__"
|
||||||
model_fields = "__all__"
|
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
@@ -2,16 +2,30 @@
|
|||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
{{block.super}}
|
{{block.super}}
|
||||||
<br/>
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||||
Exams: {{exam}}->
|
<div class="container-fluid px-3">
|
||||||
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
|
<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 %}
|
{% if exam.exam_mode %}
|
||||||
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
|
||||||
<a href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
|
||||||
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||||
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||||
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a>
|
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||||
{% 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 %}
|
</ul>
|
||||||
|
|
||||||
|
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
|
<span class="navbar-toggler-icon"></span>
|
||||||
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
|
</button>
|
||||||
<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>
|
<div class="collapse navbar-collapse" id="questionNav">
|
||||||
<a href="#"
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
|
{% if exam %}
|
||||||
Add Note</a>
|
<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 %}
|
{% if request.user.is_superuser %}
|
||||||
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}"
|
<li><hr class="dropdown-divider"></li>
|
||||||
title="Edit the Question using the admin interface">Admin Edit</a>
|
<li>
|
||||||
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
|
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
|
||||||
{% endif %}
|
<i class="bi bi-tools me-2"></i> Admin Edit
|
||||||
{% if exam %}
|
</a>
|
||||||
<div>
|
</li>
|
||||||
|
<li>
|
||||||
{% if previous > -1 %}
|
<a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
|
||||||
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
|
<i class="bi bi-people me-2"></i> User answers
|
||||||
{% endif %}
|
</a>
|
||||||
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
|
</li>
|
||||||
{% if next %}
|
|
||||||
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
</nav>
|
||||||
+4
-4
@@ -32,9 +32,9 @@ router = Router()
|
|||||||
class SeriesSchema(ModelSchema):
|
class SeriesSchema(ModelSchema):
|
||||||
case_id: List[int] = []
|
case_id: List[int] = []
|
||||||
|
|
||||||
class Config:
|
class Meta:
|
||||||
model = Series
|
model = Series
|
||||||
model_fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"modality",
|
"modality",
|
||||||
"examination",
|
"examination",
|
||||||
@@ -48,9 +48,9 @@ class SeriesSchema(ModelSchema):
|
|||||||
|
|
||||||
|
|
||||||
class CaseSchema(ModelSchema):
|
class CaseSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Case
|
model = Case
|
||||||
model_fields = ["id", "title"]
|
fields = ["id", "title"]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload_dicom", auth=django_auth)
|
@router.post("/upload_dicom", auth=django_auth)
|
||||||
|
|||||||
+60
-5
@@ -164,13 +164,16 @@ class SeriesFilter(django_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class ConditionFilter(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:
|
class Meta:
|
||||||
model = Condition
|
model = Condition
|
||||||
fields = {
|
fields = {
|
||||||
"name": ["icontains"],
|
"name": ["icontains"],
|
||||||
"primary": ["exact"],
|
|
||||||
"synonym": ["exact"],
|
|
||||||
#"parent": ["exact"],
|
|
||||||
"subspecialty": ["exact"],
|
"subspecialty": ["exact"],
|
||||||
"rcr_curriculum": ["exact"],
|
"rcr_curriculum": ["exact"],
|
||||||
}
|
}
|
||||||
@@ -189,11 +192,32 @@ class ConditionFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
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):
|
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:
|
class Meta:
|
||||||
model = Finding
|
model = Finding
|
||||||
fields = ("name", "primary", "synonym")
|
fields = {
|
||||||
|
"name": ["icontains"],
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -209,11 +233,29 @@ class FindingFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
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):
|
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:
|
class Meta:
|
||||||
model = Structure
|
model = Structure
|
||||||
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
|
fields = {"name": ["icontains"]}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -229,6 +271,19 @@ class StructureFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
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 PresentationFilter(django_filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
+91
-31
@@ -40,6 +40,8 @@ from atlas.models import (
|
|||||||
Condition,
|
Condition,
|
||||||
Structure,
|
Structure,
|
||||||
Subspecialty,
|
Subspecialty,
|
||||||
|
Presentation,
|
||||||
|
PathologicalProcess,
|
||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
CaseDisplaySet,
|
CaseDisplaySet,
|
||||||
@@ -54,7 +56,8 @@ from generic.models import Examination # , Sign
|
|||||||
|
|
||||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
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 crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||||
|
|
||||||
from tinymce.widgets import TinyMCE
|
from tinymce.widgets import TinyMCE
|
||||||
@@ -116,11 +119,10 @@ class ConditionForm(ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Condition
|
model = Condition
|
||||||
exclude = ["rcr_curriculum"]
|
exclude = ["rcr_curriculum"]
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
"synonym": autocomplete.ModelSelect2Multiple(
|
# Use an AJAX/autocomplete single-select for canonical so users
|
||||||
url="atlas:condition-autocomplete"
|
# can search for existing canonical Conditions by name.
|
||||||
),
|
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
|
||||||
"parent": autocomplete.ModelSelect2Multiple(
|
"parent": autocomplete.ModelSelect2Multiple(
|
||||||
url="atlas:condition-autocomplete"
|
url="atlas:condition-autocomplete"
|
||||||
),
|
),
|
||||||
@@ -129,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 CaseCollectionForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -313,9 +341,10 @@ class FindingForm(ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Finding
|
model = Finding
|
||||||
exclude = []
|
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 = {
|
widgets = {
|
||||||
"synonym": autocomplete.ModelSelect2Multiple(
|
"canonical": autocomplete.ModelSelect2(
|
||||||
url="atlas:finding-autocomplete"
|
url="atlas:finding-autocomplete"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -325,14 +354,31 @@ class StructureForm(ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Structure
|
model = Structure
|
||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
"synonym": autocomplete.ModelSelect2Multiple(
|
"canonical": autocomplete.ModelSelect2(
|
||||||
url="atlas:structure-autocomplete"
|
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 SeriesFindingForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SeriesFinding
|
model = SeriesFinding
|
||||||
@@ -831,7 +877,7 @@ class BaseReportAnswerForm(ModelForm):
|
|||||||
# "answer": "Write your answer in here."
|
# "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)
|
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
||||||
self.fields["answer"].required = False
|
self.fields["answer"].required = False
|
||||||
|
|
||||||
@@ -847,12 +893,12 @@ class BaseQuestionAnswerForm(ModelForm):
|
|||||||
# "answer": "Write your answer in here."
|
# "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)
|
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
||||||
# self.fields["json_answer"].schema = case_detail.question_schema
|
# self.fields["json_answer"].schema = casedetail.question_schema
|
||||||
if case_detail.question_schema is not None:
|
if casedetail.question_schema is not None:
|
||||||
self.fields["json_answer"] = JSONSchemaField(
|
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 = ""
|
self.fields["json_answer"].label = ""
|
||||||
|
|
||||||
@@ -1013,6 +1059,37 @@ class ConditionAutocomplete(ModelAutocomplete):
|
|||||||
model = Condition
|
model = Condition
|
||||||
search_attrs = ["name"]
|
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):
|
class ConditionAutocompleteForm(Form):
|
||||||
condition = ModelChoiceField(
|
condition = ModelChoiceField(
|
||||||
@@ -1153,23 +1230,6 @@ class PriorCaseForm(Form):
|
|||||||
|
|
||||||
prior_visibility = CharField(max_length=2, required=True)
|
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):
|
class CaseSeriesForm(forms.ModelForm):
|
||||||
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
|
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
+183
-32
@@ -110,36 +110,121 @@ class SynMixin(object):
|
|||||||
# abstract = True
|
# abstract = True
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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
|
return self.name
|
||||||
else:
|
return f"{self.name} [syn]"
|
||||||
|
except Exception:
|
||||||
|
# defensive fallback
|
||||||
return f"{self.name} [syn]"
|
return f"{self.name} [syn]"
|
||||||
|
|
||||||
def get_old_str(self) -> str:
|
# No canonical support: assume primary
|
||||||
if self.primary:
|
|
||||||
if self.synonym.count() == 0:
|
|
||||||
return self.name
|
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:
|
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})"
|
return f"{self.name} ({synonyms})"
|
||||||
else:
|
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]"
|
return f"{self.name} [syn]"
|
||||||
|
|
||||||
def get_synonym(self):
|
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]"
|
return "[Primary]"
|
||||||
else:
|
except Exception:
|
||||||
s = self.synonym.filter(primary=True).values_list("name", flat=True)
|
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)
|
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):
|
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]"
|
return "[Primary]"
|
||||||
else:
|
except Exception:
|
||||||
syns = self.synonym.filter(primary=True)
|
pass
|
||||||
return ", ".join(
|
|
||||||
[f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
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):
|
def get_link(self):
|
||||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||||
@@ -147,21 +232,49 @@ class SynMixin(object):
|
|||||||
|
|
||||||
class Finding(SynMixin, models.Model):
|
class Finding(SynMixin, models.Model):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
synonym = models.ManyToManyField("self", blank=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 = models.BooleanField(default="True")
|
# `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):
|
def get_absolute_url(self):
|
||||||
return reverse("atlas:finding_detail", kwargs={"pk": self.pk})
|
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):
|
class Condition(SynMixin, models.Model):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
|
# New canonical/alias field (Option B): if set, this Condition is an alias
|
||||||
synonym = models.ManyToManyField(
|
# and points to the canonical/master Condition.
|
||||||
|
canonical = models.ForeignKey(
|
||||||
"self",
|
"self",
|
||||||
|
null=True,
|
||||||
blank=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(
|
parent = models.ManyToManyField(
|
||||||
"self",
|
"self",
|
||||||
@@ -172,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'.",
|
help_text="Use if the condition could be considered a subset of another, e.g. 'Alzheimer disease' and 'Dementia'.",
|
||||||
)
|
)
|
||||||
|
|
||||||
primary = models.BooleanField(
|
# Legacy fields `synonym` (M2M) and `primary` (boolean) have been removed.
|
||||||
default="True",
|
# Canonical/aliases are represented by the `canonical` ForeignKey and
|
||||||
help_text="Sets if this should be the canonical item, all other synonyms will then redirect to this by default.",
|
# the reverse `aliases` related_name. Use `get_synonyms()` /
|
||||||
)
|
# `canonical_condition` to access canonical groups.
|
||||||
|
|
||||||
subspecialty = models.ManyToManyField(
|
subspecialty = models.ManyToManyField(
|
||||||
"subspecialty",
|
"subspecialty",
|
||||||
@@ -198,6 +311,23 @@ class Condition(SynMixin, models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("atlas:condition_detail", kwargs={"pk": self.pk})
|
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):
|
def get_children(self):
|
||||||
return self.child.all()
|
return self.child.all()
|
||||||
|
|
||||||
@@ -276,14 +406,28 @@ class Differential(models.Model):
|
|||||||
|
|
||||||
class Structure(SynMixin, models.Model):
|
class Structure(SynMixin, models.Model):
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
|
# Migrate to canonical/aliases model like Condition and Finding
|
||||||
synonym = models.ManyToManyField("self", blank=True)
|
canonical = models.ForeignKey(
|
||||||
|
"self",
|
||||||
primary = models.BooleanField(default="True")
|
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):
|
def get_absolute_url(self):
|
||||||
return reverse("atlas:structure_detail", kwargs={"pk": self.pk})
|
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
|
@reversion.register
|
||||||
class Case(models.Model, AuthorMixin, QuestionMixin):
|
class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||||
@@ -1443,6 +1587,13 @@ class CaseDetail(models.Model):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.case} -> {self.collection}"
|
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):
|
def get_question_schema(self):
|
||||||
return json.dumps(self.question_schema)
|
return json.dumps(self.question_schema)
|
||||||
|
|
||||||
@@ -1545,7 +1696,7 @@ class CaseDetail(models.Model):
|
|||||||
return mark_safe(html)
|
return mark_safe(html)
|
||||||
|
|
||||||
class CasePrior(models.Model):
|
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")
|
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")
|
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
|
||||||
@@ -1564,10 +1715,10 @@ class CasePrior(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("case_detail", "prior_case")
|
unique_together = ("casedetail", "prior_case")
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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):
|
class BaseReportAnswer(models.Model):
|
||||||
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||||
|
|||||||
+3
-3
@@ -276,7 +276,7 @@ class ConditionTable(SelectionTable):
|
|||||||
class Meta(SelectionTable.Meta):
|
class Meta(SelectionTable.Meta):
|
||||||
model = Condition
|
model = Condition
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ class FindingTable(SelectionTable):
|
|||||||
class Meta(SelectionTable.Meta):
|
class Meta(SelectionTable.Meta):
|
||||||
model = Finding
|
model = Finding
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ class StructureTable(SelectionTable):
|
|||||||
class Meta(SelectionTable.Meta):
|
class Meta(SelectionTable.Meta):
|
||||||
model = Structure
|
model = Structure
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
<span id="add-case-form">
|
<div id="add-case-form" class="p-2 border rounded">
|
||||||
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.
|
<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>
|
||||||
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
|
|
||||||
{% csrf_token %}
|
{% include 'atlas/partials/case_search_widget.html' %}
|
||||||
<label for="case">Select Case to add:</label>
|
|
||||||
<select name="case" id="case">
|
<div class="mt-2">
|
||||||
{% for case in cases %}
|
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
|
||||||
<option value="{{ case.id }}">{{ case.title }}</option>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</select>
|
|
||||||
<button type="submit">Add</button>
|
|
||||||
<button _="on click remove #add-case-form">Cancel</button>
|
|
||||||
</form>
|
|
||||||
</span
|
|
||||||
@@ -66,8 +66,32 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
|
<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>
|
||||||
<li class="nav-item dropdown">
|
<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" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
||||||
|
|||||||
@@ -464,7 +464,7 @@
|
|||||||
|
|
||||||
|
|
||||||
{% if casedetail %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div>
|
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_details" %}
|
||||||
{% 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>
|
|
||||||
|
|
||||||
<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>
|
<p>This page allows you to configure how the case is displayed as part of the collection.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -3,33 +3,34 @@
|
|||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Setup default display for case</h2>
|
{% 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;"
|
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px; max-width: 1600px;"
|
||||||
data-auto-cache-stack="true"
|
data-auto-cache-stack="true"
|
||||||
{% if case_detail.default_viewerstate %}
|
{% if casedetail.default_viewerstate %}
|
||||||
data-viewerstate={{ case_detail.default_viewerstate }}
|
data-viewerstate={{ casedetail.default_viewerstate }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
|
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
|
||||||
Save Viewer State
|
Save Viewer State
|
||||||
</button>
|
</button>
|
||||||
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
|
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
|
||||||
Reset to Default
|
Reset to Default
|
||||||
</button>
|
</button>
|
||||||
<div id="viewerstate-save-response" class="mt-2"></div>
|
<div id="viewerstate-save-response" class="mt-2"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('save-viewerstate').addEventListener('click', function() {
|
document.getElementById('save-viewerstate').addEventListener('click', function() {
|
||||||
let viewerState = window.exportViewerState_root();
|
let viewerState = window.exportViewerState_root();
|
||||||
if (!viewerState) {
|
if (!viewerState) {
|
||||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||||
return;
|
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",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -44,9 +45,9 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
|
|||||||
.catch(error => {
|
.catch(error => {
|
||||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
|
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById('reset-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",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -62,7 +63,7 @@ document.getElementById('reset-viewerstate').addEventListener('click', function(
|
|||||||
.catch(error => {
|
.catch(error => {
|
||||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
|
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -4,106 +4,28 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div>
|
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_priors" %}
|
||||||
|
|
||||||
{% if previous %}
|
<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>
|
||||||
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
|
|
||||||
{% endif %}
|
<div class="card mb-4">
|
||||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
<div class="card-body">
|
||||||
{% if next %}
|
<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>
|
||||||
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
|
<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>
|
||||||
{% endif %}
|
</div>
|
||||||
</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>
|
<div class="row g-3">
|
||||||
|
|
||||||
<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>
|
|
||||||
{% for case, added, relation, visibility in available_priors %}
|
{% for case, added, relation, visibility in available_priors %}
|
||||||
|
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
|
||||||
<li class='
|
{% empty %}
|
||||||
{% if added %}
|
<div class="col-12">
|
||||||
added
|
<div class="alert alert-info">No available prior cases found for the case.</div>
|
||||||
{% else %}
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,11 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div>
|
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
|
||||||
|
|
||||||
{% if previous %}
|
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
|
||||||
<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>
|
|
||||||
|
|
||||||
|
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
|
||||||
<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 %}
|
|
||||||
|
|
||||||
|
|
||||||
<div class="alert alert-danger" role="alert">
|
<div class="alert alert-danger" role="alert">
|
||||||
@@ -42,7 +32,7 @@
|
|||||||
|
|
||||||
Import schema from:
|
Import schema from:
|
||||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
<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"
|
hx-target=".offcanvas-body"
|
||||||
>Collection questions</button>
|
>Collection questions</button>
|
||||||
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
|
<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
|
Question Block Title
|
||||||
</summary>
|
</summary>
|
||||||
<p>Title of the question block.</p>
|
<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>
|
||||||
<details><summary>Modify Questions</summary>
|
<details><summary>Modify Questions</summary>
|
||||||
<div id="questions-container" style="margin-top: 20px;">
|
<div id="questions-container" style="margin-top: 20px;">
|
||||||
@@ -123,7 +113,7 @@
|
|||||||
</form> {% endcomment %}
|
</form> {% endcomment %}
|
||||||
|
|
||||||
|
|
||||||
{% if case_detail.question_schema %}
|
{% if casedetail.question_schema %}
|
||||||
<h2>Question display</h2>
|
<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.
|
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 %}
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<span class="collection-name-blend">Collection: {{collection}}</span>
|
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
|
||||||
<h2>Case {{case_number|add:1}}
|
|
||||||
|
<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 %}
|
{% if show_title %}
|
||||||
: {{case.title}}
|
: {{case.title}}
|
||||||
@@ -11,19 +14,36 @@
|
|||||||
|
|
||||||
|
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
<span class="stamp-white">REVIEW</span>
|
<span class="badge bg-warning text-dark ms-2 small">REVIEW</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
</h2>
|
</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 %}
|
{% 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.">
|
<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.">
|
||||||
<strong>Time remaining:</strong>
|
<div class="d-flex align-items-center gap-3">
|
||||||
<span id="question-timer" aria-live="polite"> </span>
|
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite"> </span></div>
|
||||||
<div id="question-timer-progress" style="display:inline-block; vertical-align: middle; width: 200px; margin-left:12px;">
|
<div style="flex:1; max-width:320px;">
|
||||||
<div id="question-timer-progress-outer" style="background:#e9ecef; border-radius:6px; height:10px; overflow:hidden;">
|
<div class="progress" style="height:10px;">
|
||||||
<div id="question-timer-progress-inner" style="width:100%; height:100%; background:var(--timer-color, #28a745);"></div>
|
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="timer-htmx-target" style="display:none;"></div>
|
<div id="timer-htmx-target" style="display:none;"></div>
|
||||||
@@ -38,12 +58,10 @@
|
|||||||
|
|
||||||
{% if not question_completed %}
|
{% if not question_completed %}
|
||||||
{% if resources %}
|
{% if resources %}
|
||||||
<h5>Resources</h4>
|
<h5 class="mt-3">Resources</h5>
|
||||||
<ul class="no-list-style">
|
<ul class="list-group list-group-flush mb-3">
|
||||||
{% for caseresource in resources %}
|
{% for caseresource in resources %}
|
||||||
<li>
|
<li class="list-group-item py-1 small">{{caseresource.resource.get_display}}</li>
|
||||||
{{caseresource.resource.get_display}}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -51,14 +69,21 @@
|
|||||||
|
|
||||||
|
|
||||||
{% if show_description and case.description %}
|
{% if show_description and case.description %}
|
||||||
<div>
|
<div class="mb-3">
|
||||||
Description: {{case.description}}
|
<h6 class="mb-1">Description</h6>
|
||||||
|
<p class="mb-0">{{case.description}}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if show_history %}
|
{% if show_history %}
|
||||||
<div>
|
<div class="card mb-3">
|
||||||
History: {{case_detail.get_history_pre|linebreaks}}
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -66,9 +91,9 @@
|
|||||||
<div>
|
<div>
|
||||||
|
|
||||||
{% if question_completed %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -78,9 +103,9 @@
|
|||||||
{% if collection.show_ohif_viewer_link %}
|
{% if collection.show_ohif_viewer_link %}
|
||||||
<div>
|
<div>
|
||||||
{% if question_completed %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -92,7 +117,7 @@
|
|||||||
<div id="main_viewer" class="dicom-viewer-root"
|
<div id="main_viewer" class="dicom-viewer-root"
|
||||||
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||||
data-auto-cache-stack="false"
|
data-auto-cache-stack="false"
|
||||||
data-named-stacks='{{case_detail.get_case_named_stacks}}'
|
data-named-stacks='{{casedetail.get_case_named_stacks}}'
|
||||||
></div>
|
></div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -131,28 +156,24 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if show_discussion and case.discussion %}
|
{% if show_discussion and case.discussion %}
|
||||||
<p>
|
<details class="card mb-3">
|
||||||
<details>
|
<summary class="card-header p-2" style="cursor: pointer;">
|
||||||
<summary>
|
<strong>Discussion</strong>
|
||||||
Discussion:
|
|
||||||
</summary>
|
</summary>
|
||||||
<div>
|
<div class="card-body p-2 small">
|
||||||
{{case.discussion|linebreaks}}
|
{{case.discussion|linebreaks}}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if show_report and case.report %}
|
{% if show_report and case.report %}
|
||||||
<p>
|
<details class="card mb-3">
|
||||||
<details>
|
<summary class="card-header p-2" style="cursor: pointer;">
|
||||||
<summary>
|
<strong>Report</strong>
|
||||||
Report:
|
|
||||||
</summary>
|
</summary>
|
||||||
<div>
|
<div class="card-body p-2 small">
|
||||||
{{case.report|linebreaks}}
|
{{case.report|linebreaks}}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
@@ -246,25 +267,28 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="mb-2">
|
||||||
{% if previous %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if next %}
|
{% 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" %}
|
{% elif collection.collection_type == "REP" %}
|
||||||
{% if not exam.publish_results %}
|
{% 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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if collection.self_review and not collection.feedback_once_collection_complete and not question_completed %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
<br />
|
<div class="mt-3">
|
||||||
<button type="submit" name="finish" class="save btn btn-default">Overview</button>
|
<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>
|
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<style>
|
<style>
|
||||||
label {
|
label {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h2>Case: {{ case.title }}</h2>
|
<h2>Case: {{ case.title }}</h2>
|
||||||
{{ case_detail.question_schema }}
|
{{ casedetail.question_schema }}
|
||||||
|
|
||||||
|
|
||||||
{{ case.question_answers }}
|
{{ case.question_answers }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -4,139 +4,154 @@
|
|||||||
|
|
||||||
{% partialdef casedetails-management-links %}
|
{% 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 %}
|
{% endpartialdef %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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' %}
|
{% include 'exam_notes.html' %}
|
||||||
|
|
||||||
<div>
|
<p class="mb-1">
|
||||||
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
|
<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#publish-results" %}
|
||||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||||
Collection Type: {{collection.get_collection_type_display}}<br />
|
</p>
|
||||||
Self review: {{collection.self_review}}<br />
|
|
||||||
Open access: {{collection.open_access}}<br />
|
<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 %}
|
{% if collection.prerequisites.exists %}
|
||||||
Prerequisite collections:
|
<div class="mt-3 mb-2">
|
||||||
<ul>
|
<strong>Prerequisites</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
{% for prereq in collection.prerequisites.all %}
|
{% for prereq in collection.prerequisites.all %}
|
||||||
<li>
|
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
|
||||||
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
|
|
||||||
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</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
|
<button
|
||||||
title="Sync users from all prerequisite collections into this collection"
|
title="Sync users from all prerequisite collections into this collection"
|
||||||
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="btn btn-sm btn-secondary"
|
class="btn btn-sm btn-secondary"
|
||||||
>Sync prerequisite users</button>
|
>Sync prerequisite users</button>
|
||||||
</p>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h3 class="mb-3">Cases</h3>
|
||||||
|
|
||||||
{% if collection.collection_type == "VIV" %}
|
<ol id="full-question-list" class="sortable list-group list-group-numbered">
|
||||||
<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">
|
|
||||||
{% for casedetail in casesdetails %}
|
{% for casedetail in casesdetails %}
|
||||||
<li data-question_pk={{casedetail.case.pk}}>
|
{% include 'atlas/partials/case_list_item.html' %}
|
||||||
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% if can_edit %}
|
{% 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>
|
<div class="row mt-4">
|
||||||
<p><button id='button-add-case'
|
<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'
|
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
|
Add case
|
||||||
</button>
|
</button>
|
||||||
<div id='case-list'></div>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
<div id='case-list'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
<details>
|
<details>
|
||||||
<summary>Answer management</summary>
|
<summary>Answer management</summary>
|
||||||
|
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger mt-3">
|
||||||
<p>Manage answers for this collection. </p>
|
<p class="mb-1">Manage answers for this collection.</p>
|
||||||
|
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
|
||||||
Please note these are permanant and cannot be undone.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
<button title="This will clear all user answers and attempts"
|
<button title="This will clear all user answers and attempts"
|
||||||
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
|
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>
|
>Reset all answers</button>
|
||||||
|
|
||||||
<button title="This will specific user answers and attempts"
|
<button title="This will specific user answers and attempts"
|
||||||
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-target="#user-list"
|
hx-target="#user-list"
|
||||||
>Reset user answers</button>
|
class="btn btn-warning btn-sm">
|
||||||
<div id="user-list"></div>
|
Reset user answers</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="user-list" class="mt-3"></div>
|
||||||
</details>
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include 'exam_overview_js.html' %}
|
{% include 'exam_overview_js.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
<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_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: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>
|
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="navbar-nav ms-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
{% if request.user.is_superuser %}
|
{% if request.user.is_superuser %}
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
|
<li><a class="dropdown-item" href="{% url '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 %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="vstack gap-3">
|
<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">
|
<div class="border border-dark">
|
||||||
<h4>Case: {{case_detail.case.title}}</h4>
|
<h4>Case: {{casedetail.case.title}}</h4>
|
||||||
Answers
|
Answers
|
||||||
<ul>
|
<ul>
|
||||||
{% for answer in answers %}
|
{% for answer in answers %}
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
{% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
|
{% 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 %}
|
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||||
<ol>
|
<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 %}>
|
<li id="case-detail-{{casedetail.pk}}"{% if casedetail.question_schema %} class="cases"{% endif %}>
|
||||||
<h2>Case: {{ case_detail.case.title }}</h2>
|
<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>
|
<button class="btn btn-sm btn-secondary use-layout">Use schema</button>
|
||||||
<details><summary>Schema:</summary>
|
<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>
|
</details>
|
||||||
<div class="form"></div>
|
<div class="form"></div>
|
||||||
{% else %}
|
{% 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>
|
</div>
|
||||||
{# Hidden per-case question snippet to inject when case is loaded #}
|
{# Hidden per-case question snippet to inject when case is loaded #}
|
||||||
<div class="question-block-snippet" style="display:none;">
|
<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>
|
</div>
|
||||||
{% if case.display_sets.all %}
|
{% if case.display_sets.all %}
|
||||||
<details class="displayset-detail"><summary>Display Sets:</summary>
|
<details class="displayset-detail"><summary>Display Sets:</summary>
|
||||||
@@ -212,14 +212,14 @@
|
|||||||
let c = this.closest(".case-item");
|
let c = this.closest(".case-item");
|
||||||
console.log('clicked local', c)
|
console.log('clicked local', c)
|
||||||
console.log('open local', c.dataset.case)
|
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 viewerstate = JSON.parse(c.dataset.viewerstate) || {};
|
||||||
$('#loading-case').show()
|
$('#loading-case').show()
|
||||||
$("#open-viewer-local").addClass("flash-button");
|
$("#open-viewer-local").addClass("flash-button");
|
||||||
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
|
$('#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-history").html("<span class='title'>History:</span> "+casedetails['history']);
|
||||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
|
||||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
|
||||||
|
|
||||||
bc.postMessage({"type": "open",
|
bc.postMessage({"type": "open",
|
||||||
"case" : c.dataset.case, "images": c.dataset.images,
|
"case" : c.dataset.case, "images": c.dataset.images,
|
||||||
@@ -239,15 +239,15 @@
|
|||||||
let c = this;
|
let c = this;
|
||||||
console.log('clicked local', c)
|
console.log('clicked local', c)
|
||||||
console.log('open local', c.dataset.case)
|
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 viewerstate = JSON.parse(c.dataset.viewerstate) || {};
|
||||||
let annotations = JSON.parse(c.dataset.annotations) || {};
|
let annotations = JSON.parse(c.dataset.annotations) || {};
|
||||||
$('#loading-case').show()
|
$('#loading-case').show()
|
||||||
$("#open-viewer-local").addClass("flash-button");
|
$("#open-viewer-local").addClass("flash-button");
|
||||||
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
|
$('#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-history").html("<span class='title'>History:</span> "+casedetails['history']);
|
||||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
|
||||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
|
||||||
|
|
||||||
bc.postMessage({"type": "open",
|
bc.postMessage({"type": "open",
|
||||||
"case" : c.dataset.case, "images": c.dataset.images,
|
"case" : c.dataset.case, "images": c.dataset.images,
|
||||||
@@ -302,15 +302,15 @@
|
|||||||
|
|
||||||
let c = $(this).closest(".case-item");
|
let c = $(this).closest(".case-item");
|
||||||
|
|
||||||
case_details = c.data('casejson');
|
casedetails = c.data('casejson');
|
||||||
console.log(case_details);
|
console.log(casedetails);
|
||||||
|
|
||||||
$('#loading-case').show()
|
$('#loading-case').show()
|
||||||
$("#open-viewer").addClass("flash-button");
|
$("#open-viewer").addClass("flash-button");
|
||||||
$('#current-case-title').html("<span class='title'>Case:</span> "+c.data('title'));
|
$('#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-history").html("<span class='title'>History:</span> "+casedetails['history']);
|
||||||
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
|
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
|
||||||
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
|
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
|
||||||
|
|
||||||
console.log($(this).data('type'))
|
console.log($(this).data('type'))
|
||||||
if ($(this).data('type') == 'case') {
|
if ($(this).data('type') == 'case') {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<h5 class="mb-2">Details</h5>
|
<h5 class="mb-2">Details</h5>
|
||||||
<dl class="row">
|
<dl class="row">
|
||||||
<dt class="col-sm-4">Primary name</dt>
|
<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>
|
<dt class="col-sm-4">Subspecialty</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
@@ -31,11 +31,9 @@
|
|||||||
|
|
||||||
<dt class="col-sm-4">Synonyms</dt>
|
<dt class="col-sm-4">Synonyms</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{% for syn in condition.synonym.all %}
|
<div id="synonyms-container">
|
||||||
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
{% include 'atlas/partials/_synonyms_list.html' %}
|
||||||
{% empty %}
|
</div>
|
||||||
—
|
|
||||||
{% endfor %}
|
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4">Parent</dt>
|
<dt class="col-sm-4">Parent</dt>
|
||||||
|
|||||||
@@ -1,27 +1,20 @@
|
|||||||
{% extends "atlas/base.html" %}
|
{% extends "atlas/base.html" %}
|
||||||
<!-- {% load static from static %} -->
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block css %}{% endblock %}
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
{% endblock %}
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
{{ form.media }}
|
||||||
{{form.media}}
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- {{ form.media }} -->
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Add/Edit Condition</h2>
|
<h2>Add / Edit Condition</h2>
|
||||||
<p>Use this form to create or edit a condition.</p>
|
<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>
|
<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">
|
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{% crispy form %}
|
||||||
<table>
|
|
||||||
{{ form.as_table }}
|
|
||||||
</table>
|
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Name: {{finding.name}}</h3>
|
<h3>Name: {{finding.name}}</h3>
|
||||||
Primary name: {{finding.primary}}<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.synonym.all|join:", "}}<br />
|
Synonyms: {{ finding.get_synonym_link|safe }}<br />
|
||||||
</div>
|
</div>
|
||||||
{% if finding.seriesfinding_set.all %}
|
{% if finding.seriesfinding_set.all %}
|
||||||
<h4>Associated Cases</h4>
|
<h4>Associated Cases</h4>
|
||||||
|
|||||||
@@ -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,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. #}
|
{# Partial: render questions and answers as HTML. #}
|
||||||
|
|
||||||
{% if case_detail.question_schema %}
|
{% if casedetail.question_schema %}
|
||||||
<div class="collection-question-block">
|
<div class="collection-question-block">
|
||||||
<h4>Questions</h4>
|
<h4>Questions</h4>
|
||||||
<dl class="row">
|
<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>
|
<dt class="col-sm-4">{{ prop.title|default:name }}</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{% if prop.description %}
|
{% if prop.description %}
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
|
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
|
||||||
{% if case_detail.question_answers %}
|
{% if casedetail.question_answers %}
|
||||||
{% with correct=case_detail.question_answers|get_item:name %}
|
{% with correct=casedetail.question_answers|get_item:name %}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<strong>Example / Correct:</strong>
|
<strong>Example / Correct:</strong>
|
||||||
{% if correct %}
|
{% if correct %}
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
{% if user_answer %}
|
{% if user_answer %}
|
||||||
{% if user_answer|get_item:name %}
|
{% if user_answer|get_item:name %}
|
||||||
{% with ua=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">
|
<div class="mt-1">
|
||||||
<strong>Your answer:</strong>
|
<strong>Your answer:</strong>
|
||||||
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
|
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if collection %}
|
{% if collection %}
|
||||||
<div class="text-end mb-2">
|
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_view" %}
|
||||||
<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>
|
|
||||||
{% endif %}
|
{% 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>
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||||
|
|
||||||
<div class="container my-4">
|
<div class="container my-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 mb-3">
|
<div class="col-12 mb-3">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
@@ -49,7 +49,6 @@
|
|||||||
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<!-- Finding form moved below the viewport -->
|
<!-- Finding form moved below the viewport -->
|
||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
@@ -114,6 +113,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</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>
|
||||||
|
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
@@ -190,17 +214,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
||||||
<div class="modal-content"></div>
|
<div class="modal-content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
const apiKey = "root"
|
const apiKey = "root"
|
||||||
|
|
||||||
@@ -331,7 +355,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
@@ -402,14 +426,14 @@
|
|||||||
transition: background 0.2s, font-size 0.2s;
|
transition: background 0.2s, font-size 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
details.styled-detail:not([open]) {
|
details.styled-detail:not([open]) {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
details.styled-detail:not([open]) > *:not(summary) {
|
details.styled-detail:not([open]) > *:not(summary) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
details.styled-detail:not([open]) > summary {
|
details.styled-detail:not([open]) > summary {
|
||||||
padding: 0.7em 1.2em 0.3em 1.2em;
|
padding: 0.7em 1.2em 0.3em 1.2em;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Name: {{structure.name}}</h3>
|
<h3>Name: {{structure.name}}</h3>
|
||||||
Primary name: {{structure.primary}}<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.synonym.all|join:", "}}<br />
|
Synonyms: {{ structure.get_synonym_link|safe }}<br />
|
||||||
</div>
|
</div>
|
||||||
<h4>Associated Cases</h4>
|
<h4>Associated Cases</h4>
|
||||||
<ul>
|
<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 %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="container-fluid">
|
<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>
|
{% comment %} <details>
|
||||||
<summary>
|
<summary>
|
||||||
<h4>Filter</h4>
|
<h4>Filter</h4>
|
||||||
@@ -19,7 +26,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details> {% endcomment %}
|
</details> {% endcomment %}
|
||||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
|
||||||
{% render_table table %}
|
{% render_table table %}
|
||||||
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+68
-6
@@ -34,6 +34,7 @@ urlpatterns = [
|
|||||||
path("author/", views.author_list, name="author_list"),
|
path("author/", views.author_list, name="author_list"),
|
||||||
path("case/", views.CaseView.as_view(), name="case_view"),
|
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/<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/", views.CollectionView.as_view(), name="collection_view"),
|
||||||
path("collection/user", views.user_collections, name="user_collections"),
|
path("collection/user", views.user_collections, name="user_collections"),
|
||||||
path("uploads", views.user_uploads, name="user_uploads"),
|
path("uploads", views.user_uploads, name="user_uploads"),
|
||||||
@@ -111,6 +112,11 @@ urlpatterns = [
|
|||||||
views.CollectionCaseUpdate.as_view(),
|
views.CollectionCaseUpdate.as_view(),
|
||||||
name="collection_case_update",
|
name="collection_case_update",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"collection/<int:pk>/case_jump",
|
||||||
|
views.collection_case_jump_partial,
|
||||||
|
name="collection_case_jump_partial",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"collection/add_cases",
|
"collection/add_cases",
|
||||||
views.add_cases_to_collection,
|
views.add_cases_to_collection,
|
||||||
@@ -167,30 +173,56 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
|
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
|
||||||
path(
|
path(
|
||||||
"collection/<int:exam_id>/case/<int:case_id>/details",
|
"collection/<int:exam_id>/case/<int:case_number>/details",
|
||||||
views.collection_case_details,
|
views.collection_case_details,
|
||||||
name="collection_case_details",
|
name="collection_case_details",
|
||||||
),
|
),
|
||||||
|
# Backwards-compatible redirect from legacy case_id-style URLs
|
||||||
path(
|
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,
|
views.collection_case_questions,
|
||||||
name="collection_case_questions",
|
name="collection_case_questions",
|
||||||
),
|
),
|
||||||
path(
|
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,
|
views.collection_case_priors,
|
||||||
name="collection_case_priors",
|
name="collection_case_priors",
|
||||||
),
|
),
|
||||||
path(
|
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,
|
views.collection_case_dicom_json,
|
||||||
name="collection_case_dicom_json",
|
name="collection_case_dicom_json",
|
||||||
),
|
),
|
||||||
path(
|
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,
|
views.collection_case_dicom_json_review,
|
||||||
name="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(
|
path(
|
||||||
"collection/<int:exam_id>/question_schemas",
|
"collection/<int:exam_id>/question_schemas",
|
||||||
views.collection_question_schemas,
|
views.collection_question_schemas,
|
||||||
@@ -271,26 +303,52 @@ urlpatterns = [
|
|||||||
views.collection_case_view,
|
views.collection_case_view,
|
||||||
name="collection_case_view",
|
name="collection_case_view",
|
||||||
),
|
),
|
||||||
|
# legacy path that used case PK; redirect to canonical case_number URL
|
||||||
path(
|
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,
|
views.collection_case_displaysetup,
|
||||||
name="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(
|
path(
|
||||||
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
|
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
|
||||||
views.collection_case_view_take,
|
views.collection_case_view_take,
|
||||||
name="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(
|
path(
|
||||||
"collection/<int:pk>/<int:case_number>/take/",
|
"collection/<int:pk>/<int:case_number>/take/",
|
||||||
views.collection_case_view_take_user,
|
views.collection_case_view_take_user,
|
||||||
name="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(
|
path(
|
||||||
"collection/<int:pk>/<int:case_number>/take/answers",
|
"collection/<int:pk>/<int:case_number>/take/answers",
|
||||||
views.collection_case_view_take_user_answers,
|
views.collection_case_view_take_user_answers,
|
||||||
name="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(
|
path(
|
||||||
"collection/<int:pk>/json_edit",
|
"collection/<int:pk>/json_edit",
|
||||||
views.GenericExamViews.exam_json_edit,
|
views.GenericExamViews.exam_json_edit,
|
||||||
@@ -370,6 +428,7 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"subspecialty/<int:pk>", views.subspecialty_detail, name="subspecialty_detail"
|
"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("condition/create", views.ConditionCreate.as_view(), name="condition_create"),
|
||||||
path("finding/", views.FindingView.as_view(), name="finding_view"),
|
path("finding/", views.FindingView.as_view(), name="finding_view"),
|
||||||
path("finding/<int:pk>", views.finding_detail, name="finding_detail"),
|
path("finding/<int:pk>", views.finding_detail, name="finding_detail"),
|
||||||
@@ -398,6 +457,7 @@ urlpatterns = [
|
|||||||
# TODO: case context series viewing (so that we can view series in the context of a case)
|
# 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>", views.series_detail, name="series_detail"),
|
||||||
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
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: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/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||||
path("series/", views.SeriesView.as_view(), name="series_view"),
|
path("series/", views.SeriesView.as_view(), name="series_view"),
|
||||||
@@ -543,6 +603,7 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
|
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
|
||||||
),
|
),
|
||||||
|
path("presentation/create", views.PresentationCreate.as_view(), name="presentation_create"),
|
||||||
path(
|
path(
|
||||||
"process/",
|
"process/",
|
||||||
views.PathologicalProcessView.as_view(),
|
views.PathologicalProcessView.as_view(),
|
||||||
@@ -553,6 +614,7 @@ urlpatterns = [
|
|||||||
views.pathological_process_detail,
|
views.pathological_process_detail,
|
||||||
name="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("combine_series/", views.combine_series, name="combine_series"),
|
||||||
path(
|
path(
|
||||||
"case/<int:case_id>/linked/",
|
"case/<int:case_id>/linked/",
|
||||||
|
|||||||
+658
-137
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
|
- 8000:8000
|
||||||
- 3459:3459
|
- 3459:3459
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env.dev
|
- ./.env.dev.local
|
||||||
db:
|
db:
|
||||||
image: postgres:14.2-alpine
|
image: postgres:14.2-alpine
|
||||||
volumes:
|
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}
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="h4 mb-1">Exam: {{ exam }}</h1>
|
<h1 class="h4 mb-1">Exam: {{ exam }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 mt-md-0">
|
{% comment %} <div class="mt-2 mt-md-0">
|
||||||
{% include "generic/exam_link_headers.html" %}
|
{% include "generic/exam_link_headers.html" %}
|
||||||
</div>
|
</div> {% endcomment %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'exam_notes.html' %}
|
{% 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>)
|
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>
|
||||||
|
|
||||||
|
<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">
|
<div class="mt-2">
|
||||||
Exam mode: <strong>{{ exam.exam_mode }}</strong>
|
Exam mode: <strong>{{ exam.exam_mode }}</strong>
|
||||||
{% if exam.exam_mode %}
|
{% if exam.exam_mode %}
|
||||||
@@ -178,17 +180,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
{# Authors and supervisor visibility moved earlier in the header; duplicate block removed #}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
|
|||||||
@@ -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>
|
||||||
+7
-6
@@ -14,19 +14,20 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
# Use `fields` per newer ninja ModelSchema API
|
||||||
model_fields = "__all__"
|
#fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
@@ -2,12 +2,27 @@
|
|||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
{{block.super}}
|
{{block.super}}
|
||||||
<br/>
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||||
Exams: {{exam}}->
|
<div class="container-fluid px-3">
|
||||||
<a href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a> /
|
<span class="navbar-text me-3 text-white">
|
||||||
<a href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a> /
|
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
|
||||||
<a href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
</span>
|
||||||
<a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<a href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
<span class="navbar-toggler-icon"></span>
|
||||||
<a href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a> /
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
+6
-6
@@ -15,19 +15,19 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
# Use `fields` per newer ninja ModelSchema API
|
||||||
model_fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
@@ -48,3 +48,32 @@ class ExamMarkerForm(ExamMarkerFormMixin):
|
|||||||
class ExamGroupsForm(ExamGroupsFormMixin):
|
class ExamGroupsForm(ExamGroupsFormMixin):
|
||||||
class Meta(ExamGroupsFormMixin.Meta):
|
class Meta(ExamGroupsFormMixin.Meta):
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Question
|
||||||
|
fields = [
|
||||||
|
"stem",
|
||||||
|
"a",
|
||||||
|
"a_answer",
|
||||||
|
"a_feedback",
|
||||||
|
"b",
|
||||||
|
"b_answer",
|
||||||
|
"b_feedback",
|
||||||
|
"c",
|
||||||
|
"c_answer",
|
||||||
|
"c_feedback",
|
||||||
|
"d",
|
||||||
|
"d_answer",
|
||||||
|
"d_feedback",
|
||||||
|
"e",
|
||||||
|
"e_answer",
|
||||||
|
"e_feedback",
|
||||||
|
"category",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# accept an optional user kwarg for parity with other apps
|
||||||
|
self.user = kwargs.pop("user", None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
@@ -7,70 +7,101 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% load thumbnail %}
|
{% load thumbnail %}
|
||||||
<div class="physics">
|
<div class="container physics my-3">
|
||||||
|
|
||||||
This exam will be available to take <a href="{% url 'physics:exam_start' pk=exam.pk %}">here</a> (when active).
|
<p class="mb-3">This exam will be available to take <a href="{% url 'physics:exam_start' pk=exam.pk %}">here</a> (when active).</p>
|
||||||
|
|
||||||
{% autoescape off %}
|
<div class="card">
|
||||||
<ol id="full-question-list-physics">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Questions <small class="text-muted">({{ questions.count }})</small></h5>
|
||||||
|
<div class="text-muted small">Exam: {{ exam.title }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group list-group-flush" id="full-question-list-physics">
|
||||||
{% for question in questions.all %}
|
{% for question in questions.all %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<div class="me-3">
|
||||||
|
<span class="badge bg-secondary">{{ forloop.counter }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold mb-2">{{ question.stem|safe }}</div>
|
||||||
|
|
||||||
<li>
|
<div class="mb-2">
|
||||||
{{ question.stem }}
|
<ol type="a" class="mb-0">
|
||||||
<ol type="a" class="abcde">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<li class="question-a">
|
<div><span class="me-2">a)</span><span>{{ question.a }}</span></div>
|
||||||
<span class="question-text">{{ question.a }}</span>: <span class="question-answer">{{ question.a_answer }}</span>
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.a_answer }}</div>
|
||||||
{% if question.a_feedback %}
|
{% if question.a_feedback %}
|
||||||
<span class="question-feedback">{{question.a_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.a_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="question-b">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<span class="question-text">{{ question.b }}</span>: <span class="question-answer">{{ question.b_answer }}</span>
|
<div><span class="me-2">b)</span><span>{{ question.b }}</span></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.b_answer }}</div>
|
||||||
{% if question.b_feedback %}
|
{% if question.b_feedback %}
|
||||||
<span class="question-feedback">{{question.b_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.b_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="question-c">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<span class="question-text">{{ question.c }}</span>: <span class="question-answer">{{ question.c_answer }}</span>
|
<div><span class="me-2">c)</span><span>{{ question.c }}</span></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.c_answer }}</div>
|
||||||
{% if question.c_feedback %}
|
{% if question.c_feedback %}
|
||||||
<span class="question-feedback">{{question.c_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.c_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="question-d">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<span class="question-text">{{ question.d }}</span>: <span class="question-answer">{{ question.d_answer }}</span>
|
<div><span class="me-2">d)</span><span>{{ question.d }}</span></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.d_answer }}</div>
|
||||||
{% if question.d_feedback %}
|
{% if question.d_feedback %}
|
||||||
<span class="question-feedback">{{question.d_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.d_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="question-e">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<span class="question-text">{{ question.e }}</span>: <span class="question-answer">{{ question.e_answer }}</span>
|
<div><span class="me-2">e)</span><span>{{ question.e }}</span></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.e_answer }}</div>
|
||||||
{% if question.e_feedback %}
|
{% if question.e_feedback %}
|
||||||
<span class="question-feedback">{{question.e_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.e_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
Category: {{ question.category }}, <a href="{% url 'physics:question_detail' pk=question.pk %}">View [id: {{question.pk}}]</a> <a
|
</div>
|
||||||
href="{% url 'admin:physics_question_change' question.id %}">Edit</a>
|
|
||||||
|
|
||||||
|
<div class="small text-muted">
|
||||||
|
Category: {{ question.category }}
|
||||||
|
— <a href="{% url 'physics:question_detail' pk=question.pk %}">View</a>
|
||||||
|
<a href="{% url 'admin:physics_question_change' question.id %}" class="ms-2">Edit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item text-muted">No questions available.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ul>
|
||||||
{% endautoescape %}
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'exam_overview_js.html' %}
|
{% include 'exam_overview_js.html' %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style>
|
<style>
|
||||||
.question-answer {
|
/* Small visual tweaks on top of Bootstrap */
|
||||||
font-weight: bolder;
|
.question-feedback { opacity: 0.7; }
|
||||||
}
|
.list-group-item ol { margin: 0; padding-left: 1.4rem; }
|
||||||
.question-feedback {
|
.list-group-item li { list-style-position: inside; }
|
||||||
opacity: 50%;
|
|
||||||
float: right
|
|
||||||
}
|
|
||||||
.question-feedback::before {
|
|
||||||
content: "Feedback: ";
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -58,21 +58,7 @@
|
|||||||
- Also supports a manual `.dark-mode` class on body or a parent
|
- Also supports a manual `.dark-mode` class on body or a parent
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #ffffff;
|
|
||||||
--text: #212529;
|
|
||||||
--muted: #6c757d;
|
|
||||||
--secondary: #6c757d;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--card-border: #e9ecef;
|
|
||||||
--primary: #0d6efd;
|
|
||||||
--success: #198754;
|
|
||||||
--danger: #dc3545;
|
|
||||||
--feedback-bg: #f8f9fa;
|
|
||||||
--shadow: 0 1px 2px rgba(0,0,0,0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #050607;
|
--bg: #050607;
|
||||||
--text: #e6eef6;
|
--text: #e6eef6;
|
||||||
@@ -86,7 +72,6 @@
|
|||||||
--shadow: 0 2px 6px rgba(0,0,0,0.6);
|
--shadow: 0 2px 6px rgba(0,0,0,0.6);
|
||||||
}
|
}
|
||||||
body, .exam-take-container { background: var(--bg); color: var(--text); }
|
body, .exam-take-container { background: var(--bg); color: var(--text); }
|
||||||
}
|
|
||||||
|
|
||||||
/* Manual override: add `dark-mode` to body or a parent to force dark theme */
|
/* Manual override: add `dark-mode` to body or a parent to force dark theme */
|
||||||
body.dark-mode, .dark-mode { color: var(--text); background: var(--bg); }
|
body.dark-mode, .dark-mode { color: var(--text); background: var(--bg); }
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
{% extends 'physics/base.html' %}
|
{% extends 'physics/base.html' %}
|
||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
{{block.super}}
|
{{block.super}}
|
||||||
<br/>
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||||
Exams: {{exam}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a> /
|
<div class="container-fluid px-3">
|
||||||
<a href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
<span class="navbar-text me-3 text-white">
|
||||||
<a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
|
||||||
<a href="{% url 'physics:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
</span>
|
||||||
<a href="{% url 'physics:exam_review_start' pk=exam.pk %}">Review</a> /
|
<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 'physics:exam_overview' pk=exam.pk %}">Overview</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'physics:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "physics/base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1>Add question</h1>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.media }}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if form.helper %}
|
||||||
|
{% crispy form form.helper %}
|
||||||
|
{% else %}
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ field.label_tag }}
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<div class="form-text">{{ field.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<div class="text-danger small">{{ error }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="javascript:history.back()" class="btn btn-secondary">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -10,6 +10,8 @@ urlpatterns = []
|
|||||||
urlpatterns.extend(
|
urlpatterns.extend(
|
||||||
[
|
[
|
||||||
path("question/", views.QuestionView.as_view(), name="question_view"),
|
path("question/", views.QuestionView.as_view(), name="question_view"),
|
||||||
|
path("question/create/", views.QuestionCreate.as_view(), name="question_create"),
|
||||||
|
path("question/create/exam/<int:pk>", views.QuestionCreate.as_view(), name="question_create_exam"),
|
||||||
path("question/<int:pk>/", views.question_detail, name="question_detail"),
|
path("question/<int:pk>/", views.question_detail, name="question_detail"),
|
||||||
path("exam/<int:pk>/take_old", views.exam_take_old, name="exam_take_old"),
|
path("exam/<int:pk>/take_old", views.exam_take_old, name="exam_take_old"),
|
||||||
path(
|
path(
|
||||||
|
|||||||
+57
-1
@@ -56,7 +56,14 @@ from generic.views import (
|
|||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
from .forms import ExamGroupsForm, ExamMarkerForm, UserAnswerForm, ExamAuthorForm, ExamForm
|
from .forms import (
|
||||||
|
ExamGroupsForm,
|
||||||
|
ExamMarkerForm,
|
||||||
|
UserAnswerForm,
|
||||||
|
ExamAuthorForm,
|
||||||
|
ExamForm,
|
||||||
|
QuestionForm,
|
||||||
|
)
|
||||||
from .decorators import (
|
from .decorators import (
|
||||||
user_is_author_or_physics_checker,
|
user_is_author_or_physics_checker,
|
||||||
user_is_exam_author_or_physics_checker,
|
user_is_exam_author_or_physics_checker,
|
||||||
@@ -534,6 +541,55 @@ class QuestionView(
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||||
|
model = Question
|
||||||
|
form_class = QuestionForm
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super(QuestionCreateBase, self).get_form_kwargs()
|
||||||
|
kwargs.update({"user": self.request.user})
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
self.object = form.save(commit=False)
|
||||||
|
self.object.save()
|
||||||
|
|
||||||
|
# add the current user as an author
|
||||||
|
form.instance.author.add(self.request.user.id)
|
||||||
|
|
||||||
|
response = super().form_valid(form)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionCreate(QuestionCreateBase):
|
||||||
|
def get_initial(self):
|
||||||
|
if "pk" in self.kwargs:
|
||||||
|
initial = super().get_initial()
|
||||||
|
exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
|
||||||
|
# When creating a question from an exam context we don't have an
|
||||||
|
# 'exams' field on the ModelForm (exams is a reverse relation).
|
||||||
|
# Store nothing in the initial data here; attachment is handled
|
||||||
|
# in form_valid below.
|
||||||
|
return initial
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Let the base class save the object and set the author
|
||||||
|
response = super().form_valid(form)
|
||||||
|
|
||||||
|
# If the create URL included an exam pk, add the new question to that exam
|
||||||
|
if "pk" in self.kwargs:
|
||||||
|
try:
|
||||||
|
exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
|
||||||
|
# exam.exam_questions is a ManyToMany through relation; add is fine
|
||||||
|
exam.exam_questions.add(self.object)
|
||||||
|
except Exception:
|
||||||
|
# Don't fail the whole request if attaching to the exam fails;
|
||||||
|
# the question has already been created and author set.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class UserAnswerView(AuthorOrCheckerRequiredMixin, LoginRequiredMixin, DetailView):
|
class UserAnswerView(AuthorOrCheckerRequiredMixin, LoginRequiredMixin, DetailView):
|
||||||
model = UserAnswer
|
model = UserAnswer
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": "rad",
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
# Copy uv helper binary from the uv image so we can manage venvs and pip
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Install build deps required for common Python packages and netcat for health wait
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
gcc \
|
||||||
|
git \
|
||||||
|
libpq-dev \
|
||||||
|
pkg-config \
|
||||||
|
libcairo2-dev \
|
||||||
|
# some distributions provide libgdk-pixbuf under the xlib name
|
||||||
|
libgdk-pixbuf-xlib-2.0-0 \
|
||||||
|
libgdk-pixbuf-xlib-2.0-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
wget \
|
||||||
|
ca-certificates \
|
||||||
|
netcat-openbsd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Install python deps into a uv-managed venv for reproducible builds
|
||||||
|
COPY requirements.txt /usr/src/app/
|
||||||
|
# Prebuild wheels into /wheels so compiled artifacts are cached in a layer.
|
||||||
|
# This speeds rebuilds when `requirements.txt` hasn't changed. The first build
|
||||||
|
# still needs to compile any packages without prebuilt wheels.
|
||||||
|
RUN mkdir -p /wheels \
|
||||||
|
&& python -m venv /tmp/venvbuild \
|
||||||
|
&& /tmp/venvbuild/bin/pip install --upgrade pip setuptools wheel \
|
||||||
|
&& /tmp/venvbuild/bin/pip wheel --wheel-dir=/wheels -r requirements.txt \
|
||||||
|
&& rm -rf /tmp/venvbuild
|
||||||
|
|
||||||
|
# Create the final uv-managed venv and install from the cached wheels where
|
||||||
|
# possible. Using `--no-index --find-links=/wheels` makes pip prefer the
|
||||||
|
# prebuilt wheels, avoiding recompilation.
|
||||||
|
RUN uv venv /opt/venv \
|
||||||
|
&& export VIRTUAL_ENV=/opt/venv \
|
||||||
|
&& export PATH="/opt/venv/bin:$PATH" \
|
||||||
|
&& uv pip install --upgrade pip setuptools wheel \
|
||||||
|
&& uv pip install --no-index --find-links=/wheels -r requirements.txt
|
||||||
|
|
||||||
|
# Ensure the runtime environment uses the uv-managed venv by default.
|
||||||
|
# This persists the venv into image ENV so `python` and `gunicorn` point
|
||||||
|
# to the venv-installed binaries at container start.
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Create non-root user before copying files so we can use Docker's
|
||||||
|
# `--chown` flag during COPY and avoid an expensive recursive `chown -R`.
|
||||||
|
RUN useradd -m appuser
|
||||||
|
|
||||||
|
# Create directory for application logs and ensure ownership is correct.
|
||||||
|
# Do this as root before switching to `appuser` so the directory exists
|
||||||
|
# and can be mounted by the dev compose override.
|
||||||
|
RUN mkdir -p /var/log/rad \
|
||||||
|
&& chown -R appuser:appuser /var/log/rad
|
||||||
|
|
||||||
|
# Copy project files into image and set ownership during copy (faster than
|
||||||
|
# a separate `chown -R` step). This keeps layer reuse efficient.
|
||||||
|
COPY --chown=appuser:appuser . /usr/src/app
|
||||||
|
|
||||||
|
# Ensure the venv is usable by the non-root user; chown only the venv path
|
||||||
|
# (much smaller than the whole project tree) to avoid a long recursive chown.
|
||||||
|
RUN chown -R appuser:appuser /opt/venv
|
||||||
|
|
||||||
|
# Make the entrypoint executable (some files in the repo may not have the
|
||||||
|
# executable bit set). Do this as root before switching to the app user.
|
||||||
|
RUN [ -f /usr/src/app/entrypoint.sh ] && chmod +x /usr/src/app/entrypoint.sh || true
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/src/app/entrypoint.sh"]
|
||||||
+30
@@ -10,6 +10,36 @@ https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
from loguru import logger
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Configure Loguru sinks early so app imports see consistent logging.
|
||||||
|
# Log to stdout (so `docker logs` shows messages) and to a file at
|
||||||
|
# `/var/log/rad/app.log` for persistent collection / host tailing.
|
||||||
|
try:
|
||||||
|
# ensure directory exists
|
||||||
|
log_dir = Path("/var/log/rad")
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Add stdout sink (if not already added by other code)
|
||||||
|
logger.remove() # remove default handlers to avoid duplicate lines
|
||||||
|
logger.add(sys.stdout, level="INFO", enqueue=True, backtrace=False, diagnose=False)
|
||||||
|
# Add rotating file sink for persistence in container. Use JSON lines
|
||||||
|
# (serialize=True) to make logs easy to query in Loki/Grafana.
|
||||||
|
logger.add(
|
||||||
|
str(log_dir / "app.log"),
|
||||||
|
rotation="10 MB",
|
||||||
|
retention="10 days",
|
||||||
|
level="DEBUG",
|
||||||
|
enqueue=True,
|
||||||
|
serialize=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Best-effort only — don't crash WSGI startup if logging can't be configured
|
||||||
|
try:
|
||||||
|
logger.add(sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rad.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rad.settings")
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -15,19 +15,19 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class RapidSchema(ModelSchema):
|
class RapidSchema(ModelSchema):
|
||||||
class Config:
|
# `ninja` newer versions require an inner `Meta` class instead of `Config`
|
||||||
|
# for ModelSchema. Replace `Config` with `Meta` to be compatible.
|
||||||
|
class Meta:
|
||||||
model = Rapid
|
model = Rapid
|
||||||
|
#model_fields (old API) -> use `fields` per newer ninja ModelSchema API
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
#fields can be a list of field names or the string "__all__".
|
||||||
model_fields = "__all__"
|
#fields = ["question", "history", "feedback", "normal", "laterality"]
|
||||||
|
fields = "__all__"
|
||||||
#model_exclude = ["answers"]
|
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_rapids(request):
|
def list_rapids(request):
|
||||||
|
|||||||
@@ -2,15 +2,30 @@
|
|||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
{{block.super}}
|
{{block.super}}
|
||||||
<br/>
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||||
Exams: {{exam}}->
|
<div class="container-fluid px-3">
|
||||||
<a href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a> /
|
<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 'rapids:exam_overview' pk=exam.pk %}">Overview</a></li>
|
||||||
{% if exam.exam_mode %}
|
{% if exam.exam_mode %}
|
||||||
<a href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a></li>
|
||||||
<a href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||||
<a href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||||
<a href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add New Question</a>
|
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add Question</a></li>
|
||||||
<a href="{% url 'rapids:exam_review_start' pk=exam.pk %}">Review</a>
|
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+5
-6
@@ -15,19 +15,18 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
fields = "__all__"
|
||||||
model_fields = "__all__"
|
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
@@ -2,11 +2,26 @@
|
|||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
{{block.super}}
|
{{block.super}}
|
||||||
<br/>
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||||
Exams: {{exam}}->
|
<div class="container-fluid px-3">
|
||||||
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">Overview</a> /
|
<span class="navbar-text me-3 text-white">
|
||||||
<a href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
|
||||||
<a href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
</span>
|
||||||
<a href="{% url 'sbas:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<a href="{% url 'sbas:exam_review_start' pk=exam.pk %}">Review</a> /
|
<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 'sbas:exam_overview' pk=exam.pk %}">Overview</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'sbas:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.test.yml -f docker/docker-compose.dev.yml down --volumes --remove-orphans
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Simple local bring-up helper. Usage:
|
||||||
|
# ./rad/scripts/local-up.sh
|
||||||
|
# It requires rad/.env.dev to exist (do NOT auto-create from examples) and then
|
||||||
|
# runs the prod+dev compose stack using COMPOSE_ENV to select the env file.
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [ ! -f .env.dev ]; then
|
||||||
|
echo ".env.dev not found. Create .env.dev with your development values (pointing to the external dev DB) and re-run."
|
||||||
|
echo "You can copy .env.prod.example to start from a template, but DO NOT commit secrets."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Default to 'dev' but allow overriding with COMPOSE_ENV environment variable
|
||||||
|
COMPOSE_ENV=${COMPOSE_ENV:-dev}
|
||||||
|
|
||||||
|
echo "Starting compose with COMPOSE_ENV=$COMPOSE_ENV (using .env.$COMPOSE_ENV)"
|
||||||
|
# Load variables from .env.<env> into the environment so Compose variable
|
||||||
|
# substitution (e.g. ${NGINX_HTTP_PORT}) works. We export all variables from
|
||||||
|
# the file for the duration of the command.
|
||||||
|
ENV_FILE="$REPO_ROOT/.env.$COMPOSE_ENV"
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
# Export variables from the .env file safely without sourcing it. Some
|
||||||
|
# values include characters (parentheses, ampersands) that break POSIX
|
||||||
|
# shell parsing if the file is sourced directly. Read line-by-line,
|
||||||
|
# ignore comments/empty lines and export KEY=VALUE pairs.
|
||||||
|
while IFS= read -r _line || [ -n "$_line" ]; do
|
||||||
|
line="$_line"
|
||||||
|
case "$line" in
|
||||||
|
''|\#*) continue ;;
|
||||||
|
esac
|
||||||
|
# Split on first '=' into key and value
|
||||||
|
key=${line%%=*}
|
||||||
|
val=${line#*=}
|
||||||
|
# Export directly (preserves special characters in the value)
|
||||||
|
export "$key=$val"
|
||||||
|
done < "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE_ENV=$COMPOSE_ENV docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up --build
|
||||||
+6
-6
@@ -15,19 +15,19 @@ router = Router()
|
|||||||
|
|
||||||
|
|
||||||
class QuestionSchema(ModelSchema):
|
class QuestionSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
|
# Use `fields` per newer ninja ModelSchema API
|
||||||
model_fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
#model_exclude = ["answers"]
|
#exclude = ["answers"]
|
||||||
|
|
||||||
class ExamSchema(ModelSchema):
|
class ExamSchema(ModelSchema):
|
||||||
class Config:
|
class Meta:
|
||||||
model = Exam
|
model = Exam
|
||||||
|
|
||||||
model_fields = ["id", "name", "active", "publish_results"]
|
fields = ["id", "name", "active", "publish_results"]
|
||||||
|
|
||||||
@router.get('/')
|
@router.get('/')
|
||||||
def list_questions(request):
|
def list_questions(request):
|
||||||
|
|||||||
@@ -2,15 +2,30 @@
|
|||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
{{block.super}}
|
{{block.super}}
|
||||||
<br/>
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
|
||||||
Exams: {{exam}}->
|
<div class="container-fluid px-3">
|
||||||
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">Overview</a> /
|
<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 'shorts:exam_overview' pk=exam.pk %}">Overview</a></li>
|
||||||
{% if exam.exam_mode %}
|
{% if exam.exam_mode %}
|
||||||
<a href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a></li>
|
||||||
<a href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a></li>
|
||||||
<a href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
|
||||||
<a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add New Question</a>
|
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add Question</a></li>
|
||||||
<a href="{% url 'shorts:exam_review_start' pk=exam.pk %}">Review</a>
|
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_review_start' pk=exam.pk %}">Review</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% include 'generic/partials/exams/dropdown_menu.html'%}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+402
@@ -0,0 +1,402 @@
|
|||||||
|
upstream app_server {
|
||||||
|
server unix:/run/gunicorn.sock fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
#listen 80;
|
||||||
|
|
||||||
|
root /home/ross/web/viewer;
|
||||||
|
#index index.html index.htm;
|
||||||
|
|
||||||
|
|
||||||
|
server_name viewer.penracourses.org.uk;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
|
||||||
|
|
||||||
|
#if ($request_method = 'OPTIONS') {
|
||||||
|
# add_header 'Access-Control-Allow-Origin' * 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 204;
|
||||||
|
#}
|
||||||
|
|
||||||
|
#alias /home/ross/web/viewer;
|
||||||
|
|
||||||
|
#add_header Cross-Origin-Opener-Policy same-origin;
|
||||||
|
#add_header Cross-Origin-Embedder-Policy require-corp;
|
||||||
|
|
||||||
|
#add_header 'Cross-Origin-Opener-Policy' 'cross-origin' always;
|
||||||
|
#add_header 'Cross-Origin-Embedder-Policy' 'credentialless' always;
|
||||||
|
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
add_header 'Cross-Origin-Resource-Policy' 'cross-origin';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/viewer.penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/viewer.penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name penracourses.org.uk;
|
||||||
|
|
||||||
|
return 301 https://www.penracourses.org.uk$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
server_name penracourses.org.uk;
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
return 301 https://www.penracourses.org.uk$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
#listen 80;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
client_max_body_size 4G;
|
||||||
|
#server_name _;
|
||||||
|
server_name www.penracourses.org.uk;
|
||||||
|
resolver 127.0.0.11;
|
||||||
|
|
||||||
|
keepalive_timeout 20;
|
||||||
|
|
||||||
|
set $cors "";
|
||||||
|
if ($http_origin ~* (https?://(localhost:5173|viewer\.penracourses\.org\.uk))) {
|
||||||
|
set $cors $http_origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Your Django project's media files - amend as required
|
||||||
|
location /media {
|
||||||
|
if ($request_method = OPTIONS ) {
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors always;
|
||||||
|
#add_header 'Access-Control-Allow-Origin' 'https://viewer.penracourses.org.uk' 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' 'https://viewer.penracourses.org.uk' always;
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors always;
|
||||||
|
|
||||||
|
|
||||||
|
alias /home/ross/web/rad/media;
|
||||||
|
#add_header Access-Control-Allow-Origin *;
|
||||||
|
#add_header Access-Control-Allow-Origin http://localhost:8000;
|
||||||
|
#add_header Vary Origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
# your Django project's static files - amend as required
|
||||||
|
location /static {
|
||||||
|
alias /home/ross/web/static;
|
||||||
|
}
|
||||||
|
|
||||||
|
# rts
|
||||||
|
location /rts {
|
||||||
|
alias /home/ross/web/rts;
|
||||||
|
}
|
||||||
|
# OHIF
|
||||||
|
location /viewer {
|
||||||
|
#add_header 'Cross-Origin-Opener-Policy' 'cross-origin' always;
|
||||||
|
#add_header 'Cross-Origin-Embedder-Policy' 'credentialless' always;
|
||||||
|
#add_header 'Cross-Origin-Resource-Policy' 'cross-origin' always;
|
||||||
|
alias /home/ross/web/viewer;
|
||||||
|
}
|
||||||
|
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 /home/ross/web/ohif;
|
||||||
|
try_files $uri /ohif/index.html;
|
||||||
|
#autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# # Proxy the static assests for the Django Admin panel
|
||||||
|
# location /static/admin {
|
||||||
|
# alias /usr/lib/python3/dist-packages/django/contrib/admin/static/admin/;
|
||||||
|
# }
|
||||||
|
location /rota {
|
||||||
|
alias /home/ross/proc/proc-rota;
|
||||||
|
autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploader {
|
||||||
|
alias /home/ross/uploader;
|
||||||
|
autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/reporter/(.*)$ {
|
||||||
|
alias /home/ross/web/nicereporter;
|
||||||
|
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://127.0.0.1:5129/$1?$args;
|
||||||
|
proxy_set_header X-Forwarded-Prefix /reporter;
|
||||||
|
proxy_redirect http://127.0.0.1:5129/ /reporter/;
|
||||||
|
}
|
||||||
|
|
||||||
|
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-Opener-Policy' 'single-origin' always;
|
||||||
|
#add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always;
|
||||||
|
#add_header 'Cross-Origin-Resource-Policy' 'same-site' always;
|
||||||
|
#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;
|
||||||
|
#add_header 'Access-Control-Allow-Origin' $cors always;
|
||||||
|
|
||||||
|
proxy_pass http://app_server;
|
||||||
|
#add_header Access-Control-Allow-Origin *;
|
||||||
|
#add_header Vary Origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location ~* \.(jpg|jpeg|png|dicom)$ {
|
||||||
|
# add_header Access-Control-Allow-Origin *;
|
||||||
|
#}
|
||||||
|
# rota
|
||||||
|
|
||||||
|
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#server {
|
||||||
|
#server_name 161.35.163.87;
|
||||||
|
#add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
#
|
||||||
|
#return 301 $scheme://penracourses.org.uk$request_uri;
|
||||||
|
#}
|
||||||
|
|
||||||
|
|
||||||
|
#server {
|
||||||
|
# #listen 80;
|
||||||
|
# #server_name penracoureses.org.uk www.penracourses.org.uk;
|
||||||
|
#
|
||||||
|
# root /usr/share/nginx/html;
|
||||||
|
# index index.html index.htm;
|
||||||
|
#
|
||||||
|
# client_max_body_size 4G;
|
||||||
|
# #server_name _;
|
||||||
|
# server_name penracourses.org.uk; # managed by Certbot
|
||||||
|
#
|
||||||
|
# keepalive_timeout 5;
|
||||||
|
#
|
||||||
|
# # Your Django project's media files - amend as required
|
||||||
|
# location /media {
|
||||||
|
# alias /home/ross/web/rad/media;
|
||||||
|
# #add_header Access-Control-Allow-Origin *;
|
||||||
|
# #add_header Access-Control-Allow-Origin http://localhost:8000;
|
||||||
|
# #add_header Vary Origin;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# # your Django project's static files - amend as required
|
||||||
|
# location /static {
|
||||||
|
# alias /home/ross/web/rad/static;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# # rts
|
||||||
|
# location /rts {
|
||||||
|
# alias /home/ross/web/rts;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# # # Proxy the static assests for the Django Admin panel
|
||||||
|
# # location /static/admin {
|
||||||
|
# # alias /usr/lib/python3/dist-packages/django/contrib/admin/static/admin/;
|
||||||
|
# # }
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_redirect off;
|
||||||
|
# proxy_buffering off;
|
||||||
|
#
|
||||||
|
# proxy_pass http://app_server;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# #location ~* \.(jpg|jpeg|png|dicom)$ {
|
||||||
|
# # add_header Access-Control-Allow-Origin *;
|
||||||
|
# #}
|
||||||
|
#
|
||||||
|
# # rota
|
||||||
|
# location /rota {
|
||||||
|
# alias /home/ross/proc-rota;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /rota2 {
|
||||||
|
# alias /home/ross/neos;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# listen [::]:443 ssl ipv6only=on; # managed by Certbot
|
||||||
|
# listen 443 ssl; # managed by Certbot
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
# include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
#
|
||||||
|
#}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
root /home/ross/web/rts;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
client_max_body_size 4G;
|
||||||
|
#server_name _;
|
||||||
|
server_name dev.penracourses.org.uk www.dev.penracourses.org.uk;
|
||||||
|
|
||||||
|
keepalive_timeout 5;
|
||||||
|
|
||||||
|
# Your Django project's media files - amend as required
|
||||||
|
location /media {
|
||||||
|
# Simple requests
|
||||||
|
if ($request_method ~* "(GET|POST)") {
|
||||||
|
add_header "Access-Control-Allow-Origin" *;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preflighted requests
|
||||||
|
if ($request_method = OPTIONS ) {
|
||||||
|
add_header "Access-Control-Allow-Origin" *;
|
||||||
|
add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD";
|
||||||
|
add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept";
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
alias /home/ross/web/rad/media;
|
||||||
|
#add_header Access-Control-Allow-Origin *;
|
||||||
|
#add_header Access-Control-Allow-Origin http://localhost:8000;
|
||||||
|
#add_header Vary Origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
# your Django project's static files - amend as required
|
||||||
|
location /static {
|
||||||
|
alias /home/ross/web/rad/static;
|
||||||
|
}
|
||||||
|
|
||||||
|
# rts
|
||||||
|
location /rts {
|
||||||
|
alias /home/ross/web/rts;
|
||||||
|
}
|
||||||
|
|
||||||
|
# # Proxy the static assests for the Django Admin panel
|
||||||
|
# location /static/admin {
|
||||||
|
# alias /usr/lib/python3/dist-packages/django/contrib/admin/static/admin/;
|
||||||
|
# }
|
||||||
|
|
||||||
|
location / {
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
#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;
|
||||||
|
#add_header Access-Control-Allow-Origin *;
|
||||||
|
#add_header Vary Origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
#location ~* \.(jpg|jpeg|png|dicom)$ {
|
||||||
|
# add_header Access-Control-Allow-Origin *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#listen [::]:443 ssl; # managed by Certbot
|
||||||
|
#listen 443 ssl; # managed by Certbot
|
||||||
|
#ssl_certificate /etc/letsencrypt/live/penracourses.org.uk/fullchain.pem; # managed by Certbot
|
||||||
|
#ssl_certificate_key /etc/letsencrypt/live/penracourses.org.uk/privkey.pem; # managed by Certbot
|
||||||
|
#include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
#ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = penracourses.org.uk) {
|
||||||
|
return 301 https://www.$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
listen 80 ;
|
||||||
|
listen [::]:80 ;
|
||||||
|
server_name penracourses.org.uk;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = www.penracourses.org.uk) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server ipv6only=on;
|
||||||
|
server_name www.penracourses.org.uk;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = viewer.penracourses.org.uk) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
server_name viewer.penracourses.org.uk;
|
||||||
|
listen 80;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
DEBUG = False
|
||||||
|
INTERNAL_IPS = ["82.69.88.125", "217.155.198.96"]
|
||||||
|
#SECURE_SSL_REDIRECT = False
|
||||||
|
#SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'http')
|
||||||
|
#REMOTE_URL = "http://46.101.13.46:8123"
|
||||||
|
#
|
||||||
|
#MEDIA_ROOT = '/home/django/rad/media/'
|
||||||
|
STATIC_ROOT = '/home/ross/web/static/'
|
||||||
|
|
||||||
|
#EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
#"django.core.mail": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||||
|
#"smtplib": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CIMAR_USERNAME = "ross.kruger@nhs.net"
|
||||||
|
CIMAR_PASSWORD = "[prdr32@cimar]"
|
||||||
|
|
||||||
|
# Celery settings
|
||||||
|
CELERY_BROKER_URL = "redis://localhost:6379"
|
||||||
|
CELERY_RESULT_BACKEND = "redis://localhost:6379"
|
||||||
@@ -143,7 +143,8 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
$("#button-edit-order").click(function (evt) {
|
$("#button-edit-order").click(function (evt) {
|
||||||
$(this).remove();
|
// hide the edit button while in edit mode so we can restore it after saving
|
||||||
|
$(this).addClass('d-none').prop('disabled', true);
|
||||||
sortable('.sortable');
|
sortable('.sortable');
|
||||||
$("#full-question-list").addClass('sorting');
|
$("#full-question-list").addClass('sorting');
|
||||||
|
|
||||||
@@ -155,9 +156,29 @@
|
|||||||
for (var i = ul.children.length; i >= 0; i--) {
|
for (var i = ul.children.length; i >= 0; i--) {
|
||||||
ul.appendChild(ul.children[Math.random() * i | 0]);
|
ul.appendChild(ul.children[Math.random() * i | 0]);
|
||||||
}
|
}
|
||||||
|
renumber();
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
// helper to renumber visible cases and update links
|
||||||
|
function renumber() {
|
||||||
|
$("#full-question-list li").each((i, e) => {
|
||||||
|
// update Case N label
|
||||||
|
$(e).find('.case-number').text('Case ' + (i + 1));
|
||||||
|
|
||||||
|
// update link href based on data-case-base-href (replace trailing 0)
|
||||||
|
const a = $(e).find('.case-link');
|
||||||
|
if (a.length) {
|
||||||
|
const base = a.attr('data-case-base-href');
|
||||||
|
if (base) {
|
||||||
|
// replace trailing '/0' with new index
|
||||||
|
const newHref = base.replace(/\/0$/, '/' + i);
|
||||||
|
a.attr('href', newHref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$("#full-question-list li").each((n, el) => {
|
$("#full-question-list li").each((n, el) => {
|
||||||
// Add up/down arrows at the start of each <li>
|
// Add up/down arrows at the start of each <li>
|
||||||
const upBtn = $("<button class='move-up btn btn-sm' title='Move up'>↑</button>").click(function(e){
|
const upBtn = $("<button class='move-up btn btn-sm' title='Move up'>↑</button>").click(function(e){
|
||||||
@@ -166,6 +187,7 @@
|
|||||||
const prev = li.prev();
|
const prev = li.prev();
|
||||||
if (prev.length) {
|
if (prev.length) {
|
||||||
prev.before(li);
|
prev.before(li);
|
||||||
|
renumber();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const downBtn = $("<button class='move-down btn btn-sm' title='Move down'>↓</button>").click(function(e){
|
const downBtn = $("<button class='move-down btn btn-sm' title='Move down'>↓</button>").click(function(e){
|
||||||
@@ -174,20 +196,27 @@
|
|||||||
const next = li.next();
|
const next = li.next();
|
||||||
if (next.length) {
|
if (next.length) {
|
||||||
next.after(li);
|
next.after(li);
|
||||||
|
renumber();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$(el).prepend(downBtn).prepend(upBtn);
|
$(el).prepend(downBtn).prepend(upBtn);
|
||||||
|
|
||||||
// Existing delete button logic
|
// Prefer server-side HTMX remove button when present; otherwise keep a fallback
|
||||||
|
if ($(el).find('.case-remove').length === 0) {
|
||||||
|
// fallback: append an inline client-side remove button (for contexts without server-side remove)
|
||||||
$(el).append($(
|
$(el).append($(
|
||||||
"<span class='exam-question-delete flex-col'><button title='Remove this question from the exam/collection'>Remove</button></span>"
|
"<span class='exam-question-delete flex-col'><button title='Remove this question from the exam/collection'>Remove</button></span>"
|
||||||
).click(() => {
|
).click(() => {
|
||||||
el.remove();
|
el.remove();
|
||||||
|
renumber();
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
// If HTMX remove exists it will handle the server call and swap; renumber after swap via htmx:afterSwap
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#full-question-list").after($(
|
$("#full-question-list").after($(
|
||||||
"<button title='click and drag questions to change order'>Save exam order</button>"
|
"<button id='save-order-button' class='btn btn-primary btn-sm' title='click and drag questions to change order'>Save exam order</button>"
|
||||||
).click(() => {
|
).click(() => {
|
||||||
new_order = [];
|
new_order = [];
|
||||||
$("#full-question-list li").each((n, el) => {
|
$("#full-question-list li").each((n, el) => {
|
||||||
@@ -208,6 +237,21 @@
|
|||||||
|
|
||||||
if (data.status == "success") {
|
if (data.status == "success") {
|
||||||
toastr.info('Exam order changed.')
|
toastr.info('Exam order changed.')
|
||||||
|
renumber();
|
||||||
|
|
||||||
|
// cleanup ordering controls and restore edit button
|
||||||
|
$('#random-order-button').remove();
|
||||||
|
$('#save-order-button').remove();
|
||||||
|
$('#cancel-order-button').remove();
|
||||||
|
// remove per-item move controls
|
||||||
|
$('#full-question-list .move-up, #full-question-list .move-down').remove();
|
||||||
|
// remove fallback delete buttons if desired (keep server-side removes intact)
|
||||||
|
$('#full-question-list .exam-question-delete').remove();
|
||||||
|
// remove visual sorting state
|
||||||
|
$('#full-question-list').removeClass('sorting');
|
||||||
|
|
||||||
|
// restore the edit button so the user can re-enter edit mode
|
||||||
|
$('#button-edit-order').removeClass('d-none').prop('disabled', false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.always(function () {
|
.always(function () {
|
||||||
@@ -220,6 +264,20 @@
|
|||||||
).click(() => {
|
).click(() => {
|
||||||
location.reload();
|
location.reload();
|
||||||
}));
|
}));
|
||||||
|
// Keep list numbering in sync after HTMX swaps that affect the case list
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||||
|
try {
|
||||||
|
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
|
||||||
|
if (!target) return;
|
||||||
|
if (target.id === 'full-question-list' || (target.closest && target.closest('#full-question-list'))) {
|
||||||
|
setTimeout(function(){
|
||||||
|
if (typeof renumber === 'function') renumber();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('htmx afterSwap renumber error', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user