-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
21 changed files
with
778 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
20
apis_core/generic/templates/generic/generic_confirm_delete.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 %} |
Oops, something went wrong.