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,
|
||||
CaseDetail,
|
||||
CaseCollection,
|
||||
NormalCase,
|
||||
Differential,
|
||||
Series,
|
||||
SeriesImage,
|
||||
@@ -79,6 +80,22 @@ class CaseCollectionAdmin(VersionAdmin):
|
||||
|
||||
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 Meta:
|
||||
model = Case
|
||||
|
||||
@@ -11,6 +11,7 @@ from .models import (
|
||||
Series,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
NormalCase,
|
||||
)
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
@@ -286,6 +287,54 @@ class SubspecialtyFilter(django_filters.FilterSet):
|
||||
)
|
||||
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 Meta:
|
||||
model = QuestionSchema
|
||||
|
||||
+45
-24
@@ -44,6 +44,7 @@ from atlas.models import (
|
||||
UserReportAnswer,
|
||||
CaseDisplaySet,
|
||||
)
|
||||
from .models import NormalCase
|
||||
|
||||
from anatomy.models import Modality
|
||||
|
||||
@@ -236,35 +237,55 @@ class CaseCollectionForm(ModelForm):
|
||||
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
||||
widget=SplitDateTimeWidget(
|
||||
date_attrs={"type": "date", "class": "datepicker"},
|
||||
time_attrs={"type": "time", "class": "timepicker"},
|
||||
date_format="%Y-%m-%d",
|
||||
time_format="%H:%M",
|
||||
|
||||
class NormalCaseForm(ModelForm):
|
||||
# Keep non-model input fields for age entry so users can provide a
|
||||
# value + unit (e.g. 6 months) which will be converted to canonical
|
||||
# days on save. The model now stores only `age_days`.
|
||||
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,
|
||||
help_text="The date the exam is due to start (time is optional)",
|
||||
)
|
||||
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)",
|
||||
Field("notes"),
|
||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
# Get the unsaved Case instance
|
||||
instance = ModelForm.save(self, False)
|
||||
# Respect commit flag. Compute canonical `age_days` from the
|
||||
# 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:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
to_keep = [
|
||||
"Columns",
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
<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>
|
||||
</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>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@@ -69,6 +72,7 @@
|
||||
<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>
|
||||
</li>
|
||||
|
||||
<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>
|
||||
</li>
|
||||
|
||||
@@ -147,6 +147,10 @@
|
||||
|
||||
{# Diagnostic certainty as a badge #}
|
||||
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>
|
||||
|
||||
|
||||
@@ -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/search_partial/", views.categories_search_partial, name="categories_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>/delete",
|
||||
@@ -454,6 +455,9 @@ urlpatterns = [
|
||||
# path("unchecked/", views.unchecked_list, name="unchecked_list"),
|
||||
# path("verified/<int:pk>/", views.verified_detail, name="verified_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: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.query import QuerySet
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django import forms
|
||||
from django.utils import timezone
|
||||
@@ -107,6 +108,7 @@ from .models import (
|
||||
UserReportAnswer,
|
||||
PrerequisiteRequired
|
||||
)
|
||||
from .models import NormalCase
|
||||
from .tables import (
|
||||
CaseCollectionTable,
|
||||
CaseTable,
|
||||
@@ -130,6 +132,7 @@ from .filters import (
|
||||
SeriesFilter,
|
||||
StructureFilter,
|
||||
SubspecialtyFilter,
|
||||
NormalCaseFilter,
|
||||
)
|
||||
|
||||
from .tasks import push_case_to_cimar_task
|
||||
@@ -288,6 +291,22 @@ class AtlasEditorRequiredMixin(object):
|
||||
return obj
|
||||
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
|
||||
@user_has_case_view_access
|
||||
def case_displaysets(request, pk):
|
||||
@@ -437,6 +456,10 @@ def case_detail(request, pk):
|
||||
case = get_case_for_case_detail(pk)
|
||||
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(
|
||||
request,
|
||||
"atlas/case_detail.html",
|
||||
@@ -444,9 +467,265 @@ def case_detail(request, pk):
|
||||
"case": case,
|
||||
"cimar_sid": request.user.userprofile.cimar_sid,
|
||||
"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
|
||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||
def series_thumbnail_fail(request, pk):
|
||||
|
||||
+205
-168
@@ -556,187 +556,224 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
|
||||
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
||||
|
||||
|
||||
class UserUserGroupForm(ModelForm):
|
||||
class UserSearchSelectMultiple(object):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
class UserSearchSelectMultipleWidget:
|
||||
"""Reusable lightweight widget renderer for a searchable multi-user selector.
|
||||
|
||||
def __init__(self, name, value):
|
||||
self.name = name
|
||||
# value is a list of selected user ids
|
||||
self.value = value or []
|
||||
Usage: instantiate with (name, value) where value is an iterable of user ids
|
||||
and call .render() to get the HTML fragment. This mirrors the previous
|
||||
nested widget but is available module-wide for reuse.
|
||||
"""
|
||||
|
||||
def render(self):
|
||||
field_id = f"id_{self.name}"
|
||||
search_id = f"user_search_{self.name}"
|
||||
results_id = f"user_search_results_{self.name}"
|
||||
selected_list_id = f"selected_users_{self.name}"
|
||||
def __init__(self, name, value):
|
||||
self.name = name
|
||||
self.value = value or []
|
||||
|
||||
# Build initial options for selected users
|
||||
users = User.objects.filter(pk__in=self.value) if self.value else []
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
for u in users:
|
||||
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 = ""
|
||||
def render(self):
|
||||
field_id = f"id_{self.name}"
|
||||
search_id = f"user_search_{self.name}"
|
||||
results_id = f"user_search_results_{self.name}"
|
||||
selected_list_id = f"selected_users_{self.name}"
|
||||
|
||||
selected_html += (
|
||||
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
||||
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
||||
+ '</span>'
|
||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
||||
f'</li>'
|
||||
)
|
||||
|
||||
url = reverse("generic:user_search_widget")
|
||||
|
||||
# Build a small grade filter select
|
||||
grades_options = '<option value="">All grades</option>'
|
||||
users = User.objects.filter(pk__in=self.value) if self.value else []
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
for u in users:
|
||||
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
||||
grade_text = ""
|
||||
try:
|
||||
from generic.models import UserGrades
|
||||
grades_qs = UserGrades.objects.all()
|
||||
for g in grades_qs:
|
||||
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
||||
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:
|
||||
grades_options = '<option value="">All grades</option>'
|
||||
grade_text = ""
|
||||
|
||||
html = f"""
|
||||
<div class="user-search-widget">
|
||||
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
||||
{options_html}
|
||||
</select>
|
||||
selected_html += (
|
||||
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
||||
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
||||
+ '</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">
|
||||
<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" />
|
||||
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
||||
{grades_options}
|
||||
</select>
|
||||
</div>
|
||||
<div id="{results_id}" class="mt-2"></div>
|
||||
url = reverse("generic:user_search_widget")
|
||||
|
||||
grades_options = '<option value="">All grades</option>'
|
||||
try:
|
||||
from generic.models import UserGrades
|
||||
|
||||
grades_qs = UserGrades.objects.all()
|
||||
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>
|
||||
<label class="form-label small">Selected users</label>
|
||||
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
||||
{selected_html}
|
||||
<div id="{results_id}" class="mt-2"></div>
|
||||
|
||||
<!-- Collapsible help panel (hidden by default) -->
|
||||
<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>
|
||||
</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>
|
||||
"""
|
||||
|
||||
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(
|
||||
required=False,
|
||||
@@ -770,7 +807,7 @@ class UserUserGroupForm(ModelForm):
|
||||
# store attrs so template helpers can inspect them
|
||||
self.attrs = attrs or {}
|
||||
value = value or []
|
||||
widget = UserUserGroupForm.UserSearchSelectMultiple(name, value)
|
||||
widget = UserSearchSelectMultipleWidget(name, value)
|
||||
return widget.render()
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user