Source code for ferrosoft.apps.ferrobase.views.generic

#  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 PlatformActivityMixin(ContextMixin): """Provide context about the current activity to the base template.""" activity_title = "" """Activity title should be a very short label e.g. indicating the current model being operated on.""" activity_app = None """Django identifier of the app the activity belongs to. If it is None, the app is derived from template_name.""" container_class = None """Classes for <main> container.""" help_topic: str | None = None """ID of help topic passed to get_help_url. Resulting URL is added to template context as help_url."""
[docs] def get_context_data(self, **kwargs): """Inject activity metadata (title, app, container, help URL) into the context. Raises: RuntimeError: If neither :attr:`activity_app` nor a string ``template_name`` is defined and the app label cannot be inferred. """ context = super(PlatformActivityMixin, self).get_context_data(**kwargs) context["activity_title"] = self.activity_title context["container_class"] = self.container_class if self.activity_app is not None: context["activity_app"] = self.activity_app elif hasattr(self, "template_name") and isinstance(self.template_name, str): context["activity_app"] = PurePath(self.template_name).parts[0] else: raise RuntimeError( "PlatformActivity must have activity_app or template_name defined" ) if self.help_topic is not None: context["help_url"] = get_help_url(self.help_topic) return context
[docs] class TemplateView( LoginRequiredMixin, PlatformActivityMixin, django_generic.TemplateView, ): """Login-required TemplateView pre-mixed with platform activity context."""
[docs] class DetailViewMeta(type): """ Conveniently add common attributes to DetailView classes. """ def __new__(cls, name, bases, attrs, **kwargs): """Validate required attributes and inject activity/permission defaults.""" if attrs.get("abstract", False): return type.__new__(cls, name, bases, attrs, **kwargs) builder = ( ClassAttributes(attrs) .require_all("model", "template_name") .add_activity_view() .add_designation() .add_permission_required(Verb.READ) ) return type.__new__(cls, name, bases, builder.attrs, **kwargs)
[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 TableViewMeta(type): """ Conveniently add common attributes to TableView classes. """ def __new__(cls, name, bases, attrs, **kwargs): """Require ``model``/``filterset_class``/``table_class``/``ordering`` and seed defaults.""" if attrs.get("abstract", False): return type.__new__(cls, name, bases, attrs, **kwargs) builder = ( ClassAttributes(attrs) .require_all("model", "filterset_class", "table_class", "ordering") .add_activity_view() .add_actions(create_action=attrs.pop("add_create_action", True)) .add_designation() .add_permission_required(Verb.READ_TABLE_FILTERED) ) return type.__new__(cls, name, bases, builder.attrs, **kwargs)
[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 FilterViewMeta(type): """ Conveniently add common attributes to FilterView classes. """ def __new__(cls, name, bases, attrs, **kwargs): """Require ``model``/``filterset_class``/``ordering`` and seed defaults.""" if attrs.get("abstract", False): return type.__new__(cls, name, bases, attrs, **kwargs) builder = ( ClassAttributes(attrs) .require_all("model", "filterset_class", "ordering") .add_activity_view() .add_actions(create_action=attrs.pop("add_create_action", True)) .add_designation() .add_permission_required(Verb.READ_FILTERED) ) return type.__new__(cls, name, bases, builder.attrs, **kwargs)
[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 ListViewMeta(type): """ Conveniently add common attributes to ListView classes. """ def __new__(cls, name, bases, attrs, **kwargs): """Require ``model``/``ordering`` and seed activity/permission defaults.""" if attrs.get("abstract", False): return type.__new__(cls, name, bases, attrs, **kwargs) builder = ( ClassAttributes(attrs) .require_all("model", "ordering") .add_activity_view() .add_actions(create_action=attrs.pop("add_create_action", True)) .add_designation() .add_permission_required(Verb.READ) ) return type.__new__(cls, name, bases, builder.attrs, **kwargs)
[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 CopyViewMeta(type): """Metaclass that wires copy-specific defaults onto :class:`CopyView` subclasses.""" def __new__(cls, name, bases, attrs, **kwargs): """Require ``model``/``form_class``; set ``Copy <model>`` title and CREATE permission.""" if attrs.get("abstract", False): return super().__new__(cls, name, bases, attrs) # noinspection PyProtectedMember meta = attrs["model"]._meta builder = ( ClassAttributes(attrs) .require_all("model", "form_class") .add_activity_view(title=_("Copy %(name)s") % {"name": meta.verbose_name}) .add_actions(cancel_action=attrs.pop("add_cancel_action", True)) .add_permission_required(Verb.CREATE) ) return super().__new__(cls, name, bases, builder.attrs, **kwargs)
[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] def get_form_kwargs(self): """Bind the form to the source object on GET only, never on POST.""" return super().get_form_kwargs() | { "initial": self.get_form_initial(), # Populate form with existing values only for GET method, as passing # an existing instance for POST method would cause the source object # to be updated, which is of course undesired for copying. "instance": self.source_object if self.request.method == "GET" else None, }
[docs] def get_form_initial(self): """Pass the ``source_id`` to the form so it can record provenance.""" return { "source_id": self.request.resolver_match.kwargs.get("source_id"), }
[docs] class CreateViewMeta(type): """ Conveniently add common attributes to CreateView classes. """ def __new__(cls, name, bases, attrs, **kwargs): """Require ``model`` plus ``fields`` or ``form_class``; seed CREATE permission.""" if attrs.get("abstract", False): return super().__new__(cls, name, bases, attrs, **kwargs) builder = ( ClassAttributes(attrs) .require_all("model") .require_any("fields", "form_class") .add_activity_view() .add_actions(cancel_action=attrs.pop("add_cancel_action", True)) .add_permission_required(Verb.CREATE) ) return super().__new__(cls, name, bases, builder.attrs, **kwargs)
[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 UpdateViewMeta(type): """ Conveniently add common attributes to UpdateView classes. """ def __new__(cls, name, bases, attrs, **kwargs): """Require ``model`` plus ``fields`` or ``form_class``; seed UPDATE permission.""" if attrs.get("abstract", False): return type.__new__(cls, name, bases, attrs, **kwargs) builder = ( ClassAttributes(attrs) .require_all("model") .require_any("fields", "form_class") .add_activity_view() .add_designation() .add_actions( cancel_action=attrs.pop("add_cancel_action", True), create_action=attrs.pop("add_create_action", True), delete_action=attrs.pop("add_delete_action", True), read_action=attrs.pop("add_read_action", True), copy_action=attrs.pop("add_copy_action", False), ) .add_permission_required(Verb.UPDATE) ) return type.__new__(cls, name, bases, builder.attrs, **kwargs)
[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 DeleteViewMeta(type): """ Conveniently add common attributes to DeleteView classes. """ def __new__(cls, name, bases, attrs, **kwargs): """Require ``model``; seed DELETE permission and cancel action.""" if attrs.get("abstract", False): return type.__new__(cls, name, bases, attrs, **kwargs) builder = ( ClassAttributes(attrs) .require_all("model") .add_activity_view() .add_actions(cancel_action=attrs.pop("add_cancel_action", True)) .add_permission_required(Verb.DELETE) ) return type.__new__(cls, name, bases, builder.attrs, **kwargs)
[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 form_valid(self, form): """Perform the delete, falling back to the form when ``on_delete=RESTRICT`` fires.""" try: return super().form_valid(form) except RestrictedError: self.deletion_restricted = True return self.get(self.request)
[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