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,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)

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.create_alias import (
CreatePageAliasAction,
CreatePageAliasIntegrityError,
)
from wagtail.api.v2.utils import BadRequestError
from wagtail.models import Page
from .base import APIAction
class CreatePageAliasAPIActionSerializer(Serializer):
destination_page_id = fields.IntegerField(required=False)
recursive = fields.BooleanField(default=False, required=False)
update_slug = fields.CharField(required=False)
class CreatePageAliasAPIAction(APIAction):
serializer = CreatePageAliasAPIActionSerializer
def _action_from_data(self, instance, data):
parent, destination_page_id = None, data.get("destination_page_id")
if destination_page_id:
parent = get_object_or_404(Page, id=destination_page_id).specific
return CreatePageAliasAction(
page=instance,
recursive=data["recursive"],
parent=parent,
update_slug=data.get("update_slug"),
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 CreatePageAliasIntegrityError 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,26 @@
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.delete_page import DeletePageAction
from .base import APIAction
class DeletePageAPIAction(APIAction):
serializer = Serializer
def _action_from_data(self, instance, data):
return DeletePageAction(page=instance, user=self.request.user)
def execute(self, instance, data):
action = self._action_from_data(instance, data)
try:
action.execute()
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,53 @@
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.move_page import MovePageAction
from wagtail.models import Page
from .base import APIAction
class MovePageAPIActionSerializer(Serializer):
destination_page_id = fields.IntegerField(required=True)
position = fields.ChoiceField(
required=False,
choices=[
"left",
"right",
"first-child",
"last-child",
"first-sibling",
"last-sibling",
],
)
class MovePageAPIAction(APIAction):
serializer = MovePageAPIActionSerializer
def _action_from_data(self, instance, data):
destination_page_id = data["destination_page_id"]
target = get_object_or_404(Page, id=destination_page_id)
return MovePageAction(
page=instance,
target=target,
pos=data.get("position"),
user=self.request.user,
)
def execute(self, instance, data):
action = self._action_from_data(instance, data)
try:
action.execute()
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
instance.refresh_from_db()
serializer = self.view.get_serializer(instance)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,34 @@
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.publish_page_revision import PublishPageRevisionAction
from wagtail.api.v2.utils import BadRequestError
from .base import APIAction
class PublishPageAPIAction(APIAction):
serializer = Serializer
def _action_from_data(self, instance, data):
user = self.request.user
revision = instance.get_latest_revision() or instance.save_revision(user=user)
return PublishPageRevisionAction(revision, user=user)
def execute(self, instance, data):
try:
action = self._action_from_data(instance, data)
except RuntimeError as e:
raise BadRequestError(e.args[0])
try:
action.execute()
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
new_page = instance.specific_class.objects.get(pk=instance.pk)
serializer = self.view.get_serializer(new_page)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,42 @@
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.revert_to_page_revision import (
RevertToPageRevisionAction,
RevertToPageRevisionError,
)
from wagtail.api.v2.utils import BadRequestError
from .base import APIAction
class RevertToPageRevisionAPIActionSerializer(Serializer):
revision_id = fields.IntegerField()
class RevertToPageRevisionAPIAction(APIAction):
serializer = RevertToPageRevisionAPIActionSerializer
def _action_from_data(self, instance, data):
revision = get_object_or_404(instance.revisions, id=data["revision_id"])
return RevertToPageRevisionAction(
page=instance, revision=revision, user=self.request.user
)
def execute(self, instance, data):
action = self._action_from_data(instance, data)
try:
new_revision = action.execute()
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
except RevertToPageRevisionError as e:
raise BadRequestError(e.args[0])
serializer = self.view.get_serializer(new_revision.as_object())
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,35 @@
from django.core.exceptions import ValidationError as DjangoValidationError
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.unpublish_page import UnpublishPageAction
from .base import APIAction
class UnpublishPageAPIActionSerializer(Serializer):
recursive = fields.BooleanField(default=False, required=False)
class UnpublishPageAPIAction(APIAction):
serializer = UnpublishPageAPIActionSerializer
def _action_from_data(self, instance, data):
return UnpublishPageAction(
page=instance,
user=self.request.user,
include_descendants=data["recursive"],
)
def execute(self, instance, data):
action = self._action_from_data(instance, data)
try:
action.execute()
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
serializer = self.view.get_serializer(instance)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,45 @@
from rest_framework.filters import BaseFilterBackend
from wagtail import hooks
from wagtail.api.v2.utils import BadRequestError, parse_boolean
from wagtail.permissions import page_permission_policy
class HasChildrenFilter(BaseFilterBackend):
"""
Filters the queryset by checking if the pages have children or not.
This is useful when you want to get just the branches or just the leaves.
"""
def filter_queryset(self, request, queryset, view):
if "has_children" in request.GET:
try:
has_children_filter = parse_boolean(request.GET["has_children"])
except ValueError:
raise BadRequestError("has_children must be 'true' or 'false'")
if has_children_filter is True:
return queryset.filter(numchild__gt=0)
else:
return queryset.filter(numchild=0)
return queryset
class ForExplorerFilter(BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
if request.GET.get("for_explorer"):
if not hasattr(queryset, "_filtered_by_child_of"):
raise BadRequestError(
"filtering by for_explorer without child_of is not supported"
)
parent_page = queryset._filtered_by_child_of
for hook in hooks.get_hooks("construct_explorer_page_queryset"):
queryset = hook(parent_page, queryset, request)
queryset = (
page_permission_policy.explorable_instances(request.user) & queryset
)
return queryset

View File

@@ -0,0 +1,192 @@
from collections import OrderedDict
from rest_framework.fields import Field, ReadOnlyField
from wagtail.api.v2.serializers import PageSerializer, get_serializer_class
from wagtail.api.v2.utils import get_full_url
from wagtail.models import Page
def get_model_listing_url(context, model):
url_path = context["router"].get_model_listing_urlpath(model)
if url_path:
return get_full_url(context["request"], url_path)
class PageStatusField(Field):
"""
Serializes the "status" field.
Example:
"status": {
"status": "live",
"live": true,
"has_unpublished_changes": false
},
"""
def get_attribute(self, instance):
return instance
def to_representation(self, page):
return OrderedDict(
[
("status", page.status_string),
("live", page.live),
("has_unpublished_changes", page.has_unpublished_changes),
]
)
class PageChildrenField(Field):
"""
Serializes the "children" field.
Example:
"children": {
"count": 1,
"listing_url": "/api/v1/pages/?child_of=2"
}
"""
def get_attribute(self, instance):
return instance
def to_representation(self, page):
return OrderedDict(
[
("count", self.context["base_queryset"].child_of(page).count()),
(
"listing_url",
get_model_listing_url(self.context, Page)
+ "?child_of="
+ str(page.id),
),
]
)
class PageDescendantsField(Field):
"""
Serializes the "descendants" field.
Example:
"descendants": {
"count": 10,
"listing_url": "/api/v1/pages/?descendant_of=2"
}
"""
def get_attribute(self, instance):
return instance
def to_representation(self, page):
return OrderedDict(
[
("count", self.context["base_queryset"].descendant_of(page).count()),
(
"listing_url",
get_model_listing_url(self.context, Page)
+ "?descendant_of="
+ str(page.id),
),
]
)
class PageAncestorsField(Field):
"""
Serializes the page's ancestry.
Example:
"ancestry": [
{
"id": 1,
"meta": {
"type": "wagtailcore.Page",
"detail_url": "/api/v1/pages/1/"
},
"title": "Root"
},
{
"id": 2,
"meta": {
"type": "home.HomePage",
"detail_url": "/api/v1/pages/2/"
},
"title": "Home"
}
]
"""
def get_attribute(self, instance):
return instance
def to_representation(self, page):
serializer_class = get_serializer_class(
Page,
["id", "type", "detail_url", "html_url", "title", "admin_display_title"],
meta_fields=["type", "detail_url", "html_url"],
base=AdminPageSerializer,
)
serializer = serializer_class(context=self.context, many=True)
return serializer.to_representation(page.get_ancestors())
class PageTranslationsField(Field):
"""
Serializes the page's translations.
Example:
"translations": [
{
"id": 1,
"meta": {
"type": "home.HomePage",
"detail_url": "/api/v1/pages/1/",
"locale": "es"
},
"title": "Casa"
},
{
"id": 2,
"meta": {
"type": "home.HomePage",
"detail_url": "/api/v1/pages/2/",
"locale": "fr"
},
"title": "Maison"
}
]
"""
def get_attribute(self, instance):
return instance
def to_representation(self, page):
serializer_class = get_serializer_class(
Page,
[
"id",
"type",
"detail_url",
"html_url",
"locale",
"title",
"admin_display_title",
],
meta_fields=["type", "detail_url", "html_url", "locale"],
base=AdminPageSerializer,
)
serializer = serializer_class(context=self.context, many=True)
return serializer.to_representation(page.get_translations())
class AdminPageSerializer(PageSerializer):
status = PageStatusField(read_only=True)
children = PageChildrenField(read_only=True)
descendants = PageDescendantsField(read_only=True)
ancestors = PageAncestorsField(read_only=True)
translations = PageTranslationsField(read_only=True)
admin_display_title = ReadOnlyField(source="get_admin_display_title")

View File

@@ -0,0 +1,16 @@
from django.urls import path
from wagtail import hooks
from wagtail.api.v2.router import WagtailAPIRouter
from .views import PagesAdminAPIViewSet
admin_api = WagtailAPIRouter("wagtailadmin_api")
admin_api.register_endpoint("pages", PagesAdminAPIViewSet)
for fn in hooks.get_hooks("construct_admin_api"):
fn(admin_api)
urlpatterns = [
path("main/", admin_api.urls),
]

View File

@@ -0,0 +1,161 @@
from collections import OrderedDict
from django.conf import settings
from django.http import Http404
from django.urls import path
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.models import Page
from .actions.convert_alias import ConvertAliasPageAPIAction
from .actions.copy import CopyPageAPIAction
from .actions.copy_for_translation import CopyForTranslationAPIAction
from .actions.create_alias import CreatePageAliasAPIAction
from .actions.delete import DeletePageAPIAction
from .actions.move import MovePageAPIAction
from .actions.publish import PublishPageAPIAction
from .actions.revert_to_page_revision import RevertToPageRevisionAPIAction
from .actions.unpublish import UnpublishPageAPIAction
from .filters import ForExplorerFilter, HasChildrenFilter
from .serializers import AdminPageSerializer
class PagesAdminAPIViewSet(PagesAPIViewSet):
base_serializer_class = AdminPageSerializer
authentication_classes = [SessionAuthentication]
actions = {
"convert_alias": ConvertAliasPageAPIAction,
"copy": CopyPageAPIAction,
"delete": DeletePageAPIAction,
"publish": PublishPageAPIAction,
"unpublish": UnpublishPageAPIAction,
"move": MovePageAPIAction,
"copy_for_translation": CopyForTranslationAPIAction,
"create_alias": CreatePageAliasAPIAction,
"revert_to_page_revision": RevertToPageRevisionAPIAction,
}
# Add has_children and for_explorer filters
filter_backends = PagesAPIViewSet.filter_backends + [
HasChildrenFilter,
ForExplorerFilter,
]
meta_fields = PagesAPIViewSet.meta_fields + [
"latest_revision_created_at",
"status",
"children",
"descendants",
"parent",
"ancestors",
"translations",
]
body_fields = PagesAPIViewSet.body_fields + [
"admin_display_title",
]
listing_default_fields = PagesAPIViewSet.listing_default_fields + [
"latest_revision_created_at",
"status",
"children",
"admin_display_title",
]
# Allow the parent field to appear on listings
detail_only_fields = []
known_query_parameters = PagesAPIViewSet.known_query_parameters.union(
["for_explorer", "has_children"]
)
@classmethod
def get_detail_default_fields(cls, model):
detail_default_fields = super().get_detail_default_fields(model)
# When i18n is disabled, remove "translations" from default fields
if not getattr(settings, "WAGTAIL_I18N_ENABLED", False):
detail_default_fields.remove("translations")
return detail_default_fields
def get_root_page(self):
"""
Returns the page that is used when the `&child_of=root` filter is used.
"""
return Page.get_first_root_node()
def get_base_queryset(self):
"""
Returns a queryset containing all pages that can be seen by this user.
This is used as the base for get_queryset and is also used to find the
parent pages when using the child_of and descendant_of filters as well.
"""
return Page.objects.all()
def get_queryset(self):
queryset = super().get_queryset()
# Hide root page
# TODO: Add "include_root" flag
queryset = queryset.exclude(depth=1).defer_streamfields().specific()
return queryset
def get_type_info(self):
types = OrderedDict()
for name, model in self.seen_types.items():
types[name] = OrderedDict(
[
("verbose_name", model._meta.verbose_name),
("verbose_name_plural", model._meta.verbose_name_plural),
]
)
return types
def listing_view(self, request):
response = super().listing_view(request)
response.data["__types"] = self.get_type_info()
return response
def detail_view(self, request, pk):
response = super().detail_view(request, pk)
response.data["__types"] = self.get_type_info()
return response
def action_view(self, request, pk, action_name):
instance = self.get_object()
if action_name not in self.actions:
raise Http404(f"unrecognised action '{action_name}'")
action = self.actions[action_name](self, request)
action_data = action.serializer(data=request.data)
if not action_data.is_valid():
return Response(action_data.errors, status=400)
return action.execute(instance, action_data.data)
@classmethod
def get_urlpatterns(cls):
"""
This returns a list of URL patterns for the endpoint
"""
urlpatterns = super().get_urlpatterns()
urlpatterns.extend(
[
path(
"<int:pk>/action/<str:action_name>/",
cls.as_view({"post": "action_view"}),
name="action",
),
]
)
return urlpatterns

View File

@@ -0,0 +1,16 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from . import checks # NOQA: F401
class WagtailAdminAppConfig(AppConfig):
name = "wagtail.admin"
label = "wagtailadmin"
verbose_name = _("Wagtail admin")
default_auto_field = "django.db.models.AutoField"
def ready(self):
from wagtail.admin.signal_handlers import register_signal_handlers
register_signal_handlers()

View File

@@ -0,0 +1,188 @@
import types
from functools import wraps
import l18n
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.timezone import override as override_tz
from django.utils.translation import gettext as _
from django.utils.translation import override
from wagtail.admin import messages
from wagtail.log_actions import LogContext
from wagtail.permissions import page_permission_policy
def permission_denied(request):
"""Return a standard 'permission denied' response"""
if request.headers.get("x-requested-with") == "XMLHttpRequest":
raise PermissionDenied
from wagtail.admin import messages
messages.error(request, _("Sorry, you do not have permission to access this area."))
return redirect("wagtailadmin_home")
def user_passes_test(test):
"""
Given a test function that takes a user object and returns a boolean,
return a view decorator that denies access to the user if the test returns false.
"""
def decorator(view_func):
# decorator takes the view function, and returns the view wrapped in
# a permission check
@wraps(view_func)
def wrapped_view_func(request, *args, **kwargs):
if test(request.user):
# permission check succeeds; run the view function as normal
return view_func(request, *args, **kwargs)
else:
# permission check failed
return permission_denied(request)
return wrapped_view_func
return decorator
def permission_required(permission_name):
"""
Replacement for django.contrib.auth.decorators.permission_required which returns a
more meaningful 'permission denied' response than just redirecting to the login page.
(The latter doesn't work anyway because Wagtail doesn't define LOGIN_URL...)
"""
def test(user):
return user.has_perm(permission_name)
# user_passes_test constructs a decorator function specific to the above test function
return user_passes_test(test)
def any_permission_required(*perms):
"""
Decorator that accepts a list of permission names, and allows the user
to pass if they have *any* of the permissions in the list
"""
def test(user):
for perm in perms:
if user.has_perm(perm):
return True
return False
return user_passes_test(test)
class PermissionPolicyChecker:
"""
Provides a view decorator that enforces the given permission policy,
returning the wagtailadmin 'permission denied' response if permission not granted
"""
def __init__(self, policy):
self.policy = policy
def require(self, action):
def test(user):
return self.policy.user_has_permission(user, action)
return user_passes_test(test)
def require_any(self, *actions):
def test(user):
return self.policy.user_has_any_permission(user, actions)
return user_passes_test(test)
def user_has_any_page_permission(user):
"""
Check if a user has any permission to add, edit, or otherwise manage any
page.
"""
return page_permission_policy.user_has_any_permission(
user, {"add", "change", "publish", "bulk_delete", "lock", "unlock"}
)
def reject_request(request):
if request.headers.get("x-requested-with") == "XMLHttpRequest":
raise PermissionDenied
# import redirect_to_login here to avoid circular imports on model files that import
# wagtail.admin.auth, specifically where custom user models are involved
from django.contrib.auth.views import redirect_to_login as auth_redirect_to_login
login_url = getattr(
settings, "WAGTAILADMIN_LOGIN_URL", reverse("wagtailadmin_login")
)
return auth_redirect_to_login(request.get_full_path(), login_url=login_url)
def require_admin_access(view_func):
def decorated_view(request, *args, **kwargs):
user = request.user
if user.is_anonymous:
return reject_request(request)
if user.has_perms(["wagtailadmin.access_admin"]):
try:
preferred_language = None
if hasattr(user, "wagtail_userprofile"):
preferred_language = (
user.wagtail_userprofile.get_preferred_language()
)
l18n.set_language(preferred_language)
time_zone = user.wagtail_userprofile.get_current_time_zone()
else:
time_zone = settings.TIME_ZONE
with override_tz(time_zone), LogContext(user=user):
if preferred_language:
with override(preferred_language):
response = view_func(request, *args, **kwargs)
else:
response = view_func(request, *args, **kwargs)
if hasattr(response, "render"):
# If the response has a render() method, Django treats it
# like a TemplateResponse, so we should do the same
# In this case, we need to guarantee that when the TemplateResponse
# is rendered, it is done within the override context manager
# or the user preferred_language/timezone will not be used
# (this could be replaced with simply rendering the TemplateResponse
# for simplicity but this does remove some of its middleware modification
# potential)
render = response.render
def overridden_render(response):
with override_tz(time_zone):
if preferred_language:
with override(preferred_language):
return render()
return render()
response.render = types.MethodType(overridden_render, response)
# decorate the response render method with the override context manager
return response
except PermissionDenied:
if request.headers.get("x-requested-with") == "XMLHttpRequest":
raise
return permission_denied(request)
if not request.headers.get("x-requested-with") == "XMLHttpRequest":
messages.error(request, _("You do not have permission to access the admin"))
return reject_request(request)
return decorated_view

View File

@@ -0,0 +1,7 @@
import warnings
from wagtail.blocks import * # noqa: F403
warnings.warn(
"wagtail.admin.blocks has moved to wagtail.blocks", UserWarning, stacklevel=2
)

View File

@@ -0,0 +1,278 @@
import os
from django.core.checks import Error, Tags, Warning, register
@register("staticfiles")
def css_install_check(app_configs, **kwargs):
errors = []
css_path = os.path.join(
os.path.dirname(__file__), "static", "wagtailadmin", "css", "core.css"
)
if not os.path.isfile(css_path):
error_hint = (
"""
Most likely you are running a development (non-packaged) copy of
Wagtail and have not built the static assets -
see https://docs.wagtail.org/en/latest/contributing/developing.html
File not found: %s
"""
% css_path
)
errors.append(
Warning(
"CSS for the Wagtail admin is missing",
hint=error_hint,
id="wagtailadmin.W001",
)
)
return errors
@register(Tags.admin)
def base_form_class_check(app_configs, **kwargs):
from wagtail.admin.forms import WagtailAdminPageForm
from wagtail.models import get_page_models
errors = []
for cls in get_page_models():
if not issubclass(cls.base_form_class, WagtailAdminPageForm):
errors.append(
Error(
"{}.base_form_class does not extend WagtailAdminPageForm".format(
cls.__name__
),
hint="Ensure that {}.{} extends WagtailAdminPageForm".format(
cls.base_form_class.__module__, cls.base_form_class.__name__
),
obj=cls,
id="wagtailadmin.E001",
)
)
return errors
@register(Tags.admin)
def get_form_class_check(app_configs, **kwargs):
from wagtail.admin.forms import WagtailAdminPageForm
from wagtail.models import get_page_models
errors = []
for cls in get_page_models():
edit_handler = cls.get_edit_handler()
if not issubclass(edit_handler.get_form_class(), WagtailAdminPageForm):
errors.append(
Error(
"{cls}.get_edit_handler().get_form_class() does not extend WagtailAdminPageForm".format(
cls=cls.__name__
),
hint="Ensure that the panel definition for {cls} creates a subclass of WagtailAdminPageForm".format(
cls=cls.__name__
),
obj=cls,
id="wagtailadmin.E002",
)
)
return errors
@register("panels")
def inline_panel_model_panels_check(app_configs, **kwargs):
from wagtail.models import get_page_models
errors = []
page_models = get_page_models()
for cls in page_models:
errors.extend(check_panels_in_model(cls))
# filter out duplicate errors found for the same model
unique_errors = []
for error in errors:
if error.msg not in [e.msg for e in unique_errors]:
unique_errors.append(error)
return unique_errors
def check_panels_in_model(cls, context="model"):
"""Check panels configuration uses `panels` when `edit_handler` not in use."""
from wagtail.admin.panels import InlinePanel, PanelGroup
from wagtail.models import Page
errors = []
if hasattr(cls, "get_edit_handler"):
# must check the InlinePanel related models
edit_handler = cls.get_edit_handler()
for tab in edit_handler.children:
if isinstance(tab, PanelGroup):
inline_panel_children = [
panel for panel in tab.children if isinstance(panel, InlinePanel)
]
for inline_panel_child in inline_panel_children:
errors.extend(
check_panels_in_model(
inline_panel_child.db_field.related_model,
context="InlinePanel model",
)
)
if issubclass(cls, Page) or hasattr(cls, "edit_handler"):
# Pages do not need to be checked for standalone tabbed_panel usage
# if edit_handler is used on any model, assume config is correct
return errors
tabbed_panels = [
"content_panels",
"promote_panels",
"settings_panels",
]
for panel_name in tabbed_panels:
class_name = cls.__name__
if not hasattr(cls, panel_name):
continue
panel_name_short = panel_name.replace("_panels", "").title()
error_title = "{}.{} will have no effect on {} editing".format(
class_name, panel_name, context
)
if "InlinePanel" in context:
error_hint = """Ensure that {} uses `panels` instead of `{}`.
There are no tabs on non-Page model editing within InlinePanels.""".format(
class_name, panel_name
)
else:
error_hint = """Ensure that {} uses `panels` instead of `{}`\
or set up an `edit_handler` if you want a tabbed editing interface.
There are no default tabs on non-Page models so there will be no \
{} tab for the {} to render in.""".format(
class_name, panel_name, panel_name_short, panel_name
)
error = Warning(error_title, hint=error_hint, obj=cls, id="wagtailadmin.W002")
errors.append(error)
return errors
@register("wagtailadmin_base_url")
def wagtail_admin_base_url_check(app_configs, **kwargs):
from django.conf import settings
errors = []
if getattr(settings, "WAGTAILADMIN_BASE_URL", None) is None:
errors.append(
Warning(
"The WAGTAILADMIN_BASE_URL setting is not defined",
hint="This should be the base URL used to access the Wagtail admin site. "
"Without this, URLs in notification emails will not display correctly.",
id="wagtailadmin.W003",
)
)
return errors
@register("file_overwrite")
def file_overwrite_check(app_configs, **kwargs):
from django import VERSION as DJANGO_VERSION
from django.conf import settings
if DJANGO_VERSION >= (5, 1):
file_storage = getattr(settings, "STORAGES")["default"]["BACKEND"]
else:
try:
file_storage = getattr(settings, "STORAGES")["default"]["BACKEND"]
except AttributeError:
file_storage = getattr(settings, "DEFAULT_FILE_STORAGE", None)
errors = []
if file_storage == "storages.backends.s3boto3.S3Boto3Storage" and getattr(
settings, "AWS_S3_FILE_OVERWRITE", True
):
errors.append(
Warning(
"The AWS_S3_FILE_OVERWRITE setting is set to True",
hint="This should be set to False. The incorrect setting can cause documents and "
"other user-uploaded files to be silently overwritten or deleted.",
id="wagtailadmin.W004",
)
)
if file_storage == "storages.backends.azure_storage.AzureStorage" and getattr(
settings, "AZURE_OVERWRITE_FILES", False
):
errors.append(
Warning(
"The AZURE_OVERWRITE_FILES setting is set to True",
hint="This should be set to False. The incorrect setting can cause documents and "
"other user-uploaded files to be silently overwritten or deleted.",
id="wagtailadmin.W004",
)
)
if file_storage == "storages.backends.gcloud.GoogleCloudStorage" and getattr(
settings, "GS_FILE_OVERWRITE", True
):
errors.append(
Warning(
"The GS_FILE_OVERWRITE setting is set to True",
hint="This should be set to False. The incorrect setting can cause documents and "
"other user-uploaded files to be silently overwritten or deleted.",
id="wagtailadmin.W004",
)
)
return errors
@register("datetime_format")
def datetime_format_check(app_configs, **kwargs):
"""
If L10N is enabled, check if WAGTAIL_* formats are compatible with Django input formats.
See https://docs.djangoproject.com/en/stable/topics/i18n/formatting/#creating-custom-format-files
See https://docs.wagtail.org/en/stable/reference/settings.html#wagtail-date-format-wagtail-datetime-format-wagtail-time-format
"""
from django.conf import settings
from django.utils import formats
errors = []
if not getattr(settings, "USE_L10N", False):
return errors
for code, label in settings.LANGUAGES:
for wagtail_setting, django_setting in [
("WAGTAIL_DATE_FORMAT", "DATE_INPUT_FORMATS"),
("WAGTAIL_DATETIME_FORMAT", "DATETIME_INPUT_FORMATS"),
("WAGTAIL_TIME_FORMAT", "TIME_INPUT_FORMATS"),
]:
wagtail_format_value = getattr(settings, wagtail_setting, None)
if wagtail_format_value is None:
# Skip the iteration if wagtail_format is not present
continue
input_formats = formats.get_format(django_setting, lang=code)
if wagtail_format_value not in input_formats:
errors.append(
Error(
"Configuration error",
hint=f"'{wagtail_format_value}' must be in {django_setting} for language {label} ({code}).",
obj=wagtail_setting,
id="wagtailadmin.E003",
)
)
return errors

View File

@@ -0,0 +1,852 @@
import difflib
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.utils.encoding import force_str
from django.utils.html import escape, format_html, format_html_join
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager
from wagtail import blocks
from wagtail.fields import RichTextField, StreamField
from wagtail.utils.registry import ModelFieldRegistry
from wagtail.utils.text import text_from_html
comparison_class_registry = ModelFieldRegistry()
def register_comparison_class(
field_class, to=None, comparison_class=None, exact_class=False
):
"""
Define parameters for form fields to be used by WagtailAdminModelForm for a given
database field.
"""
if comparison_class is None:
raise ImproperlyConfigured(
"register_comparison_class must be passed a 'comparison_class' keyword argument"
)
if to and field_class != models.ForeignKey:
raise ImproperlyConfigured(
"The 'to' argument on register_comparison_class is only valid for ForeignKey fields"
)
comparison_class_registry.register(
field_class, to=to, value=comparison_class, exact_class=exact_class
)
class FieldComparison:
is_field = True
is_child_relation = False
def __init__(self, field, obj_a, obj_b):
self.field = field
self.val_a = field.value_from_object(obj_a)
self.val_b = field.value_from_object(obj_b)
def field_label(self):
"""
Returns a label for this field to be displayed to the user
"""
verbose_name = getattr(self.field, "verbose_name", None)
if verbose_name is None:
# Relations don't have a verbose_name
verbose_name = self.field.name.replace("_", " ")
return capfirst(verbose_name)
def htmldiff(self):
if self.val_a != self.val_b:
return TextDiff(
[("deletion", self.val_a), ("addition", self.val_b)]
).to_html()
else:
return escape(self.val_a)
def has_changed(self):
"""
Returns True if the field has changed
"""
return self.val_a != self.val_b
class TextFieldComparison(FieldComparison):
def htmldiff(self):
return diff_text(self.val_a, self.val_b).to_html()
register_comparison_class(models.CharField, comparison_class=TextFieldComparison)
register_comparison_class(models.TextField, comparison_class=TextFieldComparison)
class RichTextFieldComparison(TextFieldComparison):
def htmldiff(self):
return diff_text(
text_from_html(self.val_a), text_from_html(self.val_b)
).to_html()
register_comparison_class(RichTextField, comparison_class=RichTextFieldComparison)
def get_comparison_class_for_block(block):
if hasattr(block, "get_comparison_class"):
return block.get_comparison_class()
elif isinstance(block, (blocks.CharBlock, blocks.TextBlock)):
return CharBlockComparison
elif isinstance(block, blocks.RawHTMLBlock):
# Compare raw HTML blocks as if they were plain text, so that tags are shown explicitly
return CharBlockComparison
elif isinstance(block, blocks.RichTextBlock):
return RichTextBlockComparison
elif isinstance(block, blocks.StructBlock):
return StructBlockComparison
elif isinstance(block, blocks.StreamBlock):
return StreamBlockComparison
elif isinstance(block, blocks.ListBlock):
return ListBlockComparison
else:
# As all stream field blocks have a HTML representation, fall back to diffing that.
return RichTextBlockComparison
class BlockComparison:
def __init__(self, block, exists_a, exists_b, val_a, val_b):
self.block = block
self.exists_a = exists_a
self.exists_b = exists_b
self.val_a = val_a
self.val_b = val_b
def is_new(self):
return self.exists_b and not self.exists_a
def is_deleted(self):
return self.exists_a and not self.exists_b
def has_changed(self):
return self.val_a != self.val_b
def htmlvalue(self, val):
"""
Return an HTML representation of this block that is safe to be included
in comparison views
"""
return escape(text_from_html(self.block.render_basic(val)))
def htmldiff(self):
html_val_a = self.block.render_basic(self.val_a)
html_val_b = self.block.render_basic(self.val_b)
return diff_text(
text_from_html(html_val_a), text_from_html(html_val_b)
).to_html()
class CharBlockComparison(BlockComparison):
def htmldiff(self):
return diff_text(force_str(self.val_a), force_str(self.val_b)).to_html()
def htmlvalue(self, val):
return escape(val)
class RichTextBlockComparison(BlockComparison):
pass
class StructBlockComparison(BlockComparison):
def htmlvalue(self, val):
htmlvalues = []
for name, block in self.block.child_blocks.items():
label = self.block.child_blocks[name].label
comparison_class = get_comparison_class_for_block(block)
htmlvalues.append(
(
label,
comparison_class(block, True, True, val[name], val[name]).htmlvalue(
val[name]
),
)
)
return format_html(
"<dl>\n{}\n</dl>",
format_html_join("\n", " <dt>{}</dt>\n <dd>{}</dd>", htmlvalues),
)
def htmldiff(self):
htmldiffs = []
for name, block in self.block.child_blocks.items():
label = self.block.child_blocks[name].label
comparison_class = get_comparison_class_for_block(block)
htmldiffs.append(
(
label,
comparison_class(
block,
self.exists_a,
self.exists_b,
self.val_a[name],
self.val_b[name],
).htmldiff(),
)
)
return format_html(
"<dl>\n{}\n</dl>",
format_html_join("\n", " <dt>{}</dt>\n <dd>{}</dd>", htmldiffs),
)
class BaseSequenceBlockComparison(BlockComparison):
@staticmethod
def get_blocks_from_value(val):
raise NotImplementedError
@staticmethod
def get_blocks_by_id(a_blocks, b_blocks):
a_blocks_by_id = {block.id: block for block in a_blocks}
b_blocks_by_id = {block.id: block for block in b_blocks}
return a_blocks_by_id, b_blocks_by_id
def get_block_comparisons_by_id(self):
a_blocks = self.get_blocks_from_value(self.val_a)
b_blocks = self.get_blocks_from_value(self.val_b)
a_blocks_by_id, b_blocks_by_id = self.get_blocks_by_id(a_blocks, b_blocks)
deleted_ids = a_blocks_by_id.keys() - b_blocks_by_id.keys()
comparisons = []
for block in b_blocks:
comparison_class = get_comparison_class_for_block(block.block)
if block.id in a_blocks_by_id:
# Changed/existing block
comparisons.append(
comparison_class(
block.block,
True,
True,
a_blocks_by_id[block.id].value,
block.value,
)
)
else:
# New block
comparisons.append(
comparison_class(block.block, False, True, None, block.value)
)
# Insert deleted blocks at the index where they used to be
deleted_block_indices = [
(block, i) for i, block in enumerate(a_blocks) if block.id in deleted_ids
]
for block, index in deleted_block_indices:
comparison_class = get_comparison_class_for_block(block.block)
comparison_to_insert = comparison_class(
block.block, True, False, block.value, None
)
# Insert the block back in where it was before it was deleted.
# Note: we need to account for new blocks when finding the position.
current_index = 0
block_inserted = False
for i, comparison in enumerate(comparisons):
if comparison.is_new():
continue
if current_index == index:
comparisons.insert(i, comparison_to_insert)
block_inserted = True
break
current_index += 1
# Deleted block was from the end
if not block_inserted:
comparisons.append(comparison_to_insert)
return comparisons
def htmldiff(self):
comparisons_html = []
for comparison in self.get_block_comparisons():
classes = ["comparison__child-object"]
if comparison.is_new():
classes.append("addition")
block_rendered = comparison.htmlvalue(comparison.val_b)
elif comparison.is_deleted():
classes.append("deletion")
block_rendered = comparison.htmlvalue(comparison.val_a)
elif comparison.has_changed():
block_rendered = comparison.htmldiff()
else:
block_rendered = comparison.htmlvalue(comparison.val_a)
classes = " ".join(classes)
comparisons_html.append(f'<div class="{classes}">{block_rendered}</div>')
return mark_safe("\n".join(comparisons_html))
class StreamBlockComparison(BaseSequenceBlockComparison):
@staticmethod
def get_blocks_from_value(val):
blocks = list(val) or []
return blocks
def get_block_comparisons(self):
return self.get_block_comparisons_by_id()
class ListBlockComparison(BaseSequenceBlockComparison):
@staticmethod
def get_blocks_from_value(val):
blocks = list(val.bound_blocks) or []
return blocks
def get_block_comparisons(self):
a_blocks = self.get_blocks_from_value(self.val_a)
b_blocks = self.get_blocks_from_value(self.val_b)
# ListBlock could be in one of two formats.
# If IDs in both a and b are all newly created (ie we're loading data in the old format)
# there's no point in using id for identifying which blocks are the same vs moved / added / deleted.
both_in_new_format = any(block.original_id for block in a_blocks) and any(
block.original_id for block in b_blocks
)
if both_in_new_format:
# All blocks have ids, so we can use the BaseSequenceBlock comparison logic, which uses ids.
return self.get_block_comparisons_by_id()
# We're dealing with data in the old format
# Let's compare blocks by position - it's not perfect, but it's better than rendering to HTML
comparisons = []
comparison_class = get_comparison_class_for_block(self.block.child_block)
a_length = len(a_blocks)
b_length = len(b_blocks)
for index, block in enumerate(b_blocks):
if index < a_length:
# Assume blocks with the same index are the same changed block
# (not necessarily true as blocks could be moved)
# Changed/existing block
comparisons.append(
comparison_class(
block.block,
True,
True,
a_blocks[index].value,
block.value,
)
)
else:
# New block
comparisons.append(
comparison_class(block.block, False, True, None, block.value)
)
if a_length > b_length:
for block in a_blocks[b_length:]:
# Deleted blocks
comparisons.append(
comparison_class(block.block, True, False, block.value, None)
)
return comparisons
class StreamFieldComparison(FieldComparison):
def has_block_ids(self, val):
if not val:
return True
return bool(val[0].id)
def htmldiff(self):
# Our method for diffing streamfields relies on the blocks in both revisions having UUIDs.
# But as UUIDs were added in Wagtail 1.11 we can't compare revisions that were created before
# that Wagtail version.
if self.has_block_ids(self.val_a) and self.has_block_ids(self.val_b):
return StreamBlockComparison(
self.field.stream_block, True, True, self.val_a, self.val_b
).htmldiff()
else:
# Fall back to diffing the HTML representation
return diff_text(
text_from_html(self.val_a), text_from_html(self.val_b)
).to_html()
register_comparison_class(StreamField, comparison_class=StreamFieldComparison)
class ChoiceFieldComparison(FieldComparison):
def htmldiff(self):
val_a = force_str(
dict(self.field.flatchoices).get(self.val_a, self.val_a), strings_only=True
)
val_b = force_str(
dict(self.field.flatchoices).get(self.val_b, self.val_b), strings_only=True
)
if self.val_a != self.val_b:
diffs = []
if val_a:
diffs += [("deletion", val_a)]
if val_b:
diffs += [("addition", val_b)]
return TextDiff(diffs).to_html()
else:
return escape(val_a)
class M2MFieldComparison(FieldComparison):
def get_items(self):
return list(self.val_a), list(self.val_b)
def get_item_display(self, item):
return str(item)
def htmldiff(self):
# Get tags
items_a, items_b = self.get_items()
# Calculate changes
sm = difflib.SequenceMatcher(0, items_a, items_b)
changes = []
for op, i1, i2, j1, j2 in sm.get_opcodes():
if op == "replace":
for item in items_a[i1:i2]:
changes.append(("deletion", self.get_item_display(item)))
for item in items_b[j1:j2]:
changes.append(("addition", self.get_item_display(item)))
elif op == "delete":
for item in items_a[i1:i2]:
changes.append(("deletion", self.get_item_display(item)))
elif op == "insert":
for item in items_b[j1:j2]:
changes.append(("addition", self.get_item_display(item)))
elif op == "equal":
for item in items_a[i1:i2]:
changes.append(("equal", self.get_item_display(item)))
# Convert changelist to HTML
return TextDiff(changes, separator=", ").to_html()
def has_changed(self):
items_a, items_b = self.get_items()
return items_a != items_b
class TagsFieldComparison(M2MFieldComparison):
def get_item_display(self, tag):
return tag.slug
register_comparison_class(TaggableManager, comparison_class=TagsFieldComparison)
class ForeignObjectComparison(FieldComparison):
def get_objects(self):
model = self.field.related_model
obj_a = model.objects.filter(pk=self.val_a).first()
obj_b = model.objects.filter(pk=self.val_b).first()
return obj_a, obj_b
def htmldiff(self):
obj_a, obj_b = self.get_objects()
if obj_a != obj_b:
if obj_a and obj_b:
# Changed
return TextDiff(
[("deletion", force_str(obj_a)), ("addition", force_str(obj_b))]
).to_html()
elif obj_b:
# Added
return TextDiff([("addition", force_str(obj_b))]).to_html()
elif obj_a:
# Removed
return TextDiff([("deletion", force_str(obj_a))]).to_html()
else:
if obj_a:
return escape(force_str(obj_a))
else:
return _("None")
class ChildRelationComparison:
is_field = False
is_child_relation = True
def __init__(self, field, field_comparisons, obj_a, obj_b, label=""):
self.field = field
self.field_comparisons = field_comparisons
self.val_a = getattr(obj_a, field.related_name)
self.val_b = getattr(obj_b, field.related_name)
self.label = label
def field_label(self):
"""
Returns a label for this field to be displayed to the user
"""
verbose_name = getattr(self.field, "verbose_name", None)
if verbose_name is None:
# Relations don't have a verbose_name
if self.label:
# If the panel has a label, we set it instead.
# See InlinePanel.get_comparison for usage
verbose_name = self.label
else:
verbose_name = self.field.name.replace("_", " ")
return capfirst(verbose_name)
def get_mapping(self, objs_a, objs_b):
"""
This bit of code attempts to match the objects in the A revision with
their counterpart in the B revision.
A match is firstly attempted by PK (where a matching ID indicates they're the same).
We compare remaining the objects by their field data; the objects with the fewest
fields changed are matched until there are no more possible matches left.
This returns 4 values:
- map_forwards => a mapping of object indexes from the B version to the A version
- map_backwards => a mapping of object indexes from the A version to the B version
- added => a list of indices for objects that didn't exist in the B version
- deleted => a list of indices for objects that didn't exist in the A version
Note the indices are 0-based array indices indicating the location of the object in either
the objs_a or objs_b arrays.
For example:
objs_a => A, B, C, D
objs_b => B, C, D, E
Will return the following:
map_forwards = {
1: 0, # B (objs_a: objs_b)
2: 1, # C (objs_a: objs_b)
3: 2, # D (objs_a: objs_b)
}
map_backwards = {
0: 1, # B (objs_b: objs_a)
1: 2, # C (objs_b: objs_a)
2: 3, # D (objs_b: objs_a)
}
added = [4] # D in objs_b
deleted = [0] # A in objs_a
"""
map_forwards = {}
map_backwards = {}
added = []
deleted = []
# Match child objects on PK (ID)
for a_idx, a_child in enumerate(objs_a):
for b_idx, b_child in enumerate(objs_b):
if b_idx in map_backwards:
continue
if (
a_child.pk is not None
and b_child.pk is not None
and a_child.pk == b_child.pk
):
map_forwards[a_idx] = b_idx
map_backwards[b_idx] = a_idx
# Now try to match them by data
matches = []
for a_idx, a_child in enumerate(objs_a):
if a_idx not in map_forwards:
for b_idx, b_child in enumerate(objs_b):
if b_idx not in map_backwards:
# If they both have a PK (ID) that is different, they can't be the same child object
if a_child.pk and b_child.pk and a_child.pk != b_child.pk:
continue
comparison = self.get_child_comparison(
objs_a[a_idx], objs_b[b_idx]
)
num_differences = comparison.get_num_differences()
matches.append((a_idx, b_idx, num_differences))
# Objects with the least differences will be matched first. So only the best possible matches are made
matches.sort(key=lambda match: match[2])
for a_idx, b_idx, num_differences in matches:
# Make sure both objects were not matched previously
if a_idx in map_forwards or b_idx in map_backwards:
continue
# Match!
map_forwards[a_idx] = b_idx
map_backwards[b_idx] = a_idx
# Mark unmapped objects as added/deleted
for a_idx, a_child in enumerate(objs_a):
if a_idx not in map_forwards:
deleted.append(a_idx)
for b_idx, b_child in enumerate(objs_b):
if b_idx not in map_backwards:
added.append(b_idx)
return map_forwards, map_backwards, added, deleted
def get_child_comparison(self, obj_a, obj_b):
return ChildObjectComparison(
self.field.related_model, self.field_comparisons, obj_a, obj_b
)
def get_child_comparisons(self):
"""
Returns a list of ChildObjectComparison objects. Representing all child
objects that existed in either version.
They are returned in the order they appear in the B version with deletions
appended at the end.
All child objects are returned, regardless of whether they were actually changed.
"""
objs_a = list(self.val_a.all())
objs_b = list(self.val_b.all())
map_forwards, map_backwards, added, deleted = self.get_mapping(objs_a, objs_b)
objs_a = dict(enumerate(objs_a))
objs_b = dict(enumerate(objs_b))
comparisons = []
for b_idx, b_child in objs_b.items():
if b_idx in added:
comparisons.append(self.get_child_comparison(None, b_child))
else:
comparisons.append(
self.get_child_comparison(objs_a[map_backwards[b_idx]], b_child)
)
for a_idx, a_child in objs_a.items():
if a_idx in deleted:
comparisons.append(self.get_child_comparison(a_child, None))
return comparisons
def has_changed(self):
"""
Returns true if any changes were made to any of the child objects. This includes
adding, deleting and reordering.
"""
objs_a = list(self.val_a.all())
objs_b = list(self.val_b.all())
map_forwards, map_backwards, added, deleted = self.get_mapping(objs_a, objs_b)
if added or deleted:
return True
for a_idx, b_idx in map_forwards.items():
comparison = self.get_child_comparison(objs_a[a_idx], objs_b[b_idx])
if comparison.has_changed():
return True
return False
class ChildObjectComparison:
def __init__(self, model, field_comparisons, obj_a, obj_b):
self.model = model
self.field_comparisons = field_comparisons
self.obj_a = obj_a
self.obj_b = obj_b
def is_addition(self):
"""
Returns True if this child object was created since obj_a
"""
return self.obj_b and not self.obj_a
def is_deletion(self):
"""
Returns True if this child object was deleted in obj_b
"""
return self.obj_a and not self.obj_b
def get_position_change(self):
"""
Returns the change in position as an integer. Positive if the object
was moved down, negative if it moved up.
For example: '3' indicates the object moved down three spaces. '-1'
indicates the object moved up one space.
"""
if not self.is_addition() and not self.is_deletion():
sort_a = getattr(self.obj_a, "sort_order", 0) or 0
sort_b = getattr(self.obj_b, "sort_order", 0) or 0
return sort_b - sort_a
def get_field_comparisons(self):
"""
Returns a list of comparisons for all the fields in this object.
Fields that haven't changed are included as well.
"""
comparisons = []
if self.is_addition() or self.is_deletion():
# Display the fields without diff as one of the versions are missing
obj = self.obj_a or self.obj_b
for field_comparison in self.field_comparisons:
comparisons.append(field_comparison(obj, obj))
else:
for field_comparison in self.field_comparisons:
comparisons.append(field_comparison(self.obj_a, self.obj_b))
return comparisons
def has_changed(self):
for comparison in self.get_field_comparisons():
if comparison.has_changed():
return True
return False
def get_num_differences(self):
"""
Returns the number of fields that differ between the two
objects.
"""
num_differences = 0
for comparison in self.get_field_comparisons():
if comparison.has_changed():
num_differences += 1
return num_differences
class TextDiff:
def __init__(self, changes, separator=""):
self.changes = changes
self.separator = separator
def to_html(self, tag="span", addition_class="addition", deletion_class="deletion"):
html = []
for change_type, value in self.changes:
if change_type == "equal":
html.append(escape(value))
elif change_type == "addition":
html.append(
'<{tag} class="{classname}">{value}</{tag}>'.format(
tag=tag, classname=addition_class, value=escape(value)
)
)
elif change_type == "deletion":
html.append(
'<{tag} class="{classname}">{value}</{tag}>'.format(
tag=tag, classname=deletion_class, value=escape(value)
)
)
return mark_safe(self.separator.join(html))
def diff_text(a, b):
"""
Performs a diffing algorithm on two pieces of text. Returns
a string of HTML containing the content of both texts with
<span> tags inserted indicating where the differences are.
"""
def tokenise(text):
"""
Tokenises a string by splitting it into individual characters
and grouping the alphanumeric ones together.
This means that punctuation, whitespace, CJK characters, etc
become separate tokens and words/numbers are merged together
to form bigger tokens.
This makes the output of the diff easier to read as words are
not broken up.
"""
tokens = []
current_token = ""
for c in text or "":
if c.isalnum():
current_token += c
else:
if current_token:
tokens.append(current_token)
current_token = ""
tokens.append(c)
if current_token:
tokens.append(current_token)
return tokens
a_tok = tokenise(a)
b_tok = tokenise(b)
sm = difflib.SequenceMatcher(lambda t: len(t) <= 4, a_tok, b_tok)
changes = []
for op, i1, i2, j1, j2 in sm.get_opcodes():
if op == "replace":
for token in a_tok[i1:i2]:
changes.append(("deletion", token))
for token in b_tok[j1:j2]:
changes.append(("addition", token))
elif op == "delete":
for token in a_tok[i1:i2]:
changes.append(("deletion", token))
elif op == "insert":
for token in b_tok[j1:j2]:
changes.append(("addition", token))
elif op == "equal":
for token in a_tok[i1:i2]:
changes.append(("equal", token))
# Merge adjacent changes which have the same type. This just cleans up the HTML a bit
merged_changes = []
current_value = []
current_change_type = None
for change_type, value in changes:
if change_type != current_change_type:
if current_change_type is not None:
merged_changes.append((current_change_type, "".join(current_value)))
current_value = []
current_change_type = change_type
current_value.append(value)
if current_value:
merged_changes.append((current_change_type, "".join(current_value)))
return TextDiff(merged_changes)

View File

@@ -0,0 +1,38 @@
# Adapted from https://djangosnippets.org/snippets/10563/
# original author bernd-wechner
def to_datetimepicker_format(python_format_string):
"""
Given a python datetime format string, attempts to convert it to
the nearest PHP datetime format string possible.
"""
python2PHP = {
"%a": "D",
"%A": "l",
"%b": "M",
"%B": "F",
"%c": "",
"%d": "d",
"%H": "H",
"%I": "h",
"%j": "z",
"%m": "m",
"%M": "i",
"%p": "A",
"%S": "s",
"%U": "",
"%w": "w",
"%W": "W",
"%x": "",
"%X": "",
"%y": "y",
"%Y": "Y",
"%Z": "e",
}
php_format_string = python_format_string
for py, php in python2PHP.items():
php_format_string = php_format_string.replace(py, php)
return php_format_string

View File

@@ -0,0 +1,262 @@
import django_filters
from django import forms
from django.conf import settings
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_filters.widgets import SuffixedMultiWidget
from wagtail.admin.models import popular_tags_for_model
from wagtail.admin.utils import get_user_display_name
from wagtail.admin.widgets import AdminDateInput, BooleanRadioSelect, FilteredSelect
from wagtail.coreutils import get_content_languages, get_content_type_label
class DateRangePickerWidget(SuffixedMultiWidget):
"""
A widget allowing a start and end date to be picked.
"""
template_name = "wagtailadmin/widgets/daterange_input.html"
suffixes = ["from", "to"]
def __init__(self, attrs=None):
widgets = (
AdminDateInput(attrs={"placeholder": _("Date from")}),
AdminDateInput(attrs={"placeholder": _("Date to")}),
)
super().__init__(widgets, attrs)
def decompress(self, value):
if value:
return [value.start, value.stop]
return [None, None]
class FilteredModelChoiceIterator(django_filters.fields.ModelChoiceIterator):
"""
A variant of Django's ModelChoiceIterator that, instead of yielding (value, label) tuples,
returns (value, label, filter_value) so that FilteredSelect can drop filter_value into
the data-filter-value attribute.
"""
def choice(self, obj):
return (
self.field.prepare_value(obj),
self.field.label_from_instance(obj),
self.field.get_filter_value(obj),
)
class FilteredModelChoiceField(django_filters.fields.ModelChoiceField):
"""
A ModelChoiceField that uses FilteredSelect to dynamically show/hide options based on another
ModelChoiceField of related objects; an option will be shown whenever the selected related
object is present in the result of filter_accessor for that option.
filter_field - the HTML `id` of the related ModelChoiceField
filter_accessor - either the name of a relation, property or method on the model instance which
returns a queryset of related objects, or a function which accepts the model instance and
returns such a queryset.
"""
widget = FilteredSelect
iterator = FilteredModelChoiceIterator
def __init__(self, *args, **kwargs):
self.filter_accessor = kwargs.pop("filter_accessor")
filter_field = kwargs.pop("filter_field")
super().__init__(*args, **kwargs)
self.widget.filter_field = filter_field
def get_filter_value(self, obj):
# Use filter_accessor to obtain a queryset of related objects
if callable(self.filter_accessor):
queryset = self.filter_accessor(obj)
else:
# treat filter_accessor as a method/property name of obj
queryset = getattr(obj, self.filter_accessor)
if isinstance(queryset, models.Manager):
queryset = queryset.all()
elif callable(queryset):
queryset = queryset()
# Turn this queryset into a list of IDs that will become the 'data-filter-value' used to
# filter this listing
return queryset.values_list("pk", flat=True)
class FilteredModelChoiceFilter(django_filters.ModelChoiceFilter):
field_class = FilteredModelChoiceField
class LocaleFilter(django_filters.ChoiceFilter):
def filter(self, qs, language_code):
if language_code:
return qs.filter(locale__language_code=language_code)
return qs
class WagtailFilterSet(django_filters.FilterSet):
def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
super().__init__(data, queryset, request=request, prefix=prefix)
if getattr(settings, "WAGTAIL_I18N_ENABLED", False):
self._add_locale_filter()
def _add_locale_filter(self):
# Add a locale filter if the model is translatable
# and there isn't one already.
from wagtail.models.i18n import Locale, TranslatableMixin
if (
self._meta.model
and issubclass(self._meta.model, TranslatableMixin)
and "locale" not in self.filters
):
# Only add the locale filter if there are multiple content languages
# in the settings and the corresponding Locales exist.
languages = get_content_languages()
locales = set(Locale.objects.values_list("language_code", flat=True))
choices = [(k, v) for k, v in languages.items() if k in locales]
if len(choices) <= 1:
return
self.filters["locale"] = LocaleFilter(
label=_("Locale"),
choices=choices,
empty_label=None,
null_label=_("All"),
null_value=None,
widget=forms.RadioSelect,
)
@classmethod
def filter_for_lookup(cls, field, lookup_type):
filter_class, params = super().filter_for_lookup(field, lookup_type)
if filter_class == django_filters.ChoiceFilter:
params.setdefault("widget", forms.RadioSelect)
params.setdefault("empty_label", _("All"))
elif filter_class in [django_filters.DateFilter, django_filters.DateTimeFilter]:
params.setdefault("widget", AdminDateInput)
elif filter_class == django_filters.DateFromToRangeFilter:
params.setdefault("widget", DateRangePickerWidget)
elif filter_class == django_filters.BooleanFilter:
params.setdefault("widget", BooleanRadioSelect)
return filter_class, params
class ContentTypeModelChoiceField(django_filters.fields.ModelChoiceField):
"""
Custom ModelChoiceField for ContentType, to show the model verbose name as the label rather
than the default 'wagtailcore | page' representation of a ContentType
"""
def label_from_instance(self, obj):
return get_content_type_label(obj)
class ContentTypeFilter(django_filters.ModelChoiceFilter):
field_class = ContentTypeModelChoiceField
class ContentTypeModelMultipleChoiceField(
django_filters.fields.ModelMultipleChoiceField
):
"""
Custom ModelMultipleChoiceField for ContentType, to show the model verbose name as the label rather
than the default 'wagtailcore | page' representation of a ContentType
"""
def label_from_instance(self, obj):
return get_content_type_label(obj)
class MultipleContentTypeFilter(django_filters.ModelMultipleChoiceFilter):
field_class = ContentTypeModelMultipleChoiceField
class UserModelMultipleChoiceField(django_filters.fields.ModelMultipleChoiceField):
"""
Custom ModelMultipleChoiceField for user models, to show the result of
get_user_display_name as the label rather than the default string representation
"""
def label_from_instance(self, obj):
return get_user_display_name(obj)
class MultipleUserFilter(django_filters.ModelMultipleChoiceFilter):
field_class = UserModelMultipleChoiceField
class CollectionChoiceIterator(django_filters.fields.ModelChoiceIterator):
@cached_property
def min_depth(self):
return self.queryset.get_min_depth()
def choice(self, obj):
return (obj.pk, obj.get_indented_name(self.min_depth, html=True))
class CollectionChoiceField(django_filters.fields.ModelChoiceField):
iterator = CollectionChoiceIterator
class CollectionFilter(django_filters.ModelChoiceFilter):
field_class = CollectionChoiceField
class PopularTagsFilter(django_filters.MultipleChoiceFilter):
# This uses a MultipleChoiceFilter instead of a ModelMultipleChoiceFilter
# because the queryset has been sliced, which means ModelMultipleChoiceFilter
# cannot do further queries to validate the selected tags.
def __init__(self, *args, use_subquery=False, **kwargs):
super().__init__(*args, **kwargs)
self.use_subquery = use_subquery
def filter(self, qs, value):
filtered = super().filter(qs, value)
if not self.use_subquery:
return filtered
# Workaround for https://github.com/wagtail/wagtail/issues/6616
pks = list(filtered.values_list("pk", flat=True))
return qs.filter(pk__in=pks)
class BaseMediaFilterSet(WagtailFilterSet):
permission_policy = None
def __init__(
self, data=None, queryset=None, *, request=None, prefix=None, is_searching=None
):
super().__init__(data, queryset, request=request, prefix=prefix)
collections_qs = self.permission_policy.collections_user_has_any_permission_for(
request.user, ["add", "change"]
)
# Add collection filter only if there are multiple collections
if collections_qs.count() > 1:
self.filters["collection_id"] = CollectionFilter(
field_name="collection_id",
label=_("Collection"),
queryset=collections_qs,
)
popular_tags = popular_tags_for_model(self._meta.model)
if popular_tags:
self.filters["tag"] = PopularTagsFilter(
label=_("Tag"),
field_name="tags__name",
choices=[(tag.name, tag.name) for tag in popular_tags],
widget=forms.CheckboxSelectMultiple,
use_subquery=is_searching,
help_text=_("Filter by up to ten most popular tags."),
)

View File

@@ -0,0 +1,9 @@
# definitions which are not being deprecated from wagtail.admin.forms
from .models import ( # NOQA: F401
DIRECT_FORM_FIELD_OVERRIDES,
FORM_FIELD_OVERRIDES,
WagtailAdminModelForm,
WagtailAdminModelFormMetaclass,
formfield_for_dbfield,
)
from .pages import WagtailAdminPageForm # NOQA: F401

View File

@@ -0,0 +1,143 @@
import warnings
from operator import itemgetter
import l18n
from django import forms
from django.contrib.auth import get_user_model
from django.db.models.fields import BLANK_CHOICE_DASH
from django.utils.translation import get_language_info
from django.utils.translation import gettext_lazy as _
from wagtail.admin.localization import (
get_available_admin_languages,
get_available_admin_time_zones,
)
from wagtail.admin.widgets import SwitchInput
from wagtail.permissions import page_permission_policy
from wagtail.users.models import UserProfile
User = get_user_model()
class NotificationPreferencesForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
permission_policy = page_permission_policy
if not permission_policy.user_has_permission(self.instance.user, "publish"):
del self.fields["submitted_notifications"]
if not permission_policy.user_has_permission(self.instance.user, "change"):
del self.fields["approved_notifications"]
del self.fields["rejected_notifications"]
del self.fields["updated_comments_notifications"]
class Meta:
model = UserProfile
fields = [
"submitted_notifications",
"approved_notifications",
"rejected_notifications",
"updated_comments_notifications",
]
widgets = {
"submitted_notifications": SwitchInput(),
"approved_notifications": SwitchInput(),
"rejected_notifications": SwitchInput(),
"updated_comments_notifications": SwitchInput(),
}
def _get_language_choices():
language_choices = [
(lang_code, get_language_info(lang_code)["name_local"])
for lang_code, lang_name in get_available_admin_languages()
]
return sorted(
BLANK_CHOICE_DASH + language_choices,
key=lambda language_choice: language_choice[1].lower(),
)
def _get_time_zone_choices():
time_zones = [
(tz, str(l18n.tz_fullnames.get(tz, tz)))
for tz in get_available_admin_time_zones()
]
time_zones.sort(key=itemgetter(1))
return BLANK_CHOICE_DASH + time_zones
class LocalePreferencesForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if len(get_available_admin_languages()) <= 1:
del self.fields["preferred_language"]
if len(get_available_admin_time_zones()) <= 1:
del self.fields["current_time_zone"]
preferred_language = forms.ChoiceField(
required=False, choices=_get_language_choices, label=_("Preferred language")
)
current_time_zone = forms.ChoiceField(
required=False, choices=_get_time_zone_choices, label=_("Current time zone")
)
class Meta:
model = UserProfile
fields = ["preferred_language", "current_time_zone"]
class NameEmailForm(forms.ModelForm):
first_name = forms.CharField(required=True, label=_("First Name"))
last_name = forms.CharField(required=True, label=_("Last Name"))
email = forms.EmailField(required=True, label=_("Email"))
def __init__(self, *args, **kwargs):
from wagtail.admin.views.account import email_management_enabled
super().__init__(*args, **kwargs)
if not email_management_enabled():
del self.fields["email"]
class Meta:
model = User
fields = ["first_name", "last_name", "email"]
class AvatarPreferencesForm(forms.ModelForm):
avatar = forms.ImageField(label=_("Upload a profile picture"), required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._original_avatar = self.instance.avatar
def save(self, commit=True):
if (
commit
and self._original_avatar
and (self._original_avatar != self.cleaned_data["avatar"])
):
# Call delete() on the storage backend directly, as calling self._original_avatar.delete()
# will clear the now-updated field on self.instance too
try:
self._original_avatar.storage.delete(self._original_avatar.name)
except OSError:
# failure to delete the old avatar shouldn't prevent us from continuing
warnings.warn(
"Failed to delete old avatar file: %s" % self._original_avatar.name
)
super().save(commit=commit)
class Meta:
model = UserProfile
fields = ["avatar"]
class ThemePreferencesForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ["theme", "density"]

View File

@@ -0,0 +1,81 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.forms import PasswordChangeForm as DjangoPasswordChangeForm
from django.contrib.auth.forms import PasswordResetForm as DjangoPasswordResetForm
from django.utils.translation import gettext_lazy
class LoginForm(AuthenticationForm):
username = forms.CharField(max_length=254, widget=forms.TextInput())
password = forms.CharField(
widget=forms.PasswordInput(
attrs={
"placeholder": gettext_lazy("Enter password"),
}
),
strip=False,
)
remember = forms.BooleanField(required=False)
error_messages = {
**AuthenticationForm.error_messages,
"invalid_login": gettext_lazy(
"Your %(username_field)s and password didn't match. Please try again."
),
}
def __init__(self, request=None, *args, **kwargs):
super().__init__(request=request, *args, **kwargs)
self.fields["username"].widget.attrs["placeholder"] = gettext_lazy(
"Enter your %(username_field_name)s"
) % {"username_field_name": self.username_field.verbose_name}
self.fields["username"].widget.attrs["autofocus"] = ""
@property
def extra_fields(self):
for field_name in self.fields.keys():
if field_name not in ["username", "password", "remember"]:
yield field_name, self[field_name]
def get_invalid_login_error(self):
return forms.ValidationError(
self.error_messages["invalid_login"],
code="invalid_login",
params={"username_field": self.username_field.verbose_name},
)
class PasswordResetForm(DjangoPasswordResetForm):
email = forms.EmailField(
label=gettext_lazy("Enter your email address to reset your password"),
max_length=254,
required=True,
)
@property
def extra_fields(self):
for field_name in self.fields.keys():
if field_name not in ["email"]:
yield field_name, self[field_name]
class PasswordChangeForm(DjangoPasswordChangeForm):
"""
Since this is displayed as part of a larger form, this differs from the vanilla Django
PasswordChangeForm as follows:
* the old-password field is not auto-focused
* Fields are not marked as required
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
try:
del self.fields["old_password"].widget.attrs["autofocus"]
except KeyError:
pass
self.fields["old_password"].required = False
self.fields["new_password1"].required = False
self.fields["new_password2"].required = False

View File

@@ -0,0 +1,151 @@
import warnings
from django import forms
from django.core import validators
from django.forms.widgets import TextInput
from django.utils.translation import gettext_lazy as _
from wagtail.models import Locale
from wagtail.search.backends import get_search_backend
class URLOrAbsolutePathValidator(validators.URLValidator):
@staticmethod
def is_absolute_path(value):
return value.startswith("/")
def __call__(self, value):
if URLOrAbsolutePathValidator.is_absolute_path(value):
return None
else:
return super().__call__(value)
class URLOrAbsolutePathField(forms.URLField):
widget = TextInput
default_validators = [URLOrAbsolutePathValidator()]
def to_python(self, value):
if not URLOrAbsolutePathValidator.is_absolute_path(value):
value = super().to_python(value)
return value
class ExternalLinkChooserForm(forms.Form):
url = URLOrAbsolutePathField(required=True, label=_("URL"))
link_text = forms.CharField(required=False)
class AnchorLinkChooserForm(forms.Form):
url = forms.CharField(required=True, label="#")
link_text = forms.CharField(required=False)
class EmailLinkChooserForm(forms.Form):
email_address = forms.EmailField(required=True)
link_text = forms.CharField(required=False)
subject = forms.CharField(required=False)
body = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
class PhoneLinkChooserForm(forms.Form):
phone_number = forms.CharField(required=True)
link_text = forms.CharField(required=False)
class BaseFilterForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.is_searching = False
self.is_filtering_by_collection = False
self.search_query = None
def filter(self, objects):
return objects
class SearchFilterMixin(forms.Form):
"""
Mixin for a chooser listing filter form, to provide a search field
"""
q = forms.CharField(
label=_("Search term"),
widget=forms.TextInput(attrs={"placeholder": _("Search")}),
required=False,
)
def filter(self, objects):
objects = super().filter(objects)
search_query = self.cleaned_data.get("q")
if search_query:
search_backend = get_search_backend()
if objects.model.get_autocomplete_search_fields():
objects = search_backend.autocomplete(search_query, objects)
else:
# fall back on non-autocompleting search
warnings.warn(
f"{objects.model} is defined as Indexable but does not specify "
"any AutocompleteFields. Searches within the chooser will only "
"respond to complete words.",
category=RuntimeWarning,
)
objects = search_backend.search(search_query, objects)
self.is_searching = True
self.search_query = search_query
return objects
class CollectionFilterMixin(forms.Form):
"""
Mixin for a chooser listing filter form, to provide a collection filter field.
The view must pass a `collections` keyword argument when constructing the form
"""
def __init__(self, *args, collections=None, **kwargs):
super().__init__(*args, **kwargs)
if collections:
collection_choices = [
("", _("All collections"))
] + collections.get_indented_choices()
self.fields["collection_id"] = forms.ChoiceField(
label=_("Collection"),
choices=collection_choices,
required=False,
widget=forms.Select(attrs={"data-chooser-modal-search-filter": True}),
)
def filter(self, objects):
collection_id = self.cleaned_data.get("collection_id")
if collection_id:
self.is_filtering_by_collection = True
objects = objects.filter(collection=collection_id)
return super().filter(objects)
class LocaleFilterMixin(forms.Form):
"""
Mixin for a chooser listing filter form, to provide a locale filter field.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
locales = Locale.objects.all()
if locales:
self.fields["locale"] = forms.ChoiceField(
choices=[
(locale.language_code, locale.get_display_name())
for locale in locales
],
required=False,
widget=forms.Select(attrs={"data-chooser-modal-search-filter": True}),
)
def filter(self, objects):
selected_locale_code = self.cleaned_data.get("locale")
if selected_locale_code:
selected_locale = Locale.objects.get(language_code=selected_locale_code)
objects = objects.filter(locale=selected_locale)
return super().filter(objects)

View File

@@ -0,0 +1,384 @@
from itertools import groupby
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group, Permission
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Min
from django.template.loader import render_to_string
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from wagtail.models import (
Collection,
CollectionViewRestriction,
GroupCollectionPermission,
)
from .view_restrictions import BaseViewRestrictionForm
class CollectionViewRestrictionForm(BaseViewRestrictionForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not getattr(settings, "WAGTAILDOCS_PRIVATE_COLLECTION_OPTIONS", {}).get(
"SHARED_PASSWORD",
True,
):
self.fields["restriction_type"].choices = [
choice
for choice in CollectionViewRestriction.RESTRICTION_CHOICES
if choice[0] != CollectionViewRestriction.PASSWORD
]
del self.fields["password"]
class Meta:
model = CollectionViewRestriction
fields = ("restriction_type", "password", "groups")
class SelectWithDisabledOptions(forms.Select):
"""
Subclass of Django's select widget that allows disabling options.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.disabled_values = ()
def create_option(self, name, value, *args, **kwargs):
option_dict = super().create_option(name, value, *args, **kwargs)
if value in self.disabled_values:
option_dict["attrs"]["disabled"] = "disabled"
return option_dict
class CollectionChoiceField(forms.ModelChoiceField):
widget = SelectWithDisabledOptions
def __init__(self, *args, disabled_queryset=None, **kwargs):
super().__init__(*args, **kwargs)
self._indentation_start_depth = 2
self.disabled_queryset = disabled_queryset
def _get_disabled_queryset(self):
return self._disabled_queryset
def _set_disabled_queryset(self, queryset):
self._disabled_queryset = queryset
if queryset is None:
self.widget.disabled_values = ()
else:
self.widget.disabled_values = queryset.values_list(
self.to_field_name or "pk", flat=True
)
disabled_queryset = property(_get_disabled_queryset, _set_disabled_queryset)
def _set_queryset(self, queryset):
min_depth = self.queryset.aggregate(Min("depth"))["depth__min"]
if min_depth is None:
self._indentation_start_depth = 2
else:
self._indentation_start_depth = min_depth + 1
def label_from_instance(self, obj):
return obj.get_indented_name(self._indentation_start_depth, html=True)
class CollectionForm(forms.ModelForm):
parent = CollectionChoiceField(
label=gettext_lazy("Parent"),
queryset=Collection.objects.all(),
required=True,
help_text=gettext_lazy(
"Select hierarchical position. Note: a collection cannot become a child of itself or one of its "
"descendants."
),
)
class Meta:
model = Collection
fields = ("name",)
def clean_parent(self):
"""
Our rules about where a user may add or move a collection are as follows:
1. The user must have 'add' permission on the parent collection (or its ancestors)
2. We are not moving a collection used to assign permissions for this user
3. We are not trying to move a collection to be parented by one of their descendants
The first 2 items are taken care in the Create and Edit views by deleting the 'parent' field
from the edit form if the user cannot move the collection. This causes Django's form
machinery to ignore the parent field for parent regardless of what the user submits.
This methods enforces rule #3 when we are editing an existing collection.
"""
parent = self.cleaned_data["parent"]
if not self.instance._state.adding and not parent.pk == self.initial.get(
"parent"
):
old_descendants = list(
self.instance.get_descendants(inclusive=True).values_list(
"pk", flat=True
)
)
if parent.pk in old_descendants:
raise ValidationError(gettext_lazy("Please select another parent"))
return parent
class BaseCollectionMemberForm(forms.ModelForm):
"""
Abstract form handler for editing models that belong to a collection,
such as documents and images. These forms are (optionally) instantiated
with a 'user' kwarg, and take care of populating the 'collection' field's
choices with the collections the user has permission for, as well as
hiding the field when only one collection is available.
Subclasses must define a 'permission_policy' attribute.
"""
def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if user is None:
self.collections = Collection.objects.all()
else:
self.collections = (
self.permission_policy.collections_user_has_permission_for(user, "add")
)
if self.instance.pk:
# editing an existing document; ensure that the list of available collections
# includes its current collection
self.collections = self.collections | Collection.objects.filter(
id=self.instance.collection_id
)
if len(self.collections) == 0:
raise Exception(
"Cannot construct %s for a user with no collection permissions"
% type(self)
)
elif len(self.collections) == 1:
# don't show collection field if only one collection is available
del self.fields["collection"]
else:
self.fields["collection"].queryset = self.collections
def save(self, commit=True):
if len(self.collections) == 1:
# populate the instance's collection field with the one available collection
self.instance.collection = self.collections[0]
return super().save(commit=commit)
class BaseGroupCollectionMemberPermissionFormSet(forms.BaseFormSet):
"""
A base formset class for managing GroupCollectionPermissions for a
model with CollectionMember behaviour. Subclasses should provide attributes:
permission_types - a list of (codename, short_label, long_label) tuples for the permissions
being managed here
permission_queryset - a queryset of Permission objects for the above permissions
default_prefix - prefix to use on form fields if one is not specified in __init__
template = template filename
"""
def __init__(self, data=None, files=None, instance=None, prefix=None):
if prefix is None:
prefix = self.default_prefix
if instance is None:
instance = Group()
if instance.pk is None:
full_collection_permissions = []
else:
full_collection_permissions = (
instance.collection_permissions.filter(
permission__in=self.permission_queryset
)
.select_related("permission__content_type", "collection")
.order_by("collection")
)
self.instance = instance
initial_data = []
for collection, collection_permissions in groupby(
full_collection_permissions,
lambda cp: cp.collection,
):
initial_data.append(
{
"collection": collection,
"permissions": [cp.permission for cp in collection_permissions],
}
)
super().__init__(data, files, initial=initial_data, prefix=prefix)
for form in self.forms:
form.fields["DELETE"].widget = forms.HiddenInput()
@property
def empty_form(self):
empty_form = super().empty_form
empty_form.fields["DELETE"].widget = forms.HiddenInput()
return empty_form
def clean(self):
"""Checks that no two forms refer to the same collection object"""
if any(self.errors):
# Don't bother validating the formset unless each form is valid on its own
return
collections = [
form.cleaned_data["collection"]
for form in self.forms
# need to check for presence of 'collection' in cleaned_data,
# because a completely blank form passes validation
if form not in self.deleted_forms and "collection" in form.cleaned_data
]
if len(set(collections)) != len(collections):
# collections list contains duplicates
raise forms.ValidationError(
_(
"You cannot have multiple permission records for the same collection."
)
)
@transaction.atomic
def save(self):
if self.instance.pk is None:
raise Exception(
"Cannot save a GroupCollectionMemberPermissionFormSet "
"for an unsaved group instance"
)
# get a set of (collection, permission) tuples for all ticked permissions
forms_to_save = [
form
for form in self.forms
if form not in self.deleted_forms and "collection" in form.cleaned_data
]
final_permission_records = set()
for form in forms_to_save:
for permission in form.cleaned_data["permissions"]:
final_permission_records.add(
(form.cleaned_data["collection"], permission)
)
# fetch the group's existing collection permission records for this model,
# and from that, build a list of records to be created / deleted
permission_ids_to_delete = []
permission_records_to_keep = set()
for cp in self.instance.collection_permissions.filter(
permission__in=self.permission_queryset,
):
if (cp.collection, cp.permission) in final_permission_records:
permission_records_to_keep.add((cp.collection, cp.permission))
else:
permission_ids_to_delete.append(cp.id)
self.instance.collection_permissions.filter(
id__in=permission_ids_to_delete
).delete()
permissions_to_add = final_permission_records - permission_records_to_keep
GroupCollectionPermission.objects.bulk_create(
[
GroupCollectionPermission(
group=self.instance, collection=collection, permission=permission
)
for (collection, permission) in permissions_to_add
]
)
def as_admin_panel(self):
return render_to_string(
self.template,
{"formset": self},
)
def collection_member_permission_formset_factory(
model, permission_types, template, default_prefix=None
):
permission_queryset = Permission.objects.filter(
content_type__app_label=model._meta.app_label,
codename__in=[
codename for codename, short_label, long_label in permission_types
],
).select_related("content_type")
if default_prefix is None:
default_prefix = "%s_permissions" % model._meta.model_name
class PermissionMultipleChoiceField(forms.ModelMultipleChoiceField):
"""
Allows the custom labels from ``permission_types`` to be applied to
permission checkboxes for the ``CollectionMemberPermissionsForm`` below
"""
def label_from_instance(self, obj):
for codename, short_label, long_label in permission_types:
if codename == obj.codename:
return long_label
return str(obj)
class CollectionMemberPermissionsForm(forms.Form):
"""
For a given model with CollectionMember behaviour,
defines the permissions that are assigned to an entity
(such as a group or user) for a specific collection
"""
collection = CollectionChoiceField(
label=_("Collection"),
queryset=Collection.objects.all().prefetch_related("group_permissions"),
empty_label=None,
)
permissions = PermissionMultipleChoiceField(
queryset=permission_queryset,
required=False,
widget=forms.CheckboxSelectMultiple,
)
GroupCollectionMemberPermissionFormSet = type(
"GroupCollectionMemberPermissionFormSet",
(BaseGroupCollectionMemberPermissionFormSet,),
{
"permission_types": permission_types,
"permission_queryset": permission_queryset,
"default_prefix": default_prefix,
"template": template,
},
)
return forms.formset_factory(
CollectionMemberPermissionsForm,
formset=GroupCollectionMemberPermissionFormSet,
extra=0,
can_delete=True,
)
GroupCollectionManagementPermissionFormSet = (
collection_member_permission_formset_factory(
Collection,
[
("add_collection", _("Add"), _("Add collections")),
("change_collection", _("Edit"), _("Edit collections")),
("delete_collection", _("Delete"), _("Delete collections")),
],
"wagtailadmin/permissions/includes/collection_management_permissions_form.html",
)
)

View File

@@ -0,0 +1,87 @@
from django.forms import BooleanField, ValidationError
from django.utils.timezone import now
from django.utils.translation import gettext as _
from modelcluster.forms import BaseChildFormSet
from .models import WagtailAdminModelForm
class CommentReplyForm(WagtailAdminModelForm):
class Meta:
fields = ("text",)
def clean(self):
cleaned_data = super().clean()
user = self.for_user
if not self.instance.pk:
self.instance.user = user
elif self.instance.user != user:
# trying to edit someone else's comment reply
if any(field for field in self.changed_data):
# includes DELETION_FIELD_NAME, as users cannot delete each other's individual comment replies
# if deleting a whole thread, this should be done by deleting the parent Comment instead
self.add_error(
None, ValidationError(_("You cannot edit another user's comment."))
)
return cleaned_data
class CommentForm(WagtailAdminModelForm):
"""
This is designed to be subclassed and have the user overridden to enable user-based validation within the edit handler system
"""
resolved = BooleanField(required=False)
class Meta:
formsets = {
"replies": {
"form": CommentReplyForm,
"inherit_kwargs": ["for_user"],
}
}
def clean(self):
cleaned_data = super().clean()
user = self.for_user
if not self.instance.pk:
self.instance.user = user
elif self.instance.user != user:
# trying to edit someone else's comment
if (
any(
field
for field in self.changed_data
if field not in ["resolved", "position", "contentpath"]
)
or cleaned_data["contentpath"].split(".")[0]
!= self.instance.contentpath.split(".")[0]
):
# users can resolve each other's base comments and change their positions within a field, or move a comment between blocks in a StreamField
self.add_error(
None, ValidationError(_("You cannot edit another user's comment."))
)
return cleaned_data
def save(self, *args, **kwargs):
if self.cleaned_data.get("resolved", False):
if not getattr(self.instance, "resolved_at"):
self.instance.resolved_at = now()
self.instance.resolved_by = self.for_user
else:
self.instance.resolved_by = None
self.instance.resolved_at = None
return super().save(*args, **kwargs)
class CommentFormSet(BaseChildFormSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
valid_comment_ids = [
comment.id
for comment in self.queryset
if comment.has_valid_contentpath(self.instance)
]
self.queryset = self.queryset.filter(id__in=valid_comment_ids)

View File

@@ -0,0 +1,169 @@
import copy
from django import forms
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext as _
from modelcluster.forms import ClusterForm, ClusterFormMetaclass, ClusterFormOptions
from permissionedforms import (
PermissionedForm,
PermissionedFormMetaclass,
PermissionedFormOptionsMixin,
)
from taggit.managers import TaggableManager
from wagtail.admin import widgets
from wagtail.admin.forms.tags import TagField
from wagtail.models import Page
from wagtail.utils.registry import ModelFieldRegistry
# Define a registry of form field properties to override for a given model field
registry = ModelFieldRegistry()
# Aliases to lookups in the overrides registry, for backwards compatibility
FORM_FIELD_OVERRIDES = registry.values_by_class
DIRECT_FORM_FIELD_OVERRIDES = registry.values_by_exact_class
def register_form_field_override(
db_field_class, to=None, override=None, exact_class=False
):
"""
Define parameters for form fields to be used by WagtailAdminModelForm for a given
database field.
"""
if override is None:
raise ImproperlyConfigured(
"register_form_field_override must be passed an 'override' keyword argument"
)
if to and db_field_class != models.ForeignKey:
raise ImproperlyConfigured(
"The 'to' argument on register_form_field_override is only valid for ForeignKey fields"
)
registry.register(db_field_class, to=to, value=override, exact_class=exact_class)
# Define built-in overrides
# Date / time fields
register_form_field_override(
models.DateField, override={"widget": widgets.AdminDateInput}
)
register_form_field_override(
models.TimeField, override={"widget": widgets.AdminTimeInput}
)
register_form_field_override(
models.DateTimeField, override={"widget": widgets.AdminDateTimeInput}
)
# Auto-height text fields (defined as exact_class=True so that it doesn't take effect for RichTextField)
register_form_field_override(
models.TextField,
override={"widget": widgets.AdminAutoHeightTextInput},
exact_class=True,
)
# Page chooser
register_form_field_override(
models.ForeignKey,
to=Page,
override=lambda db_field: {
"widget": widgets.AdminPageChooser(target_models=[db_field.remote_field.model])
},
)
# Tag fields
register_form_field_override(
TaggableManager,
override=(
lambda db_field: {"form_class": TagField, "tag_model": db_field.related_model}
),
)
# Slug fields
register_form_field_override(
models.SlugField,
override={"widget": widgets.SlugInput},
)
# Callback to allow us to override the default form fields provided for each model field.
def formfield_for_dbfield(db_field, **kwargs):
overrides = registry.get(db_field)
if overrides:
kwargs = dict(copy.deepcopy(overrides), **kwargs)
return db_field.formfield(**kwargs)
class WagtailAdminModelFormOptions(PermissionedFormOptionsMixin, ClusterFormOptions):
# Container for the options set in the inner 'class Meta' of a model form, supporting
# extensions for both ClusterForm ('formsets') and PermissionedForm ('field_permissions').
pass
class WagtailAdminModelFormMetaclass(PermissionedFormMetaclass, ClusterFormMetaclass):
options_class = WagtailAdminModelFormOptions
# set extra_form_count to 0, as we're creating extra forms in JS
extra_form_count = 0
@classmethod
def child_form(cls):
return WagtailAdminModelForm
class WagtailAdminModelForm(
PermissionedForm, ClusterForm, metaclass=WagtailAdminModelFormMetaclass
):
def __init__(self, *args, **kwargs):
# keep hold of the `for_user` kwarg as well as passing it on to PermissionedForm
self.for_user = kwargs.get("for_user")
super().__init__(*args, **kwargs)
class Meta:
formfield_callback = formfield_for_dbfield
# Now, any model forms built off WagtailAdminModelForm instead of ModelForm should pick up
# the nice form fields defined in FORM_FIELD_OVERRIDES.
class WagtailAdminDraftStateFormMixin:
@property
def show_schedule_publishing_toggle(self):
return "go_live_at" in self.__class__.base_fields
def clean(self):
super().clean()
# Check scheduled publishing fields
go_live_at = self.cleaned_data.get("go_live_at")
expire_at = self.cleaned_data.get("expire_at")
# Go live must be before expire
if go_live_at and expire_at:
if go_live_at > expire_at:
msg = _("Go live date/time must be before expiry date/time")
self.add_error("go_live_at", forms.ValidationError(msg))
self.add_error("expire_at", forms.ValidationError(msg))
# Expire at must be in the future
if expire_at and expire_at < timezone.now():
self.add_error(
"expire_at",
forms.ValidationError(_("Expiry date/time must be in the future")),
)
# Don't allow an existing first_published_at to be unset by clearing the field
if (
"first_published_at" in self.cleaned_data
and not self.cleaned_data["first_published_at"]
):
del self.cleaned_data["first_published_at"]
return self.cleaned_data

View File

@@ -0,0 +1,276 @@
from django import forms
from django.conf import settings
from django.utils.translation import gettext as _
from django.utils.translation import ngettext
from wagtail.admin import widgets
from wagtail.models import Page, PageViewRestriction
from .models import WagtailAdminModelForm
from .view_restrictions import BaseViewRestrictionForm
class CopyForm(forms.Form):
def __init__(self, *args, **kwargs):
# CopyPage must be passed a 'page' kwarg indicating the page to be copied
self.page = kwargs.pop("page")
self.user = kwargs.pop("user", None)
can_publish = kwargs.pop("can_publish")
super().__init__(*args, **kwargs)
self.fields["new_title"] = forms.CharField(
initial=self.page.title, label=_("New title")
)
allow_unicode = getattr(settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True)
self.fields["new_slug"] = forms.SlugField(
initial=self.page.slug,
label=_("New slug"),
allow_unicode=allow_unicode,
widget=widgets.SlugInput,
)
self.fields["new_parent_page"] = forms.ModelChoiceField(
initial=self.page.get_parent(),
queryset=Page.objects.all(),
widget=widgets.AdminPageChooser(can_choose_root=True, user_perms="copy_to"),
label=_("New parent page"),
help_text=_("This copy will be a child of this given parent page."),
)
pages_to_copy = self.page.get_descendants(inclusive=True)
subpage_count = pages_to_copy.count() - 1
if subpage_count > 0:
self.fields["copy_subpages"] = forms.BooleanField(
required=False,
initial=True,
label=_("Copy subpages"),
help_text=ngettext(
"This will copy %(count)s subpage.",
"This will copy %(count)s subpages.",
subpage_count,
)
% {"count": subpage_count},
)
if can_publish:
pages_to_publish_count = pages_to_copy.live().count()
if pages_to_publish_count > 0:
# In the specific case that there are no subpages, customise the field label and help text
if subpage_count == 0:
label = _("Publish copied page")
help_text = _(
"This page is live. Would you like to publish its copy as well?"
)
else:
label = _("Publish copies")
help_text = ngettext(
"%(count)s of the pages being copied is live. Would you like to publish its copy?",
"%(count)s of the pages being copied are live. Would you like to publish their copies?",
pages_to_publish_count,
) % {"count": pages_to_publish_count}
self.fields["publish_copies"] = forms.BooleanField(
required=False, initial=False, label=label, help_text=help_text
)
# Note that only users who can publish in the new parent page can create an alias.
# This is because alias pages must always match their original page's state.
self.fields["alias"] = forms.BooleanField(
required=False,
initial=False,
label=_("Alias"),
help_text=_("Keep the new pages updated with future changes"),
)
def clean(self):
cleaned_data = super().clean()
# Make sure the slug isn't already in use
slug = cleaned_data.get("new_slug")
# New parent page given in form or parent of source, if parent_page is empty
parent_page = cleaned_data.get("new_parent_page") or self.page.get_parent()
# check if user is allowed to create a page at given location.
if not parent_page.permissions_for_user(self.user).can_add_subpage():
self._errors["new_parent_page"] = self.error_class(
[
_('You do not have permission to copy to page "%(page_title)s"')
% {
"page_title": parent_page.specific_deferred.get_admin_display_title()
}
]
)
# Count the pages with the same slug within the context of our copy's parent page
if slug and parent_page.get_children().filter(slug=slug).count():
self._errors["new_slug"] = self.error_class(
[
_(
'This slug is already in use within the context of its parent page "%(parent_page_title)s"'
)
% {"parent_page_title": parent_page}
]
)
# The slug is no longer valid, hence remove it from cleaned_data
del cleaned_data["new_slug"]
# Don't allow recursive copies into self
if cleaned_data.get("copy_subpages") and (
self.page == parent_page or parent_page.is_descendant_of(self.page)
):
self._errors["new_parent_page"] = self.error_class(
[_("You cannot copy a page into itself when copying subpages")]
)
return cleaned_data
class PageViewRestrictionForm(BaseViewRestrictionForm):
def __init__(self, *args, **kwargs):
# get the list of private page options from the page
private_page_options = kwargs.pop("private_page_options", [])
super().__init__(*args, **kwargs)
if not getattr(settings, "WAGTAIL_PRIVATE_PAGE_OPTIONS", {}).get(
"SHARED_PASSWORD", True
):
self.fields["restriction_type"].choices = [
choice
for choice in PageViewRestriction.RESTRICTION_CHOICES
if choice[0] != PageViewRestriction.PASSWORD
]
del self.fields["password"]
# Remove the fields that are not allowed for the page
self.fields["restriction_type"].choices = [
choice
for choice in self.fields["restriction_type"].choices
if choice[0] in private_page_options
or choice[0] == PageViewRestriction.NONE
]
class Meta:
model = PageViewRestriction
fields = ("restriction_type", "password", "groups")
class WagtailAdminPageForm(WagtailAdminModelForm):
comment_notifications = forms.BooleanField(
widget=forms.CheckboxInput(), required=False
)
def __init__(
self,
data=None,
files=None,
parent_page=None,
subscription=None,
*args,
**kwargs,
):
self.subscription = subscription
initial = kwargs.pop("initial", {})
if self.subscription:
initial["comment_notifications"] = subscription.comment_notifications
super().__init__(data, files, *args, initial=initial, **kwargs)
self.parent_page = parent_page
if not self.show_comments_toggle:
del self.fields["comment_notifications"]
@property
def show_comments_toggle(self):
return "comments" in self.__class__.formsets
def save(self, commit=True):
# Save comment notifications updates to PageSubscription
if self.show_comments_toggle and self.subscription:
self.subscription.comment_notifications = self.cleaned_data[
"comment_notifications"
]
if commit:
self.subscription.save()
return super().save(commit=commit)
def is_valid(self):
comments = self.formsets.get("comments")
# Remove the comments formset if the management form is invalid
if comments and not comments.management_form.is_valid():
del self.formsets["comments"]
return super().is_valid()
def clean(self):
cleaned_data = super().clean()
if "slug" in self.cleaned_data:
page_slug = cleaned_data["slug"]
if not Page._slug_is_available(page_slug, self.parent_page, self.instance):
self.add_error(
"slug",
forms.ValidationError(
_(
"The slug '%(page_slug)s' is already in use within the parent page"
)
% {"page_slug": page_slug}
),
)
return cleaned_data
class MoveForm(forms.Form):
def __init__(self, *args, **kwargs):
self.page_to_move = kwargs.pop("page_to_move")
self.target_parent_models = kwargs.pop("target_parent_models")
super().__init__(*args, **kwargs)
self.fields["new_parent_page"] = forms.ModelChoiceField(
initial=self.page_to_move.get_parent(),
queryset=Page.objects.all(),
widget=widgets.AdminPageMoveChooser(
can_choose_root=True,
user_perms="move_to",
target_models=self.target_parent_models,
pages_to_move=[self.page_to_move.pk],
),
label=_("New parent page"),
help_text=_("Select a new parent for this page."),
)
class ParentChooserForm(forms.Form):
def __init__(self, child_page_type, user, *args, **kwargs):
self.child_page_type = child_page_type
self.user = user
super().__init__(*args, **kwargs)
self.fields["parent_page"] = forms.ModelChoiceField(
queryset=Page.objects.all(),
widget=widgets.AdminPageChooser(
target_models=self.child_page_type.allowed_parent_page_models(),
can_choose_root=True,
user_perms="add_subpage",
),
label=_("Parent page"),
help_text=_("The new page will be a child of this given parent page."),
)
def clean_parent_page(self):
parent_page = self.cleaned_data["parent_page"].specific_deferred
if not parent_page.permissions_for_user(self.user).can_add_subpage():
raise forms.ValidationError(
_('You do not have permission to create a page under "%(page_title)s".')
% {"page_title": parent_page.get_admin_display_title()}
)
if not self.child_page_type.can_create_at(parent_page):
raise forms.ValidationError(
_(
'You cannot create a page of type "%(page_type)s" under "%(page_title)s".'
)
% {
"page_type": self.child_page_type.get_verbose_name(),
"page_title": parent_page.get_admin_display_title(),
}
)
return parent_page

View File

@@ -0,0 +1,19 @@
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
class SearchForm(forms.Form):
def __init__(self, *args, **kwargs):
placeholder = kwargs.pop("placeholder", _("Search…"))
super().__init__(*args, **kwargs)
self.fields["q"].widget.attrs = {
"placeholder": placeholder,
"data-w-swap-target": "input",
}
q = forms.CharField(
label=gettext_lazy("Search term"),
widget=forms.TextInput(),
required=False,
)

View File

@@ -0,0 +1,68 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from taggit.forms import TagField as TaggitTagField
from taggit.models import Tag, TagBase
from wagtail.admin.widgets import AdminTagWidget
def validate_tag_length(
value, max_tag_length=TagBase._meta.get_field("name").max_length
):
if not value:
return
value_too_long = ""
for val in value:
if len(val) > max_tag_length:
if value_too_long:
value_too_long += ", "
value_too_long += val
if value_too_long:
raise ValidationError(
_("Tag(s) %(value_too_long)s are over %(max_tag_length)d characters")
% {
"value_too_long": value_too_long,
"max_tag_length": max_tag_length,
}
)
class TagField(TaggitTagField):
"""
Extends taggit's TagField with the option to prevent creating tags that do not already exist
"""
widget = AdminTagWidget
def __init__(self, *args, **kwargs):
self.tag_model = kwargs.pop("tag_model", None)
self.free_tagging = kwargs.pop("free_tagging", None)
super().__init__(*args, **kwargs)
# pass on tag_model and free_tagging kwargs to the widget,
# if (and only if) they have been passed explicitly here.
# Otherwise, set default values for clean() to use
if self.tag_model is None:
self.tag_model = Tag
else:
self.widget.tag_model = self.tag_model
if self.free_tagging is None:
self.free_tagging = getattr(self.tag_model, "free_tagging", True)
else:
self.widget.free_tagging = self.free_tagging
def clean(self, value):
value = super().clean(value)
validate_tag_length(value, self.tag_model.name.field.max_length)
if not self.free_tagging:
# filter value to just the tags that already exist in tag_model
value = list(
self.tag_model.objects.filter(name__in=value).values_list(
"name", flat=True
)
)
return value

View File

@@ -0,0 +1,44 @@
from django import forms
from django.contrib.auth.models import Group
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from wagtail.models import BaseViewRestriction
class BaseViewRestrictionForm(forms.ModelForm):
restriction_type = forms.ChoiceField(
label=gettext_lazy("Visibility"),
choices=BaseViewRestriction.RESTRICTION_CHOICES,
widget=forms.RadioSelect,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["groups"].widget = forms.CheckboxSelectMultiple()
self.fields["groups"].queryset = Group.objects.all()
def clean_password(self):
password = self.cleaned_data.get("password")
if (
self.cleaned_data.get("restriction_type") == BaseViewRestriction.PASSWORD
and not password
):
raise forms.ValidationError(_("This field is required."), code="invalid")
return password
def clean_groups(self):
groups = self.cleaned_data.get("groups")
if (
self.cleaned_data.get("restriction_type") == BaseViewRestriction.GROUPS
and not groups
):
raise forms.ValidationError(
_("Please select at least one group."), code="invalid"
)
return groups
class Meta:
model = BaseViewRestriction
fields = ("restriction_type", "password", "groups")

View File

@@ -0,0 +1,326 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import transaction
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.text import capfirst
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from wagtail.admin import widgets
from wagtail.admin.forms import WagtailAdminModelForm
from wagtail.admin.panels import FieldPanel, InlinePanel, ObjectList
from wagtail.admin.widgets.workflows import AdminTaskChooser
from wagtail.coreutils import get_content_type_label, get_model_string
from wagtail.models import Page, Task, Workflow, WorkflowContentType, WorkflowPage
from wagtail.snippets.models import get_workflow_enabled_models
class TaskChooserSearchForm(forms.Form):
q = forms.CharField(
label=gettext_lazy("Search term"), widget=forms.TextInput(), required=False
)
def __init__(self, *args, task_type_choices=None, **kwargs):
placeholder = kwargs.pop("placeholder", _("Search…"))
super().__init__(*args, **kwargs)
self.fields["q"].widget.attrs = {"placeholder": placeholder}
# Add task type filter if there is more than one task type option
if task_type_choices and len(task_type_choices) > 1:
self.fields["task_type"] = forms.ChoiceField(
choices=(
# Append an "All types" choice to the beginning
[(None, _("All types"))]
# The task type choices that are passed in use the models as values, we need
# to convert these to something that can be represented in HTML
+ [
(get_model_string(model), verbose_name)
for model, verbose_name in task_type_choices
]
),
required=False,
)
# Save a mapping of task_type values back to the model that we can reference later
self.task_type_choices = {
get_model_string(model): model for model, verbose_name in task_type_choices
}
def is_searching(self):
"""
Returns True if the user typed a search query
"""
return self.is_valid() and bool(self.cleaned_data.get("q"))
@cached_property
def task_model(self):
"""
Returns the selected task model.
This looks for the task model in the following order:
1) If there's only one task model option, return it
2) If a task model has been selected, return it
3) Return the generic Task model
"""
models = list(self.task_type_choices.values())
if len(models) == 1:
return models[0]
elif self.is_valid():
model_name = self.cleaned_data.get("task_type")
if model_name and model_name in self.task_type_choices:
return self.task_type_choices[model_name]
return Task
def specific_task_model_selected(self):
return self.task_model is not Task
class WorkflowPageForm(forms.ModelForm):
page = forms.ModelChoiceField(
queryset=Page.objects.all(),
widget=widgets.AdminPageChooser(target_models=[Page], can_choose_root=True),
)
class Meta:
model = WorkflowPage
fields = ["page"]
def clean(self):
page = self.cleaned_data.get("page")
try:
existing_workflow = page.workflowpage.workflow
if not self.errors and existing_workflow != self.cleaned_data["workflow"]:
# If the form has no errors, Page has an existing Workflow assigned, that Workflow is not
# the selected Workflow, and overwrite_existing is not True, add a new error. This should be used to
# trigger the confirmation message in the view. This is why this error is only added if there are no
# other errors - confirmation should be the final step.
self.add_error(
"page",
ValidationError(
_(
"This page already has workflow '%(workflow_name)s' assigned."
)
% {"workflow_name": existing_workflow},
code="existing_workflow",
),
)
except AttributeError:
pass
def save(self, commit=False):
page = self.cleaned_data["page"]
if commit:
WorkflowPage.objects.update_or_create(
page=page,
defaults={"workflow": self.cleaned_data["workflow"]},
)
class BaseWorkflowPagesFormSet(forms.BaseInlineFormSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for form in self.forms:
form.fields["DELETE"].widget = forms.HiddenInput()
@property
def empty_form(self):
empty_form = super().empty_form
empty_form.fields["DELETE"].widget = forms.HiddenInput()
return empty_form
def clean(self):
"""Checks that no two forms refer to the same page object"""
if any(self.errors):
# Don't bother validating the formset unless each form is valid on its own
return
pages = [
form.cleaned_data["page"]
for form in self.forms
# need to check for presence of 'page' in cleaned_data,
# because a completely blank form passes validation
if form not in self.deleted_forms and "page" in form.cleaned_data
]
if len(set(pages)) != len(pages):
# pages list contains duplicates
raise forms.ValidationError(
_("You cannot assign this workflow to the same page multiple times.")
)
class WorkflowContentTypeForm(forms.Form):
class ContentTypeMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj):
return get_content_type_label(obj)
class CheckboxSelectMultiple(forms.CheckboxSelectMultiple):
"""Custom CheckboxSelectMultiple widget that renders errors for each content type ID"""
option_template_name = (
"wagtailadmin/workflows/includes/workflow_content_types_checkbox.html"
)
def get_errors_by_id(self, errors):
errors_by_id = {}
for error in errors.as_data():
ct_id = error.params and error.params.get("content_type_id")
errors_by_id.setdefault(ct_id, []).append(error)
return errors_by_id
def render_with_errors(
self, name, value, attrs=None, renderer=None, errors=None
):
context = {
**self.get_context(name, value, attrs),
"errors_by_id": self.get_errors_by_id(errors),
}
return self._render(self.template_name, context, renderer)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.none(),
widget=CheckboxSelectMultiple(),
required=False,
)
def __init__(self, *args, workflow=None, **kwargs):
self.workflow = workflow
if workflow and "initial" not in kwargs:
kwargs["initial"] = {"content_types": workflow.workflow_content_types.all()}
super().__init__(*args, **kwargs)
# Start with an always-false query, as Django can optimise it by
# returning an empty queryset without running any database queries.
workflow_enabled_q = Q(pk__in=[])
# Then union the query for each workflow-enabled model.
for model in get_workflow_enabled_models():
workflow_enabled_q |= Q(
app_label=model._meta.app_label, model=model._meta.model_name
)
self.fields["content_types"].queryset = ContentType.objects.filter(
workflow_enabled_q
)
def clean(self):
content_types = self.cleaned_data.get("content_types")
if not content_types:
return
existing_assignments = WorkflowContentType.objects.filter(
content_type__in=content_types,
workflow__active=True,
).exclude(workflow=self.workflow)
for assignment in existing_assignments:
self.add_error(
"content_types",
ValidationError(
_(
"Snippet '%(content_type)s' already has workflow '%(workflow_name)s' assigned."
)
% {
"content_type": capfirst(assignment.content_type.name),
"workflow_name": assignment.workflow,
},
code="existing_workflow_content_type",
params={"content_type_id": assignment.content_type_id},
),
)
def save(self, commit=True):
if not commit:
return
content_types = self.cleaned_data["content_types"]
with transaction.atomic():
# Remove any content types that are no longer selected
WorkflowContentType.objects.filter(workflow=self.workflow).exclude(
content_type__in=content_types
).delete()
# Add any new content types, ignoring conflicts with existing ones
# to avoid additional query for existing content types
objects = [
WorkflowContentType(workflow=self.workflow, content_type=ct)
for ct in content_types
]
WorkflowContentType.objects.bulk_create(objects, ignore_conflicts=True)
WorkflowPagesFormSet = forms.inlineformset_factory(
Workflow,
WorkflowPage,
form=WorkflowPageForm,
formset=BaseWorkflowPagesFormSet,
extra=1,
can_delete=True,
fields=["page"],
)
class BaseTaskForm(forms.ModelForm):
pass
def get_task_form_class(task_model, for_edit=False):
"""
Generates a form class for the given task model.
If the form is to edit an existing task, set for_edit to True. This applies
the readonly restrictions on fields defined in admin_form_readonly_on_edit_fields.
"""
fields = task_model.admin_form_fields
form_class = forms.modelform_factory(
task_model,
form=BaseTaskForm,
fields=fields,
widgets=getattr(task_model, "admin_form_widgets", {}),
)
if for_edit:
for field_name in getattr(task_model, "admin_form_readonly_on_edit_fields", []):
if field_name not in form_class.base_fields:
raise ImproperlyConfigured(
"`%s.admin_form_readonly_on_edit_fields` contains the field "
"'%s' that doesn't exist. Did you forget to add "
"it to `%s.admin_form_fields`?"
% (task_model.__name__, field_name, task_model.__name__)
)
form_class.base_fields[field_name].disabled = True
return form_class
def get_workflow_edit_handler():
"""
Returns an edit handler which provides the "name" and "tasks" fields for workflow.
"""
# Note. It's a bit of a hack that we use edit handlers here. Ideally, it should be
# made easier to reuse the inline panel templates for any formset.
# Since this form is internal, we're OK with this for now. We might want to revisit
# this decision later if we decide to allow custom fields on Workflows.
panels = [
FieldPanel("name", heading=_("Give your workflow a name")),
InlinePanel(
"workflow_tasks",
[
FieldPanel("task", widget=AdminTaskChooser(show_clear_link=False)),
],
heading=_("Add tasks to your workflow"),
label=_("Task"),
icon="thumbtack",
),
]
edit_handler = ObjectList(panels, base_form_class=WagtailAdminModelForm)
return edit_handler.bind_to_model(Workflow)

View File

@@ -0,0 +1,43 @@
import hashlib
import itertools
import re
from functools import lru_cache
from django.conf import settings
from django.template.loader import render_to_string
from django.urls import reverse
from wagtail import hooks
icon_comment_pattern = re.compile(r"<!--.*?-->")
@lru_cache(maxsize=None)
def get_icons():
icon_hooks = hooks.get_hooks("register_icons")
all_icons = sorted(itertools.chain.from_iterable(hook([]) for hook in icon_hooks))
combined_icon_markup = ""
for icon in all_icons:
symbol = (
render_to_string(icon)
.replace('xmlns="http://www.w3.org/2000/svg"', "")
.replace("svg", "symbol")
)
symbol = icon_comment_pattern.sub("", symbol)
combined_icon_markup += symbol
return render_to_string(
"wagtailadmin/shared/icons.html", {"icons": combined_icon_markup}
)
@lru_cache(maxsize=None)
def get_icon_sprite_hash():
# SECRET_KEY is used to prevent exposing the Wagtail version
return hashlib.sha1(
(get_icons() + settings.SECRET_KEY).encode("utf-8")
).hexdigest()[:8]
def get_icon_sprite_url():
return reverse("wagtailadmin_sprite") + f"?h={get_icon_sprite_hash()}"

View File

@@ -0,0 +1,19 @@
import jinja2
from jinja2.ext import Extension
from .templatetags.wagtailuserbar import wagtailuserbar
class WagtailUserbarExtension(Extension):
def __init__(self, environment):
super().__init__(environment)
self.environment.globals.update(
{
"wagtailuserbar": jinja2.pass_context(wagtailuserbar),
}
)
# Nicer import names
userbar = WagtailUserbarExtension

View File

@@ -0,0 +1,15 @@
#
# Translators:
# Jaco du Plessis <jaco@jacoduplessis.co.za>, 2022
#
msgid ""
msgstr ""
"Last-Translator: Jaco du Plessis <jaco@jacoduplessis.co.za>, 2022\n"
"Language-Team: Afrikaans (https://app.transifex.com/torchbox/teams/8009/"
"af/)\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Language: af\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Save"
msgstr "Stoor"

File diff suppressed because it is too large Load Diff

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