Skip to content

Commit

Permalink
feat: introduce generic app
Browse files Browse the repository at this point in the history
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
  • Loading branch information
b1rger committed Jan 10, 2024
1 parent e89e03d commit 3d18a41
Show file tree
Hide file tree
Showing 21 changed files with 778 additions and 0 deletions.
29 changes: 29 additions & 0 deletions apis_core/generic/api_views.py
Original file line number Diff line number Diff line change
@@ -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 viewsset 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
45 changes: 45 additions & 0 deletions apis_core/generic/filtersets.py
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions apis_core/generic/forms.py
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions apis_core/generic/helpers.py
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 48 additions & 0 deletions apis_core/generic/serializers.py
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions apis_core/generic/tables.py
Original file line number Diff line number Diff line change
@@ -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. Therefore 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 columns 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 columns 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)
3 changes: 3 additions & 0 deletions apis_core/generic/templates/columns/delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% load apisgeneric %}
<a title="delete" href="{% url 'apis_core:generic:delete' record|contenttype record.id %}"
class="text-danger"><span class="material-symbols-outlined">delete</span></a>
2 changes: 2 additions & 0 deletions apis_core/generic/templates/columns/description.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{% load apisgeneric %}
<a href="{% url 'apis_core:generic:detail' record|contenttype record.id %}">{{ record }}</a>
3 changes: 3 additions & 0 deletions apis_core/generic/templates/columns/edit.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% load apisgeneric %}
<a title="edit" href="{% url 'apis_core:generic:update' record|contenttype record.id %}"
class="text-warning"><span class="material-symbols-outlined">edit</span></a>
3 changes: 3 additions & 0 deletions apis_core/generic/templates/columns/view.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% load apisgeneric %}
<a title="view" href="{% url 'apis_core:generic:detail' record|contenttype record.id %}"
class="text-success"><span class="material-symbols-outlined">visibility</span></a>
20 changes: 20 additions & 0 deletions apis_core/generic/templates/generic/generic_confirm_delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends basetemplate|default:"base.html" %}

{% block content %}
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
<form action="" method="post">
{% csrf_token %}
<h4>
Confirm deletion of:
<div class="text-center">
<strong>{{ object }}</strong>
</div>
</h4>
<input class="btn btn-danger" type="submit" value="Yes, delete" />
</form>
</div>
</div>
</div>
{% endblock content %}
Loading

0 comments on commit 3d18a41

Please sign in to comment.