from dal import autocomplete
from django import forms, http
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.forms import modelform_factory
from django.template.exceptions import TemplateDoesNotExist
from django.template.loader import select_template
from django.urls import reverse, reverse_lazy
from django.views.generic import DetailView
from django.views.generic.base import TemplateView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from django_filters.filterset import filterset_factory
from django_filters.views import FilterView
from django_tables2 import SingleTableMixin
from django_tables2.columns import library
from django_tables2.tables import table_factory
from apis_core.core.mixins import ListViewObjectFilterMixin
from apis_core.utils.helpers import create_object_from_uri
from .filtersets import GenericFilterSet
from .forms import GenericImportForm, GenericModelForm
from .helpers import (
first_member_match,
generate_search_filter,
module_paths,
permission_fullname,
template_names_via_mro,
)
from .tables import GenericTable
[docs]
class Overview(TemplateView):
template_name = "generic/overview.html"
[docs]
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.
"""
[docs]
def setup(self, *args, **kwargs):
super().setup(*args, **kwargs)
if contenttype := kwargs.get("contenttype"):
self.model = contenttype.model_class()
self.queryset = self.model.objects.all()
[docs]
def get_template_names(self):
template_names = []
if hasattr(super(), "get_template_names"):
template_names = super().get_template_names()
suffix = ".html"
if hasattr(self, "template_name_suffix"):
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
[docs]
def get_permission_required(self):
if hasattr(settings, "APIS_VIEW_PASSES_TEST"):
if settings.APIS_VIEW_PASSES_TEST(self):
return []
if hasattr(self, "permission_action_required"):
return [permission_fullname(self.permission_action_required, self.model)]
return []
[docs]
class List(
ListViewObjectFilterMixin,
GenericModelMixin,
PermissionRequiredMixin,
SingleTableMixin,
FilterView,
):
"""
List view for a generic model.
Access requires the `<model>_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_member_match` helper.
The filterset class is overridden by the first match from
the `first_member_match` helper.
The queryset is overridden by the first match from
the `first_member_match` helper.
"""
template_name_suffix = "_list"
permission_action_required = "view"
[docs]
def get_table_class(self):
table_modules = module_paths(self.model, path="tables", suffix="Table")
table_class = first_member_match(table_modules, GenericTable)
return table_factory(self.model, table_class)
[docs]
def get_table_kwargs(self):
kwargs = super().get_table_kwargs()
# we look at the selected columns and exclude
# all modelfields that are not part of that list
selected_columns = self.request.GET.getlist(
"columns",
self.get_filterset(self.get_filterset_class()).form["columns"].initial,
)
modelfields = self.model._meta.get_fields()
kwargs["exclude"] = [
field.name for field in modelfields if field.name not in selected_columns
]
# now we look at the selected columns and
# add all modelfields and annotated fields that
# are part of the selected columns to the extra_columns
annotationfields = list()
for key, value in self.object_list.query.annotations.items():
fake_field = getattr(value, "field", value.output_field)
setattr(fake_field, "name", key)
annotationfields.append(fake_field)
extra_fields = list(
filter(
lambda x: x.name in selected_columns,
modelfields + tuple(annotationfields),
)
)
kwargs["extra_columns"] = [
(field.name, library.column_for_field(field, accessor=field.name))
for field in extra_fields
if field.name not in self.get_table_class().base_columns
]
return kwargs
[docs]
def get_filterset_class(self):
filterset_modules = module_paths(
self.model, path="filtersets", suffix="FilterSet"
)
filterset_class = first_member_match(filterset_modules, GenericFilterSet)
return filterset_factory(self.model, filterset_class)
def _get_columns_choices(self, columns_exclude):
# we start with the model fields
choices = [
(field.name, getattr(field, "verbose_name", field.name))
for field in self.model._meta.get_fields()
]
# we add any annotated fields to that
choices += [(key, key) for key in self.get_queryset().query.annotations.keys()]
# now we drop all the choices that are listed in columns_exclude
choices = list(filter(lambda x: x[0] not in columns_exclude, choices))
return choices
def _get_columns_initial(self, columns_exclude):
return [
field
for field in self.get_table().columns.names()
if field not in columns_exclude
]
[docs]
def get_filterset(self, filterset_class):
"""
We override the `get_filterset` method, so we can inject a
`columns` selector into the form
"""
filterset = super().get_filterset(filterset_class)
columns_exclude = filterset.form.columns_exclude
# we inject a `columns` selector in the beginning of the form
columns = forms.MultipleChoiceField(
required=False,
choices=self._get_columns_choices(columns_exclude),
initial=self._get_columns_initial(columns_exclude),
)
filterset.form.fields = {**{"columns": columns}, **filterset.form.fields}
return filterset
[docs]
def get_queryset(self):
queryset_methods = module_paths(
self.model, path="querysets", suffix="ListViewQueryset"
)
queryset = first_member_match(queryset_methods) or (lambda x: x)
return self.filter_queryset(queryset(self.model.objects.all()))
[docs]
class Detail(GenericModelMixin, PermissionRequiredMixin, DetailView):
"""
Detail view for a generic model.
Access requires the `<model>_view` permission.
"""
permission_action_required = "view"
[docs]
class Create(GenericModelMixin, PermissionRequiredMixin, CreateView):
"""
Create view for a generic model.
Access requires the `<model>_add` permission.
The form class is overridden by the first match from
the `first_member_match` helper.
"""
template_name = "generic/generic_form.html"
permission_action_required = "add"
[docs]
def get_success_url(self):
return self.object.get_create_success_url()
[docs]
class Delete(GenericModelMixin, PermissionRequiredMixin, DeleteView):
"""
Delete view for a generic model.
Access requires the `<model>_delete` permission.
"""
permission_action_required = "delete"
[docs]
def get_success_url(self):
return reverse(
"apis_core:generic:list",
args=[self.request.resolver_match.kwargs["contenttype"]],
)
[docs]
def delete(self, *args, **kwargs):
if "HX-Request" in self.request.headers:
return (
reverse_lazy(
"apis_core:generic:list",
args=[self.request.resolver_match.kwargs["contenttype"]],
),
)
return super().delete(*args, **kwargs)
[docs]
class Update(GenericModelMixin, PermissionRequiredMixin, UpdateView):
"""
Update view for a generic model.
Access requires the `<model>_change` permission.
The form class is overridden by the first match from
the `first_member_match` helper.
"""
permission_action_required = "change"
[docs]
def get_success_url(self):
return self.object.get_update_success_url()
[docs]
class Autocomplete(
GenericModelMixin, PermissionRequiredMixin, autocomplete.Select2QuerySetView
):
"""
Autocomplete view for a generic model.
Access requires the `<model>_view` permission.
The queryset is overridden by the first match from
the `first_member_match` helper.
"""
permission_action_required = "view"
template_name_suffix = "_autocomplete_result"
create_field = "thisisnotimportant" # because we are using create_object_from_uri
[docs]
def setup(self, *args, **kwargs):
super().setup(*args, **kwargs)
try:
template = select_template(self.get_template_names())
self.template = template.template.name
except TemplateDoesNotExist:
self.template = None
[docs]
def get_queryset(self):
queryset_methods = module_paths(
self.model, path="querysets", suffix="AutocompleteQueryset"
)
queryset = first_member_match(queryset_methods)
if queryset:
return queryset(self.model, self.q)
return self.model.objects.filter(generate_search_filter(self.model, self.q))
[docs]
def get_results(self, context):
external_only = self.kwargs.get("external_only", False)
results = [] if external_only else super().get_results(context)
queryset_methods = module_paths(
self.model, path="querysets", suffix="ExternalAutocomplete"
)
ExternalAutocomplete = first_member_match(queryset_methods)
if ExternalAutocomplete:
results.extend(ExternalAutocomplete().get_results(self.q))
return results
[docs]
def create_object(self, value):
return create_object_from_uri(value, self.queryset.model)
[docs]
def post(self, request, *args, **kwargs):
try:
return super().post(request, *args, **kwargs)
except Exception as e:
return http.JsonResponse({"error": str(e)})
[docs]
class Import(GenericModelMixin, PermissionRequiredMixin, FormView):
template_name = "generic/generic_import_form.html"
template_name_suffix = "_import"
permission_action_required = "add"
[docs]
def get_success_url(self):
return self.object.get_absolute_url()