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 %}
+
+
+
+
+ {% modeldict object as d %}
+ {% for key, value in d.items %}
+
+ {{ key.verbose_name }} |
+ {{ value }} |
+
+ {% endfor %}
+
+
+
+{% 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 %}
+
+
+
+ {% 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 %}
+
+
+
+ {% block filter %}
+ {% crispy filter.form filter.form.helper %}
+ {% endblock filter %}
+
+
+
+{% endblock col %}
+{% endif %}
+
+
+{% if table %}
+{% block additionalcols %}
+
+
+
+
+ {% 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 %}
+
+
+
+ {% 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: