#8868·django-cms

Extract reusable placeholder/plugin operation services from PlaceholderAdmin

Author: fsbraunCreated Sep 15, 2026Updated Sep 15, 2026
Labelsneeds design decisionkind: enhancement

Description

There is no supported way to add, change, move, copy or delete a plugin from outside the admin and still get the side effects the admin produces. Today there are two tiers and nothing in between:

Tier 1 — model/API level. Placeholder.add_plugin(), move_plugin(), delete_plugin() and cms.api.add_plugin() manipulate the plugin tree correctly, but perform no permission checks, no operation signals, no cache invalidation and no log entries.

Tier 2 — PlaceholderAdmin. add_plugin(), edit_plugin(), move_plugin(), copy_plugins(), delete_plugin() and clear_placeholder() do all of it correctly — but the logic is entangled with HttpRequest, the admin site registry, and HTML/close-frame responses, so it cannot be called from anywhere else.

Anything that edits content outside the admin — a headless/REST write API, an import or site-migration script, a management command, an AI/MCP integration — must therefore either re-implement tier 2 by hand or silently skip the side effects. Skipping them is not cosmetic:

  • post_placeholder_operation drives djangocms-versioning's update_modified_date_for_placeholder_source, so the version's modified date is never updated;
  • cms/signals/log_entries.py creates the admin LogEntry, so the history page loses the change;
  • placeholder.clear_cache() is never called, so stale content is served.

Three symptoms that the current arrangement is structurally awkward:

  1. CMSPluginBase.save_model() reaches into the admin registry to emit a signalsite._registry[obj.placeholder.__class__] and then a call to the private pl_admin._send_pre_placeholder_operation(). The plugin base class should not need the admin site to announce an operation.
  2. Signal emission is gated on a GET parameter. _send_pre_placeholder_operation() returns early unless request.GET["cms_path"] is present, behind a deprecation warning that says it will become a 400 "on 3.5". Any non-admin caller silently gets no signals at all.
  3. Scattered emission produces drift. #8668 catalogued five independent broken or stale signal kwargs, spread across placeholderadmin.py and plugin_base.py. One emission site per operation would have made most of those impossible.

Expected behaviour

A caller that has a user and a placeholder should be able to perform a plugin operation with exactly the permission checks, validation, signals, cache invalidation and logging the admin performs — without constructing an HttpRequest or touching admin.site.

PlaceholderAdmin should then become a thin HTTP adapter over that layer, so the admin cannot drift from the programmatic path: there is only one implementation.

Possible resolution

1. A service layer

Add cms/operations/services.py (name open — see questions below) with functions that take an explicit actor and origin instead of a request:

python
@dataclass(frozen=True)
class OperationContext:
    user: AbstractBaseUser
    origin: str = ""                     # replaces request.GET["cms_path"]
    language: str | None = None          # replaces _get_operation_language()
    request: HttpRequest | None = None   # optional, forwarded to signals for BC
python
def add_plugin(ctx, placeholder, plugin_type, language, data, *, position="last-child", target=None) -> CMSPlugin
def change_plugin(ctx, plugin, data) -> CMSPlugin
def move_plugin(ctx, plugin, target_position, *, target_placeholder=None, target_parent=None) -> CMSPlugin
def copy_plugins(ctx, plugins, target_placeholder, target_language, *, target_parent=None) -> list[CMSPlugin]
def delete_plugin(ctx, plugin) -> None
def clear_placeholder(ctx, placeholder, language=None) -> None

Each performs, in one place and in this order:

  1. permission checks — placeholder.has_add_plugin_permission() / has_change_plugin_permission() / has_delete_plugin_permission() / has_move_plugin_permission() / has_clear_permission(), raising PermissionDenied;
  2. placeholder.check_source(user) — the editability/lock gate;
  3. structural validation — has_reached_plugin_limit(), get_plugin_disallowed_in_slot(), parent/child and language consistency, raising ValidationError;
  4. pre_placeholder_operation;
  5. the tree mutation (Placeholder.*, copy_plugins_to_placeholder());
  6. placeholder.clear_cache() for every affected placeholder;
  7. LogEntry;
  8. post_placeholder_operation with post-operation state.

PlaceholderAdmin._move_plugin() is already close to this — it uses request only for request.user and for the signal origin, so it converts almost mechanically:

python
# cms/admin/placeholderadmin.py — after
def _move_plugin(self, request, plugin, target_position, target_placeholder=None, target_parent=None):
    return services.move_plugin(
        self._operation_context(request),
        plugin,
        target_position,
        target_placeholder=target_placeholder,
        target_parent=target_parent,
    )

2. Signal contract

Send user and origin as explicit kwargs on both operation signals, keep sending request when there is one (None otherwise), and drop the silent no-op when cms_path is absent — the origin is now a parameter, not a query string. log_placeholder_operations() switches to kwargs["user"] with a fallback to request.user.

3. What deliberately stays out

  • Form handling. plugin_instance.get_form(request, obj) genuinely needs a request, and plugin forms are where validation and sanitisation live (djangocms-text sanitises in the form field's clean()). Services take already-cleaned data; callers that want admin-equivalent validation use the plugin's form. A small cms.admin.utils helper for building and binding a plugin form outside an admin view would be a useful companion, but it is a separate concern.
  • The clipboard. Copy/paste to and from the clipboard is a toolbar/session concept; it stays in the admin layer, which resolves the clipboard placeholder and then calls copy_plugins() with explicit source and target.
  • cms.api.add_plugin() keeps its current unchecked behaviour so fixtures and tests are unaffected. It could optionally grow user=None, routing through the service when a user is given.

4. Rollout

  1. Add the services module with its own tests. No behaviour change.
  2. Refactor PlaceholderAdmin and CMSPluginBase.save_model() to call it — behaviour-preserving, with the existing admin test suite as the safety net.
  3. Extend the signal kwargs; deprecate the cms_path requirement.
  4. Follow-up issue: the same treatment for page operations (move_page, copy_page, set_home), which have the identical shape around send_pre_/post_page_operation.

Target: 5.2.

Open questions

  • Placement. New cms/operations/services.py, or extend cms.api? cms.api is the documented programmatic entry point, which argues for putting them there; but its existing functions are deliberately permission-free, and mixing checked and unchecked functions in one module invites mistakes.
  • Error type. PermissionDenied + ValidationError for callers to map, or return-value based results? Exceptions match the admin's current behaviour and Django conventions.
  • Signal kwargs. Adding user/origin is additive, but is keeping request worth it once callers may not have one, or should it be deprecated outright?

Additional information

This comes out of designing a write API (POST/PATCH/DELETE for placeholders and plugins) for djangocms-rest. That work currently has to vendor a copy of the admin's permission/signal/cache glue, which will drift from core. djangocms-history (undo/redo) and any import/migration tooling consuming the operation signals are in the same position.

Happy to submit the PR.