Skip to content

Instantly share code, notes, and snippets.

@zerolab
Last active July 7, 2025 14:06
Show Gist options
  • Save zerolab/3a4e4aabcc4479c8f6c7bbb19b97b013 to your computer and use it in GitHub Desktop.
Save zerolab/3a4e4aabcc4479c8f6c7bbb19b97b013 to your computer and use it in GitHub Desktop.
Wagtail read-only workflow step

This is an example of a read-only Wagtail workflow task that still allows comments.

Note

This was written for Wagtail 6.3/6.4 and may need small adjustments in the monkey-patched EditView

The code was in a workflows app in a Django project with the layout

.
└── myproject/
    └── workflows/
        ├── migrations
        ├── static/
        │   └── workflows/
        │       ├── css/
        │       │   └── readonly-task.css
        │       └── js/
        │           └── action-readonly-review.js
        ├── templates/
        │   └── wagtailadmin/
        │       └── pages/
        │           └── edit.html
        ├── templatetags/
        │   ├── __init__.py
        │   └── workflow_tags.py
        ├── apps.py
        ├── models.py
        ├── monkey_patches.py
        ├── signal_handlers.py
        ├── views.py
        └── wagtail_hooks.py

replace myproject.workflows accordingly, and generate migrations!

function initReviewButton() {
let reviewButton = document.querySelector('button[name="action-readonly-review"]');
if (!reviewButton) {
return;
}
const target = reviewButton.parentElement.querySelector('button[data-w-dropdown-target="toggle"]');
if (!target) {
return;
}
reviewButton.addEventListener('click', function (event) {
target.click();
event.preventDefault();
});
}
// Function to find the React internal instance
function getReactInternalInstance(element) {
return Object.keys(element).find(key =>
key.startsWith('__reactInternalInstance$')
);
}
function preventContentChanges(editorInstance) {
let originalContent = editorInstance.getEditorState().getCurrentContent();
const originalOnChange = editorInstance.onChange;
editorInstance.onChange = (editorState) => {
if (editorState.getCurrentContent() !== originalContent) {
if (editorState.getLastChangeType() === "change-inline-style") {
originalOnChange(editorState);
originalContent = editorState.getCurrentContent();
}
else {
// preserve selection
const state = DraftJS.EditorState.set(
DraftJS.EditorState.createWithContent(originalContent, editorState.getDecorator()),
{
selection: editorState.getSelection(),
undoStack: editorState.getUndoStack(),
redoStack: editorState.getRedoStack(),
inlineStyleOverride: editorState.getInlineStyleOverride(),
},
);
return originalOnChange(DraftJS.EditorState.acceptSelection(state, state.getSelection()));
}
}
else {
return originalOnChange(editorState);
}
};
}
function initializeReadOnlyDraftail() {
const possibleEditors = document.querySelectorAll('[data-draftail-editor]');
possibleEditors.forEach(element => {
// This is likely a Draft.js editor
const internalInstance = getReactInternalInstance(element);
if (internalInstance) {
const editor = element[internalInstance].return.stateNode;
if (editor && typeof editor.getEditorState === 'function') {
// Confirm it's a Draft.js editor and make it read-only
preventContentChanges(editor);
}
}
});
}
document.addEventListener('DOMContentLoaded', function() {
if (document.body.classList.contains("workflow-readonly-task")) {
initReviewButton();
initializeReadOnlyDraftail();
}
});
from django.apps import AppConfig
class WorkflowsAppConfig(AppConfig):
name = "myproject.workflows"
def ready(self):
# note: using a monkey patch until https://github.com/wagtail/wagtail/issues/12463 is fixed
import .monkey_patches
from .signal_handlers import register_signal_handlers
register_signal_handlers()
{# add to myproject/workflows/templates/wagtailadmin/pages/ #}
{% extends "wagtailadmin/pages/edit.html" %}
{% load workflow_tags %}
{% block bodyclass %}{{ block.super }}{% if page.current_workflow_task|is_readonly_task %} workflow-readonly-task{% if request.user.is_superuser %}-override{% endif %}{% endif %}{% endblock %}
from django.utils.translation import gettext_lazy as _
from wagtail.admin.mail import GroupApprovalTaskStateSubmissionEmailNotifier
from wagtail.models import AbstractGroupApprovalTask
class ReadOnlyGroupTask(AbstractGroupApprovalTask):
def user_can_access_editor(self, obj, user):
return True
def locked_for_user(self, obj, user):
return not user.is_superuser
@classmethod
def get_description(cls):
return _("Members of the chosen Wagtail Groups can approve this task, but they cannot change any content")
class Meta:
verbose_name = _("Read-only Group approval task")
verbose_name_plural = _("Read-only Group approval tasks")
class ReadOnlyGroupTaskStateSubmissionEmailNotifier(GroupApprovalTaskStateSubmissionEmailNotifier):
"""A notifier to send email updates for our submission events
@see https://docs.wagtail.org/en/stable/extending/custom_tasks.html#adding-notifications
"""
def can_handle(self, instance, **kwargs):
return isinstance(instance, self.valid_classes) and isinstance(
instance.task.specific, ReadOnlyGroupTask)
)
import wagtail.admin.views.pages.edit
from .views import EditView as OverriddenView
wagtail.admin.views.pages.edit.EditView.as_view = OverriddenView.as_view
.content-locked.workflow-readonly-task .w-panel__content .w-field__comment-button,
.content-locked.workflow-readonly-task .w-panel__content .Draftail-CommentControl,
.content-locked.workflow-readonly-task .w-panel__content .Draftail-ToolbarButton--pin {
pointer-events: initial!important;
}
.content-locked.workflow-readonly-task .w-panel__content .DraftEditor-editorContainer {
user-select: initial;
-moz-user-select: initial;
-webkit-user-select: initial;
pointer-events: painted;
}
from wagtail.models import TaskState
from wagtail.signals import task_submitted
from .models import TaskStateSubmissionEmailNotifier
task_submission_email_notifier = TaskStateSubmissionEmailNotifier()
def register_signal_handlers():
task_submitted.connect(
task_submission_email_notifier,
sender=TaskState,
dispatch_uid="read_only_group_task_submitted_email_notification",
)
import json
from django.shortcuts import redirect
from wagtail.admin.views.pages.edit import EditView as WagtailPageEditView
from wagtail.models import COMMENTS_RELATION_NAME, Page
from .models import ReadOnlyGroupTask
class EditView(WagtailPageEditView):
def post(self, request):
if isinstance(self.page.current_workflow_task, ReadOnlyGroupTask) and not self.request.user.is_superuser:
# temporarily mark as non-locked
self.locked_for_user = False # pylint: disable=attribute-defined-outside-init
return super().post(request)
def perform_workflow_action(self):
"""
Note: this skips saving a draft when the workflow task is a ReadyOnlyGroupTask
"""
if self.request.user.is_superuser:
return super().perform_workflow_action()
self.page: Page = self.form.save(commit=not self.page.live) # pylint: disable=attribute-defined-outside-init
if not isinstance(self.page.current_workflow_task, ReadOnlyGroupTask):
return super().perform_workflow_action()
self.subscription.save()
if self.has_content_changes and "comments" in self.form.formsets:
for comment in getattr(self.page, COMMENTS_RELATION_NAME).filter(pk__isnull=True):
# We need to ensure comments have an id in the revision, so positions can be identified correctly
comment.save()
changes = self.get_commenting_changes()
self.log_commenting_changes(changes, self.page.get_latest_revision())
self.send_commenting_notifications(changes)
extra_workflow_data_json = self.request.POST.get("workflow-action-extra-data", "{}")
extra_workflow_data = json.loads(extra_workflow_data_json)
self.page.current_workflow_task.on_action(
self.page.current_workflow_task_state,
self.request.user,
self.workflow_action,
**extra_workflow_data,
)
self.add_save_confirmation_message()
response = self.run_hook("after_edit_page", self.request, self.page)
if response:
return response
# we're done here - redirect back to the explorer
return redirect("wagtailadmin_explore", self.page.get_parent().id)
from typing import Mapping
from django.http import HttpRequest
from django.templatetags.static import static
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from wagtail import hooks
from wagtail.admin.action_menu import ActionMenuItem, CancelWorkflowMenuItem, WorkflowMenuItem
from .models import ReadOnlyGroupTask
class ReviewActionMenuItem(ActionMenuItem):
label = _("Submit review")
name = "action-readonly-review"
class Media:
js = ["workflows/js/action-readonly-review.js"]
@hooks.register("construct_page_action_menu")
def amend_page_action_menu_items(menu_items: list, request: HttpRequest, context: Mapping):
if not all([context["view"] == "edit", context["locked_for_user"], context.get("page")]):
return
page = context["page"]
task = page.current_workflow_task
if not isinstance(task, ReadOnlyGroupTask) or request.user.is_superuser:
return
if task:
for idx, menu_item in enumerate(menu_items):
if isinstance(menu_item, CancelWorkflowMenuItem):
del menu_items[idx]
is_final_task = page.current_workflow_state and page.current_workflow_state.is_at_final_task
workflow_menu_items = []
for name, label, launch_modal in task.get_actions(page, request.user):
icon_name = "edit"
if name == "approve":
icon_name = "success"
if is_final_task:
label = _("%(label)s and Publish") % {"label": label}
workflow_menu_items.append(WorkflowMenuItem(name, label, launch_modal, icon_name=icon_name, order=0))
# insert our custom "review" button to show by default
# its role is to open up the actions dropdown menu
menu_items.insert(0, ReviewActionMenuItem())
menu_items.extend(workflow_menu_items)
@hooks.register("insert_editor_js")
def editor_js():
# note: remove in a future release of Wagtail
# needed as PageActionMenu.media doesn't consider the PageActionMenu.default_item media
return format_html('<script src="{}"></script>', static("workflows/js/action-readonly-review.js"))
@hooks.register("insert_global_admin_css")
def global_admin_css():
return format_html('<link rel="stylesheet" href="{}">', static("workflows/css/readonly-task.css"))
# myproject/workflows/templatetags/
from django import template
from wagtail.models import Task
from myproject.workflows.models import ReadOnlyGroupTask
register = template.Library()
@register.filter(name="is_readonly_task")
def is_readonly_task(task: Task | None) -> bool:
return isinstance(task, ReadOnlyGroupTask)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment