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,431 @@
from collections import OrderedDict
from functools import cached_property
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth import views as auth_views
from django.db import transaction
from django.forms import Media
from django.http import Http404
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy, override
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic.base import TemplateView
from wagtail import hooks
from wagtail.admin.forms.account import (
AvatarPreferencesForm,
LocalePreferencesForm,
NameEmailForm,
NotificationPreferencesForm,
ThemePreferencesForm,
)
from wagtail.admin.forms.auth import LoginForm, PasswordChangeForm, PasswordResetForm
from wagtail.admin.localization import (
get_available_admin_languages,
get_available_admin_time_zones,
)
from wagtail.admin.views.generic import WagtailAdminTemplateMixin
from wagtail.log_actions import log
from wagtail.users.models import UserProfile
from wagtail.utils.loading import get_custom_form
def get_user_login_form():
form_setting = "WAGTAILADMIN_USER_LOGIN_FORM"
if hasattr(settings, form_setting):
return get_custom_form(form_setting)
else:
return LoginForm
def get_password_reset_form():
form_setting = "WAGTAILADMIN_USER_PASSWORD_RESET_FORM"
if hasattr(settings, form_setting):
return get_custom_form(form_setting)
else:
return PasswordResetForm
# Helper functions to check password management settings to enable/disable views as appropriate.
# These are functions rather than class-level constants so that they can be overridden in tests
# by override_settings
def password_management_enabled():
return getattr(settings, "WAGTAIL_PASSWORD_MANAGEMENT_ENABLED", True)
def email_management_enabled():
return getattr(settings, "WAGTAIL_EMAIL_MANAGEMENT_ENABLED", True)
def password_reset_enabled():
return getattr(
settings, "WAGTAIL_PASSWORD_RESET_ENABLED", password_management_enabled()
)
# Tabs
class SettingsTab:
def __init__(self, name, title, order=0):
self.name = name
self.title = title
self.order = order
profile_tab = SettingsTab("profile", gettext_lazy("Profile"), order=100)
notifications_tab = SettingsTab(
"notifications", gettext_lazy("Notifications"), order=200
)
# Panels
class BaseSettingsPanel:
name = ""
title = ""
tab = profile_tab
help_text = None
template_name = "wagtailadmin/account/settings_panels/base.html"
form_class = None
form_object = "user"
def __init__(self, request, user, profile):
self.request = request
self.user = user
self.profile = profile
def is_active(self):
"""
Returns True to display the panel.
"""
return True
def get_form(self):
"""
Returns an initialised form.
"""
kwargs = {
"instance": self.profile if self.form_object == "profile" else self.user,
"prefix": self.name,
}
if self.request.method == "POST":
return self.form_class(self.request.POST, self.request.FILES, **kwargs)
else:
return self.form_class(**kwargs)
def get_context_data(self):
"""
Returns the template context to use when rendering the template.
"""
return {"form": self.get_form()}
def render(self):
"""
Renders the panel using the template specified in .template_name and context from .get_context_data()
"""
return render_to_string(
self.template_name, self.get_context_data(), request=self.request
)
class NameEmailSettingsPanel(BaseSettingsPanel):
name = "name_email"
order = 100
form_class = NameEmailForm
@cached_property
def title(self):
if email_management_enabled():
return _("Name and Email")
return _("Name")
class AvatarSettingsPanel(BaseSettingsPanel):
name = "avatar"
title = gettext_lazy("Profile picture")
order = 300
template_name = "wagtailadmin/account/settings_panels/avatar.html"
form_class = AvatarPreferencesForm
form_object = "profile"
class NotificationsSettingsPanel(BaseSettingsPanel):
name = "notifications"
title = gettext_lazy("Notifications")
tab = notifications_tab
order = 100
form_class = NotificationPreferencesForm
form_object = "profile"
def is_active(self):
# Hide the panel if there are no notification preferences
return bool(self.get_form().fields)
class LocaleSettingsPanel(BaseSettingsPanel):
name = "locale"
title = gettext_lazy("Locale")
order = 400
form_class = LocalePreferencesForm
form_object = "profile"
def is_active(self):
return (
len(get_available_admin_languages()) > 1
or len(get_available_admin_time_zones()) > 1
)
class ThemeSettingsPanel(BaseSettingsPanel):
name = "theme"
title = gettext_lazy("Theme preferences")
order = 450
form_class = ThemePreferencesForm
form_object = "profile"
class ChangePasswordPanel(BaseSettingsPanel):
name = "password"
title = gettext_lazy("Password")
order = 500
form_class = PasswordChangeForm
def is_active(self):
return password_management_enabled() and self.user.has_usable_password()
def get_form(self):
# Note: don't bind the form unless a field is specified
# This prevents the validation error from displaying if the user wishes to ignore this
bind_form = False
if self.request.method == "POST":
bind_form = any(
[
self.request.POST.get(self.name + "-new_password1"),
self.request.POST.get(self.name + "-new_password2"),
]
)
if bind_form:
return self.form_class(self.user, self.request.POST, prefix=self.name)
else:
return self.form_class(self.user, prefix=self.name)
# Views
@method_decorator(sensitive_post_parameters(), name="post")
class AccountView(WagtailAdminTemplateMixin, TemplateView):
template_name = "wagtailadmin/account/account.html"
page_title = gettext_lazy("Account")
header_icon = "user"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
panels = self.get_panels()
context["panels_by_tab"] = self.get_panels_by_tab(panels)
context["menu_items"] = self.get_menu_items()
context["media"] = self.get_media(panels)
context["user"] = self.request.user
return context
def get_panels(self):
request = self.request
user = self.request.user
profile = UserProfile.get_for_user(user)
# Panels
panels = [
NameEmailSettingsPanel(request, user, profile),
AvatarSettingsPanel(request, user, profile),
NotificationsSettingsPanel(request, user, profile),
LocaleSettingsPanel(request, user, profile),
ThemeSettingsPanel(request, user, profile),
ChangePasswordPanel(request, user, profile),
]
for fn in hooks.get_hooks("register_account_settings_panel"):
panel = fn(request, user, profile)
if panel and panel.is_active():
panels.append(panel)
panels = [panel for panel in panels if panel.is_active()]
return panels
def get_panels_by_tab(self, panels):
# Get tabs and order them
tabs = list({panel.tab for panel in panels})
tabs.sort(key=lambda tab: tab.order)
# Get dict of tabs to ordered panels
panels_by_tab = OrderedDict([(tab, []) for tab in tabs])
for panel in panels:
panels_by_tab[panel.tab].append(panel)
for tab, tab_panels in panels_by_tab.items():
tab_panels.sort(key=lambda panel: panel.order)
return panels_by_tab
def get_menu_items(self):
# Menu items
menu_items = []
for fn in hooks.get_hooks("register_account_menu_item"):
item = fn(self.request)
if item:
menu_items.append(item)
return menu_items
def get_media(self, panels):
panel_forms = [panel.get_form() for panel in panels]
media = Media()
for form in panel_forms:
media += form.media
return media
def post(self, request):
panel_forms = [panel.get_form() for panel in self.get_panels()]
user = self.request.user
profile = UserProfile.get_for_user(user)
if all(form.is_valid() or not form.is_bound for form in panel_forms):
with transaction.atomic():
for form in panel_forms:
if form.is_bound:
form.save()
log(user, "wagtail.edit")
# Prevent a password change from logging this user out
update_session_auth_hash(request, user)
# Override the language when creating the success message
# If the user has changed their language in this request, the message should
# be in the new language, not the existing one
with override(profile.get_preferred_language()):
messages.success(
request, _("Your account settings have been changed successfully!")
)
return redirect("wagtailadmin_account")
return TemplateResponse(request, self.template_name, self.get_context_data())
class PasswordResetEnabledViewMixin:
"""
Class based view mixin that disables the view if password reset is disabled by one of the following settings:
- WAGTAIL_PASSWORD_RESET_ENABLED
- WAGTAIL_PASSWORD_MANAGEMENT_ENABLED
"""
def dispatch(self, *args, **kwargs):
if not password_reset_enabled():
raise Http404
return super().dispatch(*args, **kwargs)
class PasswordResetView(PasswordResetEnabledViewMixin, auth_views.PasswordResetView):
template_name = "wagtailadmin/account/password_reset/form.html"
email_template_name = "wagtailadmin/account/password_reset/email.txt"
subject_template_name = "wagtailadmin/account/password_reset/email_subject.txt"
success_url = reverse_lazy("wagtailadmin_password_reset_done")
def get_form_class(self):
return get_password_reset_form()
class PasswordResetDoneView(
PasswordResetEnabledViewMixin, auth_views.PasswordResetDoneView
):
template_name = "wagtailadmin/account/password_reset/done.html"
class PasswordResetConfirmView(
PasswordResetEnabledViewMixin, auth_views.PasswordResetConfirmView
):
template_name = "wagtailadmin/account/password_reset/confirm.html"
success_url = reverse_lazy("wagtailadmin_password_reset_complete")
class PasswordResetCompleteView(
PasswordResetEnabledViewMixin, auth_views.PasswordResetCompleteView
):
template_name = "wagtailadmin/account/password_reset/complete.html"
class LoginView(auth_views.LoginView):
template_name = "wagtailadmin/login.html"
def get_success_url(self):
return self.get_redirect_url() or reverse("wagtailadmin_home")
def get(self, *args, **kwargs):
# If user is already logged in, redirect them to the dashboard
if self.request.user.is_authenticated and self.request.user.has_perm(
"wagtailadmin.access_admin"
):
return redirect(self.get_success_url())
return super().get(*args, **kwargs)
def get_form_class(self):
return get_user_login_form()
def form_valid(self, form):
response = super().form_valid(form)
remember = form.cleaned_data.get("remember")
if remember:
self.request.session.set_expiry(settings.SESSION_COOKIE_AGE)
else:
self.request.session.set_expiry(0)
return response
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["show_password_reset"] = password_reset_enabled()
from django.contrib.auth import get_user_model
User = get_user_model()
context["username_field"] = User._meta.get_field(
User.USERNAME_FIELD
).verbose_name
return context
class LogoutView(auth_views.LogoutView):
next_page = "wagtailadmin_login"
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
messages.success(self.request, _("You have been successfully logged out."))
# By default, logging out will generate a fresh sessionid cookie. We want to use the
# absence of sessionid as an indication that front-end pages are being viewed by a
# non-logged-in user and are therefore cacheable, so we forcibly delete the cookie here.
response.delete_cookie(
settings.SESSION_COOKIE_NAME,
domain=settings.SESSION_COOKIE_DOMAIN,
path=settings.SESSION_COOKIE_PATH,
)
# HACK: pretend that the session hasn't been modified, so that SessionMiddleware
# won't override the above and write a new cookie.
self.request.session.modified = False
return response

View File

@@ -0,0 +1,4 @@
from .base_bulk_action import BulkAction
from .dispatcher import index
__all__ = ["BulkAction", "index"]

View File

@@ -0,0 +1,163 @@
from abc import ABC, abstractmethod
from django import forms
from django.db import transaction
from django.shortcuts import get_list_or_404, redirect
from django.utils.functional import classproperty
from django.views.generic import FormView
from wagtail import hooks
from wagtail.admin import messages
from wagtail.admin.utils import get_valid_next_url_from_request
class BulkAction(ABC, FormView):
@property
@abstractmethod
def display_name(self):
pass
@property
@abstractmethod
def action_type(self):
pass
@property
@abstractmethod
def aria_label(self):
pass
extras = {}
action_priority = 100
classes = set()
form_class = forms.Form
cleaned_form = None
def __init__(self, request, model):
self.request = request
next_url = get_valid_next_url_from_request(request)
if not next_url:
next_url = request.path
self.next_url = next_url
self.num_parent_objects = self.num_child_objects = 0
if model in self.models:
self.model = model
else:
raise Exception(
"model {} is not among the specified list of models".format(
model.__class__.__name__
)
)
@classproperty
def models(cls):
return []
@classmethod
def get_queryset(cls, model, object_ids):
return get_list_or_404(model, pk__in=object_ids)
def check_perm(self, obj):
return True
@classmethod
def execute_action(cls, objects, **kwargs):
raise NotImplementedError("execute_action needs to be implemented")
def get_success_message(self, num_parent_objects, num_child_objects):
pass
def object_context(self, obj):
return {"item": obj}
@classmethod
def get_default_model(cls):
models = cls.models
if len(models) == 1:
return models[0]
raise Exception(
"Cannot get default model if number of models is greater than 1"
)
def __run_before_hooks(self, action_type, request, objects):
for hook in hooks.get_hooks("before_bulk_action"):
result = hook(request, action_type, objects, self)
if hasattr(result, "status_code"):
return result
def __run_after_hooks(self, action_type, request, objects):
for hook in hooks.get_hooks("after_bulk_action"):
result = hook(request, action_type, objects, self)
if hasattr(result, "status_code"):
return result
def get_all_objects_in_listing_query(self, parent_id):
return self.model.objects.all().values_list("pk", flat=True)
def get_actionable_objects(self):
objects = []
items_with_no_access = []
object_ids = self.request.GET.getlist("id")
if "all" in object_ids:
object_ids = self.get_all_objects_in_listing_query(
self.request.GET.get("childOf")
)
for obj in self.get_queryset(self.model, object_ids):
if not self.check_perm(obj):
items_with_no_access.append(obj)
else:
objects.append(obj)
return objects, {"items_with_no_access": items_with_no_access}
def get_context_data(self, **kwargs):
items, items_with_no_access = self.get_actionable_objects()
_items = []
for item in items:
_items.append(self.object_context(item))
return {
**super().get_context_data(**kwargs),
"items": _items,
**items_with_no_access,
"next": self.next_url,
"submit_url": self.request.path + "?" + self.request.META["QUERY_STRING"],
}
def prepare_action(self, objects, objects_without_access):
return
def get_execution_context(self):
return {}
def form_valid(self, form):
request = self.request
self.cleaned_form = form
objects, objects_without_access = self.get_actionable_objects()
self.actionable_objects = objects
resp = self.prepare_action(objects, objects_without_access)
if hasattr(resp, "status_code"):
return resp
with transaction.atomic():
before_hook_result = self.__run_before_hooks(
self.action_type, request, objects
)
if before_hook_result is not None:
return before_hook_result
num_parent_objects, num_child_objects = self.execute_action(
objects, **self.get_execution_context()
)
after_hook_result = self.__run_after_hooks(
self.action_type, request, objects
)
if after_hook_result is not None:
return after_hook_result
success_message = self.get_success_message(
num_parent_objects, num_child_objects
)
if success_message is not None:
messages.success(request, success_message)
return redirect(self.next_url)
def form_invalid(self, form):
return super().form_invalid(form)

View File

@@ -0,0 +1,15 @@
from django.apps import apps
from django.http import Http404
from wagtail.admin.views.bulk_action.registry import bulk_action_registry as registry
def index(request, app_label, model_name, action):
try:
model = apps.get_model(app_label, model_name)
except LookupError:
raise Http404
action_class = registry.get_bulk_action_class(app_label, model_name, action)
if action_class is not None:
return action_class(request, model).dispatch(request)
raise Http404

View File

@@ -0,0 +1,40 @@
from wagtail import hooks
from wagtail.admin.views.bulk_action import BulkAction
class BulkActionRegistry:
def __init__(self):
self.actions = {} # {app_name: {model_name: {action_name: action_class]}}
self.has_scanned_for_bulk_actions = False
def _scan_for_bulk_actions(self):
if not self.has_scanned_for_bulk_actions:
for action_class in hooks.get_hooks("register_bulk_action"):
if not issubclass(action_class, BulkAction):
raise Exception(
"{} is not a subclass of {}".format(
action_class.__name__, BulkAction.__name__
)
)
for model in action_class.models:
self.actions.setdefault(model._meta.app_label, {})
self.actions[model._meta.app_label].setdefault(
model._meta.model_name, {}
)
self.actions[model._meta.app_label][model._meta.model_name][
action_class.action_type
] = action_class
self.has_scanned_for_bulk_actions = True
def get_bulk_actions_for_model(self, app_label, model_name):
self._scan_for_bulk_actions()
return self.actions.get(app_label, {}).get(model_name, {}).values()
def get_bulk_action_class(self, app_label, model_name, action_type):
self._scan_for_bulk_actions()
return (
self.actions.get(app_label, {}).get(model_name, {}).get(action_type, None)
)
bulk_action_registry = BulkActionRegistry()

View File

@@ -0,0 +1,831 @@
import re
from collections import defaultdict
from urllib.parse import parse_qs, quote, urlencode, urlsplit
from django.conf import settings
from django.core.paginator import InvalidPage, Paginator
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.urls import NoReverseMatch
from django.urls.base import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View
from wagtail import hooks
from wagtail.admin.forms.choosers import (
AnchorLinkChooserForm,
EmailLinkChooserForm,
ExternalLinkChooserForm,
PhoneLinkChooserForm,
)
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.admin.ui.tables import Column, DateColumn, Table
from wagtail.coreutils import resolve_model_string
from wagtail.models import Locale, Page, Site
def shared_context(request, extra_context=None):
context = {
# parent_page ID is passed as a GET parameter on the external_link, anchor_link and mail_link views
# so that it's remembered when browsing from 'Internal link' to another link type
# and back again. On the 'browse' / 'internal link' view this will be overridden to be
# sourced from the standard URL path parameter instead.
"parent_page_id": request.GET.get("parent_page_id"),
"allow_external_link": request.GET.get("allow_external_link"),
"allow_email_link": request.GET.get("allow_email_link"),
"allow_phone_link": request.GET.get("allow_phone_link"),
"allow_anchor_link": request.GET.get("allow_anchor_link"),
}
if extra_context:
context.update(extra_context)
return context
def page_models_from_string(string):
page_models = []
for sub_string in string.split(","):
page_model = resolve_model_string(sub_string)
if not issubclass(page_model, Page):
raise ValueError("Model is not a page")
page_models.append(page_model)
return tuple(page_models)
def can_choose_page(
page,
user,
desired_classes,
can_choose_root=True,
user_perm=None,
target_pages=None,
match_subclass=True,
):
"""Returns boolean indicating of the user can choose page.
will check if the root page can be selected and if user permissions
should be checked.
"""
if not target_pages:
target_pages = []
if not match_subclass and page.specific_class not in desired_classes:
return False
elif (
match_subclass
and not issubclass(page.specific_class or Page, desired_classes)
and not desired_classes == (Page,)
):
return False
elif not can_choose_root and page.is_root():
return False
if user_perm in ["move_to", "bulk_move_to"]:
pages_to_move = target_pages
for page_to_move in pages_to_move:
if page.pk == page_to_move.pk or page.is_descendant_of(page_to_move):
return False
if user_perm == "move_to":
return page_to_move.permissions_for_user(user).can_move_to(page)
if user_perm in {"add_subpage", "copy_to"}:
return page.permissions_for_user(user).can_add_subpage()
return True
class PageChooserTable(Table):
classname = "listing chooser"
def __init__(self, *args, show_locale_labels=False, **kwargs):
super().__init__(*args, **kwargs)
self.show_locale_labels = show_locale_labels
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["show_locale_labels"] = self.show_locale_labels
return context
def get_row_classname(self, page):
classnames = []
if page.is_parent_page:
classnames.append("parent-page")
if not page.live:
classnames.append("unpublished")
if not page.can_choose:
classnames.append("disabled")
return " ".join(classnames)
class PageTitleColumn(Column):
cell_template_name = "wagtailadmin/chooser/tables/page_title_cell.html"
def __init__(self, *args, is_multiple_choice=False, **kwargs):
super().__init__(*args, **kwargs)
self.is_multiple_choice = is_multiple_choice
def get_value(self, instance):
return instance.get_admin_display_title()
def get_cell_context_data(self, instance, parent_context):
context = super().get_cell_context_data(instance, parent_context)
context["page"] = instance
# only need to show locale labels for top-level pages
context["show_locale_labels"] = (
parent_context.get("show_locale_labels") and instance.depth == 2
)
return context
class ParentPageColumn(Column):
cell_template_name = "wagtailadmin/chooser/tables/parent_page_cell.html"
def get_value(self, instance):
return instance.get_parent()
def get_cell_context_data(self, instance, parent_context):
context = super().get_cell_context_data(instance, parent_context)
context["show_locale_labels"] = parent_context.get("show_locale_labels")
return context
class PageStatusColumn(Column):
cell_template_name = "wagtailadmin/chooser/tables/page_status_cell.html"
def get_value(self, instance):
return instance
class PageNavigateToChildrenColumn(Column):
cell_template_name = (
"wagtailadmin/chooser/tables/page_navigate_to_children_cell.html"
)
def get_value(self, instance):
return instance
class PageCheckboxSelectColumn(Column):
cell_template_name = "wagtailadmin/chooser/tables/page_checkbox_select_cell.html"
class BrowseView(View):
@property
def columns(self):
cols = [
PageTitleColumn(
"title",
label=_("Title"),
is_multiple_choice=self.is_multiple_choice,
),
DateColumn(
"updated",
label=_("Updated"),
width="12%",
accessor="latest_revision_created_at",
),
Column(
"type",
label=_("Type"),
width="12%",
accessor="page_type_display_name",
),
PageStatusColumn("status", label=_("Status"), width="12%"),
PageNavigateToChildrenColumn("children", label="", width="10%"),
]
if self.is_multiple_choice:
cols.insert(
0,
PageCheckboxSelectColumn(
"select", label=_("Select"), width="1%", accessor="pk"
),
)
return cols
def get_object_list(self):
# Get children of parent page (without streamfields)
pages = self.parent_page.get_children().defer_streamfields().specific()
if self.i18n_enabled:
pages = pages.select_related("locale")
return pages
def filter_object_list(self, pages):
# allow hooks to modify the queryset
for hook in hooks.get_hooks("construct_page_chooser_queryset"):
pages = hook(pages, self.request)
# Filter them by page type
if self.desired_classes != (Page,):
# restrict the page listing to just those pages that:
# - are of the given content type (taking into account class inheritance)
# - or can be navigated into (i.e. have children)
choosable_pages = pages.type(*self.desired_classes)
descendable_pages = pages.filter(numchild__gt=0)
pages = choosable_pages | descendable_pages
return pages
def get(self, request, parent_page_id=None):
self.i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False)
self.is_multiple_choice = request.GET.get("multiple")
# A missing or empty page_type parameter indicates 'all page types'
# (i.e. descendants of wagtailcore.page)
page_type_string = request.GET.get("page_type") or "wagtailcore.page"
user_perm = request.GET.get("user_perms", False)
try:
self.desired_classes = page_models_from_string(page_type_string)
except (ValueError, LookupError):
raise Http404
# Find parent page
if parent_page_id:
self.parent_page = get_object_or_404(Page, id=parent_page_id)
elif self.desired_classes == (Page,):
# Just use the root page
self.parent_page = Page.get_first_root_node()
else:
# Find the highest common ancestor for the specific classes passed in
# In many cases, such as selecting an EventPage under an EventIndex,
# this will help the administrator find their page quicker.
all_desired_pages = Page.objects.all().type(*self.desired_classes)
self.parent_page = all_desired_pages.first_common_ancestor()
self.parent_page = self.parent_page.specific
pages = self.get_object_list()
pages = self.filter_object_list(pages)
can_choose_root = request.GET.get("can_choose_root", False)
target_pages = Page.objects.filter(
pk__in=[int(pk) for pk in request.GET.getlist("target_pages[]", []) if pk]
)
match_subclass = request.GET.get("match_subclass", True)
# Parent page can be chosen if it is a instance of desired_classes
self.parent_page.can_choose = can_choose_page(
self.parent_page,
request.user,
self.desired_classes,
can_choose_root,
user_perm,
target_pages=target_pages,
match_subclass=match_subclass,
)
self.parent_page.is_parent_page = True
self.parent_page.can_descend = False
selected_locale = None
locale_options = []
if self.i18n_enabled:
# Ensure query parameters (e.g. `page_type`, `user_perms`, etc.) are
# preserved when switching locales, but reset the pagination as the
# number of pages might be different.
new_params = request.GET.copy()
new_params.pop("p", None)
if self.parent_page.is_root():
# 'locale' is the current value of the "Locale" selector in the UI
if request.GET.get("locale"):
selected_locale = get_object_or_404(
Locale, language_code=request.GET["locale"]
)
active_locale_id = selected_locale.pk
else:
active_locale_id = Locale.get_active().pk
# we are at the Root level, so get the locales from the current pages
choose_url = reverse("wagtailadmin_choose_page")
for locale in Locale.objects.filter(
pk__in=pages.values_list("locale_id")
).exclude(pk=active_locale_id):
new_params["locale"] = locale.language_code
locale_options.append(
{
"locale": locale,
"url": choose_url + "?" + new_params.urlencode(),
}
)
else:
# We have a parent page (that is not the root page). Use its locale as the selected localer
selected_locale = self.parent_page.locale
new_params.pop("locale", None)
# and get the locales based on its available translations
locales_and_parent_pages = {
item["locale"]: item["pk"]
for item in Page.objects.translation_of(self.parent_page).values(
"locale", "pk"
)
}
locales_and_parent_pages[selected_locale.pk] = self.parent_page.pk
for locale in Locale.objects.filter(
pk__in=list(locales_and_parent_pages.keys())
).exclude(pk=selected_locale.pk):
choose_child_url = reverse(
"wagtailadmin_choose_page_child",
args=[locales_and_parent_pages[locale.pk]],
)
locale_options.append(
{
"locale": locale,
"url": choose_child_url + "?" + new_params.urlencode(),
}
)
# finally, filter the browsable pages on the selected locale
if selected_locale:
pages = pages.filter(locale=selected_locale)
# Pagination
# We apply pagination first so we don't need to walk the entire list
# in the block below
paginator = Paginator(pages, per_page=25)
try:
pages = paginator.page(request.GET.get("p", 1))
except InvalidPage:
raise Http404
# Annotate each page with can_choose/can_descend flags
for page in pages:
page.can_choose = can_choose_page(
page,
request.user,
self.desired_classes,
can_choose_root,
user_perm,
target_pages=target_pages,
match_subclass=match_subclass,
)
page.can_descend = page.get_children_count()
page.is_parent_page = False
table = PageChooserTable(
self.columns,
[self.parent_page] + list(pages),
show_locale_labels=self.i18n_enabled,
)
# Render
context = shared_context(
request,
{
"parent_page": self.parent_page,
"parent_page_id": self.parent_page.pk,
"table": table,
"pagination_page": pages,
"search_form": SearchForm(),
"page_type_string": page_type_string,
"page_type_names": [
desired_class.get_verbose_name()
for desired_class in self.desired_classes
],
"page_types_restricted": (page_type_string != "wagtailcore.page"),
"show_locale_controls": self.i18n_enabled,
"locale_options": locale_options,
"selected_locale": selected_locale,
"is_multiple_choice": self.is_multiple_choice,
},
)
return render_modal_workflow(
request,
"wagtailadmin/chooser/browse.html",
None,
context,
json_data={"step": "browse", "parent_page_id": context["parent_page_id"]},
)
class SearchView(View):
@property
def columns(self):
cols = [
PageTitleColumn("title", label=_("Title")),
ParentPageColumn("parent", label=_("Parent")),
DateColumn(
"updated",
label=_("Updated"),
width="12%",
accessor="latest_revision_created_at",
),
Column(
"type",
label=_("Type"),
width="12%",
accessor="page_type_display_name",
),
PageStatusColumn("status", label=_("Status"), width="12%"),
]
if self.is_multiple_choice:
cols.insert(
0,
PageCheckboxSelectColumn(
"select", label=_("Select"), width="1%", accessor="pk"
),
)
return cols
def get(self, request):
self.i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False)
self.is_multiple_choice = request.GET.get("multiple")
# A missing or empty page_type parameter indicates 'all page types' (i.e. descendants of wagtailcore.page)
page_type_string = request.GET.get("page_type") or "wagtailcore.page"
try:
desired_classes = page_models_from_string(page_type_string)
except (ValueError, LookupError):
raise Http404
pages = Page.objects.all()
if self.i18n_enabled:
pages = pages.select_related("locale")
# allow hooks to modify the queryset
for hook in hooks.get_hooks("construct_page_chooser_queryset"):
pages = hook(pages, request)
search_form = SearchForm(request.GET)
if search_form.is_valid() and search_form.cleaned_data["q"]:
pages = pages.exclude(depth=1) # never include root
pages = pages.type(*desired_classes)
pages = pages.specific()
pages = pages.autocomplete(search_form.cleaned_data["q"])
else:
pages = pages.none()
paginator = Paginator(pages, per_page=25)
pages = paginator.get_page(request.GET.get("p"))
for page in pages:
page.can_choose = True
page.is_parent_page = False
table = PageChooserTable(
self.columns,
pages,
show_locale_labels=self.i18n_enabled,
)
return TemplateResponse(
request,
"wagtailadmin/chooser/_search_results.html",
shared_context(
request,
{
"searchform": search_form,
"table": table,
"pages": pages,
"page_type_string": page_type_string,
},
),
)
class ChosenMultipleView(View):
"""
A view that takes a list of 'id' URL parameters and returns a modal workflow response indicating
that those objects have been chosen
"""
def render_chosen_response(self, result):
return render_modal_workflow(
self.request,
None,
None,
None,
json_data={"step": "page_chosen", "result": result},
)
def get(self, request):
pks = request.GET.getlist("id")
pages = Page.objects.filter(pk__in=pks).specific()
result = [
{
"id": page.pk,
"parentId": page.get_parent().pk,
"adminTitle": page.get_admin_display_title(),
"editUrl": reverse("wagtailadmin_pages:edit", args=(page.pk,)),
"url": page.url,
}
for page in pages
]
return self.render_chosen_response(result)
class BaseLinkFormView(View):
def get_initial_data(self):
return {
self.link_url_field_name: self.request.GET.get("link_url", ""),
"link_text": self.request.GET.get("link_text", ""),
}
def get_url_from_field_value(self, value):
return value
def get_result_data(self):
url_field_value = self.form.cleaned_data[self.link_url_field_name]
return {
"url": self.get_url_from_field_value(url_field_value),
"title": self.form.cleaned_data["link_text"].strip() or url_field_value,
# If the user has explicitly entered / edited something in the link_text field,
# always use that text. If not, we should favour keeping the existing link/selection
# text, where applicable.
# (Normally this will match the link_text passed in the URL here anyhow,
# but that won't account for non-text content such as images.)
"prefer_this_title_as_link_text": ("link_text" in self.form.changed_data),
}
def get(self, request):
self.form = self.form_class(
initial=self.get_initial_data(), prefix=self.form_prefix
)
return self.render_form_response()
def post(self, request):
self.form = self.form_class(
request.POST, initial=self.get_initial_data(), prefix=self.form_prefix
)
if self.form.is_valid():
result = self.get_result_data()
return self.render_chosen_response(result)
else: # form invalid
return self.render_form_response()
def render_form_response(self):
return render_modal_workflow(
self.request,
self.template_name,
None,
shared_context(
self.request,
{
"form": self.form,
},
),
json_data={"step": self.step_name},
)
def render_chosen_response(self, result):
return render_modal_workflow(
self.request,
None,
None,
None,
json_data={"step": "external_link_chosen", "result": result},
)
LINK_CONVERSION_ALL = "all"
LINK_CONVERSION_EXACT = "exact"
LINK_CONVERSION_CONFIRM = "confirm"
class ExternalLinkView(BaseLinkFormView):
form_prefix = "external-link-chooser"
form_class = ExternalLinkChooserForm
template_name = "wagtailadmin/chooser/external_link.html"
step_name = "external_link"
link_url_field_name = "url"
def post(self, request):
self.form = self.form_class(
request.POST,
initial=self.get_initial_data(),
prefix=self.form_prefix,
)
if self.form.is_valid():
result = self.get_result_data()
submitted_url = result["url"]
link_conversion = getattr(
settings,
"WAGTAILADMIN_EXTERNAL_LINK_CONVERSION",
LINK_CONVERSION_ALL,
).lower()
if link_conversion not in [
LINK_CONVERSION_ALL,
LINK_CONVERSION_EXACT,
LINK_CONVERSION_CONFIRM,
]:
# We should not attempt to convert external urls to page links
return self.render_chosen_response(result)
# Next, we should check if the url matches an internal page
# Strip the url of its query/fragment link parameters - these won't match a page
url_without_query = re.split(r"\?|#", submitted_url)[0]
# Start by finding any sites the url could potentially match
sites = getattr(request, "_wagtail_cached_site_root_paths", None)
if sites is None:
sites = Site.get_site_root_paths()
try:
# The serve view might not be routed to the root path of the domain,
# e.g. /pages/, so we need to account for the path to the serve view
serve_path = reverse("wagtail_serve", args=("",))
except NoReverseMatch:
serve_path = None
match_relative_paths = submitted_url.startswith("/") and len(sites) == 1
# We should only match relative urls if there's only a single site
# Otherwise this could get very annoying accidentally matching coincidentally
# named pages on different sites
possible_sites = defaultdict(list)
if match_relative_paths:
for pk, path, url, language_code in sites:
possible_sites[pk].append(url_without_query)
# If the submitted URL is prefixed with the serve path,
# also consider it without the serve path so we can match
# the page using Page.route()
if serve_path and url_without_query.startswith(serve_path):
possible_sites[pk].append(
url_without_query[len(serve_path) - 1 :]
)
else:
for pk, path, url, language_code in sites:
if not submitted_url.startswith(url):
continue
possible_sites[pk].append(url_without_query[len(url) :])
# If the submitted URL is prefixed with the serve path,
# also consider it without the serve path so we can match
# the page using Page.route()
if serve_path and url_without_query.startswith(url + serve_path):
possible_sites[pk].append(
url_without_query[len(url) + len(serve_path) - 1 :]
)
# Loop over possible sites to identify a page match
for pk, possible_urls in possible_sites.items():
site = Site.objects.select_related("root_page").get(pk=pk)
root_page = site.root_page.specific
for url in possible_urls:
try:
route = root_page.route(
request,
[component for component in url.split("/") if component],
)
except Http404:
continue
matched_page = route.page.specific
internal_data = {
"id": matched_page.pk,
"parentId": matched_page.get_parent().pk,
"adminTitle": matched_page.draft_title,
"editUrl": reverse(
"wagtailadmin_pages:edit", args=(matched_page.pk,)
),
"url": matched_page.url,
}
# Let's check what this page's normal url would be
normal_url = (
matched_page.get_url_parts(request=request)[-1]
if match_relative_paths
else matched_page.get_full_url(request=request)
)
# If that's what the user provided, great. Let's just convert the external
# url to an internal link automatically unless we're set up tp manually check
# all conversions
if (
normal_url == submitted_url
and link_conversion != LINK_CONVERSION_CONFIRM
):
return self.render_chosen_response(internal_data)
# If not, they might lose query parameters or routable page information
if link_conversion == LINK_CONVERSION_EXACT:
# We should only convert exact matches
continue
# Let's confirm the conversion with them explicitly
else:
return render_modal_workflow(
request,
"wagtailadmin/chooser/confirm_external_to_internal.html",
None,
{
"submitted_url": submitted_url,
"internal_url": normal_url,
"page": matched_page.draft_title,
},
json_data={
"step": "confirm_external_to_internal",
"external": result,
"internal": internal_data,
},
)
# Otherwise, with no internal matches, fall back to an external url
return self.render_chosen_response(result)
else: # form invalid
return self.render_form_response()
class AnchorLinkView(BaseLinkFormView):
form_prefix = "anchor-link-chooser"
form_class = AnchorLinkChooserForm
template_name = "wagtailadmin/chooser/anchor_link.html"
step_name = "anchor_link"
link_url_field_name = "url"
def get_url_from_field_value(self, value):
return "#" + value
class EmailLinkView(BaseLinkFormView):
form_prefix = "email-link-chooser"
form_class = EmailLinkChooserForm
template_name = "wagtailadmin/chooser/email_link.html"
step_name = "email_link"
link_url_field_name = "email_address"
def get_initial_data(self):
parsed_email = self.parse_email_link(self.request.GET.get("link_url", ""))
return {
"email_address": parsed_email["email"],
"link_text": self.request.GET.get("link_text", ""),
"subject": parsed_email["subject"],
"body": parsed_email["body"],
}
def get_url_from_field_value(self, value):
return "mailto:" + value
def get_result_data(self):
params = {
"subject": self.form.cleaned_data["subject"],
"body": self.form.cleaned_data["body"],
}
encoded_params = urlencode(
{k: v for k, v in params.items() if v is not None and v != ""},
quote_via=quote,
)
url = "mailto:" + self.form.cleaned_data["email_address"]
if encoded_params:
url += "?" + encoded_params
return {
"url": url,
"title": self.form.cleaned_data["link_text"].strip()
or self.form.cleaned_data["email_address"],
# If the user has explicitly entered / edited something in the link_text field,
# always use that text. If not, we should favour keeping the existing link/selection
# text, where applicable.
"prefer_this_title_as_link_text": ("link_text" in self.form.changed_data),
}
def post(self, request):
self.form = self.form_class(
request.POST, initial=self.get_initial_data(), prefix=self.form_prefix
)
if self.form.is_valid():
result = self.get_result_data()
return self.render_chosen_response(result)
else: # form invalid
return self.render_form_response()
def parse_email_link(self, mailto):
result = {}
mail_result = urlsplit(mailto)
result["email"] = mail_result.path
query = parse_qs(mail_result.query)
result["subject"] = query["subject"][0] if "subject" in query else ""
result["body"] = query["body"][0] if "body" in query else ""
return result
class PhoneLinkView(BaseLinkFormView):
form_prefix = "phone-link-chooser"
form_class = PhoneLinkChooserForm
template_name = "wagtailadmin/chooser/phone_link.html"
step_name = "phone_link"
link_url_field_name = "phone_number"
def get_url_from_field_value(self, value):
value = re.sub(r"\s", "", value)
return "tel:" + value

