Skip to content

Commit

Permalink
Merge pull request #165 from boxine/generic-model-field-filter
Browse files Browse the repository at this point in the history
Add: Generic view to filter any model by any field value
  • Loading branch information
cnschn authored Jul 9, 2024
2 parents b831c45 + d3dbf83 commit c1b74c5
Show file tree
Hide file tree
Showing 15 changed files with 698 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
# Move to pyproject.toml after: https://github.com/PyCQA/flake8/issues/234
#
[flake8]
#ignore = E402
ignore = E203, W504, E226
exclude = .*, migrations, dist, htmlcov
max-line-length = 119
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ Feature flags: https://github.com/boxine/bx_django_utils/blob/master/bx_django_u
* [`clean_filename()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/filename.py#L34-L64) - Convert filename to ASCII only via slugify.
* [`filename2human_name()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/filename.py#L7-L31) - Convert filename to a capitalized name.

#### bx_django_utils.generic_model_filter.admin_views

Generic "AdminExtraView" view to filter accessible model by any field value.

* [`GenericModelFilterBaseView()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/generic_model_filter/admin_views.py#L171-L321) - Base "AdminExtraView" to add this view to the admin interface via @register_admin_view().

### bx_django_utils.http

* [`build_url_parameters()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/http.py#L4-L30) - Return an encoded string of all given parameters.
Expand All @@ -190,6 +196,12 @@ Feature flags: https://github.com/boxine/bx_django_utils/blob/master/bx_django_u
* [`ColorModelField()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/models/color_field.py#L14-L29) - Hex color model field, e.g.: "#0055ff" (It's not a html color picker widget)
* [`HexColorValidator()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/models/color_field.py#L6-L11) - Hex color validator (seven-character hexadecimal notation, e.g.: "#0055ff")

#### bx_django_utils.models.get_models4user

* [`SelectModelForm()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/models/get_models4user.py#L43-L57) - Form to select a model that the user can "view"
* [`get_user_model_choices()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/models/get_models4user.py#L29-L40) - Build a form choices list with all models the given user can "view" | "add" | "change" | "delete" etc.
* [`iter_user_models()`](https://github.com/boxine/bx_django_utils/blob/master/bx_django_utils/models/get_models4user.py#L11-L26) - Filter models for the given user.

#### bx_django_utils.models.manipulate

Utilities to manipulate objects in database via models:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[
"/admin/feature_flags/feature-flags-values-demo/",
"/admin/feature_flags/manage/",
"/admin/generic-model-filter/generic-filter/",
"/admin/pseudo-app-1/demo-view-1/",
"/admin/pseudo-app-1/demo-view-2/",
"/admin/pseudo-app-2/demo-view-3/"
Expand Down
Empty file.
321 changes: 321 additions & 0 deletions bx_django_utils/generic_model_filter/admin_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
"""
Generic "AdminExtraView" view to filter accessible model by any field value.
This is not a perfect solution, because some model field types are not supported.
"""

import dataclasses

from django import forms
from django.apps import apps
from django.contrib import messages
from django.contrib.admin.utils import label_for_field
from django.core.paginator import Paginator
from django.db import models
from django.db.models.options import Options
from django.forms import modelform_factory
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.datastructures import MultiValueDict
from django.views.generic import FormView

from bx_django_utils.admin_extra_views.base_view import AdminExtraViewMixin
from bx_django_utils.admin_extra_views.datatypes import AdminExtraMeta
from bx_django_utils.http import build_url_parameters
from bx_django_utils.json_utils import make_json_serializable
from bx_django_utils.models.get_models4user import SelectModelForm

SESSION_KEY = 'generic_model_filter'
IS_NULL_CHECK_PREFIX = 'is_null_check_'


def iter_model_fields(ModelClass: type[models.Model]):
opts = ModelClass._meta
model_fields = opts.get_fields()
yield from model_fields


class NullChoices(models.TextChoices):
"""[no-doc] Choices for "isnull" fields."""

__empty__ = '-----'
IS_NULL = '0', 'is null'
NOT_NULL = '1', 'is not null'


def make_search_form(ModelClass: type[models.Model], field_names: list[str]) -> type[forms.BaseForm]:
"""[no-doc] Generate a search form that also include "non-editable" fields."""
editable_field_names = []
non_editable_fields = []
for model_field in iter_model_fields(ModelClass):
if model_field.name not in field_names:
continue

if model_field.editable:
editable_field_names.append(model_field.name)
else:
non_editable_fields.append(model_field)

# Start with a model form that contains all editable fields:
ModelValuesForm = modelform_factory(ModelClass, fields=editable_field_names)

# Make all fields optional:
for model_field in ModelValuesForm.base_fields.values():
model_field.required = False

# Add all non-editable fields:
for model_field in non_editable_fields:
form_field = model_field.formfield(required=False)
ModelValuesForm.base_fields[model_field.name] = form_field

# Add "isnull" choice fields to ModelValuesForm:
for field_name in field_names:
label = f'{IS_NULL_CHECK_PREFIX}{field_name}'
is_null_field = forms.ChoiceField(
label=label,
required=False,
choices=NullChoices.choices,
)
ModelValuesForm.base_fields[label] = is_null_field

return ModelValuesForm


def get_model_field_choices(ModelClass, exclude_fields=()):
opts = ModelClass._meta

choices = []
for field in iter_model_fields(ModelClass):
if field.name in exclude_fields:
continue

if field.related_model:
# TODO: Support relations
continue

if isinstance(field, models.FileField):
# TODO: Support file fields by replace the input to a normal Charfield
continue

label = label_for_field(field.name, ModelClass, opts)
choices.append((field.name, label))
return choices


class SelectModelFieldsForm(forms.Form):
"""[no-doc] Select model fields for the search."""

field_names = forms.MultipleChoiceField(label='Fields:')

def __init__(self, *args, ModelClass, **kwargs):
super().__init__(*args, **kwargs)

field = self.fields['field_names']
field.choices = get_model_field_choices(ModelClass)


@dataclasses.dataclass
class ModelFilterData:
"""[no-doc] Holds all data required to search in a model."""

perform_search: bool = False

# Step 1: selected model to be searched:
model_name: str = None
ModelClass: type[models.Model] = None

# Step 2: selected fields to be searched on the selected model:
field_names: list[str] = None
ModelValuesForm: type[forms.BaseForm] = None

def _set_model_name(self, model_name) -> None:
self.model_name = model_name
app_label, model_name = self.model_name.split('.')
self.ModelClass = apps.get_model(app_label=app_label, model_name=model_name)

def _set_field_names(self, field_names) -> None:
self.field_names = field_names
self.ModelValuesForm = make_search_form(ModelClass=self.ModelClass, field_names=self.field_names)

@classmethod
def from_query(cls, get_query: MultiValueDict) -> "ModelFilterData":
data = cls()
if model_name := get_query.get('model_name'):
data._set_model_name(model_name)

if field_names := get_query.getlist('field_names'):
data._set_field_names(field_names)

return data

def update_by_cleaned_data(self, cleaned_data: dict) -> None:
if model_name := cleaned_data.get('model_name'):
# Step 1: selected model to be searched:
self._set_model_name(model_name)
elif field_names := cleaned_data.get('field_names'):
# Step 2: selected fields to be searched on the selected model:
self._set_field_names(field_names)

def get_search_model_query_str(self) -> str:
return build_url_parameters(model_name=self.model_name)

def get_query_str(self) -> str:
"""
:return: all current information as URL encoded string
"""
parms = dict(model_name=self.model_name)
if self.field_names:
parms['field_names'] = self.field_names
return build_url_parameters(**parms)


class GenericModelFilterBaseView(AdminExtraViewMixin, FormView):
"""
Base "AdminExtraView" to add this view to the admin interface via @register_admin_view().
"""

meta = AdminExtraMeta(name='Generic model item filter', app_label='generic-filter')
template_name = 'generic_model_filter.html'

def get(self, request, *args, **kwargs):
self.data = ModelFilterData.from_query(get_query=request.GET)
return super().get(request, *args, **kwargs)

def post(self, request, *args, **kwargs):
self.data = ModelFilterData.from_query(get_query=request.GET)
return super().post(request, *args, **kwargs)

def form_valid(self, form):
cleaned_data = form.cleaned_data
if self.data.field_names:
# Input the search values
self.request.session[SESSION_KEY] = make_json_serializable(cleaned_data)
else:
self.data.update_by_cleaned_data(cleaned_data)

query_str = self.data.get_query_str()
url = reverse(self.meta.url_name)
url = f'{url}?{query_str}'
return HttpResponseRedirect(url)

def get_form_kwargs(self):
kwargs = super().get_form_kwargs()

if self.data.model_name and self.data.field_names:
# Step 3 + 4: Input the search values / search with last input
if self.request.method == 'POST':
kwargs['data'] = self.request.POST
elif SESSION_KEY in self.request.session:
kwargs['data'] = self.request.session[SESSION_KEY]
elif not self.data.model_name:
# Step 1: SelectModelForm:
kwargs['user'] = self.request.user
elif not self.data.field_names:
# Step 2: SelectModelFieldsForm:
kwargs['ModelClass'] = self.data.ModelClass
if SESSION_KEY in self.request.session:
del self.request.session[SESSION_KEY]
else:
raise NotImplementedError

return kwargs

def get_form_class(self):
if form_class := self.data.ModelValuesForm:
# Step 3 + 4: Input the search values / search with last input
return form_class
elif not self.data.model_name:
# Step 1: selected model to be searched:
return SelectModelForm
elif not self.data.field_names:
# Step 2: selected fields to be searched on the selected model:
return SelectModelFieldsForm
else:
raise NotImplementedError

def get_context_data(self, **context):
context = super().get_context_data(**context)

context['title'] = self.meta.name

start_url = reverse(self.meta.url_name)
context['start_url'] = start_url

if self.data.model_name and self.data.field_names:
context['subtitle'] = 'Step 3: The field values used to filter the model'
context['search_values_input'] = True
elif not self.data.model_name:
context['subtitle'] = 'Step 1: selected model to be searched'
elif not self.data.field_names:
context['subtitle'] = 'Step 2: selected fields to be searched on the selected model'

if ModelClass := self.data.ModelClass:
options: Options = ModelClass._meta
context['opts'] = options

search_model_query_str = self.data.get_search_model_query_str()
context['search_model_url'] = f'{start_url}?{search_model_query_str}'

if SESSION_KEY in self.request.session:
raw_search_values = self.request.session[SESSION_KEY]

qs_filter_kwargs = {}
is_null_check_fields = []
for key, value in raw_search_values.items():
if key.startswith(IS_NULL_CHECK_PREFIX):
if value == NullChoices.IS_NULL:
isnull = True
elif value == NullChoices.NOT_NULL:
isnull = False
else:
continue

model_field_name = key[len(IS_NULL_CHECK_PREFIX) :]
qs_filter_kwargs[f'{model_field_name}__isnull'] = isnull
is_null_check_fields.append(model_field_name)

for key, value in raw_search_values.items():
if not key.startswith(IS_NULL_CHECK_PREFIX) and key not in is_null_check_fields:
qs_filter_kwargs[key] = value

messages.info(self.request, f'Search with: {qs_filter_kwargs}')

queryset = ModelClass.objects.all()
context['total_count'] = queryset.count()

queryset = queryset.filter(**qs_filter_kwargs)

# Pagination may yield inconsistent results with an unordered object list
order_by = queryset.query.order_by
if not order_by:
if options.ordering:
# Order by Meta.ordering:
queryset = queryset.order_by(*options.ordering)
else:
queryset = queryset.order_by('pk')

context['filtered_count'] = queryset.count()

paginator = Paginator(queryset, 50)
page_number = self.request.GET.get('__page')
context['page_obj'] = paginator.get_page(page_number)
context['query_str'] = self.data.get_query_str()

# Collect "isnull" fields:
form = context['form']
regular_field_names = []
null_fields = {}
for form_field in form:
field_name = form_field.name
if field_name.startswith(IS_NULL_CHECK_PREFIX):
field_name = field_name[len(IS_NULL_CHECK_PREFIX) :]
null_fields[field_name] = form_field
else:
regular_field_names.append(field_name)
context['regular_field_names'] = regular_field_names

# Add the "isnull" field to the regular field, for forms rendering:
for form_field in form:
if null_field := null_fields.get(form_field.name):
form_field.null_field = null_field

return context
8 changes: 8 additions & 0 deletions bx_django_utils/generic_model_filter/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.apps import AppConfig


class GenericModelFilterAppConfig(AppConfig):
"""""" # noqa - don't add in README

name = 'bx_django_utils.generic_model_filter'
verbose_name = 'Generic Model Filter'
Loading

0 comments on commit c1b74c5

Please sign in to comment.