diff --git a/apis_core/core/templatetags/apiscore.py b/apis_core/core/templatetags/apiscore.py index e001c1a30..afed7184f 100644 --- a/apis_core/core/templatetags/apiscore.py +++ b/apis_core/core/templatetags/apiscore.py @@ -1,5 +1,8 @@ +from itertools import chain + from django import template from django.conf import settings +from django.contrib.contenttypes.models import ContentType register = template.Library() @@ -13,3 +16,31 @@ def shared_url(): @register.simple_tag def page_range(paginator, number): return paginator.get_elided_page_range(number=number) + + +@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) + return data + + +@register.simple_tag +def contenttypes(app_label=None): + if app_label: + return ContentType.objects.filter(app_label=app_label) + return ContentType.objects.all() diff --git a/apis_core/generic/api_views.py b/apis_core/generic/api_views.py new file mode 100644 index 000000000..dd3e78057 --- /dev/null +++ b/apis_core/generic/api_views.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets +from .serializers import serializer_factory + + +class GenericViewSet(viewsets.ModelViewSet): + def dispatch(self, *args, **kwargs): + self.model = kwargs.get("contenttype").model_class() + return super().dispatch(*args, **kwargs) + + def get_queryset(self): + return self.model.objects.all() + + def get_serializer_class(self): + return serializer_factory(self.model) diff --git a/apis_core/generic/filtersets.py b/apis_core/generic/filtersets.py new file mode 100644 index 000000000..0c848f21b --- /dev/null +++ b/apis_core/generic/filtersets.py @@ -0,0 +1,23 @@ +from django_filters.filterset import FilterSet +from .forms import GenericFilterSetForm + + +class GenericFilterSet(FilterSet): + + @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, fields="__all__"): + meta = type(str("Meta"), (object,), {"model": model, "fields": fields, "form": GenericFilterSetForm}) + filterset = type( + str("%sFilterSet" % model._meta.object_name), (GenericFilterSet,), {"Meta": meta} + ) + return filterset diff --git a/apis_core/generic/forms.py b/apis_core/generic/forms.py new file mode 100644 index 000000000..9a515bea1 --- /dev/null +++ b/apis_core/generic/forms.py @@ -0,0 +1,31 @@ +from django import forms +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Submit + + +class GenericFilterSetForm(forms.Form): + 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") + return self.cleaned_data + + +class GenericModelForm(forms.ModelForm): + class Meta: + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.add_input(Submit('submit', 'Submit')) diff --git a/apis_core/generic/serializers.py b/apis_core/generic/serializers.py new file mode 100644 index 000000000..a7744ea38 --- /dev/null +++ b/apis_core/generic/serializers.py @@ -0,0 +1,9 @@ +from rest_framework.serializers import ModelSerializer + + +def serializer_factory(model, fields="__all__", **kwargs): + meta = type(str("Meta"), (object,), {"model": model, "fields": fields}) + serializer = type( + str("%sModelSerializer" % model._meta.object_name), (ModelSerializer,), {"Meta": meta} + ) + return serializer diff --git a/apis_core/generic/tables.py b/apis_core/generic/tables.py new file mode 100644 index 000000000..957fb05dc --- /dev/null +++ b/apis_core/generic/tables.py @@ -0,0 +1,38 @@ +import django_tables2 as tables + + +class CustomTemplateColumn(tables.TemplateColumn): + template_name = None + orderable = None + + def __init__(self, *args, **kwargs): + super().__init__(template_name=self.template_name, orderable=self.orderable, *args, **kwargs) + + +class DeleteColumn(CustomTemplateColumn): + template_name = "columns/delete.html" + orderable = False + + +class DescriptionColumn(CustomTemplateColumn): + template_name = "columns/description.html" + orderable = False + + +class GenericTable(tables.Table): + desc = DescriptionColumn() + delete = DeleteColumn() + + class Meta: + fields = ['id', 'desc'] + + # TODO: refactor + def __init__(self, *args, **kwargs): + extra_columns = kwargs.get("extra_columns", []) + fields = [field for field in self._meta.model._meta.fields if field.name in kwargs.get("columns", [])] + for field in fields: + extra_columns.append((field.name, tables.columns.library.column_for_field(field, accessor=field.name))) + del kwargs["columns"] + kwargs["extra_columns"] = extra_columns + + 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..565182f1b --- /dev/null +++ b/apis_core/generic/templates/columns/delete.html @@ -0,0 +1,2 @@ +{% load apiscore %} + diff --git a/apis_core/generic/templates/columns/description.html b/apis_core/generic/templates/columns/description.html new file mode 100644 index 000000000..3739b0658 --- /dev/null +++ b/apis_core/generic/templates/columns/description.html @@ -0,0 +1,2 @@ +{% load apiscore %} +{{ record }} 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..6cf5240c5 --- /dev/null +++ b/apis_core/generic/templates/generic/generic_confirm_delete.html @@ -0,0 +1,32 @@ +{% 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..e39792ec0 --- /dev/null +++ b/apis_core/generic/templates/generic/generic_content.html @@ -0,0 +1,14 @@ +{% 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..d9c241f17 --- /dev/null +++ b/apis_core/generic/templates/generic/generic_detail.html @@ -0,0 +1,23 @@ +{% extends "generic/generic_content.html" %} +{% load apiscore %} + +{% 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..3b8494369 --- /dev/null +++ b/apis_core/generic/templates/generic/generic_form.html @@ -0,0 +1,13 @@ +{% 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..8b50371bd --- /dev/null +++ b/apis_core/generic/templates/generic/generic_list.html @@ -0,0 +1,43 @@ +{% extends "generic/generic_content.html" %} +{% load render_table from django_tables2 %} +{% load crispy_forms_tags %} +{% load apiscore %} + +{% if filter %} +{% block col %} +
+
+
+
{{ object_list.model|contenttype }}
+ +
+
+
+ {% 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..cabf5abfa --- /dev/null +++ b/apis_core/generic/templates/generic/overview.html @@ -0,0 +1,16 @@ +{% extends "generic/generic_content.html" %} +{% load apiscore %} + +{% block col %} +
+
+ Overview +
+
+ {% contenttypes "apis_ontology" as contenttypes %} + {% for contenttype in contenttypes %} + + {% endfor %} +
+
+{% endblock col %} diff --git a/apis_core/generic/urls.py b/apis_core/generic/urls.py new file mode 100644 index 000000000..dbf94c489 --- /dev/null +++ b/apis_core/generic/urls.py @@ -0,0 +1,42 @@ +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 + +router = routers.DefaultRouter() +router.register(r"", api_views.GenericViewSet, basename="foo") + + +class ContenttypeConverter: + 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, "ccc") + +app_name = "generic" + +urlpatterns = [ + path("overview/", views.Overview.as_view(), name="overview"), + path( + "/", + include( + [ + path("", views.ListCC.as_view(), name="list"), + path("", views.DetailCC.as_view(), name="detail"), + path("create", views.CreateCC.as_view(), name="create"), + path("delete/", views.DeleteCC.as_view(), name="delete"), + path("update/", views.UpdateCC.as_view(), name="update"), + ] + ), + ), + 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..cba75cbd4 --- /dev/null +++ b/apis_core/generic/views.py @@ -0,0 +1,79 @@ +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 +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 .tables import GenericTable +from .filtersets import filterset_factory +from .forms import GenericModelForm + + +class Overview(TemplateView): + template_name = "generic/overview.html" + + +class CCMixin: + 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): + return super().get_template_names() + [ + f"generic/generic{self.template_name_suffix}.html" + ] + + def get_permission_required(self): + if hasattr(self, "permission_action_required"): + return [get_permission_codename(self.permission_action_required, self.model._meta)] + return [] + + +class ListCC(CCMixin, PermissionRequiredMixin, SingleTableMixin, FilterView): + template_name_suffix = "_list" + permission_action_required = "view" + + def get_table_class(self): + return table_factory(self.model, GenericTable) + + def get_table_kwargs(self): + return {"columns": self.request.GET.getlist("columns", [])} + + def get_filterset_class(self): + return filterset_factory(self.model) + + +class DetailCC(CCMixin, PermissionRequiredMixin, DetailView): + permission_action_required = "view" + + +class CreateCC(CCMixin, PermissionRequiredMixin, CreateView): + template_name = "generic/generic_form.html" + permission_action_required = "add" + + def get_form_class(self): + return modelform_factory(self.model, GenericModelForm) + + +class DeleteCC(CCMixin, PermissionRequiredMixin, DeleteView): + permission_action_required = "delete" + + def get_success_url(self): + return reverse( + "apis:generic:genericlist", + args=[self.request.resolver_match.kwargs["contenttype"]], + ) + + +class UpdateCC(CCMixin, PermissionRequiredMixin, UpdateView): + permission_action_required = "change" + + def get_form_class(self): + return modelform_factory(self.model, GenericModelForm) diff --git a/apis_core/urls.py b/apis_core/urls.py index 5b9a2cbf6..095e7ea08 100644 --- a/apis_core/urls.py +++ b/apis_core/urls.py @@ -168,6 +168,7 @@ def build_apis_mock_request(method, path, view, original_request, **kwargs): name="GetEntityGeneric", ), path("api/dumpdata", Dumpdata.as_view()), + path("", include("apis_core.generic.urls", namespace="generic")), ] if "apis_fulltext_download" in settings.INSTALLED_APPS: