Files
old-saburly-wagtail-web/env/lib/python3.10/site-packages/wagtail/admin/views/generic/mixins.py
2024-08-27 20:33:44 +02:00

825 lines
29 KiB
Python

import json
from django.conf import settings
from django.contrib.admin.utils import quote
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction
from django.forms import Media
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.admin import messages
from wagtail.admin.models import EditingSession
from wagtail.admin.templatetags.wagtailadmin_tags import user_display_name
from wagtail.admin.ui.editing_sessions import EditingSessionsModule
from wagtail.admin.ui.tables import TitleColumn
from wagtail.admin.utils import get_latest_str, set_query_params
from wagtail.locks import BasicLock, ScheduledForPublishLock, WorkflowLock
from wagtail.log_actions import log
from wagtail.log_actions import registry as log_registry
from wagtail.models import (
DraftStateMixin,
Locale,
LockableMixin,
PreviewableMixin,
RevisionMixin,
TranslatableMixin,
WorkflowMixin,
WorkflowState,
)
from wagtail.utils.timestamps import render_timestamp
class HookResponseMixin:
"""
A mixin for class-based views to run hooks by `hook_name`.
"""
def run_hook(self, hook_name, *args, **kwargs):
"""
Run the named hook, passing args and kwargs to each function registered under that hook name.
If any return an HttpResponse, stop processing and return that response
"""
for fn in hooks.get_hooks(hook_name):
result = fn(*args, **kwargs)
if hasattr(result, "status_code"):
return result
return None
class BeforeAfterHookMixin(HookResponseMixin):
"""
A mixin for class-based views to support hooks like `before_edit_page` and
`after_edit_page`, which are triggered during execution of some operation and
can return a response to halt that operation and/or change the view response.
"""
def run_before_hook(self):
"""
Define how to run the hooks before the operation is executed.
The `self.run_hook(hook_name, *args, **kwargs)` from HookResponseMixin
can be utilised to call the hooks.
If this method returns a response, the operation will be aborted and the
hook response will be returned as the view response, skipping the default
response.
"""
return None
def run_after_hook(self):
"""
Define how to run the hooks after the operation is executed.
The `self.run_hook(hook_name, *args, **kwargs)` from HookResponseMixin
can be utilised to call the hooks.
If this method returns a response, it will be returned as the view
response immediately after the operation finishes, skipping the default
response.
"""
return None
def dispatch(self, *args, **kwargs):
hooks_result = self.run_before_hook()
if hooks_result is not None:
return hooks_result
return super().dispatch(*args, **kwargs)
def form_valid(self, form):
response = super().form_valid(form)
hooks_result = self.run_after_hook()
if hooks_result is not None:
return hooks_result
return response
class LocaleMixin:
@cached_property
def locale(self):
return self.get_locale()
@cached_property
def translations(self):
return self.get_translations() if self.locale else []
def get_locale(self):
if not getattr(self, "model", None):
return None
i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False)
if not i18n_enabled or not issubclass(self.model, TranslatableMixin):
return None
if hasattr(self, "object") and self.object:
return self.object.locale
selected_locale = self.request.GET.get("locale")
if selected_locale:
return get_object_or_404(Locale, language_code=selected_locale)
return Locale.get_default()
def get_translations(self):
# Return a list of {"locale": Locale, "url": str} objects for available locales
return []
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if not self.locale:
return context
context["locale"] = self.locale
context["translations"] = self.translations
return context
def _set_locale_query_param(self, url, locale=None):
if not (locale := locale or self.locale):
return url
return set_query_params(url, {"locale": locale.language_code})
class PanelMixin:
panel = None
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.panel = self.get_panel()
def get_panel(self):
return self.panel
def get_bound_panel(self, form):
if not self.panel:
return None
return self.panel.get_bound_panel(
request=self.request, instance=form.instance, form=form
)
def get_form_class(self):
# The form_class takes precedence if specified
if self.form_class or not self.panel:
return super().get_form_class()
return self.panel.get_form_class()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
form = context.get("form")
panel = self.get_bound_panel(form)
media = context.get("media", Media())
if form:
media += form.media
if panel:
media += panel.media
context.update(
{
"panel": panel,
"media": media,
}
)
return context
class IndexViewOptionalFeaturesMixin:
"""
A mixin for generic IndexView to support optional features that are applied
to the model as mixins (e.g. DraftStateMixin, RevisionMixin).
"""
def _get_title_column(self, field_name, column_class=TitleColumn, **kwargs):
accessor = kwargs.pop("accessor", None)
if not accessor and field_name == "__str__":
accessor = get_latest_str
return super()._get_title_column(
field_name, column_class, accessor=accessor, **kwargs
)
def _annotate_queryset_updated_at(self, queryset):
if issubclass(queryset.model, RevisionMixin):
# Use the latest revision's created_at
queryset = queryset.select_related("latest_revision")
queryset = queryset.annotate(
_updated_at=models.F("latest_revision__created_at")
)
return queryset
return super()._annotate_queryset_updated_at(queryset)
class CreateEditViewOptionalFeaturesMixin:
"""
A mixin for generic CreateView/EditView to support optional features that
are applied to the model as mixins (e.g. DraftStateMixin, RevisionMixin).
"""
view_name = "create"
preview_url_name = None
lock_url_name = None
unlock_url_name = None
revisions_unschedule_url_name = None
revisions_compare_url_name = None
workflow_history_url_name = None
confirm_workflow_cancellation_url_name = None
def setup(self, request, *args, **kwargs):
# Need to set these here as they are used in get_object()
self.request = request
self.args = args
self.kwargs = kwargs
self.preview_enabled = self.model and issubclass(self.model, PreviewableMixin)
self.revision_enabled = self.model and issubclass(self.model, RevisionMixin)
self.draftstate_enabled = self.model and issubclass(self.model, DraftStateMixin)
self.locking_enabled = (
self.model
and issubclass(self.model, LockableMixin)
and self.view_name != "create"
)
# Set the object before super().setup() as LocaleMixin.setup() needs it
self.object = self.get_object()
self.lock = self.get_lock()
self.locked_for_user = self.lock and self.lock.for_user(request.user)
super().setup(request, *args, **kwargs)
@cached_property
def workflow(self):
if not self.model or not issubclass(self.model, WorkflowMixin):
return None
if self.object:
return self.object.get_workflow()
return self.model.get_default_workflow()
@cached_property
def workflow_enabled(self):
return self.workflow is not None
@cached_property
def workflow_state(self):
if not self.workflow_enabled or not self.object:
return None
return (
self.object.current_workflow_state
or self.object.workflow_states.order_by("created_at").last()
)
@cached_property
def current_workflow_task(self):
if not self.workflow_enabled or not self.object:
return None
return self.object.current_workflow_task
@cached_property
def workflow_tasks(self):
if not self.workflow_state:
return []
return self.workflow_state.all_tasks_with_status()
def user_has_permission(self, permission):
user = self.request.user
# Workflow lock/unlock methods take precedence before the base
# "lock" and "unlock" permissions -- see PagePermissionTester for reference
if permission == "lock" and self.current_workflow_task:
# Follow the logic in PagePermissionTester.user_can_lock()
# (superusers can always lock)
if user.is_superuser:
return True
return self.current_workflow_task.user_can_lock(self.object, user)
if permission == "unlock":
# Follow the logic in PagePermissionTester.user_can_unlock()
# (superusers can always unlock)
if user.is_superuser:
return True
# Allow unlocking even if the user does not have the 'unlock' permission
# if they are the user who locked the object
if self.object.locked_by_id == user.pk:
return True
if self.current_workflow_task:
return self.current_workflow_task.user_can_unlock(self.object, user)
# Check with base PermissionCheckedMixin logic
has_base_permission = super().user_has_permission(permission)
if has_base_permission:
return True
# Allow access to the editor if the current workflow task allows it,
# even if the user does not normally have edit access. Users with edit
# permissions can always edit regardless what this method returns --
# see Task.user_can_access_editor() for reference
if (
permission == "change"
and self.current_workflow_task
and self.current_workflow_task.user_can_access_editor(
self.object, self.request.user
)
):
return True
return False
def workflow_action_is_valid(self):
if not self.current_workflow_task:
return False
self.workflow_action = self.request.POST.get("workflow-action-name")
available_actions = self.current_workflow_task.get_actions(
self.object, self.request.user
)
available_action_names = [
name for name, verbose_name, modal in available_actions
]
return self.workflow_action in available_action_names
def get_available_actions(self):
actions = [*super().get_available_actions()]
if self.request.method != "POST":
return actions
if self.draftstate_enabled and (
not self.permission_policy
or self.permission_policy.user_has_permission(self.request.user, "publish")
):
actions.append("publish")
if self.workflow_enabled:
actions.append("submit")
if self.workflow_state and (
self.workflow_state.user_can_cancel(self.request.user)
):
actions.append("cancel-workflow")
if self.object and not self.object.workflow_in_progress:
actions.append("restart-workflow")
if self.workflow_action_is_valid():
actions.append("workflow-action")
return actions
def get_object(self, queryset=None):
if self.view_name == "create":
return None
self.live_object = super().get_object(queryset)
if self.draftstate_enabled:
return self.live_object.get_latest_revision_as_object()
return self.live_object
def get_lock(self):
if not self.locking_enabled:
return None
return self.object.get_lock()
def get_lock_url(self):
if not self.locking_enabled or not self.lock_url_name:
return None
return reverse(self.lock_url_name, args=[quote(self.object.pk)])
def get_unlock_url(self):
if not self.locking_enabled or not self.unlock_url_name:
return None
return reverse(self.unlock_url_name, args=[quote(self.object.pk)])
def get_preview_url(self):
if not self.preview_enabled or not self.preview_url_name:
return None
args = [] if self.view_name == "create" else [quote(self.object.pk)]
return reverse(self.preview_url_name, args=args)
def get_workflow_history_url(self):
if not self.workflow_enabled or not self.workflow_history_url_name:
return None
return reverse(self.workflow_history_url_name, args=[quote(self.object.pk)])
def get_confirm_workflow_cancellation_url(self):
if not self.workflow_enabled or not self.confirm_workflow_cancellation_url_name:
return None
return reverse(
self.confirm_workflow_cancellation_url_name, args=[quote(self.object.pk)]
)
def get_error_message(self):
if self.action == "cancel-workflow":
return None
if self.locked_for_user:
return capfirst(
_("The %(model_name)s could not be saved as it is locked")
% {"model_name": self.model._meta.verbose_name}
)
return super().get_error_message()
def get_success_message(self, instance=None):
object = instance or self.object
message = _("%(model_name)s '%(object)s' updated.")
if self.view_name == "create":
message = _("%(model_name)s '%(object)s' created.")
if self.action == "publish":
# Scheduled publishing
if object.go_live_at and object.go_live_at > timezone.now():
message = _(
"%(model_name)s '%(object)s' has been scheduled for publishing."
)
if self.view_name == "create":
message = _(
"%(model_name)s '%(object)s' created and scheduled for publishing."
)
elif object.live:
message = _(
"%(model_name)s '%(object)s' is live and this version has been scheduled for publishing."
)
# Immediate publishing
else:
message = _("%(model_name)s '%(object)s' updated and published.")
if self.view_name == "create":
message = _("%(model_name)s '%(object)s' created and published.")
if self.action == "submit":
message = _(
"%(model_name)s '%(object)s' has been submitted for moderation."
)
if self.view_name == "create":
message = _(
"%(model_name)s '%(object)s' created and submitted for moderation."
)
if self.action == "restart-workflow":
message = _("Workflow on %(model_name)s '%(object)s' has been restarted.")
if self.action == "cancel-workflow":
message = _("Workflow on %(model_name)s '%(object)s' has been cancelled.")
return message % {
"model_name": capfirst(self.model._meta.verbose_name),
"object": get_latest_str(object),
}
def get_success_url(self):
# If DraftStateMixin is enabled and the action is saving a draft
# or cancelling a workflow, remain on the edit view
remain_actions = {"create", "edit", "cancel-workflow"}
if self.draftstate_enabled and self.action in remain_actions:
return self.get_edit_url()
return super().get_success_url()
def save_instance(self):
"""
Called after the form is successfully validated - saves the object to the db
and returns the new object. Override this to implement custom save logic.
"""
if self.draftstate_enabled:
instance = self.form.save(
commit=self.view_name == "edit" and not self.object.live
)
# If DraftStateMixin is applied, only save to the database in CreateView,
# and make sure the live field is set to False.
if self.view_name == "create":
instance.live = False
instance.save()
self.form.save_m2m()
else:
instance = self.form.save()
self.has_content_changes = self.view_name == "create" or self.form.has_changed()
# Save revision if the model inherits from RevisionMixin
self.new_revision = None
if self.revision_enabled:
self.new_revision = instance.save_revision(user=self.request.user)
log(
instance=instance,
action="wagtail.create" if self.view_name == "create" else "wagtail.edit",
revision=self.new_revision,
content_changed=self.has_content_changes,
)
return instance
def publish_action(self):
hook_response = self.run_hook("before_publish", self.request, self.object)
if hook_response is not None:
return hook_response
# Skip permission check as it's already done in get_available_actions
self.new_revision.publish(user=self.request.user, skip_permission_checks=True)
hook_response = self.run_hook("after_publish", self.request, self.object)
if hook_response is not None:
return hook_response
return None
def submit_action(self):
if (
self.workflow_state
and self.workflow_state.status == WorkflowState.STATUS_NEEDS_CHANGES
):
# If the workflow was in the needs changes state, resume the existing workflow on submission
self.workflow_state.resume(self.request.user)
else:
# Otherwise start a new workflow
self.workflow.start(self.object, self.request.user)
return None
def restart_workflow_action(self):
self.workflow_state.cancel(user=self.request.user)
self.workflow.start(self.object, self.request.user)
return None
def cancel_workflow_action(self):
self.workflow_state.cancel(user=self.request.user)
return None
def workflow_action_action(self):
extra_workflow_data_json = self.request.POST.get(
"workflow-action-extra-data", "{}"
)
extra_workflow_data = json.loads(extra_workflow_data_json)
self.object.current_workflow_task.on_action(
self.object.current_workflow_task_state,
self.request.user,
self.workflow_action,
**extra_workflow_data,
)
return None
def run_action_method(self):
action_method = getattr(self, self.action.replace("-", "_") + "_action", None)
if action_method:
return action_method()
return None
def form_valid(self, form):
self.form = form
with transaction.atomic():
self.object = self.save_instance()
response = self.run_action_method()
if response is not None:
return response
response = self.save_action()
hook_response = self.run_after_hook()
if hook_response is not None:
return hook_response
return response
def form_invalid(self, form):
# Even if the object is locked due to not having permissions,
# the original submitter can still cancel the workflow
if self.action == "cancel-workflow":
self.cancel_workflow_action()
messages.success(
self.request,
self.get_success_message(),
buttons=self.get_success_buttons(),
)
# Refresh the lock object as now WorkflowLock no longer applies
self.lock = self.get_lock()
self.locked_for_user = self.lock and self.lock.for_user(self.request.user)
return super().form_invalid(form)
def get_last_updated_info(self):
# Create view doesn't have last updated info
if self.view_name == "create":
return None
# DraftStateMixin is applied but object is not live
if self.draftstate_enabled and not self.object.live:
return None
revision = None
# DraftStateMixin is applied and object is live
if self.draftstate_enabled and self.object.live_revision:
revision = self.object.live_revision
# RevisionMixin is applied, so object is assumed to be live
elif self.revision_enabled and self.object.latest_revision:
revision = self.object.latest_revision
# No mixin is applied or no revision exists, fall back to latest log entry
if not revision:
return log_registry.get_logs_for_instance(self.object).first()
return {
"timestamp": revision.created_at,
"user_display_name": user_display_name(revision.user),
}
def get_lock_context(self):
if not self.locking_enabled:
return {}
user_can_lock = (
not self.lock or isinstance(self.lock, WorkflowLock)
) and self.user_has_permission("lock")
user_can_unlock = (
isinstance(self.lock, BasicLock)
) and self.user_has_permission("unlock")
user_can_unschedule = (
isinstance(self.lock, ScheduledForPublishLock)
) and self.user_has_permission("publish")
context = {
"lock": self.lock,
"locked_for_user": self.locked_for_user,
"lock_url": self.get_lock_url(),
"unlock_url": self.get_unlock_url(),
"user_can_lock": user_can_lock,
"user_can_unlock": user_can_unlock,
}
# Do not add lock message if the request method is not GET,
# as POST request may add success/validation error messages already
if not self.lock or self.request.method != "GET":
return context
lock_message = self.lock.get_message(self.request.user)
if lock_message:
if user_can_unlock:
lock_message = format_html(
'{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{}">{}</button></span>',
lock_message,
self.get_unlock_url(),
_("Unlock"),
)
if user_can_unschedule:
lock_message = format_html(
'{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{}">{}</button></span>',
lock_message,
reverse(
self.revisions_unschedule_url_name,
args=[quote(self.object.pk), self.object.scheduled_revision.id],
),
_("Cancel scheduled publish"),
)
if (
not isinstance(self.lock, ScheduledForPublishLock)
and self.locked_for_user
):
messages.warning(self.request, lock_message, extra_tags="lock")
else:
messages.info(self.request, lock_message, extra_tags="lock")
return context
def get_editing_sessions(self):
if self.view_name == "create":
return None
EditingSession.cleanup()
content_type = ContentType.objects.get_for_model(self.model)
session = EditingSession.objects.create(
user=self.request.user,
content_type=content_type,
object_id=self.object.pk,
last_seen_at=timezone.now(),
)
revision_id = self.object.latest_revision_id if self.revision_enabled else None
return EditingSessionsModule(
session,
reverse(
"wagtailadmin_editing_sessions:ping",
args=(
self.model._meta.app_label,
self.model._meta.model_name,
quote(self.object.pk),
session.id,
),
),
reverse(
"wagtailadmin_editing_sessions:release",
args=(session.id,),
),
[],
revision_id,
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_lock_context())
context["revision_enabled"] = self.revision_enabled
context["draftstate_enabled"] = self.draftstate_enabled
context["workflow_enabled"] = self.workflow_enabled
context["workflow_history_url"] = self.get_workflow_history_url()
context[
"confirm_workflow_cancellation_url"
] = self.get_confirm_workflow_cancellation_url()
context["publishing_will_cancel_workflow"] = getattr(
settings, "WAGTAIL_WORKFLOW_CANCEL_ON_PUBLISH", True
) and bool(self.workflow_tasks)
context["revisions_compare_url_name"] = self.revisions_compare_url_name
context["editing_sessions"] = self.get_editing_sessions()
return context
def post(self, request, *args, **kwargs):
form = self.get_form()
# Make sure object is not locked
if not self.locked_for_user and form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
class RevisionsRevertMixin:
revision_id_kwarg = "revision_id"
revisions_revert_url_name = None
def setup(self, request, *args, **kwargs):
self.revision_id = kwargs.get(self.revision_id_kwarg)
super().setup(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
self._add_warning_message()
return super().get(request, *args, **kwargs)
def get_revisions_revert_url(self):
return reverse(
self.revisions_revert_url_name,
args=[quote(self.object.pk), self.revision_id],
)
def get_warning_message(self):
user_avatar = render_to_string(
"wagtailadmin/shared/user_avatar.html", {"user": self.revision.user}
)
message_string = _(
"You are viewing a previous version of this %(model_name)s from <b>%(created_at)s</b> by %(user)s"
)
message_data = {
"model_name": capfirst(self.model._meta.verbose_name),
"created_at": render_timestamp(self.revision.created_at),
"user": user_avatar,
}
message = mark_safe(message_string % message_data)
return message
def _add_warning_message(self):
messages.warning(self.request, self.get_warning_message())
def get_object(self, queryset=None):
object = super().get_object(queryset)
self.revision = get_object_or_404(object.revisions, id=self.revision_id)
return self.revision.as_object()
def save_instance(self):
commit = not issubclass(self.model, DraftStateMixin) or not self.object.live
instance = self.form.save(commit=commit)
self.has_content_changes = self.form.has_changed()
self.new_revision = instance.save_revision(
user=self.request.user,
log_action=True,
previous_revision=self.revision,
)
return instance
def get_success_message(self):
message = _(
"%(model_name)s '%(object)s' has been replaced with version from %(timestamp)s."
)
if self.draftstate_enabled and self.action == "publish":
message = _(
"Version from %(timestamp)s of %(model_name)s '%(object)s' has been published."
)
if self.object.go_live_at and self.object.go_live_at > timezone.now():
message = _(
"Version from %(timestamp)s of %(model_name)s '%(object)s' has been scheduled for publishing."
)
return message % {
"model_name": capfirst(self.model._meta.verbose_name),
"object": self.object,
"timestamp": render_timestamp(self.revision.created_at),
}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["revision"] = self.revision
context["action_url"] = self.get_revisions_revert_url()
return context