View File

@@ -0,0 +1,79 @@
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
from wagtail.admin.forms.collections import CollectionViewRestrictionForm
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.models import Collection, CollectionViewRestriction
from wagtail.permissions import collection_permission_policy
def set_privacy(request, collection_id):
collection = get_object_or_404(Collection, id=collection_id)
if not collection_permission_policy.user_has_permission(request.user, "change"):
raise PermissionDenied
# fetch restriction records in depth order so that ancestors appear first
restrictions = collection.get_view_restrictions().order_by("collection__depth")
if restrictions:
restriction = restrictions[0]
restriction_exists_on_ancestor = restriction.collection != collection
else:
restriction = None
restriction_exists_on_ancestor = False
if request.method == "POST":
form = CollectionViewRestrictionForm(request.POST, instance=restriction)
if form.is_valid() and not restriction_exists_on_ancestor:
if form.cleaned_data["restriction_type"] == CollectionViewRestriction.NONE:
# remove any existing restriction
if restriction:
restriction.delete()
else:
restriction = form.save(commit=False)
restriction.collection = collection
form.save()
return render_modal_workflow(
request,
None,
None,
None,
json_data={
"step": "set_privacy_done",
"is_public": (form.cleaned_data["restriction_type"] == "none"),
},
)
else: # request is a GET
if not restriction_exists_on_ancestor:
if restriction:
form = CollectionViewRestrictionForm(instance=restriction)
else:
# no current view restrictions on this collection
form = CollectionViewRestrictionForm(
initial={"restriction_type": "none"}
)
if restriction_exists_on_ancestor:
# display a message indicating that there is a restriction at ancestor level -
# do not provide the form for setting up new restrictions
return render_modal_workflow(
request,
"wagtailadmin/collection_privacy/ancestor_privacy.html",
None,
{
"collection_with_restriction": restriction.collection,
},
)
else:
# no restriction set at ancestor level - can set restrictions here
return render_modal_workflow(
request,
"wagtailadmin/collection_privacy/set_privacy.html",
None,
{
"collection": collection,
"form": form,
},
json_data={"step": "set_privacy"},
)

View File

@@ -0,0 +1,194 @@
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect
from django.utils.translation import gettext_lazy
from wagtail import hooks
from wagtail.admin import messages
from wagtail.admin.forms.collections import CollectionForm
from wagtail.admin.ui.tables import TitleColumn
from wagtail.admin.views.generic import CreateView, DeleteView, EditView, IndexView
from wagtail.models import Collection
from wagtail.permissions import collection_permission_policy
class Index(IndexView):
permission_policy = collection_permission_policy
model = Collection
context_object_name = "collections"
results_template_name = "wagtailadmin/collections/index_results.html"
add_url_name = "wagtailadmin_collections:add"
index_url_name = "wagtailadmin_collections:index"
page_title = gettext_lazy("Collections")
add_item_label = gettext_lazy("Add a collection")
header_icon = "folder-open-1"
columns = [
TitleColumn(
"name",
label=gettext_lazy("Name"),
url_name="wagtailadmin_collections:edit",
id_accessor="0",
accessor="1",
)
]
_show_breadcrumbs = True
def get_queryset(self):
return self.permission_policy.instances_user_has_any_permission_for(
self.request.user, ["add", "change", "delete"]
).exclude(depth=1)
def get_table(self, object_list):
return super().get_table(object_list.get_indented_choices())
class Create(CreateView):
permission_policy = collection_permission_policy
model = Collection
form_class = CollectionForm
page_title = gettext_lazy("Add collection")
success_message = gettext_lazy("Collection '%(object)s' created.")
add_url_name = "wagtailadmin_collections:add"
edit_url_name = "wagtailadmin_collections:edit"
index_url_name = "wagtailadmin_collections:index"
header_icon = "folder-open-1"
_show_breadcrumbs = True
def get_form(self, form_class=None):
form = super().get_form(form_class)
# Now filter collections offered in parent field by current user's add permissions
collections = self.permission_policy.instances_user_has_permission_for(
self.request.user, "add"
)
form.fields["parent"].queryset = collections
return form
def save_instance(self):
instance = self.form.save(commit=False)
parent = self.form.cleaned_data["parent"]
parent.add_child(instance=instance)
return instance
class Edit(EditView):
permission_policy = collection_permission_policy
model = Collection
form_class = CollectionForm
template_name = "wagtailadmin/collections/edit.html"
success_message = gettext_lazy("Collection '%(object)s' updated.")
error_message = gettext_lazy("The collection could not be saved due to errors.")
delete_item_label = gettext_lazy("Delete collection")
edit_url_name = "wagtailadmin_collections:edit"
index_url_name = "wagtailadmin_collections:index"
delete_url_name = "wagtailadmin_collections:delete"
context_object_name = "collection"
header_icon = "folder-open-1"
_show_breadcrumbs = True
def _user_may_move_collection(self, user, instance):
"""
Is this instance used for assigning GroupCollectionPermissions to the user?
If so, this user is not allowed do move the collection to a new part of the tree
"""
if user.is_active and user.is_superuser:
return True
else:
permissions = (
self.permission_policy._get_user_permission_objects_for_actions(
user, {"add", "change", "delete"}
)
)
return not {
permission
for permission in permissions
if permission.collection_id == instance.pk
}
def get_queryset(self):
return self.permission_policy.instances_user_has_permission_for(
self.request.user, "change"
).exclude(depth=1)
def get_form(self, form_class=None):
form = super().get_form(form_class)
user = self.request.user
# if user does not have add permission anywhere, they can't move a collection
if not self.permission_policy.user_has_permission(user, "add"):
form.fields.pop("parent")
# If this instance is a collection used to assign permissions for this user,
# do not let the user move this collection.
elif not self._user_may_move_collection(user, form.instance):
form.fields.pop("parent")
else:
# Filter collections offered in parent field by current user's add permissions
collections = self.permission_policy.instances_user_has_permission_for(
user, "add"
)
form.fields["parent"].queryset = collections
# Disable unavailable options in CollectionChoiceField select widget
form.fields["parent"].disabled_queryset = form.instance.get_descendants(
inclusive=True
)
form.initial["parent"] = form.instance.get_parent().pk
return form
def save_instance(self):
instance = self.form.save()
if "parent" in self.form.changed_data:
instance.move(self.form.cleaned_data["parent"], "sorted-child")
return instance
class Delete(DeleteView):
permission_policy = collection_permission_policy
model = Collection
success_message = gettext_lazy("Collection '%(object)s' deleted.")
index_url_name = "wagtailadmin_collections:index"
edit_url_name = "wagtailadmin_collections:edit"
delete_url_name = "wagtailadmin_collections:delete"
page_title = gettext_lazy("Delete collection")
confirmation_message = gettext_lazy(
"Are you sure you want to delete this collection?"
)
header_icon = "folder-open-1"
def get_queryset(self):
return self.permission_policy.instances_user_has_permission_for(
self.request.user, "delete"
).exclude(depth=1)
def get_collection_contents(self):
collection_contents = [
hook(self.object)
for hook in hooks.get_hooks("describe_collection_contents")
]
# filter out any hook responses that report that the collection is empty
# (by returning None, or a dict with 'count': 0)
def is_nonempty(item_type):
return item_type and item_type["count"] > 0
return list(filter(is_nonempty, collection_contents))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
collection_contents = self.get_collection_contents()
if collection_contents:
# collection is non-empty; render the 'not allowed to delete' response
self.template_name = "wagtailadmin/collections/delete_not_empty.html"
context["collection_contents"] = collection_contents
return context
def post(self, request, pk):
self.object = get_object_or_404(self.get_queryset(), id=pk)
collection_contents = self.get_collection_contents()
if collection_contents:
# collection is non-empty; refuse to delete it
return HttpResponseForbidden()
messages.success(request, self.get_success_message())
self.object.delete()
return redirect(self.index_url_name)

View File

@@ -0,0 +1,26 @@
import json
from django.http import HttpResponseBadRequest, JsonResponse
from django.views import View
from wagtail.users.models import UserProfile
class DismissiblesView(View):
def get(self, request, *args, **kwargs):
# The UserProfile may not exist for the user, in which case return an empty object
profile = getattr(request.user, "wagtail_userprofile", None)
dismissibles = profile.dismissibles if profile else {}
return JsonResponse(dismissibles)
def patch(self, request, *args, **kwargs):
try:
updates = json.loads(request.body)
except json.JSONDecodeError:
return HttpResponseBadRequest()
# Make sure the UserProfile exists
profile = UserProfile.get_for_user(request.user)
profile.dismissibles.update(updates)
profile.save(update_fields=["dismissibles"])
return JsonResponse(profile.dismissibles)

View File

@@ -0,0 +1,200 @@
from django.apps import apps
from django.contrib.admin.utils import unquote
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from wagtail.admin.models import EditingSession
from wagtail.admin.ui.editing_sessions import EditingSessionsList
from wagtail.admin.utils import get_user_display_name
from wagtail.models import Page, Revision, RevisionMixin, WorkflowMixin
@require_POST
def ping(request, app_label, model_name, object_id, session_id):
try:
model = apps.get_model(app_label, model_name)
except LookupError:
raise Http404
unquoted_object_id = unquote(object_id)
content_type = ContentType.objects.get_for_model(model)
obj = get_object_or_404(model, pk=unquoted_object_id)
if isinstance(obj, Page):
can_edit = obj.permissions_for_user(request.user).can_edit()
else:
try:
permission_policy = model.snippet_viewset.permission_policy
except AttributeError:
# model is neither a Page nor a snippet
raise Http404
can_edit = permission_policy.user_has_permission_for_instance(
request.user, "change", obj
)
if not can_edit and isinstance(obj, WorkflowMixin):
workflow = obj.get_workflow()
if workflow is not None:
current_workflow_task = obj.current_workflow_task
can_edit = (
current_workflow_task
and current_workflow_task.user_can_access_editor(obj, request.user)
)
if not can_edit:
raise Http404
try:
session = EditingSession.objects.get(
id=session_id,
user=request.user,
content_type=content_type,
object_id=unquoted_object_id,
)
except EditingSession.DoesNotExist:
session = EditingSession(
content_type=content_type,
object_id=unquoted_object_id,
user=request.user,
)
session.last_seen_at = timezone.now()
session.is_editing = request.POST.get("is_editing", False)
try:
session.full_clean()
except ValidationError:
return JsonResponse({"error": "Invalid data"}, status=400)
else:
session.save()
other_sessions = (
EditingSession.objects.filter(
content_type=content_type,
object_id=unquoted_object_id,
last_seen_at__gte=timezone.now() - timezone.timedelta(minutes=1),
)
.exclude(id=session.id)
.select_related("user", "user__wagtail_userprofile")
.order_by("-last_seen_at")
)
# create a lookup of sessions indexed by user ID. Multiple sessions from the same user
# are merged, such that the most recently seen one is reported, but is_editing is true
# if any session has the editing flag set (not just the latest one).
other_sessions_lookup = {}
for other_session in other_sessions:
try:
other_session_info = other_sessions_lookup[other_session.user.pk]
except KeyError:
other_sessions_lookup[other_session.user.pk] = {
"session_id": other_session.id,
"user": other_session.user,
"last_seen_at": other_session.last_seen_at,
"is_editing": other_session.is_editing,
"revision_id": None,
}
else:
if other_session.is_editing:
other_session_info["is_editing"] = True
revision_id = request.POST.get("revision_id", None)
if revision_id is not None and issubclass(model, RevisionMixin):
all_revisions = obj.revisions.defer("content")
try:
original_revision = all_revisions.get(id=revision_id)
except Revision.DoesNotExist:
raise Http404
newest_revision = (
all_revisions.filter(created_at__gt=original_revision.created_at)
.order_by("-created_at", "-pk")
.select_related("user")
.first()
)
if newest_revision:
try:
session_info = other_sessions_lookup[newest_revision.user_id]
except KeyError:
other_sessions_lookup[newest_revision.user_id] = {
"session_id": None,
"user": newest_revision.user,
"last_seen_at": newest_revision.created_at,
"is_editing": False,
"revision_id": newest_revision.id,
}
else:
session_info["revision_id"] = newest_revision.id
if newest_revision.created_at > session_info["last_seen_at"]:
session_info["last_seen_at"] = newest_revision.created_at
try:
users_other_session = other_sessions_lookup[request.user.pk]
except KeyError:
pass
else:
# If the user has a different session that is not editing and hasn't
# created the latest revision, hide it as it's not relevant.
if (
not users_other_session["is_editing"]
and not users_other_session["revision_id"]
):
other_sessions_lookup.pop(request.user.pk)
# Sort the other sessions so that they are presented in the following order:
# 1. Prioritise any session with the latest revision. Then,
# 2. Prioritise any session that is currently editing. Then,
# 3. Prioritise any session with the smallest id, so that new sessions are
# appended to the end of the list (they're shown last). We are not sorting
# by last_seen_at to avoid shifting the order of the sessions as they
# ping the server.
other_sessions = sorted(
other_sessions_lookup.values(),
key=lambda other_session: (
# We want to sort revision_id and is_editing in descending order,
# but we want to sort session_id in ascending order. To achieve this
# in a single pass, we negate the values of revision_id and is_editing.
# We can negate revision_id because there can only be one (at most)
# session with revision_id, so we only care about the presence and
# not the ID itself, thus we can treat it as a boolean flag.
not other_session["revision_id"],
not other_session["is_editing"],
other_session["session_id"],
),
)
return JsonResponse(
{
"session_id": session.id,
"ping_url": reverse(
"wagtailadmin_editing_sessions:ping",
args=(app_label, model_name, object_id, session.id),
),
"release_url": reverse(
"wagtailadmin_editing_sessions:release", args=(session.id,)
),
"other_sessions": [
{
"session_id": other_session["session_id"],
"user": get_user_display_name(other_session["user"]),
"last_seen_at": other_session["last_seen_at"].isoformat(),
"is_editing": other_session["is_editing"],
"revision_id": other_session["revision_id"],
}
for other_session in other_sessions
],
"html": EditingSessionsList(session, other_sessions).render_html(),
}
)
@require_POST
def release(request, session_id):
EditingSession.objects.filter(id=session_id, user=request.user).delete()
return JsonResponse({})

View File

@@ -0,0 +1,30 @@
from .base import ( # noqa: F401
BaseListingView,
BaseObjectMixin,
BaseOperationView,
WagtailAdminTemplateMixin,
)
from .history import HistoryView # noqa: F401
from .mixins import ( # noqa: F401
BeforeAfterHookMixin,
CreateEditViewOptionalFeaturesMixin,
HookResponseMixin,
IndexViewOptionalFeaturesMixin,
LocaleMixin,
PanelMixin,
RevisionsRevertMixin,
)
from .models import ( # noqa: F401
CopyView,
CopyViewMixin,
CreateView,
DeleteView,
EditView,
IndexView,
InspectView,
RevisionsCompareView,
RevisionsUnscheduleView,
UnpublishView,
)
from .permissions import PermissionCheckedMixin # noqa: F401
from .usage import UsageView # noqa: F401

View File

@@ -0,0 +1,477 @@
from collections import namedtuple
from django.contrib.admin.utils import quote, unquote
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic.base import ContextMixin, TemplateResponseMixin
from django.views.generic.list import BaseListView
from django_filters.filters import (
ChoiceFilter,
DateFromToRangeFilter,
ModelChoiceFilter,
ModelMultipleChoiceFilter,
MultipleChoiceFilter,
)
from wagtail.admin import messages
from wagtail.admin.ui.tables import Column, Table
from wagtail.admin.utils import get_valid_next_url_from_request
from wagtail.admin.widgets.button import ButtonWithDropdown
from wagtail.utils.utils import flatten_choices
class WagtailAdminTemplateMixin(TemplateResponseMixin, ContextMixin):
"""
Mixin for views that render a template response using the standard Wagtail admin
page furniture.
Provides accessors for page title, subtitle and header icon.
"""
page_title = ""
page_subtitle = ""
header_icon = ""
# Breadcrumbs are opt-in until we have a design that can be consistently applied
_show_breadcrumbs = False
breadcrumbs_items = [{"url": reverse_lazy("wagtailadmin_home"), "label": _("Home")}]
template_name = "wagtailadmin/generic/base.html"
header_buttons = []
header_more_buttons = []
def get_page_title(self):
return self.page_title
def get_page_subtitle(self):
return self.page_subtitle
def get_header_title(self):
title = self.get_page_title()
subtitle = self.get_page_subtitle()
if subtitle:
title = f"{title}: {subtitle}"
return title
def get_header_icon(self):
return self.header_icon
def get_breadcrumbs_items(self):
return self.breadcrumbs_items
def get_header_buttons(self):
buttons = sorted(self.header_buttons)
more_buttons = self.get_header_more_buttons()
if more_buttons:
buttons.append(
ButtonWithDropdown(
buttons=more_buttons,
icon_name="dots-horizontal",
attrs={"aria-label": _("Actions")},
classname="w-h-slim-header",
)
)
return buttons
def get_header_more_buttons(self):
return sorted(self.header_more_buttons)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# These are only used for legacy header.html
# and view templates that don't use "wagtailadmin/generic/base.html"
context["page_title"] = self.get_page_title()
context["page_subtitle"] = self.get_page_subtitle()
context["header_icon"] = self.get_header_icon()
# Once all appropriate views use "wagtailadmin/generic/base.html" and
# the slim_header.html, _show_breadcrumbs can be removed
context["header_title"] = self.get_header_title()
context["breadcrumbs_items"] = None
if self._show_breadcrumbs:
context["breadcrumbs_items"] = self.get_breadcrumbs_items()
context["header_buttons"] = self.get_header_buttons()
return context
def get_template_names(self):
# Instead of always wrapping self.template_name in a list like
# TemplateResponseMixin does, only do so if it's not already a list/tuple.
# This allows us to use a list of template names in self.template_name.
if isinstance(self.template_name, (list, tuple)):
return self.template_name
return super().get_template_names()
class BaseObjectMixin:
"""Mixin for views that make use of a model instance."""
model = None
pk_url_kwarg = "pk"
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.pk = self.get_pk()
self.object = self.get_object()
self.model_opts = self.object._meta
def get_pk(self):
return unquote(str(self.kwargs[self.pk_url_kwarg]))
def get_base_object_queryset(self):
return self.model._default_manager.all()
def get_object(self):
if not self.model:
raise ImproperlyConfigured(
"Subclasses of wagtail.admin.views.generic.base.BaseObjectMixin must provide a "
"model attribute or a get_object method"
)
return get_object_or_404(self.get_base_object_queryset(), pk=self.pk)
class BaseOperationView(BaseObjectMixin, View):
"""Base view to perform an operation on a model instance using a POST request."""
success_message = None
success_message_extra_tags = ""
success_url_name = None
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.next_url = get_valid_next_url_from_request(request)
def perform_operation(self):
raise NotImplementedError
def get_success_message(self):
return self.success_message
def add_success_message(self):
success_message = self.get_success_message()
if success_message:
messages.success(
self.request,
success_message,
extra_tags=self.success_message_extra_tags,
)
def get_success_url(self):
if not self.success_url_name:
raise ImproperlyConfigured(
"Subclasses of wagtail.admin.views.generic.base.BaseOperationView must provide a "
"success_url_name attribute or a get_success_url method"
)
if self.next_url:
return self.next_url
return reverse(self.success_url_name, args=[quote(self.object.pk)])
def post(self, request, *args, **kwargs):
self.perform_operation()
self.add_success_message()
return redirect(self.get_success_url())
# Represents a django-filters filter that is currently in force on a listing queryset
ActiveFilter = namedtuple(
"ActiveFilter", ["auto_id", "field_label", "value", "removed_filter_url"]
)
class BaseListingView(WagtailAdminTemplateMixin, BaseListView):
template_name = "wagtailadmin/generic/listing.html"
results_template_name = "wagtailadmin/generic/listing_results.html"
results_only = False # If true, just render the results as an HTML fragment
table_class = Table
table_classname = None
columns = [Column("__str__", label=_("Title"))]
index_url_name = None
index_results_url_name = None
page_kwarg = "p"
default_ordering = None
filterset_class = None
def get_template_names(self):
if self.results_only:
if isinstance(self.results_template_name, (list, tuple)):
return self.results_template_name
return [self.results_template_name]
else:
return super().get_template_names()
@cached_property
def filters(self):
if self.filterset_class:
filterset = self.filterset_class(**self.get_filterset_kwargs())
# Don't use the filterset if it has no fields
if filterset.form.fields:
return filterset
@cached_property
def is_filtering(self):
# we are filtering if the filter form has changed from its default state
return (
self.filters and self.filters.is_valid() and self.filters.form.has_changed()
)
def get_filterset_kwargs(self):
return {
"data": self.request.GET,
"request": self.request,
}
def filter_queryset(self, queryset):
if self.filters and self.filters.is_valid():
queryset = self.filters.filter_queryset(queryset)
return queryset
def get_url_without_filter_param(self, param):
"""
Return the index URL with the given filter parameter removed from the query string
"""
base_url = self.index_results_url.split("?")[0]
query_dict = self.request.GET.copy()
query_dict.pop(self.page_kwarg, None) # reset pagination to first page
if isinstance(param, (list, tuple)):
for p in param:
query_dict.pop(p, None)
else:
query_dict.pop(param, None)
query_dict["_w_filter_fragment"] = 1
return base_url + "?" + query_dict.urlencode()
def get_url_without_filter_param_value(self, param, value):
"""
Return the index URL where the filter parameter with the given value has been removed
from the query string, preserving all other values for that parameter
"""
base_url = self.index_results_url.split("?")[0]
query_dict = self.request.GET.copy()
query_dict.pop(self.page_kwarg, None) # reset pagination to first page
query_dict.setlist(
param, [v for v in query_dict.getlist(param) if v != str(value)]
)
query_dict["_w_filter_fragment"] = 1
return base_url + "?" + query_dict.urlencode()
@cached_property
def active_filters(self):
filters = []
if not self.filters:
return filters
for field_name in self.filters.form.changed_data:
filter_def = self.filters.filters[field_name]
bound_field = self.filters.form[field_name]
try:
value = self.filters.form.cleaned_data[field_name]
except KeyError:
continue # invalid filter value
if value == bound_field.initial:
continue # filter value is the same as the default
if isinstance(filter_def, ModelMultipleChoiceFilter):
field = filter_def.field
for item in value:
filters.append(
ActiveFilter(
bound_field.auto_id,
filter_def.label,
field.label_from_instance(item),
self.get_url_without_filter_param_value(
field_name, item.pk
),
)
)
elif isinstance(filter_def, MultipleChoiceFilter):
choices = flatten_choices(filter_def.field.choices)
for item in value:
filters.append(
ActiveFilter(
bound_field.auto_id,
filter_def.label,
choices.get(str(item), str(item)),
self.get_url_without_filter_param_value(field_name, item),
)
)
elif isinstance(filter_def, ModelChoiceFilter):
field = filter_def.field
filters.append(
ActiveFilter(
bound_field.auto_id,
filter_def.label,
field.label_from_instance(value),
self.get_url_without_filter_param(field_name),
)
)
elif isinstance(filter_def, DateFromToRangeFilter):
start_date_display = date_format(value.start) if value.start else ""
end_date_display = date_format(value.stop) if value.stop else ""
widget = filter_def.field.widget
filters.append(
ActiveFilter(
bound_field.auto_id,
filter_def.label,
"%s - %s" % (start_date_display, end_date_display),
self.get_url_without_filter_param(
[
widget.suffixed(field_name, suffix)
for suffix in widget.suffixes
]
),
)
)
elif isinstance(filter_def, ChoiceFilter):
choices = flatten_choices(filter_def.field.choices)
filters.append(
ActiveFilter(
bound_field.auto_id,
filter_def.label,
choices.get(str(value), str(value)),
self.get_url_without_filter_param(field_name),
)
)
else:
filters.append(
ActiveFilter(
bound_field.auto_id,
filter_def.label,
str(value),
self.get_url_without_filter_param(field_name),
)
)
return filters
def get_valid_orderings(self):
orderings = []
for col in self.columns:
if col.sort_key:
orderings.append(col.sort_key)
orderings.append("-%s" % col.sort_key)
return orderings
@cached_property
def is_explicitly_ordered(self):
return "ordering" in self.request.GET
def get_ordering(self):
ordering = self.request.GET.get("ordering", self.default_ordering)
if ordering not in self.get_valid_orderings():
ordering = self.default_ordering
return ordering
@cached_property
def ordering(self):
return self.get_ordering()
def order_queryset(self, queryset):
if not self.ordering:
return queryset
ordering = self.ordering
if not isinstance(ordering, (list, tuple)):
ordering = (ordering,)
return queryset.order_by(*ordering)
def get_base_queryset(self):
if self.queryset is not None:
queryset = self.queryset
if isinstance(queryset, models.QuerySet):
queryset = queryset.all()
elif self.model is not None:
queryset = self.model._default_manager.all()
else:
raise ImproperlyConfigured(
"%(cls)s is missing a QuerySet. Define "
"%(cls)s.model, %(cls)s.queryset, or override "
"%(cls)s.get_queryset()." % {"cls": self.__class__.__name__}
)
return queryset
def get_queryset(self):
# Instead of calling super().get_queryset(), we copy the initial logic from Django's
# MultipleObjectMixin into get_base_queryset(). This allows us to perform additional steps
# before the ordering step (such as annotations), and funnel the call to get_ordering()
# through the cached property self.ordering so that we don't have to worry about calling
# get_ordering() multiple times.
# https://github.com/django/django/blob/stable/4.1.x/django/views/generic/list.py#L22-L47
queryset = self.get_base_queryset()
queryset = self.order_queryset(queryset)
queryset = self.filter_queryset(queryset)
return queryset
def get_table_kwargs(self):
return {
"ordering": self.ordering,
"classname": self.table_classname,
"base_url": self.index_url,
}
def get_table(self, object_list):
return self.table_class(
self.columns,
object_list,
**self.get_table_kwargs(),
)
@cached_property
def index_url(self):
return self.get_index_url()
def get_index_url(self):
if self.index_url_name:
return reverse(self.index_url_name)
@cached_property
def index_results_url(self):
return self.get_index_results_url()
def get_index_results_url(self):
if self.index_results_url_name:
return reverse(self.index_results_url_name)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
table = self.get_table(context["object_list"])
context["index_url"] = self.index_url
context["index_results_url"] = self.index_results_url
context["table"] = table
context["media"] = table.media
# On Django's BaseListView, a listing where pagination is applied, but the results
# only run to a single page, is considered is_paginated=False. Override this to
# always consider a listing to be paginated if pagination is applied. This ensures
# that we output "Page 1 of 1" as is standard in Wagtail.
context["is_paginated"] = context["page_obj"] is not None
if context["is_paginated"]:
context["items_count"] = context["paginator"].count
else:
context["items_count"] = len(context["object_list"])
if self.filters:
context["filters"] = self.filters
context["is_filtering"] = self.is_filtering
context["media"] += self.filters.form.media
# If we're rendering the results as an HTML fragment, the caller can pass a _w_filter_fragment=1
# URL parameter to indicate that the filters should be rendered as a <template> block so that
# we can replace the existing filters.
context["render_filters_fragment"] = (
self.request.GET.get("_w_filter_fragment")
and self.filters
and self.results_only
)
context["render_buttons_fragment"] = (
context.get("header_buttons") and self.results_only
)
return context

View File

@@ -0,0 +1,577 @@
import re
import urllib.parse
from django.conf import settings
from django.contrib.admin.utils import quote, unquote
from django.core.exceptions import (
ImproperlyConfigured,
ObjectDoesNotExist,
PermissionDenied,
)
from django.core.paginator import InvalidPage, Paginator
from django.db.models import Model
from django.forms.models import modelform_factory
from django.http import Http404
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import ContextMixin, View
from wagtail import hooks
from wagtail.admin.admin_url_finder import AdminURLFinder
from wagtail.admin.forms.choosers import (
BaseFilterForm,
CollectionFilterMixin,
LocaleFilterMixin,
SearchFilterMixin,
)
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.admin.ui.tables import Column, Table, TitleColumn
from wagtail.coreutils import resolve_model_string
from wagtail.models import CollectionMember, TranslatableMixin
from wagtail.permission_policies import BlanketPermissionPolicy, ModelPermissionPolicy
from wagtail.search.index import class_is_indexed
class ModalPageFurnitureMixin(ContextMixin):
"""
Add icon, page title and page subtitle to the template context
"""
icon = None
page_title = None
page_subtitle = None
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
"header_icon": self.icon,
"page_title": self.page_title,
"page_subtitle": self.page_subtitle,
}
)
return context
class ModelLookupMixin:
"""
Allows a class to have a `model` attribute, which can be set as either a model class or a string,
and then retrieve it as `model_class` to consistently get back a model class
"""
model = None
@cached_property
def model_class(self):
if self.model:
return resolve_model_string(self.model)
class PreserveURLParametersMixin:
"""
Adds support for passing designated URL parameters from the current request when constructing URLs
for links / form actions.
"""
preserve_url_parameters = ["multiple"]
@cached_property
def _preserved_param_string(self):
params = {}
for param in self.preserve_url_parameters:
try:
params[param] = self.request.GET[param]
except KeyError:
pass
return urllib.parse.urlencode(params)
def append_preserved_url_parameters(self, url):
"""
Given a base URL (which might already include URL parameters), append any URL parameters
from the preserve_url_parameters list that are present in the current request URL
"""
if self._preserved_param_string:
if "?" in url:
url += "&" + self._preserved_param_string
else:
url += "?" + self._preserved_param_string
return url
class CheckboxSelectColumn(Column):
cell_template_name = "wagtailadmin/generic/chooser/checkbox_select_cell.html"
class BaseChooseView(
ModalPageFurnitureMixin,
ModelLookupMixin,
PreserveURLParametersMixin,
ContextMixin,
View,
):
"""
Provides common functionality for views that present a (possibly searchable / filterable) list
of objects to choose from
"""
per_page = 10
ordering = None
chosen_url_name = None
chosen_multiple_url_name = None
results_url_name = None
icon = "snippet"
page_title = _("Choose")
filter_form_class = None
template_name = "wagtailadmin/generic/chooser/chooser.html"
results_template_name = "wagtailadmin/generic/chooser/results.html"
construct_queryset_hook_name = None
url_filter_parameters = []
def get_object_list(self):
return self.model_class.objects.all()
def apply_object_list_ordering(self, objects):
if isinstance(self.ordering, (list, tuple)):
objects = objects.order_by(*self.ordering)
elif self.ordering:
objects = objects.order_by(self.ordering)
elif objects.ordered:
# Preserve the model-level ordering if specified
pass
else:
# fall back on PK to ensure pagination is consistent
objects = objects.order_by("pk")
return objects
def get_filter_form_class(self):
if self.filter_form_class:
return self.filter_form_class
else:
bases = [BaseFilterForm]
if self.model_class:
if class_is_indexed(self.model_class):
bases.insert(0, SearchFilterMixin)
if issubclass(self.model_class, CollectionMember):
bases.insert(0, CollectionFilterMixin)
i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False)
if i18n_enabled and issubclass(self.model_class, TranslatableMixin):
bases.insert(0, LocaleFilterMixin)
return type(
"FilterForm",
tuple(bases),
{},
)
def get_filter_form(self):
FilterForm = self.get_filter_form_class()
return FilterForm(self.request.GET)
def filter_object_list(self, objects):
filters = {}
for filter in self.url_filter_parameters:
try:
filters[filter] = self.request.GET[filter]
except KeyError:
pass
if filters:
objects = objects.filter(**filters)
if self.construct_queryset_hook_name:
# allow hooks to modify the queryset
for hook in hooks.get_hooks(self.construct_queryset_hook_name):
objects = hook(objects, self.request)
if self.filter_form.is_valid():
objects = self.filter_form.filter(objects)
return objects
def get_results_url(self):
return self.append_preserved_url_parameters(reverse(self.results_url_name))
def get_chosen_multiple_url(self):
return self.append_preserved_url_parameters(
reverse(self.chosen_multiple_url_name)
)
@cached_property
def is_multiple_choice(self):
return self.request.GET.get("multiple")
@property
def columns(self):
return [self.title_column]
@property
def title_column(self):
if self.is_multiple_choice:
return TitleColumn(
"title",
label=_("Title"),
accessor=str,
label_prefix="chooser-modal-select",
)
else:
return TitleColumn(
"title",
label=_("Title"),
accessor=str,
get_url=(
lambda obj: self.append_preserved_url_parameters(
reverse(self.chosen_url_name, args=(quote(obj.pk),))
)
),
link_attrs={"data-chooser-modal-choice": True},
)
@property
def checkbox_column(self):
return CheckboxSelectColumn(
"select", label=_("Select"), width="1%", accessor="pk"
)
def get_results_page(self, request):
objects = self.get_object_list()
objects = self.apply_object_list_ordering(objects)
objects = self.filter_object_list(objects)
paginator = Paginator(objects, per_page=self.per_page)
try:
return paginator.page(request.GET.get("p", 1))
except InvalidPage:
raise Http404
def get(self, request):
self.filter_form = self.get_filter_form()
self.results = self.get_results_page(request)
columns = self.columns
if self.is_multiple_choice:
columns.insert(0, self.checkbox_column)
self.table = Table(columns, self.results)
return self.render_to_response()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
results_url = self.get_results_url()
# For result pagination links, we need a version of results_url with parameters removed,
# so that the pagination include can append its own parameters via the {% querystring %} template tag
results_pagination_url = re.sub(r"\?.*$", "", results_url)
context.update(
{
"results": self.results,
"table": self.table,
"results_url": results_url,
"results_pagination_url": results_pagination_url,
"is_searching": self.filter_form.is_searching,
"is_filtering_by_collection": self.filter_form.is_filtering_by_collection,
"is_multiple_choice": self.is_multiple_choice,
"search_query": self.filter_form.search_query,
"can_create": self.can_create(),
}
)
if self.is_multiple_choice:
context["chosen_multiple_url"] = self.get_chosen_multiple_url()
return context
def render_to_response(self):
raise NotImplementedError()
class CreationFormMixin(ModelLookupMixin, PreserveURLParametersMixin):
"""
Provides a form class for creating new objects
"""
creation_form_class = None
form_fields = None
exclude_form_fields = None
creation_form_template_name = "wagtailadmin/generic/chooser/creation_form.html"
creation_tab_id = "create"
create_action_label = _("Create")
create_action_clicked_label = None
create_url_name = None
permission_policy = None
def get_permission_policy(self):
if self.permission_policy:
return self.permission_policy
elif self.model_class and issubclass(self.model_class, Model):
return ModelPermissionPolicy(self.model_class)
else:
return BlanketPermissionPolicy(None)
def can_create(self):
return self.get_permission_policy().user_has_permission(
self.request.user, "add"
)
def get_creation_form_class(self):
if self.creation_form_class:
return self.creation_form_class
elif self.form_fields is not None or self.exclude_form_fields is not None:
return modelform_factory(
self.model_class,
fields=self.form_fields,
exclude=self.exclude_form_fields,
)
def get_creation_form_kwargs(self):
kwargs = {}
if self.request.method in ("POST", "PUT"):
kwargs.update(
{
"data": self.request.POST,
"files": self.request.FILES,
}
)
return kwargs
def get_creation_form(self):
form_class = self.get_creation_form_class()
if not form_class:
return None
return form_class(**self.get_creation_form_kwargs())
def get_create_url(self):
if not self.create_url_name:
raise ImproperlyConfigured(
"%r must provide a create_url_name attribute or a get_create_url method"
% type(self)
)
return self.append_preserved_url_parameters(reverse(self.create_url_name))
def get_creation_form_context_data(self, form):
return {
"creation_form": form,
"create_action_url": self.get_create_url(),
"create_action_label": self.create_action_label,
"create_action_clicked_label": self.create_action_clicked_label,
}
class ChooseViewMixin:
"""
A view that renders a complete modal response for the chooser, including a tab for the object
listing and (optionally) a 'create' form
"""
search_tab_label = _("Search")
creation_tab_label = None
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
"filter_form": self.filter_form,
"search_tab_label": self.search_tab_label,
"creation_tab_label": self.creation_tab_label
or self.create_action_label,
}
)
if context["can_create"]:
creation_form = self.get_creation_form()
if creation_form:
context.update(self.get_creation_form_context_data(creation_form))
return context
def get_response_json_data(self):
return {
"step": "choose",
}
# Return the choose view as a ModalWorkflow response
def render_to_response(self):
return render_modal_workflow(
self.request,
self.template_name,
None,
self.get_context_data(),
json_data=self.get_response_json_data(),
)
class ChooseView(ChooseViewMixin, CreationFormMixin, BaseChooseView):
pass
class ChooseResultsViewMixin:
"""
A view that renders just the object listing as an HTML fragment, used to replace the listing
when paginating or searching
"""
# Return just the HTML fragment for the results
def render_to_response(self):
return TemplateResponse(
self.request,
self.results_template_name,
self.get_context_data(),
)
class ChooseResultsView(ChooseResultsViewMixin, CreationFormMixin, BaseChooseView):
pass
class ChosenResponseMixin:
"""
Provides methods for returning the chosen object from the modal workflow.
"""
response_data_title_key = "title"
chosen_response_name = "chosen"
def get_object_id(self, instance):
return instance.pk
def get_display_title(self, instance):
"""
Return a string representation of the given object instance
"""
return str(instance)
def get_edit_item_url(self, instance):
return AdminURLFinder(user=self.request.user).get_edit_url(instance)
def get_chosen_response_data(self, item):
"""
Generate the result value to be returned when an object has been chosen
"""
return {
"id": str(self.get_object_id(item)),
self.response_data_title_key: self.get_display_title(item),
"edit_url": self.get_edit_item_url(item),
}
def _wrap_chosen_response_data(self, response_data):
"""
Wrap a response_data JSON payload in a modal workflow response
"""
return render_modal_workflow(
self.request,
None,
None,
None,
json_data={"step": self.chosen_response_name, "result": response_data},
)
def get_multiple_chosen_response(self, items):
response_data = [self.get_chosen_response_data(item) for item in items]
return self._wrap_chosen_response_data(response_data)
def get_chosen_response(self, item):
"""
Return the HTTP response to indicate that an object has been chosen
"""
response_data = self.get_chosen_response_data(item)
if self.request.GET.get("multiple"):
# a multiple result was requested but we're only returning one,
# so wrap as a list
response_data = [response_data]
return self._wrap_chosen_response_data(response_data)
class ChosenViewMixin(ModelLookupMixin):
"""
A view that takes an object ID in the URL and returns a modal workflow response indicating
that object has been chosen
"""
def get_object(self, pk):
return self.model_class.objects.get(pk=pk)
def get(self, request, pk):
try:
item = self.get_object(unquote(pk))
except ObjectDoesNotExist:
raise Http404
return self.get_chosen_response(item)
class ChosenView(ChosenViewMixin, ChosenResponseMixin, View):
pass
class ChosenMultipleViewMixin(ModelLookupMixin):
"""
A view that takes a list of 'id' URL parameters and returns a modal workflow response indicating
that those objects have been chosen
"""
def get_objects(self, pks):
return self.model_class.objects.filter(pk__in=pks)
def get(self, request):
items = self.get_objects(request.GET.getlist("id"))
return self.get_multiple_chosen_response(items)
class ChosenMultipleView(ChosenMultipleViewMixin, ChosenResponseMixin, View):
pass
class CreateViewMixin:
"""
A view that handles submissions of the 'create' form
"""
model = None
def dispatch(self, request, *args, **kwargs):
if not self.can_create():
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get(self, request):
self.form = self.get_creation_form()
return self.get_reshow_creation_form_response()
def save_form(self, form):
return form.save()
def post(self, request):
self.form = self.get_creation_form()
if self.form.is_valid():
object = self.save_form(self.form)
return self.get_chosen_response(object)
else:
return self.get_reshow_creation_form_response()
def get_reshow_creation_form_response(self):
context = {"view": self}
context.update(self.get_creation_form_context_data(self.form))
response_html = render_to_string(
self.creation_form_template_name, context, self.request
)
return render_modal_workflow(
self.request,
None,
None,
None,
json_data={
"step": "reshow_creation_form",
"htmlFragment": response_html,
},
)
class CreateView(CreateViewMixin, CreationFormMixin, ChosenResponseMixin, View):
pass

View File

@@ -0,0 +1,487 @@
from datetime import timedelta
import django_filters
from django.contrib.admin.utils import quote
from django.core.paginator import Paginator
from django.forms import CheckboxSelectMultiple
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.text import capfirst
from django.utils.translation import gettext, gettext_lazy
from django.views.generic import TemplateView
from wagtail.admin.filters import (
DateRangePickerWidget,
MultipleUserFilter,
WagtailFilterSet,
)
from wagtail.admin.ui.tables import Column, DateColumn, UserColumn
from wagtail.admin.utils import get_latest_str
from wagtail.admin.views.generic.base import (
BaseListingView,
BaseObjectMixin,
WagtailAdminTemplateMixin,
)
from wagtail.admin.views.generic.permissions import PermissionCheckedMixin
from wagtail.admin.widgets.button import HeaderButton
from wagtail.log_actions import registry as log_registry
from wagtail.models import (
BaseLogEntry,
DraftStateMixin,
PreviewableMixin,
Revision,
RevisionMixin,
TaskState,
WorkflowState,
)
def get_actions_for_filter(queryset):
# Only return those actions used by model log entries.
actions = set(queryset.get_actions())
return [action for action in log_registry.get_choices() if action[0] in actions]
class HistoryFilterSet(WagtailFilterSet):
action = django_filters.MultipleChoiceFilter(
label=gettext_lazy("Action"),
widget=CheckboxSelectMultiple,
# choices are set dynamically in __init__()
)
user = MultipleUserFilter(
label=gettext_lazy("User"),
widget=CheckboxSelectMultiple,
# queryset is set dynamically in __init__()
)
timestamp = django_filters.DateFromToRangeFilter(
label=gettext_lazy("Date"), widget=DateRangePickerWidget
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
actions = self.get_action_choices()
if not actions:
del self.filters["action"]
else:
self.filters["action"].extra["choices"] = actions
users = self.get_users_queryset()
if not users.exists():
del self.filters["user"]
else:
self.filters["user"].extra["queryset"] = users
def get_action_choices(self):
return get_actions_for_filter(self.queryset)
def get_users_queryset(self):
return self.queryset.get_users()
class ActionColumn(Column):
def __init__(self, *args, object, url_names, user_can_unschedule, **kwargs):
super().__init__(*args, **kwargs)
self.object = object
self.url_names = url_names
self.user_can_unschedule = user_can_unschedule
self.revision_enabled = isinstance(object, RevisionMixin)
self.draftstate_enabled = isinstance(object, DraftStateMixin)
@cached_property
def cell_template_name(self):
if self.revision_enabled:
return "wagtailadmin/generic/history/action_cell.html"
return super().cell_template_name
def get_status(self, instance, parent_context):
if self.draftstate_enabled:
if (
instance.action == "wagtail.publish"
and instance.revision_id == self.object.live_revision_id
):
return gettext("Live version")
elif (
instance.content_changed
and instance.revision_id == self.object.latest_revision_id
):
return gettext("Current draft")
return None
def get_actions(self, instance, parent_context):
actions = []
# Do not show the revision actions if the log entry:
# - has no revision attached
# - has no content changes
# - is a "publish" action
# (because we want to show the options on the "edit" action instead)
if (
not self.revision_enabled
or not instance.revision_id
or not instance.content_changed
or instance.action == "wagtail.publish"
):
return actions
if (
isinstance(self.object, PreviewableMixin)
and self.object.is_previewable()
and (url_name := self.url_names.get("revisions_view"))
):
url = reverse(url_name, args=(quote(self.object.pk), instance.revision_id))
action = {"url": url, "label": gettext("Preview")}
actions.append(action)
if instance.revision_id == self.object.latest_revision_id:
if url_name := self.url_names.get("edit"):
url = reverse(url_name, args=(quote(self.object.pk),))
action = {"url": url, "label": gettext("Edit")}
actions.append(action)
elif url_name := self.url_names.get("revisions_revert"):
url = reverse(url_name, args=(quote(self.object.pk), instance.revision_id))
action = {"url": url, "label": gettext("Review this version")}
actions.append(action)
if url_name := self.url_names.get("revisions_compare"):
if instance.previous_revision_id:
url = reverse(
url_name,
args=(
quote(self.object.pk),
instance.previous_revision_id,
instance.revision_id,
),
)
action = {"url": url, "label": gettext("Compare with previous version")}
actions.append(action)
if instance.revision_id != self.object.latest_revision_id:
url = reverse(
url_name,
args=(quote(self.object.pk), instance.revision_id, "latest"),
)
action = {"url": url, "label": gettext("Compare with current version")}
actions.append(action)
if (
(url_name := self.url_names.get("revisions_unschedule"))
and instance.revision.approved_go_live_at
and self.user_can_unschedule
):
url = reverse(url_name, args=(quote(self.object.pk), instance.revision_id))
action = {"url": url, "label": gettext("Cancel scheduled publish")}
actions.append(action)
return actions
def get_cell_context_data(self, instance, parent_context):
context = super().get_cell_context_data(instance, parent_context)
context["status"] = self.get_status(instance, parent_context)
context["actions"] = self.get_actions(instance, parent_context)
return context
class LogEntryUserColumn(UserColumn):
def __init__(self, name, **kwargs):
# Instead of accepting a blank_display_name arg, we'll make use of the
# BaseLogEntry.user_display_name property which also handles the display
# name for a deleted user (as the BaseLogEntry still stores the ID).
super().__init__(name, blank_display_name=None, **kwargs)
def get_cell_context_data(self, instance, parent_context):
context = super().get_cell_context_data(instance, parent_context)
if not context["display_name"]:
context["display_name"] = instance.user_display_name
return context
class HistoryView(PermissionCheckedMixin, BaseObjectMixin, BaseListingView):
any_permission_required = ["add", "change", "delete"]
page_title = gettext_lazy("History")
results_template_name = "wagtailadmin/generic/history_results.html"
header_icon = "history"
is_searchable = False
paginate_by = 20
filterset_class = HistoryFilterSet
history_url_name = None
history_results_url_name = None
edit_url_name = None
revisions_view_url_name = None
revisions_revert_url_name = None
revisions_compare_url_name = None
revisions_unschedule_url_name = None
@cached_property
def columns(self):
return [
ActionColumn(
"message",
label=gettext_lazy("Action"),
object=self.object,
url_names={
"edit": self.edit_url_name,
"revisions_view": self.revisions_view_url_name,
"revisions_revert": self.revisions_revert_url_name,
"revisions_compare": self.revisions_compare_url_name,
"revisions_unschedule": self.revisions_unschedule_url_name,
},
user_can_unschedule=self.user_can_unschedule(),
),
LogEntryUserColumn("user", label=gettext_lazy("User"), width="25%"),
DateColumn("timestamp", label=gettext_lazy("Date"), width="15%"),
]
def get_base_object_queryset(self):
queryset = super().get_base_object_queryset()
if issubclass(queryset.model, RevisionMixin):
return queryset.select_related("latest_revision")
return queryset
def get_page_subtitle(self):
return get_latest_str(self.object)
def get_breadcrumbs_items(self):
items = []
if self.index_url_name:
items.append(
{
"url": reverse(self.index_url_name),
"label": capfirst(self.model._meta.verbose_name_plural),
}
)
edit_url = self.get_edit_url(self.object)
obj_name = self.get_page_subtitle()
if edit_url:
items.append(
{
"url": edit_url,
"label": obj_name,
}
)
items.append(
{
"url": "",
"label": gettext("History"),
"sublabel": obj_name,
}
)
return self.breadcrumbs_items + items
@cached_property
def header_buttons(self):
return [
HeaderButton(
label=gettext("Edit"),
url=self.get_edit_url(self.object),
icon_name="edit",
),
]
def get_edit_url(self, instance):
if self.edit_url_name:
return reverse(self.edit_url_name, args=(quote(instance.pk),))
def get_history_url(self, instance):
if self.history_url_name:
return reverse(self.history_url_name, args=(quote(instance.pk),))
def get_history_results_url(self, instance):
if self.history_results_url_name:
return reverse(self.history_results_url_name, args=(quote(instance.pk),))
def get_index_url(self): # used for pagination links
return self.get_history_url(self.object)
def get_index_results_url(self):
return self.get_history_results_url(self.object)
def user_can_unschedule(self):
return self.user_has_permission("publish")
def get_context_data(self, *args, object_list=None, **kwargs):
context = super().get_context_data(*args, object_list=object_list, **kwargs)
context["object"] = self.object
context["model_opts"] = BaseLogEntry._meta
return context
def get_base_queryset(self):
queryset = log_registry.get_logs_for_instance(self.object)
return self._annotate_queryset(queryset)
def _annotate_queryset(self, queryset):
queryset = queryset.select_related("user", "user__wagtail_userprofile")
if isinstance(self.object, RevisionMixin):
queryset = queryset.select_related("revision").annotate(
previous_revision_id=Revision.objects.previous_revision_id_subquery(),
)
return queryset
def get_filterset_kwargs(self):
# Pass custom queryset so the FilterSet can use it when initialising the
# filters, instead of using the default model.objects.all() queryset.
kwargs = super().get_filterset_kwargs()
kwargs["queryset"] = self.get_base_queryset()
return kwargs
class WorkflowHistoryView(BaseObjectMixin, WagtailAdminTemplateMixin, TemplateView):
template_name = "wagtailadmin/shared/workflow_history/index.html"
page_kwarg = "p"
workflow_history_url_name = None
workflow_history_detail_url_name = None
@cached_property
def workflow_states(self):
return WorkflowState.objects.for_instance(self.object).order_by("-created_at")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
paginator = Paginator(self.workflow_states, per_page=20)
workflow_states = paginator.get_page(self.request.GET.get(self.page_kwarg))
context.update(
{
"object": self.object,
"workflow_states": workflow_states,
"workflow_history_url_name": self.workflow_history_url_name,
"workflow_history_detail_url_name": self.workflow_history_detail_url_name,
"model_opts": self.object._meta,
}
)
return context
class WorkflowHistoryDetailView(
BaseObjectMixin, WagtailAdminTemplateMixin, TemplateView
):
template_name = "wagtailadmin/shared/workflow_history/detail.html"
workflow_state_url_kwarg = "workflow_state_id"
workflow_history_url_name = None
page_title = gettext_lazy("Workflow progress")
header_icon = "list-ul"
object_icon = "doc-empty-inverse"
@cached_property
def workflow_state(self):
return get_object_or_404(
WorkflowState.objects.for_instance(self.object).filter(
id=self.kwargs[self.workflow_state_url_kwarg]
),
)
@cached_property
def revisions(self):
"""
Get QuerySet of all revisions that have existed during this workflow state.
It's possible that the object is edited while the workflow is running,
so some tasks may be repeated. All tasks that have been completed no matter
what revision needs to be displayed on this page.
"""
return (
Revision.objects.for_instance(self.object)
.filter(
id__in=TaskState.objects.filter(
workflow_state=self.workflow_state
).values_list("revision_id", flat=True),
)
.order_by("-created_at")
)
@cached_property
def tasks(self):
return self.workflow_state.workflow.tasks.all()
@cached_property
def task_states_by_revision(self):
"""Get QuerySet of tasks completed for each revision."""
task_states_by_revision_task = [
(
revision,
{
task_state.task: task_state
for task_state in TaskState.objects.filter(
workflow_state=self.workflow_state, revision=revision
).specific()
},
)
for revision in self.revisions
]
# Make sure task states are always in a consistent order
# In some cases, they can be completed in a different order to what they are defined
task_states_by_revision = [
(revision, [task_states_by_task.get(task, None) for task in self.tasks])
for revision, task_states_by_task in task_states_by_revision_task
]
return task_states_by_revision
@cached_property
def timeline(self):
"""Generate timeline."""
completed_task_states = (
TaskState.objects.filter(workflow_state=self.workflow_state)
.exclude(finished_at__isnull=True)
.exclude(status=TaskState.STATUS_CANCELLED)
)
timeline = [
{
"time": self.workflow_state.created_at,
"action": "workflow_started",
"workflow_state": self.workflow_state,
}
]
if self.workflow_state.status not in (
WorkflowState.STATUS_IN_PROGRESS,
WorkflowState.STATUS_NEEDS_CHANGES,
):
last_task = completed_task_states.order_by("finished_at").last()
if last_task:
timeline.append(
{
"time": last_task.finished_at + timedelta(milliseconds=1),
"action": "workflow_completed",
"workflow_state": self.workflow_state,
}
)
for revision in self.revisions:
timeline.append(
{
"time": revision.created_at,
"action": "edited",
"revision": revision,
}
)
for task_state in completed_task_states:
timeline.append(
{
"time": task_state.finished_at,
"action": "task_completed",
"task_state": task_state,
}
)
timeline.sort(key=lambda t: t["time"])
timeline.reverse()
return timeline
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
"object": self.object,
"object_icon": self.object_icon,
"workflow_state": self.workflow_state,
"tasks": self.tasks,
"task_states_by_revision": self.task_states_by_revision,
"timeline": self.timeline,
"workflow_history_url_name": self.workflow_history_url_name,
}
)
return context

View File

@@ -0,0 +1,42 @@
from django.utils import timezone
from django.utils.text import capfirst
from django.utils.translation import gettext as _
from wagtail.admin.utils import get_latest_str
from wagtail.admin.views.generic.base import BaseOperationView
from wagtail.log_actions import log
class LockView(BaseOperationView):
success_message_extra_tags = "lock"
def perform_operation(self):
if self.object.locked:
return
self.object.locked = True
self.object.locked_by = self.request.user
self.object.locked_at = timezone.now()
self.object.save(update_fields=["locked", "locked_by", "locked_at"])
log(instance=self.object, action="wagtail.lock", user=self.request.user)
class UnlockView(BaseOperationView):
success_message_extra_tags = "unlock"
def perform_operation(self):
if not self.object.locked:
return
self.object.locked = False
self.object.locked_by = None
self.object.locked_at = None
self.object.save(update_fields=["locked", "locked_by", "locked_at"])
log(instance=self.object, action="wagtail.unlock", user=self.request.user)
def get_success_message(self):
return capfirst(
_("%(model_name)s '%(title)s' is now unlocked.")
% {
"model_name": self.model._meta.verbose_name,
"title": get_latest_str(self.object),
}
)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,400 @@
import os.path
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers
from django.views.generic.base import TemplateView, View
from wagtail.admin.views.generic import PermissionCheckedMixin
from wagtail.models import UploadedFile
class AddView(PermissionCheckedMixin, TemplateView):
# subclasses need to provide:
# - permission_policy
# - template_name
# - edit_object_url_name
# - delete_object_url_name
# - edit_object_form_prefix
# - context_object_name
# - context_object_id_name
# - edit_upload_url_name
# - delete_upload_url_name
# - edit_upload_form_prefix
# - context_upload_name
# - context_upload_id_name
# - get_model()
# - get_upload_form_class()
# - get_edit_form_class()
permission_required = "add"
edit_form_template_name = "wagtailadmin/generic/multiple_upload/edit_form.html"
@method_decorator(vary_on_headers("X-Requested-With"))
def dispatch(self, request):
self.model = self.get_model()
return super().dispatch(request)
def save_object(self, form):
return form.save()
def get_edit_object_form_context_data(self):
"""
Return the context data necessary for rendering the HTML form for editing
an object that has been successfully uploaded
"""
edit_form_class = self.get_edit_form_class()
return {
self.context_object_name: self.object,
"edit_action": reverse(self.edit_object_url_name, args=(self.object.pk,)),
"delete_action": reverse(
self.delete_object_url_name, args=(self.object.pk,)
),
"form": edit_form_class(
instance=self.object,
prefix="%s-%d" % (self.edit_object_form_prefix, self.object.pk),
user=self.request.user,
),
}
def get_edit_object_response_data(self):
"""
Return the JSON response data for an object that has been successfully uploaded
"""
return {
"success": True,
self.context_object_id_name: self.object.pk,
"form": render_to_string(
self.edit_form_template_name,
self.get_edit_object_form_context_data(),
request=self.request,
),
}
def get_edit_upload_form_context_data(self):
"""
Return the context data necessary for rendering the HTML form for supplying the
metadata to turn an upload object into a final object
"""
edit_form_class = self.get_edit_form_class()
return {
self.context_upload_name: self.upload_object,
"edit_action": reverse(
self.edit_upload_url_name, args=(self.upload_object.id,)
),
"delete_action": reverse(
self.delete_upload_url_name, args=(self.upload_object.id,)
),
"form": edit_form_class(
instance=self.object,
prefix="%s-%d" % (self.edit_upload_form_prefix, self.upload_object.id),
user=self.request.user,
),
}
def get_edit_upload_response_data(self):
"""
Return the JSON response data for an object that has been uploaded to an
upload object and now needs extra metadata to become a final object
"""
return {
"success": True,
self.context_upload_id_name: self.upload_object.id,
"form": render_to_string(
self.edit_form_template_name,
self.get_edit_upload_form_context_data(),
request=self.request,
),
}
def get_invalid_response_data(self, form):
"""
Return the JSON response data for an invalid form submission
"""
return {
"success": False,
"error_message": "\n".join(form.errors["file"]),
}
def post(self, request):
if not request.FILES:
return HttpResponseBadRequest("Must upload a file")
# Build a form for validation
upload_form_class = self.get_upload_form_class()
form = upload_form_class(
{
"title": request.POST.get("title", request.FILES["files[]"].name),
"collection": request.POST.get("collection"),
},
{
"file": request.FILES["files[]"],
},
user=request.user,
)
if form.is_valid():
# Save it
self.object = self.save_object(form)
# Success! Send back an edit form for this object to the user
return JsonResponse(self.get_edit_object_response_data())
elif "file" in form.errors:
# The uploaded file is invalid; reject it now
return JsonResponse(self.get_invalid_response_data(form))
else:
# Some other field of the form has failed validation, e.g. a required metadata field
# on a custom image model. Store the object as an UploadedFile instance instead and
# present the edit form so that it will become a proper object when successfully filled in
self.upload_object = UploadedFile.objects.create(
for_content_type=ContentType.objects.get_for_model(self.get_model()),
file=self.request.FILES["files[]"],
uploaded_by_user=self.request.user,
)
self.object = self.model(
title=self.request.FILES["files[]"].name,
collection_id=self.request.POST.get("collection"),
)
return JsonResponse(self.get_edit_upload_response_data())
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Instantiate a dummy copy of the form that we can retrieve validation messages and media from;
# actual rendering of forms will happen on AJAX POST rather than here
upload_form_class = self.get_upload_form_class()
self.form = upload_form_class(user=self.request.user)
selected_collection_id = self.request.GET.get("collection_id")
collections = self.permission_policy.collections_user_has_permission_for(
self.request.user, "add"
)
if len(collections) < 2:
# no need to show a collections chooser
collections = None
context.update(
{
"help_text": self.form.fields["file"].help_text,
"collections": collections,
"form_media": self.form.media,
"selected_collection_id": selected_collection_id,
}
)
return context
class EditView(View):
# subclasses need to provide:
# - permission_policy
# - pk_url_kwarg
# - edit_object_form_prefix
# - context_object_name
# - context_object_id_name
# - edit_object_url_name
# - delete_object_url_name
# - get_model()
# - get_edit_form_class()
http_method_names = ["post"]
edit_form_template_name = "wagtailadmin/generic/multiple_upload/edit_form.html"
def save_object(self, form):
form.save()
def post(self, request, *args, **kwargs):
object_id = kwargs[self.pk_url_kwarg]
self.model = self.get_model()
self.form_class = self.get_edit_form_class()
self.object = get_object_or_404(self.model, pk=object_id)
if not self.permission_policy.user_has_permission_for_instance(
request.user, "change", self.object
):
raise PermissionDenied
form = self.form_class(
request.POST,
request.FILES,
instance=self.object,
prefix="%s-%d" % (self.edit_object_form_prefix, object_id),
user=request.user,
)
if form.is_valid():
self.save_object(form)
return JsonResponse(
{
"success": True,
self.context_object_id_name: self.object.pk,
}
)
else:
return JsonResponse(
{
"success": False,
self.context_object_id_name: self.object.pk,
"form": render_to_string(
self.edit_form_template_name,
{
self.context_object_name: self.object, # only used for tests
"edit_action": reverse(
self.edit_object_url_name, args=(object_id,)
),
"delete_action": reverse(
self.delete_object_url_name, args=(object_id,)
),
"form": form,
},
request=request,
),
}
)
class DeleteView(View):
# subclasses need to provide:
# - permission_policy
# - pk_url_kwarg
# - context_object_id_name
http_method_names = ["post"]
def post(self, request, *args, **kwargs):
object_id = kwargs[self.pk_url_kwarg]
self.model = self.get_model()
self.object = get_object_or_404(self.model, pk=object_id)
object_id = (
self.object.pk
) # retrieve object id cast to the appropriate type (usually int)
if not self.permission_policy.user_has_permission_for_instance(
request.user, "delete", self.object
):
raise PermissionDenied
self.object.delete()
return JsonResponse(
{
"success": True,
self.context_object_id_name: object_id,
}
)
class CreateFromUploadView(View):
# subclasses need to provide:
# - edit_upload_url_name
# - delete_upload_url_name
# - upload_pk_url_kwarg
# - edit_upload_form_prefix
# - context_object_id_name
# - context_upload_name
# - get_model()
# - get_edit_form_class()
http_method_names = ["post"]
edit_form_template_name = "wagtailadmin/generic/multiple_upload/edit_form.html"
def save_object(self, form):
self.object.file.save(
os.path.basename(self.upload.file.name), self.upload.file.file, save=False
)
self.object.uploaded_by_user = self.request.user
form.save()
def post(self, request, *args, **kwargs):
upload_id = kwargs[self.upload_pk_url_kwarg]
self.model = self.get_model()
self.form_class = self.get_edit_form_class()
self.upload = get_object_or_404(
UploadedFile,
id=upload_id,
for_content_type=ContentType.objects.get_for_model(self.model),
)
if self.upload.uploaded_by_user != request.user:
raise PermissionDenied
self.object = self.model()
form = self.form_class(
request.POST,
request.FILES,
instance=self.object,
prefix="%s-%d" % (self.edit_upload_form_prefix, upload_id),
user=request.user,
)
if form.is_valid():
self.save_object(form)
self.upload.file.delete()
self.upload.delete()
return JsonResponse(
{
"success": True,
self.context_object_id_name: self.object.id,
}
)
else:
return JsonResponse(
{
"success": False,
"form": render_to_string(
self.edit_form_template_name,
{
self.context_upload_name: self.upload,
"edit_action": reverse(
self.edit_upload_url_name, args=(self.upload.id,)
),
"delete_action": reverse(
self.delete_upload_url_name, args=(self.upload.id,)
),
"form": form,
},
request=request,
),
}
)
class DeleteUploadView(View):
# subclasses need to provide:
# - upload_pk_url_kwarg
http_method_names = ["post"]
def post(self, request, *args, **kwargs):
upload_id = kwargs[self.upload_pk_url_kwarg]
upload = get_object_or_404(
UploadedFile,
id=upload_id,
for_content_type=ContentType.objects.get_for_model(self.get_model()),
)
if upload.uploaded_by_user != request.user:
raise PermissionDenied
upload.file.delete()
upload.delete()
return JsonResponse(
{
"success": True,
}
)

View File

@@ -0,0 +1,49 @@
from django.core.exceptions import PermissionDenied
class PermissionCheckedMixin:
"""
Mixin for class-based views to enforce permission checks according to
a permission policy (see wagtail.permission_policies).
To take advantage of this, subclasses should set the class property:
* permission_policy (a policy object)
and either of:
* permission_required (an action name such as 'add', 'change' or 'delete')
* any_permission_required (a list of action names - the user must have
one or more of those permissions)
"""
permission_policy = None
permission_required = None
any_permission_required = None
def dispatch(self, request, *args, **kwargs):
if self.permission_required is not None:
if not self.user_has_permission(self.permission_required):
raise PermissionDenied
if self.any_permission_required is not None:
if not self.user_has_any_permission(self.any_permission_required):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def user_has_permission(self, permission):
return not self.permission_policy or (
self.permission_policy.user_has_permission(self.request.user, permission)
)
def user_has_permission_for_instance(self, permission, instance):
return not self.permission_policy or (
self.permission_policy.user_has_permission_for_instance(
self.request.user, permission, instance
)
)
def user_has_any_permission(self, permissions):
return not self.permission_policy or (
self.permission_policy.user_has_any_permission(
self.request.user, permissions
)
)

View File

@@ -0,0 +1,165 @@
from time import time
from django.contrib.admin.utils import unquote
from django.core.exceptions import PermissionDenied
from django.http import Http404, JsonResponse
from django.http.request import QueryDict
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views.generic import View
from wagtail.admin.panels import get_edit_handler
from wagtail.models import PreviewableMixin, RevisionMixin
from wagtail.utils.decorators import xframe_options_sameorigin_override
class PreviewOnEdit(View):
model = None
form_class = None
http_method_names = ("post", "get", "delete")
preview_expiration_timeout = 60 * 60 * 24 # seconds
session_key_prefix = "wagtail-preview-"
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.object = self.get_object()
def dispatch(self, request, *args, **kwargs):
if not isinstance(self.object, PreviewableMixin):
raise Http404
return super().dispatch(request, *args, **kwargs)
def remove_old_preview_data(self):
expiration = time() - self.preview_expiration_timeout
expired_keys = [
k
for k, v in self.request.session.items()
if k.startswith(self.session_key_prefix) and v[1] < expiration
]
# Removes the session key gracefully
for k in expired_keys:
self.request.session.pop(k)
@property
def session_key(self):
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
unique_key = f"{app_label}-{model_name}-{self.object.pk}"
return f"{self.session_key_prefix}{unique_key}"
def get_object(self):
obj = get_object_or_404(self.model, pk=unquote(str(self.kwargs["pk"])))
if isinstance(obj, RevisionMixin):
obj = obj.get_latest_revision_as_object()
return obj
def get_form_class(self):
if self.form_class:
return self.form_class
return get_edit_handler(self.model).get_form_class()
def get_form(self, query_dict):
form_class = self.get_form_class()
if not query_dict:
# Query dict is empty, return null form
return form_class(instance=self.object, for_user=self.request.user)
return form_class(query_dict, instance=self.object, for_user=self.request.user)
def _get_data_from_session(self):
post_data, _ = self.request.session.get(self.session_key, (None, None))
if not isinstance(post_data, str):
post_data = ""
return QueryDict(post_data)
def post(self, request, *args, **kwargs):
self.remove_old_preview_data()
form = self.get_form(request.POST)
is_valid = form.is_valid()
if is_valid:
# TODO: Handle request.FILES.
request.session[self.session_key] = request.POST.urlencode(), time()
is_available = True
else:
# Check previous data in session to determine preview availability
form = self.get_form(self._get_data_from_session())
is_available = form.is_valid()
return JsonResponse({"is_valid": is_valid, "is_available": is_available})
def error_response(self):
return TemplateResponse(
self.request,
"wagtailadmin/generic/preview_error.html",
{"object": self.object},
)
@method_decorator(xframe_options_sameorigin_override)
def get(self, request, *args, **kwargs):
form = self.get_form(self._get_data_from_session())
if not form.is_valid():
return self.error_response()
form.save(commit=False)
try:
preview_mode = request.GET.get("mode", self.object.default_preview_mode)
except IndexError:
raise PermissionDenied
extra_attrs = {
"in_preview_panel": request.GET.get("in_preview_panel") == "true",
"is_editing": True,
}
return self.object.make_preview_request(request, preview_mode, extra_attrs)
def delete(self, request, *args, **kwargs):
request.session.pop(self.session_key, None)
return JsonResponse({"success": True})
class PreviewOnCreate(PreviewOnEdit):
@property
def session_key(self):
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
return f"{self.session_key_prefix}{app_label}-{model_name}"
def get_object(self):
return self.model()
class PreviewRevision(View):
model = None
http_method_names = ("get",)
def setup(self, request, pk, revision_id, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.pk = pk
self.revision_id = revision_id
self.object = self.get_object()
self.revision_object = self.get_revision_object()
def get_object(self):
if not issubclass(self.model, RevisionMixin):
raise Http404
return get_object_or_404(self.model, pk=unquote(str(self.pk)))
def get_revision_object(self):
revision = get_object_or_404(self.object.revisions, id=self.revision_id)
return revision.as_object()
def get(self, request, *args, **kwargs):
try:
preview_mode = request.GET.get(
"mode", self.revision_object.default_preview_mode
)
except IndexError:
raise PermissionDenied
return self.revision_object.make_preview_request(request, preview_mode)

View File

@@ -0,0 +1,148 @@
from django.contrib.admin.utils import quote
from django.urls import reverse
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.admin_url_finder import AdminURLFinder
from wagtail.admin.ui import tables
from wagtail.admin.utils import get_latest_str
from wagtail.admin.widgets.button import HeaderButton
from wagtail.models import DraftStateMixin, ReferenceIndex
from .base import BaseListingView, BaseObjectMixin
from .permissions import PermissionCheckedMixin
class TitleColumn(tables.TitleColumn):
def get_link_attrs(self, instance, parent_context):
return {"title": instance["edit_link_title"]}
class UsageView(PermissionCheckedMixin, BaseObjectMixin, BaseListingView):
paginate_by = 20
page_title = gettext_lazy("Usage")
index_url_name = None
edit_url_name = None
usage_url_name = None
permission_required = "change"
@cached_property
def describe_on_delete(self):
return bool(self.request.GET.get("describe_on_delete"))
def get_object(self):
object = super().get_object()
if isinstance(object, DraftStateMixin):
return object.get_latest_revision_as_object()
return object
def get_edit_url(self, instance):
if self.edit_url_name:
return reverse(self.edit_url_name, args=(quote(instance.pk),))
def get_usage_url(self, instance):
if self.usage_url_name:
return reverse(self.usage_url_name, args=(quote(instance.pk),))
def get_index_url(self): # used for pagination links
return self.get_usage_url(self.object)
def get_page_subtitle(self):
return get_latest_str(self.object)
def get_breadcrumbs_items(self):
items = []
if self.index_url_name:
items.append(
{
"url": reverse(self.index_url_name),
"label": capfirst(self.object._meta.verbose_name_plural),
}
)
edit_url = self.get_edit_url(self.object)
if edit_url:
items.append(
{
"url": edit_url,
"label": get_latest_str(self.object),
}
)
items.append(
{
"url": "",
"label": _("Usage"),
"sublabel": self.get_page_subtitle(),
}
)
return self.breadcrumbs_items + items
@cached_property
def header_buttons(self):
edit_url = self.get_edit_url(self.object)
buttons = []
if edit_url:
buttons.append(
HeaderButton(
label=_("Edit"),
url=edit_url,
icon_name="edit",
)
)
return buttons
def get_queryset(self):
return ReferenceIndex.get_references_to(self.object).group_by_source_object()
@cached_property
def columns(self):
return [
TitleColumn(
"name",
label=_("Name"),
accessor="label",
get_url=lambda r: r["edit_url"],
),
tables.Column(
"content_type",
label=_("Type"),
# Use the content type from the ReferenceIndex object instead of the
# object itself, so we can get the specific content type without
# having to fetch the specific object from the database.
accessor=lambda r: capfirst(r["references"][0].model_name),
),
tables.ReferencesColumn(
"field",
label=_("If you confirm deletion")
if self.describe_on_delete
else _("Field"),
accessor="references",
get_url=lambda r: r["edit_url"],
describe_on_delete=self.describe_on_delete,
),
]
def get_table(self, object_list, **kwargs):
url_finder = AdminURLFinder(self.request.user)
results = []
for object, references in object_list:
row = {"object": object, "references": references}
row["edit_url"] = url_finder.get_edit_url(object)
if row["edit_url"] is None:
row["label"] = _("(Private %(object)s)") % {
"object": object._meta.verbose_name
}
row["edit_link_title"] = None
else:
row["label"] = str(object)
row["edit_link_title"] = _("Edit this %(object)s") % {
"object": object._meta.verbose_name
}
results.append(row)
return super().get_table(results, **kwargs)
def get_context_data(self, *args, object_list=None, **kwargs):
return super().get_context_data(
*args, object_list=object_list, object=self.object, **kwargs
)

View File

@@ -0,0 +1,265 @@
from django.conf import settings
from django.contrib.admin.utils import quote
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views import View
from wagtail.admin import messages
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.admin.utils import get_latest_str, get_valid_next_url_from_request
from wagtail.admin.views.generic.base import BaseObjectMixin
from wagtail.models import Task, TaskState, WorkflowState
class BaseWorkflowFormView(BaseObjectMixin, View):
"""
Shared functionality for views that need to render the modal form to collect extra details
for a workflow task
"""
redirect_url_name = None
submit_url_name = None
template_name = "wagtailadmin/shared/workflow_action_modal.html"
def setup(self, request, *args, action_name, task_state_id, **kwargs):
super().setup(request, *args, **kwargs)
self.action_name = action_name
self.task_state_id = task_state_id
self.redirect_url = self.get_redirect_url()
self.task_state = self.get_task_state()
self.task = self.get_task()
self.form_class = self.get_form_class()
def get_redirect_url(self):
next_url = get_valid_next_url_from_request(self.request)
if next_url:
return next_url
return reverse(self.redirect_url_name, args=(quote(self.object.pk),))
def get_task_state(self):
return get_object_or_404(TaskState, id=self.task_state_id).specific
def get_task(self):
return self.task_state.task.specific
def get_form_class(self):
return self.task.get_form_for_action(self.action_name)
def add_not_in_moderation_error(self):
messages.error(
self.request,
_("The %(model_name)s '%(title)s' is not currently awaiting moderation.")
% {
"model_name": self.model._meta.verbose_name,
"title": get_latest_str(self.object),
},
)
def check_action(self):
actions = self.task.get_actions(self.object, self.request.user)
self.action_verbose_name = ""
action_available = False
self.action_modal = False
for name, verbose_name, modal in actions:
if name == self.action_name:
action_available = True
if modal:
self.action_modal = True
# if two actions have the same name, use the verbose name
# of the one allowing modal data entry within the modal
self.action_verbose_name = verbose_name
if not action_available:
raise PermissionDenied
def dispatch(self, request, *args, **kwargs):
if not self.object.workflow_in_progress:
self.add_not_in_moderation_error()
return redirect(self.redirect_url)
self.check_action()
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
return self.render_modal_form(request, self.form_class())
def get_submit_url(self):
return reverse(
self.submit_url_name,
args=(quote(self.object.pk), self.action_name, self.task_state.id),
)
def get_context_data(self, **kwargs):
return {
"object": self.object,
"action": self.action_name,
"action_verbose": self.action_verbose_name,
"task_state": self.task_state,
"submit_url": self.get_submit_url(),
**kwargs,
}
def render_modal_form(self, request, form):
return render_modal_workflow(
request,
self.template_name,
None,
self.get_context_data(form=form),
json_data={"step": "action"},
)
def render_modal_json(self, request, json_data):
return render_modal_workflow(request, "", None, {}, json_data=json_data)
class WorkflowAction(BaseWorkflowFormView):
"""Provides a modal view to enter additional data for the specified workflow action on GET,
or perform the specified action on POST"""
def post(self, request, *args, **kwargs):
if self.form_class:
form = self.form_class(self.request.POST)
if form.is_valid():
self.redirect_url = (
self.task.on_action(
self.task_state,
self.request.user,
self.action_name,
**form.cleaned_data,
)
or self.redirect_url
)
elif (
self.action_modal
and self.request.headers.get("x-requested-with") == "XMLHttpRequest"
):
# show form errors
return self.render_modal_form(self.request, form)
else:
self.redirect_url = (
self.task.on_action(
self.task_state, self.request.user, self.action_name
)
or self.redirect_url
)
if self.request.headers.get("x-requested-with") == "XMLHttpRequest":
return self.render_modal_json(
self.request,
{"step": "success", "redirect": self.redirect_url},
)
return redirect(self.redirect_url)
class CollectWorkflowActionData(BaseWorkflowFormView):
"""
On GET, provides a modal view to enter additional data for the specified workflow action;
on POST, return the validated form data back to the modal's caller via a JSON response, so that
the calling view can subsequently perform the action as part of its own processing
(for example, approving moderation while making an edit).
"""
def post(self, request, *args, **kwargs):
form = self.form_class(request.POST)
if form.is_valid():
return self.render_modal_json(
request,
{"step": "success", "cleaned_data": form.cleaned_data},
)
elif (
self.action_modal
and request.headers.get("x-requested-with") == "XMLHttpRequest"
):
# show form errors
return self.render_modal_form(request, form)
return redirect(self.redirect_url)
class ConfirmWorkflowCancellation(BaseObjectMixin, View):
template_name = "wagtailadmin/generic/confirm_workflow_cancellation.html"
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.workflow_state = self.object.current_workflow_state
def dispatch(self, request, *args, **kwargs):
if not self.workflow_state or not getattr(
settings, "WAGTAIL_WORKFLOW_CANCEL_ON_PUBLISH", True
):
return render_modal_workflow(
request,
"",
None,
{},
json_data={"step": "no_confirmation_needed"},
)
return render_modal_workflow(
request,
self.template_name,
None,
self.get_context_data(),
json_data={"step": "confirm"},
)
def get_context_data(self, **kwargs):
return {
"needs_changes": self.workflow_state.status
== WorkflowState.STATUS_NEEDS_CHANGES,
"task": self.workflow_state.current_task_state.task.name,
"workflow": self.workflow_state.workflow.name,
"model_opts": self.model_opts,
**kwargs,
}
class PreviewRevisionForTask(BaseObjectMixin, View):
def setup(self, request, *args, task_id, **kwargs):
super().setup(request, *args, **kwargs)
self.task_id = task_id
self.task = self.get_task()
self.task_state = self.get_task_state()
def get_task(self):
return get_object_or_404(Task, id=self.task_id).specific
def get_task_state(self):
return TaskState.objects.filter(
revision__base_content_type=self.object.get_base_content_type(),
revision__object_id=self.pk,
task=self.task,
status=TaskState.STATUS_IN_PROGRESS,
).first()
def add_error_message(self):
messages.error(
self.request,
_(
"The %(model_name)s '%(title)s' is not currently awaiting moderation in task '%(task_name)s'."
)
% {
"model_name": self.model._meta.verbose_name,
"title": get_latest_str(self.object),
"task_name": self.task.name,
},
)
def get(self, request, *args, **kwargs):
if not self.task_state:
self.add_error_message()
return redirect("wagtailadmin_home")
if not self.task.get_actions(self.object, request.user):
raise PermissionDenied
revision = self.task_state.revision
object_to_view = revision.as_object()
# TODO: provide workflow actions within this view
return object_to_view.make_preview_request(
request,
object_to_view.default_preview_mode,
extra_request_attrs={"revision_id": revision.id},
)

View File

@@ -0,0 +1,352 @@
from typing import Any, Mapping, Union
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import permission_required
from django.db.models import Exists, IntegerField, Max, OuterRef, Q
from django.db.models.functions import Cast
from django.forms import Media
from django.http import Http404, HttpResponse
from django.utils.translation import gettext_lazy
from django.views.generic.base import TemplateView
from wagtail import hooks
from wagtail.admin.icons import get_icons
from wagtail.admin.navigation import get_site_for_user
from wagtail.admin.site_summary import SiteSummaryPanel
from wagtail.admin.ui.components import Component
from wagtail.admin.views.generic import WagtailAdminTemplateMixin
from wagtail.models import (
Page,
PageLogEntry,
Revision,
TaskState,
WorkflowState,
get_default_page_content_type,
)
from wagtail.permissions import page_permission_policy
User = get_user_model()
# Panels for the homepage
class UpgradeNotificationPanel(Component):
name = "upgrade_notification"
template_name = "wagtailadmin/home/upgrade_notification.html"
order = 100
def get_upgrade_check_setting(self) -> Union[bool, str]:
return getattr(settings, "WAGTAIL_ENABLE_UPDATE_CHECK", True)
def upgrade_check_lts_only(self) -> bool:
upgrade_check = self.get_upgrade_check_setting()
if isinstance(upgrade_check, str) and upgrade_check.lower() == "lts":
return True
return False
def get_context_data(self, parent_context: Mapping[str, Any]) -> Mapping[str, Any]:
return {"lts_only": self.upgrade_check_lts_only()}
def render_html(self, parent_context: Mapping[str, Any] = None) -> str:
if (
parent_context["request"].user.is_superuser
and self.get_upgrade_check_setting()
):
return super().render_html(parent_context)
else:
return ""
class WhatsNewInWagtailVersionPanel(Component):
name = "whats_new_in_wagtail_version"
template_name = "wagtailadmin/home/whats_new_in_wagtail_version.html"
order = 110
_version = "4"
def get_whats_new_banner_setting(self) -> Union[bool, str]:
return getattr(settings, "WAGTAIL_ENABLE_WHATS_NEW_BANNER", True)
def get_dismissible_id(self) -> str:
return f"{self.name}_{self._version}"
def get_context_data(self, parent_context: Mapping[str, Any]) -> Mapping[str, Any]:
return {"dismissible_id": self.get_dismissible_id(), "version": self._version}
def is_shown(self, parent_context: Mapping[str, Any] = None) -> bool:
if not self.get_whats_new_banner_setting():
return False
profile = getattr(parent_context["request"].user, "wagtail_userprofile", None)
if profile and profile.dismissibles.get(self.get_dismissible_id()):
return False
return True
def render_html(self, parent_context: Mapping[str, Any] = None) -> str:
if not self.is_shown(parent_context):
return ""
return super().render_html(parent_context)
class UserObjectsInWorkflowModerationPanel(Component):
name = "user_objects_in_workflow_moderation"
template_name = "wagtailadmin/home/user_objects_in_workflow_moderation.html"
order = 210
def get_context_data(self, parent_context):
request = parent_context["request"]
context = super().get_context_data(parent_context)
if getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
# Need to cast the page ids to string because Postgres doesn't support
# implicit type casts when querying on GenericRelations. We also need
# to cast the object_id to integer when querying the pages for the same reason.
# https://code.djangoproject.com/ticket/16055
# Once the issue is resolved, this query can be removed and the
# filter can be changed to:
# Q(page__owner=request.user) | Q(requested_by=request.user)
pages_owned_by_user = Q(
base_content_type_id=get_default_page_content_type().id
) & Exists(
Page.objects.filter(
owner=request.user,
id=Cast(OuterRef("object_id"), output_field=IntegerField()),
)
)
# Find in progress workflow states which are either requested by the user or on pages owned by the user
context["workflow_states"] = (
WorkflowState.objects.active()
.filter(pages_owned_by_user | Q(requested_by=request.user))
.prefetch_related(
"content_object",
"content_object__latest_revision",
)
.select_related(
"current_task_state",
"current_task_state__task",
)
.order_by("-current_task_state__started_at")
)
# Filter out workflow states where the GenericForeignKey points to
# a nonexistent object. This can happen if the model does not define
# a GenericRelation to WorkflowState and the instance is deleted.
context["workflow_states"] = [
state for state in context["workflow_states"] if state.content_object
]
else:
context["workflow_states"] = WorkflowState.objects.none()
context["request"] = request
return context
class WorkflowObjectsToModeratePanel(Component):
name = "workflow_objects_to_moderate"
template_name = "wagtailadmin/home/workflow_objects_to_moderate.html"
order = 220
def get_context_data(self, parent_context):
request = parent_context["request"]
context = super().get_context_data(parent_context)
context["states"] = []
context["request"] = request
context["csrf_token"] = parent_context["csrf_token"]
if not getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
return context
states = (
TaskState.objects.reviewable_by(request.user)
.select_related(
"revision",
"revision__user",
"workflow_state",
"workflow_state__workflow",
)
.prefetch_related(
"revision__content_object",
"revision__content_object__latest_revision",
)
.order_by("-started_at")
.annotate(
previous_revision_id=Revision.objects.previous_revision_id_subquery(),
)
)
for state in states:
obj = state.revision.content_object
# Skip task states where the revision's GenericForeignKey points to
# a nonexistent object. This can happen if the model does not define
# a GenericRelation to WorkflowState and/or Revision and the instance
# is deleted.
if not obj:
continue
actions = state.task.specific.get_actions(obj, request.user)
workflow_tasks = state.workflow_state.all_tasks_with_status()
workflow_action_url_name = "wagtailadmin_pages:workflow_action"
workflow_preview_url_name = "wagtailadmin_pages:workflow_preview"
revisions_compare_url_name = "wagtailadmin_pages:revisions_compare"
# Snippets can also have workflows
if not isinstance(obj, Page):
viewset = obj.snippet_viewset
workflow_action_url_name = viewset.get_url_name("workflow_action")
workflow_preview_url_name = viewset.get_url_name("workflow_preview")
revisions_compare_url_name = viewset.get_url_name("revisions_compare")
if not getattr(obj, "is_previewable", False):
workflow_preview_url_name = None
context["states"].append(
{
"obj": obj,
"revision": state.revision,
"previous_revision_id": state.previous_revision_id,
"live_revision_id": obj.live_revision_id,
"task_state": state,
"actions": actions,
"workflow_tasks": workflow_tasks,
"workflow_action_url_name": workflow_action_url_name,
"workflow_preview_url_name": workflow_preview_url_name,
"revisions_compare_url_name": revisions_compare_url_name,
}
)
return context
class LockedPagesPanel(Component):
name = "locked_pages"
template_name = "wagtailadmin/home/locked_pages.html"
order = 300
def get_context_data(self, parent_context):
request = parent_context["request"]
context = super().get_context_data(parent_context)
context.update(
{
"locked_pages": Page.objects.filter(
locked=True,
locked_by=request.user,
)
.order_by("-locked_at", "-latest_revision_created_at", "-pk")
.specific(defer=True),
"can_remove_locks": page_permission_policy.user_has_permission(
request.user, "unlock"
),
"request": request,
"csrf_token": parent_context["csrf_token"],
}
)
return context
class RecentEditsPanel(Component):
name = "recent_edits"
template_name = "wagtailadmin/home/recent_edits.html"
order = 250
def get_context_data(self, parent_context):
request = parent_context["request"]
context = super().get_context_data(parent_context)
# Last n edited pages
edit_count = getattr(settings, "WAGTAILADMIN_RECENT_EDITS_LIMIT", 5)
# Query the audit log to get a resultset of (page ID, latest edit timestamp)
last_edits_dates = (
PageLogEntry.objects.filter(user=request.user, action="wagtail.edit")
.values("page_id")
.annotate(latest_date=Max("timestamp"))
.order_by("-latest_date")[:edit_count]
)
# Retrieve the page objects for those IDs
pages_mapping = (
Page.objects.specific()
.prefetch_workflow_states()
.annotate_approved_schedule()
.in_bulk([log["page_id"] for log in last_edits_dates])
)
# Compile a list of (latest edit timestamp, page object) tuples
last_edits = []
for log in last_edits_dates:
page = pages_mapping.get(log["page_id"])
if page:
last_edits.append((log["latest_date"], page))
context["last_edits"] = last_edits
context["request"] = request
return context
class HomeView(WagtailAdminTemplateMixin, TemplateView):
template_name = "wagtailadmin/home.html"
page_title = gettext_lazy("Dashboard")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
panels = self.get_panels()
site_details = self.get_site_details()
context["media"] = self.get_media(panels)
context["panels"] = sorted(panels, key=lambda p: p.order)
context["user"] = self.request.user
return {**context, **site_details}
def get_media(self, panels=[]):
media = Media()
for panel in panels:
media += panel.media
return media
def get_panels(self):
request = self.request
panels = [
SiteSummaryPanel(request),
# Disabled until a release warrants the banner.
# WhatsNewInWagtailVersionPanel(),
UpgradeNotificationPanel(),
WorkflowObjectsToModeratePanel(),
UserObjectsInWorkflowModerationPanel(),
RecentEditsPanel(),
LockedPagesPanel(),
]
for fn in hooks.get_hooks("construct_homepage_panels"):
fn(request, panels)
return panels
def get_site_details(self):
request = self.request
site = get_site_for_user(request.user)
return {
"root_page": site["root_page"],
"root_site": site["root_site"],
"site_name": site["site_name"],
}
def error_test(request):
raise Exception("This is a test of the emergency broadcast system.")
@permission_required("wagtailadmin.access_admin", login_url="wagtailadmin_login")
def default(request):
"""
Called whenever a request comes in with the correct prefix (eg /admin/) but
doesn't actually correspond to a Wagtail view.
For authenticated users, it'll raise a 404 error. Anonymous users will be
redirected to the login page.
"""
raise Http404
def sprite(request):
return HttpResponse(get_icons(), content_type="image/svg+xml; charset=utf-8")

View File

@@ -0,0 +1,333 @@
import csv
import datetime
from collections import OrderedDict
from functools import partial
from io import BytesIO
from django.contrib.admin.utils import label_for_field
from django.core.exceptions import FieldDoesNotExist
from django.http import FileResponse, StreamingHttpResponse
from django.utils import timezone
from django.utils.dateformat import Formatter
from django.utils.encoding import force_str
from django.utils.formats import get_format
from django.utils.functional import cached_property
from django.utils.text import capfirst
from django.utils.translation import gettext as _
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
from wagtail.admin.widgets.button import Button
from wagtail.coreutils import multigetattr
class Echo:
"""An object that implements just the write method of the file-like interface."""
def write(self, value):
"""Write the value by returning it, instead of storing in a buffer."""
return value.encode("UTF-8")
def list_to_str(value):
return force_str(", ".join(value))
class ExcelDateFormatter(Formatter):
data = None
# From: https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date
# To: https://support.microsoft.com/en-us/office/format-numbers-as-dates-or-times-418bd3fe-0577-47c8-8caa-b4d30c528309#bm2
_formats = {
# Day of the month, 2 digits with leading zeros.
"d": "dd",
# Day of the month without leading zeros.
"j": "d",
# Day of the week, textual, 3 letters.
"D": "ddd",
# Day of the week, textual, full.
"l": "dddd",
# English ordinal suffix for the day of the month, 2 characters.
"S": "", # Not supported in Excel
# Day of the week, digits without leading zeros.
"w": "", # Not supported in Excel
# Day of the year.
"z": "", # Not supported in Excel
# ISO-8601 week number of year, with weeks starting on Monday.
"W": "", # Not supported in Excel
# Month, 2 digits with leading zeros.
"m": "mm",
# Month without leading zeros.
"n": "m",
# Month, textual, 3 letters.
"M": "mmm",
# Month, textual, 3 letters, lowercase. (Not supported in Excel)
"b": "mmm",
# Month, locale specific alternative representation usually used for long date representation.
"E": "mmmm", # Not supported in Excel
# Month, textual, full.
"F": "mmmm",
# Month abbreviation in Associated Press style. Proprietary extension.
"N": "mmm.", # Approximation, wrong for May
# Number of days in the given month.
"t": "", # Not supported in Excel
# Year, 2 digits with leading zeros.
"y": "yy",
# Year, 4 digits with leading zeros.
"Y": "yyyy",
# Whether it's a leap year.
"L": "", # Not supported in Excel
# ISO-8601 week-numbering year.
"o": "yyyy", # Approximation, same as Y
# Hour, 12-hour format without leading zeros.
"g": "h", # Only works when combined with AM/PM, 24-hour format is used otherwise
# Hour, 24-hour format without leading zeros.
"G": "hH",
# Hour, 12-hour format with leading zeros.
"h": "hh", # Only works when combined with AM/PM, 24-hour format is used otherwise
# Hour, 24-hour format with leading zeros.
"H": "hh",
# Minutes.
"i": "mm",
# Seconds.
"s": "ss",
# Microseconds.
"u": ".00", # Only works when combined with ss
# 'a.m.' or 'p.m.'.
"a": "AM/PM", # Approximation, uses AM/PM and only works when combined with h/hh
# AM/PM.
"A": "AM/PM", # Only works when combined with h/hh
# Time, in 12-hour hours and minutes, with minutes left off if theyre zero.
"f": "h:mm", # Approximation, uses 24-hour format and minutes are never left off
# Time, in 12-hour hours, minutes and a.m./p.m., with minutes left off if theyre zero and the special-case strings midnight and noon if appropriate.
"P": "h:mm AM/PM", # Approximation, minutes are never left off, no special case strings
# Timezone name.
"e": "", # Not supported in Excel
# Daylight saving time, whether its in effect or not.
"I": "", # Not supported in Excel
# Difference to Greenwich time in hours.
"O": "", # Not supported in Excel
# Time zone of this machine.
"T": "", # Not supported in Excel
# Timezone offset in seconds.
"Z": "", # Not supported in Excel
# ISO 8601 format.
"c": "yyyy-mm-ddThh:mm:ss.00",
# RFC 5322 formatted date.
"r": "ddd, d mmm yyyy hh:mm:ss",
# Seconds since the Unix epoch.
"U": "", # Not supported in Excel
}
def get(self):
format = get_format("SHORT_DATETIME_FORMAT")
return self.format(format)
def __getattr__(self, name):
if name in self._formats:
return lambda: self._formats[name]
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
class SpreadsheetExportMixin:
"""A mixin for views, providing spreadsheet export functionality in csv and xlsx formats"""
FORMAT_XLSX = "xlsx"
FORMAT_CSV = "csv"
FORMATS = (FORMAT_XLSX, FORMAT_CSV)
# A list of fields or callables (without arguments) to export from each item in the queryset (dotted paths allowed)
list_export = []
# A dictionary of custom preprocessing functions by field and format (expected value would be of the form {field_name: {format: function}})
# If a valid field preprocessing function is found, any applicable value preprocessing functions will not be used
custom_field_preprocess = {}
# A dictionary of preprocessing functions by value class and format
custom_value_preprocess = {
datetime.datetime: {
FORMAT_XLSX: lambda value: (
value
if timezone.is_naive(value)
else timezone.make_naive(value, datetime.timezone.utc)
)
},
(datetime.date, datetime.time): {FORMAT_XLSX: None},
list: {FORMAT_CSV: list_to_str, FORMAT_XLSX: list_to_str},
}
# A dictionary of column heading overrides in the format {field: heading}
export_headings = {}
export_buttons_template_name = "wagtailadmin/shared/export_buttons.html"
export_filename = "spreadsheet-export"
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.is_export = request.GET.get("export") in self.FORMATS
def get_paginate_by(self, queryset):
if self.is_export:
return None
return super().get_paginate_by(queryset)
def get_filename(self):
"""Gets the base filename for the exported spreadsheet, without extensions"""
return self.export_filename
def to_row_dict(self, item):
"""Returns an OrderedDict (in the order given by list_export) of the exportable information for a model instance"""
row_dict = OrderedDict(
(field, multigetattr(item, field)) for field in self.list_export
)
return row_dict
def get_preprocess_function(self, field, value, export_format):
"""Returns the preprocessing function for a given field name, field value, and export format"""
# Try to find a field specific function and return it
format_dict = self.custom_field_preprocess.get(field, {})
if export_format in format_dict:
return format_dict[export_format]
# Otherwise check for a value class specific function
for value_classes, format_dict in self.custom_value_preprocess.items():
if isinstance(value, value_classes) and export_format in format_dict:
return format_dict[export_format]
# Finally resort to force_str to prevent encoding errors
return partial(force_str, strings_only=True)
def preprocess_field_value(self, field, value, export_format):
"""Preprocesses a field value before writing it to the spreadsheet"""
preprocess_function = self.get_preprocess_function(field, value, export_format)
if preprocess_function is not None:
return preprocess_function(value)
else:
return value
def generate_xlsx_row(self, worksheet, row_dict, date_format=None):
"""Generate cells to append to the worksheet"""
for field, value in row_dict.items():
cell = WriteOnlyCell(
worksheet, self.preprocess_field_value(field, value, self.FORMAT_XLSX)
)
if date_format and isinstance(value, datetime.datetime):
cell.number_format = date_format
yield cell
def write_csv_row(self, writer, row_dict):
return writer.writerow(
{
field: self.preprocess_field_value(field, value, self.FORMAT_CSV)
for field, value in row_dict.items()
}
)
def get_heading(self, queryset, field):
"""Get the heading label for a given field for a spreadsheet generated from queryset"""
heading_override = self.export_headings.get(field)
if heading_override:
return force_str(heading_override)
try:
return capfirst(force_str(label_for_field(field, queryset.model)))
except (AttributeError, FieldDoesNotExist):
return force_str(field)
def stream_csv(self, queryset):
"""Generate a csv file line by line from queryset, to be used in a StreamingHTTPResponse"""
writer = csv.DictWriter(Echo(), fieldnames=self.list_export)
yield writer.writerow(
{field: self.get_heading(queryset, field) for field in self.list_export}
)
for item in queryset:
yield self.write_csv_row(writer, self.to_row_dict(item))
def write_xlsx(self, queryset, output):
"""Write an xlsx workbook from a queryset"""
workbook = Workbook(write_only=True, iso_dates=True)
worksheet = workbook.create_sheet(title="Sheet1")
worksheet.append(
self.get_heading(queryset, field) for field in self.list_export
)
date_format = ExcelDateFormatter().get()
for item in queryset:
worksheet.append(
self.generate_xlsx_row(
worksheet, self.to_row_dict(item), date_format=date_format
)
)
workbook.save(output)
def write_xlsx_response(self, queryset):
"""Write an xlsx file from a queryset and return a FileResponse"""
output = BytesIO()
self.write_xlsx(queryset, output)
output.seek(0)
return FileResponse(
output,
as_attachment=True,
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"{self.get_filename()}.xlsx",
)
def write_csv_response(self, queryset):
stream = self.stream_csv(queryset)
response = StreamingHttpResponse(stream, content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="{}.csv"'.format(
self.get_filename()
)
return response
def as_spreadsheet(self, queryset, spreadsheet_format):
"""Return a response with a spreadsheet representing the exported data from queryset, in the format specified"""
if spreadsheet_format == self.FORMAT_CSV:
return self.write_csv_response(queryset)
elif spreadsheet_format == self.FORMAT_XLSX:
return self.write_xlsx_response(queryset)
def get_export_url(self, format):
params = self.request.GET.copy()
params["export"] = format
return self.request.path + "?" + params.urlencode()
@property
def xlsx_export_url(self):
return self.get_export_url("xlsx")
@property
def csv_export_url(self):
return self.get_export_url("csv")
@cached_property
def show_export_buttons(self):
return bool(self.list_export)
@cached_property
def header_more_buttons(self):
buttons = super().header_more_buttons.copy()
if self.show_export_buttons:
buttons.append(
Button(
_("Download XLSX"),
url=self.xlsx_export_url,
icon_name="download",
priority=90,
)
)
buttons.append(
Button(
_("Download CSV"),
url=self.csv_export_url,
icon_name="download",
priority=100,
)
)
return buttons

View File

@@ -0,0 +1,94 @@
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
from wagtail.admin.forms.pages import PageViewRestrictionForm
from wagtail.admin.modal_workflow import render_modal_workflow
from wagtail.models import Page, PageViewRestriction
def set_privacy(request, page_id):
page = get_object_or_404(Page, id=page_id).specific_deferred
page_perms = page.permissions_for_user(request.user)
if not page_perms.can_set_view_restrictions():
raise PermissionDenied
# fetch restriction records in depth order so that ancestors appear first
restrictions = page.get_view_restrictions().order_by("page__depth")
if restrictions:
restriction = restrictions[0]
restriction_exists_on_ancestor = restriction.page.id != page.id
else:
restriction = None
restriction_exists_on_ancestor = False
if request.method == "POST":
form = PageViewRestrictionForm(
request.POST,
instance=restriction,
private_page_options=page.private_page_options,
)
if form.is_valid() and not restriction_exists_on_ancestor:
if form.cleaned_data["restriction_type"] == PageViewRestriction.NONE:
# remove any existing restriction
if restriction:
restriction.delete(user=request.user)
else:
restriction = form.save(commit=False)
restriction.page = page
restriction.save(user=request.user)
# Save the groups many-to-many field
form.save_m2m()
return render_modal_workflow(
request,
None,
None,
None,
json_data={
"step": "set_privacy_done",
"is_public": (form.cleaned_data["restriction_type"] == "none"),
},
)
else: # request is a GET
if not restriction_exists_on_ancestor:
if restriction:
form = PageViewRestrictionForm(
instance=restriction, private_page_options=page.private_page_options
)
else:
# no current view restrictions on this page
form = PageViewRestrictionForm(
initial={"restriction_type": "none"},
private_page_options=page.private_page_options,
)
if restriction_exists_on_ancestor:
# display a message indicating that there is a restriction at ancestor level -
# do not provide the form for setting up new restrictions
return render_modal_workflow(
request,
"wagtailadmin/page_privacy/ancestor_privacy.html",
None,
{
"page_with_restriction": restriction.page,
},
)
elif len(page.private_page_options) == 0:
return render_modal_workflow(
request,
"wagtailadmin/page_privacy/no_privacy.html",
None,
)
else:
# no restriction set at ancestor level - can set restrictions here
return render_modal_workflow(
request,
"wagtailadmin/page_privacy/set_privacy.html",
None,
{
"page": page,
"form": form,
},
json_data={"step": "set_privacy"},
)

View File

@@ -0,0 +1,11 @@
from .delete import DeleteBulkAction
from .move import MoveBulkAction
from .publish import PublishBulkAction
from .unpublish import UnpublishBulkAction
__all__ = [
"DeleteBulkAction",
"MoveBulkAction",
"PublishBulkAction",
"UnpublishBulkAction",
]

View File

@@ -0,0 +1,57 @@
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from wagtail.admin.views.pages.bulk_actions.page_bulk_action import PageBulkAction
class DeleteBulkAction(PageBulkAction):
display_name = _("Delete")
action_type = "delete"
aria_label = _("Delete selected pages")
template_name = "wagtailadmin/pages/bulk_actions/confirm_bulk_delete.html"
action_priority = 30
classes = {"serious"}
def check_perm(self, page):
return page.permissions_for_user(self.request.user).can_delete()
def object_context(self, page):
return {
"item": page,
"descendant_count": page.get_descendant_count(),
}
@classmethod
def execute_action(cls, objects, user=None, **kwargs):
num_parent_objects, num_child_objects = 0, 0
for page in objects:
num_parent_objects += 1
num_child_objects += page.get_descendant_count()
page.delete(user=user)
return num_parent_objects, num_child_objects
def get_success_message(self, num_parent_objects, num_child_objects):
if num_parent_objects == 1:
if num_child_objects == 0:
success_message = _("1 page has been deleted")
else:
success_message = ngettext(
"1 page and %(num_child_objects)d child page have been deleted",
"1 page and %(num_child_objects)d child pages have been deleted",
num_child_objects,
) % {"num_child_objects": num_child_objects}
else:
if num_child_objects == 0:
success_message = _(
"%(num_parent_objects)d pages have been deleted"
) % {"num_parent_objects": num_parent_objects}
else:
success_message = ngettext(
"%(num_parent_objects)d pages and %(num_child_objects)d child page have been deleted",
"%(num_parent_objects)d pages and %(num_child_objects)d child pages have been deleted",
num_child_objects,
) % {
"num_child_objects": num_child_objects,
"num_parent_objects": num_parent_objects,
}
return success_message

View File

@@ -0,0 +1,158 @@
from django import forms
from django.template.response import TemplateResponse
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from wagtail.admin import widgets
from wagtail.admin.views.pages.bulk_actions.page_bulk_action import PageBulkAction
from wagtail.models import Page
class MoveForm(forms.Form):
def __init__(self, *args, **kwargs):
destination = kwargs.pop("destination")
target_parent_models = kwargs.pop("target_parent_models")
pages_to_move = kwargs.pop("pages_to_move")
super().__init__(*args, **kwargs)
self.fields["chooser"] = forms.ModelChoiceField(
initial=destination,
queryset=Page.objects.all(),
widget=widgets.AdminPageMoveChooser(
can_choose_root=True,
user_perms="bulk_move_to",
target_models=target_parent_models,
pages_to_move=pages_to_move,
),
label=_("Select a new parent page"),
)
class MoveBulkAction(PageBulkAction):
display_name = _("Move")
action_type = "move"
aria_label = _("Move selected pages")
template_name = "wagtailadmin/pages/bulk_actions/confirm_bulk_move.html"
action_priority = 10
form_class = MoveForm
destination = None
def __init__(self, request, model):
super().__init__(request, model)
self.target_parent_models = set()
self.pages_to_move = []
def get_form_kwargs(self):
ctx = super().get_form_kwargs()
ctx["destination"] = self.destination or Page.get_first_root_node()
ctx["target_parent_models"] = self.target_parent_models
ctx["pages_to_move"] = self.pages_to_move
return ctx
def check_perm(self, page):
return page.permissions_for_user(self.request.user).can_move()
def get_success_message(self, num_parent_objects, num_child_objects):
success_message = ngettext(
"%(num_pages)d page has been moved",
"%(num_pages)d pages have been moved",
num_parent_objects,
) % {"num_pages": num_parent_objects}
return success_message
def object_context(self, obj):
context = super().object_context(obj)
context["child_pages"] = context["item"].get_descendants().count()
return context
def get_actionable_objects(self):
objects, objects_without_access = super().get_actionable_objects()
request = self.request
if objects:
self.target_parent_models = set(
objects[0].specific_class.allowed_parent_page_models()
)
for obj in objects:
self.target_parent_models.intersection_update(
set(obj.specific_class.allowed_parent_page_models())
)
self.pages_to_move = [page.id for page in objects]
if self.cleaned_form is None:
if len(self.target_parent_models) == 0:
return [], {
**objects_without_access,
"pages_without_common_parent_page": [
{
"item": page,
"can_edit": page.permissions_for_user(
self.request.user
).can_edit(),
}
for page in objects
],
}
return objects, objects_without_access
destination = self.cleaned_form.cleaned_data["chooser"]
pages = []
pages_without_destination_access = []
pages_with_duplicate_slugs = []
for page in objects:
if not page.permissions_for_user(request.user).can_move_to(destination):
pages_without_destination_access.append(page)
elif not Page._slug_is_available(page.slug, destination, page=page):
pages_with_duplicate_slugs.append(page)
else:
pages.append(page)
return pages, {
**objects_without_access,
"pages_without_destination_access": [
{
"item": page,
"can_edit": page.permissions_for_user(self.request.user).can_edit(),
}
for page in pages_without_destination_access
],
"pages_with_duplicate_slugs": [
{
"item": page,
"can_edit": page.permissions_for_user(self.request.user).can_edit(),
}
for page in pages_with_duplicate_slugs
],
}
def prepare_action(self, pages, pages_without_access):
request = self.request
destination = self.cleaned_form.cleaned_data["chooser"]
if (
pages_without_access["pages_without_destination_access"]
or pages_without_access["pages_with_duplicate_slugs"]
):
# this will be picked up by the form
self.destination = destination
return TemplateResponse(
request,
self.template_name,
{"destination": destination, **self.get_context_data()},
)
def get_execution_context(self):
return {
**super().get_execution_context(),
"destination": self.cleaned_form.cleaned_data["chooser"],
}
@classmethod
def execute_action(cls, objects, destination=None, user=None, **kwargs):
num_parent_objects = 0
if destination is None:
return
for page in objects:
page.move(destination, pos="last-child", user=user)
num_parent_objects += 1
return num_parent_objects, 0

View File

@@ -0,0 +1,57 @@
from django import forms
from wagtail.admin.views.bulk_action import BulkAction
from wagtail.admin.views.pages.search import page_filter_search
from wagtail.models import Page
class DefaultPageForm(forms.Form):
include_descendants = forms.BooleanField(required=False)
class PageBulkAction(BulkAction):
models = [Page]
form_class = DefaultPageForm
def get_all_objects_in_listing_query(self, parent_id):
listing_objects = self.model.objects.all()
q = None
if "q" in self.request.GET:
q = self.request.GET.get("q", "")
if parent_id is not None:
listing_objects = listing_objects.get(id=parent_id)
# If we're searching, include the descendants as well.
# Otherwise, just include the direct children.
if q:
listing_objects = listing_objects.get_descendants()
else:
listing_objects = listing_objects.get_children()
listing_objects = listing_objects.values_list("pk", flat=True)
if q:
listing_objects = page_filter_search(q, listing_objects)[0].results()
return listing_objects
def object_context(self, obj):
context = super().object_context(obj)
# Make 'item' into the specific instance, so that custom get_admin_display_title methods are respected
context["item"] = context["item"].specific_deferred
return context
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["items_with_no_access"] = [
{
"item": page,
"can_edit": page.permissions_for_user(self.request.user).can_edit(),
}
for page in context["items_with_no_access"]
]
return context
def get_execution_context(self):
return {"user": self.request.user}

View File

@@ -0,0 +1,105 @@
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from wagtail.admin.views.pages.bulk_actions.page_bulk_action import PageBulkAction
class PublishBulkAction(PageBulkAction):
display_name = _("Publish")
action_type = "publish"
aria_label = _("Publish selected pages")
template_name = "wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html"
action_priority = 40
def check_perm(self, page):
return page.permissions_for_user(self.request.user).can_publish()
def object_context(self, obj):
context = super().object_context(obj)
context["draft_descendant_count"] = (
context["item"].get_descendants().not_live().count()
)
return context
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["has_draft_descendants"] = any(
item["draft_descendant_count"] for item in context["items"]
)
return context
def get_execution_context(self):
return {
**super().get_execution_context(),
"include_descendants": self.cleaned_form.cleaned_data[
"include_descendants"
],
}
@classmethod
def execute_action(cls, objects, include_descendants=False, user=None, **kwargs):
num_parent_objects, num_child_objects = 0, 0
for page in objects:
revision = page.get_latest_revision() or page.specific.save_revision(
user=user
)
revision.publish(user=user)
num_parent_objects += 1
if include_descendants:
for draft_descendant_page in (
page.get_descendants()
.not_live()
.defer_streamfields()
.specific()
.iterator()
):
if (
user is None
or draft_descendant_page.permissions_for_user(
user
).can_publish()
):
draft_descendant_revision = (
draft_descendant_page.get_latest_revision()
or draft_descendant_page.save_revision(user=user)
)
draft_descendant_revision.publish(user=user)
num_child_objects += 1
return num_parent_objects, num_child_objects
def get_success_message(self, num_parent_objects, num_child_objects):
include_descendants = self.cleaned_form.cleaned_data["include_descendants"]
if num_parent_objects == 1:
if include_descendants:
if num_child_objects == 0:
success_message = _("1 page has been published")
else:
success_message = ngettext(
"1 page and %(num_child_objects)d child page have been published",
"1 page and %(num_child_objects)d child pages have been published",
num_child_objects,
) % {"num_child_objects": num_child_objects}
else:
success_message = _("1 page has been published")
else:
if include_descendants:
if num_child_objects == 0:
success_message = _(
"%(num_parent_objects)d pages have been published"
) % {"num_parent_objects": num_parent_objects}
else:
success_message = ngettext(
"%(num_parent_objects)d pages and %(num_child_objects)d child page have been published",
"%(num_parent_objects)d pages and %(num_child_objects)d child pages have been published",
num_child_objects,
) % {
"num_child_objects": num_child_objects,
"num_parent_objects": num_parent_objects,
}
else:
success_message = _(
"%(num_parent_objects)d pages have been published"
) % {"num_parent_objects": num_parent_objects}
return success_message

View File

@@ -0,0 +1,99 @@
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from wagtail.admin.views.pages.bulk_actions.page_bulk_action import PageBulkAction
class UnpublishBulkAction(PageBulkAction):
display_name = _("Unpublish")
action_type = "unpublish"
aria_label = _("Unpublish selected pages")
template_name = "wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html"
action_priority = 50
def check_perm(self, page):
return page.permissions_for_user(self.request.user).can_unpublish()
def object_context(self, page):
return {
**super().object_context(page),
"live_descendant_count": page.get_descendants().live().count(),
}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["has_live_descendants"] = any(
item["live_descendant_count"] > 0 for item in context["items"]
)
return context
def get_execution_context(self):
return {
**super().get_execution_context(),
"permission_checker": self.check_perm,
"include_descendants": self.cleaned_form.cleaned_data[
"include_descendants"
],
}
@classmethod
def execute_action(
cls,
objects,
include_descendants=False,
user=None,
permission_checker=None,
**kwargs,
):
num_parent_objects, num_child_objects = 0, 0
for page in objects:
page.unpublish(user=user)
num_parent_objects += 1
if include_descendants:
for live_descendant_page in (
page.get_descendants()
.live()
.defer_streamfields()
.specific()
.iterator()
):
if user is None or permission_checker(live_descendant_page):
live_descendant_page.unpublish()
num_child_objects += 1
return num_parent_objects, num_child_objects
def get_success_message(self, num_parent_objects, num_child_objects):
include_descendants = self.cleaned_form.cleaned_data["include_descendants"]
if num_parent_objects == 1:
if include_descendants:
if num_child_objects == 0:
success_message = _("1 page has been unpublished")
else:
success_message = ngettext(
"1 page and %(num_child_objects)d child page have been unpublished",
"1 page and %(num_child_objects)d child pages have been unpublished",
num_child_objects,
) % {"num_child_objects": num_child_objects}
else:
success_message = _("1 page has been unpublished")
else:
if include_descendants:
if num_child_objects == 0:
success_message = _(
"%(num_parent_objects)d pages have been unpublished"
) % {"num_parent_objects": num_parent_objects}
else:
success_message = ngettext(
"%(num_parent_objects)d pages and %(num_child_objects)d child page have been unpublished",
"%(num_parent_objects)d pages and %(num_child_objects)d child pages have been unpublished",
num_child_objects,
) % {
"num_child_objects": num_child_objects,
"num_parent_objects": num_parent_objects,
}
else:
success_message = _(
"%(num_parent_objects)d pages have been unpublished"
) % {"num_parent_objects": num_parent_objects}
return success_message

View File

@@ -0,0 +1,135 @@
from django.contrib.admin.utils import quote
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import redirect
from django.urls import reverse
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 django.views.generic import FormView
from wagtail.admin.forms.pages import ParentChooserForm
from wagtail.admin.views.generic.base import WagtailAdminTemplateMixin
from wagtail.models import Page
from wagtail.permissions import page_permission_policy
class ChooseParentView(WagtailAdminTemplateMixin, FormView):
template_name = "wagtailadmin/pages/choose_parent.html"
model = Page
index_url_name = None
page_title = gettext_lazy("Choose parent")
def get_valid_parent_pages(self, user):
"""
Identifies possible parent pages for the current user by first looking
at allowed_parent_page_models() on self.model to limit options to the
correct type of page, then checking permissions on those individual
pages to make sure we have permission to add a subpage to it.
"""
# Get queryset of pages where this page type can be added
allowed_parent_page_content_types = list(
ContentType.objects.get_for_models(
*self.model.allowed_parent_page_models()
).values()
)
allowed_parent_pages = Page.objects.filter(
content_type__in=allowed_parent_page_content_types
)
# Get queryset of pages where the user has permission to add subpages
if user.is_superuser:
pages_where_user_can_add = Page.objects.all()
else:
pages_where_user_can_add = Page.objects.none()
perms = {
perm
for perm in page_permission_policy.get_cached_permissions_for_user(user)
if perm.permission.codename == "add_page"
}
for perm in perms:
# user has add permission on any subpage of perm.page
# (including perm.page itself)
pages_where_user_can_add |= Page.objects.descendant_of(
perm.page, inclusive=True
)
# Combine them
return allowed_parent_pages & pages_where_user_can_add
def get(self, request, *args, **kwargs):
parents = self.get_valid_parent_pages(request.user)
# Only fetch the IDs for the first two parents to check if there's only
# one valid parent, and if so, redirect to the add page view with that
# parent pre-selected
parent_ids = parents.values_list("pk", flat=True)[:2]
if len(parent_ids) == 1:
parent_id = quote(parent_ids[0])
model_opts = self.model._meta
return redirect(
"wagtailadmin_pages:add",
model_opts.app_label,
model_opts.model_name,
parent_id,
)
# The page can be added in multiple places, so proceed with rendering
# the view's form so that the parent can be specified
return super().get(request, *args, **kwargs)
def get_form(self):
if self.request.method == "POST":
return ParentChooserForm(self.model, self.request.user, self.request.POST)
return ParentChooserForm(self.model, self.request.user)
def get_index_url(self):
if self.index_url_name:
return reverse(self.index_url_name)
def get_breadcrumbs_items(self):
items = []
index_url = self.get_index_url()
if index_url:
items.append(
{
"url": index_url,
"label": capfirst(self.model._meta.verbose_name_plural),
}
)
items.append(
{
"url": "",
"label": self.get_page_title(),
"sublabel": self.get_page_subtitle(),
}
)
return self.breadcrumbs_items + items
def get_page_subtitle(self):
return self.model.get_verbose_name()
@cached_property
def submit_button_label(self):
return _("Create a new %(model_name)s") % {
"model_name": self.model._meta.verbose_name,
}
def form_valid(self, form):
model_opts = self.model._meta
parent_id = quote(form.cleaned_data["parent_page"].pk)
return redirect(
"wagtailadmin_pages:add",
model_opts.app_label,
model_opts.model_name,
parent_id,
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["media"] = context["form"].media
context["submit_button_label"] = self.submit_button_label
return context

View File

@@ -0,0 +1,53 @@
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.actions.convert_alias import ConvertAliasPageAction
from wagtail.admin import messages
from wagtail.admin.utils import get_valid_next_url_from_request
from wagtail.models import Page
def convert_alias(request, page_id):
page = get_object_or_404(Page, id=page_id, alias_of_id__isnull=False).specific
if not page.permissions_for_user(request.user).can_edit():
raise PermissionDenied
with transaction.atomic():
for fn in hooks.get_hooks("before_convert_alias_page"):
result = fn(request, page)
if hasattr(result, "status_code"):
return result
next_url = get_valid_next_url_from_request(request)
if request.method == "POST":
action = ConvertAliasPageAction(page, user=request.user)
action.execute(skip_permission_checks=True)
messages.success(
request,
_("Page '%(page_title)s' has been converted into an ordinary page.")
% {"page_title": page.get_admin_display_title()},
)
for fn in hooks.get_hooks("after_convert_alias_page"):
result = fn(request, page)
if hasattr(result, "status_code"):
return result
if next_url:
return redirect(next_url)
return redirect("wagtailadmin_pages:edit", page.id)
return TemplateResponse(
request,
"wagtailadmin/pages/confirm_convert_alias.html",
{
"page": page,
"next": next_url,
},
)

View File

@@ -0,0 +1,115 @@
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.actions.copy_page import CopyPageAction
from wagtail.actions.create_alias import CreatePageAliasAction
from wagtail.admin import messages
from wagtail.admin.auth import user_has_any_page_permission, user_passes_test
from wagtail.admin.forms.pages import CopyForm
from wagtail.admin.utils import get_valid_next_url_from_request
from wagtail.models import Page
@user_passes_test(user_has_any_page_permission)
def copy(request, page_id):
page = Page.objects.get(id=page_id)
# Parent page defaults to parent of source page
parent_page = page.get_parent()
# Check if the user has permission to publish subpages on the parent
can_publish = parent_page.permissions_for_user(request.user).can_publish_subpage()
form_class = getattr(page.specific_class, "copy_form_class", CopyForm)
form = form_class(
request.POST or None, user=request.user, page=page, can_publish=can_publish
)
next_url = get_valid_next_url_from_request(request)
for fn in hooks.get_hooks("before_copy_page"):
result = fn(request, page)
if hasattr(result, "status_code"):
return result
# Check if user is submitting
if request.method == "POST":
# Prefill parent_page in case the form is invalid (as prepopulated value for the form field,
# because ModelChoiceField seems to not fall back to the user given value)
parent_page = Page.objects.get(id=request.POST["new_parent_page"])
if form.is_valid():
# Receive the parent page (this should never be empty)
if form.cleaned_data["new_parent_page"]:
parent_page = form.cleaned_data["new_parent_page"]
# Re-check if the user has permission to publish subpages on the new parent
can_publish = parent_page.permissions_for_user(
request.user
).can_publish_subpage()
keep_live = can_publish and form.cleaned_data.get("publish_copies")
# Copy the page
# 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.
if can_publish and form.cleaned_data.get("alias"):
action = CreatePageAliasAction(
page.specific,
recursive=form.cleaned_data.get("copy_subpages"),
parent=parent_page,
update_slug=form.cleaned_data["new_slug"],
user=request.user,
)
new_page = action.execute(skip_permission_checks=True)
else:
action = CopyPageAction(
page=page,
recursive=form.cleaned_data.get("copy_subpages"),
to=parent_page,
update_attrs={
"title": form.cleaned_data["new_title"],
"slug": form.cleaned_data["new_slug"],
},
keep_live=keep_live,
user=request.user,
)
new_page = action.execute()
# Give a success message back to the user
if form.cleaned_data.get("copy_subpages"):
messages.success(
request,
_("Page '%(page_title)s' and %(subpages_count)s subpages copied.")
% {
"page_title": page.specific_deferred.get_admin_display_title(),
"subpages_count": new_page.get_descendants().count(),
},
)
else:
messages.success(
request,
_("Page '%(page_title)s' copied.")
% {"page_title": page.specific_deferred.get_admin_display_title()},
)
for fn in hooks.get_hooks("after_copy_page"):
result = fn(request, page, new_page)
if hasattr(result, "status_code"):
return result
# Redirect to explore of parent page
if next_url:
return redirect(next_url)
return redirect("wagtailadmin_explore", parent_page.id)
return TemplateResponse(
request,
"wagtailadmin/pages/copy.html",
{
"page": page,
"form": form,
"next": next_url,
},
)

View File

@@ -0,0 +1,454 @@
from urllib.parse import quote, urlencode
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from django.views.generic.base import View
from wagtail.admin import messages, signals
from wagtail.admin.action_menu import PageActionMenu
from wagtail.admin.ui.components import MediaContainer
from wagtail.admin.ui.side_panels import (
ChecksSidePanel,
CommentsSidePanel,
PageStatusSidePanel,
PreviewSidePanel,
)
from wagtail.admin.utils import get_valid_next_url_from_request
from wagtail.admin.views.generic import HookResponseMixin
from wagtail.admin.views.generic.base import WagtailAdminTemplateMixin
from wagtail.models import Locale, Page, PageSubscription
def add_subpage(request, parent_page_id):
parent_page = get_object_or_404(Page, id=parent_page_id).specific
if not parent_page.permissions_for_user(request.user).can_add_subpage():
raise PermissionDenied
page_types = [
(
model.get_verbose_name(),
model._meta.app_label,
model._meta.model_name,
model.get_page_description(),
)
for model in type(parent_page).creatable_subpage_models()
if model.can_create_at(parent_page)
]
# sort by lower-cased version of verbose name
page_types.sort(key=lambda page_type: page_type[0].lower())
if len(page_types) == 1:
# Only one page type is available - redirect straight to the create form rather than
# making the user choose
verbose_name, app_label, model_name, description = page_types[0]
return redirect("wagtailadmin_pages:add", app_label, model_name, parent_page.id)
return TemplateResponse(
request,
"wagtailadmin/pages/add_subpage.html",
{
"parent_page": parent_page,
"page_types": page_types,
"next": get_valid_next_url_from_request(request),
},
)
class CreateView(WagtailAdminTemplateMixin, HookResponseMixin, View):
template_name = "wagtailadmin/pages/create.html"
page_title = gettext_lazy("New")
def dispatch(
self, request, content_type_app_name, content_type_model_name, parent_page_id
):
self.parent_page = get_object_or_404(Page, id=parent_page_id).specific
self.parent_page_perms = self.parent_page.permissions_for_user(
self.request.user
)
if not self.parent_page_perms.can_add_subpage():
raise PermissionDenied
try:
self.page_content_type = ContentType.objects.get_by_natural_key(
content_type_app_name, content_type_model_name
)
except ContentType.DoesNotExist:
raise Http404
# Get class
self.page_class = self.page_content_type.model_class()
# Make sure the class is a descendant of Page
if not issubclass(self.page_class, Page):
raise Http404
# page must be in the list of allowed subpage types for this parent ID
if self.page_class not in self.parent_page.creatable_subpage_models():
raise PermissionDenied
if not self.page_class.can_create_at(self.parent_page):
raise PermissionDenied
response = self.run_hook(
"before_create_page", self.request, self.parent_page, self.page_class
)
if response:
return response
self.locale = self.parent_page.locale
self.page = self.page_class(owner=self.request.user)
self.page.locale = self.locale
if getattr(settings, "WAGTAIL_I18N_ENABLED", False):
# If the parent page is the root page. The user may specify any locale they like
if self.parent_page.is_root():
selected_locale = request.GET.get("locale", None) or request.POST.get(
"locale", None
)
if selected_locale:
self.locale = get_object_or_404(
Locale, language_code=selected_locale
)
self.page.locale = self.locale
self.translations = self.get_translations()
else:
self.locale = None
self.translations = []
self.edit_handler = self.page_class.get_edit_handler()
self.form_class = self.edit_handler.get_form_class()
# Note: Comment notifications should be enabled by default for pages that a user creates
self.subscription = PageSubscription(
page=self.page, user=self.request.user, comment_notifications=True
)
self.next_url = get_valid_next_url_from_request(self.request)
return super().dispatch(request)
def post(self, request):
self.form = self.form_class(
self.request.POST,
self.request.FILES,
instance=self.page,
subscription=self.subscription,
parent_page=self.parent_page,
for_user=self.request.user,
)
if self.form.is_valid():
return self.form_valid(self.form)
else:
return self.form_invalid(self.form)
def form_valid(self, form):
if (
bool(self.request.POST.get("action-publish"))
and self.parent_page_perms.can_publish_subpage()
):
return self.publish_action()
elif (
bool(self.request.POST.get("action-submit"))
and self.parent_page.has_workflow
):
return self.submit_action()
else:
return self.save_action()
def get_page_subtitle(self):
return self.page_class.get_verbose_name()
def get_edit_message_button(self):
return messages.button(
reverse("wagtailadmin_pages:edit", args=(self.page.id,)), _("Edit")
)
def get_view_draft_message_button(self):
return messages.button(
reverse("wagtailadmin_pages:view_draft", args=(self.page.id,)),
_("View draft"),
new_window=False,
)
def get_view_live_message_button(self):
return messages.button(self.page.url, _("View live"), new_window=False)
def save_action(self):
self.page = self.form.save(commit=False)
self.page.live = False
# Save page
self.parent_page.add_child(instance=self.page)
# Save revision
self.page.save_revision(user=self.request.user, log_action=True)
# Save subscription settings
self.subscription.page = self.page
self.subscription.save()
# Notification
messages.success(
self.request,
_("Page '%(page_title)s' created.")
% {"page_title": self.page.get_admin_display_title()},
)
response = self.run_hook("after_create_page", self.request, self.page)
if response:
return response
# remain on edit page for further edits
return self.redirect_and_remain()
def publish_action(self):
self.page = self.form.save(commit=False)
# Save page
self.parent_page.add_child(instance=self.page)
# Save revision
revision = self.page.save_revision(user=self.request.user, log_action=True)
# Save subscription settings
self.subscription.page = self.page
self.subscription.save()
# Publish
response = self.run_hook("before_publish_page", self.request, self.page)
if response:
return response
revision.publish(user=self.request.user)
# get a fresh copy so that any changes coming from revision.publish() are passed on
self.page.refresh_from_db()
response = self.run_hook("after_publish_page", self.request, self.page)
if response:
return response
# Notification
if self.page.go_live_at and self.page.go_live_at > timezone.now():
messages.success(
self.request,
_("Page '%(page_title)s' created and scheduled for publishing.")
% {"page_title": self.page.get_admin_display_title()},
buttons=[self.get_edit_message_button()],
)
else:
buttons = []
if self.page.url is not None:
buttons.append(self.get_view_live_message_button())
buttons.append(self.get_edit_message_button())
messages.success(
self.request,
_("Page '%(page_title)s' created and published.")
% {"page_title": self.page.get_admin_display_title()},
buttons=buttons,
)
response = self.run_hook("after_create_page", self.request, self.page)
if response:
return response
return self.redirect_away()
def submit_action(self):
self.page = self.form.save(commit=False)
self.page.live = False
# Save page
self.parent_page.add_child(instance=self.page)
# Save revision
self.page.save_revision(user=self.request.user, log_action=True)
# Submit
workflow = self.page.get_workflow()
workflow.start(self.page, self.request.user)
# Save subscription settings
self.subscription.page = self.page
self.subscription.save()
# Notification
buttons = []
if self.page.is_previewable():
buttons.append(self.get_view_draft_message_button())
buttons.append(self.get_edit_message_button())
messages.success(
self.request,
_("Page '%(page_title)s' created and submitted for moderation.")
% {"page_title": self.page.get_admin_display_title()},
buttons=buttons,
)
response = self.run_hook("after_create_page", self.request, self.page)
if response:
return response
return self.redirect_away()
def redirect_away(self):
if self.next_url:
# redirect back to 'next' url if present
return redirect(self.next_url)
else:
# redirect back to the explorer
return redirect("wagtailadmin_explore", self.page.get_parent().id)
def redirect_and_remain(self):
target_url = reverse("wagtailadmin_pages:edit", args=[self.page.id])
if self.next_url:
# Ensure the 'next' url is passed through again if present
target_url += "?next=%s" % quote(self.next_url)
return redirect(target_url)
def form_invalid(self, form):
messages.validation_error(
self.request,
_("The page could not be created due to validation errors"),
self.form,
)
self.has_unsaved_changes = True
return self.render_to_response(self.get_context_data())
def get(self, request):
signals.init_new_page.send(
sender=CreateView, page=self.page, parent=self.parent_page
)
self.form = self.form_class(
instance=self.page,
subscription=self.subscription,
parent_page=self.parent_page,
for_user=self.request.user,
)
self.has_unsaved_changes = False
return self.render_to_response(self.get_context_data())
def get_preview_url(self):
return reverse(
"wagtailadmin_pages:preview_on_add",
args=[
self.page_content_type.app_label,
self.page_content_type.model,
self.parent_page.id,
],
)
def get_side_panels(self):
side_panels = [
PageStatusSidePanel(
self.page,
self.request,
show_schedule_publishing_toggle=self.form.show_schedule_publishing_toggle,
locale=self.locale,
translations=self.translations,
),
]
if self.page.is_previewable():
side_panels.append(
PreviewSidePanel(
self.page, self.request, preview_url=self.get_preview_url()
)
)
side_panels.append(
ChecksSidePanel(
self.page,
self.request,
)
)
if self.form.show_comments_toggle:
side_panels.append(CommentsSidePanel(self.page, self.request))
return MediaContainer(side_panels)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
bound_panel = self.edit_handler.get_bound_panel(
request=self.request, instance=self.page, form=self.form
)
action_menu = PageActionMenu(
self.request,
view="create",
parent_page=self.parent_page,
lock=None,
locked_for_user=False,
)
side_panels = self.get_side_panels()
media = MediaContainer([bound_panel, self.form, action_menu, side_panels]).media
context.update(
{
"content_type": self.page_content_type,
"page_class": self.page_class,
"parent_page": self.parent_page,
"edit_handler": bound_panel,
"action_menu": action_menu,
"side_panels": side_panels,
"form": self.form,
"next": self.next_url,
"has_unsaved_changes": self.has_unsaved_changes,
"locale": self.locale,
"media": media,
}
)
return context
def get_translations(self):
# Pages can be created in any language at the root level
if self.parent_page.is_root():
return [
{
"locale": locale,
"url": reverse(
"wagtailadmin_pages:add",
args=[
self.page_content_type.app_label,
self.page_content_type.model,
self.parent_page.id,
],
)
+ "?"
+ urlencode({"locale": locale.language_code}),
}
# Do not show the switcher for the current locale
for locale in Locale.objects.exclude(pk=self.locale.pk)
]
else:
return [
{
"locale": translation.locale,
"url": reverse(
"wagtailadmin_pages:add",
args=[
self.page_content_type.app_label,
self.page_content_type.model,
translation.id,
],
),
}
for translation in self.parent_page.get_translations()
.only("id", "locale")
.select_related("locale")
if translation.permissions_for_user(self.request.user).can_add_subpage()
and self.page_class
in translation.specific_class.creatable_subpage_models()
and self.page_class.can_create_at(translation)
]

View File

@@ -0,0 +1,119 @@
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.actions.delete_page import DeletePageAction
from wagtail.admin import messages
from wagtail.admin.utils import get_valid_next_url_from_request
from wagtail.models import Page, ReferenceIndex
def delete(request, page_id):
page = get_object_or_404(Page, id=page_id).specific
if not page.permissions_for_user(request.user).can_delete():
raise PermissionDenied
wagtail_site_name = getattr(settings, "WAGTAIL_SITE_NAME", "wagtail")
with transaction.atomic():
for fn in hooks.get_hooks("before_delete_page"):
result = fn(request, page)
if hasattr(result, "status_code"):
return result
next_url = get_valid_next_url_from_request(request)
pages_to_delete = {page}
# The `construct_translated_pages_to_cascade_actions` hook returns translation and
# alias pages when the action is set to "delete"
if getattr(settings, "WAGTAIL_I18N_ENABLED", False):
for fn in hooks.get_hooks("construct_translated_pages_to_cascade_actions"):
fn_pages = fn([page], "delete")
if fn_pages and isinstance(fn_pages, dict):
for additional_pages in fn_pages.values():
pages_to_delete.update(additional_pages)
pages_to_delete = list(pages_to_delete)
if request.method == "POST":
continue_deleting = True
if (
request.POST.get("confirm_site_name")
and request.POST.get("confirm_site_name") != wagtail_site_name
):
messages.error(
request, f"Please type '{wagtail_site_name}' to confirm."
)
continue_deleting = False
if continue_deleting:
parent_id = page.get_parent().id
# Delete the source page.
action = DeletePageAction(page, user=request.user)
# Permission checks are done above, so skip them in execute.
action.execute(skip_permission_checks=True)
# Delete translation and alias pages if they have the same parent page.
if getattr(settings, "WAGTAIL_I18N_ENABLED", False):
parent_page_translations = page.get_parent().get_translations()
for page_or_alias in pages_to_delete:
if page_or_alias.get_parent() in parent_page_translations:
action = DeletePageAction(page_or_alias, user=request.user)
# Permission checks are done above, so skip them in execute.
action.execute(skip_permission_checks=True)
messages.success(
request,
_("Page '%(page_title)s' deleted.")
% {"page_title": page.get_admin_display_title()},
)
for fn in hooks.get_hooks("after_delete_page"):
result = fn(request, page)
if hasattr(result, "status_code"):
return result
if next_url:
return redirect(next_url)
return redirect("wagtailadmin_explore", parent_id)
usage = ReferenceIndex.get_references_to(page).group_by_source_object()
descendant_count = page.get_descendant_count()
return TemplateResponse(
request,
"wagtailadmin/pages/confirm_delete.html",
{
"page": page,
"descendant_count": descendant_count,
"next": next_url,
"model_opts": page._meta,
"usage_url": reverse("wagtailadmin_pages:usage", args=(page.id,))
+ "?describe_on_delete=1",
"usage_count": usage.count(),
"is_protected": usage.is_protected,
# if the number of pages ( child pages + current page) exceeds this limit, then confirm before delete.
"confirm_before_delete": (descendant_count + 1)
>= getattr(settings, "WAGTAILADMIN_UNSAFE_PAGE_DELETION_LIMIT", 10),
"wagtail_site_name": wagtail_site_name,
# note that while pages_to_delete may contain a mix of translated pages
# and aliases, we count the "translations" only, as aliases are similar
# to symlinks, so they should just follow the source
"translation_count": len(
[
translation.id
for translation in pages_to_delete
if not translation.alias_of_id and translation.id != page.id
]
),
"translation_descendant_count": sum(
[
translation.get_descendants().filter(alias_of__isnull=True).count()
for translation in pages_to_delete
]
),
},
)

View File

@@ -0,0 +1,970 @@
import json
from urllib.parse import quote
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.db.models import Prefetch, Q
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import gettext as _
from django.views.generic.base import View
from wagtail.actions.publish_page_revision import PublishPageRevisionAction
from wagtail.admin import messages
from wagtail.admin.action_menu import PageActionMenu
from wagtail.admin.mail import send_notification
from wagtail.admin.models import EditingSession
from wagtail.admin.ui.components import MediaContainer
from wagtail.admin.ui.editing_sessions import EditingSessionsModule
from wagtail.admin.ui.side_panels import (
ChecksSidePanel,
CommentsSidePanel,
PageStatusSidePanel,
PreviewSidePanel,
)
from wagtail.admin.utils import get_valid_next_url_from_request
from wagtail.admin.views.generic import HookResponseMixin
from wagtail.admin.views.generic.base import WagtailAdminTemplateMixin
from wagtail.exceptions import PageClassNotFoundError
from wagtail.locks import BasicLock, ScheduledForPublishLock, WorkflowLock
from wagtail.models import (
COMMENTS_RELATION_NAME,
Comment,
CommentReply,
Page,
PageSubscription,
WorkflowState,
get_default_page_content_type,
)
from wagtail.utils.timestamps import render_timestamp
class EditView(WagtailAdminTemplateMixin, HookResponseMixin, View):
def get_page_title(self):
return _("Editing %(page_type)s") % {
"page_type": self.page_class.get_verbose_name()
}
def get_page_subtitle(self):
return self.page.get_admin_display_title()
def get_template_names(self):
if self.page.alias_of_id:
return ["wagtailadmin/pages/edit_alias.html"]
else:
return ["wagtailadmin/pages/edit.html"]
def add_save_confirmation_message(self):
if self.is_reverting:
message = _(
"Page '%(page_title)s' has been replaced "
"with version from %(previous_revision_datetime)s."
) % {
"page_title": self.page.get_admin_display_title(),
"previous_revision_datetime": render_timestamp(
self.previous_revision.created_at
),
}
else:
message = _("Page '%(page_title)s' has been updated.") % {
"page_title": self.page.get_admin_display_title()
}
messages.success(self.request, message)
def get_commenting_changes(self):
"""
Finds comments that have been changed during this request.
Returns a tuple of 5 lists:
- New comments
- Deleted comments
- Resolved comments
- Edited comments
- Replied comments (dict containing the instance and list of replies)
"""
# Get changes
comments_formset = self.form.formsets["comments"]
new_comments = comments_formset.new_objects
deleted_comments = comments_formset.deleted_objects
# Assume any changed comments that are resolved were only just resolved
resolved_comments = []
edited_comments = []
for changed_comment, changed_fields in comments_formset.changed_objects:
if changed_comment.resolved_at and "resolved" in changed_fields:
resolved_comments.append(changed_comment)
if "text" in changed_fields:
edited_comments.append(changed_comment)
new_replies = []
deleted_replies = []
edited_replies = []
for comment_form in comments_formset.forms:
# New
replies = getattr(comment_form.formsets["replies"], "new_objects", [])
if replies:
new_replies.append((comment_form.instance, replies))
# Deleted
replies = getattr(comment_form.formsets["replies"], "deleted_objects", [])
if replies:
deleted_replies.append((comment_form.instance, replies))
# Edited
replies = getattr(comment_form.formsets["replies"], "changed_objects", [])
replies = [
reply for reply, changed_fields in replies if "text" in changed_fields
]
if replies:
edited_replies.append((comment_form.instance, replies))
return {
"new_comments": new_comments,
"deleted_comments": deleted_comments,
"resolved_comments": resolved_comments,
"edited_comments": edited_comments,
"new_replies": new_replies,
"deleted_replies": deleted_replies,
"edited_replies": edited_replies,
}
def send_commenting_notifications(self, changes):
"""
Sends notifications about any changes to comments to anyone who is subscribed.
"""
relevant_comment_ids = []
relevant_comment_ids.extend(
comment.pk for comment in changes["resolved_comments"]
)
relevant_comment_ids.extend(
comment.pk for comment, replies in changes["new_replies"]
)
# Skip if no changes were made
# Note: We don't email about edited comments so ignore those here
if (
not changes["new_comments"]
and not changes["deleted_comments"]
and not changes["resolved_comments"]
and not changes["new_replies"]
):
return
# Get global page comment subscribers
subscribers = PageSubscription.objects.filter(
page=self.page, comment_notifications=True
).select_related("user")
global_recipient_users = [
subscriber.user
for subscriber in subscribers
if subscriber.user != self.request.user
]
# Get subscribers to individual threads
replies = CommentReply.objects.filter(comment_id__in=relevant_comment_ids)
comments = Comment.objects.filter(id__in=relevant_comment_ids)
thread_users = (
get_user_model()
.objects.exclude(pk=self.request.user.pk)
.exclude(pk__in=subscribers.values_list("user_id", flat=True))
.filter(
Q(comment_replies__comment_id__in=relevant_comment_ids)
| Q(**{("%s__pk__in" % COMMENTS_RELATION_NAME): relevant_comment_ids})
)
.prefetch_related(
Prefetch("comment_replies", queryset=replies),
Prefetch(COMMENTS_RELATION_NAME, queryset=comments),
)
)
# Skip if no recipients
if not (global_recipient_users or thread_users):
return
thread_users = [
(
user,
set(
list(user.comment_replies.values_list("comment_id", flat=True))
+ list(
getattr(user, COMMENTS_RELATION_NAME).values_list(
"pk", flat=True
)
)
),
)
for user in thread_users
]
mailed_users = set()
for current_user, current_threads in thread_users:
# We are trying to avoid calling send_notification for each user for performance reasons
# so group the users receiving the same thread notifications together here
if current_user in mailed_users:
continue
users = [current_user]
mailed_users.add(current_user)
for user, threads in thread_users:
if user not in mailed_users and threads == current_threads:
users.append(user)
mailed_users.add(user)
send_notification(
users,
"updated_comments",
{
"page": self.page,
"editor": self.request.user,
"new_comments": [
comment
for comment in changes["new_comments"]
if comment.pk in threads
],
"resolved_comments": [
comment
for comment in changes["resolved_comments"]
if comment.pk in threads
],
"deleted_comments": [],
"replied_comments": [
{
"comment": comment,
"replies": replies,
}
for comment, replies in changes["new_replies"]
if comment.pk in threads
],
},
)
return send_notification(
global_recipient_users,
"updated_comments",
{
"page": self.page,
"editor": self.request.user,
"new_comments": changes["new_comments"],
"resolved_comments": changes["resolved_comments"],
"deleted_comments": changes["deleted_comments"],
"replied_comments": [
{
"comment": comment,
"replies": replies,
}
for comment, replies in changes["new_replies"]
],
},
)
def log_commenting_changes(self, changes, revision):
"""
Generates log entries for any changes made to comments or replies.
"""
for comment in changes["new_comments"]:
comment.log_create(page_revision=revision, user=self.request.user)
for comment in changes["edited_comments"]:
comment.log_edit(page_revision=revision, user=self.request.user)
for comment in changes["resolved_comments"]:
comment.log_resolve(page_revision=revision, user=self.request.user)
for comment in changes["deleted_comments"]:
comment.log_delete(page_revision=revision, user=self.request.user)
for comment, replies in changes["new_replies"]:
for reply in replies:
reply.log_create(page_revision=revision, user=self.request.user)
for comment, replies in changes["edited_replies"]:
for reply in replies:
reply.log_edit(page_revision=revision, user=self.request.user)
for comment, replies in changes["deleted_replies"]:
for reply in replies:
reply.log_delete(page_revision=revision, user=self.request.user)
def get_edit_message_button(self):
return messages.button(
reverse("wagtailadmin_pages:edit", args=(self.page.id,)), _("Edit")
)
def get_view_draft_message_button(self):
return messages.button(
reverse("wagtailadmin_pages:view_draft", args=(self.page.id,)),
_("View draft"),
new_window=False,
)
def get_view_live_message_button(self):
return messages.button(self.page.url, _("View live"), new_window=False)
def get_compare_with_live_message_button(self):
return messages.button(
reverse(
"wagtailadmin_pages:revisions_compare",
args=(self.page.id, "live", self.latest_revision.id),
),
_("Compare with live version"),
)
def get_page_for_status(self):
if self.page.live and self.page.has_unpublished_changes:
# Page status needs to present the version of the page containing the correct live URL
return self.real_page_record.specific
else:
return self.page
def dispatch(self, request, page_id):
self.real_page_record = get_object_or_404(
Page.objects.prefetch_workflow_states(), id=page_id
)
self.latest_revision = self.real_page_record.get_latest_revision()
self.scheduled_revision = self.real_page_record.scheduled_revision
self.page_content_type = self.real_page_record.cached_content_type
self.page_class = self.real_page_record.specific_class
if self.page_class is None:
raise PageClassNotFoundError(
f"The page '{self.real_page_record}' cannot be edited because the "
f"model class used to create it ({self.page_content_type.app_label}."
f"{self.page_content_type.model}) can no longer be found in the codebase. "
"This usually happens as a result of switching between git "
"branches without running migrations to trigger the removal of "
"unused ContentTypes. To edit the page, you will need to switch "
"back to a branch where the model class is still present."
)
self.page = self.real_page_record.get_latest_revision_as_object()
self.parent = self.page.get_parent()
self.scheduled_page = self.real_page_record.get_scheduled_revision_as_object()
self.page_perms = self.page.permissions_for_user(self.request.user)
self.lock = self.page.get_lock()
self.locked_for_user = self.lock is not None and self.lock.for_user(
self.request.user
)
if not self.page_perms.can_edit():
raise PermissionDenied
self.next_url = get_valid_next_url_from_request(self.request)
response = self.run_hook("before_edit_page", self.request, self.page)
if response:
return response
self.subscription, created = PageSubscription.objects.get_or_create(
page=self.page,
user=self.request.user,
defaults={
"comment_notifications": False,
},
)
self.edit_handler = self.page_class.get_edit_handler()
self.form_class = self.edit_handler.get_form_class()
if getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
# Retrieve current workflow state if set, default to last workflow state
self.workflow_state = (
self.page.current_workflow_state
or self.page.workflow_states.order_by("created_at").last()
)
else:
self.workflow_state = None
if getattr(settings, "WAGTAIL_I18N_ENABLED", False):
self.locale = self.page.locale
self.translations = self.get_translations()
else:
self.locale = None
self.translations = []
if self.workflow_state:
self.workflow_tasks = self.workflow_state.all_tasks_with_status()
else:
self.workflow_tasks = []
self.errors_debug = None
return super().dispatch(request)
def get(self, request):
if self.lock:
lock_message = self.lock.get_message(self.request.user)
if lock_message:
if isinstance(self.lock, BasicLock) and self.page_perms.can_unlock():
lock_message = format_html(
'{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{}">{}</button></span>',
lock_message,
reverse("wagtailadmin_pages:unlock", args=(self.page.id,)),
_("Unlock"),
)
if (
isinstance(self.lock, ScheduledForPublishLock)
and self.page_perms.can_unschedule()
):
lock_message = format_html(
'{} <span class="buttons"><button type="button" class="button button-small button-secondary" data-action="w-action#post" data-controller="w-action" data-w-action-url-value="{}">{}</button></span>',
lock_message,
reverse(
"wagtailadmin_pages:revisions_unschedule",
args=[self.page.id, self.scheduled_revision.pk],
),
_("Cancel scheduled publish"),
)
if (
not isinstance(self.lock, ScheduledForPublishLock)
and self.locked_for_user
):
messages.warning(self.request, lock_message, extra_tags="lock")
else:
messages.info(self.request, lock_message, extra_tags="lock")
self.form = self.form_class(
instance=self.page,
subscription=self.subscription,
parent_page=self.parent,
for_user=self.request.user,
)
self.has_unsaved_changes = False
self.page_for_status = self.get_page_for_status()
return self.render_to_response(self.get_context_data())
def add_cancel_workflow_confirmation_message(self):
message = _("Workflow on page '%(page_title)s' has been cancelled.") % {
"page_title": self.page.get_admin_display_title()
}
messages.success(
self.request,
message,
buttons=[
self.get_view_draft_message_button(),
self.get_edit_message_button(),
],
)
def post(self, request):
# Don't allow POST requests if the page is an alias
if self.page.alias_of_id:
# Return 405 "Method Not Allowed" response
return HttpResponse(status=405)
self.form = self.form_class(
self.request.POST,
self.request.FILES,
instance=self.page,
subscription=self.subscription,
parent_page=self.parent,
for_user=self.request.user,
)
self.is_cancelling_workflow = (
bool(self.request.POST.get("action-cancel-workflow"))
and self.workflow_state
and self.workflow_state.user_can_cancel(self.request.user)
)
if self.form.is_valid() and not self.locked_for_user:
return self.form_valid(self.form)
else:
return self.form_invalid(self.form)
def workflow_action_is_valid(self):
self.workflow_action = self.request.POST["workflow-action-name"]
available_actions = self.page.current_workflow_task.get_actions(
self.page, self.request.user
)
available_action_names = [
name for name, verbose_name, modal in available_actions
]
return self.workflow_action in available_action_names
def form_valid(self, form):
self.is_reverting = bool(self.request.POST.get("revision"))
# If a revision ID was passed in the form, get that revision so its
# date can be referenced in notification messages
if self.is_reverting:
self.previous_revision = get_object_or_404(
self.page.revisions, id=self.request.POST.get("revision")
)
self.has_content_changes = self.form.has_changed()
if self.request.POST.get("action-publish") and self.page_perms.can_publish():
return self.publish_action()
elif (
self.request.POST.get("action-submit")
and self.page_perms.can_submit_for_moderation()
):
return self.submit_action()
elif (
self.request.POST.get("action-restart-workflow")
and self.page_perms.can_submit_for_moderation()
and self.workflow_state
and self.workflow_state.user_can_cancel(self.request.user)
):
return self.restart_workflow_action()
elif (
self.request.POST.get("action-workflow-action")
and self.workflow_action_is_valid()
):
return self.perform_workflow_action()
elif self.is_cancelling_workflow:
return self.cancel_workflow_action()
else:
return self.save_action()
def save_action(self):
self.page = self.form.save(commit=not self.page.live)
self.subscription.save()
# Save revision
revision = self.page.save_revision(
user=self.request.user,
log_action=True, # Always log the new revision on edit
previous_revision=(self.previous_revision if self.is_reverting else None),
)
self.add_save_confirmation_message()
if self.has_content_changes and "comments" in self.form.formsets:
changes = self.get_commenting_changes()
self.log_commenting_changes(changes, revision)
self.send_commenting_notifications(changes)
response = self.run_hook("after_edit_page", self.request, self.page)
if response:
return response
# Just saving - remain on edit page for further edits
return self.redirect_and_remain()
def publish_action(self):
self.page = self.form.save(commit=not self.page.live)
self.subscription.save()
# Save revision
revision = self.page.save_revision(
user=self.request.user,
log_action=True, # Always log the new revision on edit
previous_revision=(self.previous_revision if self.is_reverting else None),
)
# store submitted go_live_at for messaging below
go_live_at = self.page.go_live_at
response = self.run_hook("before_publish_page", self.request, self.page)
if response:
return response
action = PublishPageRevisionAction(
revision,
user=self.request.user,
changed=self.has_content_changes,
previous_revision=(self.previous_revision if self.is_reverting else None),
)
action.execute(skip_permission_checks=True)
if self.has_content_changes and "comments" in self.form.formsets:
changes = self.get_commenting_changes()
self.log_commenting_changes(changes, revision)
self.send_commenting_notifications(changes)
# Need to reload the page because the URL may have changed, and we
# need the up-to-date URL for the "View Live" button.
self.page = self.page.specific_class.objects.get(pk=self.page.pk)
response = self.run_hook("after_publish_page", self.request, self.page)
if response:
return response
# Notifications
if go_live_at and go_live_at > timezone.now():
# Page has been scheduled for publishing in the future
if self.is_reverting:
message = _(
"Version from %(previous_revision_datetime)s "
"of page '%(page_title)s' has been scheduled for publishing."
) % {
"previous_revision_datetime": render_timestamp(
self.previous_revision.created_at
),
"page_title": self.page.get_admin_display_title(),
}
else:
if self.page.live:
message = _(
"Page '%(page_title)s' is live and this version has been scheduled for publishing."
) % {"page_title": self.page.get_admin_display_title()}
else:
message = _(
"Page '%(page_title)s' has been scheduled for publishing."
) % {"page_title": self.page.get_admin_display_title()}
messages.success(
self.request, message, buttons=[self.get_edit_message_button()]
)
else:
# Page is being published now
if self.is_reverting:
message = _(
"Version from %(datetime)s of page '%(page_title)s' has been published."
) % {
"datetime": render_timestamp(self.previous_revision.created_at),
"page_title": self.page.get_admin_display_title(),
}
else:
message = _("Page '%(page_title)s' has been published.") % {
"page_title": self.page.get_admin_display_title()
}
buttons = []
if self.page.url is not None:
buttons.append(self.get_view_live_message_button())
buttons.append(self.get_edit_message_button())
messages.success(self.request, message, buttons=buttons)
response = self.run_hook("after_edit_page", self.request, self.page)
if response:
return response
# we're done here - redirect back to the explorer
return self.redirect_away()
def submit_action(self):
self.page = self.form.save(commit=not self.page.live)
self.subscription.save()
# Save revision
revision = self.page.save_revision(
user=self.request.user,
log_action=True, # Always log the new revision on edit
previous_revision=(self.previous_revision if self.is_reverting else None),
)
if self.has_content_changes and "comments" in self.form.formsets:
changes = self.get_commenting_changes()
self.log_commenting_changes(changes, revision)
self.send_commenting_notifications(changes)
if (
self.workflow_state
and self.workflow_state.status == WorkflowState.STATUS_NEEDS_CHANGES
):
# If the workflow was in the needs changes state, resume the existing workflow on submission
self.workflow_state.resume(self.request.user)
else:
# Otherwise start a new workflow
workflow = self.page.get_workflow()
workflow.start(self.page, self.request.user)
message = _("Page '%(page_title)s' has been submitted for moderation.") % {
"page_title": self.page.get_admin_display_title()
}
messages.success(
self.request,
message,
buttons=[
self.get_view_draft_message_button(),
self.get_edit_message_button(),
],
)
response = self.run_hook("after_edit_page", self.request, self.page)
if response:
return response
# we're done here - redirect back to the explorer
return self.redirect_away()
def restart_workflow_action(self):
self.page = self.form.save(commit=not self.page.live)
self.subscription.save()
# save revision
revision = self.page.save_revision(
user=self.request.user,
log_action=True, # Always log the new revision on edit
previous_revision=(self.previous_revision if self.is_reverting else None),
)
if self.has_content_changes and "comments" in self.form.formsets:
changes = self.get_commenting_changes()
self.log_commenting_changes(changes, revision)
self.send_commenting_notifications(changes)
# cancel workflow
self.workflow_state.cancel(user=self.request.user)
# start new workflow
workflow = self.page.get_workflow()
workflow.start(self.page, self.request.user)
message = _("Workflow on page '%(page_title)s' has been restarted.") % {
"page_title": self.page.get_admin_display_title()
}
messages.success(
self.request,
message,
buttons=[
self.get_view_draft_message_button(),
self.get_edit_message_button(),
],
)
response = self.run_hook("after_edit_page", self.request, self.page)
if response:
return response
# we're done here - redirect back to the explorer
return self.redirect_away()
def perform_workflow_action(self):
self.page = self.form.save(commit=not self.page.live)
self.subscription.save()
if self.has_content_changes:
# Save revision
revision = self.page.save_revision(
user=self.request.user,
log_action=True, # Always log the new revision on edit
previous_revision=(
self.previous_revision if self.is_reverting else None
),
)
if "comments" in self.form.formsets:
changes = self.get_commenting_changes()
self.log_commenting_changes(changes, revision)
self.send_commenting_notifications(changes)
extra_workflow_data_json = self.request.POST.get(
"workflow-action-extra-data", "{}"
)
extra_workflow_data = json.loads(extra_workflow_data_json)
self.page.current_workflow_task.on_action(
self.page.current_workflow_task_state,
self.request.user,
self.workflow_action,
**extra_workflow_data,
)
self.add_save_confirmation_message()
response = self.run_hook("after_edit_page", self.request, self.page)
if response:
return response
# we're done here - redirect back to the explorer
return self.redirect_away()
def cancel_workflow_action(self):
self.workflow_state.cancel(user=self.request.user)
self.page = self.form.save(commit=not self.page.live)
self.subscription.save()
# Save revision
revision = self.page.save_revision(
user=self.request.user,
log_action=True, # Always log the new revision on edit
previous_revision=(self.previous_revision if self.is_reverting else None),
)
if self.has_content_changes and "comments" in self.form.formsets:
changes = self.get_commenting_changes()
self.log_commenting_changes(changes, revision)
self.send_commenting_notifications(changes)
# Notifications
self.add_cancel_workflow_confirmation_message()
response = self.run_hook("after_edit_page", self.request, self.page)
if response:
return response
# Just saving - remain on edit page for further edits
return self.redirect_and_remain()
def redirect_away(self):
if self.next_url:
# redirect back to 'next' url if present
return redirect(self.next_url)
else:
# redirect back to the explorer
return redirect("wagtailadmin_explore", self.page.get_parent().id)
def redirect_and_remain(self):
target_url = reverse("wagtailadmin_pages:edit", args=[self.page.id])
if self.next_url:
# Ensure the 'next' url is passed through again if present
target_url += "?next=%s" % quote(self.next_url)
return redirect(target_url)
def form_invalid(self, form):
# even if the page is locked due to not having permissions, the original submitter can still cancel the workflow
if self.is_cancelling_workflow:
self.workflow_state.cancel(user=self.request.user)
self.add_cancel_workflow_confirmation_message()
# Refresh the lock object as now WorkflowLock no longer applies
self.lock = self.page.get_lock()
self.locked_for_user = self.lock is not None and self.lock.for_user(
self.request.user
)
elif self.locked_for_user:
messages.error(
self.request, _("The page could not be saved as it is locked")
)
else:
messages.validation_error(
self.request,
_("The page could not be saved due to validation errors"),
self.form,
)
self.errors_debug = repr(self.form.errors) + repr(
[
(name, formset.errors)
for (name, formset) in self.form.formsets.items()
if formset.errors
]
)
self.has_unsaved_changes = True
self.page_for_status = self.get_page_for_status()
return self.render_to_response(self.get_context_data())
def get_preview_url(self):
return reverse("wagtailadmin_pages:preview_on_edit", args=[self.page.id])
def get_history_url(self):
permissions = self.page.permissions_for_user(self.request.user)
if permissions.can_view_revisions():
return reverse("wagtailadmin_pages:history", args=[self.page.id])
def get_side_panels(self):
side_panels = [
PageStatusSidePanel(
self.page,
self.request,
show_schedule_publishing_toggle=self.form.show_schedule_publishing_toggle,
live_object=self.real_page_record,
scheduled_object=self.scheduled_page,
locale=self.locale,
translations=self.translations,
),
]
if self.page.is_previewable():
side_panels.append(
PreviewSidePanel(
self.page, self.request, preview_url=self.get_preview_url()
)
)
side_panels.append(ChecksSidePanel(self.page, self.request))
if self.form.show_comments_toggle:
side_panels.append(CommentsSidePanel(self.page, self.request))
return MediaContainer(side_panels)
def get_editing_sessions(self):
EditingSession.cleanup()
content_type = get_default_page_content_type()
session = EditingSession.objects.create(
user=self.request.user,
content_type=content_type,
object_id=self.page.pk,
last_seen_at=timezone.now(),
)
return EditingSessionsModule(
session,
reverse(
"wagtailadmin_editing_sessions:ping",
args=("wagtailcore", "page", self.page.pk, session.id),
),
reverse(
"wagtailadmin_editing_sessions:release",
args=(session.id,),
),
[],
self.page.latest_revision_id,
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user_perms = self.page.permissions_for_user(self.request.user)
bound_panel = self.edit_handler.get_bound_panel(
instance=self.page, request=self.request, form=self.form
)
action_menu = PageActionMenu(
self.request,
view="edit",
page=self.page,
lock=self.lock,
locked_for_user=self.locked_for_user,
)
side_panels = self.get_side_panels()
media = MediaContainer([bound_panel, self.form, action_menu, side_panels]).media
context.update(
{
"page": self.page,
"page_for_status": self.page_for_status,
"content_type": self.page_content_type,
"edit_handler": bound_panel,
"errors_debug": self.errors_debug,
"action_menu": action_menu,
"side_panels": side_panels,
"form": self.form,
"next": self.next_url,
"history_url": self.get_history_url(),
"has_unsaved_changes": self.has_unsaved_changes,
"page_locked": self.locked_for_user,
"workflow_state": self.workflow_state
if self.workflow_state and self.workflow_state.is_active
else None,
"current_task_state": self.page.current_workflow_task_state,
"publishing_will_cancel_workflow": self.workflow_tasks
and getattr(settings, "WAGTAIL_WORKFLOW_CANCEL_ON_PUBLISH", True),
"confirm_workflow_cancellation_url": reverse(
"wagtailadmin_pages:confirm_workflow_cancellation",
args=(self.page.id,),
),
"user_can_lock": (not self.lock or isinstance(self.lock, WorkflowLock))
and user_perms.can_lock(),
"user_can_unlock": isinstance(self.lock, BasicLock)
and user_perms.can_unlock(),
"locale": self.locale,
"media": media,
"editing_sessions": self.get_editing_sessions(),
}
)
return context
def get_translations(self):
return [
{
"locale": translation.locale,
"url": reverse("wagtailadmin_pages:edit", args=[translation.id]),
}
for translation in self.page.get_translations()
.only("id", "locale", "depth")
.select_related("locale")
if translation.permissions_for_user(self.request.user).can_edit()
]

View File

@@ -0,0 +1,95 @@
import django_filters
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy
from wagtail.admin.views.generic import history
from wagtail.admin.views.pages.utils import (
GenericPageBreadcrumbsMixin,
)
from wagtail.admin.widgets import BooleanRadioSelect
from wagtail.models import Page, PageLogEntry
from wagtail.permissions import page_permission_policy
class PageHistoryFilterSet(history.HistoryFilterSet):
is_commenting_action = django_filters.BooleanFilter(
label=gettext_lazy("Is commenting action"),
method="filter_is_commenting_action",
widget=BooleanRadioSelect,
)
def filter_is_commenting_action(self, queryset, name, value):
if value is None:
return queryset
q = Q(action__startswith="wagtail.comments")
if value is False:
q = ~q
return queryset.filter(q)
class PageWorkflowHistoryViewMixin:
model = Page
pk_url_kwarg = "page_id"
def dispatch(self, request, *args, **kwargs):
if not self.object.permissions_for_user(request.user).can_edit():
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs, page=self.object)
class WorkflowHistoryView(PageWorkflowHistoryViewMixin, history.WorkflowHistoryView):
header_icon = "doc-empty-inverse"
workflow_history_url_name = "wagtailadmin_pages:workflow_history"
workflow_history_detail_url_name = "wagtailadmin_pages:workflow_history_detail"
class WorkflowHistoryDetailView(
PageWorkflowHistoryViewMixin, history.WorkflowHistoryDetailView
):
object_icon = "doc-empty-inverse"
workflow_history_url_name = "wagtailadmin_pages:workflow_history"
class PageHistoryView(GenericPageBreadcrumbsMixin, history.HistoryView):
template_name = "wagtailadmin/pages/history.html"
filterset_class = PageHistoryFilterSet
model = Page
pk_url_kwarg = "page_id"
permission_policy = page_permission_policy
any_permission_required = {
"add",
"change",
"publish",
"bulk_delete",
"lock",
"unlock",
}
history_url_name = "wagtailadmin_pages:history"
history_results_url_name = "wagtailadmin_pages:history_results"
edit_url_name = "wagtailadmin_pages:edit"
revisions_view_url_name = "wagtailadmin_pages:revisions_view"
revisions_revert_url_name = "wagtailadmin_pages:revisions_revert"
revisions_compare_url_name = "wagtailadmin_pages:revisions_compare"
revisions_unschedule_url_name = "wagtailadmin_pages:revisions_unschedule"
def get_object(self):
return get_object_or_404(Page, id=self.pk).specific
def get_page_subtitle(self):
return self.object.get_admin_display_title()
def user_can_unschedule(self):
return self.object.permissions_for_user(self.request.user).can_unschedule()
def get_base_queryset(self):
return self._annotate_queryset(PageLogEntry.objects.filter(page=self.object))
def _annotate_queryset(self, queryset):
return super()._annotate_queryset(queryset).select_related("page")

View File

@@ -0,0 +1,523 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import F
from django.forms import CheckboxSelectMultiple, RadioSelect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property, classproperty
from django.utils.translation import gettext_lazy as _
from django_filters.filters import (
ChoiceFilter,
DateFromToRangeFilter,
ModelMultipleChoiceFilter,
)
from wagtail import hooks
from wagtail.admin.filters import (
DateRangePickerWidget,
MultipleContentTypeFilter,
MultipleUserFilter,
WagtailFilterSet,
)
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.ui.components import MediaContainer
from wagtail.admin.ui.side_panels import (
PageStatusSidePanel,
)
from wagtail.admin.ui.tables import Column, DateColumn
from wagtail.admin.ui.tables.pages import (
BulkActionsColumn,
NavigateToChildrenColumn,
OrderingColumn,
PageStatusColumn,
PageTable,
PageTitleColumn,
)
from wagtail.admin.views import generic
from wagtail.models import Page, PageLogEntry, Site, get_page_content_types
from wagtail.permissions import page_permission_policy
class SiteFilter(ModelMultipleChoiceFilter):
def get_filter_predicate(self, v):
return {"path__startswith": v.root_page.path}
class HasChildPagesFilter(ChoiceFilter):
def filter(self, qs, value):
if value == "true":
return qs.filter(numchild__gt=0)
elif value == "false":
return qs.filter(numchild=0)
else: # None / empty string
return qs
class EditedByFilter(MultipleUserFilter):
def filter(self, qs, value):
if value:
qs = qs.filter(
pk__in=PageLogEntry.objects.filter(
action="wagtail.edit", user__in=value
)
.order_by()
.values_list("page_id", flat=True)
.distinct()
)
return qs
class PageFilterSet(WagtailFilterSet):
latest_revision_created_at = DateFromToRangeFilter(
label=_("Date updated"),
widget=DateRangePickerWidget,
)
owner = MultipleUserFilter(
label=_("Owner"),
queryset=(
lambda request: get_user_model().objects.filter(
pk__in=Page.objects.order_by()
.values_list("owner_id", flat=True)
.distinct()
)
),
widget=CheckboxSelectMultiple,
)
edited_by = EditedByFilter(
label=_("Edited by"),
queryset=(
lambda request: get_user_model().objects.filter(
pk__in=PageLogEntry.objects.filter(action="wagtail.edit")
.order_by()
.values_list("user_id", flat=True)
.distinct()
)
),
widget=CheckboxSelectMultiple,
)
site = SiteFilter(
label=_("Site"),
queryset=Site.objects.all(),
widget=CheckboxSelectMultiple,
)
has_child_pages = HasChildPagesFilter(
label=_("Has child pages"),
empty_label=_("Any"),
choices=[
("true", _("Yes")),
("false", _("No")),
],
widget=RadioSelect,
)
class Meta:
model = Page
fields = [] # only needed for filters being generated automatically
class ExplorablePageFilterSet(PageFilterSet):
content_type = MultipleContentTypeFilter(
label=_("Page type"),
queryset=lambda request: get_page_content_types(include_base_page_type=False),
widget=CheckboxSelectMultiple,
)
class IndexView(generic.IndexView):
template_name = "wagtailadmin/pages/index.html"
results_template_name = "wagtailadmin/pages/index_results.html"
permission_policy = page_permission_policy
any_permission_required = {
"add",
"change",
"publish",
"bulk_delete",
"lock",
"unlock",
}
context_object_name = "pages"
page_kwarg = "p"
paginate_by = 50
table_class = PageTable
table_classname = "listing full-width"
filterset_class = PageFilterSet
index_url_name = None
index_results_url_name = None
default_ordering = "-latest_revision_created_at"
model = Page
_show_breadcrumbs = True
columns = [
BulkActionsColumn("bulk_actions"),
PageTitleColumn(
"title",
label=_("Title"),
sort_key="title",
classname="title",
),
DateColumn(
"latest_revision_created_at",
label=_("Updated"),
sort_key="latest_revision_created_at",
width="12%",
),
PageStatusColumn(
"status",
label=_("Status"),
sort_key="live",
width="12%",
),
]
def get(self, request):
# Search
self.query_string = None
self.is_searching = False
if "q" in self.request.GET:
self.search_form = SearchForm(self.request.GET)
if self.search_form.is_valid():
self.query_string = self.search_form.cleaned_data["q"]
else:
self.search_form = SearchForm()
if self.query_string:
self.is_searching = True
return super().get(request)
def get_valid_orderings(self):
valid_orderings = super().get_valid_orderings()
if self.is_searching:
# ordering by content type not currently available when searching, due to
# https://github.com/wagtail/wagtail/issues/6616
try:
valid_orderings.remove("content_type")
valid_orderings.remove("-content_type")
except ValueError:
pass
return valid_orderings
def get_ordering(self):
if self.is_searching and not self.is_explicitly_ordered:
# default to ordering by relevance
default_ordering = None
else:
default_ordering = self.default_ordering
ordering = self.request.GET.get("ordering", default_ordering)
if ordering not in self.get_valid_orderings():
ordering = default_ordering
return ordering
def get_base_queryset(self):
pages = self.model.objects.filter(depth__gt=1)
pages = self._annotate_queryset(pages)
return pages
def _annotate_queryset(self, pages):
pages = pages.prefetch_related("content_type", "sites_rooted_here").filter(
pk__in=self.permission_policy.explorable_instances(
self.request.user
).values_list("pk", flat=True)
)
# We want specific page instances, but do not need streamfield values here
pages = pages.defer_streamfields().specific()
# Annotate queryset with various states to be used later for performance optimisations
if getattr(settings, "WAGTAIL_WORKFLOW_ENABLED", True):
pages = pages.prefetch_workflow_states()
pages = pages.annotate_site_root_state().annotate_approved_schedule()
return pages
def order_queryset(self, queryset):
if self.is_searching and not self.is_explicitly_ordered:
# search backend will order by relevance in this case, so don't bother to
# apply an ordering on the queryset
return queryset
if self.ordering == "ord":
# preserve the native ordering from get_children()
pass
elif self.ordering == "latest_revision_created_at" and not self.is_searching:
# order by oldest revision first.
# Special case NULL entries - these should go at the top of the list.
# Skip this special case when searching (and fall through to plain field ordering
# instead) as search backends do not support F objects in order_by
queryset = queryset.order_by(
F("latest_revision_created_at").asc(nulls_first=True)
)
elif self.ordering == "-latest_revision_created_at" and not self.is_searching:
# order by oldest revision first.
# Special case NULL entries - these should go at the end of the list.
# Skip this special case when searching (and fall through to plain field ordering
# instead) as search backends do not support F objects in order_by
queryset = queryset.order_by(
F("latest_revision_created_at").desc(nulls_last=True)
)
else:
queryset = super().order_queryset(queryset)
return queryset
def search_queryset(self, queryset):
if self.is_searching:
queryset = queryset.autocomplete(
self.query_string, order_by_relevance=(not self.is_explicitly_ordered)
)
return queryset
def get_index_url(self):
return reverse(self.index_url_name)
def get_index_results_url(self):
return reverse(self.index_results_url_name)
def get_breadcrumbs_items(self):
return self.breadcrumbs_items + [{"url": "", "label": self.get_page_title()}]
def get_table_kwargs(self):
kwargs = super().get_table_kwargs()
kwargs["actions_next_url"] = self.index_url
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
"ordering": self.ordering,
"search_form": self.search_form,
"is_searching": self.is_searching,
}
)
return context
class ExplorableIndexView(IndexView):
"""
A version of the page listing where the user is presented with a view of a specified parent page;
normally this will show the children of that page, but it may show results from the whole tree while
searching or filtering.
"""
template_name = "wagtailadmin/pages/explorable_index.html"
index_url_name = "wagtailadmin_explore"
index_results_url_name = "wagtailadmin_explore_results"
page_title = _("Exploring")
filterset_class = ExplorablePageFilterSet
@classproperty
def columns(cls):
columns = super().columns.copy()
columns.insert(
3,
Column(
"type",
label=_("Type"),
accessor="page_type_display_name",
sort_key="content_type",
width="12%",
),
)
columns.append(NavigateToChildrenColumn("navigate", width="10%"))
return columns
def get(self, request, parent_page_id=None):
if parent_page_id:
self.parent_page = get_object_or_404(
Page.objects.all().prefetch_workflow_states(), id=parent_page_id
)
else:
self.parent_page = Page.get_first_root_node()
# This will always succeed because of the check performed by PermissionCheckedMixin.
root_page = self.permission_policy.explorable_root_instance(request.user)
# If this page isn't a descendant of the user's explorable root page,
# then redirect to that explorable root page instead.
if not (
self.parent_page.pk == root_page.pk
or self.parent_page.is_descendant_of(root_page)
):
return redirect(self.index_url_name, root_page.pk)
self.parent_page = self.parent_page.specific
self.scheduled_page = self.parent_page.get_scheduled_revision_as_object()
self.i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False)
if self.i18n_enabled and not self.parent_page.is_root():
self.locale = self.parent_page.locale
self.translations = self.get_translations()
else:
self.locale = None
self.translations = []
return super().get(request)
@cached_property
def is_searching_whole_tree(self):
return bool(self.request.GET.get("search_all")) and (
self.is_searching or self.is_filtering
)
def get_base_queryset(self):
if self.is_searching or self.is_filtering:
if self.is_searching_whole_tree:
pages = Page.objects.all()
else:
pages = self.parent_page.get_descendants()
else:
pages = self.parent_page.get_children()
pages = self._annotate_queryset(pages)
return pages
def search_queryset(self, queryset):
# allow hooks to modify queryset. This should happen as close as possible to the
# final queryset, but (for backward compatibility) needs to be passed an actual queryset
# rather than a search result object
for hook in hooks.get_hooks("construct_explorer_page_queryset"):
queryset = hook(self.parent_page, queryset, self.request)
return super().search_queryset(queryset)
def get_index_url(self):
return reverse(self.index_url_name, args=[self.parent_page.id])
def get_index_results_url(self):
return reverse(self.index_results_url_name, args=[self.parent_page.id])
def get_history_url(self):
permissions = self.parent_page.permissions_for_user(self.request.user)
if permissions.can_view_revisions():
return reverse("wagtailadmin_pages:history", args=[self.parent_page.id])
def get_table_kwargs(self):
kwargs = super().get_table_kwargs()
kwargs["use_row_ordering_attributes"] = self.show_ordering_column
kwargs["parent_page"] = self.parent_page
kwargs["show_locale_labels"] = self.i18n_enabled and self.parent_page.is_root()
if self.show_ordering_column:
kwargs["caption"] = _(
"Focus on the drag button and press up or down arrows to move the item, then press enter to submit the change."
)
kwargs["attrs"] = {
"data-controller": "w-orderable",
"data-w-orderable-active-class": "w-orderable--active",
"data-w-orderable-chosen-class": "w-orderable__item--active",
"data-w-orderable-container-value": "tbody",
"data-w-orderable-message-value": _(
"'%(page_title)s' has been moved successfully."
)
% {"page_title": "__LABEL__"},
"data-w-orderable-url-value": reverse(
"wagtailadmin_pages:set_page_position", args=[999999]
),
}
return kwargs
def get_valid_orderings(self):
valid_orderings = super().get_valid_orderings()
if not self.is_searching:
# ordering by page order is only available when not searching
valid_orderings.append("ord")
return valid_orderings
def get_ordering(self):
if self.is_searching and not self.is_explicitly_ordered:
# default to ordering by relevance
default_ordering = None
else:
default_ordering = self.parent_page.get_admin_default_ordering()
ordering = self.request.GET.get("ordering", default_ordering)
if ordering not in self.get_valid_orderings():
ordering = default_ordering
return ordering
def get_paginate_by(self, queryset):
if self.ordering == "ord":
# Don't paginate if sorting by page order - all pages must be shown to
# allow drag-and-drop reordering
return None
else:
return self.paginate_by
def get_page_subtitle(self):
return self.parent_page.get_admin_display_title()
def get_context_data(self, **kwargs):
self.show_ordering_column = self.ordering == "ord"
if self.show_ordering_column:
self.columns = self.columns.copy()
self.columns[0] = OrderingColumn("ordering", width="80px", sort_key="ord")
context = super().get_context_data(**kwargs)
if self.is_searching:
# postprocess this page of results to annotate each result with its parent page
parent_page_paths = {
page.path[: -page.steplen] for page in context["object_list"]
}
parent_pages_by_path = {
page.path: page
for page in Page.objects.filter(path__in=parent_page_paths).specific()
}
for page in context["object_list"]:
parent_page = parent_pages_by_path.get(page.path[: -page.steplen])
# add annotation if parent page is found and is not the currently viewed parent
if parent_page and parent_page != self.parent_page:
page.annotated_parent_page = parent_page
context.update(
{
"parent_page": self.parent_page,
"history_url": self.get_history_url(),
"is_searching_whole_tree": self.is_searching_whole_tree,
}
)
if not self.results_only:
side_panels = self.get_side_panels()
context["side_panels"] = side_panels
context["media"] += side_panels.media
return context
def get_side_panels(self):
# Don't show side panels on the root page
if self.parent_page.is_root():
return MediaContainer()
side_panels = [
PageStatusSidePanel(
self.parent_page.get_latest_revision_as_object(),
self.request,
show_schedule_publishing_toggle=False,
live_object=self.parent_page,
scheduled_object=self.scheduled_page,
locale=self.locale,
translations=self.translations,
),
]
return MediaContainer(side_panels)
def get_translations(self):
return [
{
"locale": translation.locale,
"url": reverse(self.index_url_name, args=[translation.id]),
}
for translation in self.parent_page.get_translations()
.only("id", "locale")
.select_related("locale")
]

