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