Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# PLEASE NOTE: If you edit this file (other than updating the version number), please
# also update scripts/nightly/get_version.py as well as that needs to generate a new
# version of this file from a template for nightly builds
from wagtail.utils.version import get_semver_version, get_version
# major.minor.patch.release.number
# release must be one of alpha, beta, rc, or final
VERSION = (6, 2, 1, "final", 1)
__version__ = get_version(VERSION)
# Required for npm package for frontend
__semver__ = get_semver_version(VERSION)
def setup():
import warnings
from wagtail.utils.deprecation import removed_in_next_version_warning
warnings.simplefilter("default", removed_in_next_version_warning)
setup()

View File

@@ -0,0 +1,69 @@
from django.core.exceptions import PermissionDenied
from wagtail.log_actions import log
class ConvertAliasPageError(RuntimeError):
"""
Raised when the page to convert is not an alias.
"""
pass
class ConvertAliasPagePermissionError(PermissionDenied):
"""
Raised when the alias page conversion cannot be performed due to insufficient permissions.
"""
pass
class ConvertAliasPageAction:
def __init__(self, page, *, log_action="wagtail.convert_alias", user=None):
self.page = page
self.log_action = log_action
self.user = user
def check(self, skip_permission_checks=False):
if not self.page.alias_of_id:
raise ConvertAliasPageError("Page must be an alias to be converted.")
if (
not skip_permission_checks
and self.user
and not self.page.permissions_for_user(self.user).can_edit()
):
raise ConvertAliasPagePermissionError(
"You do not have permission to edit this page."
)
def _convert_alias(self, page, log_action, user):
page.alias_of_id = None
page.save(update_fields=["alias_of_id"], clean=False)
# Create an initial revision
revision = page.save_revision(user=user, changed=False, clean=False)
if page.live:
page.live_revision = revision
page.save(update_fields=["live_revision"], clean=False)
# Log
if log_action:
log(
instance=page,
action=log_action,
revision=revision,
user=user,
data={
"page": {"id": page.id, "title": page.get_admin_display_title()},
},
)
return page
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
return self._convert_alias(self.page, self.log_action, self.user)

View File

@@ -0,0 +1,245 @@
from django.core.exceptions import PermissionDenied
from django.db import transaction
from wagtail.coreutils import find_available_slug
from wagtail.models.copying import _copy
from wagtail.signals import copy_for_translation_done
class ParentNotTranslatedError(Exception):
"""
Raised when a call to Page.copy_for_translation is made but the
parent page is not translated and copy_parents is False.
"""
pass
class CopyForTranslationPermissionError(PermissionDenied):
"""
Raised when the object translation copy cannot be performed due to insufficient permissions.
"""
pass
class CopyPageForTranslationPermissionError(CopyForTranslationPermissionError):
pass
class CopyPageForTranslationAction:
"""
Creates a copy of this page in the specified locale.
The new page will be created in draft as a child of this page's translated
parent.
For example, if you are translating a blog post from English into French,
this method will look for the French version of the blog index and create
the French translation of the blog post under that.
If this page's parent is not translated into the locale, then a ``ParentNotTranslatedError``
is raised. You can circumvent this error by passing ``copy_parents=True`` which
copies any parents that are not translated yet.
The ``exclude_fields`` parameter can be used to set any fields to a blank value
in the copy.
Note that this method calls the ``.copy()`` method internally so any fields that
are excluded in ``.exclude_fields_in_copy`` will be excluded from the translation.
"""
def __init__(
self,
page,
locale,
copy_parents=False,
alias=False,
exclude_fields=None,
user=None,
include_subtree=False,
):
self.page = page
self.locale = locale
self.copy_parents = copy_parents
self.alias = alias
self.exclude_fields = exclude_fields
self.user = user
self.include_subtree = include_subtree
def check(self, skip_permission_checks=False):
# Permission checks
if (
self.user
and not skip_permission_checks
and not self.user.has_perms(["simple_translation.submit_translation"])
):
raise CopyPageForTranslationPermissionError(
"You do not have permission to submit a translation for this page."
)
def walk(self, current_page):
for child_page in current_page.get_children():
translated_page = self._copy_for_translation(
child_page
if child_page.live
else child_page.get_latest_revision_as_object(),
self.locale,
self.copy_parents,
self.alias,
self.exclude_fields,
)
copy_for_translation_done.send(
sender=self.__class__,
source_obj=child_page.specific,
target_obj=translated_page,
)
self.walk(child_page)
@transaction.atomic
def _copy_for_translation(self, page, locale, copy_parents, alias, exclude_fields):
# Find the translated version of the parent page to create the new page under
parent = page.get_parent().specific
slug = page.slug
if not parent.is_root():
try:
translated_parent = parent.get_translation(locale)
except parent.__class__.DoesNotExist:
if not copy_parents:
raise ParentNotTranslatedError("Parent page is not translated.")
translated_parent = parent.copy_for_translation(
locale, copy_parents=True, alias=True
)
else:
# Don't duplicate the root page for translation. Create new locale as a sibling
translated_parent = parent
# Append language code to slug as the new page
# will be created in the same section as the existing one
slug += "-" + locale.language_code
# Find available slug for new page
slug = find_available_slug(translated_parent, slug)
if alias:
return page.create_alias(
parent=translated_parent,
update_slug=slug,
update_locale=locale,
reset_translation_key=False,
)
else:
# Update locale on translatable child objects as well
def process_child_object(
original_page, page_copy, child_relation, child_object
):
from wagtail.models import TranslatableMixin
if isinstance(child_object, TranslatableMixin):
child_object.locale = locale
return page.copy(
to=translated_parent,
update_attrs={
"locale": locale,
"slug": slug,
},
copy_revisions=False,
keep_live=False,
reset_translation_key=False,
process_child_object=process_child_object,
exclude_fields=exclude_fields,
log_action="wagtail.copy_for_translation",
)
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
translated_page = self._copy_for_translation(
self.page if self.page.live else self.page.get_latest_revision_as_object(),
self.locale,
self.copy_parents,
self.alias,
self.exclude_fields,
)
copy_for_translation_done.send(
sender=self.__class__,
source_obj=self.page,
target_obj=translated_page,
)
if self.include_subtree:
self.walk(self.page)
return translated_page
class CopyForTranslationAction:
"""
Creates a copy of this object in the specified locale.
The ``exclude_fields`` parameter can be used to set any fields to a blank value
in the copy.
"""
def __init__(
self,
object,
locale,
exclude_fields=None,
user=None,
):
self.object = object
self.locale = locale
self.exclude_fields = exclude_fields
self.user = user
def check(self, skip_permission_checks=False):
# Permission checks
if (
self.user
and not skip_permission_checks
and not self.user.has_perms(["simple_translation.submit_translation"])
):
raise CopyForTranslationPermissionError(
"You do not have permission to submit a translation for this object."
)
@transaction.atomic
def _copy_for_translation(self, object, locale, exclude_fields=None):
from wagtail.models import DraftStateMixin, TranslatableMixin
# Make sure the copy includes the latest changes, including draft
if isinstance(object, DraftStateMixin):
object = object.get_latest_revision_as_object()
exclude_fields = (
getattr(object, "default_exclude_fields_in_copy", [])
+ getattr(object, "exclude_fields_in_copy", [])
+ (exclude_fields or [])
)
translated, child_object_map = _copy(object, exclude_fields=exclude_fields)
translated.locale = locale
# Update locale on any translatable child objects as well
# Note: If this is not a subclass of ClusterableModel, child_object_map will always be '{}'
for (_child_relation, _old_pk), child_object in child_object_map.items():
if isinstance(child_object, TranslatableMixin):
child_object.locale = locale
return translated
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
translated_object = self._copy_for_translation(
self.object, self.locale, self.exclude_fields
)
return translated_object