View File

@@ -0,0 +1,38 @@
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.translation import gettext as _
from wagtail.admin.views.generic import lock
from wagtail.models import Page
class PageOperationViewMixin:
model = Page
pk_url_kwarg = "page_id"
def get_object(self):
return super().get_object().specific
def get_success_url(self):
if self.next_url:
return self.next_url
return reverse("wagtailadmin_explore", args=[self.object.get_parent().id])
class LockView(PageOperationViewMixin, lock.LockView):
def perform_operation(self):
if not self.object.permissions_for_user(self.request.user).can_lock():
raise PermissionDenied
return super().perform_operation()
class UnlockView(PageOperationViewMixin, lock.UnlockView):
def perform_operation(self):
if not self.object.permissions_for_user(self.request.user).can_unlock():
raise PermissionDenied
return super().perform_operation()
def get_success_message(self):
return _("Page '%(page_title)s' is now unlocked.") % {
"page_title": self.object.get_admin_display_title()
}

View File

@@ -0,0 +1,142 @@
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.actions.move_page import MovePageAction
from wagtail.admin import messages
from wagtail.admin.forms.pages import MoveForm
from wagtail.models import Page
def move_choose_destination(request, page_to_move_id):
page_to_move = get_object_or_404(Page, id=page_to_move_id)
page_perms = page_to_move.permissions_for_user(request.user)
if not page_perms.can_move():
raise PermissionDenied
target_parent_models = set(page_to_move.specific_class.allowed_parent_page_models())
move_form = MoveForm(
request.POST or None,
page_to_move=page_to_move,
target_parent_models=target_parent_models,
)
if request.method == "POST":
if move_form.is_valid():
# Receive the new parent page (this should never be empty)
if move_form.cleaned_data["new_parent_page"]:
new_parent_page = move_form.cleaned_data["new_parent_page"]
return redirect(
"wagtailadmin_pages:move_confirm",
page_to_move.id,
new_parent_page.id,
)
return TemplateResponse(
request,
"wagtailadmin/pages/move_choose_destination.html",
{
"page_to_move": page_to_move,
"move_form": move_form,
},
)
def move_confirm(request, page_to_move_id, destination_id):
page_to_move = get_object_or_404(Page, id=page_to_move_id).specific
# Needs .specific_deferred because the .get_admin_display_title method is called in template
destination = get_object_or_404(Page, id=destination_id).specific_deferred
if not Page._slug_is_available(page_to_move.slug, destination, page=page_to_move):
messages.error(
request,
_(
"The slug '%(page_slug)s' is already in use at the selected parent page. Make sure the slug is unique and try again"
)
% {"page_slug": page_to_move.slug},
)
return redirect(
"wagtailadmin_pages:move",
page_to_move.id,
)
for fn in hooks.get_hooks("before_move_page"):
result = fn(request, page_to_move, destination)
if hasattr(result, "status_code"):
return result
pages_to_move = {page_to_move}
# The `construct_translated_pages_to_cascade_actions` hook returns translation and
# alias pages when the action is set to "move"
if getattr(settings, "WAGTAIL_I18N_ENABLED", False):
for fn in hooks.get_hooks("construct_translated_pages_to_cascade_actions"):
fn_pages = fn([page_to_move], "move")
if fn_pages and isinstance(fn_pages, dict):
for additional_pages in fn_pages.values():
pages_to_move.update(additional_pages)
pages_to_move = list(pages_to_move)
if request.method == "POST":
# any invalid moves *should* be caught by the permission check in the action
# class, so don't bother to catch InvalidMoveToDescendant
action = MovePageAction(
page_to_move, destination, pos="last-child", user=request.user
)
action.execute()
if getattr(settings, "WAGTAIL_I18N_ENABLED", False):
# Move translation and alias pages if they have the same parent page.
parent_page_translations = page_to_move.get_parent().get_translations()
for translation in pages_to_move:
if translation.get_parent() in parent_page_translations:
# Move the translated or alias page to it's translated or
# alias "destination" page.
action = MovePageAction(
translation,
destination.get_translation(translation.locale),
pos="last-child",
user=request.user,
)
action.execute()
messages.success(
request,
_("Page '%(page_title)s' moved.")
% {"page_title": page_to_move.get_admin_display_title()},
buttons=[
messages.button(
reverse("wagtailadmin_pages:edit", args=(page_to_move.id,)),
_("Edit"),
)
],
)
for fn in hooks.get_hooks("after_move_page"):
result = fn(request, page_to_move)
if hasattr(result, "status_code"):
return result
return redirect("wagtailadmin_explore", destination.id)
return TemplateResponse(
request,
"wagtailadmin/pages/confirm_move.html",
{
"page_to_move": page_to_move,
"destination": destination,
"translations_to_move_count": len(
[
translation.id
for translation in pages_to_move
if not translation.alias_of_id and translation.id != page_to_move.id
]
),
},
)

