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('') 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_, 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:__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//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", _(""))), 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 }, )