Compare commits

..
70 Commits
Author SHA1 Message Date
Ross 02a9118faf . 2025-12-08 09:29:30 +00:00
Ross 63c6a648d7 Add Loki, Promtail, and Grafana services for observability stack 2025-12-04 20:06:24 +00:00
Ross 517c30ba0e Enhance Docker setup with logging and monitoring features
- Added logging configuration to WSGI for persistent log collection.
- Updated Docker Compose files for local and development environments, including Redis, PostgreSQL, and Grafana services.
- Introduced Promtail configuration for log scraping and integration with Loki.
- Created application log directory in Dockerfile for log persistence.
2025-12-01 16:43:40 +00:00
Ross 590e0b76ed Add series images endpoint and template for HTMX integration 2025-12-01 14:50:11 +00:00
Ross e9a04e823c more docker improvements 2025-12-01 14:01:46 +00:00
Ross 9ed789ba90 more docker updates 2025-12-01 13:31:08 +00:00
Ross fd6e25d55e more ninja fixes 2025-12-01 13:31:02 +00:00
Ross f074e5b086 fix some ninja issuse 2025-12-01 13:30:43 +00:00
Ross 2157b6f781 some fixes? 2025-12-01 11:12:01 +00:00
Ross 359682a0cd start prod docker migration 2025-12-01 10:36:58 +00:00
Ross 78577076c1 Refactor discussion and report sections to use card layout for improved presentation and user interaction 2025-11-24 12:44:55 +00:00
Ross 3635275573 Enhance case view layout and styling for improved readability and user experience 2025-11-24 12:36:18 +00:00
Ross 0c412dfff6 Add prior cases information to collection case view template 2025-11-24 12:19:56 +00:00
Ross 3f00311136 Fix hx-post URL in prior card template for correct case number handling 2025-11-24 12:06:44 +00:00
Ross e8c35efd83 Refactor collection_case_questions function to use case_number instead of case_id for improved clarity and consistency in case retrieval 2025-11-24 12:02:43 +00:00
Ross 0badca4c4f Add default error value to context in collection_case_priors view to prevent variable resolution issues 2025-11-24 11:58:53 +00:00
Ross 25f4e478b3 Add edit permission check for collection case priors view 2025-11-24 11:53:50 +00:00
Ross 33cba3d005 Fix hx-post URL in prior card template for correct case number handling 2025-11-24 11:43:07 +00:00
Ross efca426359 Add case jump functionality with HTMX support and corresponding template 2025-11-24 11:40:52 +00:00
Ross f713632fdd Refactor Condition, Finding, and Structure tables: remove legacy primary field from displayed fields to streamline data representation. 2025-11-24 11:02:51 +00:00
Ross 785638ee23 Add cancel button to ConditionForm for easier navigation 2025-11-24 10:49:13 +00:00
Ross e8020c5d8f Update ConditionForm to use AJAX/autocomplete for canonical field selection 2025-11-24 10:42:38 +00:00
Ross 5ccf755e72 Refactor Condition model: remove legacy synonym and primary fields, introduce canonical field for aliasing. Update related documentation. 2025-11-24 10:31:13 +00:00
Ross 6308fbfcd7 Refactor Finding and Structure models: remove legacy primary and synonym fields, introduce canonical field for aliasing. Update filters and forms accordingly. 2025-11-24 10:18:26 +00:00
Ross 8a871d6332 Replace legacy primary filter with is_canonical and add synonym filter in ConditionFilter 2025-11-23 23:29:39 +00:00
Ross 6eeb4cbb1d Add canonical field and update synonym handling in Condition model
- Introduced a new `canonical` ForeignKey field in the Condition model to manage alias relationships.
- Enhanced synonym retrieval methods to prefer canonical names and handle aliases more effectively.
- Updated condition detail templates to reflect canonical status and improved synonym display.
- Implemented migration scripts to establish canonical relationships based on existing synonym links.
2025-11-20 22:48:03 +00:00
Ross 9d57d03cbb Refactor synonym management: extract synonyms list and add forms for adding synonyms with HTMX support 2025-11-20 21:59:50 +00:00
Ross 21de543313 Enhance ConditionForm: add Crispy Forms integration for consistent submit button rendering 2025-11-17 23:15:21 +00:00
Ross a12a9e38f3 Implement synonym management in condition detail: add forms for adding synonyms and handle POST requests for synonym creation. 2025-11-17 23:08:34 +00:00
Ross 81dc5bf48f Add forms and views for subspecialty, presentation, and pathological process management 2025-11-17 23:01:02 +00:00
Ross 4abb57900d Refactor categories dropdown: enhance layout with additional category options and staff creation links for improved navigation 2025-11-17 22:54:32 +00:00
Ross 08c9138cdb Refactor condition form: streamline JavaScript loading and enhance button layout for improved usability 2025-11-17 22:52:34 +00:00
Ross fb833a2b90 Refactor collection view: create a new template for collections with improved layout and staff action visibility 2025-11-17 22:47:15 +00:00
Ross 2f1e2f6abe Refactor collection_case_details: simplify case retrieval logic and improve parameter naming for clarity 2025-11-17 22:33:10 +00:00
Ross ffa717a9ef Refactor exam overview header: move author display earlier and remove duplicate block for improved clarity 2025-11-17 22:16:08 +00:00
Ross 2dd9f4af32 Refactor exam overview: enhance question display with improved layout and feedback visibility 2025-11-17 22:09:49 +00:00
Ross feb5a85af2 Refactor dropdown menus: enhance action items with icons and improve visual hierarchy for better user experience 2025-11-17 12:20:46 +00:00
Ross 292f81ad74 Refactor question link header: update text for clarity in exam context 2025-11-17 12:16:26 +00:00
Ross 349c46c6db Refactor viewing case template: adjust HTML structure for improved readability and maintainability 2025-11-17 12:14:05 +00:00
Ross 681a7560ba Refactor question form layout: improve indentation and formatting for better readability 2025-11-17 12:12:31 +00:00
Ross aa90ae5e09 Add QuestionForm and related views: implement form for creating questions with user context and exam association 2025-11-17 12:12:06 +00:00
Ross f55bb1b18e Refactor exam navigation: enhance layout and accessibility by replacing links with a responsive navbar structure 2025-11-17 12:02:21 +00:00
Ross 5e78cc4603 Refactor exam overview headers: comment out unused div for improved code clarity 2025-11-17 11:54:35 +00:00
Ross a9cbb1303a Refactor exam navigation: restructure HTML for improved layout and accessibility, adding dropdown menu for exam actions 2025-11-17 11:52:53 +00:00
Ross 24558f92ce Refactor case prior handling: update function to support case_number instead of case_id for improved clarity and maintainability 2025-11-17 11:15:08 +00:00
Ross 0f8bae8a14 Refactor URL patterns: update legacy case_id references to include 'id' for improved clarity and consistency 2025-11-17 11:11:09 +00:00
Ross 6e658aca93 Refactor case handling: update URLs and views to support case_number and case_id interchangeably, enhancing backward compatibility and maintainability 2025-11-17 11:05:13 +00:00
Ross 81cd109449 Refactor dark mode styles: update CSS variables for dark mode theme and set default background and text colors 2025-11-17 10:49:31 +00:00
Ross d86f990be2 Refactor case detail URL references: update template links from 'casedetail' to 'case_detail' for consistency 2025-11-17 10:36:19 +00:00
Ross b6478c44dc Refactor case detail references: standardize variable naming from case_detail to casedetail across templates for consistency 2025-11-17 10:34:55 +00:00
Ross 12b7fa6629 Refactor CaseDetail references to Casedetail across templates and views
- Updated all instances of 'case_detail' to 'casedetail' in templates to maintain consistency.
- Adjusted view functions to use 'casedetail' instead of 'case_detail' for better clarity.
- Renamed the field 'case_detail' to 'casedetail' in the CasePrior model to align with the new naming convention.
- Ensured that all related logic and references in forms and data handling reflect this change.
2025-11-17 10:34:27 +00:00
Ross 7f4570417e Refactor case navigation: consolidate previous and next links into a reusable partial for improved maintainability and consistency across templates 2025-11-17 10:09:00 +00:00
Ross 0098fa5dfc Refactor case prior display: extract prior card markup into a separate partial for improved maintainability and readability 2025-11-17 09:27:02 +00:00
Ross 740d013198 Refactor case prior display: enhance layout with Bootstrap cards for improved readability and user interaction 2025-11-17 09:22:17 +00:00
Ross b47bb141e8 Enhance case search functionality: exclude cases already in the collection from search results to prevent duplication 2025-11-16 23:28:23 +00:00
Ross 712ecec25d Enhance case search results: add data attributes for case identification and implement client-side removal of cases from search results after addition to collection 2025-11-16 23:25:35 +00:00
Ross 0193daf166 Refactor case search widget: update hx-get attribute and include collection in hx-vals for improved search functionality 2025-11-16 23:17:57 +00:00
Ross 279d6344f1 Enhance case search functionality: display recent cases in search results and adjust initial load behavior 2025-11-16 23:16:06 +00:00
Ross 8c8d484dce Enhance exam order editing: hide edit button during editing, improve save button attributes, and clean up controls after saving 2025-11-16 23:06:33 +00:00
Ross 9760be8f27 Enhance case list item links and management actions: update hrefs for dynamic case numbering and improve button structure for better accessibility 2025-11-16 23:03:19 +00:00
Ross 5e0068d490 Implement case search functionality and enhance case addition UI in collection view 2025-11-16 22:55:59 +00:00
Ross 875744a08a Refactor case management links and add case list item partial for improved organization and rendering in collection detail view 2025-11-16 22:51:22 +00:00
Ross c02a46fab1 Refactor collection detail view: enhance layout, improve organization, and add badges for better information display 2025-11-16 22:36:50 +00:00
Ross 6b73006122 Refactor collection detail view: enhance layout and improve organization of case management links and collection information 2025-11-16 22:31:00 +00:00
Ross 7eb478b111 Refactor case management links: enhance layout and improve button accessibility in collection detail view 2025-11-16 22:26:34 +00:00
Ross 10c18bd514 Refactor case management links: move to separate partial for improved template organization and clarity 2025-11-16 22:20:53 +00:00
Ross 1407fe83a2 Fix link for case series display and update edit series action in management links 2025-11-16 22:03:14 +00:00
Ross e9716f3caf Add per-case management links template for collection detail view 2025-11-16 22:01:30 +00:00
Ross 749450b03e Refactor collection detail template: streamline case management links and enhance layout for better usability 2025-11-16 21:57:54 +00:00
Ross 7e72f731b3 Move "Add New Case" link to dropdown for superusers in collection actions 2025-11-16 21:37:03 +00:00
92 changed files with 4385 additions and 1214 deletions
+22
View File
@@ -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
+20
View File
@@ -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
+4
View File
@@ -12,3 +12,7 @@ venv
*.log
temp/
# Loki runtime data
/docker/loki-data/
/docker/loki-wal/
+15 -16
View File
@@ -19,51 +19,50 @@ from django.contrib.auth import get_user_model
router = Router()
class CidUserSchema(ModelSchema):
class Config:
class Meta:
model = CidUser
model_fields = ["cid", "name", "email"]
fields = ["cid", "name", "email"]
class UserUserSchema(ModelSchema):
class Config:
class Meta:
model = get_user_model()
model_fields = ["id", "email"]
fields = ["id", "email"]
class CidUserExamSchema(ModelSchema):
cid_user : CidUserSchema | None
user_user : UserUserSchema | None
class Config:
class Meta:
model = CidUserExam
model_fields = ["id"]
fields = ["id"]
class ExamStatusSchema(ModelSchema):
class Config:
class Meta:
model = ExamUserStatus
model_fields = ["id", "datetime", "status", "extra"]
fields = ["id", "datetime", "status", "extra"]
class ExamUserStatusSchema(ModelSchema):
#cid_user : CidUserSchema | None
#user_user : UserUserSchema | None
cid_user_exam : CidUserExamSchema | None
class Config:
class Meta:
model = ExamUserStatus
model_fields = ["id", "datetime", "status", "extra"]
fields = ["id", "datetime", "status", "extra"]
class QuestionSchema(ModelSchema):
class Config:
class Meta:
model = Question
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
model_fields = "__all__"
fields = "__all__"
#model_exclude = ["answers"]
#exclude = ["answers"]
class ExamSchema(ModelSchema):
class Config:
class Meta:
model = Exam
model_fields = ["id", "name", "active", "publish_results"]
fields = ["id", "name", "active", "publish_results"]
@router.get('/')
def list_questions(request):
+24 -10
View File
@@ -2,16 +2,30 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a> /
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %}
<a href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a> /
<a href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:mark2_overview' pk=exam.pk %}">Mark2</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %}
<a href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a>
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %}
<li class="nav-item"><a class="nav-link" href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %}
@@ -1,28 +1,95 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Question:</strong>
{% if question.stem %}
{{ question.stem|striptags|truncatechars:80 }}
{% else %}
#{{ question.pk }}
{% endif %}
</span>
<div class="floating-header">
<a href="{% url 'anatomy:anatomy_question_update' question.id %}" title="Edit the Question">Edit</a>
<a href="{% url 'anatomy:question_clone' question.id %}" title="Clone the Question">Clone</a>
<a href="{% url 'anatomy:question_delete' pk=question.pk %}" title="Delete the Question">Delete</a>
<a href="{% url 'anatomy:question_answer_update' pk=question.pk %}" title="Update the question answers">Edit Answers</a>
<a href="#"
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
Add Note</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#questionNav" aria-controls="questionNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="questionNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if exam %}
<li class="nav-item">
{% if previous > -1 %}
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous</a>
{% endif %}
</li>
<li class="nav-item align-self-center">
Viewing as part of exam: <a class="nav-link d-inline p-0" href="{% url 'anatomy:exam_overview' exam.id %}">{{ exam }}</a>
<span class="navbar-text text-muted ms-2">[{{ pos }}/{{ exam_length }}]</span>
</li>
<li class="nav-item">
{% if next %}
<a class="nav-link" href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next</a>
{% endif %}
</li>
{% endif %}
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="questionActions" role="button" data-bs-toggle="dropdown" aria-expanded="false">Actions</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="questionActions">
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:question_detail' question.id %}">
<i class="bi bi-eye me-2"></i> View
</a>
</li>
<li>
<a class="dropdown-item text-primary" href="{% url 'anatomy:anatomy_question_update' question.id %}">
<i class="bi bi-pencil-square me-2"></i> Edit
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_answer_update' pk=question.pk %}">
<i class="bi bi-list-check me-2"></i> Edit Answers
</a>
</li>
<li>
<a class="dropdown-item text-white" href="{% url 'anatomy:question_clone' question.id %}">
<i class="bi bi-files me-2"></i> Clone
</a>
</li>
<li>
<a class="dropdown-item text-info" href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='anatomy' pk=question.pk %}')">
<i class="bi bi-chat-left-text me-2"></i> Add Note
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="{% url 'anatomy:question_delete' pk=question.pk %}">
<i class="bi bi-trash me-2"></i> Delete
</a>
</li>
{% if request.user.is_superuser %}
<a href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}"
title="Edit the Question using the admin interface">Admin Edit</a>
<a href="{% url 'anatomy:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
{% endif %}
{% if exam %}
<div>
{% if previous > -1 %}
<a href="{% url 'anatomy:exam_question_detail' exam.id previous %}">Previous question</a>
{% endif %}
Viewing question as part of exam: <a href="{% url 'anatomy:exam_overview' exam.id %}">{{exam}}</a> [{{pos}}/{{exam_length}}]
{% if next %}
<a href="{% url 'anatomy:exam_question_detail' exam.id next %}">Next question</a>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-warning" href="{% url 'admin:anatomy_anatomyquestion_change' question.id %}">
<i class="bi bi-tools me-2"></i> Admin Edit
</a>
</li>
<li>
<a class="dropdown-item text-muted" href="{% url 'anatomy:question_user_answers' question.id %}">
<i class="bi bi-people me-2"></i> User answers
</a>
</li>
{% endif %}
</ul>
</li>
</ul>
</div>
{% endif %}
</div>
</nav>
+4 -4
View File
@@ -32,9 +32,9 @@ router = Router()
class SeriesSchema(ModelSchema):
case_id: List[int] = []
class Config:
class Meta:
model = Series
model_fields = [
fields = [
"id",
"modality",
"examination",
@@ -48,9 +48,9 @@ class SeriesSchema(ModelSchema):
class CaseSchema(ModelSchema):
class Config:
class Meta:
model = Case
model_fields = ["id", "title"]
fields = ["id", "title"]
@router.post("/upload_dicom", auth=django_auth)
+60 -5
View File
@@ -164,13 +164,16 @@ class SeriesFilter(django_filters.FilterSet):
class ConditionFilter(django_filters.FilterSet):
# Replace legacy `primary` filter with `is_canonical` and provide a
# `synonym` filter which will filter by the canonical group of the
# selected condition.
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Condition
fields = {
"name": ["icontains"],
"primary": ["exact"],
"synonym": ["exact"],
#"parent": ["exact"],
"subspecialty": ["exact"],
"rcr_curriculum": ["exact"],
}
@@ -189,11 +192,32 @@ class ConditionFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
# value is a Condition instance selected in the filter. Return all
# Conditions that belong to the same canonical group as `value`.
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class FindingFilter(django_filters.FilterSet):
# Replace legacy `primary`/`synonym` with canonical-based filters
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Finding
fields = ("name", "primary", "synonym")
fields = {
"name": ["icontains"],
}
def __init__(
self,
@@ -209,11 +233,29 @@ class FindingFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
# value is a Finding instance selected in the filter. Return all
# Findings that belong to the same canonical group as `value`.
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class StructureFilter(django_filters.FilterSet):
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
class Meta:
model = Structure
fields = {"name": ["icontains"], "primary": ["exact"], "synonym": ["exact"]}
fields = {"name": ["icontains"]}
def __init__(
self,
@@ -229,6 +271,19 @@ class StructureFilter(django_filters.FilterSet):
)
pass
def filter_is_canonical(self, queryset, name, value):
if value in (True, "True", "true", 1, "1"):
return queryset.filter(canonical__isnull=True)
if value in (False, "False", "false", 0, "0"):
return queryset.filter(canonical__isnull=False)
return queryset
def filter_synonym(self, queryset, name, value):
if not value:
return queryset
master = value.canonical if value.canonical else value
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
class PresentationFilter(django_filters.FilterSet):
class Meta:
+91 -31
View File
@@ -40,6 +40,8 @@ from atlas.models import (
Condition,
Structure,
Subspecialty,
Presentation,
PathologicalProcess,
UncategorisedDicom,
UserReportAnswer,
CaseDisplaySet,
@@ -54,7 +56,8 @@ from generic.models import Examination # , Sign
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
from django.urls import reverse
from crispy_forms.bootstrap import Accordion, AccordionGroup
from tinymce.widgets import TinyMCE
@@ -116,11 +119,10 @@ class ConditionForm(ModelForm):
class Meta:
model = Condition
exclude = ["rcr_curriculum"]
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
# Use an AJAX/autocomplete single-select for canonical so users
# can search for existing canonical Conditions by name.
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
"parent": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
@@ -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 Meta:
@@ -313,9 +341,10 @@ class FindingForm(ModelForm):
class Meta:
model = Finding
exclude = []
# Use the canonical FK in forms (if users want to mark this Finding
# as an alias of another). The old `synonym` M2M has been removed.
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
"canonical": autocomplete.ModelSelect2(
url="atlas:finding-autocomplete"
),
}
@@ -325,14 +354,31 @@ class StructureForm(ModelForm):
class Meta:
model = Structure
exclude = []
widgets = {
"synonym": autocomplete.ModelSelect2Multiple(
"canonical": autocomplete.ModelSelect2(
url="atlas:structure-autocomplete"
),
}
class SubspecialtyForm(ModelForm):
class Meta:
model = Subspecialty
exclude = []
class PresentationForm(ModelForm):
class Meta:
model = Presentation
exclude = []
class PathologicalProcessForm(ModelForm):
class Meta:
model = PathologicalProcess
exclude = []
class SeriesFindingForm(ModelForm):
class Meta:
model = SeriesFinding
@@ -831,7 +877,7 @@ class BaseReportAnswerForm(ModelForm):
# "answer": "Write your answer in here."
# }
def __init__(self, *args, case_detail, **kwargs):
def __init__(self, *args, casedetail, **kwargs):
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
self.fields["answer"].required = False
@@ -847,12 +893,12 @@ class BaseQuestionAnswerForm(ModelForm):
# "answer": "Write your answer in here."
# }
def __init__(self, *args, case_detail, **kwargs):
def __init__(self, *args, casedetail, **kwargs):
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
# self.fields["json_answer"].schema = case_detail.question_schema
if case_detail.question_schema is not None:
# self.fields["json_answer"].schema = casedetail.question_schema
if casedetail.question_schema is not None:
self.fields["json_answer"] = JSONSchemaField(
schema=case_detail.question_schema, options="schema/options.json"
schema=casedetail.question_schema, options="schema/options.json"
)
self.fields["json_answer"].label = ""
@@ -1013,6 +1059,37 @@ class ConditionAutocomplete(ModelAutocomplete):
model = Condition
search_attrs = ["name"]
@classmethod
def get_items_from_keys(cls, keys, context_obj=None):
"""Return items for given keys, ignoring empty strings.
The autocomplete frontend sometimes sends empty string keys which
causes the ORM to raise ValueError when filtering numeric PK fields.
Filter out falsy/blank keys before querying. If no valid keys remain
return an empty queryset.
"""
# normalize and remove empty/blank values
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
if not clean_keys:
return cls.model.objects.none()
# try to convert numeric keys to ints where possible
parsed_keys = []
for k in clean_keys:
try:
parsed_keys.append(int(k))
except Exception:
parsed_keys.append(k)
qs = cls.model.objects.filter(pk__in=parsed_keys)
# Return a list of dicts matching the autocomplete core expectations
# (items subscriptable by keys like 'key' / 'label').
return [
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
for obj in qs
]
class ConditionAutocompleteForm(Form):
condition = ModelChoiceField(
@@ -1153,23 +1230,6 @@ class PriorCaseForm(Form):
prior_visibility = CharField(max_length=2, required=True)
# def __init__(self, *args, **kwargs):
# self.case_detail = kwargs.pop(
# "case_detail"
# ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole
# super(PriorCaseForm, self).__init__(*args, **kwargs)
# prior_cases = self.case_detail.case.get_all_prior_cases()
# if not prior_cases:
# prior_cases = Case.objects.none()
# self.fields["case"] = ChoiceField(
# required=False,
# # widget=Select(verbose_name="Series", is_stacked=False),
# )
class CaseSeriesForm(forms.ModelForm):
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
@@ -0,0 +1,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')},
),
]
+69
View File
@@ -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
View File
@@ -110,36 +110,121 @@ class SynMixin(object):
# abstract = True
def __str__(self) -> str:
if self.primary:
# Determine whether this record should be displayed as the
# canonical/primary entry. Prefer the new canonical FK pattern
# (`canonical is None` means this object is the master). Fallback
# to legacy `primary` boolean when present.
# If the model supports canonical FK, consider canonical==None as
# the primary/master entry. For models without canonical (legacy
# edge-cases), treat the instance as primary by default.
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return self.name
else:
return f"{self.name} [syn]"
except Exception:
# defensive fallback
return f"{self.name} [syn]"
def get_old_str(self) -> str:
if self.primary:
if self.synonym.count() == 0:
# No canonical support: assume primary
return self.name
def get_old_str(self) -> str:
# Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
try:
syns = self.get_synonyms()
except Exception:
syns = None
# If the model supports canonical, only canonical==None entries
# should be rendered as the primary name. For models without
# canonical, treat instance as primary.
if hasattr(self, "canonical"):
try:
is_primary = self.canonical is None or self.canonical == self
except Exception:
is_primary = False
else:
synonyms = ",".join([i.name for i in self.synonym.all()])
is_primary = True
if is_primary:
if syns is None:
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None or (hasattr(synonyms_field, "count") and synonyms_field.count() == 0):
return self.name
synonyms = ",".join([i.name for i in synonyms_field.all()])
return f"{self.name} ({synonyms})"
else:
if not syns:
return self.name
synonyms = ",".join([i.name for i in syns])
return f"{self.name} ({synonyms})"
return f"{self.name} [syn]"
def get_synonym(self):
if self.primary:
# Return a readable synonym string. For models that implement
# get_synonyms (Condition with canonical/aliases), use that.
try:
syns = self.get_synonyms()
except Exception:
syns = None
# If canonical is present and this instance is the master, mark Primary
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return "[Primary]"
else:
s = self.synonym.filter(primary=True).values_list("name", flat=True)
except Exception:
pass
if syns is None:
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None:
return ""
# If the old M2M exists, prefer those marked primary if such a
# attribute exists on the related objects.
try:
# Select all synonym names; legacy `primary` flag removed.
s = synonyms_field.values_list("name", flat=True)
except Exception:
s = []
return ", ".join(s)
# prefer primary names among synonyms if present
primary_names = []
for i in syns:
# Prefer items that are canonical/master in the group
if getattr(i, "canonical", None) is None:
primary_names.append(i.name)
if primary_names:
return ", ".join(primary_names)
return ", ".join([i.name for i in syns])
def get_synonym_link(self):
if self.primary:
try:
syns = self.get_synonyms()
except Exception:
syns = None
# Prefer canonical/master check
if hasattr(self, "canonical"):
try:
if self.canonical is None or self.canonical == self:
return "[Primary]"
else:
syns = self.synonym.filter(primary=True)
return ", ".join(
[f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
)
except Exception:
pass
if syns is None:
# fall back to all M2M synonyms (not just primary) for models
# that still use the old synonym field (e.g. Finding, Structure)
synonyms_field = getattr(self, "synonym", None)
if synonyms_field is None:
return ""
syns_qs = synonyms_field.all()
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
return ", ".join(items)
# render links for synonyms/canonical group
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
return ", ".join(items)
def get_link(self):
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
@@ -147,21 +232,49 @@ class SynMixin(object):
class Finding(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
synonym = models.ManyToManyField("self", blank=True)
primary = models.BooleanField(default="True")
# New canonical/alias field: if set, this Finding is an alias and points
# to the canonical/master Finding. We remove the old M2M `synonym` and
# `primary` boolean in favour of this single canonical FK.
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Finding is an alias and points to the canonical Finding.",
)
def get_absolute_url(self):
return reverse("atlas:finding_detail", kwargs={"pk": self.pk})
@property
def canonical_finding(self):
return self.canonical if self.canonical else self
def get_synonyms(self):
"""Return other Findings that are aliases/synonyms for this concept.
Behaviour mirrors Condition.get_synonyms: if this Finding is an alias
(has canonical set) return the canonical and other aliases (excluding
self). If this Finding is canonical, return all aliases (excluding
self).
"""
master = self.canonical_finding
qs = Finding.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
class Condition(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
synonym = models.ManyToManyField(
# New canonical/alias field (Option B): if set, this Condition is an alias
# and points to the canonical/master Condition.
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
help_text="Use if a direct synonym for the condition exists, e.g. 'Wegener granulomatosis' and 'Granulomatosis with Polyangitis'.",
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Condition is an alias and points to the canonical Condition.",
)
parent = models.ManyToManyField(
"self",
@@ -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'.",
)
primary = models.BooleanField(
default="True",
help_text="Sets if this should be the canonical item, all other synonyms will then redirect to this by default.",
)
# Legacy fields `synonym` (M2M) and `primary` (boolean) have been removed.
# Canonical/aliases are represented by the `canonical` ForeignKey and
# the reverse `aliases` related_name. Use `get_synonyms()` /
# `canonical_condition` to access canonical groups.
subspecialty = models.ManyToManyField(
"subspecialty",
@@ -198,6 +311,23 @@ class Condition(SynMixin, models.Model):
def get_absolute_url(self):
return reverse("atlas:condition_detail", kwargs={"pk": self.pk})
@property
def canonical_condition(self):
"""Return the canonical/master condition for this Condition (self if none)."""
return self.canonical if self.canonical else self
def get_synonyms(self):
"""Return other Conditions that are synonyms/aliases for this concept.
Behaviour:
- If this Condition is an alias (canonical set), return the canonical and
any other aliases (excluding self).
- If this Condition is canonical, return all aliases (and exclude self).
"""
master = self.canonical_condition
qs = Condition.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
def get_children(self):
return self.child.all()
@@ -276,14 +406,28 @@ class Differential(models.Model):
class Structure(SynMixin, models.Model):
name = models.CharField(max_length=255, unique=True)
synonym = models.ManyToManyField("self", blank=True)
primary = models.BooleanField(default="True")
# Migrate to canonical/aliases model like Condition and Finding
canonical = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="aliases",
help_text="If set, this Structure is an alias and points to the canonical Structure.",
)
def get_absolute_url(self):
return reverse("atlas:structure_detail", kwargs={"pk": self.pk})
@property
def canonical_structure(self):
return self.canonical if self.canonical else self
def get_synonyms(self):
master = self.canonical_structure
qs = Structure.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
return qs
@reversion.register
class Case(models.Model, AuthorMixin, QuestionMixin):
@@ -1443,6 +1587,13 @@ class CaseDetail(models.Model):
def __str__(self) -> str:
return f"{self.case} -> {self.collection}"
def get_case_index(self):
"""Returns the question index (number) in the collection (0 indexed)"""
case_details = list(self.collection.casedetail_set.all().order_by("sort_order"))
return case_details.index(self)
def get_question_schema(self):
return json.dumps(self.question_schema)
@@ -1545,7 +1696,7 @@ class CaseDetail(models.Model):
return mark_safe(html)
class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
casedetail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases")
@@ -1564,10 +1715,10 @@ class CasePrior(models.Model):
)
class Meta:
unique_together = ("case_detail", "prior_case")
unique_together = ("casedetail", "prior_case")
def __str__(self) -> str:
return f"{self.case_detail.case} -> {self.prior_case}"
return f"{self.casedetail.case} -> {self.prior_case}"
class BaseReportAnswer(models.Model):
question = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
+3 -3
View File
@@ -276,7 +276,7 @@ class ConditionTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Condition
template_name = "django_tables2/bootstrap4.html"
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
fields = ("subspecialty", "rcr_curriculum_map", "rcr_curriculum")
sequence = ("name",)
@@ -312,7 +312,7 @@ class FindingTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Finding
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
fields = ("name",)
sequence = ("name",)
@@ -339,7 +339,7 @@ class StructureTable(SelectionTable):
class Meta(SelectionTable.Meta):
model = Structure
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary")
fields = ("name",)
sequence = ("name",)
@@ -1,14 +1,9 @@
<span id="add-case-form">
This form allows you to add an existing case to the collection. You may find it easier to do this from the individual case pages or the <a href="{% url 'atlas:case_view' %}">case overview view</a> which allows you to add multiple cases at once.
<form hx-post="{% url 'atlas:add_case_to_collection' collection.id %}" hx-swap="outerHTML">
{% csrf_token %}
<label for="case">Select Case to add:</label>
<select name="case" id="case">
{% for case in cases %}
<option value="{{ case.id }}">{{ case.title }}</option>
{% endfor %}
</select>
<button type="submit">Add</button>
<button _="on click remove #add-case-form">Cancel</button>
</form>
</span
<div id="add-case-form" class="p-2 border rounded">
<p class="mb-2">Add an existing case to this collection. You can also use the case overview view to add multiple cases at once: <a href="{% url 'atlas:case_view' %}">case overview</a>.</p>
{% include 'atlas/partials/case_search_widget.html' %}
<div class="mt-2">
<button class="btn btn-sm btn-secondary" _="on click remove #add-case-form">Close</button>
</div>
</div>
+26 -2
View File
@@ -66,8 +66,32 @@
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'atlas:categories_list' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>All categories</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'atlas:condition_view' %}">Conditions</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:finding_view' %}">Findings</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:structure_view' %}">Structures</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
{% if request.user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li class="dropdown-item-text small text-muted px-3">Create</li>
<li><a class="dropdown-item" href="{% url 'atlas:condition_create' %}">New condition</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:finding_create' %}">New finding</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:structure_create' %}">New structure</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
{% endif %}
</ul>
</li>
<li class="nav-item 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>
@@ -464,7 +464,7 @@
{% if casedetail %}
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail can_edit=can_edit %}
{% include 'atlas/partials/collection_question_block.html' with can_edit=can_edit %}
{% endif %}
@@ -3,17 +3,9 @@
{% block content %}
<div>
{% if previous %}
<a href="{% url 'atlas:collection_case_details' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_details' collection.id next.id %}">Next question</a>
{% endif %}
</div>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_details" %}
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
<p>This page allows you to configure how the case is displayed as part of the collection.</p>
@@ -3,33 +3,34 @@
{% 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;"
data-auto-cache-stack="true"
{% if case_detail.default_viewerstate %}
data-viewerstate={{ case_detail.default_viewerstate }}
{% if casedetail.default_viewerstate %}
data-viewerstate={{ casedetail.default_viewerstate }}
{% endif %}
></div>
<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
</button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
</button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
Reset to Default
</button>
<div id="viewerstate-save-response" class="mt-2"></div>
</button>
<div id="viewerstate-save-response" class="mt-2"></div>
<script>
document.getElementById('save-viewerstate').addEventListener('click', function() {
<script>
document.getElementById('save-viewerstate').addEventListener('click', function() {
let viewerState = window.exportViewerState_root();
if (!viewerState) {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
return;
}
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -44,9 +45,9 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
});
});
document.getElementById('reset-viewerstate').addEventListener('click', function() {
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
});
document.getElementById('reset-viewerstate').addEventListener('click', function() {
fetch({% if case_number %}"{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk case_number %}"{% else %}"{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}"{% endif %}, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -62,7 +63,7 @@ document.getElementById('reset-viewerstate').addEventListener('click', function(
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
});
});
</script>
});
</script>
{% endblock %}
@@ -4,106 +4,28 @@
{% block content %}
<div>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_priors" %}
{% if previous %}
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
{% endif %}
<h2 class="mb-3">Managing Priors for Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}" class="text-decoration-none">{{casedetail.case.title}}</a></h2>
<div class="card mb-4">
<div class="card-body">
<p class="mb-1">This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
<p class="mb-0">It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
</div>
</div>
<h3 class="mb-3">Available priors</h3>
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
<p>This page allows you to add series from prior cases to a case in a collection. In order to be available here the cases must be linked on the system.</p>
<p>It is necessary to give a description of how the cases are related that will be displayed to the user when viewing the case.</p>
<h3>Available priors</h3>
<ul>
<div class="row g-3">
{% for case, added, relation, visibility in available_priors %}
<li class='
{% if added %}
added
{% else %}
not-added
{% endif %}
' data-case={{case.id}}>
{{case.title}} / {{added}} / {{relation}} / <a href="{% url 'atlas:case_detail' case.id %}">View case</a>
<br/>{{case.get_series_blocks|safe}}
<br/>
{% if not added %}
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
hx-target="#htmx-response-{{case.id}}"
_="on htmx:afterRequest log 'test' then remove me">
{% csrf_token %}
<input type="hidden" name="prior_case_id" value="{{case.id}}" />
<input type="text" name="relation" value="{{relation}}" placeholder="relation to case" />
<br />
<label for="prior_visibility_{{case.id}}">Visibility:</label>
<select name="prior_visibility" id="prior_visibility_{{case.id}}">
<option value="AL"
{% if visibility == 'AL' %}
selected
{% endif %}
>Always</option>
<option value="RE"
{% if visibility == 'RE' %}
selected
{% endif %}
>Review</option>
<option value="NO"
{% if visibility == 'NO' %}
selected
{% endif %}
>None</option>
</select>
<br />
<button class="btn primary">Add to case</button>
</form>
{% else %}
<form hx-post="{% url 'atlas:collection_case_priors' collection.id case_detail.case.pk %}"
hx-target="#htmx-response-{{case.id}}"
_="on htmx:afterRequest log 'test' then remove me">
Case available: {{relation}}
<input type="hidden" name="remove" value="{{case.id}}" />
<br />
<label for="prior_visibility_{{case.id}}">Visibility:</label>
<select name="prior_visibility" id="prior_visibility_{{case.id}}" disabled>
<option value="AL"
{% if visibility == 'AL' %}
selected
{% endif %}
>Always</option>
<option value="RE"
{% if visibility == 'RE' %}
selected
{% endif %}
>Review</option>
<option value="NO"
{% if visibility == 'NO' %}
selected
{% endif %}
>None</option>
</select>
<br />
<button class="btn primary">Remove from case</button>
</form>
{% endif %}
<span id="htmx-response-{{case.id}}"></span>
</li>
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
{% empty %}
<div class="col-12">
<div class="alert alert-info">No available prior cases found for the case.</div>
</div>
{% endfor %}
</ul>
</div>
@@ -4,21 +4,11 @@
{% block content %}
<div>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_questions" %}
{% if previous %}
<a href="{% url 'atlas:collection_case_questions' collection.id previous.id %}">Previous question</a>
{% endif %}
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
{% if next %}
<a href="{% url 'atlas:collection_case_questions' collection.id next.id %}">Next question</a>
{% endif %}
</div>
<h2>Case: <a href="{% url 'atlas:case_detail' casedetail.case.pk %}">{{casedetail.case.title}}</a></h2>
<h2>Case: <a href="{% url 'atlas:case_detail' case_detail.case.pk %}">{{case_detail.case.title}}</a></h2>
{% if case_detail.question_schema is not None and not case_detail.question_answers %}
{% if casedetail.question_schema is not None and not casedetail.question_answers %}
<div class="alert alert-danger" role="alert">
@@ -42,7 +32,7 @@
Import schema from:
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
hx-get="{% url 'atlas:collection_question_schemas' case_detail.collection.id %}"
hx-get="{% url 'atlas:collection_question_schemas' casedetail.collection.id %}"
hx-target=".offcanvas-body"
>Collection questions</button>
<button class="btn btn-primary btn-sm" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasRight" aria-controls="offcanvasRight"
@@ -54,7 +44,7 @@
Question Block Title
</summary>
<p>Title of the question block.</p>
<input id="question_block_title_input" type="text" name="title" value="{{case_detail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
<input id="question_block_title_input" type="text" name="title" value="{{casedetail.question_schema.title}}" class="form-control" placeholder="Question title" aria-label="Question title">
</details>
<details><summary>Modify Questions</summary>
<div id="questions-container" style="margin-top: 20px;">
@@ -123,7 +113,7 @@
</form> {% endcomment %}
{% if case_detail.question_schema %}
{% if casedetail.question_schema %}
<h2>Question display</h2>
This shows the form as it will be displayed (with the correct answers). If you wish to edit a correct answer you can change it below.
@@ -2,8 +2,11 @@
{% load crispy_forms_tags %}
{% block content %}
<span class="collection-name-blend">Collection: {{collection}}</span>
<h2>Case {{case_number|add:1}}
<div class="mb-2"><small class="text-muted">Collection: <strong>{{collection}}</strong></small></div>
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="h4 mb-0">Case {{case_number|add:1}}
{% if show_title %}
: {{case.title}}
@@ -11,19 +14,36 @@
{% if question_completed %}
<span class="stamp-white">REVIEW</span>
<span class="badge bg-warning text-dark ms-2 small">REVIEW</span>
{% endif %}
</h2>
</div>
<div class="d-flex align-items-center ms-3">
<div class="dropdown">
<a class="btn btn-outline-secondary btn-sm dropdown-toggle" href="#" role="button" id="caseJump" data-bs-toggle="dropdown" aria-expanded="false"
hx-get="{% url 'atlas:collection_case_jump_partial' pk=collection.pk %}?current={{case_number}}{% if cid %}&cid={{cid}}&passcode={{passcode}}{% endif %}"
hx-target="#case-jump-menu"
hx-swap="innerHTML"
hx-trigger="click">
Jump
</a>
<div class="dropdown-menu dropdown-menu-end" id="case-jump-menu">
<div class="px-3 py-2 text-muted">Loading...</div>
</div>
</div>
</div>
</div>
{% if not question_completed and collection.question_time_limit is not None %}
<div id="question-timer-block" style="margin-top:8px; margin-bottom:8px;" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
<strong>Time remaining:</strong>
<span id="question-timer" aria-live="polite">&nbsp;</span>
<div id="question-timer-progress" style="display:inline-block; vertical-align: middle; width: 200px; margin-left:12px;">
<div id="question-timer-progress-outer" style="background:#e9ecef; border-radius:6px; height:10px; overflow:hidden;">
<div id="question-timer-progress-inner" style="width:100%; height:100%; background:var(--timer-color, #28a745);"></div>
<div id="question-timer-block" class="mb-3" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
<div class="d-flex align-items-center gap-3">
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite">&nbsp;</span></div>
<div style="flex:1; max-width:320px;">
<div class="progress" style="height:10px;">
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
</div>
</div>
</div>
<div id="timer-htmx-target" style="display:none;"></div>
@@ -38,12 +58,10 @@
{% if not question_completed %}
{% if resources %}
<h5>Resources</h4>
<ul class="no-list-style">
<h5 class="mt-3">Resources</h5>
<ul class="list-group list-group-flush mb-3">
{% for caseresource in resources %}
<li>
{{caseresource.resource.get_display}}
</li>
<li class="list-group-item py-1 small">{{caseresource.resource.get_display}}</li>
{% endfor %}
</ul>
{% endif %}
@@ -51,14 +69,21 @@
{% if show_description and case.description %}
<div>
Description: {{case.description}}
<div class="mb-3">
<h6 class="mb-1">Description</h6>
<p class="mb-0">{{case.description}}</p>
</div>
{% endif %}
{% if show_history %}
<div>
History: {{case_detail.get_history_pre|linebreaks}}
<div class="card mb-3">
<div class="card-body p-2 d-flex justify-content-between align-items-start">
<div class="me-3 small text-muted">History: {{casedetail.get_history_pre|linebreaks}}</div>
{% if has_priors %}
<div class="ms-2">
<span title="This case has priors available for comparison" class="badge bg-secondary text-white rounded-pill small">Priors{% if prior_count %} ({{ prior_count }}){% endif %}</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
@@ -66,9 +91,9 @@
<div>
{% if question_completed %}
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe>
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe>
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
</div>
@@ -78,9 +103,9 @@
{% if collection.show_ohif_viewer_link %}
<div>
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
@@ -92,7 +117,7 @@
<div id="main_viewer" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
data-auto-cache-stack="false"
data-named-stacks='{{case_detail.get_case_named_stacks}}'
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
@@ -131,28 +156,24 @@
{% endif %}
{% if show_discussion and case.discussion %}
<p>
<details>
<summary>
Discussion:
<details class="card mb-3">
<summary class="card-header p-2" style="cursor: pointer;">
<strong>Discussion</strong>
</summary>
<div>
<div class="card-body p-2 small">
{{case.discussion|linebreaks}}
</div>
</details>
</p>
{% endif %}
{% if show_report and case.report %}
<p>
<details>
<summary>
Report:
<details class="card mb-3">
<summary class="card-header p-2" style="cursor: pointer;">
<strong>Report</strong>
</summary>
<div>
<div class="card-body p-2 small">
{{case.report|linebreaks}}
</div>
</details>
</p>
{% endif %}
{% if question_completed %}
@@ -246,25 +267,28 @@
{% endif %}
{% endif %}
<div class="mb-2">
{% if previous %}
<button type="submit" name="previous" class="save btn btn-default">Previous</button>
<button type="submit" name="previous" class="btn btn-outline-secondary btn-sm me-2">Previous</button>
{% endif %}
{% if next %}
<button type="submit" name="next" class="save btn btn-default">Next</button>
<button type="submit" name="next" class="btn btn-primary btn-sm me-2">Next</button>
{% elif collection.collection_type == "REP" %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="save btn btn-default">Save</button>
<button type="submit" name="save" class="btn btn-primary btn-sm me-2">Save</button>
{% endif %}
{% endif %}
</div>
{% if collection.self_review and not collection.feedback_once_collection_complete and not question_completed %}
<button type="submit" name="complete_case" class="save btn btn-default">Finish Case</button>
<button type="submit" name="complete_case" class="btn btn-success btn-sm">Finish Case</button>
{% endif %}
<br />
<button type="submit" name="finish" class="save btn btn-default">Overview</button>
<div class="mt-3">
<button type="submit" name="finish" class="btn btn-outline-secondary btn-sm">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div>
</form>
<style>
label {
@@ -3,10 +3,10 @@
{% block content %}
<h2>Case: {{ case.title }}</h2>
{{ case_detail.question_schema }}
<h2>Case: {{ case.title }}</h2>
{{ casedetail.question_schema }}
{{ case.question_answers }}
{{ case.question_answers }}
{% endblock %}
+98 -83
View File
@@ -4,139 +4,154 @@
{% partialdef casedetails-management-links %}
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-display" title="Setup default display"></i></a>
<div class="btn-toolbar" role="toolbar" aria-label="Case management links">
{% include 'atlas/partials/casedetails_management_links.html' %}
</div>
{% if casedetail.default_viewerstate %}
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
{% endif %}
)
(<a href="{% url 'atlas:collection_case_details' casedetail.collection.pk casedetail.case.pk %}"><i class="bi bi-info-square" title="Case details"></i></a>)
{% if collection.collection_type == "QUE" %}
(<a href='{% url "atlas:collection_case_questions" casedetail.collection.pk casedetail.case.pk %}'>
{% if casedetail.question_schema %}
<i class="bi bi-question-square text-success" title="This case has questions defined."></i>
{% else %}
<i class="bi bi-question-square text-danger" title="This case has no questions defined."></i>
{% endif %}
</a>
)
{% endif %}
{% if casedetail.case.previous_case %}
(<a href='{% url "atlas:collection_case_priors" casedetail.collection.pk casedetail.case.pk %}'>
<i class="bi bi-link-45deg" title="Manage priors"></i></a>)
{% endif %}
{% endpartialdef %}
{% block content %}
<h2>{{collection.name}}</h2>
<div class="container">
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<h2 class="card-title mb-2">{{ collection.name }}</h2>
{% include 'exam_notes.html' %}
<div>
Exam mode: {{collection.exam_mode}} (open access: {{collection.exam_open_access}})<br />
<p class="mb-1">
<span class="badge bg-info text-dark me-2">{{ collection.exam_mode }}</span>
<small class="text-muted">(open access: {{ collection.exam_open_access }})</small>
</p>
<p class="mb-2">
{% include "generic/partials/exams/exam_status.html#publish-results" %}
{% include "generic/partials/exams/exam_status.html#exam-active" %}
Collection Type: {{collection.get_collection_type_display}}<br />
Self review: {{collection.self_review}}<br />
Open access: {{collection.open_access}}<br />
</p>
<p class="mb-1">
<span class="badge bg-secondary me-2">{{ collection.get_collection_type_display }}</span>
<span class="badge bg-light text-dark me-2">Self review: {{ collection.self_review }}</span>
{% if collection.open_access %}
<span class="badge bg-success">Open</span>
{% else %}
<span class="badge bg-warning text-dark">Restricted</span>
{% endif %}
</p>
{% if collection.prerequisites.exists %}
Prerequisite collections:
<ul>
<div class="mt-3 mb-2">
<strong>Prerequisites</strong>
<ul class="mb-0">
{% for prereq in collection.prerequisites.all %}
<li>
<a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a>
&nbsp;
</li>
<li><a href="{% url 'atlas:collection_detail' prereq.pk %}">{{ prereq.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<p>
<div class="col-md-4 d-flex flex-column align-items-md-end mt-3 mt-md-0">
<div class="mb-2 w-100 text-md-end">
{% if collection.collection_type == "VIV" %}
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
{% else %}
<div class="d-inline-block">
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
<button
_="on click
writeText(#take-url-link's href) on navigator.clipboard
put 'copied url!' into me
wait 1s
put 'copy link' into me"
class="btn btn-sm btn-outline-secondary ms-1">Copy link</button>
</div>
{% endif %}
</div>
{% if collection.prerequisites.exists %}
<div class="w-100 text-md-end">
<button
title="Sync users from all prerequisite collections into this collection"
hx-post="{% url 'atlas:collection_sync_prerequisite_users' collection.pk %}"
hx-swap="outerHTML"
class="btn btn-sm btn-secondary"
>Sync prerequisite users</button>
</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<h3 class="mb-3">Cases</h3>
{% if collection.collection_type == "VIV" %}
<p><a href='{% url "atlas:collection_viva" collection.pk %}'><button class="btn btn-primary">Start Viva</button></a>
{% else %}
<p>This collection will be available to view/take <a href='{{collection.get_take_url}}' id="take-url">here</a>{% if not collection.active %} (when active){% endif %} <button
_="on click
writeText(#take-url's href) on navigator.clipboard
put 'copied url!' into me
wait 1s
put 'copy url' into me"
class="btn btn-sm">copy link</button>
<p>Review collection <a href='{% url "atlas:collection_viva" collection.pk %}'>here</a>
{% endif %}
<h3>Cases</h3>
<ol id="full-question-list" class="sortable">
<ol id="full-question-list" class="sortable list-group list-group-numbered">
{% for casedetail in casesdetails %}
<li data-question_pk={{casedetail.case.pk}}>
<a href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
:
{% if casedetail.case.series.count == 0 %}
&nbsp;<i class="bi bi-exclamation-circle text-warning" title="No series attached to this case"></i>&nbsp;
{% endif %}
{{casedetail.case.title}}
{% partial casedetails-management-links %}
</li>
{% include 'atlas/partials/case_list_item.html' %}
{% endfor %}
</ol>
</div>
</div>
{% if can_edit %}
<p><button id='button-edit-order' title='click and drag questions to change order' data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}">Edit case order / Remove cases</button></p>
<p><button id='button-add-case'
<div class="row mt-4">
<div class="col-12 d-flex gap-2">
<button id='button-edit-order'
title='click and drag questions to change order'
data-posturl="{% url 'atlas:exam_json_edit' pk=collection.pk %}"
class="btn btn-sm btn-outline-secondary">
Edit case order / Remove cases
</button>
<button id='button-add-case'
title='click to add a case to this collection'
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list">
hx-get="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-swap="innerHTML" hx-target="#case-list"
class="btn btn-sm btn-secondary">
Add case
</button>
<div id='case-list'></div>
</p>
<div id='case-list'></div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<details>
<summary>Answer management</summary>
<div class="alert alert-danger">
<p>Manage answers for this collection. </p>
Please note these are permanant and cannot be undone.
<div class="alert alert-danger mt-3">
<p class="mb-1">Manage answers for this collection.</p>
<small class="text-muted">Please note these are permanent and cannot be undone.</small>
</div>
<div class="d-flex gap-2">
<button title="This will clear all user answers and attempts"
hx-post="{% url 'atlas:collection_reset_answers' collection.pk %}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to reset all answers? This action cannot be undone."
class="btn btn-danger btn-sm"
>Reset all answers</button>
<button title="This will specific user answers and attempts"
hx-post="{% url 'atlas:collection_reset_answers_user_list' collection.pk %}"
hx-swap="innerHTML"
hx-target="#user-list"
>Reset user answers</button>
<div id="user-list"></div>
class="btn btn-warning btn-sm">
Reset user answers</button>
</div>
<div id="user-list" class="mt-3"></div>
</details>
</div>
</div>
{% endif %}
{% include 'exam_overview_js.html' %}
</div>
{% endblock %}
@@ -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_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:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
</ul>
<ul class="navbar-nav ms-auto">
@@ -32,6 +31,7 @@
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:atlas_create_exam' pk=collection.pk %}">Add New Case</a></li>
{% endif %}
</ul>
</li>
@@ -2,9 +2,9 @@
{% block content %}
<div class="vstack gap-3">
{% for case_detail, answers in case_map.items %}
{% for casedetail, answers in case_map.items %}
<div class="border border-dark">
<h4>Case: {{case_detail.case.title}}</h4>
<h4>Case: {{casedetail.case.title}}</h4>
Answers
<ul>
{% for answer in answers %}
@@ -3,15 +3,15 @@
{% comment %} <script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
<ol>
{% for case_detail in case_details %}
{% for casedetail in casedetails %}
<li id="case-detail-{{case_detail.pk}}"{% if case_detail.question_schema %} class="cases"{% endif %}>
<h2>Case: {{ case_detail.case.title }}</h2>
<li id="case-detail-{{casedetail.pk}}"{% if casedetail.question_schema %} class="cases"{% endif %}>
<h2>Case: {{ casedetail.case.title }}</h2>
{% if case_detail.question_schema %}
{% if casedetail.question_schema %}
<button class="btn btn-sm btn-secondary use-layout">Use schema</button>
<details><summary>Schema:</summary>
<div class="schema" data-schema='{{ case_detail.get_question_schema }}'>{{ case_detail.question_schema }}</div>
<div class="schema" data-schema='{{ casedetail.get_question_schema }}'>{{ casedetail.question_schema }}</div>
</details>
<div class="form"></div>
{% else %}
@@ -0,0 +1,22 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content %}
<div class="container-fluid">
<div class="d-flex align-items-start justify-content-between mb-3">
<h2 class="mb-0">Collections</h2>
{% if request.user.is_staff %}
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
{% endif %}
</div>
<p class="mb-3">Manage and browse case collections. Use filters to narrow results.</p>
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div>
{% endblock %}
+14 -14
View File
@@ -69,7 +69,7 @@
</div>
{# Hidden per-case question snippet to inject when case is loaded #}
<div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with case_detail=casedetail %}
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div>
{% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary>
@@ -212,14 +212,14 @@
let c = this.closest(".case-item");
console.log('clicked local', c)
console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson);
casedetails = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
@@ -239,15 +239,15 @@
let c = this;
console.log('clicked local', c)
console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson);
casedetails = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
let annotations = JSON.parse(c.dataset.annotations) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
@@ -302,15 +302,15 @@
let c = $(this).closest(".case-item");
case_details = c.data('casejson');
console.log(case_details);
casedetails = c.data('casejson');
console.log(casedetails);
$('#loading-case').show()
$("#open-viewer").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.data('title'));
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
console.log($(this).data('type'))
if ($(this).data('type') == 'case') {
+4 -6
View File
@@ -18,7 +18,7 @@
<h5 class="mb-2">Details</h5>
<dl class="row">
<dt class="col-sm-4">Primary name</dt>
<dd class="col-sm-8">{{ condition.primary }}</dd>
<dd class="col-sm-8">{% if condition.canonical %}No (alias of <a href="{{ condition.canonical.get_absolute_url }}">{{ condition.canonical.name }}</a>){% else %}Yes{% endif %}</dd>
<dt class="col-sm-4">Subspecialty</dt>
<dd class="col-sm-8">
@@ -31,11 +31,9 @@
<dt class="col-sm-4">Synonyms</dt>
<dd class="col-sm-8">
{% for syn in condition.synonym.all %}
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
{% empty %}
&mdash;
{% endfor %}
<div id="synonyms-container">
{% include 'atlas/partials/_synonyms_list.html' %}
</div>
</dd>
<dt class="col-sm-4">Parent</dt>
+9 -16
View File
@@ -1,27 +1,20 @@
{% extends "atlas/base.html" %}
<!-- {% load static from static %} -->
{% load crispy_forms_tags %}
{% block css %}{% endblock %}
{% block css %}
{% endblock %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
{{form.media}}
<script type="text/javascript">
</script>
<!-- {{ form.media }} -->
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Add/Edit Condition</h2>
<h2>Add / Edit Condition</h2>
<p>Use this form to create or edit a condition.</p>
<p>Please check if it already <a href='{% url "atlas:condition_view" %}'>exists</a> before doing so!</p>
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
{% csrf_token %}
<table>
{{ form.as_table }}
</table>
<input type="submit" class="submit-button" value="Submit" name="submit">
{% crispy form %}
</form>
{% endblock %}
+2 -2
View File
@@ -12,8 +12,8 @@
</div>
<div>
<h3>Name: {{finding.name}}</h3>
Primary name: {{finding.primary}}<br />
Synonyms: {{finding.synonym.all|join:", "}}<br />
Primary name: {% if finding.canonical %}No (alias of <a href="{{ finding.canonical.get_absolute_url }}">{{ finding.canonical.name }}</a>){% else %}Yes{% endif %}<br />
Synonyms: {{ finding.get_synonym_link|safe }}<br />
</div>
{% if finding.seriesfinding_set.all %}
<h4>Associated Cases</h4>
@@ -0,0 +1,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 %}
&mdash;
{% 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 %}">&larr; 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 &rarr;</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> &mdash; <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 %}
&nbsp;&nbsp;<span class="text-success">Questions defined</span>
{% endif %}
</div>
</div>
<div class="d-flex align-items-center ms-3">
{% include 'atlas/partials/casedetails_management_links.html' %}
</div>
</li>
@@ -0,0 +1,45 @@
{# Search results #}
{% if q %}
{% if cases and cases.exists %}
<div class="list-group mb-2">
{% for case in cases %}
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
<div class="flex-fill me-3">
<div class="fw-semibold">{{ case.title }}</div>
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
</div>
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
{% csrf_token %}
<input type="hidden" name="case" value="{{ case.pk }}">
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small mb-2">No cases found.</div>
{% endif %}
{% endif %}
{# Recent cases quick-add (rendered below search results) #}
{% if recent_cases and recent_cases|length > 0 %}
<div>
<div class="small text-muted mb-1">Recently created</div>
<div class="list-group">
{% for case in recent_cases %}
<div class="list-group-item d-flex justify-content-between align-items-center" data-case-pk="{{ case.pk }}">
<div class="flex-fill me-3">
<div class="fw-semibold">{{ case.title }}</div>
<div class="small text-muted">{% if case.description %}{{ case.description|truncatechars:100 }}{% endif %}</div>
</div>
<form class="m-0" hx-post="{% url 'atlas:add_case_to_collection' collection.pk %}" hx-target="#full-question-list" hx-swap="beforeend">
{% csrf_token %}
<input type="hidden" name="case" value="{{ case.pk }}">
<button type="submit" class="btn btn-sm btn-outline-primary">Add</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
@@ -0,0 +1,46 @@
<div class="case-search-widget">
<label for="case-search-input" class="form-label">Search cases</label>
<input id="case-search-input" name="q" class="form-control" type="search"
placeholder="Type to search cases..."
hx-get="{% url 'atlas:case_search' %}"
hx-include="#case-search-input"
hx-vals='{"collection": "{{ collection.pk }}"}'
hx-target="#case-search-results"
hx-trigger="keyup changed delay:400ms"
autocomplete="off">
<div id="case-search-results" class="mt-2">
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection %}
</div>
</div>
<script>
(function(){
// Robust fallback: ensure repeated searches always work by using htmx.ajax when available.
try {
var input = document.getElementById('case-search-input');
if (!input) return;
var timer = null;
input.addEventListener('input', function(){
if (timer) clearTimeout(timer);
timer = setTimeout(function(){
try {
var q = encodeURIComponent(input.value || '');
var url = "{% url 'atlas:case_search' %}?q=" + q + "&collection={{ collection.pk }}";
if (window.htmx && typeof window.htmx.ajax === 'function') {
window.htmx.ajax('GET', url, { target: '#case-search-results', swap: 'innerHTML' });
} else {
// fallback to fetch
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
var el = document.getElementById('case-search-results');
if (el) el.innerHTML = html;
}).catch(function(e){ console.error('case search fetch error', e); });
}
} catch(e) { console.error(e); }
}, 400);
});
} catch(e) { console.error('case search widget init error', e); }
})();
</script>
@@ -0,0 +1,42 @@
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
<i class="bi bi-display"></i>
</a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details">
<i class="bi bi-info-square"></i>
</a>
{% if collection.collection_type == "QUE" %}
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions">
{% if casedetail.question_schema %}
<i class="bi bi-question-square text-success"></i>
{% else %}
<i class="bi bi-question-square text-danger"></i>
{% endif %}
</a>
{% endif %}
{% if casedetail.case.previous_case %}
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors">
<i class="bi bi-link-45deg"></i>
</a>
{% endif %}
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
{% if casedetail.default_viewerstate %}
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
<i class="bi bi-check"></i>
</button>
{% endif %}
</div>
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
{% if can_edit %}
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk casedetail.collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?">
<button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
@@ -1,10 +1,10 @@
{# Partial: render questions and answers as HTML. #}
{% if case_detail.question_schema %}
{% if casedetail.question_schema %}
<div class="collection-question-block">
<h4>Questions</h4>
<dl class="row">
{% for name, prop in case_detail.question_schema.properties.items %}
{% for name, prop in casedetail.question_schema.properties.items %}
<dt class="col-sm-4">{{ prop.title|default:name }}</dt>
<dd class="col-sm-8">
{% if prop.description %}
@@ -18,8 +18,8 @@
{% endif %}
{# Safely fetch the stored/example answer using the project's `get_item` filter. #}
{% if case_detail.question_answers %}
{% with correct=case_detail.question_answers|get_item:name %}
{% if casedetail.question_answers %}
{% with correct=casedetail.question_answers|get_item:name %}
<div class="mt-2">
<strong>Example / Correct:</strong>
{% if correct %}
@@ -37,7 +37,7 @@
{% if user_answer %}
{% if user_answer|get_item:name %}
{% with ua=user_answer|get_item:name %}
{% with correct=case_detail.question_answers|get_item:name %}
{% with correct=casedetail.question_answers|get_item:name %}
<div class="mt-1">
<strong>Your answer:</strong>
<span class="ms-2 {% if ua == correct %}text-success{% else %}text-danger{% endif %}">{{ ua }}</span>
@@ -0,0 +1,31 @@
{% load static %}
<div class="series-images-list">
{% for image in images %}
<div class="d-flex align-items-center mb-2">
{% if image.image %}
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" target="_blank" class="me-2">
<img src="{{ image.image.url }}" alt="image-{{ image.pk }}" style="width:96px;height:auto;object-fit:cover;border-radius:.25rem;" class="img-thumbnail">
</a>
{% else %}
<div class="me-2 bg-secondary" style="width:96px;height:72px;border-radius:.25rem;"></div>
{% endif %}
<div class="flex-grow-1">
<div><strong>{{ image.image.name|default:"(no file)" }}</strong></div>
{% if image.image %}
<div class="small text-muted">{{ image.image.size|filesizeformat }}</div>
{% endif %}
{% if image.image_blake3_hash %}
<div class="small text-muted">hash: {{ image.image_blake3_hash|truncatechars:12 }}</div>
{% endif %}
</div>
<div class="ms-auto text-end">
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" class="btn btn-sm btn-outline-secondary">View DICOM</a>
</div>
</div>
{% empty %}
<p class="text-muted">No images found for this series.</p>
{% endfor %}
</div>
@@ -0,0 +1,23 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block js %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Add / Edit Pathological process</h2>
<p>Use this form to create or edit a pathological process.</p>
<p>Please check if it already <a href='{% url "atlas:pathological_process_view" %}'>exists</a> before doing so!</p>
<form action="" method="post" enctype="multipart/form-data" id="pathological-process-form">
{% csrf_token %}
{% crispy form %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary ms-2" href="{% url 'atlas:pathological_process_view' %}">Cancel</a>
</div>
</form>
{% endblock %}
@@ -0,0 +1,23 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block js %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Add / Edit Presentation</h2>
<p>Use this form to create or edit a presentation.</p>
<p>Please check if it already <a href='{% url "atlas:presentation_view" %}'>exists</a> before doing so!</p>
<form action="" method="post" enctype="multipart/form-data" id="presentation-form">
{% csrf_token %}
{% crispy form %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary ms-2" href="{% url 'atlas:presentation_view' %}">Cancel</a>
</div>
</form>
{% endblock %}
@@ -18,22 +18,8 @@
</div>
</div>
</div>
{% if collection %}
<div class="text-end mb-2">
<div class="small text-muted">Collection</div>
<div class="mb-1">
{% include "atlas/collection_detail.html#casedetails-management-links" %}
</div>
<div class="small">
{% if previous %}
<a class="me-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous</a>
{% include 'atlas/partials/_viewing_case_as_part_of_collection.html' with nav_link_view="atlas:collection_case_view" %}
{% endif %}
Viewing question as part of a collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{ collection.name }}</a>
[{{ case_number|add:1 }} / {{ collection_length }}]
{% if next %}
<a class="ms-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
+35 -11
View File
@@ -1,7 +1,7 @@
{% load crispy_forms_tags %}
{% 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="col-12 mb-3">
<div class="card shadow-sm">
@@ -49,7 +49,6 @@
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
</div>
</div>
<div class="mt-3">
<!-- Finding form moved below the viewport -->
<div class="card mt-3">
@@ -114,6 +113,31 @@
</div>
</details>
</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 class="col-md-4 mb-3">
@@ -190,17 +214,17 @@
{% endif %}
</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-content"></div>
</div>
</div>
</div>
<script>
<script>
$(document).ready(function () {
const apiKey = "root"
@@ -331,7 +355,7 @@
}
});
});
</script>
</script>
{% endwith %}
@@ -402,14 +426,14 @@
transition: background 0.2s, font-size 0.2s;
}
details.styled-detail:not([open]) {
details.styled-detail:not([open]) {
padding-bottom: 0;
min-height: 0;
}
}
details.styled-detail:not([open]) > *:not(summary) {
details.styled-detail:not([open]) > *:not(summary) {
display: none;
}
}
details.styled-detail:not([open]) > summary {
padding: 0.7em 1.2em 0.3em 1.2em;
+2 -2
View File
@@ -14,8 +14,8 @@
</div>
<div>
<h3>Name: {{structure.name}}</h3>
Primary name: {{structure.primary}}<br />
Synonyms: {{structure.synonym.all|join:", "}}<br />
Primary name: {% if structure.canonical %}No (alias of <a href="{{ structure.canonical.get_absolute_url }}">{{ structure.canonical.name }}</a>){% else %}Yes{% endif %}<br />
Synonyms: {{ structure.get_synonym_link|safe }}<br />
</div>
<h4>Associated Cases</h4>
<ul>
@@ -0,0 +1,23 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block js %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>Add / Edit Subspecialty</h2>
<p>Use this form to create or edit a subspecialty.</p>
<p>Please check if it already <a href='{% url "atlas:subspecialty_view" %}'>exists</a> before doing so!</p>
<form action="" method="post" enctype="multipart/form-data" id="subspecialty-form">
{% csrf_token %}
{% crispy form %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-secondary ms-2" href="{% url 'atlas:subspecialty_view' %}">Cancel</a>
</div>
</form>
{% endblock %}
+8 -2
View File
@@ -7,7 +7,14 @@
{% block content %}
<div class="container-fluid">
<h2>My Collections</h2>
<div class="d-flex align-items-start justify-content-between">
<h2 class="mb-0">{{ model_verbose_name_plural|capfirst }}</h2>
{% if request.user.is_staff and create_url %}
<div>
<a class="btn btn-sm btn-primary" href="{{ create_url }}">Add new {{ model_verbose_name }}</a>
</div>
{% endif %}
</div>
{% comment %} <details>
<summary>
<h4>Filter</h4>
@@ -19,7 +26,6 @@
</form>
</div>
</details> {% endcomment %}
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div>
+68 -6
View File
@@ -34,6 +34,7 @@ urlpatterns = [
path("author/", views.author_list, name="author_list"),
path("case/", views.CaseView.as_view(), name="case_view"),
path("collection/<int:collection_id>/add_case", views.add_case_to_collection, name="add_case_to_collection"),
path("collection/case_search", views.case_search, name="case_search"),
path("collection/", views.CollectionView.as_view(), name="collection_view"),
path("collection/user", views.user_collections, name="user_collections"),
path("uploads", views.user_uploads, name="user_uploads"),
@@ -111,6 +112,11 @@ urlpatterns = [
views.CollectionCaseUpdate.as_view(),
name="collection_case_update",
),
path(
"collection/<int:pk>/case_jump",
views.collection_case_jump_partial,
name="collection_case_jump_partial",
),
path(
"collection/add_cases",
views.add_cases_to_collection,
@@ -167,30 +173,56 @@ urlpatterns = [
),
path("exam/<int:pk>/groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"),
path(
"collection/<int:exam_id>/case/<int:case_id>/details",
"collection/<int:exam_id>/case/<int:case_number>/details",
views.collection_case_details,
name="collection_case_details",
),
# Backwards-compatible redirect from legacy case_id-style URLs
path(
"collection/<int:exam_id>/case/<int:case_id>/questions",
"collection/<int:exam_id>/case/id/<int:case_id>/details",
views.redirect_collection_case_details_by_id,
name="collection_case_details_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/questions",
views.collection_case_questions,
name="collection_case_questions",
),
path(
"collection/<int:exam_id>/case/<int:case_id>/priors",
"collection/<int:exam_id>/case/id/<int:case_id>/questions",
views.redirect_collection_case_questions_by_id,
name="collection_case_questions_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/priors",
views.collection_case_priors,
name="collection_case_priors",
),
path(
"collection/<int:exam_id>/case/<int:case_id>/dicom_json",
"collection/<int:exam_id>/case/id/<int:case_id>/priors",
views.redirect_collection_case_priors_by_id,
name="collection_case_priors_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/dicom_json",
views.collection_case_dicom_json,
name="collection_case_dicom_json",
),
path(
"collection/<int:exam_id>/case/<int:case_id>/dicom_json/review",
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json",
views.redirect_collection_case_dicom_json_by_id,
name="collection_case_dicom_json_legacy",
),
path(
"collection/<int:exam_id>/case/<int:case_number>/dicom_json/review",
views.collection_case_dicom_json_review,
name="collection_case_dicom_json_review",
),
path(
"collection/<int:exam_id>/case/id/<int:case_id>/dicom_json/review",
views.redirect_collection_case_dicom_json_review_by_id,
name="collection_case_dicom_json_review_legacy",
),
path(
"collection/<int:exam_id>/question_schemas",
views.collection_question_schemas,
@@ -271,26 +303,52 @@ urlpatterns = [
views.collection_case_view,
name="collection_case_view",
),
# legacy path that used case PK; redirect to canonical case_number URL
path(
"collection/<int:collection_id>/<int:case_id>/display_setup",
"collection/<int:pk>/id/<int:case_id>",
views.redirect_collection_case_view_by_id,
name="collection_case_view_legacy",
),
path(
"collection/<int:collection_id>/<int:case_number>/display_setup",
views.collection_case_displaysetup,
name="collection_case_displaysetup",
),
path(
"collection/<int:collection_id>/id/<int:case_id>/display_setup",
views.redirect_collection_case_displaysetup_by_id,
name="collection_case_displaysetup_legacy",
),
path(
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
views.collection_case_view_take,
name="collection_case_view_take",
),
path(
"collection/<int:pk>/id/<int:case_id>/take/<int:cid>/<str:passcode>",
views.redirect_collection_case_view_take_by_id,
name="collection_case_view_take_legacy",
),
path(
"collection/<int:pk>/<int:case_number>/take/",
views.collection_case_view_take_user,
name="collection_case_view_take_user",
),
path(
"collection/<int:pk>/id/<int:case_id>/take/",
views.redirect_collection_case_view_take_user_by_id,
name="collection_case_view_take_user_legacy",
),
path(
"collection/<int:pk>/<int:case_number>/take/answers",
views.collection_case_view_take_user_answers,
name="collection_case_view_take_user_answers",
),
path(
"collection/<int:pk>/id/<int:case_id>/take/answers",
views.redirect_collection_case_view_take_user_answers_by_id,
name="collection_case_view_take_user_answers_legacy",
),
path(
"collection/<int:pk>/json_edit",
views.GenericExamViews.exam_json_edit,
@@ -370,6 +428,7 @@ urlpatterns = [
path(
"subspecialty/<int:pk>", views.subspecialty_detail, name="subspecialty_detail"
),
path("subspecialty/create", views.SubspecialtyCreate.as_view(), name="subspecialty_create"),
path("condition/create", views.ConditionCreate.as_view(), name="condition_create"),
path("finding/", views.FindingView.as_view(), name="finding_view"),
path("finding/<int:pk>", views.finding_detail, name="finding_detail"),
@@ -398,6 +457,7 @@ urlpatterns = [
# TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
path("series/", views.SeriesView.as_view(), name="series_view"),
@@ -543,6 +603,7 @@ urlpatterns = [
path(
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
),
path("presentation/create", views.PresentationCreate.as_view(), name="presentation_create"),
path(
"process/",
views.PathologicalProcessView.as_view(),
@@ -553,6 +614,7 @@ urlpatterns = [
views.pathological_process_detail,
name="pathological_process_detail",
),
path("process/create", views.PathologicalProcessCreate.as_view(), name="pathological_process_create"),
path("combine_series/", views.combine_series, name="combine_series"),
path(
"case/<int:case_id>/linked/",
+658 -137
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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;
}
}
+127
View File
@@ -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;
}
}
+122
View File
@@ -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"
+60
View File
@@ -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"
+69
View File
@@ -0,0 +1,69 @@
# Development override: point the web service to rad/.env.dev and map nginx to
# non-privileged host ports so you can run without sudo.
services:
web:
env_file:
- ../.env.dev
# Development: run Django's autoreloading development server instead of
# the production entrypoint/gunicorn. Mount the repository into the
# container so code edits on the host trigger Django's autoreload.
entrypoint: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# Clear any command set by the prod compose file (which would be passed
# as an extra argument to manage.py). Setting an empty command prevents
# the image `command` from being appended to the entrypoint.
command: []
volumes:
- ../:/usr/src/app:cached
# Mount the app log directory so logs are visible on the host at ./logs
- ../logs:/var/log/rad
nginx:
# Development nginx override: mount a simplified config that does not
# reference LetsEncrypt certs so the container can start without real
# certificates present on the host.
image: nginx:1.25-alpine
volumes:
- ../deploy/nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro
loki:
image: grafana/loki:2.8.2
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
volumes:
- ./loki-config/local-config.yaml:/etc/loki/local-config.yaml:ro
- ./loki-data:/loki
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
interval: 5s
timeout: 3s
retries: 6
promtail:
image: grafana/promtail:2.8.2
volumes:
- ../logs:/var/log/rad:ro
- ./promtail-config.yml:/etc/promtail/promtail-config.yml:ro
command: -config.file=/etc/promtail/promtail-config.yml
depends_on:
- loki
restart: unless-stopped
grafana:
image: grafana/grafana:10.2.0
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
restart: unless-stopped
volumes:
grafana-data:
loki-data:
@@ -29,7 +29,7 @@ services:
- 8000:8000
- 3459:3459
env_file:
- ./.env.dev
- ./.env.dev.local
db:
image: postgres:14.2-alpine
volumes:
+119
View File
@@ -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
+26
View File
@@ -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:
+43
View File
@@ -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"}}
+18
View File
@@ -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
+25
View File
@@ -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>
<h1 class="h4 mb-1">Exam: {{ exam }}</h1>
</div>
<div class="mt-2 mt-md-0">
{% comment %} <div class="mt-2 mt-md-0">
{% include "generic/exam_link_headers.html" %}
</div>
</div> {% endcomment %}
</div>
{% include 'exam_notes.html' %}
@@ -44,6 +44,8 @@
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">per question</span>)
</div>
<div class="mt-2 small text-muted">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
<div class="mt-2">
Exam mode: <strong>{{ exam.exam_mode }}</strong>
{% if exam.exam_mode %}
@@ -178,17 +180,7 @@
</div>
</div>
<div class="row">
<div class="col-12 col-md-6">
{% if exam.examcollection %}
<div class="small">Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
{% endif %}
<div class="small mt-1">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
</div>
<div class="col-12 col-md-6 text-md-end small mt-2 mt-md-0">
<span title="If true the results from this exam will be visible to supervisors automatically.">Supervisor visible: {{ exam.results_supervisor_visible }}</span>
</div>
</div>
{# Authors and supervisor visibility moved earlier in the header; duplicate block removed #}
</div>
{% block css %}
@@ -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
View File
@@ -14,19 +14,20 @@ router = Router()
class QuestionSchema(ModelSchema):
class Config:
class Meta:
model = Question
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
model_fields = "__all__"
# Use `fields` per newer ninja ModelSchema API
#fields = ["question", "history", "feedback", "normal", "laterality"]
fields = "__all__"
#model_exclude = ["answers"]
#exclude = ["answers"]
class ExamSchema(ModelSchema):
class Config:
class Meta:
model = Exam
model_fields = ["id", "name", "active", "publish_results"]
fields = ["id", "name", "active", "publish_results"]
@router.get('/')
def list_questions(request):
+23 -8
View File
@@ -2,12 +2,27 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a> /
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_overview' pk=exam.pk %}">Overview</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a></li>
</ul>
{% include 'generic/partials/exams/dropdown_menu.html'%}
</div>
</div>
</nav>
{% endblock %}
+6 -6
View File
@@ -15,19 +15,19 @@ router = Router()
class QuestionSchema(ModelSchema):
class Config:
class Meta:
model = Question
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
model_fields = "__all__"
# Use `fields` per newer ninja ModelSchema API
fields = "__all__"
#model_exclude = ["answers"]
#exclude = ["answers"]
class ExamSchema(ModelSchema):
class Config:
class Meta:
model = Exam
model_fields = ["id", "name", "active", "publish_results"]
fields = ["id", "name", "active", "publish_results"]
@router.get('/')
def list_questions(request):
+29
View File
@@ -48,3 +48,32 @@ class ExamMarkerForm(ExamMarkerFormMixin):
class ExamGroupsForm(ExamGroupsFormMixin):
class Meta(ExamGroupsFormMixin.Meta):
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)
+67 -36
View File
@@ -7,70 +7,101 @@
{% block content %}
{% 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 %}
<ol id="full-question-list-physics">
<div class="card">
<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 %}
<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>
{{ question.stem }}
<ol type="a" class="abcde">
<li class="question-a">
<span class="question-text">{{ question.a }}</span>: <span class="question-answer">{{ question.a_answer }}</span>
<div class="mb-2">
<ol type="a" class="mb-0">
<li class="d-flex justify-content-between align-items-start py-1">
<div><span class="me-2">a)</span><span>{{ question.a }}</span></div>
<div class="text-end">
<div class="text-primary fw-semibold">{{ question.a_answer }}</div>
{% if question.a_feedback %}
<span class="question-feedback">{{question.a_feedback}}</span>
<div class="text-muted small">Feedback: {{ question.a_feedback }}</div>
{% endif %}
</div>
</li>
<li class="question-b">
<span class="question-text">{{ question.b }}</span>: <span class="question-answer">{{ question.b_answer }}</span>
<li class="d-flex justify-content-between align-items-start py-1">
<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 %}
<span class="question-feedback">{{question.b_feedback}}</span>
<div class="text-muted small">Feedback: {{ question.b_feedback }}</div>
{% endif %}
</div>
</li>
<li class="question-c">
<span class="question-text">{{ question.c }}</span>: <span class="question-answer">{{ question.c_answer }}</span>
<li class="d-flex justify-content-between align-items-start py-1">
<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 %}
<span class="question-feedback">{{question.c_feedback}}</span>
<div class="text-muted small">Feedback: {{ question.c_feedback }}</div>
{% endif %}
</div>
</li>
<li class="question-d">
<span class="question-text">{{ question.d }}</span>: <span class="question-answer">{{ question.d_answer }}</span>
<li class="d-flex justify-content-between align-items-start py-1">
<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 %}
<span class="question-feedback">{{question.d_feedback}}</span>
<div class="text-muted small">Feedback: {{ question.d_feedback }}</div>
{% endif %}
</div>
</li>
<li class="question-e">
<span class="question-text">{{ question.e }}</span>: <span class="question-answer">{{ question.e_answer }}</span>
<li class="d-flex justify-content-between align-items-start py-1">
<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 %}
<span class="question-feedback">{{question.e_feedback}}</span>
<div class="text-muted small">Feedback: {{ question.e_feedback }}</div>
{% endif %}
</div>
</li>
</ol>
Category: {{ question.category }}, <a href="{% url 'physics:question_detail' pk=question.pk %}">View [id: {{question.pk}}]</a> <a
href="{% url 'admin:physics_question_change' question.id %}">Edit</a>
</div>
<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>
{% empty %}
<li class="list-group-item text-muted">No questions available.</li>
{% endfor %}
</ol>
{% endautoescape %}
</ul>
</div>
</div>
{% include 'exam_overview_js.html' %}
{% endblock %}
{% block css %}
<style>
.question-answer {
font-weight: bolder;
}
.question-feedback {
opacity: 50%;
float: right
}
.question-feedback::before {
content: "Feedback: ";
}
/* Small visual tweaks on top of Bootstrap */
.question-feedback { opacity: 0.7; }
.list-group-item ol { margin: 0; padding-left: 1.4rem; }
.list-group-item li { list-style-position: inside; }
</style>
{% endblock %}
-15
View File
@@ -58,21 +58,7 @@
- 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 {
--bg: #050607;
--text: #e6eef6;
@@ -86,7 +72,6 @@
--shadow: 0 2px 6px rgba(0,0,0,0.6);
}
body, .exam-take-container { background: var(--bg); color: var(--text); }
}
/* 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); }
+23 -7
View File
@@ -1,11 +1,27 @@
{% extends 'physics/base.html' %}
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'physics:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'physics:exam_review_start' pk=exam.pk %}">Review</a> /
{{block.super}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url '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 %}
@@ -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 %}
+2
View File
@@ -10,6 +10,8 @@ urlpatterns = []
urlpatterns.extend(
[
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("exam/<int:pk>/take_old", views.exam_take_old, name="exam_take_old"),
path(
+57 -1
View File
@@ -56,7 +56,14 @@ from generic.views import (
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 (
user_is_author_or_physics_checker,
user_is_exam_author_or_physics_checker,
@@ -534,6 +541,55 @@ class QuestionView(
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):
model = UserAnswer
+15
View File
@@ -0,0 +1,15 @@
{
"folders": [
{
"name": "rad",
"path": "."
}
],
"settings": {
"python.testing.pytestArgs": [
"."
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
}
+79
View File
@@ -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
View File
@@ -10,6 +10,36 @@ https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
import os
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")
+9 -9
View File
@@ -15,19 +15,19 @@ router = Router()
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_fields = ["question", "history", "feedback", "normal", "laterality"]
model_fields = "__all__"
#model_exclude = ["answers"]
#model_fields (old API) -> use `fields` per newer ninja ModelSchema API
#fields can be a list of field names or the string "__all__".
#fields = ["question", "history", "feedback", "normal", "laterality"]
fields = "__all__"
class ExamSchema(ModelSchema):
class Config:
class Meta:
model = Exam
model_fields = ["id", "name", "active", "publish_results"]
fields = ["id", "name", "active", "publish_results"]
@router.get('/')
def list_rapids(request):
+24 -9
View File
@@ -2,15 +2,30 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a> /
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %}
<a href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a> /
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %}
<a href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add New Question</a>
<a href="{% url 'rapids:exam_review_start' pk=exam.pk %}">Review</a>
<li class="nav-item"><a class="nav-link" href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add Question</a></li>
<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 %}
+5 -6
View File
@@ -15,19 +15,18 @@ router = Router()
class QuestionSchema(ModelSchema):
class Config:
class Meta:
model = Question
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
model_fields = "__all__"
fields = "__all__"
#model_exclude = ["answers"]
#exclude = ["answers"]
class ExamSchema(ModelSchema):
class Config:
class Meta:
model = Exam
model_fields = ["id", "name", "active", "publish_results"]
fields = ["id", "name", "active", "publish_results"]
@router.get('/')
def list_questions(request):
+22 -7
View File
@@ -2,11 +2,26 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'sbas:exam_overview' pk=exam.pk %}">Overview</a> /
<a href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'sbas:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'sbas:exam_review_start' pk=exam.pk %}">Review</a> /
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url '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 %}
+7
View File
@@ -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
+44
View File
@@ -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
View File
@@ -15,19 +15,19 @@ router = Router()
class QuestionSchema(ModelSchema):
class Config:
class Meta:
model = Question
#model_fields = ["question", "history", "feedback", "normal", "laterality"]
model_fields = "__all__"
# Use `fields` per newer ninja ModelSchema API
fields = "__all__"
#model_exclude = ["answers"]
#exclude = ["answers"]
class ExamSchema(ModelSchema):
class Config:
class Meta:
model = Exam
model_fields = ["id", "name", "active", "publish_results"]
fields = ["id", "name", "active", "publish_results"]
@router.get('/')
def list_questions(request):
+24 -9
View File
@@ -2,15 +2,30 @@
{% block navigation %}
{{block.super}}
<br/>
Exams: {{exam}}->
<a href="{% url 'shorts:exam_overview' pk=exam.pk %}">Overview</a> /
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mt-2 rounded">
<div class="container-fluid px-3">
<span class="navbar-text me-3 text-white">
<strong>Exam:</strong> {{ exam.title|default:exam.name|default:exam }}
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#examNav" aria-controls="examNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="examNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_overview' pk=exam.pk %}">Overview</a></li>
{% if exam.exam_mode %}
<a href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a> /
<a href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> /
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:mark_overview' pk=exam.pk %}">Mark</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_scores_all' pk=exam.pk %}">Scores</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_cids' exam_id=exam.pk %}">Candidates</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a></li>
{% endif %}
<a href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add New Question</a>
<a href="{% url 'shorts:exam_review_start' pk=exam.pk %}">Review</a>
<li class="nav-item"><a class="nav-link" href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add Question</a></li>
<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 %}
+402
View File
@@ -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
}
+54
View File
@@ -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"
+61 -3
View File
@@ -143,7 +143,8 @@
})
})
$("#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');
$("#full-question-list").addClass('sorting');
@@ -155,9 +156,29 @@
for (var i = ul.children.length; i >= 0; i--) {
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) => {
// Add up/down arrows at the start of each <li>
const upBtn = $("<button class='move-up btn btn-sm' title='Move up'>&#8593;</button>").click(function(e){
@@ -166,6 +187,7 @@
const prev = li.prev();
if (prev.length) {
prev.before(li);
renumber();
}
});
const downBtn = $("<button class='move-down btn btn-sm' title='Move down'>&#8595;</button>").click(function(e){
@@ -174,20 +196,27 @@
const next = li.next();
if (next.length) {
next.after(li);
renumber();
}
});
$(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($(
"<span class='exam-question-delete flex-col'><button title='Remove this question from the exam/collection'>Remove</button></span>"
).click(() => {
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($(
"<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(() => {
new_order = [];
$("#full-question-list li").each((n, el) => {
@@ -208,6 +237,21 @@
if (data.status == "success") {
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 () {
@@ -220,6 +264,20 @@
).click(() => {
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);
}
});
});