View File

@@ -0,0 +1,44 @@
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from wagtail.models import Page
def set_page_position(request, page_to_move_id):
page_to_move = get_object_or_404(Page, id=page_to_move_id)
parent_page = page_to_move.get_parent()
if not parent_page.permissions_for_user(request.user).can_reorder_children():
raise PermissionDenied
if request.method == "POST":
# Get position parameter
position = request.GET.get("position", None)
# Find page that's already in this position
position_page = None
if position is not None:
try:
position_page = parent_page.get_children()[int(position)]
except IndexError:
pass # No page in this position
# Move page
# any invalid moves *should* be caught by the permission check above,
# so don't bother to catch InvalidMoveToDescendant
if position_page:
# If the page has been moved to the right, insert it to the
# right. If left, then left.
old_position = list(parent_page.get_children()).index(page_to_move)
if int(position) < old_position:
page_to_move.move(position_page, pos="left", user=request.user)
elif int(position) > old_position:
page_to_move.move(position_page, pos="right", user=request.user)
else:
# Move page to end
page_to_move.move(parent_page, pos="last-child", user=request.user)
return HttpResponse("")

View File

@@ -0,0 +1,103 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404
from wagtail.admin.views.generic.preview import PreviewOnEdit as GenericPreviewOnEdit
from wagtail.models import Page
def view_draft(request, page_id):
page = get_object_or_404(Page, id=page_id).get_latest_revision_as_object()
perms = page.permissions_for_user(request.user)
if not (perms.can_publish() or perms.can_edit()):
raise PermissionDenied
try:
preview_mode = page.default_preview_mode
except IndexError:
raise PermissionDenied
return page.make_preview_request(request, preview_mode)
class PreviewOnEdit(GenericPreviewOnEdit):
@property
def session_key(self):
return "{}{}".format(self.session_key_prefix, self.kwargs["page_id"])
def get_object(self):
return get_object_or_404(
Page, id=self.kwargs["page_id"]
).get_latest_revision_as_object()
def get_form(self, query_dict):
form_class = self.object.get_edit_handler().get_form_class()
parent_page = self.object.get_parent().specific
if not query_dict:
# Query dict is empty, return null form
return form_class(
instance=self.object,
parent_page=parent_page,
for_user=self.request.user,
)
return form_class(
query_dict,
instance=self.object,
parent_page=parent_page,
for_user=self.request.user,
)
class PreviewOnCreate(PreviewOnEdit):
@property
def session_key(self):
return "{}{}-{}-{}".format(
self.session_key_prefix,
self.kwargs["content_type_app_name"],
self.kwargs["content_type_model_name"],
self.kwargs["parent_page_id"],
)
def get_object(self):
content_type_app_name = self.kwargs["content_type_app_name"]
content_type_model_name = self.kwargs["content_type_model_name"]
parent_page_id = self.kwargs["parent_page_id"]
try:
content_type = ContentType.objects.get_by_natural_key(
content_type_app_name, content_type_model_name
)
except ContentType.DoesNotExist:
raise Http404
page = content_type.model_class()()
parent_page = get_object_or_404(Page, id=parent_page_id).specific
# We need to populate treebeard's path / depth fields in order to
# pass validation. We can't make these 100% consistent with the rest
# of the tree without making actual database changes (such as
# incrementing the parent's numchild field), but by calling treebeard's
# internal _get_path method, we can set a 'realistic' value that will
# hopefully enable tree traversal operations
# to at least partially work.
page.depth = parent_page.depth + 1
# Puts the page at the next available path
# for a child of `parent_page`.
if parent_page.is_leaf():
# set the path as the first child of parent_page
page.path = page._get_path(parent_page.path, page.depth, 1)
else:
# add the new page after the last child of parent_page
page.path = parent_page.get_last_child()._inc_path()
return page
def get_form(self, query_dict):
form = super().get_form(query_dict)
if form.is_valid():
# Ensures our unsaved page has a suitable url.
form.instance.set_url_path(form.parent_page)
form.instance.full_clean()
return form

