dynamicweb/cms/admin/utils.py

1192 lines
56 KiB
Python

import re
import typing
from copy import copy
from functools import partial
from urllib.parse import parse_qsl
from django import forms
from django.contrib.admin import ModelAdmin
from django.contrib.admin.checks import ModelAdminChecks
from django.contrib.admin.utils import label_for_field
from django.contrib.admin.views.main import ChangeList
from django.contrib.auth import get_permission_codename
from django.contrib.sites.models import Site
from django.core import checks
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import models
from django.db.models import DateField, OuterRef, Subquery, functions
from django.db.models.functions import Cast
from django.forms import modelform_factory, modelformset_factory
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, path
from django.utils.decorators import method_decorator
from django.utils.html import format_html, format_html_join
from django.utils.http import urlencode
from django.utils.safestring import mark_safe
from django.utils.translation import get_language, gettext_lazy as _
from django.views.decorators.http import require_GET
from cms.models.managers import ContentAdminManager
from cms.toolbar.utils import get_object_edit_url
from cms.utils import get_current_site, get_language_from_request
from cms.utils.helpers import is_editable_model
from cms.utils.i18n import get_language_dict, get_language_list, get_language_tuple
from cms.utils.urlutils import admin_reverse, static_with_version
class ChangeListActionsMixin(metaclass=forms.widgets.MediaDefiningClass):
"""ChangeListActionsMixin is a mixin for the ModelAdmin class. It adds the ability to have
action buttons and a burger menu in the admin's change list view. Unlike actions that affect
multiple listed items the list action buttons only affect one item at a time.
Use :meth:`~cms.admin.utils.ChangeListActionsMixin.get_action_list` to register actions and
:meth:`~cms.admin.utils.ChangeListActionsMixin.admin_action_button` to define the button
behavior.
To activate the actions make sure ``"admin_list_actions"`` is in the admin classes
:attr:`~django.contrib.admin.ModelAdmin.list_display` property.
"""
class Media:
js = (
"admin/js/jquery.init.js",
"cms/js/admin/actions.js",
)
css = {"all": (static_with_version("cms/css/cms.admin.css"),)}
EMPTY_ACTION = mark_safe('<span class="cms-empty-action"></span>')
def get_actions_list(
self,
) -> list[typing.Callable[[models.Model, HttpRequest], str]]:
"""Collect list actions from implemented methods and return as list. Make sure to call
it's ``super()`` instance when overwriting::
class MyModelAdmin(admin.ModelAdmin):
...
def get_actions_list(self):
return super().get_actions_list() + [
self.my_first_action,
self.my_second_action,
]
"""
return []
def get_admin_list_actions(self, request: HttpRequest) -> typing.Callable[[models.Model], str]:
"""Method to register the admin action menu with the admin's list display
Usage (in your model admin)::
class MyModelAdmin(AdminActionsMixin, admin.ModelAdmin):
...
list_display = ("name", ..., "admin_list_actions")
"""
def list_actions(obj: models.Model) -> str:
"""The name of this inner function must not change. css styling and js depends on it."""
return format_html_join(
"",
"{}",
((action(obj, request),) for action in self.get_actions_list()),
)
list_actions.short_description = _("Actions")
return list_actions
def admin_list_actions(self, obj: models.Model) -> None:
raise ValueError(
'ModelAdmin.display_list contains "admin_list_actions" as a placeholder for list action icons. '
'ChangeListActionsMixin is not loaded, however. If you implement "get_list_display" make '
"sure it calls super().get_list_display."
) # pragma: no cover
def get_list_display(self, request: HttpRequest) -> tuple[str | typing.Callable[[models.Model], str], ...]:
list_display = super().get_list_display(request)
return tuple(
self.get_admin_list_actions(request) if item == "admin_list_actions" else item for item in list_display
)
@staticmethod
def admin_action_button(
url: str,
icon: str,
title: str,
burger_menu: bool = False,
action: str = "get",
disabled: bool = False,
keepsideframe: bool = True,
name: str = "",
) -> str:
"""Returns a generic button supported by the ChangeListActionsMixin.
:param str url: Url of the action as string, typically generated by :func:`~cms.utils.urlutils.admin_reverse`_
:param str icon: Name of the icon shown in the button or before the title in the burger menu.
:param str title: Human-readable string describing the action.
:param bool burger_menu: If ``True`` the action item will be part of a burger menu right og all buttons.
:param str action: Either ``"get"`` or ``"post"`` defining the html method used for the url. Some urls
require a post method.
:param bool disabled: If ``True`` the item is grayed out and cannot be selected.
:param bool keepsideframe: If ``False`` the side frame (if open) will be closed before executing the action.
:param str name: A string that will be added to the class list of the button/menu item:
``cms-action-{{ name }}``
To add an action button to the change list use the following pattern in your admin class::
def my_custom_button(self, obj, request, disabled=False):
# do preparations, e.g., check permissions, get url, ...
url = admin_reverse("...", args=[obj.pk])
if permissions_ok:
return self.admin_action_button(
url, "info", _("View usage"), disabled=disabled
)
return "" # No button
"""
return render_to_string(
"admin/cms/icons/base.html",
{
"url": url or "",
"icon": icon,
"method": action,
"disabled": disabled,
"keepsideframe": keepsideframe,
"title": title,
"burger_menu": burger_menu,
"name": name,
},
)
#: Prefix for content model fields to be added to the grouper admin and to the change form.
CONTENT_PREFIX = "content__"
class GrouperChangeListBase(ChangeList):
"""Subclass ChangeList to disregard grouping fields get parameter as filter"""
current_language: str | None = None
available_languages: tuple[tuple[str, str], ...] = ()
_extra_grouping_fields: list[str] = []
_content_pk_url_param: str | None = None
def get_filters_params(self, params: dict | None = None):
lookup_params = super().get_filters_params(params)
# The content pk parameter selects a content object in the change view. It is preserved
# across the change form's submission and can therefore reach the changelist, where it is
# meaningless (the changelist always lists the latest content) - and would be rejected as
# an unknown filter.
for field in (*self._extra_grouping_fields, self._content_pk_url_param):
if field in lookup_params:
del lookup_params[field]
return lookup_params
class GrouperModelAdminChecks(ModelAdminChecks):
def _check_list_editable_item(self, obj, field_name, label):
"""Check content model fields against the content model."""
if field_name.startswith(CONTENT_PREFIX) and obj.content_model:
content_field_name = field_name[len(CONTENT_PREFIX) :]
if content_field_name == obj.grouper_field_name or content_field_name in obj.extra_grouping_fields:
return [
checks.Error(
f"The value of '{label}' refers to '{field_name}', which cannot be edited "
"from the changelist.",
obj=obj.__class__,
id="admin.E125",
)
]
content_obj = copy(obj)
content_obj.model = obj.content_model
content_obj.list_display = tuple(
item[len(CONTENT_PREFIX) :] if isinstance(item, str) and item.startswith(CONTENT_PREFIX) else item
for item in obj.list_display
)
if obj.list_display_links:
content_obj.list_display_links = tuple(
item[len(CONTENT_PREFIX) :]
if isinstance(item, str) and item.startswith(CONTENT_PREFIX)
else item
for item in obj.list_display_links
)
return super()._check_list_editable_item(content_obj, content_field_name, label)
return super()._check_list_editable_item(obj, field_name, label)
def _check_prepopulated_fields_value_item(self, obj, field_name, label):
"""For `prepopulated_fields` equal to {"slug": ("content__title",)},
`field_name` is "content__title"."""
if field_name.startswith(CONTENT_PREFIX) and obj.content_model:
field_name = field_name[len(CONTENT_PREFIX) :]
obj = copy(obj)
obj.model = obj.content_model
return super()._check_prepopulated_fields_value_item(obj, field_name, label)
def _check_prepopulated_fields_key(self, obj, field_name, label):
"""Check a key of `prepopulated_fields` dictionary, i.e. check that it
is a name of existing field and the field is one of the allowed types.
"""
if field_name.startswith(CONTENT_PREFIX) and obj.content_model:
field_name = field_name[len(CONTENT_PREFIX) :]
obj = copy(obj)
obj.model = obj.content_model
return super()._check_prepopulated_fields_key(obj, field_name, label)
class GrouperModelAdmin(ChangeListActionsMixin, ModelAdmin):
"""Easy-to-use ModelAdmin for grouper models. Usage example::
class MyGrouperAdmin(GrouperModelAdmin):
# Add language tabs to change and add views
extra_grouping_fields = ("language",)
# Add grouper and content fields to change list view
# Add preview and settings action to change list view
list_display = ("field_in_grouper_model", "content__field_in_content_model", "admin_list_actions")
# Automatically add content fields to change form (either the standard form or any form given
form = MyChangeForm
...
Using ``GrouperModelAdmin`` instead of :class:`~django.contrib.admin.ModelAdmin` adds a view standard functions
to your admin class to make it more easily and more consistently customizable.
1. By adding ``"admin_list_actions"`` to the admin's :attr:`~django.contrib.admin.ModelAdmin.list_display`
attribute the change list view gets an action column as described by
:class:`~cms.admin.utils.ChangeListActionsMixin`.
2. The admin class automatically creates a method for each field of the content model form (default: all fields)
named ``content__{content_model_field_name}``. Those fields can be used in
:attr:`~django.contrib.admin.ModelAdmin.list_display` just as grouper model fields.
Currently, they are not sortable, however.
3. The change form is amended with exactly those content fields also named ``content__{content_model_field_name}``.
As a result, the change form can (but does not have to) contain both grouper model fields and content model
fields. The admin takes care of creating the necessary model instances.
"""
#: The name of the ``ForeignKey`` in the content model that points to the grouper instance. If not given
#: it is assumed to be the snake case name of the grouper model class, e.g. ``"blog_post"`` for the
#: ``"BlogPost"`` model.
grouper_field_name: str | None = None
#: Indicates additional grouping fields such as ``"language"`` for example. Additional grouping fields create
#: tabs in the change form and a dropdown menu in the change list view.
#:
#: .. note::
#:
#: All fields serving as extra grouping fields must be part of the admin's
#: :attr:`~django.contrib.admin.ModelAdmin.fieldsets` setting for ``GrouperModelAdmin`` to work properly.
#: In the change form the fields will be invisible.
extra_grouping_fields: tuple[str, ...] = ()
#: The content model class to be used. Defaults to the model class named like the grouper model class
#: plus ``"Content"`` at the end from the same app as the grouper model class, e.g., ``BlogPostContent`` if
#: the grouper is ``BlogPost``.
content_model: type[models.Model] | None = None
#: Name of the inverse relation field giving the set of content models belonging to a grouper model. Defaults to
#: the first field found as an inverse relation. If you have more than one inverse relation please make sure
#: to specify this field. An example would be if the blog post content model contained a many-to-many
#: relationship to the grouper model for, say, related blog posts.
content_related_field: str | None = None
change_list_template = "admin/cms/grouper/change_list.html"
change_form_template = "admin/cms/grouper/change_form.html"
checks_class = GrouperModelAdminChecks
class Media:
js = (
"admin/js/jquery.init.js",
"cms/js/admin/language-selector.js",
)
EMPTY_CONTENT_VALUE = _("Empty content")
LC_SORTED_FIELDS = (models.CharField,)
CONTENT_OBJ_PK_ANNOTATION = "_content_obj_pk"
_content_content_type = None
#: Name of the GET parameter that selects a specific content object (by primary key) to be shown
#: in the change view instead of the latest content. See :meth:`get_urls`.
content_pk_url_param = "cms_content"
#: Holds the specific content object requested via :attr:`content_pk_url_param` for the current request.
_requested_content_obj: models.Model | None = None
def __init__(self, model, admin_site):
self._content_subquery_fields = []
super().__init__(model, admin_site)
# Identify content model
if self.content_model is None: # Did the Admin class specify a content model?
# If not, try identifying using the naming convention {GrouperName}Content
from django.apps import apps
self.content_model = apps.get_model(f"{self.opts.app_label}.{self.model.__name__}Content")
# Add an admin manager if the content model does not have one.
if not hasattr(self.content_model, "admin_manager"):
self.content_model.add_to_class("admin_manager", ContentAdminManager())
# Find name of related field
if not self.content_related_field:
for related_object in model._meta.related_objects:
if related_object.related_model is self.content_model:
self.content_related_field = related_object.get_accessor_name()
break
else:
raise ImproperlyConfigured(f"Related field for grouper model {model.__name__} not found")
# Set grouper field name to snake case grouper model name if not given explicitly
if not self.grouper_field_name:
self.grouper_field_name = re.sub("(?!^)([A-Z]+)", r"_\1", self.model.__name__).lower() # To snake case
# Auto-generate ModelForm for Grouper if not specified (using GrouperAdminFormMixin)
if not issubclass(self.form, _GrouperAdminFormMixin):
self.form = type(
"AutoGeneratedGrouperAdminForm",
(GrouperAdminFormMixin(self.content_model), self.form),
dict(),
)
# Generate accessor functions for content model fields
content_field_names = modelform_factory(self.content_model, fields="__all__").base_fields.keys()
for content_field in content_field_names:
if (
not hasattr(self, CONTENT_PREFIX + content_field)
and content_field != self.grouper_field_name
and content_field not in self.extra_grouping_fields
):
if CONTENT_PREFIX + content_field in self.list_display:
# Identify content fields in list_display to annotate to queryset
self._content_subquery_fields.append(content_field)
setattr(
self,
CONTENT_PREFIX + content_field,
self._getter_factory(content_field),
)
def _getter_factory(self, field: str) -> typing.Callable[[models.Model], typing.Any]:
"""Creates a getter function with ``short_description``, ``admin_order_field``, and ``boolean``
properties suitable for the :attr:`~django.contrib.admin.ModelAdmin.list_display` field."""
def getter(obj):
return self.get_content_field(obj, field)
getter.short_description = label_for_field(field, self.content_model)
if field in self._content_subquery_fields:
getter.admin_order_field = CONTENT_PREFIX + field
if isinstance(self.content_model._meta.get_field(field), self.LC_SORTED_FIELDS):
getter.admin_order_field += "__lc"
getter.boolean = isinstance(self.content_model._meta.get_field(field), models.BooleanField)
if not getter.boolean:
# First non-boolean field will show empty content value by default.
for display in getattr(self, "list_display", ()):
if display == CONTENT_PREFIX + field:
getter.empty_value_display = self.EMPTY_CONTENT_VALUE
if display.startswith(CONTENT_PREFIX):
break
return getter
def get_content_field(
self,
obj: models.Model,
field_name: str,
request: HttpRequest | None = None,
) -> typing.Any:
"""Retrieves the content of a field stored in the content model. If request is given extra
grouping fields are processed before."""
if hasattr(obj, CONTENT_PREFIX + field_name):
# Annotated?
return getattr(obj, CONTENT_PREFIX + field_name)
if request:
self.get_grouping_from_request(request)
content_obj = self.get_content_obj(obj)
return getattr(content_obj, field_name) if content_obj else None
def _get_annotation(self):
contents = self.content_model.admin_manager.latest_content(
**{self.grouper_field_name: OuterRef("pk"), **self.current_content_filters}
)
annotation = {
self.CONTENT_OBJ_PK_ANNOTATION: Subquery(contents.values("pk")[:1]),
}
for field_name in self._content_subquery_fields:
annotation[CONTENT_PREFIX + field_name] = Subquery(contents.values(field_name)[:1])
field = self.content_model._meta.get_field(field_name)
if isinstance(field, DateField):
# MySql needs an explicit cast, or it will return a string and not a date object
annotation[CONTENT_PREFIX + field_name] = Cast(
annotation[CONTENT_PREFIX + field_name], field.__class__()
)
if isinstance(field, self.LC_SORTED_FIELDS):
# Sort CharFields independently of case
annotation[CONTENT_PREFIX + field_name + "__lc"] = functions.Lower(
Subquery(contents.values(field_name)[:1])
)
return annotation
def can_change_content(self, request, content_obj):
return self.get_content_readonly_message(request, content_obj) is None
def get_content_readonly_message(self, request, content_obj):
"""Return ``None`` if the content can be changed, otherwise a human-readable
message explaining why it is read-only.
Permissions are owned by django CMS; editability (and its explanation) is
owned by the content object's ``is_editable`` method. That method may return
either a plain ``bool`` or a "rich bool" (e.g. from djangocms-versioning) that
is falsy but also carries the reason(s) the editability checks failed via
``str()``. Plain bools carry no reason, so a generic message is used.
"""
opts = self.content_model._meta
perm = f"{opts.app_label}.{get_permission_codename('change' if content_obj else 'add', opts)}"
if not request.user.has_perm(perm, content_obj):
return _("You do not have permission to change this content.")
editable = getattr(content_obj, "is_editable", lambda *_: True)(request)
if editable:
return None
# ``editable`` is falsy: a plain bool offers no explanation, while a rich bool
# exposes the reason(s) the checks failed via ``str()``.
return " " if isinstance(editable, bool) else str(editable) # " " is empty but truthy
def get_queryset(self, request: HttpRequest) -> models.QuerySet:
"""Annotates content fields with the name "content__{field_name}" to the grouper queryset if
for all content fields that appear in the"""
qs = super().get_queryset(request).annotate(**self._get_annotation())
prefetch = models.Prefetch(
self.content_related_field,
queryset=self.content_model.admin_manager.latest_content(),
to_attr="_admin_prefetch_cache",
)
return qs.prefetch_related(prefetch)
def get_language_from_request(self, request: HttpRequest) -> str:
"""Hook for get_language_from_request which by default uses the cms utility"""
return get_language_from_request(request)
def get_grouping_from_request(self, request: HttpRequest) -> None:
"""Retrieves the current grouping selectors from the request"""
for field in self.extra_grouping_fields:
if hasattr(self, f"get_{field}_from_request"):
value = getattr(self, f"get_{field}_from_request")(request)
else:
raise ImproperlyConfigured(
f"{self.__class__.__name__} lacks method 'get_{field}_from_request(request)' to work with "
f"extra_grouping_fields={self.extra_grouping_fields}"
)
if value != getattr(self, field, None):
setattr(self, field, value)
# A specific content object may be requested by its primary key. If so, show exactly that
# content object (which may not be the latest one) and align the grouping fields with it so
# that the form, its hidden fields, and the edit URLs stay consistent.
self._requested_content_obj = self.get_requested_content_obj(request)
if self._requested_content_obj is not None:
for field in self.extra_grouping_fields:
setattr(self, field, getattr(self._requested_content_obj, field))
def get_requested_content_obj(self, request: HttpRequest) -> models.Model | None:
"""Returns the content object requested by primary key via :attr:`content_pk_url_param`, or
``None`` if no (valid) content object was requested. Uses the ``admin_manager`` so that
non-current content objects (e.g. older versions) can be shown."""
content_pk = request.GET.get(self.content_pk_url_param)
if not content_pk:
return None
try:
return self.content_model.admin_manager.filter(pk=content_pk).first()
except (ValueError, TypeError, ValidationError):
# Invalid primary key for the content model: ignore and fall back to the latest content.
return None
@property
def current_content_filters(self) -> dict[str, typing.Any]:
"""Filters needed to get the correct content model instance"""
return {
field: getattr(self, field, self.get_extra_grouping_field(field)) for field in self.extra_grouping_fields
}
def get_language(self) -> str:
"""Hook on how to get the current language. By default, if it is set as a
property, use the property, otherwise let Django provide it."""
return getattr(self, "language", get_language())
def get_language_tuple(self, site: Site | None = None) -> tuple[tuple[str, str], ...]:
"""Hook on how to get all available languages for the language selector."""
return get_language_tuple(site_id=site.pk if site else None)
def get_extra_grouping_field(self, field):
"""Retrieves the current value for grouping fields - by default by calling self.get_<field>, e.g.,
self.get_language(). If those are not implemented, this method will fail."""
if callable(getattr(self, f"get_{field}", None)):
return getattr(self, f"get_{field}")()
raise ValueError("Cannot get extra grouping field")
def get_changelist(self, request: HttpRequest, **kwargs) -> type:
"""Allow for extra grouping fields as a non-filter parameter"""
return type(
GrouperChangeListBase.__name__,
(GrouperChangeListBase,),
dict(
_extra_grouping_fields=self.extra_grouping_fields,
_content_pk_url_param=self.content_pk_url_param,
),
)
def get_urls(self) -> list:
"""Adds a change URL for the content model that redirects to the grouper change view showing
the requested content object.
This gives every content object a stable admin change URL (``admin:<app>_<content>_change``)
that resolves through the unified grouper change form - without having to register a separate
admin for the content model. If the content model already has its own admin (and hence already
provides that URL name), the declaration is skipped so the existing admin keeps precedence.
"""
urls = super().get_urls()
if not self.admin_site.is_registered(self.content_model):
opts = self.content_model._meta
urls = [
path(
"content/<path:object_id>/change/",
self.admin_site.admin_view(self.content_change_redirect_view),
name=f"{opts.app_label}_{opts.model_name}_change",
),
] + urls
return urls
@method_decorator(require_GET)
def content_change_redirect_view(self, request: HttpRequest, object_id: str) -> HttpResponse:
"""Redirects from a content object's change URL to the grouper change view, selecting exactly
that content object via the :attr:`content_pk_url_param` GET parameter.
Limited to ``GET``: this is a navigation entry point only. The editable form lives at the
grouper change view (which is where it is submitted), so a non-GET request here would
otherwise be silently turned into a ``GET`` by the redirect and its payload discarded.
"""
content_obj = get_object_or_404(self.content_model.admin_manager.all(), pk=object_id)
grouper = getattr(content_obj, self.grouper_field_name)
opts = grouper._meta
url = admin_reverse(f"{opts.app_label}_{opts.model_name}_change", args=(grouper.pk,))
params = {field: getattr(content_obj, field) for field in self.extra_grouping_fields}
params[self.content_pk_url_param] = content_obj.pk
return redirect(f"{url}?{urlencode(params)}")
def get_changelist_instance(self, request: HttpRequest) -> GrouperChangeListBase:
"""Update grouping field properties and get changelist instance"""
self.get_grouping_from_request(request)
cl = super().get_changelist_instance(request)
cl.current_language = self.get_language()
if "language" in self.extra_grouping_fields:
cl.available_languages = self.get_language_tuple(site=get_current_site(request))
return cl
def changeform_view(
self,
request: HttpRequest,
object_id: str | None = None,
form_url: str = "",
extra_context: dict = None,
) -> HttpResponse:
"""Update grouping field properties for both add and change views"""
self.get_grouping_from_request(request)
return super().changeform_view(
request,
object_id,
form_url,
{
**(extra_context or {}),
**self.get_extra_context(request, object_id=object_id),
},
)
def delete_view(
self,
request: HttpRequest,
object_id: str,
extra_context: dict | None = None,
) -> HttpResponse:
"""Update grouping field properties for delete view"""
self.get_grouping_from_request(request)
return super().delete_view(request, object_id, extra_context)
def history_view(
self,
request: HttpRequest,
object_id: str,
extra_context: dict | None = None,
) -> HttpResponse:
"""Update grouping field properties for history view"""
self.get_grouping_from_request(request)
return super().history_view(request, object_id, extra_context)
def response_change(self, request: HttpRequest, obj: models.Model) -> HttpResponse:
"""Drop the content selection unless the user stays in the change form: only "save and
continue editing" returns to the very content object that was just edited. Every other
target (the changelist, the add view) shows or creates the latest content, so the
parameter would only stick to their urls."""
if "_continue" not in request.POST:
self._requested_content_obj = None
return super().response_change(request, obj)
def get_preserved_filters(self, request: HttpRequest) -> str:
"""Always preserve grouping get parameters! Also, add them to changelist filters:
* Save and continue will keep the grouping parameters
* Save and returning to changelist will keep the grouping parameters
"""
preserved_filters = dict(parse_qsl(super().get_preserved_filters(request)))
# Extra grouping fields from property
grouping_filters = {}
for field in self.extra_grouping_fields:
value = getattr(self, field, None)
if "field" not in preserved_filters:
grouping_filters[field] = value
preserved_filters.update(grouping_filters)
if "_changelist_filters" not in preserved_filters:
preserved_filters["_changelist_filters"] = urlencode(grouping_filters)
# Keep a specifically requested content object selected: the change form is submitted to the
# url built from the preserved filters, and "save and continue editing" returns to it. Without
# the parameter both would silently fall back to the latest content object - and saving would
# write the form's data to a different content object than the one being edited.
# It is deliberately not part of "_changelist_filters": the changelist always lists the
# latest content.
if self._requested_content_obj is not None:
preserved_filters[self.content_pk_url_param] = self._requested_content_obj.pk
return urlencode(preserved_filters)
def get_extra_context(self, request: HttpRequest, object_id: str | None = None) -> dict[str, typing.Any]:
"""Provide the grouping fields to the change view."""
if object_id:
# Instance provided? Get corresponding postconent
obj = get_object_or_404(self.model, pk=object_id)
content_instance = self.get_content_obj(obj)
title = _("%(object_name)s Properties") % dict(object_name=obj._meta.verbose_name.capitalize())
else:
obj = None
content_instance = None
title = _("Add new %(object_name)s") % dict(object_name=self.model._meta.verbose_name)
if content_instance:
subtitle = str(content_instance)
else:
subtitle = _("Add content")
# Whether the content object may be changed is independent of the grouping fields:
# it has to be in the context for every grouper admin, or the change form cannot
# tell an editable content object from a read-only one. Gate on ``can_change_content``,
# the same predicate ``get_readonly_fields`` uses, so the note and the actually
# read-only fields cannot disagree; the message only explains a denial.
can_change_content = self.can_change_content(request, content_instance)
readonly_message = None if can_change_content else self.get_content_readonly_message(request, content_instance)
extra_context = {
"changed_message": _(
'Content for the current language has been changed. Click "Cancel" to '
'return to the form and save changes. Click "OK" to discard changes.'
),
"title": title,
"content_instance": content_instance,
"subtitle": subtitle,
"content_readonly_message": readonly_message,
"can_change_content_obj": can_change_content,
}
"""Provide the grouping fields to edit"""
if "language" in self.extra_grouping_fields:
language = self.language
if obj:
filled_languages = self.get_content_objects(obj).values_list("language", flat=True).distinct()
else:
filled_languages = []
site = get_current_site(request)
if self.is_latest_content_obj(content_instance, obj):
# Only offer the language selector for the latest content. Switching the
# language always navigates to the latest content of the target language,
# so for an older content object switching languages back and forth would
# silently bring up a different (the latest) content object - confusing UX.
extra_context["language_tabs"] = self.get_language_tuple(site=site)
extra_context["language"] = language
extra_context["filled_languages"] = filled_languages
if content_instance is None:
subtitle = _("Add %(language)s content") % dict(
language=get_language_dict(site_id=site.pk).get(self.language)
)
extra_context["subtitle"] = subtitle
# TODO: Add context for other grouping fields to be shown as a dropdown
return extra_context
def get_form(self, request: HttpRequest, obj: models.Model | None = None, **kwargs) -> type:
"""Adds the language from the request to the form class"""
self.get_grouping_from_request(request) # direct entry point from django admin
form_class = super().get_form(request, obj, **kwargs)
form_class = type(form_class)(
form_class.__name__,
(form_class,),
{"_admin": self, "_request": request},
)
for field in self.extra_grouping_fields:
form_class.base_fields[CONTENT_PREFIX + field].widget = forms.HiddenInput()
if (getattr(form_class._meta, "fields", None) or "__all__") != "__all__":
for field in self.extra_grouping_fields:
if CONTENT_PREFIX + field not in form_class._meta.fields:
raise ImproperlyConfigured(
f"{self.__class__.__name__} needs to include all "
f"extra_grouping_fields={self.extra_grouping_fields} in its admin. {field} is missing."
)
return form_class
def get_changelist_form(self, request: HttpRequest, **kwargs) -> type:
"""Return a grouper form capable of editing grouper and content fields."""
self.get_grouping_from_request(request)
content_id_field = forms.ModelChoiceField(
queryset=self.content_model.admin_manager.all(),
required=False,
widget=forms.HiddenInput(),
)
editable_content_fields = {
field for field in self.list_editable if field.startswith(CONTENT_PREFIX)
}
form_attributes = {
"_admin": self,
"_request": request,
"_content_object_id": content_id_field,
}
form_attributes.update(
{
CONTENT_PREFIX + field: None
for field in self.form._content_fields
if CONTENT_PREFIX + field not in editable_content_fields
}
)
form_class = type(self.form)(
self.form.__name__,
(self.form,),
form_attributes,
)
return super().get_changelist_form(request, form=form_class, **kwargs)
def get_changelist_formset(self, request: HttpRequest, **kwargs) -> type:
"""Include the hidden content identity in the changelist formset."""
defaults = {
"formfield_callback": partial(self.formfield_for_dbfield, request=request),
**kwargs,
}
return modelformset_factory(
self.model,
self.get_changelist_form(request),
extra=0,
fields=(*self.list_editable, "_content_object_id"),
**defaults,
)
# Admin list actions defined below:
# * View button that takes the user to the preview endpoint of the content model
# * Settings button that lets the user change the grouper AND the content model
# using one form
def _get_view_action(self, obj: models.Model, request: HttpRequest) -> str:
if not is_editable_model(self.content_model):
return ""
view_url = self.view_on_site(obj)
if view_url:
return self.admin_action_button(
url=view_url,
icon="view",
title=_("Preview"),
disabled=not view_url,
keepsideframe=False,
name="view",
)
return ""
def _has_content(self, obj: models.Model) -> bool:
if self._is_content_obj(obj):
return True # pragma: no cover
if hasattr(obj, self.CONTENT_OBJ_PK_ANNOTATION):
return getattr(obj, self.CONTENT_OBJ_PK_ANNOTATION) is not None
return self.get_content_obj(obj) is not None # pragma: no cover
def _get_settings_action(self, obj: models.Model, request: HttpRequest) -> str:
edit_url = admin_reverse(f"{obj._meta.app_label}_{obj._meta.model_name}_change", args=(obj.pk,))
edit_url += f"?{urlencode(self.current_content_filters)}"
has_content = self._has_content(obj)
return self.admin_action_button(
url=edit_url,
icon="settings" if has_content else "plus",
title=_("Settings") if has_content else _("Add content"),
disabled=not edit_url,
name="settings",
)
def get_actions_list(self) -> list:
return [self._get_view_action, self._get_settings_action]
def endpoint_url(self, admin: str, obj: models.Model) -> str:
if self._is_content_obj(obj):
cls = obj.__class__
pk = obj.pk
else:
content = self.get_content_obj(obj)
cls = content.__class__
pk = content.pk
if self._content_content_type is None:
from django.contrib.contenttypes.models import ContentType
self._content_content_type = ContentType.objects.get_for_model(cls).pk
try:
return admin_reverse(admin, args=[self._content_content_type, pk])
except NoReverseMatch:
return ""
def _is_content_obj(self, obj: models.Model) -> bool:
return isinstance(obj, self.content_model)
def _get_content_queryset(self, obj: models.Model) -> models.QuerySet:
return getattr(obj, self.content_related_field)(manager="admin_manager").latest_content()
def get_content_obj(self, obj: models.Model | None) -> models.Model | None:
if obj is None or self._is_content_obj(obj):
return obj
# A specific content object was requested by primary key? Show exactly that object as long
# as it belongs to the grouper being edited (instead of the latest content).
requested = self._requested_content_obj
if requested is not None and getattr(requested, f"{self.grouper_field_name}_id", None) == obj.pk:
return requested
if not hasattr(obj, "_grouper_admin_content_obj_cache"):
# Check prefetch cache
if hasattr(obj, "_admin_prefetch_cache"):
for content_obj in obj._admin_prefetch_cache:
if all(
getattr(content_obj, key, None) == value for key, value in self.current_content_filters.items()
):
obj._grouper_admin_content_obj_cache = content_obj
return content_obj
obj._grouper_admin_content_obj_cache = None # no hit
return None
obj._grouper_admin_content_obj_cache = (
self._get_content_queryset(obj).filter(**self.current_content_filters).first()
)
return obj._grouper_admin_content_obj_cache
def get_content_objects(self, obj: models.Model | None) -> models.QuerySet:
if obj is None:
return None
if self._is_content_obj(obj):
# Already content object? First get grouper and then all content objects
return self.get_content_objects(self.get_grouper_obj(obj))
return self._get_content_queryset(obj)
def is_latest_content_obj(self, content_obj: models.Model | None, obj: models.Model | None = None) -> bool:
"""Hook to decide whether ``content_obj`` is the latest content for its grouper and the
current grouping fields (e.g. language).
By default :meth:`get_content_obj` returns the latest content, so this is always ``True``.
Versioning packages, however, may show an older content object in the change form. In that
case switching grouping fields (such as the language) navigates to the latest content of the
target value, so switching back and forth would not return to the same content object. The
change form therefore hides the grouping selectors when an older content object is shown.
"""
if content_obj is None:
# The add view always edits the (still non-existing) latest content.
return True
latest = self.get_content_objects(obj or content_obj).filter(**self.current_content_filters).first()
return latest is None or latest.pk == content_obj.pk
def clear_content_cache(self) -> None:
# Content objects are now stored in the object, no cache clear for admin class necessary
pass
def get_grouper_obj(self, obj: models.Model) -> models.Model:
"""Get the admin object. If obj is a content object assume that the admin object
resides in the field named after the admin model. The admin model name must be
the same as the content model name minus "Content" at the end."""
if self._is_content_obj(obj):
field_name = obj.__class__.__name__[-7:].lower()
return getattr(obj, field_name)
return obj
def view_on_site(self, obj: models.Model) -> str | None:
# Adds the View on Site button to the admin
content_obj = self.get_content_obj(obj)
if content_obj is not None and is_editable_model(content_obj.__class__):
# Try getting the language from the content object
return get_object_edit_url(content_obj, language=getattr(content_obj, "language", None))
return None
def get_readonly_fields(self, request: HttpRequest, obj: models.Model | None = None):
"""Allow access to content fields to be controlled by a method "can_change_content":
This allows versioned content to be protected if needed"""
# First, get read-only fields for grouper
fields = super().get_readonly_fields(request, obj)
content_obj = self.get_content_obj(obj)
if not self.can_change_content(request, content_obj):
# Only allow content object fields to be edited if user can change them
fields = [
*fields,
*(
CONTENT_PREFIX + field
for field in self.form._content_fields
if field != self.grouper_field_name and field not in self.extra_grouping_fields
),
]
return fields
def get_prepopulated_fields(self, request: HttpRequest, obj: models.Model | None = None) -> dict:
"""Drop prepopulated entries whose key is read-only.
``AdminForm`` looks up every prepopulated key in the rendered form, so an entry
keyed on a field that ``get_readonly_fields`` returns would raise ``KeyError``.
Filter dynamically against the class attribute so subclasses can keep declaring
``prepopulated_fields`` the normal way.
"""
prepopulated_fields = super().get_prepopulated_fields(request, obj)
if not prepopulated_fields:
return prepopulated_fields
readonly = set(self.get_readonly_fields(request, obj))
return {key: value for key, value in prepopulated_fields.items() if key not in readonly}
def save_model(self, request: HttpRequest, obj: models.Model, form: forms.Form, change: bool) -> None:
"""Save/create both grouper and content object"""
super().save_model(request, obj or form.instance, form, change)
if not hasattr(form, "_content_fields"):
return
content_dict = {
field: form.cleaned_data[CONTENT_PREFIX + field]
for field in form._content_fields
if CONTENT_PREFIX + field in form.cleaned_data
}
if not content_dict:
return
if form._content_instance is None or form._content_instance.pk is None:
content_dict[self.grouper_field_name] = form.instance
content_dict.update(self.current_content_filters)
if hasattr(form._content_model.objects, "with_user"):
# Create new using with_user syntax if available ...
form._content_model.objects.with_user(request.user).create(**content_dict)
else: # pragma: no cover
# ... without otherwise
form._content_model.objects.create(**content_dict)
elif self.can_change_content(request, form._content_instance):
# Update content instance (only if can_change_content allows it)
for key, value in content_dict.items():
setattr(form._content_instance, key, value)
# Finally force grouper field to point to grouper
setattr(form._content_instance, self.grouper_field_name, obj)
form._content_instance.save()
def get_search_fields(self, request):
"""Return search fields for either grouper model or content model"""
content_search_fields = []
grouper_search_fields = []
for field_name in self.search_fields:
if field_name.startswith(CONTENT_PREFIX):
content_search_fields.append(field_name[len(CONTENT_PREFIX) :])
else:
grouper_search_fields.append(field_name)
if getattr(request, "_content_fields", False):
return content_search_fields
return grouper_search_fields
def get_search_results(self, request, queryset, search_term):
grouper_search_result, may_have_duplicate_grouper = super().get_search_results(request, queryset, search_term)
search_result_from_content, may_have_duplicate_content = self._get_content_search_result(
request, queryset, search_term
)
return grouper_search_result | search_result_from_content, (
may_have_duplicate_grouper & may_have_duplicate_content
)
def _get_content_search_result(self, request, queryset, search_term):
"""Get search results from content model"""
try:
# Set flag on request object to get the content search fields. `get_search_results` will call
# `get_search_fields` to get the content search fields.
request._content_fields = True
content_queryset = self.content_model.admin_manager.all()
if self.get_search_fields(request):
content_search_result, __ = super().get_search_results(request, content_queryset, search_term)
else:
content_search_result = self.content_model.admin_manager.none()
search_result_from_content = queryset.filter(
id__in=content_search_result.values_list(f"{self.grouper_field_name}_id", flat=True)
)
finally:
request._content_fields = False
return search_result_from_content, False
class _ContentIdentityWidget(forms.Widget):
"""Render a content field together with its changelist content identity."""
def __init__(self, widget, content_field_name):
super().__init__(attrs=widget.attrs)
self.widget = widget
self.content_field_name = content_field_name
self.content_object_id = None
@property
def media(self):
return self.widget.media
def id_for_label(self, id_):
return self.widget.id_for_label(id_)
def value_from_datadict(self, data, files, name):
return self.widget.value_from_datadict(data, files, name)
def value_omitted_from_data(self, data, files, name):
return self.widget.value_omitted_from_data(data, files, name)
def render(self, name, value, attrs=None, renderer=None):
field = self.widget.render(name, value, attrs=attrs, renderer=renderer)
prefix = name[: -len(self.content_field_name)]
identity = forms.HiddenInput().render(
f"{prefix}_content_object_id",
self.content_object_id,
renderer=renderer,
)
return format_html("{}{}", field, identity)
class _GrouperAdminFormMixin:
_content_fields: list = []
def __init__(self, *args, **kwargs):
if not hasattr(self, "_admin"):
raise ValueError(
"GrouperModelFormMixin forms can only be instantiated if the class attribute '_admin' "
"has been set and points to the instantiating admin instance."
)
if "instance" in kwargs and kwargs["instance"]:
# Instance provided? Initialize fields from content model
instance = kwargs["instance"]
self._content_instance = self._admin.get_content_obj(instance)
if self._content_instance:
kwargs["initial"] = {
**{
CONTENT_PREFIX + field: getattr(self._content_instance, field)
for field in self._content_fields
if CONTENT_PREFIX + field in self.base_fields
},
**kwargs.get("initial", {}),
}
if "_content_object_id" in self.base_fields:
kwargs["initial"]["_content_object_id"] = self._content_instance
else:
self._content_instance = None
# set values for grouping fields
kwargs["initial"] = {
**{CONTENT_PREFIX + key: value for key, value in self._admin.current_content_filters.items()},
**kwargs.get("initial", {}),
}
# The actual init
super().__init__(*args, **kwargs)
editable_content_fields = [
field for field in self.fields if field.startswith(CONTENT_PREFIX)
]
if "_content_object_id" in self.fields and editable_content_fields:
field_name = editable_content_fields[0]
widget = _ContentIdentityWidget(self.fields[field_name].widget, field_name)
widget.content_object_id = self._content_instance.pk if self._content_instance else None
self.fields[field_name].widget = widget
# Hide grouper foreign key
grouper_field_name = CONTENT_PREFIX + self._admin.grouper_field_name
if grouper_field_name in self.fields:
self.fields[grouper_field_name].widget = forms.HiddenInput()
# Will be set on admin model save
self.fields[grouper_field_name].required = False
self.update_labels(self._content_fields)
def update_labels(self, fields: list[str]) -> None:
"""Adds a language indicator to field labels"""
if "language" in self._admin.extra_grouping_fields:
site = get_current_site(self._request)
language_dict = get_language_dict(site_id=site.pk if site else None)
language_postfix = f" ({language_dict[self._admin.language]})"
for field in fields:
if CONTENT_PREFIX + field in self.fields:
# Fields contained in field list?
self.fields[CONTENT_PREFIX + field].label += language_postfix
else:
# Get default from content model
if self._meta.labels is None:
self._meta.labels = {}
self._meta.labels.setdefault(
CONTENT_PREFIX + field,
label_for_field(field, self._admin.content_model) + language_postfix,
)
def clean(self) -> dict:
site = get_current_site(self._request)
if f"{CONTENT_PREFIX}language" in self.cleaned_data and self.cleaned_data[
f"{CONTENT_PREFIX}language"
] not in get_language_list(site_id=site.pk):
raise ValidationError(
_("Invalid language %(value)s. This form cannot be processed. Try changing languages."),
params=dict(value=self.cleaned_data.get("language", _("<unspecified>"))),
code="invalid-language",
)
cleaned_data = super().clean()
if "_content_object_id" in cleaned_data:
content_obj = cleaned_data["_content_object_id"]
if content_obj is not None:
grouper_id = getattr(content_obj, f"{self._admin.grouper_field_name}_id")
grouping_matches = all(
getattr(content_obj, field) == value
for field, value in self._admin.current_content_filters.items()
)
if grouper_id != self.instance.pk or not grouping_matches:
raise ValidationError(_("The selected content does not match this object and grouping."))
self._content_instance = content_obj
if any(field.startswith(CONTENT_PREFIX) for field in self.changed_data) and not self._admin.can_change_content(
self._request, content_obj
):
raise ValidationError(_("You do not have permission to change this content."))
return cleaned_data
class GrouperAdminFormMixin:
"""Actually a factory class that creates the GrouperAdminFormMixin. Pass the Model or ModelForm as a
parameter::
class MyGrouperModelForm(GrouperModelFormMixin(ContentModel), forms.ModelForm):
model = GrouperModel
...
.. info::
For most cases you will not need to use this mixin. :class:`~cms.admin.utils.GrouperModelAdmin` automatically
adds the mixin to the form provided to it or the standard :class:`~django.forms.ModelForm`. As a results, you
can just use a subclass of :class:`~django.forms.ModelForm` for :class:`~cms.admin.utils.GrouperModelAdmin`.
.. warning::
This mixin only works when used together with :class:`~cms.admin.utils.GrouperModelAdmin`.
"""
def __new__(cls, content_model: models.base.ModelBase) -> type:
model_form = modelform_factory(content_model, fields="__all__")
base_fields = {CONTENT_PREFIX + key: value for key, value in model_form.base_fields.items()}
return forms.forms.DeclarativeFieldsMetaclass(
GrouperAdminFormMixin.__name__,
(_GrouperAdminFormMixin,),
{
**base_fields, # inherit the content model form's fields
"_content_model": content_model, # remember the model and
"_content_fields": model_form.base_fields.keys(), # fields that come from the content form
},
)