Compare commits

...
9 Commits
13 changed files with 442 additions and 96 deletions
+63
View File
@@ -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(
+26 -2
View File
@@ -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 %}
&mdash; &mdash;
{% 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>
+13 -15
View File
@@ -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 %}
+8 -2
View File
@@ -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>
+3
View File
@@ -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/",
+130 -14
View File
@@ -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,20 +3216,14 @@ 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 = get_object_or_404(CaseCollection, pk=exam_id)
collection = None
try: try:
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id) case_obj = collection.get_case_by_index(int(case_number))
collection = casedetail.collection casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception: except Exception:
collection = get_object_or_404(CaseCollection, pk=exam_id) raise Http404("Case not found in collection")
try:
case_obj = collection.get_case_by_index(int(case_id))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
if request.method == "POST": if request.method == "POST":
form = CaseDetailForm(request.POST, instance=casedetail) form = CaseDetailForm(request.POST, instance=casedetail)
@@ -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 %}
+83 -52
View File
@@ -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">
{% if question.a_feedback %} <div class="text-primary fw-semibold">{{ question.a_answer }}</div>
<span class="question-feedback">{{question.a_feedback}}</span> {% if question.a_feedback %}
{% endif %} <div class="text-muted small">Feedback: {{ question.a_feedback }}</div>
</li> {% endif %}
<li class="question-b"> </div>
<span class="question-text">{{ question.b }}</span>: <span class="question-answer">{{ question.b_answer }}</span> </li>
{% if question.b_feedback %} <li class="d-flex justify-content-between align-items-start py-1">
<span class="question-feedback">{{question.b_feedback}}</span> <div><span class="me-2">b)</span><span>{{ question.b }}</span></div>
{% endif %} <div class="text-end">
</li> <div class="text-primary fw-semibold">{{ question.b_answer }}</div>
<li class="question-c"> {% if question.b_feedback %}
<span class="question-text">{{ question.c }}</span>: <span class="question-answer">{{ question.c_answer }}</span> <div class="text-muted small">Feedback: {{ question.b_feedback }}</div>
{% if question.c_feedback %} {% endif %}
<span class="question-feedback">{{question.c_feedback}}</span> </div>
{% endif %} </li>
</li> <li class="d-flex justify-content-between align-items-start py-1">
<li class="question-d"> <div><span class="me-2">c)</span><span>{{ question.c }}</span></div>
<span class="question-text">{{ question.d }}</span>: <span class="question-answer">{{ question.d_answer }}</span> <div class="text-end">
{% if question.d_feedback %} <div class="text-primary fw-semibold">{{ question.c_answer }}</div>
<span class="question-feedback">{{question.d_feedback}}</span> {% if question.c_feedback %}
{% endif %} <div class="text-muted small">Feedback: {{ question.c_feedback }}</div>
</li> {% endif %}
<li class="question-e"> </div>
<span class="question-text">{{ question.e }}</span>: <span class="question-answer">{{ question.e_answer }}</span> </li>
{% if question.e_feedback %} <li class="d-flex justify-content-between align-items-start py-1">
<span class="question-feedback">{{question.e_feedback}}</span> <div><span class="me-2">d)</span><span>{{ question.d }}</span></div>
{% endif %} <div class="text-end">
</li> <div class="text-primary fw-semibold">{{ question.d_answer }}</div>
</ol> {% if question.d_feedback %}
Category: {{ question.category }}, <a href="{% url 'physics:question_detail' pk=question.pk %}">View [id: {{question.pk}}]</a> <a <div class="text-muted small">Feedback: {{ question.d_feedback }}</div>
href="{% url 'admin:physics_question_change' question.id %}">Edit</a> {% endif %}
</div>
</li>
<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 %}
<div class="text-muted small">Feedback: {{ question.e_feedback }}</div>
{% endif %}
</div>
</li>
</ol>
</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> </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 %}