# Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
"""
Generic views defining a gold standard for Ferrosoft Platform.
Currently, these generic views revolve around CRUD for models. They spice up
generic views found in Django, django-filter and django_tables2.
To make use of it, define subclasses for each CRUD verb to be supported:
* Define a CreateView to create instances of a model class.
* Define a UpdateView to update instances of a model class.
* Define a DeleteView to delete instances of a model class.
* Define one of the read views to read display instances of a model class:
* Define a TableView to display a table along with filters.
* Define a FilterView to display a list along with filters.
* Define a ListView to display a list without filters.
In the same module where the subclasses are defined, call model_crud_routes
to generate a list of URL patterns, which can be used in the app's url module.
Example:
class MaterialCreateView(generic.CreateView):
fields = ["code", "name"]
model = Material
class MaterialListView(generic.FilterView):
filterset_class = MaterialFilterSet
model = Material
ordering = "code"
class MaterialUpdateView(generic.UpdateView):
fields = ["code", "name"]
model = Material
class MaterialDeleteView(generic.DeleteView):
model = Material
material_urls = generic.model_crud_routes(
Material,
create_view=MaterialCreateView,
read_view=MaterialListView,
update_view=MaterialUpdateView,
delete_view=MaterialDeleteView,
)
"""
from contextlib import suppress
from pathlib import PurePath
from string import capwords
from typing import Any, Callable, List, Type
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import ValidationError
from django.db.models import RestrictedError, Manager
from django.shortcuts import render
from django.urls import NoReverseMatch, URLPattern, path, reverse
from django.utils.translation import gettext_lazy as _
from django.views import generic as django_generic
from django.views.generic.base import ContextMixin
from django_filters.views import FilterView as UpstreamFilterView
from django_tables2.views import SingleTableMixin
from ferrosoft.apps.ferrobase.auth.roles import Permissions, Verb
from ferrosoft.apps.ferrobase.services.help import get_help_url
PAGE_SIZE = 30
"""Default page size for read views."""
[docs]
class ClassAttributes:
"""
Build view class attributes.
This is used internally by view metaclasses.
Attributes created via "add" methods are added as a default and
can be overridden by user defined attributes.
"""
def __init__(self, attrs: dict[str, Any]):
"""
Attributes:
attrs: User defined attributes of view class (third argument to metaclass __new__).
"""
self._attrs = attrs
self._defaults = {}
[docs]
def require_all(self, *required) -> "ClassAttributes":
"""
Require all named attributes be present.
"""
missing = list(filter(lambda key: key not in self._attrs, required))
if len(missing) > 0:
raise RuntimeError(
"Required class attributes missing: %s" % ", ".join(missing)
)
return self
[docs]
def require_any(self, *required) -> "ClassAttributes":
"""
Require at least one of the named attributes be present.
"""
included = filter(lambda key: key in self._attrs, required)
if next(included, None) is None:
raise RuntimeError(
"Class is missing one of the attributes: %s", ", ".join(required)
)
return self
[docs]
def add_activity_view(
self, *, title: str | None = None, app: str | None = None
) -> "ClassAttributes":
"""
Add attributes expected by ActivityViewMixin.
"""
# noinspection PyProtectedMember
meta = self._attrs["model"]._meta
self._defaults["activity_title"] = title or capwords(meta.verbose_name)
self._defaults["activity_app"] = app or meta.app_label
return self
[docs]
def add_actions(
self,
cancel_action=False,
create_action=False,
delete_action=False,
read_action=False,
copy_action=False,
) -> "ClassAttributes":
"""
Add view attributes expected by ModelContextMixin.
"""
# noinspection PyProtectedMember
meta = self._attrs["model"]._meta
app_label = self._attrs.get("activity_app", meta.app_label)
view_prefix = "%s:%s" % (app_label, meta.model_name)
if cancel_action:
self._defaults["cancel_view"] = "%s_index" % view_prefix
if create_action:
self._defaults["create_view"] = "%s_create" % view_prefix
if delete_action:
self._defaults["delete_view"] = "%s_delete" % view_prefix
if read_action:
self._defaults["read_view"] = "%s_index" % view_prefix
if copy_action:
self._defaults["copy_view"] = "%s_copy" % view_prefix
return self
[docs]
def add_designation(self) -> "ClassAttributes":
"""
Add singular/plural attributes expected by ModelContextMixin.
"""
# noinspection PyProtectedMember
meta = self._attrs["model"]._meta
self._defaults["singular"] = getattr(meta, "verbose_name", None)
self._defaults["plural"] = getattr(meta, "verbose_name_plural", None)
return self
[docs]
def add_permission_required(self, verb: Verb) -> "ClassAttributes":
"""Add a default ``permission_required`` for ``verb`` on the view's model."""
model = self._attrs["model"]
self._defaults["permission_required"] = [Permissions.for_model(model, verb)]
return self
@property
def attrs(self) -> dict[str, Any]:
"""
Combine default attributes created by "add" methods with user defined attributes.
User defined attributes overwrite default attributes.
"""
return self._defaults | self._attrs
[docs]
class ModelContextMixin(ContextMixin):
"""Add various template context variables related to model CRUD views."""
create_view: str | Callable[..., str] | None = None
"""List view displays a button to create a new object if create_view is not None."""
cancel_view: str | Callable[..., str] | None = None
"""Create, Update and Delete views display a button to cancel the operation if cancel_view is not None."""
copy_view: str | Callable[..., str] | None = None
"""Update view displays a button to copy the object if copy_view is not None."""
delete_view: str | Callable[..., str] | None = None
"""Update view displays a button to delete the object if delete_view is not None."""
read_view: str | Callable[..., str] | None = None
"""Name of read view to link to in related links widget."""
form_template = None
"""Template to be included instead of generic form display."""
list_item_template = None
"""Template to be included instead of generic list item display."""
multipart_form = False
"""Whether to use multipart/form-data enctype."""
singular = None
"""Singular may be used by templates to display the model name."""
plural = None
"""Plural may be used by templates to display the model name."""
related_links_default: bool = False
"""Whether to include a curated set of default related links in the context."""
related_links: Callable[[], list[tuple[str, str]]] = None
"""Related links to include in the context. The tuple must include an URL and label."""
enable_filter_display: bool = True
"""Whether to display filters off-canvas (applicable to read views)."""
def _resolve_view(
self,
view: str | Callable[..., str] | None,
verb: Verb | None = None,
*args,
**kwargs,
):
"""Resolve ``view`` to a URL, honouring callables and permission gating.
Returns ``None`` when ``view`` is ``None``, the optional ``verb``
permission check fails, or the named route does not exist.
"""
if view is None:
return None
if verb is not None and not self.is_allowed(verb):
return None
if callable(view):
if len(args) > 0 or len(kwargs) > 0:
return view(*args, **kwargs)
return view()
try:
return reverse(view, *args, **kwargs)
except NoReverseMatch:
return None
[docs]
def get_context_data(self, **kwargs):
"""Add CRUD-related URLs, permission flags and related-links to the context."""
context = super(ModelContextMixin, self).get_context_data(**kwargs)
read_url = self._resolve_view(self.read_view, Verb.READ)
create_url = self._resolve_view(self.create_view, Verb.CREATE)
copy_url = None
delete_url = None
disable_submit = False
instance = getattr(self, "object", None)
update_allowed = self.is_allowed(Verb.UPDATE)
if instance is not None:
copy_url = self._resolve_view(
self.copy_view, Verb.CREATE, args=[instance.pk]
)
# Honor read-only flag (e.g. mixed in through ReadOnlyMixin).
if getattr(instance, "read_only", False):
update_allowed = False
disable_submit = True
else:
delete_url = self._resolve_view(
self.delete_view, Verb.DELETE, args=[instance.pk]
)
try:
return context | {
"create_url": create_url,
"cancel_url": self._resolve_view(self.cancel_view),
"copy_url": copy_url,
"delete_url": delete_url,
"disable_submit": disable_submit,
"enable_filter_display": self.enable_filter_display,
"multipart_form": self.multipart_form,
"update_allowed": update_allowed,
"form_template": self.form_template,
"list_item_template": self.list_item_template,
"singular": self.singular,
"plural": self.plural,
"related_links": self._make_related_links(read_url, create_url),
}
except NoReverseMatch as e:
raise RuntimeError(
"View not found. If using crud_factory, check that the url patterns"
" are added in the same app as the model definitions."
) from e
[docs]
def is_allowed(self, verb: Verb) -> bool:
"""Check whether the current user holds ``verb`` permission on the model."""
if not hasattr(self, "request") or not hasattr(self, "model"):
# Class hierarchy is wrong. Be on the safe side and do not allow.
return False
return self.request.user.has_perm(Permissions.for_model(self.model, verb))
def _make_related_links(self, read_url, create_url):
"""Compose the related-links list shown alongside the main content.
Combines the optional default "Create"/"Show" links (when
:attr:`related_links_default` is set) with the user-provided
:attr:`related_links`, filters out items the current user is not
permitted to see and normalises tuple length.
"""
links = []
if self.related_links_default:
if create_url is not None:
links += [
(
create_url,
_("Create %(singular)s") % {"singular": self.singular},
)
]
if read_url is not None:
links += [(read_url, _("Show %(plural)s") % {"plural": self.plural})]
if self.related_links is not None:
links = links + self.related_links()
return list(
map(
self._ensure_link_uniformity,
filter(self._is_link_allowed(self.request), links),
)
)
@staticmethod
def _ensure_link_uniformity(link):
"""Discard the optional third permission element from a related-link tuple."""
a, b, *rest = link
return a, b
@staticmethod
def _is_link_allowed(request):
"""Return a predicate that checks the optional permission on a related link."""
def check(item) -> bool:
_a, _b, *rest = item
if len(rest) > 0:
# Expecting permission name as third item in tuple
return request.user.has_perm(rest[0])
return True
return check
[docs]
class TemplateView(
LoginRequiredMixin,
PlatformActivityMixin,
django_generic.TemplateView,
):
"""Login-required TemplateView pre-mixed with platform activity context."""
[docs]
class DetailView(
LoginRequiredMixin,
PermissionRequiredMixin,
PlatformActivityMixin,
ModelContextMixin,
django_generic.DetailView,
metaclass=DetailViewMeta,
):
"""Display a single object.
The normal CRUD workflow uses UpdateView as the standard detail view
for a given model. Use DetailView for specific tasks related to the model.
Required attributes:
model: Model class
template_name: Template to render
"""
abstract = True
template_name = "ferrobase/bootstrap5/detail_view.html"
[docs]
class TableView(
LoginRequiredMixin,
PermissionRequiredMixin,
PlatformActivityMixin,
ModelContextMixin,
SingleTableMixin,
UpstreamFilterView,
metaclass=TableViewMeta,
):
"""
Display a single table along with filters.
This is the most featureful read view and should be preferred over others.
Required attributes:
model: Model class
filterset_class: Filter set class
table_class: Table class
ordering: Default ordering to ensure pagination works reliably.
"""
abstract = True
paginate_by = PAGE_SIZE
template_name = "ferrobase/bootstrap5/table_view.html"
[docs]
class FilterView(
LoginRequiredMixin,
PermissionRequiredMixin,
PlatformActivityMixin,
ModelContextMixin,
UpstreamFilterView,
metaclass=FilterViewMeta,
):
"""
Display a list along with filters.
This is less useful as TableView, as it doesn't support ordering of
individual attributes. Prefer TableView over this class.
Required attributes:
model: Model class
filterset_class: Filter set class
ordering: Default ordering to ensure pagination works reliably.
See also ModelContextMixin for optional attributes.
"""
abstract = True
paginate_by = PAGE_SIZE
template_name = "ferrobase/bootstrap5/filter_view.html"
[docs]
class ListView(
LoginRequiredMixin,
PermissionRequiredMixin,
PlatformActivityMixin,
ModelContextMixin,
django_generic.ListView,
metaclass=ListViewMeta,
):
"""
Display a list.
This is the least useful read view, as it doesn't support ordering nor
filtering. Prefer other read views over this class.
Required attributes:
model: Model class
ordering: Default ordering to ensure pagination works reliably.
See also ModelContextMixin for optional attributes.
"""
abstract = True
paginate_by = PAGE_SIZE
template_name = "ferrobase/bootstrap5/list_view.html"
[docs]
class CopyView(
LoginRequiredMixin,
PermissionRequiredMixin,
PlatformActivityMixin,
ModelContextMixin,
django_generic.CreateView,
metaclass=CopyViewMeta,
):
"""Create-by-copy view: pre-populates the form from an existing source object.
The ``source_id`` URL kwarg identifies the record to clone. On ``GET``
the form is bound to the source instance so its values appear as
defaults; on ``POST`` the instance is intentionally cleared so saving
creates a new record instead of mutating the original.
"""
abstract = True
template_name = "ferrobase/bootstrap5/copy_view.html"
def __init__(self, *args, **kwargs):
"""Initialise the lazily-loaded ``source_object`` slot."""
super().__init__(*args, **kwargs)
self.source_object = None
[docs]
def get_source_object(self):
"""Look up the source record identified by the ``source_id`` URL kwarg."""
return self.model.objects.get(
pk=self.request.resolver_match.kwargs.get("source_id")
)
[docs]
def get(self, request, *args, **kwargs):
"""Load the source object then defer to Django's GET handling."""
self.source_object = self.get_source_object()
return super().get(request, *args, **kwargs)
[docs]
def post(self, request, *args, **kwargs):
"""Load the source object then defer to Django's POST handling."""
self.source_object = self.get_source_object()
return super().post(request, *args, **kwargs)
[docs]
def get_context_data(self, **kwargs):
"""Expose ``source_object`` so the template can display its identity."""
return super().get_context_data(**kwargs) | {
"source_object": self.source_object,
}
[docs]
class CreateView(
LoginRequiredMixin,
PermissionRequiredMixin,
PlatformActivityMixin,
ModelContextMixin,
django_generic.CreateView,
metaclass=CreateViewMeta,
):
"""
Display and handle a form for creation of a model.
Required attributes:
model: Model class
fields: Model field names
form_class: Model form class (preferred over fields)
See also ModelContextMixin for optional attributes.
"""
abstract = True
template_name = "ferrobase/bootstrap5/create_view.html"
[docs]
class DependentCreateView(CreateView):
"""Create view that requires a parent object identified via a query param.
The parent is looked up using :meth:`get_parent_kwargs` (defaulting to
``?<parent_query>=<pk>``). When the parent cannot be resolved and
:attr:`enable_missing_parent` is ``False`` (default), the view returns a
404 page so the user is told the dependency is missing.
"""
abstract = True
parent_query = "q"
parent_model = None
enable_missing_parent = False
def __init__(self, *args, **kwargs):
"""Initialise the lazily-loaded ``parent_object`` slot."""
super().__init__(*args, **kwargs)
self.parent_object = None
[docs]
def dispatch(self, request, *args, **kwargs):
"""Resolve the parent object before dispatching to the base handler."""
if self.parent_model is None:
raise TypeError("Parent model must be defined")
manager = (
self.parent_model
if isinstance(self.parent_model, Manager)
else self.parent_model.objects
)
with suppress(ValueError, ValidationError):
self.parent_object = manager.filter(**self.get_parent_kwargs()).first()
if not self.enable_missing_parent and self.parent_object is None:
return render(
self.request, "ferrobase/generic/dependency_not_found.html", status=404
)
return super().dispatch(request, *args, **kwargs)
[docs]
def get_parent_kwargs(self):
"""Build the lookup kwargs for the parent (override for custom keys)."""
return {"pk": self.request.GET.get(self.parent_query, None)}
[docs]
def get_success_url(self):
"""Redirect to the parent's absolute URL after a successful create."""
return self.parent_object.get_absolute_url()
[docs]
class UpdateView(
LoginRequiredMixin,
PermissionRequiredMixin,
PlatformActivityMixin,
ModelContextMixin,
django_generic.UpdateView,
metaclass=UpdateViewMeta,
):
"""
Display and handle a form for updating of a model.
Required attributes:
model: Model class
fields: Model field names
form_class: Model form class (preferred over fields)
See also ModelContextMixin for optional attributes.
"""
abstract = True
add_copy_action = False
"""Show a button linking to copy view, if enabled."""
related_links_default = True
"""Show default related links, if enabled."""
template_name = "ferrobase/bootstrap5/update_view.html"
[docs]
class DeleteView(
LoginRequiredMixin,
PermissionRequiredMixin,
PlatformActivityMixin,
ModelContextMixin,
django_generic.DeleteView,
metaclass=DeleteViewMeta,
):
"""
Display and handle a form for deletion of a model.
Required attributes:
model: Model class
See also ModelContextMixin for optional attributes.
"""
abstract = True
template_name = "ferrobase/bootstrap5/delete_view.html"
def __init__(self, *args, **kwargs):
"""Initialise the ``deletion_restricted`` flag used by the template."""
super().__init__(*args, **kwargs)
# Deletion is restricted if the object being deleted is still
# referenced by other objects.
self.deletion_restricted = False
[docs]
def get_context_data(self, **kwargs):
"""Expose ``deletion_restricted`` so the template can warn the user."""
return super().get_context_data(**kwargs) | {
# User should be informed about restricted deletion.
"deletion_restricted": self.deletion_restricted,
}
[docs]
def get_success_url(self):
"""Redirect to the conventional ``<app>:<model>_index`` list view."""
meta = self.object._meta
# View name conforms to naming convention of this module
# (see ClassAttributes.add_actions).
return reverse("%s:%s_index" % (meta.app_label, meta.model_name))
[docs]
def model_crud_routes(
model_class,
create_view: Type[CreateView] | None = None,
read_view: Type[django_generic.View] | None = None,
update_view: Type[UpdateView] | None = None,
delete_view: Type[DeleteView] | None = None,
copy_view: Type[CopyView] | None = None,
) -> List[URLPattern]:
"""
Create URL patterns for model CRUD routes.
* Read view name is {model_name}_index.
* Create view name is {model_name}_create.
* Update view name is {model_name}_update.
* Delete view name is {model_name}_delete.
Model name is all lowercase, no underscores.
"""
# noinspection PyProtectedMember
model_name = model_class._meta.model_name
patterns = []
if read_view is not None:
patterns.append(
path(
"%s/" % model_name,
read_view.as_view(),
name="%s_index" % model_name,
)
)
if create_view is not None:
patterns.append(
path(
"%s/new/" % model_name,
create_view.as_view(),
name="%s_create" % model_name,
)
)
if update_view is not None:
patterns.append(
path(
"%s/<pk>/" % model_name,
update_view.as_view(),
name="%s_update" % model_name,
)
)
if delete_view is not None:
patterns.append(
path(
"%s/<pk>/delete/" % model_name,
delete_view.as_view(),
name="%s_delete" % model_name,
)
)
if copy_view is not None:
patterns.append(
path(
"%s/<source_id>/copy/" % model_name,
copy_view.as_view(),
name="%s_copy" % model_name,
)
)
return patterns