View File

@@ -0,0 +1,206 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from wagtail.admin import messages
from wagtail.admin.action_menu import PageActionMenu
from wagtail.admin.auth import user_has_any_page_permission, user_passes_test
from wagtail.admin.ui.components import MediaContainer
from wagtail.admin.ui.side_panels import (
ChecksSidePanel,
CommentsSidePanel,
PageStatusSidePanel,
PreviewSidePanel,
)
from wagtail.admin.views.generic.models import (
RevisionsCompareView,
RevisionsUnscheduleView,
)
from wagtail.admin.views.generic.preview import PreviewRevision
from wagtail.models import Page
from wagtail.utils.timestamps import render_timestamp
def revisions_index(request, page_id):
return redirect("wagtailadmin_pages:history", page_id)
def revisions_revert(request, page_id, revision_id):
# TODO: refactor this into a class-based view that extends the EditView
page = get_object_or_404(Page, id=page_id).specific
page_perms = page.permissions_for_user(request.user)
if not page_perms.can_edit():
raise PermissionDenied
revision = get_object_or_404(page.revisions, id=revision_id)
revision_page = revision.as_object()
scheduled_page = page.get_scheduled_revision_as_object()
content_type = ContentType.objects.get_for_model(page)
page_class = content_type.model_class()
if getattr(settings, "WAGTAIL_I18N_ENABLED", False):
locale = page.locale
translations = [
{
"locale": translation.locale,
"url": reverse("wagtailadmin_pages:edit", args=[translation.id]),
}
for translation in page.get_translations()
.only("id", "locale", "depth")
.select_related("locale")
if translation.permissions_for_user(request.user).can_edit()
]
else:
locale = None
translations = []
edit_handler = page_class.get_edit_handler()
form_class = edit_handler.get_form_class()
form = form_class(instance=revision_page, for_user=request.user)
edit_handler = edit_handler.get_bound_panel(
instance=revision_page, request=request, form=form
)
preview_url = reverse("wagtailadmin_pages:preview_on_edit", args=[page.id])
lock = page.get_lock()
action_menu = PageActionMenu(
request,
view="revisions_revert",
is_revision=True,
page=page,
lock=lock,
locked_for_user=lock is not None and lock.for_user(request.user),
)
side_panels = [
PageStatusSidePanel(
revision_page,
request,
show_schedule_publishing_toggle=form.show_schedule_publishing_toggle,
live_object=page,
scheduled_object=scheduled_page,
locale=locale,
translations=translations,
),
]
if page.is_previewable():
side_panels.append(PreviewSidePanel(page, request, preview_url=preview_url))
side_panels.append(ChecksSidePanel(page, request))
if form.show_comments_toggle:
side_panels.append(CommentsSidePanel(page, request))
side_panels = MediaContainer(side_panels)
media = MediaContainer([edit_handler, form, action_menu, side_panels]).media
user_avatar = render_to_string(
"wagtailadmin/shared/user_avatar.html", {"user": revision.user}
)
messages.warning(
request,
mark_safe(
_(
"You are viewing a previous version of this page from <b>%(created_at)s</b> by %(user)s"
)
% {
"created_at": render_timestamp(revision.created_at),
"user": user_avatar,
}
),
)
page_title = _("Editing %(page_type)s") % {
"page_type": page_class.get_verbose_name()
}
page_subtitle = page.get_admin_display_title()
header_title = f"{page_title}: {page_subtitle}"
return TemplateResponse(
request,
"wagtailadmin/pages/edit.html",
{
"page": page,
"revision": revision,
"is_revision": True,
"content_type": content_type,
"edit_handler": edit_handler,
"errors_debug": None,
"action_menu": action_menu,
"side_panels": side_panels,
"header_title": header_title,
"form": form, # Used in unit tests
"media": media,
},
)
@method_decorator(user_passes_test(user_has_any_page_permission), name="dispatch")
class RevisionsView(PreviewRevision):
model = Page
def setup(self, request, page_id, revision_id, *args, **kwargs):
# Rename path kwargs from pk to page_id
return super().setup(request, page_id, revision_id, *args, **kwargs)
def get_object(self):
page = get_object_or_404(Page, id=self.pk).specific
perms = page.permissions_for_user(self.request.user)
if not (perms.can_publish() or perms.can_edit()):
raise PermissionDenied
return page
class RevisionsCompare(RevisionsCompareView):
history_label = gettext_lazy("Page history")
edit_label = gettext_lazy("Edit this page")
history_url_name = "wagtailadmin_pages:history"
edit_url_name = "wagtailadmin_pages:edit"
header_icon = "doc-empty-inverse"
@method_decorator(user_passes_test(user_has_any_page_permission))
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
def get_object(self, queryset=None):
return get_object_or_404(Page, id=self.pk).specific
def get_edit_handler(self):
return self.object.get_edit_handler()
def get_page_subtitle(self):
return self.object.get_admin_display_title()
class RevisionsUnschedule(RevisionsUnscheduleView):
model = Page
edit_url_name = "wagtailadmin_pages:edit"
history_url_name = "wagtailadmin_pages:history"
revisions_unschedule_url_name = "wagtailadmin_pages:revisions_unschedule"
header_icon = "doc-empty-inverse"
def setup(self, request, page_id, revision_id, *args, **kwargs):
# Rename path kwargs from pk to page_id
return super().setup(request, page_id, revision_id, *args, **kwargs)
def get_object(self, queryset=None):
page = get_object_or_404(Page, id=self.pk).specific
if not page.permissions_for_user(self.request.user).can_unschedule():
raise PermissionDenied
return page
def get_object_display_title(self):
return self.object.get_admin_display_title()

View File

@@ -0,0 +1,192 @@
from typing import Any, Dict
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models.query import QuerySet
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from wagtail.admin.forms.search import SearchForm
from wagtail.admin.ui.tables import Column, DateColumn
from wagtail.admin.ui.tables.pages import (
BulkActionsColumn,
NavigateToChildrenColumn,
PageStatusColumn,
PageTable,
PageTitleColumn,
ParentPageColumn,
)
from wagtail.admin.views.generic.base import BaseListingView
from wagtail.admin.views.generic.permissions import PermissionCheckedMixin
from wagtail.models import Page
from wagtail.permissions import page_permission_policy
from wagtail.search.query import MATCH_ALL
from wagtail.search.utils import parse_query_string
def page_filter_search(q, pages, all_pages=None, ordering=None):
# Parse query
filters, query = parse_query_string(q, operator="and", zero_terms=MATCH_ALL)
# Live filter
live_filter = filters.get("live") or filters.get("published")
live_filter = live_filter and live_filter.lower()
if live_filter in ["yes", "true"]:
if all_pages is not None:
all_pages = all_pages.filter(live=True)
pages = pages.filter(live=True)
elif live_filter in ["no", "false"]:
if all_pages is not None:
all_pages = all_pages.filter(live=False)
pages = pages.filter(live=False)
# Search
if all_pages is not None:
all_pages = all_pages.autocomplete(query, order_by_relevance=not ordering)
pages = pages.autocomplete(query, order_by_relevance=not ordering)
return pages, all_pages
class BaseSearchView(PermissionCheckedMixin, BaseListingView):
permission_policy = page_permission_policy
any_permission_required = {
"add",
"change",
"publish",
"bulk_delete",
"lock",
"unlock",
}
paginate_by = 20
page_kwarg = "p"
context_object_name = "pages"
table_class = PageTable
index_url_name = "wagtailadmin_pages:search"
columns = [
BulkActionsColumn("bulk_actions"),
PageTitleColumn(
"title",
classname="title",
label=_("Title"),
sort_key="title",
),
ParentPageColumn("parent", label=_("Parent")),
DateColumn(
"latest_revision_created_at",
label=_("Updated"),
sort_key="latest_revision_created_at",
width="12%",
),
Column(
"type",
label=_("Type"),
accessor="page_type_display_name",
width="12%",
),
PageStatusColumn(
"status",
label=_("Status"),
sort_key="live",
width="12%",
),
NavigateToChildrenColumn("navigate", width="10%"),
]
def get(self, request):
self.show_locale_labels = getattr(settings, "WAGTAIL_I18N_ENABLED", False)
self.content_types = []
self.ordering = None
if "ordering" in request.GET and request.GET["ordering"] in [
"title",
"-title",
"latest_revision_created_at",
"-latest_revision_created_at",
"live",
"-live",
]:
self.ordering = request.GET["ordering"]
if "content_type" in request.GET:
try:
app_label, model_name = request.GET["content_type"].split(".")
except ValueError:
raise Http404
try:
self.selected_content_type = ContentType.objects.get_by_natural_key(
app_label, model_name
)
except ContentType.DoesNotExist:
raise Http404
else:
self.selected_content_type = None
self.q = self.request.GET.get("q", "")
return super().get(request)
def get_queryset(self) -> QuerySet[Any]:
pages = self.all_pages = (
Page.objects.all().prefetch_related("content_type").specific()
)
if self.show_locale_labels:
pages = pages.select_related("locale")
if self.ordering:
pages = pages.order_by(self.ordering)
if self.selected_content_type:
pages = pages.filter(content_type=self.selected_content_type)
# Parse query and filter
pages, self.all_pages = page_filter_search(
self.q, pages, self.all_pages, self.ordering
)
# Facets
if pages.supports_facet:
self.content_types = [
(ContentType.objects.get(id=content_type_id), count)
for content_type_id, count in self.all_pages.facet(
"content_type_id"
).items()
]
return pages
def get_table_kwargs(self):
kwargs = super().get_table_kwargs()
kwargs["show_locale_labels"] = self.show_locale_labels
kwargs["actions_next_url"] = self.get_index_url()
return kwargs
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context.update(
{
"all_pages": self.all_pages,
"query_string": self.q,
"content_types": self.content_types,
"selected_content_type": self.selected_content_type,
"ordering": self.ordering,
}
)
return context
class SearchView(BaseSearchView):
template_name = "wagtailadmin/pages/search.html"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["search_form"] = SearchForm(self.request.GET)
return context
class SearchResultsView(BaseSearchView):
template_name = "wagtailadmin/pages/search_results.html"

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