View File

@@ -0,0 +1,376 @@
import logging
import uuid
from django.core.exceptions import PermissionDenied
from modelcluster.models import get_all_child_relations
from wagtail.log_actions import log
from wagtail.models.copying import _copy, _copy_m2m_relations
from wagtail.models.i18n import TranslatableMixin
from wagtail.signals import page_published
logger = logging.getLogger("wagtail")
class CopyPageIntegrityError(RuntimeError):
"""
Raised when the page copy cannot be performed for data integrity reasons.
"""
pass
class CopyPagePermissionError(PermissionDenied):
"""
Raised when the page copy cannot be performed due to insufficient permissions.
"""
pass
class CopyPageAction:
"""
Copies pages and page trees.
"""
def __init__(
self,
page,
to=None,
update_attrs=None,
exclude_fields=None,
recursive=False,
copy_revisions=True,
keep_live=True,
user=None,
process_child_object=None,
log_action="wagtail.copy",
reset_translation_key=True,
):
# Note: These four parameters don't apply to any copied children
self.page = page
self.to = to
self.update_attrs = update_attrs
self.exclude_fields = exclude_fields
self.recursive = recursive
self.copy_revisions = copy_revisions
self.keep_live = keep_live
self.user = user
self.process_child_object = process_child_object
self.log_action = log_action
self.reset_translation_key = reset_translation_key
self._uuid_mapping = {}
def generate_translation_key(self, old_uuid):
"""
Generates a new UUID if it isn't already being used.
Otherwise it will return the same UUID if it's already in use.
"""
if old_uuid not in self._uuid_mapping:
self._uuid_mapping[old_uuid] = uuid.uuid4()
return self._uuid_mapping[old_uuid]
def check(self, skip_permission_checks=False):
# Essential data model checks
if self.page._state.adding:
raise CopyPageIntegrityError("Page.copy() called on an unsaved page")
if (
self.to
and self.recursive
and (self.to.id == self.page.id or self.to.is_descendant_of(self.page))
):
raise CopyPageIntegrityError(
"You cannot copy a tree branch recursively into itself"
)
# Permission checks
if self.user and not skip_permission_checks:
to = self.to
if to is None:
to = self.page.get_parent()
if not self.page.permissions_for_user(self.user).can_copy_to(
to, self.recursive
):
raise CopyPagePermissionError(
"You do not have permission to copy this page"
)
if self.keep_live:
destination_perms = self.to.permissions_for_user(self.user)
if not destination_perms.can_publish_subpage():
raise CopyPagePermissionError(
"You do not have permission to publish a page at the destination"
)
def _copy_page(
self, page, to=None, update_attrs=None, exclude_fields=None, _mpnode_attrs=None
):
specific_page = page.specific
exclude_fields = (
specific_page.default_exclude_fields_in_copy
+ specific_page.exclude_fields_in_copy
+ (exclude_fields or [])
)
if self.keep_live:
base_update_attrs = {
"alias_of": None,
}
else:
base_update_attrs = {
"live": False,
"has_unpublished_changes": True,
"live_revision": None,
"first_published_at": None,
"last_published_at": None,
"alias_of": None,
}
if self.user:
base_update_attrs["owner"] = self.user
# When we're not copying for translation, we should give the translation_key a new value
if self.reset_translation_key:
base_update_attrs["translation_key"] = uuid.uuid4()
if update_attrs:
base_update_attrs.update(update_attrs)
page_copy, child_object_map = _copy(
specific_page, exclude_fields=exclude_fields, update_attrs=base_update_attrs
)
# Save copied child objects and run process_child_object on them if we need to
for (child_relation, old_pk), child_object in child_object_map.items():
if self.process_child_object:
self.process_child_object(
specific_page, page_copy, child_relation, child_object
)
if self.reset_translation_key and isinstance(
child_object, TranslatableMixin
):
child_object.translation_key = self.generate_translation_key(
child_object.translation_key
)
# Save the new page
if _mpnode_attrs:
# We've got a tree position already reserved. Perform a quick save
page_copy.path = _mpnode_attrs[0]
page_copy.depth = _mpnode_attrs[1]
page_copy.save(clean=False)
else:
if to:
page_copy = to.add_child(instance=page_copy)
else:
page_copy = page.add_sibling(instance=page_copy)
_mpnode_attrs = (page_copy.path, page_copy.depth)
_copy_m2m_relations(
specific_page,
page_copy,
exclude_fields=exclude_fields,
update_attrs=base_update_attrs,
)
# Copy revisions
if self.copy_revisions:
for revision in page.revisions.all():
use_as_latest_revision = revision.pk == page.latest_revision_id
revision.pk = None
revision.approved_go_live_at = None
revision.object_id = page_copy.id
# Update ID fields in content
revision_content = revision.content
revision_content["pk"] = page_copy.pk
for child_relation in get_all_child_relations(specific_page):
accessor_name = child_relation.get_accessor_name()
try:
child_objects = revision_content[accessor_name]
except KeyError:
# KeyErrors are possible if the revision was created
# before this child relation was added to the database
continue
for child_object in child_objects:
child_object[child_relation.field.name] = page_copy.pk
# Remap primary key to copied versions
# If the primary key is not recognised (eg, the child object has been deleted from the database)
# set the primary key to None
copied_child_object = child_object_map.get(
(child_relation, child_object["pk"])
)
child_object["pk"] = (
copied_child_object.pk if copied_child_object else None
)
if (
self.reset_translation_key
and "translation_key" in child_object
):
child_object[
"translation_key"
] = self.generate_translation_key(
child_object["translation_key"]
)
for exclude_field in specific_page.exclude_fields_in_copy:
if exclude_field in revision_content and hasattr(
page_copy, exclude_field
):
revision_content[exclude_field] = getattr(
page_copy, exclude_field, None
)
revision.content = revision_content
# Save
revision.save()
# If this revision was designated the latest revision, update the page copy to point to the copied revision
if use_as_latest_revision:
page_copy.latest_revision = revision
# Create a new revision
# This code serves a few purposes:
# * It makes sure update_attrs gets applied to the latest revision
# * It bumps the last_revision_created_at value so the new page gets ordered as if it was just created
# * It sets the user of the new revision so it's possible to see who copied the page by looking at its history
latest_revision = page_copy.get_latest_revision_as_object()
if update_attrs:
for field, value in update_attrs.items():
setattr(latest_revision, field, value)
latest_revision_as_page_revision = latest_revision.save_revision(
user=self.user, changed=False, clean=False
)
# save_revision should have updated this in the database - update the in-memory copy for consistency
page_copy.latest_revision = latest_revision_as_page_revision
if self.keep_live:
page_copy.live_revision = latest_revision_as_page_revision
page_copy.last_published_at = latest_revision_as_page_revision.created_at
page_copy.first_published_at = latest_revision_as_page_revision.created_at
# The call to save_revision above will have updated several fields of the page record, including
# draft_title and latest_revision. These changes are not reflected in page_copy, so we must only
# update the specific fields set above to avoid overwriting them.
page_copy.save(
clean=False,
update_fields=[
"live_revision",
"last_published_at",
"first_published_at",
],
)
if page_copy.live:
page_published.send(
sender=page_copy.specific_class,
instance=page_copy,
revision=latest_revision_as_page_revision,
)
# Log
if self.log_action:
parent = specific_page.get_parent()
log(
instance=page_copy,
action=self.log_action,
user=self.user,
data={
"page": {
"id": page_copy.id,
"title": page_copy.get_admin_display_title(),
"locale": {
"id": page_copy.locale_id,
"language_code": page_copy.locale.language_code,
},
},
"source": {
"id": parent.id,
"title": parent.specific_deferred.get_admin_display_title(),
}
if parent
else None,
"destination": {
"id": to.id,
"title": to.specific_deferred.get_admin_display_title(),
}
if to
else None,
"keep_live": page_copy.live and self.keep_live,
"source_locale": {
"id": page.locale_id,
"language_code": page.locale.language_code,
},
},
)
if page_copy.live and self.keep_live:
# Log the publish if the use chose to keep the copied page live
log(
instance=page_copy,
action="wagtail.publish",
user=self.user,
revision=latest_revision_as_page_revision,
)
logger.info(
'Page copied: "%s" id=%d from=%d', page_copy.title, page_copy.id, page.id
)
# Copy child pages
from wagtail.models import Page, PageViewRestriction
if self.recursive:
numchild = 0
for child_page in page.get_children().specific().iterator():
newdepth = _mpnode_attrs[1] + 1
child_mpnode_attrs = (
Page._get_path(_mpnode_attrs[0], newdepth, numchild),
newdepth,
)
numchild += 1
self._copy_page(
child_page, to=page_copy, _mpnode_attrs=child_mpnode_attrs
)
if numchild > 0:
page_copy.numchild = numchild
page_copy.save(clean=False, update_fields=["numchild"])
# Copy across any view restrictions defined directly on the page,
# unless the destination page already has view restrictions defined
if to:
parent_page_restriction = to.get_view_restrictions()
else:
parent_page_restriction = self.page.get_parent().get_view_restrictions()
if not parent_page_restriction.exists():
for view_restriction in self.page.view_restrictions.all():
view_restriction_copy = PageViewRestriction(
restriction_type=view_restriction.restriction_type,
password=view_restriction.password,
page=page_copy,
)
view_restriction_copy.save(user=self.user)
view_restriction_copy.groups.set(view_restriction.groups.all())
return page_copy
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
return self._copy_page(
self.page,
to=self.to,
update_attrs=self.update_attrs,
exclude_fields=self.exclude_fields,
)

View File

@@ -0,0 +1,267 @@
import logging
import uuid
from django.core.exceptions import PermissionDenied
from wagtail.log_actions import log
from wagtail.models.copying import _copy, _copy_m2m_relations
from wagtail.models.i18n import TranslatableMixin
logger = logging.getLogger("wagtail")
class CreatePageAliasIntegrityError(RuntimeError):
"""
Raised when creating an alias of a page cannot be performed for data integrity reasons.
"""
pass
class CreatePageAliasPermissionError(PermissionDenied):
"""
Raised when creating an alias of a page cannot be performed due to insufficient permissions.
"""
pass
class CreatePageAliasAction:
"""
Creates an alias of the given page.
An alias is like a copy, but an alias remains in sync with the original page. They
are not directly editable and do not have revisions.
You can convert an alias into a regular page by setting the .alias_of attribute to None
and creating an initial revision.
:param recursive: create aliases of the page's subtree, defaults to False
:type recursive: boolean, optional
:param parent: The page to create the new alias under
:type parent: Page, optional
:param update_slug: The slug of the new alias page, defaults to the slug of the original page
:type update_slug: string, optional
:param update_locale: The locale of the new alias page, defaults to the locale of the original page
:type update_locale: Locale, optional
:param user: The user who is performing this action. This user would be assigned as the owner of the new page and appear in the audit log
:type user: User, optional
:param log_action: Override the log action with a custom one. or pass None to skip logging, defaults to 'wagtail.create_alias'
:type log_action: string or None, optional
:param reset_translation_key: Generate new translation_keys for the page and any translatable child objects, defaults to False
:type reset_translation_key: boolean, optional
"""
def __init__(
self,
page,
*,
recursive=False,
parent=None,
update_slug=None,
update_locale=None,
user=None,
log_action="wagtail.create_alias",
reset_translation_key=True,
_mpnode_attrs=None,
):
self.page = page
self.recursive = recursive
self.parent = parent
self.update_slug = update_slug
self.update_locale = update_locale
self.user = user
self.log_action = log_action
self.reset_translation_key = reset_translation_key
self._mpnode_attrs = _mpnode_attrs
def check(self, skip_permission_checks=False):
parent = self.parent or self.page.get_parent()
if self.recursive and (
parent == self.page or parent.is_descendant_of(self.page)
):
raise CreatePageAliasIntegrityError(
"You cannot copy a tree branch recursively into itself"
)
if (
self.user
and not skip_permission_checks
and not parent.permissions_for_user(self.user).can_publish_subpage()
):
raise CreatePageAliasPermissionError(
"You do not have permission to publish a page at the destination"
)
def _create_alias(
self,
page,
*,
recursive,
parent,
update_slug,
update_locale,
user,
log_action,
reset_translation_key,
_mpnode_attrs,
):
specific_page = page.specific
# FIXME: Switch to the same fields that are excluded from copy
# We can't do this right now because we can't exclude fields from with_content_json
# which we use for updating aliases
exclude_fields = [
"id",
"path",
"depth",
"numchild",
"url_path",
"path",
"index_entries",
"postgres_index_entries",
]
update_attrs = {
"alias_of": page,
# Aliases don't have revisions so the draft title should always match the live title
"draft_title": page.title,
# Likewise, an alias page can't have unpublished changes if it's live
"has_unpublished_changes": not page.live,
}
if update_slug:
update_attrs["slug"] = update_slug
if update_locale:
update_attrs["locale"] = update_locale
if user:
update_attrs["owner"] = user
# When we're not copying for translation, we should give the translation_key a new value
if reset_translation_key:
update_attrs["translation_key"] = uuid.uuid4()
alias, child_object_map = _copy(
specific_page, update_attrs=update_attrs, exclude_fields=exclude_fields
)
# Update any translatable child objects
for child_object in child_object_map.values():
if isinstance(child_object, TranslatableMixin):
if update_locale:
child_object.locale = update_locale
# When we're not copying for translation,
# we should give the translation_key a new value for each child object as well.
if reset_translation_key:
child_object.translation_key = uuid.uuid4()
# Save the new page
if _mpnode_attrs:
# We've got a tree position already reserved. Perform a quick save.
alias.path = _mpnode_attrs[0]
alias.depth = _mpnode_attrs[1]
alias.save(clean=False)
else:
if parent:
alias = parent.add_child(instance=alias)
else:
alias = page.add_sibling(instance=alias)
_mpnode_attrs = (alias.path, alias.depth)
_copy_m2m_relations(specific_page, alias, exclude_fields=exclude_fields)
# Log
if log_action:
source_parent = specific_page.get_parent()
log(
instance=alias,
action=log_action,
user=user,
data={
"page": {"id": alias.id, "title": alias.get_admin_display_title()},
"source": {
"id": source_parent.id,
"title": source_parent.specific_deferred.get_admin_display_title(),
}
if source_parent
else None,
"destination": {
"id": parent.id,
"title": parent.specific_deferred.get_admin_display_title(),
}
if parent
else None,
},
)
logger.info(
'Page alias created: "%s" id=%d from=%d', alias.title, alias.id, page.id
)
from wagtail.models import Page, PageViewRestriction
# Copy child pages
if recursive:
numchild = 0
for child_page in page.get_children().specific().iterator():
newdepth = _mpnode_attrs[1] + 1
child_mpnode_attrs = (
Page._get_path(_mpnode_attrs[0], newdepth, numchild),
newdepth,
)
numchild += 1
self._create_alias(
child_page,
recursive=True,
parent=alias,
update_slug=None,
update_locale=update_locale,
user=user,
log_action=log_action,
reset_translation_key=reset_translation_key,
_mpnode_attrs=child_mpnode_attrs,
)
if numchild > 0:
alias.numchild = numchild
alias.save(clean=False, update_fields=["numchild"])
# Copy across any view restrictions defined directly on the page,
# unless the destination page already has view restrictions defined
if parent:
parent_page_restriction = parent.get_view_restrictions()
else:
parent_page_restriction = page.get_parent().get_view_restrictions()
if not parent_page_restriction.exists():
for view_restriction in page.view_restrictions.all():
view_restriction_copy = PageViewRestriction(
restriction_type=view_restriction.restriction_type,
password=view_restriction.password,
page=alias,
)
view_restriction_copy.save(user=self.user)
view_restriction_copy.groups.set(view_restriction.groups.all())
return alias
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
return self._create_alias(
self.page,
recursive=self.recursive,
parent=self.parent,
update_slug=self.update_slug,
update_locale=self.update_locale,
user=self.user,
log_action=self.log_action,
reset_translation_key=self.reset_translation_key,
_mpnode_attrs=self._mpnode_attrs,
)

View File

@@ -0,0 +1,59 @@
from django.core.exceptions import PermissionDenied
from wagtail.log_actions import log
class DeletePagePermissionError(PermissionDenied):
"""
Raised when the page delete cannot be performed due to insufficient permissions.
"""
pass
class DeletePageAction:
def __init__(self, page, user):
self.page = page
self.user = user
def check(self, skip_permission_checks=False):
if (
self.user
and not skip_permission_checks
and not self.page.permissions_for_user(self.user).can_delete()
):
raise DeletePagePermissionError(
"You do not have permission to delete this page"
)
def _delete_page(self, page, *args, **kwargs):
from wagtail.models import Page
# Ensure that deletion always happens on an instance of Page, not a specific subclass. This
# works around a bug in treebeard <= 3.0 where calling SpecificPage.delete() fails to delete
# child pages that are not instances of SpecificPage
if type(page) is Page:
for child in page.get_descendants().specific().iterator():
self.log_deletion(child)
self.log_deletion(page.specific)
# this is a Page instance, so carry on as we were
return super(Page, page).delete(*args, **kwargs)
else:
# retrieve an actual Page instance and delete that instead of page
return DeletePageAction(
Page.objects.get(id=page.id), user=self.user
).execute(*args, **kwargs)
def execute(self, *args, skip_permission_checks=False, **kwargs):
self.check(skip_permission_checks=skip_permission_checks)
return self._delete_page(self.page, *args, **kwargs)
def log_deletion(self, page):
log(
instance=page,
action="wagtail.delete",
user=self.user,
deleted=True,
)

View File

@@ -0,0 +1,107 @@
import logging
from django.core.exceptions import PermissionDenied
from django.db import transaction
from treebeard.mp_tree import MP_MoveHandler
from wagtail.log_actions import log
from wagtail.signals import post_page_move, pre_page_move
logger = logging.getLogger("wagtail")
class MovePagePermissionError(PermissionDenied):
"""
Raised when the page move cannot be performed due to insufficient permissions.
"""
pass
class MovePageAction:
def __init__(self, page, target, pos=None, user=None):
self.page = page
self.target = target
self.pos = pos
self.user = user
def check(self, parent_after, skip_permission_checks=False):
if self.user and not skip_permission_checks:
if not self.page.permissions_for_user(self.user).can_move_to(parent_after):
raise MovePagePermissionError(
"You do not have permission to move the page to the target specified."
)
def _move_page(self, page, target, parent_after):
from wagtail.models import Page
# Determine old and new url_paths
# Fetching new object to avoid affecting `page`
parent_before = page.get_parent()
old_page = Page.objects.get(id=page.id)
old_url_path = old_page.url_path
new_url_path = old_page.set_url_path(parent=parent_after)
url_path_changed = old_url_path != new_url_path
# Emit pre_page_move signal
pre_page_move.send(
sender=page.specific_class or page.__class__,
instance=page,
parent_page_before=parent_before,
parent_page_after=parent_after,
url_path_before=old_url_path,
url_path_after=new_url_path,
)
# Only commit when all descendants are properly updated
with transaction.atomic():
# Allow treebeard to update `path` values
MP_MoveHandler(page, target, self.pos).process()
# Treebeard's move method doesn't actually update the in-memory instance,
# so we need to work with a freshly loaded one now
new_page = Page.objects.get(id=page.id)
new_page.url_path = new_url_path
new_page.save()
# Update descendant paths if url_path has changed
if url_path_changed:
new_page._update_descendant_url_paths(old_url_path, new_url_path)
# Emit post_page_move signal
post_page_move.send(
sender=page.specific_class or page.__class__,
instance=new_page,
parent_page_before=parent_before,
parent_page_after=parent_after,
url_path_before=old_url_path,
url_path_after=new_url_path,
)
# Log
log(
instance=page,
action="wagtail.move" if url_path_changed else "wagtail.reorder",
user=self.user,
data={
"source": {
"id": parent_before.id,
"title": parent_before.specific_deferred.get_admin_display_title(),
},
"destination": {
"id": parent_after.id,
"title": parent_after.specific_deferred.get_admin_display_title(),
},
},
)
logger.info('Page moved: "%s" id=%d path=%s', page.title, page.id, new_url_path)
def execute(self, skip_permission_checks=False):
if self.pos in ("first-child", "last-child", "sorted-child"):
parent_after = self.target
else:
parent_after = self.target.get_parent()
self.check(parent_after, skip_permission_checks=skip_permission_checks)
return self._move_page(self.page, self.target, parent_after)

View File

@@ -0,0 +1,63 @@
import logging
from wagtail.actions.publish_revision import (
PublishPermissionError,
PublishRevisionAction,
)
from wagtail.signals import page_published
logger = logging.getLogger("wagtail")
class PublishPagePermissionError(PublishPermissionError):
"""
Raised when the page publish cannot be performed due to insufficient permissions.
"""
pass
class PublishPageRevisionAction(PublishRevisionAction):
"""
Publish or schedule revision for publishing.
:param revision: revision to publish
:param user: the publishing user
:param changed: indicated whether content has changed
:param log_action:
flag for the logging action. Pass False to skip logging. Cannot pass an action string as the method
performs several actions: "publish", "revert" (and publish the reverted revision),
"schedule publishing with a live revision", "schedule revision reversal publishing, with a live revision",
"schedule publishing", "schedule revision reversal publishing"
:param previous_revision: indicates a revision reversal. Should be set to the previous revision instance
"""
def check(self, skip_permission_checks: bool = False):
if (
self.user
and not skip_permission_checks
and not self.object.permissions_for_user(self.user).can_publish()
):
raise PublishPagePermissionError(
"You do not have permission to publish this page"
)
def _after_publish(self):
from wagtail.models import COMMENTS_RELATION_NAME
for comment in (
getattr(self.object, COMMENTS_RELATION_NAME).all().only("position")
):
comment.save(update_fields=["position"])
page_published.send(
sender=self.object.specific_class,
instance=self.object.specific,
revision=self.revision,
)
super()._after_publish()
self.object.update_aliases(
revision=self.revision, _content=self.revision.content
)

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Optional
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.utils import timezone
from wagtail.log_actions import log
from wagtail.permission_policies.base import ModelPermissionPolicy
from wagtail.signals import published
from wagtail.utils.timestamps import ensure_utc
if TYPE_CHECKING:
from wagtail.models import Revision
logger = logging.getLogger("wagtail")
class PublishPermissionError(PermissionDenied):
"""
Raised when the publish cannot be performed due to insufficient permissions.
"""
pass
class PublishRevisionAction:
"""
Publish or schedule revision for publishing.
:param revision: revision to publish
:param user: the publishing user
:param changed: indicated whether content has changed
:param log_action:
flag for the logging action. Pass False to skip logging. Cannot pass an action string as the method
performs several actions: "publish", "revert" (and publish the reverted revision),
"schedule publishing with a live revision", "schedule revision reversal publishing, with a live revision",
"schedule publishing", "schedule revision reversal publishing"
:param previous_revision: indicates a revision reversal. Should be set to the previous revision instance
"""
def __init__(
self,
revision: Revision,
user=None,
changed: bool = True,
log_action: bool = True,
previous_revision: Optional[Revision] = None,
):
self.revision = revision
self.object = self.revision.as_object()
self.permission_policy = ModelPermissionPolicy(type(self.object))
self.user = user
self.changed = changed
self.log_action = log_action
self.previous_revision = previous_revision
def check(self, skip_permission_checks=False):
if (
self.user
and not skip_permission_checks
and not self.permission_policy.user_has_permission(self.user, "publish")
):
raise PublishPermissionError(
"You do not have permission to publish this object"
)
def log_scheduling_action(self):
log(
instance=self.object,
action="wagtail.publish.schedule",
user=self.user,
data={
"revision": {
"id": self.revision.id,
"created": ensure_utc(self.revision.created_at),
"go_live_at": ensure_utc(self.object.go_live_at),
"has_live_version": self.object.live,
}
},
revision=self.revision,
content_changed=self.changed,
)
def _after_publish(self):
from wagtail.models import WorkflowMixin
published.send(
sender=type(self.object),
instance=self.object,
revision=self.revision,
)
if isinstance(self.object, WorkflowMixin):
workflow_state = self.object.current_workflow_state
if workflow_state and getattr(
settings, "WAGTAIL_WORKFLOW_CANCEL_ON_PUBLISH", True
):
workflow_state.cancel(user=self.user)
def _publish_revision(
self,
revision: Revision,
object,
user,
changed,
log_action: bool,
previous_revision: Optional[Revision] = None,
):
from wagtail.models import Revision
if object.go_live_at and object.go_live_at > timezone.now():
object.has_unpublished_changes = True
# Instead set the approved_go_live_at of this revision
revision.approved_go_live_at = object.go_live_at
revision.save()
# And clear the approved_go_live_at of any other revisions
object.revisions.exclude(id=revision.id).update(approved_go_live_at=None)
# if we are updating a currently live object skip the rest
if object.live_revision:
# Log scheduled publishing
if log_action:
self.log_scheduling_action()
return
# if we have a go_live in the future don't make the object live
object.live = False
else:
object.live = True
# at this point, the object has unpublished changes if and only if there are newer revisions than this one
object.has_unpublished_changes = not revision.is_latest_revision()
# If object goes live clear the approved_go_live_at of all revisions
object.revisions.update(approved_go_live_at=None)
object.expired = False # When a object is published it can't be expired
# Set first_published_at, last_published_at and live_revision
# if the object is being published now
if object.live:
now = timezone.now()
object.last_published_at = now
object.live_revision = revision
if object.first_published_at is None:
object.first_published_at = now
if previous_revision:
previous_revision_object = previous_revision.as_object()
old_object_title = (
str(previous_revision_object)
if str(object) != str(previous_revision_object)
else None
)
else:
try:
previous = revision.get_previous()
except Revision.DoesNotExist:
previous = None
old_object_title = (
str(previous.content_object)
if previous and str(object) != str(previous.content_object)
else None
)
else:
# Unset live_revision if the object is going live in the future
object.live_revision = None
object.save()
self._after_publish()
if object.live:
if log_action:
data = None
if previous_revision:
data = {
"revision": {
"id": previous_revision.id,
"created": ensure_utc(previous_revision.created_at),
}
}
if old_object_title:
data = data or {}
data["title"] = {
"old": old_object_title,
"new": str(object),
}
log(
instance=object,
action="wagtail.rename",
user=user,
data=data,
revision=revision,
)
log(
instance=object,
action=log_action
if isinstance(log_action, str)
else "wagtail.publish",
user=user,
data=data,
revision=revision,
content_changed=changed,
)
logger.info(
'Published: "%s" pk=%s revision_id=%d',
str(object),
str(object.pk),
revision.id,
)
elif object.go_live_at:
logger.info(
'Scheduled for publish: "%s" pk=%s revision_id=%d go_live_at=%s',
str(object),
str(object.pk),
revision.id,
object.go_live_at.isoformat(),
)
if log_action:
self.log_scheduling_action()
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
return self._publish_revision(
self.revision,
self.object,
user=self.user,
changed=self.changed,
log_action=self.log_action,
previous_revision=self.previous_revision,
)

View File

@@ -0,0 +1,65 @@
from django.core.exceptions import PermissionDenied
class RevertToPageRevisionError(RuntimeError):
"""
Raised when the revision revert cannot be performed for data reasons.
"""
pass
class RevertToPageRevisionPermissionError(PermissionDenied):
"""
Raised when the revision revert cannot be performed due to insufficient permissions.
"""
pass
class RevertToPageRevisionAction:
def __init__(
self,
page,
revision,
user=None,
log_action="wagtail.revert",
approved_go_live_at=None,
changed=True,
clean=True,
):
self.page = page
self.revision = revision
self.user = user
self.log_action = log_action
self.approved_go_live_at = approved_go_live_at
self.changed = changed
self.clean = clean
def check(self, skip_permission_checks=False):
if self.page.alias_of_id:
raise RevertToPageRevisionError(
"Revisions are not required for alias pages as they are an exact copy of another page."
)
# Permission checks
if (
self.user
and not skip_permission_checks
and not self.page.permissions_for_user(self.user).can_edit()
):
raise RevertToPageRevisionPermissionError(
"You do not have permission to edit this page"
)
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
return self.revision.as_object().save_revision(
previous_revision=self.revision,
user=self.user,
log_action=self.log_action,
approved_go_live_at=self.approved_go_live_at,
changed=self.changed,
clean=self.clean,
)

View File

@@ -0,0 +1,91 @@
import logging
from django.core.exceptions import PermissionDenied
from wagtail.log_actions import log
from wagtail.signals import unpublished
logger = logging.getLogger("wagtail")
class UnpublishPermissionError(PermissionDenied):
"""
Raised when the object unpublish cannot be performed due to insufficient permissions.
"""
pass
class UnpublishAction:
def __init__(
self,
object,
set_expired=False,
commit=True,
user=None,
log_action=True,
):
self.object = object
self.set_expired = set_expired
self.commit = commit
self.user = user
self.log_action = log_action
def check(self, skip_permission_checks=False):
if (
self.user
and not skip_permission_checks
and not self.object.permissions_for_user(self.user).can_unpublish()
):
raise UnpublishPermissionError(
"You do not have permission to unpublish this object"
)
def _commit_unpublish(self, object):
object.save()
def _after_unpublish(self, object):
unpublished.send(sender=type(object), instance=object)
def _unpublish_object(self, object, set_expired, commit, user, log_action):
"""
Unpublish the object by setting ``live`` to ``False``. Does nothing if ``live`` is already ``False``
:param log_action: flag for logging the action. Pass False to skip logging. Can be passed an action string.
Defaults to 'wagtail.unpublish'
"""
if object.live:
object.live = False
object.has_unpublished_changes = True
object.live_revision = None
if set_expired:
object.expired = True
if commit:
self._commit_unpublish(object)
if log_action:
log(
instance=object,
action=log_action
if isinstance(log_action, str)
else "wagtail.unpublish",
user=user,
)
logger.info('Unpublished: "%s" pk=%s', str(object), str(object.pk))
object.revisions.update(approved_go_live_at=None)
self._after_unpublish(object)
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
self._unpublish_object(
self.object,
set_expired=self.set_expired,
commit=self.commit,
user=self.user,
log_action=self.log_action,
)

View File

@@ -0,0 +1,69 @@
import logging
from wagtail.actions.unpublish import UnpublishAction, UnpublishPermissionError
from wagtail.signals import page_unpublished
logger = logging.getLogger("wagtail")
class UnpublishPagePermissionError(UnpublishPermissionError):
"""
Raised when the page unpublish cannot be performed due to insufficient permissions.
"""
pass
class UnpublishPageAction(UnpublishAction):
def __init__(
self,
page,
set_expired=False,
commit=True,
user=None,
log_action=True,
include_descendants=False,
):
super().__init__(
page,
set_expired=set_expired,
commit=commit,
user=user,
log_action=log_action,
)
self.include_descendants = include_descendants
def check(self, skip_permission_checks=False):
try:
super().check(skip_permission_checks)
except UnpublishPermissionError as error:
raise UnpublishPagePermissionError(
"You do not have permission to unpublish this page"
) from error
def _commit_unpublish(self, object):
# using clean=False to bypass validation
object.save(clean=False)
def _after_unpublish(self, object):
for alias in object.aliases.all():
alias.unpublish(log_action=False)
page_unpublished.send(sender=object.specific_class, instance=object.specific)
super()._after_unpublish(object)
def execute(self, skip_permission_checks=False):
super().execute(skip_permission_checks)
if self.include_descendants:
for live_descendant_page in (
self.object.get_descendants()
.live()
.defer_streamfields()
.specific()
.iterator()
):
action = UnpublishPageAction(live_descendant_page)
if live_descendant_page.permissions_for_user(self.user).can_unpublish():
action.execute(skip_permission_checks=True)

View File

@@ -0,0 +1,342 @@
"""Handles rendering of the list of actions in the footer of the page create/edit views."""
from django.conf import settings
from django.forms import Media
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from wagtail import hooks
from wagtail.admin.ui.components import Component
class ActionMenuItem(Component):
"""Defines an item in the actions drop-up on the page creation/edit view"""
order = 100 # default order index if one is not specified on init
template_name = "wagtailadmin/pages/action_menu/menu_item.html"
label = ""
name = None
classname = ""
icon_name = ""
def __init__(self, order=None):
if order is not None:
self.order = order
def get_user_page_permissions_tester(self, context):
if "user_page_permissions_tester" in context:
return context["user_page_permissions_tester"]
return context["page"].permissions_for_user(context["request"].user)
def is_shown(self, context):
"""
Whether this action should be shown on this request; permission checks etc should go here.
By default, actions are shown for unlocked pages, hidden for locked pages
context = dictionary containing at least:
'request' = the current request object
'view' = 'create', 'edit' or 'revisions_revert'
'page' (if view = 'edit' or 'revisions_revert') = the page being edited
'parent_page' (if view = 'create') = the parent page of the page being created
'lock' = a Lock object if the page is locked, otherwise None
'locked_for_user' = True if the lock prevents the current user from editing the page
may also contain:
'user_page_permissions_tester' = a PagePermissionTester for the current user and page
"""
return context["view"] == "create" or not context["locked_for_user"]
def get_context_data(self, parent_context):
"""Defines context for the template, overridable to use more data"""
context = parent_context.copy()
url = self.get_url(parent_context)
context.update(
{
"label": self.label,
"url": url,
"name": self.name,
"classname": self.classname,
"icon_name": self.icon_name,
"request": parent_context["request"],
}
)
return context
def get_url(self, parent_context):
return None
class PublishMenuItem(ActionMenuItem):
label = _("Publish")
name = "action-publish"
template_name = "wagtailadmin/pages/action_menu/publish.html"
icon_name = "upload"
def is_shown(self, context):
if context["view"] == "create":
return (
context["parent_page"]
.permissions_for_user(context["request"].user)
.can_publish_subpage()
)
else: # view == 'edit' or 'revisions_revert'
perms_tester = self.get_user_page_permissions_tester(context)
return not context["locked_for_user"] and perms_tester.can_publish()
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["is_revision"] = context["view"] == "revisions_revert"
return context
class SubmitForModerationMenuItem(ActionMenuItem):
label = _("Submit for moderation")
name = "action-submit"
icon_name = "resubmit"
def is_shown(self, context):
if not getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
return False
if context["view"] == "create":
return context["parent_page"].has_workflow
if context["view"] == "edit":
perms_tester = self.get_user_page_permissions_tester(context)
return (
perms_tester.can_submit_for_moderation()
and not context["locked_for_user"]
)
# context == revisions_revert
return False
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
page = context.get("page")
workflow_state = page.current_workflow_state if page else None
if (
workflow_state
and workflow_state.status == workflow_state.STATUS_NEEDS_CHANGES
):
context["label"] = _("Resubmit to %(task_name)s") % {
"task_name": workflow_state.current_task_state.task.name
}
elif page:
workflow = page.get_workflow()
if workflow:
context["label"] = _("Submit to %(workflow_name)s") % {
"workflow_name": workflow.name
}
return context
class WorkflowMenuItem(ActionMenuItem):
template_name = "wagtailadmin/pages/action_menu/workflow_menu_item.html"
def __init__(self, name, label, launch_modal, *args, **kwargs):
self.name = name
self.label = label
self.launch_modal = launch_modal
if kwargs.get("icon_name"):
self.icon_name = kwargs.pop("icon_name")
super().__init__(*args, **kwargs)
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["launch_modal"] = self.launch_modal
context["current_task_state"] = context["page"].current_workflow_task_state
return context
def is_shown(self, context):
if context["view"] == "edit":
return not context["locked_for_user"]
class RestartWorkflowMenuItem(ActionMenuItem):
label = _("Restart workflow ")
name = "action-restart-workflow"
classname = "button--icon-flipped"
icon_name = "login"
def is_shown(self, context):
if not getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
return False
elif context["view"] == "edit":
workflow_state = context["page"].current_workflow_state
perms_tester = self.get_user_page_permissions_tester(context)
return (
perms_tester.can_submit_for_moderation()
and not context["locked_for_user"]
and workflow_state
and workflow_state.user_can_cancel(context["request"].user)
)
else:
return False
class CancelWorkflowMenuItem(ActionMenuItem):
label = _("Cancel workflow ")
name = "action-cancel-workflow"
icon_name = "error"
def is_shown(self, context):
if context["view"] == "edit":
workflow_state = context["page"].current_workflow_state
return workflow_state and workflow_state.user_can_cancel(
context["request"].user
)
return False
class UnpublishMenuItem(ActionMenuItem):
label = _("Unpublish")
name = "action-unpublish"
icon_name = "download"
classname = "action-secondary"
def is_shown(self, context):
if context["view"] == "edit":
perms_tester = self.get_user_page_permissions_tester(context)
return not context["locked_for_user"] and perms_tester.can_unpublish()
def get_url(self, context):
return reverse("wagtailadmin_pages:unpublish", args=(context["page"].id,))
class SaveDraftMenuItem(ActionMenuItem):
name = "action-save-draft"
label = _("Save Draft")
template_name = "wagtailadmin/pages/action_menu/save_draft.html"
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["is_revision"] = context["view"] == "revisions_revert"
return context
class PageLockedMenuItem(ActionMenuItem):
name = "action-page-locked"
label = _("Page locked")
template_name = "wagtailadmin/pages/action_menu/page_locked.html"
def is_shown(self, context):
return "page" in context and context["locked_for_user"]
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["is_revision"] = context["view"] == "revisions_revert"
return context
BASE_PAGE_ACTION_MENU_ITEMS = None
def _get_base_page_action_menu_items():
"""
Retrieve the global list of menu items for the page action menu,
which may then be customised on a per-request basis
"""
global BASE_PAGE_ACTION_MENU_ITEMS
if BASE_PAGE_ACTION_MENU_ITEMS is None:
BASE_PAGE_ACTION_MENU_ITEMS = [
SaveDraftMenuItem(order=0),
UnpublishMenuItem(order=20),
PublishMenuItem(order=30),
CancelWorkflowMenuItem(order=40),
RestartWorkflowMenuItem(order=50),
SubmitForModerationMenuItem(order=60),
PageLockedMenuItem(order=10000),
]
for hook in hooks.get_hooks("register_page_action_menu_item"):
action_menu_item = hook()
if action_menu_item:
BASE_PAGE_ACTION_MENU_ITEMS.append(action_menu_item)
return BASE_PAGE_ACTION_MENU_ITEMS
class PageActionMenu:
template = "wagtailadmin/pages/action_menu/menu.html"
def __init__(self, request, **kwargs):
self.request = request
self.context = kwargs
self.context["request"] = request
page = self.context.get("page")
if page:
self.context["user_page_permissions_tester"] = page.permissions_for_user(
self.request.user
)
self.menu_items = []
if page:
task = page.current_workflow_task
current_workflow_state = page.current_workflow_state
is_final_task = (
current_workflow_state and current_workflow_state.is_at_final_task
)
if task:
actions = task.get_actions(page, request.user)
workflow_menu_items = []
for name, label, launch_modal in actions:
icon_name = "edit"
if name == "approve":
if is_final_task and not getattr(
settings,
"WAGTAIL_WORKFLOW_REQUIRE_REAPPROVAL_ON_EDIT",
False,
):
label = _("%(label)s and Publish") % {"label": label}
icon_name = "success"
item = WorkflowMenuItem(
name, label, launch_modal, icon_name=icon_name
)
if item.is_shown(self.context):
workflow_menu_items.append(item)
self.menu_items.extend(workflow_menu_items)
for menu_item in _get_base_page_action_menu_items():
if menu_item.is_shown(self.context):
self.menu_items.append(menu_item)
self.menu_items.sort(key=lambda item: item.order)
for hook in hooks.get_hooks("construct_page_action_menu"):
hook(self.menu_items, self.request, self.context)
try:
self.default_item = self.menu_items.pop(0)
except IndexError:
self.default_item = None
def render_html(self):
rendered_menu_items = [
menu_item.render_html(self.context) for menu_item in self.menu_items
]
rendered_default_item = self.default_item.render_html(self.context)
return render_to_string(
self.template,
{
"default_menu_item": rendered_default_item,
"show_menu": bool(self.menu_items),
"rendered_menu_items": rendered_menu_items,
},
request=self.request,
)
@cached_property
def media(self):
media = Media()
for item in self.menu_items:
media += item.media
return media

