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( + """ +
+ + Cancel +
+ """ + ) + + class BaseFormHelper(cf.FormHelper): error_text_inline = False use_custom_control = True @@ -146,6 +158,151 @@ def add_refresh_page_note(self): self.layout.insert(len(self.layout) - 1, note) +class InlineFilterFormHelper(BaseFormHelper): + """Helper class for creating an inline filtering form with a primary field.""" + + def __init__( + self, + form, + main_field: str, + appended_fields: list[str], + legend_text: str | None = None, + help_text: str | None = None, + **kwargs, + ): + """Inline form field helper, with primary search and appended fields. + + This form will have a single search bar with a primary search, inline + appended fields, and inline cancel/search buttons. + + Args: + form: form + main_field: A text input field + appended_fields: 1 or more checkbox or select fields (to right of main) + legend_text: Legend text to show on the form + help_text: help text to show on the form + **kwargs: Extra arguments + """ + self.attrs = {} + self.kwargs = kwargs + self.inputs = [] + self.form = form + self.main_field = main_field + self.appended_fields = appended_fields + self.legend_text = legend_text + self.help_text = help_text + self.build_inline_layout() + + def build_inline_layout(self): + """Build the custom inline layout, including the grid layout.""" + if self.main_field: + self.layout = cfl.Layout(*list(self.form.fields.keys())) + self.add_filter_field(self.main_field, self.appended_fields) + if self.form.grid_layout: + self.form.grid_layout.apply_layout(self) + else: + self.build_default_layout(self.form) + return self.layout + + def add_filter_field( + self, + main_field: str, + appended_fields: list[str], + expandable: bool = False, + ): + """Add the primary filter field (noncollapsed field(s)) to start of layout.""" + layout, index = self.get_layout_item(main_field) + field = layout.pop(index) + for app_field in appended_fields: + layout, index = self.get_layout_item(app_field) + layout.pop(index) + layout.insert( + 0, + FilterFormField( + fields=field, + appended_fields=appended_fields, + expandable=expandable, + ), + ) + + +class ExpandableFilterFormHelper(InlineFilterFormHelper): + """Helper class for an inline filtering form with collapsible advanced fields.""" + + collapse_field_name: str = "is_expanded" + + def __init__(self, *args, **kwargs): + """Collapsable form field helper, primary search and advanced. + + This form will have a single search bar with a primary search, and the + ability to expand the bar for additional "advanced" search fields. + + Args: + args: Arguments passed to InlineFilterFormHelper + kwargs: Keyword arguments passed to InlineFilterFormHelper + """ + super().__init__(*args, **kwargs) + self.build_collapsed_layout() + + def build_collapsed_layout(self): + """Build the custom collapsed layout including the grid layout.""" + if self.collapse_field_name not in self.form.fields: + raise ValueError(f"Field `{self.collapse_field_name}` is required for this form") + self.layout = cfl.Layout(*list(self.form.fields.keys())) + layout, collapsed_idx = self.get_layout_item(self.collapse_field_name) + collapsed_field = layout.pop(collapsed_idx) + self.add_filter_field(self.main_field, self.appended_fields, expandable=True) + self.layout.append(form_actions_big_apply_filters()) + form_index = 1 + if self.legend_text: + self.layout.insert(0, cfl.HTML(f"{self.legend_text}")) + form_index += 1 + if self.help_text: + self.layout.insert( + 1, + cfl.HTML(f'

{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 %} +
+
+ {% if expandable %} +
+ +
+ {% endif %} + +
+ {% for field in appended_fields %} + {% if field|is_checkbox %} +
+
+ {% crispy_field field 'class' 'custom-control-input' %} + +
+ {% if field.field.hover_help %} + {% include 'common/helptext_popup.html' with text=field.field.help_text %} + {% endif %} +
+ {% elif field|is_select and use_custom_control %} + {{field.widget}} + {% if field.errors %} + {% crispy_field field 'class' 'custom-select form-sm-field is-invalid' %} + {% else %} + {% crispy_field field 'class' 'custom-select form-sm-field' %} + {% endif %} + {% endif %} + {% endfor %} + + +
+
+
+{% if expandable %} + +{% endif %} \ No newline at end of file diff --git a/hawc/apps/common/templates/common/inline_filter_form.html b/hawc/apps/common/templates/common/inline_filter_form.html new file mode 100644 index 0000000000..ec59508570 --- /dev/null +++ b/hawc/apps/common/templates/common/inline_filter_form.html @@ -0,0 +1,4 @@ +{% load crispy_forms_tags %} +
+ {% crispy form %} +
\ No newline at end of file diff --git a/hawc/apps/common/views.py b/hawc/apps/common/views.py index 88989fe680..4feafba11a 100644 --- a/hawc/apps/common/views.py +++ b/hawc/apps/common/views.py @@ -706,15 +706,16 @@ def get_filterset_kwargs(self): data=self.request.GET, queryset=super().get_queryset(), request=self.request, + form_kwargs=self.get_filterset_form_kwargs(), ) - def get_filterset_class(self) -> type[BaseFilterSet]: - return self.filterset_class + def get_filterset_form_kwargs(self): + return {} @property def filterset(self): if not hasattr(self, "_filterset"): - self._filterset = self.get_filterset_class()(**self.get_filterset_kwargs()) + self._filterset = self.filterset_class(**self.get_filterset_kwargs()) return self._filterset def get_queryset(self): diff --git a/hawc/apps/eco/filterset.py b/hawc/apps/eco/filterset.py index f44dc91a36..22ff8b0b64 100644 --- a/hawc/apps/eco/filterset.py +++ b/hawc/apps/eco/filterset.py @@ -1,10 +1,11 @@ -from ..common.filterset import BaseFilterSet +from ..common.filterset import BaseFilterSet, FilterForm from . import models class NestedTermFilterSet(BaseFilterSet): class Meta: model = models.NestedTerm + form = FilterForm fields = { "name": ["contains"], } diff --git a/hawc/apps/epi/filterset.py b/hawc/apps/epi/filterset.py index 670d413afd..700c7de55e 100644 --- a/hawc/apps/epi/filterset.py +++ b/hawc/apps/epi/filterset.py @@ -6,6 +6,7 @@ from ..common.filterset import ( AutocompleteModelMultipleChoiceFilter, BaseFilterSet, + FilterForm, PaginationFilter, ) from ..study.autocomplete import StudyAutocomplete @@ -136,6 +137,7 @@ class OutcomeFilterSet(BaseFilterSet): class Meta: model = models.Outcome + form = FilterForm fields = [ "studies", "name", diff --git a/hawc/apps/epimeta/filterset.py b/hawc/apps/epimeta/filterset.py index 50b7a796ad..be63376064 100644 --- a/hawc/apps/epimeta/filterset.py +++ b/hawc/apps/epimeta/filterset.py @@ -4,6 +4,7 @@ from ..common.filterset import ( AutocompleteModelMultipleChoiceFilter, BaseFilterSet, + FilterForm, PaginationFilter, ) from ..study.autocomplete import StudyAutocomplete @@ -70,6 +71,7 @@ class MetaResultFilterSet(BaseFilterSet): class Meta: model = models.MetaResult + form = FilterForm fields = [ "studies", "label", diff --git a/hawc/apps/invitro/filterset.py b/hawc/apps/invitro/filterset.py index 3d77864080..d4033e8f8a 100644 --- a/hawc/apps/invitro/filterset.py +++ b/hawc/apps/invitro/filterset.py @@ -5,6 +5,7 @@ from ..common.filterset import ( AutocompleteModelMultipleChoiceFilter, BaseFilterSet, + FilterForm, PaginationFilter, ) from ..study.autocomplete import StudyAutocomplete @@ -113,6 +114,7 @@ class EndpointFilterSet(BaseFilterSet): class Meta: model = models.IVEndpoint + form = FilterForm fields = [ "studies", "name", diff --git a/hawc/apps/lit/filterset.py b/hawc/apps/lit/filterset.py index 23e2bb5d0f..cf265e67d3 100644 --- a/hawc/apps/lit/filterset.py +++ b/hawc/apps/lit/filterset.py @@ -3,7 +3,7 @@ from django.forms.widgets import CheckboxInput from django_filters import FilterSet -from ..common.filterset import BaseFilterSet, PaginationFilter, filter_noop +from ..common.filterset import BaseFilterSet, ExpandableFilterForm, PaginationFilter, filter_noop from . import models @@ -15,10 +15,12 @@ class ReferenceFilterSet(BaseFilterSet): label="External identifier", help_text="Pubmed ID, DOI, HERO ID, etc.", ) - year = df.NumberFilter(label="Year", help_text="Year of publication") journal = df.CharFilter(lookup_expr="icontains", label="Journal") - title_abstract = df.CharFilter(method="filter_title_abstract", label="Title/Abstract") - authors = df.CharFilter(method="filter_authors", label="Authors") + ref_search = df.CharFilter( + method="filter_search", + label="Title/Author/Year", + help_text="Filter by title, abstract, authors, or year", + ) search = df.ModelChoiceFilter( field_name="searches", queryset=models.Search.objects.all(), label="Search/Import" ) @@ -65,6 +67,7 @@ class ReferenceFilterSet(BaseFilterSet): help_text="All references that you have tagged", ) order_by = df.OrderingFilter( + empty_label="Default Order", fields=( ("authors", "authors"), ("year", "year"), @@ -83,16 +86,17 @@ class ReferenceFilterSet(BaseFilterSet): label="Partially Tagged", help_text="References with one unresolved user tag", ) - paginate_by = PaginationFilter() + paginate_by = PaginationFilter(empty_label="Default Pagination") class Meta: model = models.Reference + form = ExpandableFilterForm fields = [ "id", "db_id", "year", "journal", - "title_abstract", + "ref_search", "authors", "search", "tags", @@ -115,8 +119,14 @@ def filter_authors(self, queryset, name, value): query = Q(authors_short__unaccent__icontains=value) | Q(authors__unaccent__icontains=value) return queryset.filter(query) - def filter_title_abstract(self, queryset, name, value): - query = Q(title__icontains=value) | Q(abstract__icontains=value) + def filter_search(self, queryset, name, value): + query = ( + Q(title__icontains=value) + | Q(abstract__icontains=value) + | Q(authors_short__unaccent__icontains=value) + | Q(authors__unaccent__icontains=value) + | Q(year__icontains=value) + ) return queryset.filter(query) def filter_tags(self, queryset, name, value): diff --git a/hawc/apps/lit/templates/lit/conflict_resolution.html b/hawc/apps/lit/templates/lit/conflict_resolution.html index 0d830b5ab5..e3e8107302 100644 --- a/hawc/apps/lit/templates/lit/conflict_resolution.html +++ b/hawc/apps/lit/templates/lit/conflict_resolution.html @@ -2,7 +2,7 @@ {% block content %}

Resolve Tag Conflicts

-{% include 'common/filter_list.html' with plural_object_name='references' %} +{% include 'common/inline_filter_form.html' %}