From 602dca7bf464359c901ba6dc1845ef90bfb8b080 Mon Sep 17 00:00:00 2001 From: Birger Schacht Date: Thu, 21 Dec 2023 15:33:45 +0100 Subject: [PATCH] feat: introduce generic app This commit introduces a `generic` app, that provides generic Create, View, Update, Delete, List and Autocomplete views that work with ContentTypes as well as an API viewset that also works with ContentTypes Most of the used classes (Table, FilterSet, Serializers, ...) can be overriden an every level of the inheritance chain. Closes: #509 --- apis_core/generic/api_views.py | 29 +++ apis_core/generic/filtersets.py | 45 ++++ apis_core/generic/forms.py | 66 ++++++ apis_core/generic/helpers.py | 71 ++++++ apis_core/generic/serializers.py | 48 ++++ apis_core/generic/tables.py | 92 ++++++++ .../generic/templates/columns/delete.html | 8 + .../templates/columns/description.html | 2 + apis_core/generic/templates/columns/edit.html | 4 + apis_core/generic/templates/columns/view.html | 4 + .../generic/generic_confirm_delete.html | 18 ++ .../templates/generic/generic_content.html | 18 ++ .../templates/generic/generic_detail.html | 23 ++ .../templates/generic/generic_form.html | 9 + .../templates/generic/generic_list.html | 63 ++++++ .../generic/templates/generic/overview.html | 16 ++ apis_core/generic/templatetags/apisgeneric.py | 39 ++++ apis_core/generic/urls.py | 48 ++++ apis_core/generic/views.py | 212 ++++++++++++++++++ apis_core/urls.py | 1 + tests/settings_ci.py | 1 + 21 files changed, 817 insertions(+) create mode 100644 apis_core/generic/api_views.py create mode 100644 apis_core/generic/filtersets.py create mode 100644 apis_core/generic/forms.py create mode 100644 apis_core/generic/helpers.py create mode 100644 apis_core/generic/serializers.py create mode 100644 apis_core/generic/tables.py create mode 100644 apis_core/generic/templates/columns/delete.html create mode 100644 apis_core/generic/templates/columns/description.html create mode 100644 apis_core/generic/templates/columns/edit.html create mode 100644 apis_core/generic/templates/columns/view.html create mode 100644 apis_core/generic/templates/generic/generic_confirm_delete.html create mode 100644 apis_core/generic/templates/generic/generic_content.html create mode 100644 apis_core/generic/templates/generic/generic_detail.html create mode 100644 apis_core/generic/templates/generic/generic_form.html create mode 100644 apis_core/generic/templates/generic/generic_list.html create mode 100644 apis_core/generic/templates/generic/overview.html create mode 100644 apis_core/generic/templatetags/apisgeneric.py create mode 100644 apis_core/generic/urls.py create mode 100644 apis_core/generic/views.py diff --git a/apis_core/generic/api_views.py b/apis_core/generic/api_views.py new file mode 100644 index 000000000..c9843d3fc --- /dev/null +++ b/apis_core/generic/api_views.py @@ -0,0 +1,29 @@ +from rest_framework import viewsets +from .serializers import serializer_factory +from .helpers import first_match_via_mro + + +class ModelViewSet(viewsets.ModelViewSet): + """ + API ViewSet for a generic model. + The queryset is overridden by the first match from + the `first_match_via_mro` helper. + The serializer class is overridden by the first match from + the `first_match_via_mro` helper. + """ + + def dispatch(self, *args, **kwargs): + self.model = kwargs.get("contenttype").model_class() + return super().dispatch(*args, **kwargs) + + def get_queryset(self): + queryset = first_match_via_mro( + self.model, path="querysets", suffix="ViewSetQueryset" + ) + return queryset or self.model.objects.all() + + def get_serializer_class(self): + serializer_class = first_match_via_mro( + self.model, path="serializers", suffix="Serializer" + ) or serializer_factory(self.model) + return serializer_class diff --git a/apis_core/generic/filtersets.py b/apis_core/generic/filtersets.py new file mode 100644 index 000000000..980a93172 --- /dev/null +++ b/apis_core/generic/filtersets.py @@ -0,0 +1,45 @@ +from django_filters.filterset import FilterSet +from .forms import GenericFilterSetForm + + +class GenericFilterSet(FilterSet): + """ + This is a workaround because the FilterSet class of django-filters + does not allow passing form arguments to the form - but we want to + pass the `model` to the form, so we can create the `columns` form + field. + See also: https://github.com/carltongibson/django-filter/issues/1630 + """ + + @property + def form(self): + if not hasattr(self, "_form"): + Form = self.get_form_class() + if self.is_bound: + self._form = Form( + self.data, prefix=self.form_prefix, model=self._meta.model + ) + else: + self._form = Form(prefix=self.form_prefix, model=self._meta.model) + return self._form + + +def filterset_factory(model, filterset=GenericFilterSet, fields="__all__"): + """ + A custom filterset_factory, because we want to be a able to set the + filterset as well as the `form` attribute of the filterset + This can hopefully be removed once + https://github.com/carltongibson/django-filter/issues/1631 is implemented. + """ + + meta = type( + str("Meta"), + (object,), + {"model": model, "fields": fields, "form": GenericFilterSetForm}, + ) + filterset = type( + str("%sFilterSet" % model._meta.object_name), + (filterset,), + {"Meta": meta}, + ) + return filterset diff --git a/apis_core/generic/forms.py b/apis_core/generic/forms.py new file mode 100644 index 000000000..f1354f48e --- /dev/null +++ b/apis_core/generic/forms.py @@ -0,0 +1,66 @@ +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Submit +from dal import autocomplete + + +class GenericFilterSetForm(forms.Form): + """ + FilterSet form for generic models + Adds a submit button using the django crispy form helper + Adds a `columns` selector that lists all the fields from + the model + """ + + columns = forms.MultipleChoiceField(required=False) + + def __init__(self, *args, **kwargs): + model = kwargs.pop("model") + super().__init__(*args, **kwargs) + self.fields["columns"].choices = [ + (field.name, field.verbose_name) for field in model._meta.fields + ] + + self.helper = FormHelper() + self.helper.form_method = "GET" + self.helper.add_input(Submit("submit", "Submit")) + + def clean(self): + self.cleaned_data = super().clean() + self.cleaned_data.pop("columns", None) + return self.cleaned_data + + +class GenericModelForm(forms.ModelForm): + """ + Model form for generic models + Adds a submit button using the django crispy form helper + and sets the ModelChoiceFields and ModelMultipleChoiceFields + to use autocomplete replacement fields + """ + + class Meta: + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.add_input(Submit("submit", "Submit")) + + # override the fields pointing to other models, + # to make them use the autocomplete widgets + override_fieldtypes = { + "ModelMultipleChoiceField": autocomplete.ModelSelect2Multiple, + "ModelChoiceField": autocomplete.ModelSelect2, + } + for field in self.fields: + clsname = self.fields[field].__class__.__name__ + if clsname in override_fieldtypes.keys(): + ct = ContentType.objects.get_for_model( + self.fields[field]._queryset.model + ) + url = reverse("apis_core:generic:autocomplete", args=[ct]) + self.fields[field].widget = override_fieldtypes[clsname](url) + self.fields[field].widget.choices = self.fields[field].choices diff --git a/apis_core/generic/helpers.py b/apis_core/generic/helpers.py new file mode 100644 index 000000000..9576192c8 --- /dev/null +++ b/apis_core/generic/helpers.py @@ -0,0 +1,71 @@ +import functools +import inspect +import importlib + +from django.db.models import CharField, TextField, Q, Model + + +def generate_search_filter(model, query): + """ + Generate a default search filter that searches for the `query` + in all the CharFields and TextFields of a model (case-insensitive) + """ + + fields_to_search = [ + field.name + for field in model._meta.fields + if isinstance(field, (CharField, TextField)) + ] + q = Q() + + for token in query: + q &= functools.reduce( + lambda acc, field_name: acc | Q(**{f"{field_name}__icontains": token}), + fields_to_search, + Q(), + ) + return q + + +def mro_paths(model): + """ + Create a list of MRO classes for a Django model + """ + paths = [] + for cls in filter(lambda x: x not in Model.mro(), model.mro()): + paths.append(cls.__module__.split(".")[:-1] + [cls.__name__]) + return paths + + +def template_names_via_mro(model, suffix=""): + """ + Use the MRO to generate a list of template names for a model + """ + mro_prefix_list = ["/".join(prefix) for prefix in mro_paths(model)] + return [f"{prefix.lower()}{suffix}" for prefix in mro_prefix_list] + + +def class_from_path(classpath): + """ + Lookup if the class in `classpath` exists - if so return it, + otherwise return False + """ + module, cls = classpath.rsplit(".", 1) + try: + members = inspect.getmembers(importlib.import_module(module)) + members = list(filter(lambda c: c[0] == cls, members)) + except ModuleNotFoundError: + return False + if members: + return members[0][1] + return False + + +def first_match_via_mro(model, path: str = "", suffix: str = ""): + """ + Based on the MRO of a Django model, look for classes based on a + lookup algorithm and return the first one that matches. + """ + paths = list(map(lambda x: x[:-1] + [path] + x[-1:], mro_paths(model))) + classes = [".".join(prefix) + suffix for prefix in paths] + return next(map(class_from_path, classes), None) diff --git a/apis_core/generic/serializers.py b/apis_core/generic/serializers.py new file mode 100644 index 000000000..05a109248 --- /dev/null +++ b/apis_core/generic/serializers.py @@ -0,0 +1,48 @@ +from django.contrib.contenttypes.models import ContentType +from rest_framework.serializers import ( + HyperlinkedModelSerializer, + HyperlinkedRelatedField, +) +from rest_framework.reverse import reverse + + +class GenericHyperlinkedRelatedField(HyperlinkedRelatedField): + def get_url(self, obj, view_name, request, format): + contenttype = ContentType.objects.get_for_model(obj, for_concrete_model=True) + url_kwargs = {"contenttype": contenttype, "pk": obj.pk} + return reverse( + "apis_core:generic:genericmodelapi-detail", + kwargs=url_kwargs, + request=request, + format=format, + ) + + def use_pk_only_optimization(self): + # We have the complete object instance already. We don't need + # to run the 'only get the pk for this relationship' code. + return False + + +class GenericHyperlinkedIdentityField(GenericHyperlinkedRelatedField): + def __init__(self, view_name=None, **kwargs): + assert view_name is not None, "The `view_name` argument is required." + kwargs["read_only"] = True + kwargs["source"] = "*" + super().__init__(view_name, **kwargs) + + +class GenericHyperlinkedModelSerializer(HyperlinkedModelSerializer): + serializer_related_field = GenericHyperlinkedRelatedField + serializer_url_field = GenericHyperlinkedIdentityField + + +def serializer_factory( + model, serializer=GenericHyperlinkedModelSerializer, fields="__all__", **kwargs +): + meta = type(str("Meta"), (object,), {"model": model, "fields": fields}) + serializer = type( + str("%sModelSerializer" % model._meta.object_name), + (serializer,), + {"Meta": meta}, + ) + return serializer diff --git a/apis_core/generic/tables.py b/apis_core/generic/tables.py new file mode 100644 index 000000000..8fee975c0 --- /dev/null +++ b/apis_core/generic/tables.py @@ -0,0 +1,92 @@ +import django_tables2 as tables + + +class CustomTemplateColumn(tables.TemplateColumn): + """ + A custom template column - the `tables.TemplateColumn` class does not allow + to set attributes via class variables. Therefor we use this + CustomTemplateColumn to set some arguments based on class attributes and + override the attributes in child classes. + """ + + template_name = None + orderable = None + exclude_from_export = False + verbose_name = None + + def __init__(self, *args, **kwargs): + super().__init__( + template_name=self.template_name, + orderable=self.orderable, + exclude_from_export=self.exclude_from_export, + verbose_name=self.verbose_name, + *args, + **kwargs + ) + + +class DeleteColumn(CustomTemplateColumn): + """ + A column showing a delete button + """ + + template_name = "columns/delete.html" + orderable = False + exclude_from_export = True + verbose_name = "" + attrs = {"td": {"style": "width:1%;"}} + + +class EditColumn(CustomTemplateColumn): + """ + A column showing an edit button + """ + + template_name = "columns/edit.html" + orderable = False + exclude_from_export = True + verbose_name = "" + attrs = {"td": {"style": "width:1%;"}} + + +class ViewColumn(CustomTemplateColumn): + """ + A column showing a view button + """ + + template_name = "columns/view.html" + orderable = False + exclude_from_export = True + verbose_name = "" + attrs = {"td": {"style": "width:1%;"}} + + +class DescriptionColumn(CustomTemplateColumn): + """ + A column showing a model description + """ + + template_name = "columns/description.html" + orderable = False + + +class GenericTable(tables.Table): + """ + A generic table that contains an edit button column, a delete button column + and a description column + """ + + edit = EditColumn() + desc = DescriptionColumn() + delete = DeleteColumn() + view = ViewColumn() + + class Meta: + fields = ["id", "desc"] + + def __init__(self, *args, **kwargs): + # if there is no custom sequence set, move `edit` and `delete` to the back + if "sequence" not in kwargs: + kwargs["sequence"] = ["...", "view", "edit", "delete"] + + super().__init__(*args, **kwargs) diff --git a/apis_core/generic/templates/columns/delete.html b/apis_core/generic/templates/columns/delete.html new file mode 100644 index 000000000..1cf5afde4 --- /dev/null +++ b/apis_core/generic/templates/columns/delete.html @@ -0,0 +1,8 @@ +{% load apisgeneric %} +delete diff --git a/apis_core/generic/templates/columns/description.html b/apis_core/generic/templates/columns/description.html new file mode 100644 index 000000000..e75f409cd --- /dev/null +++ b/apis_core/generic/templates/columns/description.html @@ -0,0 +1,2 @@ +{% load apisgeneric %} +{{ record }} diff --git a/apis_core/generic/templates/columns/edit.html b/apis_core/generic/templates/columns/edit.html new file mode 100644 index 000000000..b275eb7a0 --- /dev/null +++ b/apis_core/generic/templates/columns/edit.html @@ -0,0 +1,4 @@ +{% load apisgeneric %} +edit diff --git a/apis_core/generic/templates/columns/view.html b/apis_core/generic/templates/columns/view.html new file mode 100644 index 000000000..85eeae49b --- /dev/null +++ b/apis_core/generic/templates/columns/view.html @@ -0,0 +1,4 @@ +{% load apisgeneric %} +visibility diff --git a/apis_core/generic/templates/generic/generic_confirm_delete.html b/apis_core/generic/templates/generic/generic_confirm_delete.html new file mode 100644 index 000000000..2eb7f69e3 --- /dev/null +++ b/apis_core/generic/templates/generic/generic_confirm_delete.html @@ -0,0 +1,18 @@ +{% extends basetemplate|default:"base.html" %} + +{% block content %} + +{% endblock content %} diff --git a/apis_core/generic/templates/generic/generic_content.html b/apis_core/generic/templates/generic/generic_content.html new file mode 100644 index 000000000..793796253 --- /dev/null +++ b/apis_core/generic/templates/generic/generic_content.html @@ -0,0 +1,18 @@ +{% extends basetemplate|default:"base.html" %} + +{% block content %} +
+
+
+ + {% block col %} + {% endblock col %} + +
+ + {% block additionalcols %} + {% endblock additionalcols %} + +
+
+{% endblock content %} diff --git a/apis_core/generic/templates/generic/generic_detail.html b/apis_core/generic/templates/generic/generic_detail.html new file mode 100644 index 000000000..ef46f96ee --- /dev/null +++ b/apis_core/generic/templates/generic/generic_detail.html @@ -0,0 +1,23 @@ +{% extends "generic/generic_content.html" %} +{% load apisgeneric %} + +{% if object %} + + {% block col %} +
+
{{ object }}
+
+ + {% modeldict object as d %} + {% for key, value in d.items %} + + + + + {% endfor %} +
{{ key.verbose_name }}{{ value }}
+
+
+ {% endblock col %} + +{% endif %} diff --git a/apis_core/generic/templates/generic/generic_form.html b/apis_core/generic/templates/generic/generic_form.html new file mode 100644 index 000000000..196b3c1f9 --- /dev/null +++ b/apis_core/generic/templates/generic/generic_form.html @@ -0,0 +1,9 @@ +{% extends "generic/generic_content.html" %} +{% load crispy_forms_tags %} + +{% block col %} +
+
Edit {{ object }}
+
{% crispy form form.helper %}
+
+{% endblock col %} diff --git a/apis_core/generic/templates/generic/generic_list.html b/apis_core/generic/templates/generic/generic_list.html new file mode 100644 index 000000000..7744e916a --- /dev/null +++ b/apis_core/generic/templates/generic/generic_list.html @@ -0,0 +1,63 @@ +{% extends "generic/generic_content.html" %} +{% load render_table from django_tables2 %} +{% load crispy_forms_tags %} +{% load apisgeneric %} + +{% block scripts %} + {{ block.super }} + +{% endblock scripts %} + +{% if filter %} + + {% block col %} +
+
+
+
{{ object_list.model|contenttype }}
+
+ Create +
+
+
+
+ + {% block filter %} + {% crispy filter.form filter.form.helper %} + {% endblock filter %} + +
+ +
+ {% endblock col %} + +{% endif %} + +{% if table %} + + {% block additionalcols %} +
+
+
{{ table.paginator.count }} results
+
+ + {% block table %} + {% render_table table %} + {% endblock table %} + +
+
+
+ {% endblock additionalcols %} + +{% endif %} diff --git a/apis_core/generic/templates/generic/overview.html b/apis_core/generic/templates/generic/overview.html new file mode 100644 index 000000000..3153d5c33 --- /dev/null +++ b/apis_core/generic/templates/generic/overview.html @@ -0,0 +1,16 @@ +{% extends "generic/generic_content.html" %} +{% load apisgeneric %} + +{% block col %} +
+
Overview
+
+ {% contenttypes "apis_ontology" as contenttypes %} + {% for contenttype in contenttypes %} + + + + {% endfor %} +
+
+{% endblock col %} diff --git a/apis_core/generic/templatetags/apisgeneric.py b/apis_core/generic/templatetags/apisgeneric.py new file mode 100644 index 000000000..08c773be4 --- /dev/null +++ b/apis_core/generic/templatetags/apisgeneric.py @@ -0,0 +1,39 @@ +from itertools import chain + +from django import template +from django.contrib.contenttypes.models import ContentType + + +register = template.Library() + + +@register.filter +def contenttype(model): + return ContentType.objects.get_for_model(model) + + +@register.simple_tag +def modeldict(instance, fields=None, exclude=None): + opts = instance._meta + data = {} + for f in chain(opts.concrete_fields, opts.private_fields, opts.many_to_many): + if not getattr(f, "editable", False): + continue + if fields is not None and f.name not in fields: + continue + if exclude and f.name in exclude: + continue + field = instance._meta.get_field(f.name) + data[field] = instance._get_FIELD_display(field) + if getattr(field, "m2m_field_name", False): + values = getattr(instance, field.name).all() + data[field] = ", ".join([str(value) for value in values]) + return data + + +@register.simple_tag +def contenttypes(app_labels=None): + if app_labels: + app_labels = app_labels.split(",") + return ContentType.objects.filter(app_label__in=app_labels) + return ContentType.objects.all() diff --git a/apis_core/generic/urls.py b/apis_core/generic/urls.py new file mode 100644 index 000000000..918dc285e --- /dev/null +++ b/apis_core/generic/urls.py @@ -0,0 +1,48 @@ +from django.contrib.contenttypes.models import ContentType +from django.shortcuts import get_object_or_404 +from django.urls import include, path, register_converter +from rest_framework import routers + +from apis_core.generic import views, api_views + +app_name = "generic" + + +class ContenttypeConverter: + """ + A converter that converts from a string representation of a + model (`app_label.model`) to the actual Django model class. + """ + + regex = r"\w+\.\w+" + + def to_python(self, value): + app_label, model = value.split(".") + return get_object_or_404(ContentType, app_label=app_label, model=model) + + def to_url(self, value): + return f"{value.app_label}.{value.model}" + + +register_converter(ContenttypeConverter, "contenttype") + +router = routers.DefaultRouter() +router.register(r"", api_views.ModelViewSet, basename="genericmodelapi") + +urlpatterns = [ + path("overview/", views.Overview.as_view(), name="overview"), + path( + "/", + include( + [ + path("", views.List.as_view(), name="list"), + path("", views.Detail.as_view(), name="detail"), + path("create", views.Create.as_view(), name="create"), + path("delete/", views.Delete.as_view(), name="delete"), + path("update/", views.Update.as_view(), name="update"), + path("autocomplete", views.Autocomplete.as_view(), name="autocomplete"), + ] + ), + ), + path("api//", include(router.urls)), +] diff --git a/apis_core/generic/views.py b/apis_core/generic/views.py new file mode 100644 index 000000000..eb01883b5 --- /dev/null +++ b/apis_core/generic/views.py @@ -0,0 +1,212 @@ +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth import get_permission_codename +from django.views.generic import DetailView +from django.views.generic.base import TemplateView +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.urls import reverse, reverse_lazy +from django.forms import modelform_factory + +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin +from django_tables2.tables import table_factory +from django_tables2.columns import library +from dal import autocomplete + +from .tables import GenericTable +from .filtersets import filterset_factory, GenericFilterSet +from .forms import GenericModelForm +from .helpers import first_match_via_mro, template_names_via_mro, generate_search_filter + + +class Overview(TemplateView): + template_name = "generic/overview.html" + + +class GenericModelMixin: + """ + A mixin providing the common functionality for all the views working + with `generic` models - that is models that are accessed via the + contenttype framework (using `app_label.model`). + It sets the `.model` of the view and generates a list of possible template + names (based on the MRO of the model). + If the view has a `permission_action_required` attribute, this is used + to set the permission required to access the view for this specific model. + """ + + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) + self.model = kwargs.get("contenttype").model_class() + self.queryset = self.model.objects.all() + + def get_template_names(self): + template_names = super().get_template_names() + suffix = self.template_name_suffix + ".html" + additional_templates = template_names_via_mro(self.model, suffix) + [ + f"generic/generic{suffix}" + ] + template_names += filter( + lambda template: template not in template_names, additional_templates + ) + return template_names + + def get_permission_required(self): + if hasattr(self, "permission_action_required"): + return [ + get_permission_codename( + self.permission_action_required, self.model._meta + ) + ] + return [] + + +class List(GenericModelMixin, PermissionRequiredMixin, SingleTableMixin, FilterView): + """ + List view for a generic model. + Access requires the `_view` permission. + It is based on django-filters FilterView and django-tables SingleTableMixin. + The table class is overridden by the first match from + the `first_match_via_mro` helper. + The filterset class is overridden by the first match from + the `first_match_via_mro` helper. + The queryset is overridden by the first match from + the `first_match_via_mro` helper. + """ + + template_name_suffix = "_list" + permission_action_required = "view" + + def get_table_class(self): + table_class = ( + first_match_via_mro(self.model, path="tables", suffix="Table") + or GenericTable + ) + return table_factory(self.model, table_class) + + def get_table_kwargs(self): + kwargs = super().get_table_kwargs() + columns = self.request.GET.getlist("columns", []) + column_fields = [ + field for field in self.model._meta.fields if field.name in columns + ] + kwargs["extra_columns"] = [ + (field.name, library.column_for_field(field, accessor=field.name)) + for field in column_fields + ] + return kwargs + + def get_filterset_class(self): + filterset_class = ( + first_match_via_mro(self.model, path="filtersets", suffix="FilterSet") + or GenericFilterSet + ) + return filterset_factory(self.model, filterset_class) + + def get_queryset(self): + return ( + first_match_via_mro(self.model, path="querysets", suffix="ListViewQueryset") + or self.model.objects.all() + ) + + +class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView): + """ + Detail view for a generic model. + Access requires the `_view` permission. + """ + + permission_action_required = "view" + + +class Create(GenericModelMixin, PermissionRequiredMixin, CreateView): + """ + Create view for a generic model. + Access requires the `_add` permission. + The form class is overridden by the first match from + the `first_match_via_mro` helper. + """ + + template_name = "generic/generic_form.html" + permission_action_required = "add" + + def get_form_class(self): + form_class = ( + first_match_via_mro(self.model, path="forms", suffix="Form") + or GenericModelForm + ) + return modelform_factory(self.model, form_class) + + def get_success_url(self): + return reverse( + "apis:generic:list", + args=[self.request.resolver_match.kwargs["contenttype"]], + ) + + +class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView): + """ + Delete view for a generic model. + Access requires the `_delete` permission. + """ + + permission_action_required = "delete" + + def get_success_url(self): + return reverse( + "apis:generic:list", + args=[self.request.resolver_match.kwargs["contenttype"]], + ) + + def delete(self, *args, **kwargs): + if "HX-Request" in self.request.headers: + return ( + reverse_lazy( + "apis:generic:list", + args=[self.request.resolver_match.kwargs["contenttype"]], + ), + ) + return super().delete(*args, **kwargs) + + +class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView): + """ + Update view for a generic model. + Access requires the `_change` permission. + The form class is overridden by the first match from + the `first_match_via_mro` helper. + """ + + permission_action_required = "change" + + def get_form_class(self): + form_class = ( + first_match_via_mro(self.model, path="forms", suffix="Form") + or GenericModelForm + ) + return modelform_factory(self.model, form_class) + + def get_success_url(self): + return reverse( + "apis:generic:list", + args=[self.request.resolver_match.kwargs["contenttype"]], + ) + + +class Autocomplete( + GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView +): + """ + Autocomplete view for a generic model. + Access requires the `_view` permission. + The queryset is overridden by the first match from + the `first_match_via_mro` helper. + """ + + permission_action_required = "view" + + def get_queryset(self): + queryset = first_match_via_mro( + self.model, path="querysets", suffix="AutocompleteQueryset" + ) + if queryset: + return queryset(self.model, self.q) + return self.model.objects.filter(generate_search_filter(self.model, self.q)) diff --git a/apis_core/urls.py b/apis_core/urls.py index 675d20042..67b4fe1d8 100644 --- a/apis_core/urls.py +++ b/apis_core/urls.py @@ -87,4 +87,5 @@ name="GetEntityGeneric", ), path("api/dumpdata", Dumpdata.as_view()), + path("", include("apis_core.generic.urls", namespace="generic")), ] diff --git a/tests/settings_ci.py b/tests/settings_ci.py index 13965a9f3..f278049f0 100644 --- a/tests/settings_ci.py +++ b/tests/settings_ci.py @@ -17,6 +17,7 @@ "apis_core.apis_relations", "apis_core.apis_entities", "apis_core.apis_vocabularies", + "apis_core.generic", "reversion", # ui stuff "crispy_forms",