diff --git a/hawc/apps/animal/filterset.py b/hawc/apps/animal/filterset.py index 23a3499ac6..3167228dc7 100644 --- a/hawc/apps/animal/filterset.py +++ b/hawc/apps/animal/filterset.py @@ -8,6 +8,7 @@ AutocompleteModelChoiceFilter, AutocompleteModelMultipleChoiceFilter, BaseFilterSet, + FilterForm, PaginationFilter, ) from ..study.autocomplete import StudyAutocomplete @@ -166,6 +167,7 @@ class EndpointFilterSet(BaseFilterSet): class Meta: model = models.Endpoint + form = FilterForm fields = [ "studies", "chemical", diff --git a/hawc/apps/common/filterset.py b/hawc/apps/common/filterset.py index 86f850951f..3422976889 100644 --- a/hawc/apps/common/filterset.py +++ b/hawc/apps/common/filterset.py @@ -3,12 +3,16 @@ import django_filters as df from crispy_forms import layout as cfl from django import forms -from django_filters.utils import get_model_field from pydantic import BaseModel, conlist from ..assessment.models import Assessment from . import autocomplete -from .forms import BaseFormHelper, form_actions_apply_filters +from .forms import ( + BaseFormHelper, + ExpandableFilterFormHelper, + InlineFilterFormHelper, + form_actions_apply_filters, +) def filter_noop(queryset, name, value): @@ -88,111 +92,71 @@ def apply_layout(self, helper): row.apply_layout(helper, i) -class FilterForm(forms.Form): - def __init__(self, *args, **kwargs): - grid_layout = kwargs.pop("grid_layout", None) - self.grid_layout = GridLayout.parse_obj(grid_layout) if grid_layout is not None else None +class InlineFilterForm(forms.Form): + """Form model used for inline filterset forms.""" + + helper_class = InlineFilterFormHelper + def __init__(self, *args, **kwargs): + """Grabs grid_layout kwarg and passes it to GridLayout to apply the layout.""" + layout = kwargs.pop("grid_layout", None) + self.grid_layout = GridLayout.parse_obj(layout) if layout else None + self.main_field = kwargs.pop("main_field", None) + self.appended_fields = kwargs.pop("appended_fields", []) + self.dynamic_fields = kwargs.pop("dynamic_fields", []) super().__init__(*args, **kwargs) @property def helper(self): - helper = BaseFormHelper(self, form_actions=form_actions_apply_filters()) + """Helper method for setting up the form.""" + helper = self.helper_class( + self, + main_field=self.main_field, + appended_fields=self.appended_fields, + ) helper.form_method = "GET" - - if self.grid_layout: - self.grid_layout.apply_layout(helper) - return helper -class FilterSetOptions: - def __init__(self, options=None): - self.model = getattr(options, "model", None) - self.fields = getattr(options, "fields", None) - self.exclude = getattr(options, "exclude", None) - - self.filter_overrides = getattr(options, "filter_overrides", {}) - self.field_kwargs = getattr(options, "field_kwargs", {}) - self.grid_layout = getattr(options, "grid_layout", None) +class ExpandableFilterForm(InlineFilterForm): + """Form model used for expandable inline filterset forms.""" - self.form = getattr(options, "form", FilterForm) + helper_class = ExpandableFilterFormHelper + is_expanded = forms.CharField(initial="false", widget=forms.HiddenInput()) -class FilterSetMetaclass(df.filterset.FilterSetMetaclass): - def __new__(cls, name, bases, attrs): - attrs["declared_filters"] = cls.get_declared_filters(bases, attrs) + def clean(self): + """Remove 'is_expanded' from form data before data is used for filtering.""" + cleaned_data = super().clean() + cleaned_data.pop("is_expanded", None) - new_class = type.__new__(cls, name, bases, attrs) - new_class._meta = FilterSetOptions(getattr(new_class, "Meta", None)) - new_class.base_filters = new_class.get_filters() - for filter_name, field_kwargs in new_class._meta.field_kwargs.items(): - new_class.base_filters[filter_name].extra.update(field_kwargs) +class FilterForm(forms.Form): + # TODO: remove once all filtersets are converted to Inline or Expandable forms + def __init__(self, *args, **kwargs): + grid_layout = kwargs.pop("grid_layout", None) + self.grid_layout = GridLayout.parse_obj(grid_layout) if grid_layout is not None else None + self.dynamic_fields = kwargs.pop("dynamic_fields", None) + super().__init__(*args, **kwargs) - return new_class + @property + def helper(self): + helper = BaseFormHelper(self, form_actions=form_actions_apply_filters()) + helper.form_method = "GET" + if self.grid_layout: + self.grid_layout.apply_layout(helper) -class FilterSet(df.filterset.BaseFilterSet, metaclass=FilterSetMetaclass): - pass + return helper -class BaseFilterSet(FilterSet): - def __init__(self, *args, assessment: Assessment | None = None, **kwargs): +class BaseFilterSet(df.FilterSet): + def __init__(self, *args, assessment: Assessment | None = None, form_kwargs=None, **kwargs): self.assessment = assessment super().__init__(*args, **kwargs) - - @classmethod - def get_filters(cls): - """ - Get all filters for the filterset. This is the combination of declared and - generated filters. - - Overridden from django-filter's BaseFilterSet; we do not include declared filters by default - near the end of the method. - """ - - # No model specified - skip filter generation - if not cls._meta.model: - return cls.declared_filters.copy() - - # Determine the filters that should be included on the filterset. - filters = {} - fields = cls.get_fields() - undefined = [] - - for field_name, lookups in fields.items(): - field = get_model_field(cls._meta.model, field_name) - - # warn if the field doesn't exist. - if field is None: - undefined.append(field_name) - - for lookup_expr in lookups: - filter_name = cls.get_filter_name(field_name, lookup_expr) - - # If the filter is explicitly declared on the class, skip generation - if filter_name in cls.declared_filters: - filters[filter_name] = cls.declared_filters[filter_name] - continue - - if field is not None: - filters[filter_name] = cls.filter_for_field(field, field_name, lookup_expr) - - # Allow Meta.fields to contain declared filters *only* when a list/tuple - if isinstance(cls._meta.fields, list | tuple): - undefined = [f for f in undefined if f not in cls.declared_filters] - - if undefined: - raise TypeError( - "'Meta.fields' must not contain non-model field names: %s" % ", ".join(undefined) - ) - - # Add in declared filters. This is necessary since we don't enforce adding - # declared filters to the 'Meta.fields' option - ### We actually do want to enforce Meta.fields; this allows dynamically specifying fields - # filters.update(cls.declared_filters) - return filters + self.form_kwargs = form_kwargs or {} + if "grid_layout" not in self.form_kwargs and hasattr(self.Meta, "grid_layout"): + self.form_kwargs.update(grid_layout=self.Meta.grid_layout) @property def perms(self): @@ -204,21 +168,14 @@ def form(self): self._form = self.create_form() return self._form - def create_form(self) -> forms.Form: - """Create the form used for the filterset. - - Returns: - forms.Form: a django.Form instance - """ - Form = self.get_form_class() + def create_form(self): + form_class = self.get_form_class() if self.is_bound: - form = Form(self.data, prefix=self.form_prefix, grid_layout=self._meta.grid_layout) + form = form_class(self.data, prefix=self.form_prefix, **self.form_kwargs) else: - form = Form(prefix=self.form_prefix, grid_layout=self._meta.grid_layout) + form = form_class(prefix=self.form_prefix, **self.form_kwargs) + if form.dynamic_fields: # removes unwanted fields from a filterset if specified + for field in list(form.fields.keys()): + if field not in form.dynamic_fields and field != "is_expanded": + form.fields.pop(field) return form - - -def dynamic_filterset(_class: type[BaseFilterSet], **meta_kwargs): - default_kwargs = _class._meta.__dict__ - Meta = type("Meta", (object,), {**default_kwargs, **meta_kwargs}) - return type("DynamicFilterset", (_class,), {"Meta": Meta}) diff --git a/hawc/apps/common/forms.py b/hawc/apps/common/forms.py index 49f212b256..12a4f6d456 100644 --- a/hawc/apps/common/forms.py +++ b/hawc/apps/common/forms.py @@ -57,6 +57,18 @@ def form_actions_apply_filters(): ] +def form_actions_big_apply_filters(): + """Create big, centered Submit and Cancel buttons for filter forms.""" + return cfl.HTML( + """ +
{self.help_text}
'), + ) + form_index += 1 + if self.form.grid_layout: + self.form.grid_layout.apply_layout(self) + self[form_index:].wrap_together(cfl.Div, css_class="p-4") + is_expanded = self.form.data.get(self.collapse_field_name, "false") == "true" + self[form_index:].wrap_together( + cfl.Div, + id="ff-expand-form", + css_class="collapse show" if is_expanded else "collapse", + ) + self.layout.append(collapsed_field) + return self.layout + + +class FilterFormField(cfl.Field): + """Custom crispy form field that includes appended_fields in the context.""" + + template = "common/crispy_layout_filter_field.html" + + def __init__( + self, + fields, + appended_fields: list[str], + expandable: bool = False, + **kwargs, + ): + """Set the given field values on the field model.""" + self.fields = fields + self.appended_fields = appended_fields + self.expandable = expandable + super().__init__(fields, **kwargs) + + def render(self, form, form_style, context, template_pack, extra_context=None, **kwargs): + """Render the main_field and appended_fields in the template and return it.""" + if extra_context is None: + extra_context = {} + extra_context["appended_fields"] = [form[field] for field in self.appended_fields] + extra_context["expandable"] = self.expandable + return super().render(form, form_style, context, template_pack, extra_context, **kwargs) + + class CopyAsNewSelectorForm(forms.Form): label = None parent_field = None diff --git a/hawc/apps/common/templates/common/crispy_layout_filter_field.html b/hawc/apps/common/templates/common/crispy_layout_filter_field.html new file mode 100644 index 0000000000..8532a637b1 --- /dev/null +++ b/hawc/apps/common/templates/common/crispy_layout_filter_field.html @@ -0,0 +1,47 @@ +{% load crispy_forms_field %} +{% load crispy_forms_filters %} +{% comment %} Adapted from https://github.com/django-crispy-forms/crispy-bootstrap4/blob/main/crispy_bootstrap4/templates/bootstrap4/field.html {% endcomment %} +