604 lines
22 KiB
Python
Executable File
604 lines
22 KiB
Python
Executable File
import django_filters
|
|
from django import forms
|
|
from crispy_forms.helper import FormHelper
|
|
from crispy_forms.layout import Layout, Div, Field
|
|
from collections import OrderedDict
|
|
|
|
from .models import (
|
|
Case,
|
|
CaseCollection,
|
|
Condition,
|
|
Finding,
|
|
PathologicalProcess,
|
|
Presentation,
|
|
Procedure,
|
|
QuestionSchema,
|
|
Series,
|
|
Structure,
|
|
Subspecialty,
|
|
NormalCase,
|
|
Resource,
|
|
Site,
|
|
)
|
|
from django.contrib.auth.models import User
|
|
from django.db.models import Q
|
|
from django.http import QueryDict
|
|
|
|
|
|
def get_authors(request):
|
|
if request is None:
|
|
return User.objects.none()
|
|
|
|
if not request.user.groups.filter(name="atlas_editor").exists():
|
|
queryset = User.objects.filter(id=request.user.id)
|
|
else:
|
|
queryset = User.objects.all()
|
|
|
|
return queryset
|
|
|
|
|
|
def get_cases(request):
|
|
if request is None:
|
|
return Case.objects.none()
|
|
|
|
if not request.user.groups.filter(name="atlas_editor").exists():
|
|
queryset = Case.objects.filter(author__id=request.user.id)
|
|
else:
|
|
queryset = Case.objects.all()
|
|
|
|
return queryset
|
|
|
|
|
|
def get_series(request):
|
|
if request is None:
|
|
return Series.objects.none()
|
|
|
|
if not request.user.groups.filter(name="atlas_editor").exists():
|
|
queryset = Series.objects.filter(author__id=request.user.id)
|
|
else:
|
|
queryset = Series.objects.all()
|
|
|
|
queryset = queryset.prefetch_related("examination", "plane")
|
|
|
|
return queryset
|
|
|
|
|
|
class CaseCollectionFilter(django_filters.FilterSet):
|
|
author = django_filters.ModelMultipleChoiceFilter(
|
|
queryset=get_authors, null_label="No author"
|
|
)
|
|
cases = django_filters.ModelMultipleChoiceFilter(
|
|
queryset=get_cases, null_label="No cases"
|
|
)
|
|
|
|
class Meta:
|
|
model = CaseCollection
|
|
fields = (
|
|
"archive",
|
|
"author",
|
|
) # "site",
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
# Default the archive filter to False when not provided so collections
|
|
# are not archived by default in listings.
|
|
if data is None:
|
|
data = QueryDict('', mutable=True)
|
|
data['archive'] = 'false'
|
|
else:
|
|
# QueryDict instances need to be copied to be mutable
|
|
try:
|
|
if 'archive' not in data:
|
|
data = data.copy()
|
|
data['archive'] = 'false'
|
|
except Exception:
|
|
# Fallback for non-QueryDict mappings
|
|
if isinstance(data, dict) and 'archive' not in data:
|
|
data = dict(data)
|
|
data['archive'] = 'false'
|
|
if not request.user.groups.filter(name="atlas_editor").exists():
|
|
queryset = queryset.prefetch_related("author").filter(
|
|
author__id=request.user.id
|
|
)
|
|
super(CaseCollectionFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
pass
|
|
|
|
|
|
class CaseFilter(django_filters.FilterSet):
|
|
author = django_filters.ModelMultipleChoiceFilter(
|
|
queryset=get_authors, null_label="No author"
|
|
)
|
|
# Show a distinct list of `Series.examination` values instead of raw Series
|
|
# entries so examinations that differ only by plane/description appear once.
|
|
series = django_filters.ModelMultipleChoiceFilter(
|
|
field_name='series__examination',
|
|
queryset=lambda request: __import__('generic.models', fromlist=['Examination']).Examination.objects.filter(atlas_series_examination__isnull=False).distinct(),
|
|
label='Examination',
|
|
)
|
|
# Free-text search to help find the correct examination when there are
|
|
# many similarly-named Series/Examination entries.
|
|
examination_text = django_filters.CharFilter(method='filter_examination_text', label='Examination (text)')
|
|
# Full list of conditions and a text search to find conditions
|
|
condition = django_filters.ModelMultipleChoiceFilter(
|
|
queryset=Condition.objects.all(),
|
|
label='Condition',
|
|
)
|
|
condition_text = django_filters.CharFilter(method='filter_condition_text', label='Condition (text)')
|
|
differential_case = django_filters.ModelMultipleChoiceFilter(
|
|
label="Differential",
|
|
field_name="differentialcase__condition",
|
|
# to_field_name="differential",
|
|
queryset=Condition.objects.all(),
|
|
)
|
|
|
|
class Meta:
|
|
model = Case
|
|
fields = (
|
|
"created_date",
|
|
"open_access",
|
|
"author",
|
|
"differential_case",
|
|
) # "site",
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
if not request.user.groups.filter(name="atlas_editor").exists():
|
|
queryset = queryset.prefetch_related("author").filter(
|
|
(Q(author__id=request.user.id) | Q(open_access=True))
|
|
)
|
|
super(CaseFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
pass
|
|
|
|
def filter_examination_text(self, queryset, name, value):
|
|
"""Filter Cases by a text search on related Examination records.
|
|
|
|
Matches against the Examination string representation and any
|
|
common identifying fields (name/code). Uses a case-insensitive
|
|
contains lookup and filters Cases which have a Series linked to
|
|
a matching Examination.
|
|
"""
|
|
if not value:
|
|
return queryset
|
|
|
|
from generic.models import Examination
|
|
|
|
# Only attempt to match PK when the provided value looks like an integer
|
|
try:
|
|
pk_val = int(value)
|
|
except Exception:
|
|
pk_val = None
|
|
|
|
if pk_val is not None:
|
|
matches = Examination.objects.filter(
|
|
Q(examination__icontains=value) | Q(pk=pk_val)
|
|
).distinct()
|
|
else:
|
|
matches = Examination.objects.filter(
|
|
Q(examination__icontains=value)
|
|
).distinct()
|
|
|
|
return queryset.filter(series__examination__in=matches).distinct()
|
|
|
|
def filter_condition_text(self, queryset, name, value):
|
|
"""Filter Cases by a text search on related Condition records.
|
|
|
|
Matches both direct `condition` M2M and `differential` entries.
|
|
"""
|
|
if not value:
|
|
return queryset
|
|
|
|
# Only try PK match when numeric
|
|
try:
|
|
pk_val = int(value)
|
|
except Exception:
|
|
pk_val = None
|
|
|
|
if pk_val is not None:
|
|
matches = Condition.objects.filter(Q(name__icontains=value) | Q(pk=pk_val)).distinct()
|
|
else:
|
|
matches = Condition.objects.filter(Q(name__icontains=value)).distinct()
|
|
|
|
# Match either direct condition M2M or differential (through Differential)
|
|
return queryset.filter(Q(condition__in=matches) | Q(differential__in=matches)).distinct()
|
|
|
|
|
|
class SeriesFilter(django_filters.FilterSet):
|
|
case = django_filters.ModelMultipleChoiceFilter(
|
|
queryset=get_cases, null_label="No case"
|
|
)
|
|
author = django_filters.ModelMultipleChoiceFilter(
|
|
queryset=get_authors, null_label="No author"
|
|
)
|
|
|
|
class Meta:
|
|
model = Series
|
|
fields = ("modality", "case", "examination", "author")
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
if not request.user.groups.filter(name="atlas_editor").exists():
|
|
# queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id)
|
|
queryset = queryset.filter(author__id=request.user.id)
|
|
super(SeriesFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
pass
|
|
|
|
|
|
class ConditionFilter(django_filters.FilterSet):
|
|
# Replace legacy `primary` filter with `is_canonical` and provide a
|
|
# `synonym` filter which will filter by the canonical group of the
|
|
# selected condition.
|
|
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
|
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
|
|
|
|
class Meta:
|
|
model = Condition
|
|
fields = {
|
|
"name": ["icontains"],
|
|
"subspecialty": ["exact"],
|
|
"rcr_curriculum": ["exact"],
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
super(ConditionFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
pass
|
|
|
|
def filter_is_canonical(self, queryset, name, value):
|
|
if value in (True, "True", "true", 1, "1"):
|
|
return queryset.filter(canonical__isnull=True)
|
|
if value in (False, "False", "false", 0, "0"):
|
|
return queryset.filter(canonical__isnull=False)
|
|
return queryset
|
|
|
|
def filter_synonym(self, queryset, name, value):
|
|
# value is a Condition instance selected in the filter. Return all
|
|
# Conditions that belong to the same canonical group as `value`.
|
|
if not value:
|
|
return queryset
|
|
master = value.canonical if value.canonical else value
|
|
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
|
|
|
|
|
class FindingFilter(django_filters.FilterSet):
|
|
# Replace legacy `primary`/`synonym` with canonical-based filters
|
|
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
|
synonym = django_filters.ModelChoiceFilter(queryset=Finding.objects.all(), method="filter_synonym", label="Synonym group")
|
|
|
|
class Meta:
|
|
model = Finding
|
|
fields = {
|
|
"name": ["icontains"],
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
super(FindingFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
pass
|
|
|
|
def filter_is_canonical(self, queryset, name, value):
|
|
if value in (True, "True", "true", 1, "1"):
|
|
return queryset.filter(canonical__isnull=True)
|
|
if value in (False, "False", "false", 0, "0"):
|
|
return queryset.filter(canonical__isnull=False)
|
|
return queryset
|
|
|
|
def filter_synonym(self, queryset, name, value):
|
|
# value is a Finding instance selected in the filter. Return all
|
|
# Findings that belong to the same canonical group as `value`.
|
|
if not value:
|
|
return queryset
|
|
master = value.canonical if value.canonical else value
|
|
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
|
|
|
|
|
class StructureFilter(django_filters.FilterSet):
|
|
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
|
synonym = django_filters.ModelChoiceFilter(queryset=Structure.objects.all(), method="filter_synonym", label="Synonym group")
|
|
|
|
class Meta:
|
|
model = Structure
|
|
fields = {"name": ["icontains"]}
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
super(StructureFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
pass
|
|
|
|
def filter_is_canonical(self, queryset, name, value):
|
|
if value in (True, "True", "true", 1, "1"):
|
|
return queryset.filter(canonical__isnull=True)
|
|
if value in (False, "False", "false", 0, "0"):
|
|
return queryset.filter(canonical__isnull=False)
|
|
return queryset
|
|
|
|
def filter_synonym(self, queryset, name, value):
|
|
if not value:
|
|
return queryset
|
|
master = value.canonical if value.canonical else value
|
|
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
|
|
|
|
|
class PresentationFilter(django_filters.FilterSet):
|
|
class Meta:
|
|
model = Presentation
|
|
fields = ("name", "subspecialty")
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
super(PresentationFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
pass
|
|
|
|
|
|
class PathologicalProcessFilter(django_filters.FilterSet):
|
|
class Meta:
|
|
model = PathologicalProcess
|
|
fields = ("name",)
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
super(PathologicalProcessFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
pass
|
|
|
|
|
|
class SubspecialtyFilter(django_filters.FilterSet):
|
|
class Meta:
|
|
model = Subspecialty
|
|
fields = ("name",)
|
|
|
|
def __init__(
|
|
self,
|
|
data=None,
|
|
queryset=None,
|
|
prefix=None,
|
|
strict=None,
|
|
user=None,
|
|
request=None,
|
|
):
|
|
super(SubspecialtyFilter, self).__init__(
|
|
data=data, queryset=queryset, prefix=prefix, request=request
|
|
)
|
|
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())
|
|
procedures = django_filters.ModelMultipleChoiceFilter(field_name="case__procedures", queryset=Procedure.objects.all(), label="Procedure")
|
|
# Accept min/max as years in the UI but filter against the canonical
|
|
# `age_days` field by converting years -> days.
|
|
# Numeric min/max values entered by the user (interpreted via `age_unit`)
|
|
min_value = django_filters.NumberFilter(label="Min", method="filter_min_value")
|
|
max_value = django_filters.NumberFilter(label="Max", method="filter_max_value")
|
|
AGE_UNIT_CHOICES = (
|
|
("days", "Days"),
|
|
("weeks", "Weeks"),
|
|
("months", "Months"),
|
|
("years", "Years"),
|
|
)
|
|
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES, method="filter_age_unit")
|
|
|
|
class Meta:
|
|
model = NormalCase
|
|
fields = ("examination", "modality", "procedures", "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
|
|
|
|
# We'll set the actual querysets for the filters after applying
|
|
# any NormalCase queryset restrictions for the current user so
|
|
# that the choices only include examinations/modalities that
|
|
# actually have NormalCase records available.
|
|
|
|
# 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)))
|
|
|
|
# Determine the NormalCase queryset we'll use to build choices
|
|
nc_qs = queryset if queryset is not None else NormalCase.objects.all()
|
|
|
|
# Limit examination and modality choices to those present on the
|
|
# available NormalCase records (respecting user restrictions).
|
|
try:
|
|
exam_ids = nc_qs.values_list("examination_id", flat=True)
|
|
mod_ids = nc_qs.values_list("modality_id", flat=True)
|
|
proc_ids = nc_qs.values_list("case__procedures", flat=True)
|
|
self.base_filters.get("examination").queryset = Examination.objects.filter(id__in=exam_ids).distinct()
|
|
self.base_filters.get("modality").queryset = Modality.objects.filter(id__in=mod_ids).distinct()
|
|
self.base_filters.get("procedures").queryset = Procedure.objects.filter(id__in=proc_ids).distinct()
|
|
except Exception:
|
|
# Fallback to all if something goes wrong
|
|
self.base_filters.get("examination").queryset = Examination.objects.all()
|
|
self.base_filters.get("modality").queryset = Modality.objects.all()
|
|
|
|
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
|
|
|
# Attach a Crispy FormHelper to render the filter form as a
|
|
# horizontal Bootstrap grid. We set `form_tag=False` because the
|
|
# template already provides the <form> element.
|
|
try:
|
|
helper = FormHelper()
|
|
helper.form_method = "get"
|
|
helper.form_tag = False
|
|
helper.form_class = "row g-2"
|
|
helper.layout = Layout(
|
|
Div(
|
|
Div("examination", css_class="col-md-3"),
|
|
Div("modality", css_class="col-md-3"),
|
|
Div("procedures", css_class="col-md-3"),
|
|
css_class="row g-2",
|
|
),
|
|
Div(
|
|
Div(
|
|
Field("min_value", wrapper_class="mb-0 d-flex align-items-center"),
|
|
Field("max_value", wrapper_class="mb-0 d-flex align-items-center"),
|
|
Field("age_unit", wrapper_class="mb-0 d-flex align-items-center"),
|
|
css_class="col-12 d-flex gap-2 align-items-center",
|
|
),
|
|
css_class="row g-2",
|
|
),
|
|
)
|
|
# Assign helper to the generated form instance and tweak widgets
|
|
if hasattr(self, 'form') and self.form is not None:
|
|
# Wider numeric inputs and default unit
|
|
try:
|
|
form = self.form
|
|
if "min_value" in form.fields:
|
|
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
|
|
if "max_value" in form.fields:
|
|
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control w-auto", "min": "0", "step": "1", "style": "min-width:140px;"})
|
|
if "age_unit" in form.fields:
|
|
form.fields["age_unit"].initial = "days"
|
|
form.fields["age_unit"].widget.attrs.update({"class": "form-select w-auto", "style": "min-width:220px;"})
|
|
form.helper = helper
|
|
except Exception:
|
|
# don't crash if form structure differs
|
|
self.form.helper = helper
|
|
except Exception:
|
|
pass
|
|
|
|
def _convert_to_days(self, value, unit: str) -> int:
|
|
try:
|
|
v = float(value)
|
|
except Exception:
|
|
raise
|
|
|
|
if unit == "days":
|
|
return int(v)
|
|
if unit == "weeks":
|
|
return int(v * 7)
|
|
if unit == "months":
|
|
# approximate month length
|
|
return int(v * (365.25 / 12.0))
|
|
# default: years
|
|
return int(v * 365.25)
|
|
|
|
def filter_min_value(self, queryset, name, value):
|
|
try:
|
|
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
|
|
except Exception:
|
|
unit = "days"
|
|
try:
|
|
days = self._convert_to_days(value, unit)
|
|
except Exception:
|
|
return queryset
|
|
return queryset.filter(age_days__gte=days)
|
|
|
|
def filter_max_value(self, queryset, name, value):
|
|
try:
|
|
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "days")
|
|
except Exception:
|
|
unit = "days"
|
|
try:
|
|
days = self._convert_to_days(value, unit)
|
|
except Exception:
|
|
return queryset
|
|
return queryset.filter(age_days__lte=days)
|
|
|
|
def filter_age_unit(self, queryset, name, value):
|
|
# `age_unit` is a helper input used by min/max conversion and does not
|
|
# map to a database field on NormalCase.
|
|
return queryset
|
|
|
|
|
|
class ResourceFilter(django_filters.FilterSet):
|
|
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
|
|
site = django_filters.ModelMultipleChoiceFilter(field_name='sites', queryset=lambda request: Site.objects.all() if True else Site.objects.none())
|
|
subspecialty = django_filters.ModelMultipleChoiceFilter(field_name='subspecialty', queryset=Subspecialty.objects.all())
|
|
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label='No author')
|
|
has_file = django_filters.BooleanFilter(method='filter_has_file', label='Has file')
|
|
|
|
class Meta:
|
|
model = Resource
|
|
fields = ('name', 'site', 'subspecialty', 'author')
|
|
|
|
def filter_has_file(self, queryset, name, value):
|
|
if value is True:
|
|
return queryset.exclude(file__isnull=True).exclude(file__exact='')
|
|
elif value is False:
|
|
return queryset.filter(file__isnull=True) | queryset.filter(file__exact='')
|
|
return queryset
|
|
|
|
class QuestionSchemaFilter(django_filters.FilterSet):
|
|
class Meta:
|
|
model = QuestionSchema
|
|
fields = ("name", "description") |