View File

@@ -0,0 +1,107 @@
from django.contrib.admin.utils import quote
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
from wagtail.hooks import search_for_hooks
from wagtail.utils.registry import ObjectTypeRegistry
"""
A mechanism for finding the admin edit URL for an arbitrary object instance, optionally applying
permission checks.
url_finder = AdminURLFinder(request.user)
url_finder.get_edit_url(some_page) # => "/admin/pages/123/edit/"
url_finder.get_edit_url(some_image) # => "/admin/images/456/"
url_finder.get_edit_url(some_site) # => None (user does not have edit permission for sites)
If the user parameter is omitted, edit URLs are returned without considering permissions.
Handlers for new models can be registered via register_admin_url_finder:
class SprocketAdminURLFinder(ModelAdminURLFinder):
edit_url_name = 'wagtailsprockets:edit'
register_admin_url_finder(Sprocket, SprocketAdminURLFinder)
"""
class ModelAdminURLFinder:
"""
Handles admin edit URL lookups for an individual model
"""
edit_url_name = None
permission_policy = None
def __init__(self, user=None):
self.user = user
def construct_edit_url(self, instance):
"""
Return the edit URL for the given instance - regardless of whether the user can access it -
or None if no edit URL is available.
"""
if self.edit_url_name is None:
raise ImproperlyConfigured(
"%r must define edit_url_name or override construct_edit_url"
% type(self)
)
return reverse(self.edit_url_name, args=(quote(instance.pk),))
def get_edit_url(self, instance):
"""
Return the edit URL for the given instance if one exists and the user has permission for it,
or None otherwise.
"""
if (
self.user
and self.permission_policy
and not self.permission_policy.user_has_permission_for_instance(
self.user, "change", instance
)
):
return None
else:
return self.construct_edit_url(instance)
class NullAdminURLFinder:
"""
A dummy AdminURLFinder that always returns None
"""
def __init__(self, user=None):
pass
def get_edit_url(self, instance):
return None
finder_classes = ObjectTypeRegistry()
def register_admin_url_finder(model, handler):
finder_classes.register(model, value=handler)
class AdminURLFinder:
"""
The 'main' admin URL finder, which searches across all registered models
"""
def __init__(self, user=None):
search_for_hooks() # ensure wagtail_hooks files have been loaded
self.user = user
self.finders_by_model = {}
def get_edit_url(self, instance):
model = type(instance)
try:
# do we already have a finder for this model and user?
finder = self.finders_by_model[model]
except KeyError:
finder_class = finder_classes.get(instance) or NullAdminURLFinder
finder = finder_class(self.user)
self.finders_by_model[model] = finder
return finder.get_edit_url(instance)

View File

@@ -0,0 +1,6 @@
class APIAction:
serializer = None
def __init__(self, view, request):
self.view = view
self.request = request

View File

@@ -0,0 +1,30 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from wagtail.actions.convert_alias import ConvertAliasPageAction, ConvertAliasPageError
from wagtail.api.v2.utils import BadRequestError
from .base import APIAction
class ConvertAliasPageAPIAction(APIAction):
serializer = Serializer
def _action_from_data(self, instance, data):
return ConvertAliasPageAction(instance, user=self.request.user)
def execute(self, instance, data):
action = self._action_from_data(instance, data)
try:
new_page = action.execute()
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
except ConvertAliasPageError as e:
raise BadRequestError(e.args[0])
serializer = self.view.get_serializer(new_page)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,67 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from django.shortcuts import get_object_or_404
from rest_framework import fields, status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from wagtail.actions.copy_page import CopyPageAction, CopyPageIntegrityError
from wagtail.api.v2.utils import BadRequestError
from wagtail.coreutils import find_available_slug
from wagtail.models import Page
from .base import APIAction
class CopyPageAPIActionSerializer(Serializer):
# Note: CopyPageAction will validate the destination page
destination_page_id = fields.IntegerField(required=False)
recursive = fields.BooleanField(default=False, required=False)
keep_live = fields.BooleanField(default=True, required=False)
slug = fields.CharField(required=False)
title = fields.CharField(required=False)
class CopyPageAPIAction(APIAction):
serializer = CopyPageAPIActionSerializer
def _action_from_data(self, instance, data):
destination_page_id = data.get("destination_page_id")
if destination_page_id is None:
destination = instance.get_parent()
else:
destination = get_object_or_404(Page, id=destination_page_id)
update_attrs = {}
if "slug" in data:
update_attrs["slug"] = data["slug"]
else:
# If user didn't specify a particular slug, find an available one
available_slug = find_available_slug(destination, instance.slug)
if available_slug != instance.slug:
update_attrs["slug"] = available_slug
if "title" in data:
update_attrs["title"] = data["title"]
return CopyPageAction(
page=instance,
to=destination,
recursive=data["recursive"],
keep_live=data["keep_live"],
update_attrs=update_attrs,
user=self.request.user,
)
def execute(self, instance, data):
action = self._action_from_data(instance, data)
try:
new_page = action.execute()
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
except CopyPageIntegrityError as e:
raise BadRequestError(e.args[0])
serializer = self.view.get_serializer(new_page)
return Response(serializer.data, status=status.HTTP_201_CREATED)

View File

@@ -0,0 +1,51 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from django.shortcuts import get_object_or_404
from rest_framework import fields, status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from wagtail.actions.copy_for_translation import (
CopyPageForTranslationAction,
ParentNotTranslatedError,
)
from wagtail.api.v2.utils import BadRequestError
from wagtail.models.i18n import Locale
from .base import APIAction
class CopyForTranslationAPIActionSerializer(Serializer):
locale = fields.CharField(max_length=100)
copy_parents = fields.BooleanField(default=False, required=False)
alias = fields.BooleanField(default=False, required=False)
recursive = fields.BooleanField(default=False, required=False)
class CopyForTranslationAPIAction(APIAction):
serializer = CopyForTranslationAPIActionSerializer
def _action_from_data(self, instance, data):
locale = get_object_or_404(Locale, language_code=data["locale"])
return CopyPageForTranslationAction(
page=instance,
locale=locale,
copy_parents=data["copy_parents"],
alias=data["alias"],
user=self.request.user,
include_subtree=data["recursive"],
)
def execute(self, instance, data):
action = self._action_from_data(instance, data)
try:
translated_page = action.execute()
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
except ParentNotTranslatedError as e:
raise BadRequestError(e.args[0])
serializer = self.view.get_serializer(translated_page)
return Response(serializer.data, status=status.HTTP_201_CREATED)

Some files were not shown because too many files have changed in this diff Show More