Compare commits
9
Commits
feb5a85af2
...
21de543313
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21de543313 | ||
|
|
a12a9e38f3 | ||
|
|
81dc5bf48f | ||
|
|
4abb57900d | ||
|
|
08c9138cdb | ||
|
|
fb833a2b90 | ||
|
|
2f1e2f6abe | ||
|
|
ffa717a9ef | ||
|
|
2dd9f4af32 |
@@ -40,6 +40,8 @@ from atlas.models import (
|
|||||||
Condition,
|
Condition,
|
||||||
Structure,
|
Structure,
|
||||||
Subspecialty,
|
Subspecialty,
|
||||||
|
Presentation,
|
||||||
|
PathologicalProcess,
|
||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
CaseDisplaySet,
|
CaseDisplaySet,
|
||||||
@@ -129,6 +131,18 @@ 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
|
||||||
|
self.helper.add_input(Submit("submit", "Submit"))
|
||||||
|
except Exception:
|
||||||
|
# If crispy not available or something goes wrong, don't break form
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CaseCollectionForm(ModelForm):
|
class CaseCollectionForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -333,6 +347,24 @@ class StructureForm(ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SubspecialtyForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Subspecialty
|
||||||
|
exclude = []
|
||||||
|
|
||||||
|
|
||||||
|
class PresentationForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Presentation
|
||||||
|
exclude = []
|
||||||
|
|
||||||
|
|
||||||
|
class PathologicalProcessForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = PathologicalProcess
|
||||||
|
exclude = []
|
||||||
|
|
||||||
|
|
||||||
class SeriesFindingForm(ModelForm):
|
class SeriesFindingForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SeriesFinding
|
model = SeriesFinding
|
||||||
@@ -1013,6 +1045,37 @@ class ConditionAutocomplete(ModelAutocomplete):
|
|||||||
model = Condition
|
model = Condition
|
||||||
search_attrs = ["name"]
|
search_attrs = ["name"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_items_from_keys(cls, keys, context_obj=None):
|
||||||
|
"""Return items for given keys, ignoring empty strings.
|
||||||
|
|
||||||
|
The autocomplete frontend sometimes sends empty string keys which
|
||||||
|
causes the ORM to raise ValueError when filtering numeric PK fields.
|
||||||
|
Filter out falsy/blank keys before querying. If no valid keys remain
|
||||||
|
return an empty queryset.
|
||||||
|
"""
|
||||||
|
# normalize and remove empty/blank values
|
||||||
|
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
|
||||||
|
if not clean_keys:
|
||||||
|
return cls.model.objects.none()
|
||||||
|
|
||||||
|
# try to convert numeric keys to ints where possible
|
||||||
|
parsed_keys = []
|
||||||
|
for k in clean_keys:
|
||||||
|
try:
|
||||||
|
parsed_keys.append(int(k))
|
||||||
|
except Exception:
|
||||||
|
parsed_keys.append(k)
|
||||||
|
|
||||||
|
qs = cls.model.objects.filter(pk__in=parsed_keys)
|
||||||
|
|
||||||
|
# Return a list of dicts matching the autocomplete core expectations
|
||||||
|
# (items subscriptable by keys like 'key' / 'label').
|
||||||
|
return [
|
||||||
|
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
|
||||||
|
for obj in qs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ConditionAutocompleteForm(Form):
|
class ConditionAutocompleteForm(Form):
|
||||||
condition = ModelChoiceField(
|
condition = ModelChoiceField(
|
||||||
|
|||||||
@@ -66,8 +66,32 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
|
<a class="nav-link d-inline" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
|
||||||
|
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span class="visually-hidden">Toggle Dropdown</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:categories_list' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>All categories</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:condition_view' %}">Conditions</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:finding_view' %}">Findings</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:structure_view' %}">Structures</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_view' %}">Subspecialties</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:presentation_view' %}">Presentations</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_view' %}">Pathological process</a></li>
|
||||||
|
{% if request.user.is_staff %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li class="dropdown-item-text small text-muted px-3">Create</li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:condition_create' %}">New condition</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:finding_create' %}">New finding</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:structure_create' %}">New structure</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:subspecialty_create' %}">New subspecialty</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:presentation_create' %}">New presentation</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:pathological_process_create' %}">New pathological process</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link d-inline" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
<a class="nav-link d-inline" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -36,6 +36,28 @@
|
|||||||
{% empty %}
|
{% empty %}
|
||||||
—
|
—
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if request.user.is_authenticated and can_merge %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="collapse mt-2" id="addSynonymCollapse">
|
||||||
|
<div class="card card-body">
|
||||||
|
<form method="POST">
|
||||||
|
{% 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>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Or create new synonym name</label>
|
||||||
|
<input class="form-control" type="text" name="synonym_name" placeholder="e.g. Granulomatosis with Polyangiitis" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4">Parent</dt>
|
<dt class="col-sm-4">Parent</dt>
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
{% extends "atlas/base.html" %}
|
{% extends "atlas/base.html" %}
|
||||||
<!-- {% load static from static %} -->
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block css %}{% endblock %}
|
||||||
|
|
||||||
{% block css %}
|
|
||||||
{% endblock %}
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
|
{{ form.media }}
|
||||||
{{form.media}}
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- {{ form.media }} -->
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Add/Edit Condition</h2>
|
<h2>Add / Edit Condition</h2>
|
||||||
<p>Use this form to create or edit a condition.</p>
|
<p>Use this form to create or edit a condition.</p>
|
||||||
<p>Please check if it already <a href='{% url "atlas:condition_view" %}'>exists</a> before doing so!</p>
|
<p>Please check if it already <a href='{% url "atlas:condition_view" %}'>exists</a> before doing so!</p>
|
||||||
|
|
||||||
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{% crispy form %}
|
||||||
|
|
||||||
<table>
|
<div class="mt-3">
|
||||||
{{ form.as_table }}
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
</table>
|
<a class="btn btn-secondary ms-2" href="{% url 'atlas:condition_view' %}">Cancel</a>
|
||||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "atlas/base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
{{ form.media }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Add / Edit Subspecialty</h2>
|
||||||
|
<p>Use this form to create or edit a subspecialty.</p>
|
||||||
|
<p>Please check if it already <a href='{% url "atlas:subspecialty_view" %}'>exists</a> before doing so!</p>
|
||||||
|
|
||||||
|
<form action="" method="post" enctype="multipart/form-data" id="subspecialty-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% crispy form %}
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
<a class="btn btn-secondary ms-2" href="{% url 'atlas:subspecialty_view' %}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -7,7 +7,14 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<h2>My Collections</h2>
|
<div class="d-flex align-items-start justify-content-between">
|
||||||
|
<h2 class="mb-0">{{ model_verbose_name_plural|capfirst }}</h2>
|
||||||
|
{% if request.user.is_staff and create_url %}
|
||||||
|
<div>
|
||||||
|
<a class="btn btn-sm btn-primary" href="{{ create_url }}">Add new {{ model_verbose_name }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% comment %} <details>
|
{% comment %} <details>
|
||||||
<summary>
|
<summary>
|
||||||
<h4>Filter</h4>
|
<h4>Filter</h4>
|
||||||
@@ -19,7 +26,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details> {% endcomment %}
|
</details> {% endcomment %}
|
||||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
|
||||||
{% render_table table %}
|
{% render_table table %}
|
||||||
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"subspecialty/<int:pk>", views.subspecialty_detail, name="subspecialty_detail"
|
"subspecialty/<int:pk>", views.subspecialty_detail, name="subspecialty_detail"
|
||||||
),
|
),
|
||||||
|
path("subspecialty/create", views.SubspecialtyCreate.as_view(), name="subspecialty_create"),
|
||||||
path("condition/create", views.ConditionCreate.as_view(), name="condition_create"),
|
path("condition/create", views.ConditionCreate.as_view(), name="condition_create"),
|
||||||
path("finding/", views.FindingView.as_view(), name="finding_view"),
|
path("finding/", views.FindingView.as_view(), name="finding_view"),
|
||||||
path("finding/<int:pk>", views.finding_detail, name="finding_detail"),
|
path("finding/<int:pk>", views.finding_detail, name="finding_detail"),
|
||||||
@@ -596,6 +597,7 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
|
"presentation/<int:pk>", views.presentation_detail, name="presentation_detail"
|
||||||
),
|
),
|
||||||
|
path("presentation/create", views.PresentationCreate.as_view(), name="presentation_create"),
|
||||||
path(
|
path(
|
||||||
"process/",
|
"process/",
|
||||||
views.PathologicalProcessView.as_view(),
|
views.PathologicalProcessView.as_view(),
|
||||||
@@ -606,6 +608,7 @@ urlpatterns = [
|
|||||||
views.pathological_process_detail,
|
views.pathological_process_detail,
|
||||||
name="pathological_process_detail",
|
name="pathological_process_detail",
|
||||||
),
|
),
|
||||||
|
path("process/create", views.PathologicalProcessCreate.as_view(), name="pathological_process_create"),
|
||||||
path("combine_series/", views.combine_series, name="combine_series"),
|
path("combine_series/", views.combine_series, name="combine_series"),
|
||||||
path(
|
path(
|
||||||
"case/<int:case_id>/linked/",
|
"case/<int:case_id>/linked/",
|
||||||
|
|||||||
+127
-11
@@ -40,6 +40,7 @@ from .tables import ResourceTable
|
|||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
from django.urls import reverse_lazy, reverse
|
from django.urls import reverse_lazy, reverse
|
||||||
|
from django.urls.exceptions import NoReverseMatch
|
||||||
|
|
||||||
from django.http import Http404, JsonResponse
|
from django.http import Http404, JsonResponse
|
||||||
from django.http import HttpResponseRedirect, HttpResponse
|
from django.http import HttpResponseRedirect, HttpResponse
|
||||||
@@ -82,6 +83,9 @@ from .forms import (
|
|||||||
SeriesFindingForm,
|
SeriesFindingForm,
|
||||||
CaseDifferentialFormSet,
|
CaseDifferentialFormSet,
|
||||||
StructureForm,
|
StructureForm,
|
||||||
|
SubspecialtyForm,
|
||||||
|
PresentationForm,
|
||||||
|
PathologicalProcessForm,
|
||||||
UserQuestionAnswerForm,
|
UserQuestionAnswerForm,
|
||||||
UserReportAnswerForm,
|
UserReportAnswerForm,
|
||||||
)
|
)
|
||||||
@@ -826,7 +830,12 @@ def question_schema_schemas(request):
|
|||||||
def condition_detail(request, pk):
|
def condition_detail(request, pk):
|
||||||
condition = get_object_or_404(Condition, pk=pk)
|
condition = get_object_or_404(Condition, pk=pk)
|
||||||
|
|
||||||
form = ConditionAutocompleteForm()
|
# form used for the merge UI
|
||||||
|
merge_form = ConditionAutocompleteForm()
|
||||||
|
|
||||||
|
# form used to add a synonym (can post either an existing condition via autocomplete
|
||||||
|
# or provide a plain name in 'synonym_name')
|
||||||
|
synonym_form = ConditionAutocompleteForm()
|
||||||
|
|
||||||
can_merge = False
|
can_merge = False
|
||||||
|
|
||||||
@@ -836,11 +845,47 @@ def condition_detail(request, pk):
|
|||||||
):
|
):
|
||||||
can_merge = True
|
can_merge = True
|
||||||
|
|
||||||
|
# Handle POST to add a synonym
|
||||||
|
if request.method == "POST" and request.POST.get("action") == "add_synonym":
|
||||||
|
# only allow staff/editors
|
||||||
|
if not (
|
||||||
|
request.user.is_superuser
|
||||||
|
or request.user.groups.filter(name="atlas_editor").exists()
|
||||||
|
):
|
||||||
|
return HttpResponse("Forbidden", status=403)
|
||||||
|
|
||||||
|
# Try autocomplete-selected condition first
|
||||||
|
form = ConditionAutocompleteForm(request.POST)
|
||||||
|
other = None
|
||||||
|
if form.is_valid():
|
||||||
|
other = form.cleaned_data.get("condition")
|
||||||
|
|
||||||
|
# If no autocomplete selection, accept a free-text name
|
||||||
|
synonym_name = request.POST.get("synonym_name")
|
||||||
|
if not other and synonym_name:
|
||||||
|
other, created = Condition.objects.get_or_create(name=synonym_name)
|
||||||
|
if created:
|
||||||
|
# mark created synonym as non-primary
|
||||||
|
other.primary = False
|
||||||
|
other.save()
|
||||||
|
|
||||||
|
if other and other != condition:
|
||||||
|
# add mutual synonym relationship
|
||||||
|
condition.synonym.add(other)
|
||||||
|
other.synonym.add(condition)
|
||||||
|
|
||||||
|
return redirect("atlas:condition_detail", pk=condition.pk)
|
||||||
|
|
||||||
# logging.debug(atlas.subspecialty.first().name.all())
|
# logging.debug(atlas.subspecialty.first().name.all())
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/condition_detail.html",
|
"atlas/condition_detail.html",
|
||||||
{"condition": condition, "form": form, "can_merge": can_merge},
|
{
|
||||||
|
"condition": condition,
|
||||||
|
"form": merge_form,
|
||||||
|
"synonym_form": synonym_form,
|
||||||
|
"can_merge": can_merge,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1685,6 +1730,24 @@ class StructureCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
|||||||
form_class = StructureForm
|
form_class = StructureForm
|
||||||
|
|
||||||
|
|
||||||
|
class SubspecialtyCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||||
|
model = Subspecialty
|
||||||
|
form_class = SubspecialtyForm
|
||||||
|
template_name = "atlas/subspecialty_form.html"
|
||||||
|
|
||||||
|
|
||||||
|
class PresentationCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||||
|
model = Presentation
|
||||||
|
form_class = PresentationForm
|
||||||
|
template_name = "atlas/presentation_form.html"
|
||||||
|
|
||||||
|
|
||||||
|
class PathologicalProcessCreate(RevisionMixin, LoginRequiredMixin, CreateView):
|
||||||
|
model = PathologicalProcess
|
||||||
|
form_class = PathologicalProcessForm
|
||||||
|
template_name = "atlas/pathological_process_form.html"
|
||||||
|
|
||||||
|
|
||||||
class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
|
class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
|
||||||
model = Case
|
model = Case
|
||||||
form_class = CaseForm
|
form_class = CaseForm
|
||||||
@@ -2119,6 +2182,17 @@ class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleT
|
|||||||
|
|
||||||
filterset_class = ConditionFilter
|
filterset_class = ConditionFilter
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
try:
|
||||||
|
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||||||
|
except NoReverseMatch:
|
||||||
|
ctx["create_url"] = None
|
||||||
|
# Provide verbose names to templates without accessing _meta in templates
|
||||||
|
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||||||
|
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = QuestionSchema
|
model = QuestionSchema
|
||||||
@@ -2135,6 +2209,16 @@ class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, Sing
|
|||||||
|
|
||||||
filterset_class = SubspecialtyFilter
|
filterset_class = SubspecialtyFilter
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
try:
|
||||||
|
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||||||
|
except NoReverseMatch:
|
||||||
|
ctx["create_url"] = None
|
||||||
|
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||||||
|
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Presentation
|
model = Presentation
|
||||||
@@ -2143,6 +2227,16 @@ class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, Sing
|
|||||||
|
|
||||||
filterset_class = PresentationFilter
|
filterset_class = PresentationFilter
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
try:
|
||||||
|
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||||||
|
except NoReverseMatch:
|
||||||
|
ctx["create_url"] = None
|
||||||
|
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||||||
|
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = PathologicalProcess
|
model = PathologicalProcess
|
||||||
@@ -2151,6 +2245,16 @@ class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixi
|
|||||||
|
|
||||||
filterset_class = PathologicalProcessFilter
|
filterset_class = PathologicalProcessFilter
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
try:
|
||||||
|
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||||||
|
except NoReverseMatch:
|
||||||
|
ctx["create_url"] = None
|
||||||
|
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||||||
|
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Structure
|
model = Structure
|
||||||
@@ -2159,6 +2263,16 @@ class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleT
|
|||||||
|
|
||||||
filterset_class = StructureFilter
|
filterset_class = StructureFilter
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
try:
|
||||||
|
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||||||
|
except NoReverseMatch:
|
||||||
|
ctx["create_url"] = None
|
||||||
|
ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None)
|
||||||
|
ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
|
||||||
model = Finding
|
model = Finding
|
||||||
@@ -2167,6 +2281,14 @@ class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTab
|
|||||||
|
|
||||||
filterset_class = FindingFilter
|
filterset_class = FindingFilter
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
try:
|
||||||
|
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
|
||||||
|
except NoReverseMatch:
|
||||||
|
ctx["create_url"] = None
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def case_order_dicom(request, pk):
|
def case_order_dicom(request, pk):
|
||||||
@@ -2527,7 +2649,7 @@ def categories_search_partial(request):
|
|||||||
class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||||
model = CaseCollection
|
model = CaseCollection
|
||||||
table_class = CaseCollectionTable
|
table_class = CaseCollectionTable
|
||||||
template_name = "atlas/view.html"
|
template_name = "atlas/collection_view.html"
|
||||||
|
|
||||||
filterset_class = CaseCollectionFilter
|
filterset_class = CaseCollectionFilter
|
||||||
|
|
||||||
@@ -3094,17 +3216,11 @@ def collection_case_questions(request, exam_id, case_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@user_is_collection_author_or_atlas_editor
|
@user_is_collection_author_or_atlas_editor
|
||||||
def collection_case_details(request, exam_id, case_id):
|
def collection_case_details(request, exam_id, case_number):
|
||||||
# Accept either case_number (index into collection) or case_id (case PK).
|
# Accept either case_number (index into collection) or case_id (case PK).
|
||||||
casedetail = None
|
|
||||||
collection = None
|
|
||||||
try:
|
|
||||||
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
|
|
||||||
collection = casedetail.collection
|
|
||||||
except Exception:
|
|
||||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||||
try:
|
try:
|
||||||
case_obj = collection.get_case_by_index(int(case_id))
|
case_obj = collection.get_case_by_index(int(case_number))
|
||||||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise Http404("Case not found in collection")
|
raise Http404("Case not found in collection")
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">per question</span>)
|
Time limit: <strong>{{ exam.time_limit }}</strong> seconds (<span title="Time per question: {% widthratio exam.time_limit question_number 1 %} seconds">per question</span>)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 small text-muted">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
Exam mode: <strong>{{ exam.exam_mode }}</strong>
|
Exam mode: <strong>{{ exam.exam_mode }}</strong>
|
||||||
{% if exam.exam_mode %}
|
{% if exam.exam_mode %}
|
||||||
@@ -178,17 +180,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
{# Authors and supervisor visibility moved earlier in the header; duplicate block removed #}
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
{% if exam.examcollection %}
|
|
||||||
<div class="small">Exam Collection: <a href="{{ exam.examcollection.get_absolute_url }}">{{ exam.examcollection }}</a></div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="small mt-1">Author(s): {% for author in exam.author.all %}{{ author }}{% if not forloop.last %}, {% endif %}{% endfor %}</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-6 text-md-end small mt-2 mt-md-0">
|
|
||||||
<span title="If true the results from this exam will be visible to supervisors automatically.">Supervisor visible: {{ exam.results_supervisor_visible }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
|
|||||||
@@ -7,70 +7,101 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% load thumbnail %}
|
{% load thumbnail %}
|
||||||
<div class="physics">
|
<div class="container physics my-3">
|
||||||
|
|
||||||
This exam will be available to take <a href="{% url 'physics:exam_start' pk=exam.pk %}">here</a> (when active).
|
<p class="mb-3">This exam will be available to take <a href="{% url 'physics:exam_start' pk=exam.pk %}">here</a> (when active).</p>
|
||||||
|
|
||||||
{% autoescape off %}
|
<div class="card">
|
||||||
<ol id="full-question-list-physics">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Questions <small class="text-muted">({{ questions.count }})</small></h5>
|
||||||
|
<div class="text-muted small">Exam: {{ exam.title }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group list-group-flush" id="full-question-list-physics">
|
||||||
{% for question in questions.all %}
|
{% for question in questions.all %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<div class="me-3">
|
||||||
|
<span class="badge bg-secondary">{{ forloop.counter }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold mb-2">{{ question.stem|safe }}</div>
|
||||||
|
|
||||||
<li>
|
<div class="mb-2">
|
||||||
{{ question.stem }}
|
<ol type="a" class="mb-0">
|
||||||
<ol type="a" class="abcde">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<li class="question-a">
|
<div><span class="me-2">a)</span><span>{{ question.a }}</span></div>
|
||||||
<span class="question-text">{{ question.a }}</span>: <span class="question-answer">{{ question.a_answer }}</span>
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.a_answer }}</div>
|
||||||
{% if question.a_feedback %}
|
{% if question.a_feedback %}
|
||||||
<span class="question-feedback">{{question.a_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.a_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="question-b">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<span class="question-text">{{ question.b }}</span>: <span class="question-answer">{{ question.b_answer }}</span>
|
<div><span class="me-2">b)</span><span>{{ question.b }}</span></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.b_answer }}</div>
|
||||||
{% if question.b_feedback %}
|
{% if question.b_feedback %}
|
||||||
<span class="question-feedback">{{question.b_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.b_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="question-c">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<span class="question-text">{{ question.c }}</span>: <span class="question-answer">{{ question.c_answer }}</span>
|
<div><span class="me-2">c)</span><span>{{ question.c }}</span></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.c_answer }}</div>
|
||||||
{% if question.c_feedback %}
|
{% if question.c_feedback %}
|
||||||
<span class="question-feedback">{{question.c_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.c_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="question-d">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<span class="question-text">{{ question.d }}</span>: <span class="question-answer">{{ question.d_answer }}</span>
|
<div><span class="me-2">d)</span><span>{{ question.d }}</span></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.d_answer }}</div>
|
||||||
{% if question.d_feedback %}
|
{% if question.d_feedback %}
|
||||||
<span class="question-feedback">{{question.d_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.d_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="question-e">
|
<li class="d-flex justify-content-between align-items-start py-1">
|
||||||
<span class="question-text">{{ question.e }}</span>: <span class="question-answer">{{ question.e_answer }}</span>
|
<div><span class="me-2">e)</span><span>{{ question.e }}</span></div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-primary fw-semibold">{{ question.e_answer }}</div>
|
||||||
{% if question.e_feedback %}
|
{% if question.e_feedback %}
|
||||||
<span class="question-feedback">{{question.e_feedback}}</span>
|
<div class="text-muted small">Feedback: {{ question.e_feedback }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
Category: {{ question.category }}, <a href="{% url 'physics:question_detail' pk=question.pk %}">View [id: {{question.pk}}]</a> <a
|
</div>
|
||||||
href="{% url 'admin:physics_question_change' question.id %}">Edit</a>
|
|
||||||
|
|
||||||
|
<div class="small text-muted">
|
||||||
|
Category: {{ question.category }}
|
||||||
|
— <a href="{% url 'physics:question_detail' pk=question.pk %}">View</a>
|
||||||
|
<a href="{% url 'admin:physics_question_change' question.id %}" class="ms-2">Edit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item text-muted">No questions available.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ul>
|
||||||
{% endautoescape %}
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'exam_overview_js.html' %}
|
{% include 'exam_overview_js.html' %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style>
|
<style>
|
||||||
.question-answer {
|
/* Small visual tweaks on top of Bootstrap */
|
||||||
font-weight: bolder;
|
.question-feedback { opacity: 0.7; }
|
||||||
}
|
.list-group-item ol { margin: 0; padding-left: 1.4rem; }
|
||||||
.question-feedback {
|
.list-group-item li { list-style-position: inside; }
|
||||||
opacity: 50%;
|
|
||||||
float: right
|
|
||||||
}
|
|
||||||
.question-feedback::before {
|
|
||||||
content: "Feedback: ";
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user