Files

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")