Compare commits
10
Commits
7fa08037f0
...
a2e9d5e681
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e9d5e681 | ||
|
|
8da2dd27da | ||
|
|
970242e1fb | ||
|
|
f8be1918f6 | ||
|
|
5ac998454b | ||
|
|
d259d0e8dd | ||
|
|
5fb3c98941 | ||
|
|
a70354c18d | ||
|
|
345977e2ef | ||
|
|
726c7ea401 |
@@ -3,6 +3,7 @@ from .models import (
|
|||||||
Case,
|
Case,
|
||||||
CaseDetail,
|
CaseDetail,
|
||||||
CaseCollection,
|
CaseCollection,
|
||||||
|
NormalCase,
|
||||||
Differential,
|
Differential,
|
||||||
Series,
|
Series,
|
||||||
SeriesImage,
|
SeriesImage,
|
||||||
@@ -79,6 +80,22 @@ class CaseCollectionAdmin(VersionAdmin):
|
|||||||
|
|
||||||
admin.site.register(CaseCollection, CaseCollectionAdmin)
|
admin.site.register(CaseCollection, CaseCollectionAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class NormalCaseAdmin(VersionAdmin):
|
||||||
|
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
|
||||||
|
list_filter = ("examination", "modality")
|
||||||
|
search_fields = ("case__title", "case__pk")
|
||||||
|
|
||||||
|
def display_age(self, obj):
|
||||||
|
try:
|
||||||
|
return obj.display_age()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
display_age.short_description = "Age"
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(NormalCase, NormalCaseAdmin)
|
||||||
|
|
||||||
class AtlasAdminForm(ModelForm):
|
class AtlasAdminForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Case
|
model = Case
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from .models import (
|
|||||||
Series,
|
Series,
|
||||||
Structure,
|
Structure,
|
||||||
Subspecialty,
|
Subspecialty,
|
||||||
|
NormalCase,
|
||||||
)
|
)
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@@ -286,6 +287,54 @@ class SubspecialtyFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NormalCaseFilter(django_filters.FilterSet):
|
||||||
|
"""FilterSet for the NormalCase model allowing filtering by
|
||||||
|
examination, modality and nearest-year age ranges.
|
||||||
|
"""
|
||||||
|
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||||
|
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||||
|
# Accept min/max as years in the UI but filter against the canonical
|
||||||
|
# `age_days` field by converting years -> days.
|
||||||
|
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
|
||||||
|
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = NormalCase
|
||||||
|
fields = ("examination", "modality", "age_days")
|
||||||
|
|
||||||
|
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
||||||
|
# Use the global Examination and Modality querysets from the Series model
|
||||||
|
from generic.models import Examination, Modality
|
||||||
|
|
||||||
|
# Set up the actual querysets for the filters
|
||||||
|
self.base_filters.get("examination").queryset = Examination.objects.all()
|
||||||
|
self.base_filters.get("modality").queryset = Modality.objects.all()
|
||||||
|
|
||||||
|
# If not an atlas editor restrict to cases authored by the user or open access cases
|
||||||
|
if request is not None and not request.user.groups.filter(name="atlas_editor").exists():
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
if queryset is None:
|
||||||
|
queryset = NormalCase.objects.select_related("case")
|
||||||
|
queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True)))
|
||||||
|
|
||||||
|
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||||
|
|
||||||
|
def filter_min_age(self, queryset, name, value):
|
||||||
|
try:
|
||||||
|
days = int(float(value) * 365.25)
|
||||||
|
except Exception:
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(age_days__gte=days)
|
||||||
|
|
||||||
|
def filter_max_age(self, queryset, name, value):
|
||||||
|
try:
|
||||||
|
days = int(float(value) * 365.25)
|
||||||
|
except Exception:
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(age_days__lte=days)
|
||||||
|
|
||||||
class QuestionSchemaFilter(django_filters.FilterSet):
|
class QuestionSchemaFilter(django_filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = QuestionSchema
|
model = QuestionSchema
|
||||||
|
|||||||
+45
-24
@@ -44,6 +44,7 @@ from atlas.models import (
|
|||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
CaseDisplaySet,
|
CaseDisplaySet,
|
||||||
)
|
)
|
||||||
|
from .models import NormalCase
|
||||||
|
|
||||||
from anatomy.models import Modality
|
from anatomy.models import Modality
|
||||||
|
|
||||||
@@ -236,35 +237,55 @@ class CaseCollectionForm(ModelForm):
|
|||||||
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
|
||||||
widget=SplitDateTimeWidget(
|
class NormalCaseForm(ModelForm):
|
||||||
date_attrs={"type": "date", "class": "datepicker"},
|
# Keep non-model input fields for age entry so users can provide a
|
||||||
time_attrs={"type": "time", "class": "timepicker"},
|
# value + unit (e.g. 6 months) which will be converted to canonical
|
||||||
date_format="%Y-%m-%d",
|
# days on save. The model now stores only `age_days`.
|
||||||
time_format="%H:%M",
|
age_value = forms.IntegerField(required=False, min_value=0)
|
||||||
|
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = NormalCase
|
||||||
|
fields = ("examination", "modality", "notes")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper = FormHelper()
|
||||||
|
# Do not render a <form> tag from crispy here because the template
|
||||||
|
# already provides the surrounding form element (we inject this
|
||||||
|
# fragment into a modal that contains the form). Rendering a second
|
||||||
|
# <form> would nest forms and cause fields to be outside the
|
||||||
|
# submitted form (HTMX would only send the outer form's fields).
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
Div(
|
||||||
|
Field("age_value", wrapper_class="col-md-3"),
|
||||||
|
Field("age_unit", wrapper_class="col-md-3"),
|
||||||
|
Field("examination", wrapper_class="col-md-3"),
|
||||||
|
Field("modality", wrapper_class="col-md-3"),
|
||||||
|
css_class="row",
|
||||||
),
|
),
|
||||||
required=False,
|
Field("notes"),
|
||||||
help_text="The date the exam is due to start (time is optional)",
|
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||||
)
|
|
||||||
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
|
|
||||||
widget=SplitDateTimeWidget(
|
|
||||||
date_attrs={"type": "date", "class": "datepicker"},
|
|
||||||
time_attrs={"type": "time", "class": "timepicker"},
|
|
||||||
date_format="%Y-%m-%d",
|
|
||||||
time_format="%H:%M",
|
|
||||||
),
|
|
||||||
required=False,
|
|
||||||
help_text="The date the exam is due to start (time is optional)",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
# Get the unsaved Case instance
|
# Respect commit flag. Compute canonical `age_days` from the
|
||||||
instance = ModelForm.save(self, False)
|
# provided age_value/age_unit and set it on the instance.
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
age_value = self.cleaned_data.get("age_value")
|
||||||
|
age_unit = self.cleaned_data.get("age_unit")
|
||||||
|
if age_value is not None and age_unit:
|
||||||
|
instance.age_days = NormalCase.to_days(age_value, age_unit)
|
||||||
|
else:
|
||||||
|
# If the user left fields blank, clear any previous value.
|
||||||
|
instance.age_days = None
|
||||||
|
|
||||||
# Do we need to save all changes now?
|
if commit:
|
||||||
# if commit:
|
instance.save()
|
||||||
instance.save()
|
self.save_m2m()
|
||||||
self.save_m2m()
|
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-13 21:43
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0079_casecollection_prerequisites'),
|
||||||
|
('generic', '0029_add_unique_constraint_supervisor_email'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='NormalCase',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('age_years', models.PositiveSmallIntegerField(blank=True, help_text='Age of the patient at scan in years (rounded to nearest year).', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(150)])),
|
||||||
|
('added_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('notes', models.TextField(blank=True, null=True)),
|
||||||
|
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_normals', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('case', models.OneToOneField(help_text='The Case that is marked as a normal example.', on_delete=django.db.models.deletion.CASCADE, related_name='normal_case', to='atlas.case')),
|
||||||
|
('examination', models.ForeignKey(blank=True, help_text='Primary examination for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.examination')),
|
||||||
|
('modality', models.ForeignKey(blank=True, help_text='Primary modality for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.modality')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-added_date'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-13 22:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0080_normalcase'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='normalcase',
|
||||||
|
name='age_years',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='normalcase',
|
||||||
|
name='age_days',
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text='Patient age at scan in days (canonical).', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
+149
@@ -611,6 +611,155 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
|
|
||||||
return json.dumps(results)
|
return json.dumps(results)
|
||||||
|
|
||||||
|
|
||||||
|
class NormalCase(models.Model):
|
||||||
|
"""Marks a Case as a 'normal' example used in the Atlas normals section.
|
||||||
|
|
||||||
|
This version stores a canonical `age_days` integer (patient age in days).
|
||||||
|
We keep the examination/modality metadata and authored fields. The
|
||||||
|
age fields previously stored (`age_years`, `age_value`, `age_unit`) have
|
||||||
|
been removed in favour of the single canonical `age_days` value.
|
||||||
|
"""
|
||||||
|
case = models.OneToOneField(
|
||||||
|
Case,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="normal_case",
|
||||||
|
help_text="The Case that is marked as a normal example.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Canonical internal representation: patient age in days
|
||||||
|
age_days = models.PositiveIntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Patient age at scan in days (canonical).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Helpful constants for converting/displaying ages. These are not
|
||||||
|
# stored directly on the model but are used by forms/views/templates.
|
||||||
|
AGE_UNIT_YEARS = "years"
|
||||||
|
AGE_UNIT_MONTHS = "months"
|
||||||
|
AGE_UNIT_WEEKS = "weeks"
|
||||||
|
AGE_UNIT_DAYS = "days"
|
||||||
|
AGE_UNIT_CHOICES = [
|
||||||
|
(AGE_UNIT_YEARS, "years"),
|
||||||
|
(AGE_UNIT_MONTHS, "months"),
|
||||||
|
(AGE_UNIT_WEEKS, "weeks"),
|
||||||
|
(AGE_UNIT_DAYS, "days"),
|
||||||
|
]
|
||||||
|
|
||||||
|
examination = models.ForeignKey(
|
||||||
|
Examination,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="normal_cases",
|
||||||
|
help_text="Primary examination for this normal case (optional).",
|
||||||
|
)
|
||||||
|
|
||||||
|
modality = models.ForeignKey(
|
||||||
|
Modality,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="normal_cases",
|
||||||
|
help_text="Primary modality for this normal case (optional).",
|
||||||
|
)
|
||||||
|
|
||||||
|
added_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="added_normals",
|
||||||
|
)
|
||||||
|
|
||||||
|
added_date = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
|
notes = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-added_date"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Normal: {self.case} ({self.display_age()})"
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return self.case.get_absolute_url()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_days(value: int | float, unit: str) -> int | None:
|
||||||
|
"""Convert a numeric value + unit into integer days (rounded).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
to_days(2, 'years') -> ~730
|
||||||
|
to_days(6, 'months') -> ~183
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if value is None or unit is None:
|
||||||
|
return None
|
||||||
|
v = float(value)
|
||||||
|
if unit == NormalCase.AGE_UNIT_YEARS:
|
||||||
|
return int(round(v * 365.25))
|
||||||
|
if unit == NormalCase.AGE_UNIT_MONTHS:
|
||||||
|
return int(round(v * 30.4375))
|
||||||
|
if unit == NormalCase.AGE_UNIT_WEEKS:
|
||||||
|
return int(round(v * 7))
|
||||||
|
if unit == NormalCase.AGE_UNIT_DAYS:
|
||||||
|
return int(round(v))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def days_to_value_unit(days: int | None) -> tuple[int | None, str | None]:
|
||||||
|
"""Convert canonical days into a (value, unit) tuple for display.
|
||||||
|
|
||||||
|
Prefer years for ages >= 365 days, months for ages >= 30 days,
|
||||||
|
weeks for >=7 days, else days.
|
||||||
|
"""
|
||||||
|
if days is None:
|
||||||
|
return (None, None)
|
||||||
|
try:
|
||||||
|
d = int(days)
|
||||||
|
if d >= 365:
|
||||||
|
yrs = int(round(d / 365.25))
|
||||||
|
return (yrs, NormalCase.AGE_UNIT_YEARS)
|
||||||
|
if d >= 30:
|
||||||
|
months = int(round(d / 30.4375))
|
||||||
|
return (months, NormalCase.AGE_UNIT_MONTHS)
|
||||||
|
if d >= 7:
|
||||||
|
weeks = int(round(d / 7))
|
||||||
|
return (weeks, NormalCase.AGE_UNIT_WEEKS)
|
||||||
|
return (d, NormalCase.AGE_UNIT_DAYS)
|
||||||
|
except Exception:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
def display_age(self) -> str:
|
||||||
|
"""Return a human friendly age string derived from age_days."""
|
||||||
|
if self.age_days is None:
|
||||||
|
return "age unknown"
|
||||||
|
val, unit = NormalCase.days_to_value_unit(self.age_days)
|
||||||
|
if val is None or unit is None:
|
||||||
|
return "age unknown"
|
||||||
|
# abbreviate years to 'y' for backwards compatibility where helpful
|
||||||
|
if unit == NormalCase.AGE_UNIT_YEARS:
|
||||||
|
return f"{val}y"
|
||||||
|
return f"{val} {unit}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# If examination/modality not specified, try to infer from the first series
|
||||||
|
try:
|
||||||
|
if (self.examination is None or self.modality is None) and self.case.series.exists():
|
||||||
|
first_series = self.case.series.first()
|
||||||
|
if self.examination is None and getattr(first_series, 'examination', None) is not None:
|
||||||
|
self.examination = first_series.examination
|
||||||
|
if self.modality is None and getattr(first_series, 'modality', None) is not None:
|
||||||
|
self.modality = first_series.modality
|
||||||
|
except Exception:
|
||||||
|
# Be defensive: if anything goes wrong whilst inferring, continue and save as-is
|
||||||
|
pass
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
def extract_image_dicom_json_from_ds(ds, url, image_index):
|
||||||
to_keep = [
|
to_keep = [
|
||||||
"Columns",
|
"Columns",
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
|
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
@@ -69,6 +72,7 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" 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" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -147,6 +147,10 @@
|
|||||||
|
|
||||||
{# Diagnostic certainty as a badge #}
|
{# Diagnostic certainty as a badge #}
|
||||||
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
|
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
|
||||||
|
{# Normal toggle HTMX block #}
|
||||||
|
<div class="mt-1">
|
||||||
|
{% include "atlas/partials/_normal_toggle.html" %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "atlas/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<h1>Normals</h1>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<form method="get" class="form-inline">
|
||||||
|
{% for field in filter.form.visible_fields %}
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ field.label_tag }}
|
||||||
|
{{ field }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<button class="btn btn-primary" type="submit">Filter</button>
|
||||||
|
<a class="btn btn-link" href="?">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if page_obj.object_list %}
|
||||||
|
<div class="list-group">
|
||||||
|
{% for normal in page_obj.object_list %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">{{ normal.case.title }}</h5>
|
||||||
|
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1">
|
||||||
|
Age: {{ normal.display_age }}
|
||||||
|
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
|
||||||
|
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if normal.notes %}
|
||||||
|
<p class="mb-0 text-muted">{{ normal.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group-item">No normal cases found.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<ul class="pagination mt-3">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<div id="normal-modal-{{ case.pk }}" class="modal modal-blur fade" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" hx-post="{% url 'atlas:case_create_normal' case.pk %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Mark case {{ case.pk }} as Normal</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
{{ form.media }}
|
||||||
|
{% crispy form %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Normal</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Show the modal after HTMX injects it
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var el = document.getElementById('normal-modal-{{ case.pk }}');
|
||||||
|
if(el){
|
||||||
|
var m = new bootstrap.Modal(el);
|
||||||
|
m.show();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to show normal modal', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1"><a href="{{ normal.case.get_absolute_url }}">{{ normal.case.title }}</a></h5>
|
||||||
|
<small>{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1">Age: {{ normal.display_age }}</p>
|
||||||
|
<p class="mb-1">Examination: {{ normal.examination }} | Modality: {{ normal.modality }}</p>
|
||||||
|
{% if normal.notes %}
|
||||||
|
<p class="mb-0">Notes: {{ normal.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div id="normal-toggle-block">
|
||||||
|
{% if case.normal_case %}
|
||||||
|
<span class="badge bg-success">Normal</span>
|
||||||
|
<small class="text-muted">{{ case.normal_case.display_age }}</small>
|
||||||
|
{% if can_mark_normal %}
|
||||||
|
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||||
|
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||||
|
hx-target="#normal-toggle-block"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Unmark this case as normal?">
|
||||||
|
Unmark normal
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if can_mark_normal %}
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Mark as normal
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Normal status only editable by atlas editors or case authors</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -349,6 +349,7 @@ urlpatterns = [
|
|||||||
path("categories/", views.categories_list, name="categories_list"),
|
path("categories/", views.categories_list, name="categories_list"),
|
||||||
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
|
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
|
||||||
path("search/cases/", views.case_search_partial, name="case_search_partial"),
|
path("search/cases/", views.case_search_partial, name="case_search_partial"),
|
||||||
|
path("normals/", views.NormalCaseList.as_view(), name="normals_list"),
|
||||||
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
|
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
|
||||||
path(
|
path(
|
||||||
"condition/<int:pk>/delete",
|
"condition/<int:pk>/delete",
|
||||||
@@ -454,6 +455,9 @@ urlpatterns = [
|
|||||||
# path("unchecked/", views.unchecked_list, name="unchecked_list"),
|
# path("unchecked/", views.unchecked_list, name="unchecked_list"),
|
||||||
# path("verified/<int:pk>/", views.verified_detail, name="verified_detail"),
|
# path("verified/<int:pk>/", views.verified_detail, name="verified_detail"),
|
||||||
path("case/<int:pk>/", views.case_detail, name="case_detail"),
|
path("case/<int:pk>/", views.case_detail, name="case_detail"),
|
||||||
|
path("case/<int:pk>/toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"),
|
||||||
|
path("case/<int:pk>/normal_form/", views.case_normal_form, name="case_normal_form"),
|
||||||
|
path("case/<int:pk>/create_normal/", views.create_case_normal, name="case_create_normal"),
|
||||||
# path("case/<int:pk>/collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"),
|
# path("case/<int:pk>/collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"),
|
||||||
path(
|
path(
|
||||||
"case/<int:case_id>/collection-form",
|
"case/<int:case_id>/collection-form",
|
||||||
|
|||||||
+279
@@ -5,6 +5,7 @@ from dal import autocomplete
|
|||||||
from django.db.models.base import Model as Model
|
from django.db.models.base import Model as Model
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
|
from django.db.models import Q
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -107,6 +108,7 @@ from .models import (
|
|||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
PrerequisiteRequired
|
PrerequisiteRequired
|
||||||
)
|
)
|
||||||
|
from .models import NormalCase
|
||||||
from .tables import (
|
from .tables import (
|
||||||
CaseCollectionTable,
|
CaseCollectionTable,
|
||||||
CaseTable,
|
CaseTable,
|
||||||
@@ -130,6 +132,7 @@ from .filters import (
|
|||||||
SeriesFilter,
|
SeriesFilter,
|
||||||
StructureFilter,
|
StructureFilter,
|
||||||
SubspecialtyFilter,
|
SubspecialtyFilter,
|
||||||
|
NormalCaseFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .tasks import push_case_to_cimar_task
|
from .tasks import push_case_to_cimar_task
|
||||||
@@ -288,6 +291,22 @@ class AtlasEditorRequiredMixin(object):
|
|||||||
return obj
|
return obj
|
||||||
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404
|
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class NormalCaseList(LoginRequiredMixin, FilterView):
|
||||||
|
model = NormalCase
|
||||||
|
template_name = "atlas/normals_list.html"
|
||||||
|
filterset_class = NormalCaseFilter
|
||||||
|
paginate_by = 25
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = NormalCase.objects.select_related("case", "examination", "modality", "added_by")
|
||||||
|
# Restrict similar to CaseFilter: non-editors only see authored or open_access
|
||||||
|
if not self.request.user.groups.filter(name="atlas_editor").exists():
|
||||||
|
qs = qs.filter((Q(case__author__id=self.request.user.id) | Q(case__open_access=True)))
|
||||||
|
|
||||||
|
return qs.order_by("-added_date")
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_has_case_view_access
|
@user_has_case_view_access
|
||||||
def case_displaysets(request, pk):
|
def case_displaysets(request, pk):
|
||||||
@@ -437,6 +456,10 @@ def case_detail(request, pk):
|
|||||||
case = get_case_for_case_detail(pk)
|
case = get_case_for_case_detail(pk)
|
||||||
can_edit = case.check_user_can_edit(request.user)
|
can_edit = case.check_user_can_edit(request.user)
|
||||||
|
|
||||||
|
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||||
|
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||||
|
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/case_detail.html",
|
"atlas/case_detail.html",
|
||||||
@@ -444,9 +467,265 @@ def case_detail(request, pk):
|
|||||||
"case": case,
|
"case": case,
|
||||||
"cimar_sid": request.user.userprofile.cimar_sid,
|
"cimar_sid": request.user.userprofile.cimar_sid,
|
||||||
"can_edit": can_edit,
|
"can_edit": can_edit,
|
||||||
|
"is_atlas_editor": is_atlas_editor,
|
||||||
|
"is_case_author": is_case_author,
|
||||||
|
"can_mark_normal": can_mark_normal,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def toggle_case_normal(request, pk):
|
||||||
|
"""HTMX endpoint to toggle the NormalCase mark for a Case.
|
||||||
|
|
||||||
|
- Only users in the 'atlas_editor' group or superusers may toggle.
|
||||||
|
- On create, attempt to auto-extract age_years from DICOM metadata.
|
||||||
|
- Returns an HTML fragment that replaces the toggle block.
|
||||||
|
"""
|
||||||
|
case = get_case_for_case_detail(pk)
|
||||||
|
|
||||||
|
# Permission: atlas editors, case authors, or superusers
|
||||||
|
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||||
|
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||||
|
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||||
|
if not can_mark_normal:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
# If already marked normal, unmark (delete the NormalCase)
|
||||||
|
try:
|
||||||
|
normal = case.normal_case
|
||||||
|
except Exception:
|
||||||
|
normal = None
|
||||||
|
|
||||||
|
if normal is not None:
|
||||||
|
normal.delete()
|
||||||
|
# Remove any cached relation on the case and reload so the template
|
||||||
|
# sees the updated state (no normal_case).
|
||||||
|
try:
|
||||||
|
if hasattr(case, "normal_case"):
|
||||||
|
delattr(case, "normal_case")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
case = get_case_for_case_detail(pk)
|
||||||
|
else:
|
||||||
|
# Attempt to auto-extract age from DICOM
|
||||||
|
age = None
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Iterate series then images to find first usable DICOM
|
||||||
|
for series in case.series.all():
|
||||||
|
img = series.images.filter(removed=False).first()
|
||||||
|
if not img:
|
||||||
|
continue
|
||||||
|
ds = img.get_dicom_data()
|
||||||
|
if not ds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try PatientAge (format often like '045Y' or '45Y')
|
||||||
|
try:
|
||||||
|
pa = getattr(ds, "PatientAge", None)
|
||||||
|
except Exception:
|
||||||
|
pa = None
|
||||||
|
|
||||||
|
if pa:
|
||||||
|
s = str(pa)
|
||||||
|
m = re.search(r"(\d+)", s)
|
||||||
|
if m:
|
||||||
|
age = int(m.group(1))
|
||||||
|
break
|
||||||
|
|
||||||
|
# Try birthdate + studydate
|
||||||
|
try:
|
||||||
|
pbd = getattr(ds, "PatientBirthDate", None)
|
||||||
|
sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None)
|
||||||
|
except Exception:
|
||||||
|
pbd = None
|
||||||
|
sdate = None
|
||||||
|
|
||||||
|
if pbd and sdate:
|
||||||
|
try:
|
||||||
|
bd = datetime.strptime(str(pbd), "%Y%m%d")
|
||||||
|
sd = datetime.strptime(str(sdate), "%Y%m%d")
|
||||||
|
years = round((sd - bd).days / 365.25)
|
||||||
|
age = int(years)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
age = None
|
||||||
|
|
||||||
|
# Create NormalCase
|
||||||
|
from .models import NormalCase
|
||||||
|
|
||||||
|
# Convert inferred integer years -> canonical days (approx)
|
||||||
|
if age is not None:
|
||||||
|
age_days = NormalCase.to_days(age, NormalCase.AGE_UNIT_YEARS)
|
||||||
|
else:
|
||||||
|
age_days = None
|
||||||
|
|
||||||
|
NormalCase.objects.create(case=case, age_days=age_days, added_by=request.user)
|
||||||
|
|
||||||
|
# Render the updated fragment
|
||||||
|
# Recompute flags for the fragment render
|
||||||
|
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||||
|
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||||
|
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||||
|
html = render_to_string(
|
||||||
|
"atlas/partials/_normal_toggle.html",
|
||||||
|
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_mark_normal": can_mark_normal},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def case_normal_form(request, pk):
|
||||||
|
"""Return an HTMX modal with a NormalCaseForm prepopulated with auto-extracted values."""
|
||||||
|
case = get_case_for_case_detail(pk)
|
||||||
|
|
||||||
|
# Permission check: allow atlas editors, case authors, or superusers
|
||||||
|
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||||
|
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||||
|
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||||
|
if not can_mark_normal:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
# Extract defaults
|
||||||
|
age = None
|
||||||
|
inferred_examination = None
|
||||||
|
inferred_modality = None
|
||||||
|
try:
|
||||||
|
for series in case.series.all():
|
||||||
|
img = series.images.filter(removed=False).first()
|
||||||
|
if not img:
|
||||||
|
continue
|
||||||
|
ds = img.get_dicom_data()
|
||||||
|
if not ds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pa = getattr(ds, "PatientAge", None)
|
||||||
|
if pa:
|
||||||
|
import re
|
||||||
|
m = re.search(r"(\d+)", str(pa))
|
||||||
|
if m:
|
||||||
|
age = int(m.group(1))
|
||||||
|
break
|
||||||
|
|
||||||
|
pbd = getattr(ds, "PatientBirthDate", None)
|
||||||
|
sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None)
|
||||||
|
if pbd and sdate:
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
bd = datetime.strptime(str(pbd), "%Y%m%d")
|
||||||
|
sd = datetime.strptime(str(sdate), "%Y%m%d")
|
||||||
|
years = round((sd - bd).days / 365.25)
|
||||||
|
age = int(years)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try inferring exam/modality from first series
|
||||||
|
first_series = case.series.first()
|
||||||
|
if first_series is not None:
|
||||||
|
inferred_examination = getattr(first_series, "examination", None)
|
||||||
|
inferred_modality = getattr(first_series, "modality", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build form with initial values
|
||||||
|
from .forms import NormalCaseForm
|
||||||
|
|
||||||
|
# Map inferred age (years) into age_value/unit defaults. If we inferred
|
||||||
|
# a fractional year (from birthdate), prefer months for ages < 2 years.
|
||||||
|
from .models import NormalCase
|
||||||
|
inferred_age_value = None
|
||||||
|
inferred_age_unit = None
|
||||||
|
try:
|
||||||
|
if age is not None:
|
||||||
|
# age currently holds integer years; for small ages prefer months
|
||||||
|
if age < 2:
|
||||||
|
inferred_age_value = int(age * 12)
|
||||||
|
inferred_age_unit = NormalCase.AGE_UNIT_MONTHS
|
||||||
|
else:
|
||||||
|
inferred_age_value = int(age)
|
||||||
|
inferred_age_unit = NormalCase.AGE_UNIT_YEARS
|
||||||
|
except Exception:
|
||||||
|
inferred_age_value = None
|
||||||
|
inferred_age_unit = None
|
||||||
|
|
||||||
|
|
||||||
|
form = NormalCaseForm(initial={
|
||||||
|
"age_value": inferred_age_value,
|
||||||
|
"age_unit": inferred_age_unit,
|
||||||
|
"examination": inferred_examination,
|
||||||
|
"modality": inferred_modality,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Log the rendered form HTML so we can verify inputs/names reach the client
|
||||||
|
from loguru import logger
|
||||||
|
try:
|
||||||
|
form_html = form.as_p()
|
||||||
|
logger.debug("case_normal_form rendered form HTML: {}", form_html)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to render form.as_p(): {}", e)
|
||||||
|
|
||||||
|
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request)
|
||||||
|
logger.debug("case_normal_form returning HTML length: {}", len(html))
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def create_case_normal(request, pk):
|
||||||
|
case = get_case_for_case_detail(pk)
|
||||||
|
|
||||||
|
# Permission check: allow atlas editors, case authors, or superusers
|
||||||
|
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||||
|
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||||
|
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||||
|
if not can_mark_normal:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
from .forms import NormalCaseForm
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
form = NormalCaseForm(request.POST)
|
||||||
|
logger.debug("create_case_normal POST data: {}", dict(request.POST))
|
||||||
|
if form.is_valid():
|
||||||
|
logger.debug("NormalCaseForm.cleaned_data: {}", form.cleaned_data)
|
||||||
|
nc = form.save(commit=False)
|
||||||
|
nc.case = case
|
||||||
|
nc.added_by = request.user
|
||||||
|
nc.save()
|
||||||
|
|
||||||
|
# Ensure the case reflects the newly created NormalCase when rendering
|
||||||
|
try:
|
||||||
|
if hasattr(case, "normal_case"):
|
||||||
|
delattr(case, "normal_case")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
case = get_case_for_case_detail(pk)
|
||||||
|
|
||||||
|
# Return the updated toggle fragment and close modal via inline script
|
||||||
|
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||||
|
is_case_author = case.author.filter(id=request.user.id).exists()
|
||||||
|
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
|
||||||
|
toggle_html = render_to_string(
|
||||||
|
"atlas/partials/_normal_toggle.html",
|
||||||
|
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_mark_normal": can_mark_normal},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(case.pk)
|
||||||
|
return HttpResponse(toggle_html)
|
||||||
|
else:
|
||||||
|
# On form errors, re-render the modal with errors
|
||||||
|
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request)
|
||||||
|
return HttpResponse(html, status=400)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||||
def series_thumbnail_fail(request, pk):
|
def series_thumbnail_fail(request, pk):
|
||||||
|
|||||||
+205
-168
@@ -556,187 +556,224 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
|
|||||||
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
||||||
|
|
||||||
|
|
||||||
class UserUserGroupForm(ModelForm):
|
class UserSearchSelectMultipleWidget:
|
||||||
class UserSearchSelectMultiple(object):
|
"""Reusable lightweight widget renderer for a searchable multi-user selector.
|
||||||
"""
|
|
||||||
Lightweight widget renderer for a searchable multi-user selector.
|
|
||||||
Renders a hidden <select multiple> containing selected user ids and
|
|
||||||
a search box with results. Uses a small inline script to fetch
|
|
||||||
results from the `generic:user_search_widget` endpoint and add/remove
|
|
||||||
users from the selection.
|
|
||||||
This keeps implementation simple and avoids external JS dependencies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name, value):
|
Usage: instantiate with (name, value) where value is an iterable of user ids
|
||||||
self.name = name
|
and call .render() to get the HTML fragment. This mirrors the previous
|
||||||
# value is a list of selected user ids
|
nested widget but is available module-wide for reuse.
|
||||||
self.value = value or []
|
"""
|
||||||
|
|
||||||
def render(self):
|
def __init__(self, name, value):
|
||||||
field_id = f"id_{self.name}"
|
self.name = name
|
||||||
search_id = f"user_search_{self.name}"
|
self.value = value or []
|
||||||
results_id = f"user_search_results_{self.name}"
|
|
||||||
selected_list_id = f"selected_users_{self.name}"
|
|
||||||
|
|
||||||
# Build initial options for selected users
|
def render(self):
|
||||||
users = User.objects.filter(pk__in=self.value) if self.value else []
|
field_id = f"id_{self.name}"
|
||||||
options_html = ""
|
search_id = f"user_search_{self.name}"
|
||||||
selected_html = ""
|
results_id = f"user_search_results_{self.name}"
|
||||||
for u in users:
|
selected_list_id = f"selected_users_{self.name}"
|
||||||
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
|
||||||
# include grade display in the selected list if available
|
|
||||||
grade_text = ""
|
|
||||||
try:
|
|
||||||
up = getattr(u, "userprofile", None)
|
|
||||||
if up and getattr(up, "grade", None):
|
|
||||||
g = up.grade
|
|
||||||
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
|
||||||
except Exception:
|
|
||||||
grade_text = ""
|
|
||||||
|
|
||||||
selected_html += (
|
users = User.objects.filter(pk__in=self.value) if self.value else []
|
||||||
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
options_html = ""
|
||||||
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
selected_html = ""
|
||||||
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
for u in users:
|
||||||
+ '</span>'
|
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
||||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
grade_text = ""
|
||||||
f'</li>'
|
|
||||||
)
|
|
||||||
|
|
||||||
url = reverse("generic:user_search_widget")
|
|
||||||
|
|
||||||
# Build a small grade filter select
|
|
||||||
grades_options = '<option value="">All grades</option>'
|
|
||||||
try:
|
try:
|
||||||
from generic.models import UserGrades
|
up = getattr(u, "userprofile", None)
|
||||||
grades_qs = UserGrades.objects.all()
|
if up and getattr(up, "grade", None):
|
||||||
for g in grades_qs:
|
g = up.grade
|
||||||
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
||||||
except Exception:
|
except Exception:
|
||||||
grades_options = '<option value="">All grades</option>'
|
grade_text = ""
|
||||||
|
|
||||||
html = f"""
|
selected_html += (
|
||||||
<div class="user-search-widget">
|
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||||
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
||||||
{options_html}
|
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
||||||
</select>
|
+ '</span>'
|
||||||
|
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
||||||
|
f'</li>'
|
||||||
|
)
|
||||||
|
|
||||||
<div class="mb-2">
|
url = reverse("generic:user_search_widget")
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
|
grades_options = '<option value="">All grades</option>'
|
||||||
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
try:
|
||||||
{grades_options}
|
from generic.models import UserGrades
|
||||||
</select>
|
|
||||||
</div>
|
grades_qs = UserGrades.objects.all()
|
||||||
<div id="{results_id}" class="mt-2"></div>
|
for g in grades_qs:
|
||||||
|
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
||||||
|
except Exception:
|
||||||
|
grades_options = '<option value="">All grades</option>'
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<div class="user-search-widget">
|
||||||
|
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
||||||
|
{options_html}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="d-flex gap-2 align-items-start">
|
||||||
|
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
|
||||||
|
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
||||||
|
{grades_options}
|
||||||
|
</select>
|
||||||
|
<!-- Help button for advanced search features -->
|
||||||
|
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div id="{results_id}" class="mt-2"></div>
|
||||||
<label class="form-label small">Selected users</label>
|
|
||||||
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
<!-- Collapsible help panel (hidden by default) -->
|
||||||
{selected_html}
|
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
|
||||||
|
<strong>Advanced search tips</strong>
|
||||||
|
<ul class="mb-0 mt-1">
|
||||||
|
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
|
||||||
|
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
|
||||||
|
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
|
||||||
|
<ul class="mb-0 mt-1">
|
||||||
|
<li><code>name:</code> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
|
||||||
|
<li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
|
||||||
|
<li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
|
||||||
|
<li><code>email:</code> — matches email address.</li>
|
||||||
|
<li><code>username:</code> — matches username.</li>
|
||||||
|
<li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
|
||||||
|
<li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
|
||||||
|
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
|
||||||
|
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
(function() {{
|
|
||||||
const search = document.getElementById('{search_id}');
|
|
||||||
const results = document.getElementById('{results_id}');
|
|
||||||
const select = document.getElementById('{field_id}');
|
|
||||||
const selectedList = document.getElementById('{selected_list_id}');
|
|
||||||
const gradeSelect = document.getElementById('grade_filter_{self.name}');
|
|
||||||
|
|
||||||
let timeout = null;
|
|
||||||
function fetchResults(q) {{
|
|
||||||
const grade = gradeSelect ? gradeSelect.value : '';
|
|
||||||
if (!q || q.trim().length === 0) {{
|
|
||||||
results.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
|
|
||||||
fetch(url)
|
|
||||||
.then(r => r.text())
|
|
||||||
.then(html => {{ results.innerHTML = html; }});
|
|
||||||
}}
|
|
||||||
|
|
||||||
search.addEventListener('keyup', function(e) {{
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
|
||||||
}});
|
|
||||||
|
|
||||||
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
|
||||||
// re-run search with same query when grade changes
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(() => fetchResults(search.value), 50);
|
|
||||||
}});
|
|
||||||
|
|
||||||
// fieldName for this widget instance
|
|
||||||
const widgetFieldName = '{self.name}';
|
|
||||||
|
|
||||||
// Add button click delegation: results list buttons have data attributes
|
|
||||||
results.addEventListener('click', function(e) {{
|
|
||||||
const btn = e.target.closest('.user-add-btn');
|
|
||||||
if (!btn) return;
|
|
||||||
const id = btn.getAttribute('data-user-id');
|
|
||||||
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
|
|
||||||
const grade = btn.getAttribute('data-user-grade') || '';
|
|
||||||
addUserToField(widgetFieldName, id, text, grade);
|
|
||||||
}});
|
|
||||||
|
|
||||||
// Wire up the "Add all results" button if present
|
|
||||||
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
|
|
||||||
if (addAllBtn) {{
|
|
||||||
addAllBtn.addEventListener('click', function(e) {{
|
|
||||||
e.preventDefault();
|
|
||||||
addAllFromResults(widgetFieldName);
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
|
|
||||||
window.addUserToField = function(fieldName, id, text, grade) {{
|
|
||||||
const sel = document.getElementById('id_' + fieldName);
|
|
||||||
if (!sel) return;
|
|
||||||
// prevent duplicate
|
|
||||||
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
|
||||||
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
|
|
||||||
// add to visible list (include grade if provided)
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
|
||||||
li.id = 'selected_user_' + fieldName + '_' + id;
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
|
|
||||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
|
||||||
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
|
||||||
li.appendChild(span); li.appendChild(btn);
|
|
||||||
selectedList.appendChild(li);
|
|
||||||
}}
|
|
||||||
|
|
||||||
window.removeUserFromField = function(fieldName, id) {{
|
|
||||||
const sel = document.getElementById('id_' + fieldName);
|
|
||||||
if (!sel) return;
|
|
||||||
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
|
||||||
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
|
|
||||||
if (li && li.parentNode) li.parentNode.removeChild(li);
|
|
||||||
}}
|
|
||||||
|
|
||||||
window.addAllFromResults = function(fieldName) {{
|
|
||||||
const list = document.getElementById('user_search_results_list_' + fieldName);
|
|
||||||
if (!list) return;
|
|
||||||
const items = list.querySelectorAll('li[data-user-id]');
|
|
||||||
items.forEach(function(it) {{
|
|
||||||
const id = it.getAttribute('data-user-id');
|
|
||||||
const text = it.getAttribute('data-user-text') || it.textContent.trim();
|
|
||||||
const grade = it.getAttribute('data-user-grade') || '';
|
|
||||||
addUserToField(fieldName, id, text, grade);
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
|
||||||
|
|
||||||
return mark_safe(html)
|
<div>
|
||||||
|
<label class="form-label small">Selected users</label>
|
||||||
|
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
||||||
|
{selected_html}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {{
|
||||||
|
const search = document.getElementById('{search_id}');
|
||||||
|
const results = document.getElementById('{results_id}');
|
||||||
|
const select = document.getElementById('{field_id}');
|
||||||
|
const selectedList = document.getElementById('{selected_list_id}');
|
||||||
|
const gradeSelect = document.getElementById('grade_filter_{self.name}');
|
||||||
|
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
|
||||||
|
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
|
||||||
|
|
||||||
|
let timeout = null;
|
||||||
|
function fetchResults(q) {{
|
||||||
|
const grade = gradeSelect ? gradeSelect.value : '';
|
||||||
|
if (!q || q.trim().length === 0) {{
|
||||||
|
results.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
|
||||||
|
fetch(url)
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(html => {{ results.innerHTML = html; }});
|
||||||
|
}}
|
||||||
|
|
||||||
|
search.addEventListener('keyup', function(e) {{
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||||
|
}});
|
||||||
|
|
||||||
|
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
||||||
|
// re-run search with same query when grade changes
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => fetchResults(search.value), 50);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Toggle help panel visibility
|
||||||
|
if (helpBtn && helpPanel) {{
|
||||||
|
helpBtn.addEventListener('click', function(e) {{
|
||||||
|
e.preventDefault();
|
||||||
|
helpPanel.classList.toggle('d-none');
|
||||||
|
const expanded = !helpPanel.classList.contains('d-none');
|
||||||
|
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
// fieldName for this widget instance
|
||||||
|
const widgetFieldName = '{self.name}';
|
||||||
|
|
||||||
|
// Add button click delegation: results list buttons have data attributes
|
||||||
|
results.addEventListener('click', function(e) {{
|
||||||
|
const btn = e.target.closest('.user-add-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.getAttribute('data-user-id');
|
||||||
|
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
|
||||||
|
const grade = btn.getAttribute('data-user-grade') || '';
|
||||||
|
addUserToField(widgetFieldName, id, text, grade);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Wire up the "Add all results" button if present
|
||||||
|
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
|
||||||
|
if (addAllBtn) {{
|
||||||
|
addAllBtn.addEventListener('click', function(e) {{
|
||||||
|
e.preventDefault();
|
||||||
|
addAllFromResults(widgetFieldName);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.addUserToField = function(fieldName, id, text, grade) {{
|
||||||
|
const sel = document.getElementById('id_' + fieldName);
|
||||||
|
if (!sel) return;
|
||||||
|
// prevent duplicate
|
||||||
|
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
||||||
|
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
|
||||||
|
// add to visible list (include grade if provided)
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
li.id = 'selected_user_' + fieldName + '_' + id;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
|
||||||
|
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||||
|
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
||||||
|
li.appendChild(span); li.appendChild(btn);
|
||||||
|
selectedList.appendChild(li);
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.removeUserFromField = function(fieldName, id) {{
|
||||||
|
const sel = document.getElementById('id_' + fieldName);
|
||||||
|
if (!sel) return;
|
||||||
|
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
||||||
|
const li = document.getElementById('selected_user_' + fieldName + '_' + id);
|
||||||
|
if (li && li.parentNode) li.parentNode.removeChild(li);
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.addAllFromResults = function(fieldName) {{
|
||||||
|
const list = document.getElementById('user_search_results_list_' + fieldName);
|
||||||
|
if (!list) return;
|
||||||
|
const items = list.querySelectorAll('li[data-user-id]');
|
||||||
|
items.forEach(function(it) {{
|
||||||
|
const id = it.getAttribute('data-user-id');
|
||||||
|
const text = it.getAttribute('data-user-text') || it.textContent.trim();
|
||||||
|
const grade = it.getAttribute('data-user-grade') || '';
|
||||||
|
addUserToField(fieldName, id, text, grade);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return mark_safe(html)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UserUserGroupForm(ModelForm):
|
||||||
|
# Use module-level UserSearchSelectMultipleWidget for rendering the users field
|
||||||
|
|
||||||
users = UserUserGroupModelChoiceField(
|
users = UserUserGroupModelChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
@@ -770,7 +807,7 @@ class UserUserGroupForm(ModelForm):
|
|||||||
# store attrs so template helpers can inspect them
|
# store attrs so template helpers can inspect them
|
||||||
self.attrs = attrs or {}
|
self.attrs = attrs or {}
|
||||||
value = value or []
|
value = value or []
|
||||||
widget = UserUserGroupForm.UserSearchSelectMultiple(name, value)
|
widget = UserSearchSelectMultipleWidget(name, value)
|
||||||
return widget.render()
|
return widget.render()
|
||||||
|
|
||||||
def value_from_datadict(self, data, files, name):
|
def value_from_datadict(self, data, files, name):
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="badge bg-primary">Answer: {{ answer }}</span>
|
<span class="badge bg-primary">Answer: {{ answer }}</span>
|
||||||
<span class="ms-2">{{ feedback }}</span>
|
<span class="ms-2 fst-italic opacity-75">{{ feedback }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user