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,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
Presentation,
|
||||
PathologicalProcess,
|
||||
UncategorisedDicom,
|
||||
UserReportAnswer,
|
||||
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 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 Meta:
|
||||
model = SeriesFinding
|
||||
@@ -1013,6 +1045,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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
—
|
||||
{% 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>
|
||||
|
||||
<dt class="col-sm-4">Parent</dt>
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
{% 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 %}
|
||||
{% crispy form %}
|
||||
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
<a class="btn btn-secondary ms-2" href="{% url 'atlas:condition_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 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 %}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -423,6 +423,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"),
|
||||
@@ -596,6 +597,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(),
|
||||
@@ -606,6 +608,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/",
|
||||
|
||||
+127
-11
@@ -40,6 +40,7 @@ from .tables import ResourceTable
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from django.urls import reverse_lazy, reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
@@ -82,6 +83,9 @@ from .forms import (
|
||||
SeriesFindingForm,
|
||||
CaseDifferentialFormSet,
|
||||
StructureForm,
|
||||
SubspecialtyForm,
|
||||
PresentationForm,
|
||||
PathologicalProcessForm,
|
||||
UserQuestionAnswerForm,
|
||||
UserReportAnswerForm,
|
||||
)
|
||||
@@ -826,7 +830,12 @@ def question_schema_schemas(request):
|
||||
def condition_detail(request, 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
|
||||
|
||||
@@ -836,11 +845,47 @@ def condition_detail(request, pk):
|
||||
):
|
||||
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())
|
||||
return render(
|
||||
request,
|
||||
"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
|
||||
|
||||
|
||||
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):
|
||||
model = Case
|
||||
form_class = CaseForm
|
||||
@@ -2119,6 +2182,17 @@ class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleT
|
||||
|
||||
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):
|
||||
model = QuestionSchema
|
||||
@@ -2135,6 +2209,16 @@ class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, Sing
|
||||
|
||||
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):
|
||||
model = Presentation
|
||||
@@ -2143,6 +2227,16 @@ class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, Sing
|
||||
|
||||
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):
|
||||
model = PathologicalProcess
|
||||
@@ -2151,6 +2245,16 @@ class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixi
|
||||
|
||||
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):
|
||||
model = Structure
|
||||
@@ -2159,6 +2263,16 @@ class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleT
|
||||
|
||||
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):
|
||||
model = Finding
|
||||
@@ -2167,6 +2281,14 @@ class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTab
|
||||
|
||||
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
|
||||
def case_order_dicom(request, pk):
|
||||
@@ -2527,7 +2649,7 @@ def categories_search_partial(request):
|
||||
class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
model = CaseCollection
|
||||
table_class = CaseCollectionTable
|
||||
template_name = "atlas/view.html"
|
||||
template_name = "atlas/collection_view.html"
|
||||
|
||||
filterset_class = CaseCollectionFilter
|
||||
|
||||
@@ -3094,17 +3216,11 @@ def collection_case_questions(request, exam_id, case_id):
|
||||
)
|
||||
|
||||
@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).
|
||||
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)
|
||||
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)
|
||||
except Exception:
|
||||
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>)
|
||||
</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 %}
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user