Extract reusable placeholder/plugin operation services from PlaceholderAdmin
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_operationdrives djangocms-versioning'supdate_modified_date_for_placeholder_source, so the version's modified date is never updated;cms/signals/log_entries.pycreates the adminLogEntry, 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:
CMSPluginBase.save_model()reaches into the admin registry to emit a signal —site._registry[obj.placeholder.__class__]and then a call to the privatepl_admin._send_pre_placeholder_operation(). The plugin base class should not need the admin site to announce an operation.- Signal emission is gated on a GET parameter.
_send_pre_placeholder_operation()returns early unlessrequest.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. - Scattered emission produces drift. #8668 catalogued five independent broken or stale signal kwargs, spread across
placeholderadmin.pyandplugin_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:
@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 BCdef 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) -> NoneEach performs, in one place and in this order:
- permission checks —
placeholder.has_add_plugin_permission()/has_change_plugin_permission()/has_delete_plugin_permission()/has_move_plugin_permission()/has_clear_permission(), raisingPermissionDenied; placeholder.check_source(user)— the editability/lock gate;- structural validation —
has_reached_plugin_limit(),get_plugin_disallowed_in_slot(), parent/child and language consistency, raisingValidationError; pre_placeholder_operation;- the tree mutation (
Placeholder.*,copy_plugins_to_placeholder()); placeholder.clear_cache()for every affected placeholder;LogEntry;post_placeholder_operationwith 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:
# 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-textsanitises in the form field'sclean()). Services take already-cleaned data; callers that want admin-equivalent validation use the plugin's form. A smallcms.admin.utilshelper 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 growuser=None, routing through the service when a user is given.
4. Rollout
- Add the services module with its own tests. No behaviour change.
- Refactor
PlaceholderAdminandCMSPluginBase.save_model()to call it — behaviour-preserving, with the existing admin test suite as the safety net. - Extend the signal kwargs; deprecate the
cms_pathrequirement. - Follow-up issue: the same treatment for page operations (
move_page,copy_page,set_home), which have the identical shape aroundsend_pre_/post_page_operation.
Target: 5.2.
Open questions
- Placement. New
cms/operations/services.py, or extendcms.api?cms.apiis 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+ValidationErrorfor callers to map, or return-value based results? Exceptions match the admin's current behaviour and Django conventions. - Signal kwargs. Adding
user/originis additive, but is keepingrequestworth 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.
Source: django-cms/django-cms