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,21 @@
# DIRECT_FORM_FIELD_OVERRIDES, FORM_FIELD_OVERRIDES are imported for backwards
# compatibility, as people are likely importing them from here and then
# appending their own overrides
from wagtail.admin.forms.models import ( # NOQA: F401
DIRECT_FORM_FIELD_OVERRIDES,
FORM_FIELD_OVERRIDES,
)
from .base import * # NOQA: F403
from .comment_panel import * # NOQA: F403
from .field_panel import * # NOQA: F403
from .group import * # NOQA: F403
from .help_panel import * # NOQA: F403
from .inline_panel import * # NOQA: F403
from .model_utils import * # NOQA: F403
from .multiple_chooser_panel import * # NOQA: F403
from .page_chooser_panel import * # NOQA: F403
from .page_utils import * # NOQA: F403
from .publishing_panel import * # NOQA: F403
from .signal_handlers import * # NOQA: F403
from .title_field_panel import * # NOQA: F403

View File

@@ -0,0 +1,326 @@
from django.core.exceptions import ImproperlyConfigured
from django.utils.safestring import mark_safe
from wagtail.admin.forms.models import (
WagtailAdminDraftStateFormMixin,
WagtailAdminModelForm,
)
from wagtail.admin.ui.components import Component
from wagtail.blocks import StreamValue
from wagtail.coreutils import safe_snake_case
from wagtail.models import DraftStateMixin
from wagtail.rich_text import RichText
from wagtail.utils.text import text_from_html
def get_form_for_model(
model,
form_class=WagtailAdminModelForm,
**kwargs,
):
"""
Construct a ModelForm subclass using the given model and base form class. Any additional
keyword arguments are used to populate the form's Meta class.
"""
# This is really just Django's modelform_factory, tweaked to accept arbitrary kwargs.
meta_class_attrs = kwargs
meta_class_attrs["model"] = model
# The kwargs passed here are expected to come from Panel.get_form_options, which collects
# them by descending the tree of child edit handlers. If there are no edit handlers that
# specify form fields, this can legitimately result in both 'fields' and 'exclude' being
# absent, which ModelForm doesn't normally allow. In this case, explicitly set fields to [].
if "fields" not in meta_class_attrs and "exclude" not in meta_class_attrs:
meta_class_attrs["fields"] = []
# Give this new form class a reasonable name.
class_name = model.__name__ + "Form"
bases = (form_class.Meta,) if hasattr(form_class, "Meta") else ()
Meta = type("Meta", bases, meta_class_attrs)
form_class_attrs = {"Meta": Meta}
metaclass = type(form_class)
bases = [form_class]
if issubclass(model, DraftStateMixin):
bases.insert(0, WagtailAdminDraftStateFormMixin)
return metaclass(class_name, tuple(bases), form_class_attrs)
class Panel:
"""
Defines part (or all) of the edit form interface for pages and other models
within the Wagtail admin. Each model has an associated top-level panel definition
(also known as an edit handler), consisting of a nested structure of ``Panel`` objects.
This provides methods for obtaining a :class:`~django.forms.ModelForm` subclass,
with the field list and other parameters collated from all panels in the structure.
It then handles rendering that form as HTML.
The following parameters can be used to customise how the panel is displayed.
For more details, see :ref:`customising_panels`.
:param heading: The heading text to display for the panel.
:param classname: A CSS class name to add to the panel's HTML element.
:param help_text: Help text to display within the panel.
:param base_form_class: The base form class to use for the panel. Defaults to the model's ``base_form_class``, before falling back to :class:`~wagtail.admin.forms.WagtailAdminModelForm`. This is only relevant for the top-level panel.
:param icon: The name of the icon to display next to the panel heading.
:param attrs: A dictionary of HTML attributes to add to the panel's HTML element.
"""
BASE_ATTRS = {}
def __init__(
self,
heading="",
classname="",
help_text="",
base_form_class=None,
icon="",
attrs=None,
):
self.heading = heading
self.classname = classname
self.help_text = help_text
self.base_form_class = base_form_class
self.icon = icon
self.model = None
self.attrs = self.BASE_ATTRS.copy()
if attrs is not None:
self.attrs.update(attrs)
def clone(self):
"""
Create a clone of this panel definition. By default, constructs a new instance, passing the
keyword arguments returned by ``clone_kwargs``.
"""
return self.__class__(**self.clone_kwargs())
def clone_kwargs(self):
"""
Return a dictionary of keyword arguments that can be used to create a clone of this panel definition.
"""
return {
"icon": self.icon,
"attrs": self.attrs,
"heading": self.heading,
"classname": self.classname,
"help_text": self.help_text,
"base_form_class": self.base_form_class,
}
def get_form_options(self):
"""
Return a dictionary of attributes such as 'fields', 'formsets' and 'widgets'
which should be incorporated into the form class definition to generate a form
that this panel can use.
This will only be called after binding to a model (i.e. self.model is available).
"""
return {}
def get_form_class(self):
"""
Construct a form class that has all the fields and formsets named in
the children of this edit handler.
"""
form_options = self.get_form_options()
# If a custom form class was passed to the panel, use it.
# Otherwise, use the base_form_class from the model.
# If that is not defined, use WagtailAdminModelForm.
model_form_class = getattr(self.model, "base_form_class", WagtailAdminModelForm)
base_form_class = self.base_form_class or model_form_class
return get_form_for_model(
self.model,
form_class=base_form_class,
**form_options,
)
def bind_to_model(self, model):
"""
Create a clone of this panel definition with a ``model`` attribute pointing to the linked model class.
"""
new = self.clone()
new.model = model
new.on_model_bound()
return new
def get_bound_panel(self, instance=None, request=None, form=None, prefix="panel"):
"""
Return a ``BoundPanel`` instance that can be rendered onto the template as a component. By default, this creates an instance
of the panel class's inner ``BoundPanel`` class, which must inherit from ``Panel.BoundPanel``.
"""
if self.model is None:
raise ImproperlyConfigured(
"%s.bind_to_model(model) must be called before get_bound_panel"
% type(self).__name__
)
if not issubclass(self.BoundPanel, Panel.BoundPanel):
raise ImproperlyConfigured(
"%s.BoundPanel must be a subclass of Panel.BoundPanel"
% type(self).__name__
)
return self.BoundPanel(
panel=self, instance=instance, request=request, form=form, prefix=prefix
)
def on_model_bound(self):
"""
Called after the panel has been associated with a model class and the ``self.model`` attribute is available;
panels can override this method to perform additional initialisation related to the model.
"""
pass
def __repr__(self):
return "<{} with model={}>".format(
self.__class__.__name__,
self.model,
)
def classes(self):
"""
Additional CSS classnames to add to whatever kind of object this is at output.
Subclasses of Panel should override this, invoking super().classes() to
append more classes specific to the situation.
"""
if self.classname:
return [self.classname]
return []
def id_for_label(self):
"""
The ID to be used as the 'for' attribute of any <label> elements that refer
to this object but are rendered outside of it. Leave blank if this object does not render
as a single input field.
"""
return ""
@property
def clean_name(self):
"""
A name for this panel, consisting only of ASCII alphanumerics and underscores, suitable for use in identifiers.
Usually generated from the panel heading. Note that this is not guaranteed to be unique or non-empty; anything
making use of this and requiring uniqueness should validate and modify the return value as needed.
"""
return safe_snake_case(self.heading)
def format_value_for_display(self, value):
"""
Hook to allow formatting of raw field values (and other attribute values) for human-readable
display. For example, if rendering a ``RichTextField`` value, you might extract text from the HTML
to generate a safer display value.
"""
# Improve representation of many-to-many values
if callable(getattr(value, "all", "")):
return ", ".join(str(obj) for obj in value.all()) or "None"
# Avoid rendering potentially unsafe HTML mid-form
if isinstance(value, (RichText, StreamValue)):
return text_from_html(value)
return value
class BoundPanel(Component):
"""
A template component for a panel that has been associated with a model instance, form, and request.
"""
def __init__(self, panel, instance, request, form, prefix):
#: The panel definition corresponding to this bound panel
self.panel = panel
#: The model instance associated with this panel
self.instance = instance
#: The request object associated with this panel
self.request = request
#: The form object associated with this panel
self.form = form
#: A unique prefix for this panel, for use in HTML IDs
self.prefix = prefix
self.heading = self.panel.heading
self.help_text = self.panel.help_text
@property
def classname(self):
return self.panel.classname
def classes(self):
return self.panel.classes()
@property
def attrs(self):
return self.panel.attrs
@property
def icon(self):
return self.panel.icon
def id_for_label(self):
"""
Returns an HTML ID to be used as the target for any label referencing this panel.
"""
return self.panel.id_for_label()
def is_shown(self):
"""
Whether this panel should be rendered; if false, it is skipped in the template output.
"""
return True
def show_panel_furniture(self):
"""
Whether this panel shows the panel furniture instead of being rendered outside of it.
"""
return self.is_shown()
def is_required(self):
return False
def get_context_data(self, parent_context=None):
context = super().get_context_data(parent_context)
context["self"] = self
context["attrs"] = self.attrs
return context
def get_comparison(self):
return []
def render_missing_fields(self):
"""
Helper function: render all of the fields that are defined on the form but not "claimed" by
any panels via required_fields. These fields are most likely to be hidden fields introduced
by the forms framework itself, such as ORDER / DELETE fields on formset members.
(If they aren't actually hidden fields, then they will appear as ugly unstyled / label-less fields
outside of the panel furniture. But there's not much we can do about that.)
"""
rendered_fields = self.panel.get_form_options().get("fields", [])
missing_fields_html = [
str(self.form[field_name])
for field_name in self.form.fields
if field_name not in rendered_fields
]
return mark_safe("".join(missing_fields_html))
def render_form_content(self):
"""
Render this as an 'object', ensuring that all fields necessary for a valid form
submission are included
"""
return mark_safe(self.render_html() + self.render_missing_fields())
def __repr__(self):
return "<{} with model={} instance={} request={} form={}>".format(
self.__class__.__name__,
self.panel.model,
self.instance,
self.request,
self.form.__class__.__name__,
)

View File

@@ -0,0 +1,88 @@
from django.contrib.auth import get_user_model
from modelcluster.models import get_serializable_data_for_fields
from wagtail.admin.forms.comments import CommentForm, CommentFormSet
from wagtail.admin.templatetags.wagtailadmin_tags import avatar_url, user_display_name
from wagtail.models import COMMENTS_RELATION_NAME
from .base import Panel
class CommentPanel(Panel):
def get_form_options(self):
# add the comments formset
return {
# Adds the comment notifications field to the form.
# Note, this field is defined directly on WagtailAdminPageForm.
"fields": ["comment_notifications"],
"formsets": {
COMMENTS_RELATION_NAME: {
"formset": CommentFormSet,
"form": CommentForm,
"fields": ["text", "contentpath", "position"],
"formset_name": "comments",
"inherit_kwargs": ["for_user"],
}
},
}
@property
def clean_name(self):
return super().clean_name or "comments"
class BoundPanel(Panel.BoundPanel):
template_name = "wagtailadmin/panels/comments/comment_panel.html"
def get_context_data(self, parent_context=None):
context = super().get_context_data(parent_context)
def user_data(user):
return {"name": user_display_name(user), "avatar_url": avatar_url(user)}
user = getattr(self.request, "user", None)
user_pks = {user.pk}
serialized_comments = []
bound = self.form.is_bound
comment_formset = self.form.formsets.get("comments")
comment_forms = comment_formset.forms if comment_formset else []
for form in comment_forms:
# iterate over comments to retrieve users (to get display names) and serialized versions
replies = []
for reply_form in form.formsets["replies"].forms:
user_pks.add(reply_form.instance.user_id)
reply_data = get_serializable_data_for_fields(reply_form.instance)
reply_data["deleted"] = (
reply_form.cleaned_data.get("DELETE", False) if bound else False
)
replies.append(reply_data)
user_pks.add(form.instance.user_id)
data = get_serializable_data_for_fields(form.instance)
data["deleted"] = (
form.cleaned_data.get("DELETE", False) if bound else False
)
data["resolved"] = (
form.cleaned_data.get("resolved", False)
if bound
else form.instance.resolved_at is not None
)
data["replies"] = replies
serialized_comments.append(data)
authors = {
str(user.pk): user_data(user)
for user in get_user_model()
.objects.filter(pk__in=user_pks)
.select_related("wagtail_userprofile")
}
comments_data = {
"comments": serialized_comments,
"user": user.pk,
"authors": authors,
}
context["comments_data"] = comments_data
return context
def show_panel_furniture(self):
return False

View File

@@ -0,0 +1,370 @@
import functools
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db.models import ForeignKey
from django.forms.models import ModelChoiceIterator
from django.template.loader import get_template
from django.utils.functional import cached_property
from django.utils.text import capfirst
from wagtail.admin import compare
from wagtail.admin.forms.models import registry as model_field_registry
from wagtail.blocks import BlockField
from .base import Panel
class FieldPanel(Panel):
TEMPLATE_VAR = "field_panel"
read_only_output_template_name = "wagtailadmin/panels/read_only_output.html"
def __init__(
self,
field_name,
widget=None,
disable_comments=None,
permission=None,
read_only=False,
**kwargs,
):
super().__init__(**kwargs)
self.field_name = field_name
self.widget = widget
self.disable_comments = disable_comments
self.permission = permission
self.read_only = read_only
def clone_kwargs(self):
kwargs = super().clone_kwargs()
kwargs.update(
field_name=self.field_name,
widget=self.widget,
disable_comments=self.disable_comments,
permission=self.permission,
read_only=self.read_only,
)
return kwargs
def get_form_options(self):
if self.read_only:
return {}
opts = {
"fields": [self.field_name],
}
if self.widget:
opts["widgets"] = {self.field_name: self.widget}
if self.permission:
opts["field_permissions"] = {self.field_name: self.permission}
return opts
def get_comparison_class(self):
try:
field = self.db_field
if field.choices:
return compare.ChoiceFieldComparison
comparison_class = compare.comparison_class_registry.get(field)
if comparison_class:
return comparison_class
if field.is_relation:
if field.many_to_many:
return compare.M2MFieldComparison
return compare.ForeignObjectComparison
except FieldDoesNotExist:
pass
return compare.FieldComparison
@cached_property
def db_field(self):
try:
model = self.model
except AttributeError:
raise ImproperlyConfigured(
"%r must be bound to a model before calling db_field" % self
)
return model._meta.get_field(self.field_name)
@property
def clean_name(self):
return self.field_name
def format_value_for_display(self, value):
"""
Overrides ``Panel.format_value_for_display()`` to add additional treatment
for choice fields.
"""
# NOTE: We look for formfield.choices over db_field.choices here,
# as more field types can benefit that way.
choices = getattr(self.db_field.formfield(), "choices", None)
if not isinstance(choices, ModelChoiceIterator) and choices:
labels = dict(choices)
display_values = [
str(labels.get(v, v)) # Use raw value if no match found
for v in
(
# Account for single AND multiple choice fields
tuple(value) if isinstance(value, (list, tuple)) else (value,)
)
]
return ", ".join(display_values)
return super().format_value_for_display(value)
def __repr__(self):
return "<{} '{}' with model={}>".format(
self.__class__.__name__,
self.field_name,
self.model,
)
class BoundPanel(Panel.BoundPanel):
template_name = "wagtailadmin/panels/field_panel.html"
# Default icons for common model field types,
# based on the corresponding FieldBlock's icon.
default_field_icons = {
"DateField": "date",
"TimeField": "time",
"DateTimeField": "date",
"URLField": "link-external",
"TaggableManager": "tag",
"EmailField": "mail",
"TextField": "pilcrow",
"RichTextField": "pilcrow",
"FloatField": "decimal",
"DecimalField": "decimal",
"BooleanField": "tick-inverse",
}
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.bound_field = None
self.read_only = False
if self.form is None:
return
try:
self.bound_field = self.form[self.field_name]
except KeyError:
if self.panel.read_only:
self.read_only = True
# Ensure heading and help_text are set to something useful
self.heading = self.panel.heading or capfirst(
self.panel.db_field.verbose_name
)
self.help_text = self.panel.help_text or capfirst(
self.panel.db_field.help_text
)
return
# Ensure heading and help_text are consistent across
# Panel, BoundPanel and Field
if self.panel.heading:
self.heading = self.bound_field.label = self.panel.heading
else:
self.heading = self.bound_field.label
self.help_text = self.panel.help_text or self.bound_field.help_text
@property
def field_name(self):
return self.panel.field_name
def is_shown(self):
if (
self.form is not None
and self.bound_field is None
and not self.read_only
):
# this field is missing from the form
return False
if (
self.panel.permission
and self.request
and not self.request.user.has_perm(self.panel.permission)
):
return False
return True
def is_required(self):
if self.bound_field is None:
return False
return self.bound_field.field.required
def classes(self):
classes = self.panel.classes()
if self.bound_field and isinstance(self.bound_field.field, BlockField):
classes.append("w-panel--nested")
return classes
@property
def icon(self):
"""
Display a different icon depending on the field's type.
"""
# If the panel has an icon, use that.
if self.panel.icon:
return self.panel.icon
# Try to use the model field first, then the form field because it's
# possible to use FieldPanel without a model field by using a custom
# form class.
try:
field = self.panel.db_field
except FieldDoesNotExist:
# The defined default icons are for model fields, but most of them
# have a corresponding form field with the same name, so we just
# hope the name matches.
field = self.bound_field.field
field_type = type(field)
# ForeignKey fields can have a custom icon defined in the form field's widget
# (e.g. page, image, and document choosers). If there's an overridden widget
# with an icon attribute, use that.
if issubclass(field_type, ForeignKey):
overrides = model_field_registry.get(field) or {}
widget = overrides.get("widget", None)
return getattr(widget, "icon", None)
# Otherwise, find a default icon based on the field's class or superclasses.
for field_class in field_type.mro():
field_name = field_class.__name__
if field_name in self.default_field_icons:
return self.default_field_icons[field_name]
return None
def id_for_label(self):
if self.read_only:
return self.prefix
return self.bound_field.id_for_label
@property
def comments_enabled(self):
if self.panel.disable_comments is None and not self.read_only:
# by default, enable comments on all fields except StreamField (which has its own comment handling)
return not isinstance(self.bound_field.field, BlockField)
else:
return not self.panel.disable_comments
@cached_property
def value_from_instance(self):
return getattr(self.instance, self.field_name)
def get_context_data(self, parent_context=None):
context = super().get_context_data(parent_context)
if self.read_only:
context.update(self.get_read_only_context_data())
else:
context.update(self.get_editable_context_data())
return context
def get_editable_context_data(self):
widget_described_by_ids = []
help_text_id = "%s-helptext" % self.prefix
error_message_id = "%s-errors" % self.prefix
widget_described_by_ids = []
if self.help_text:
widget_described_by_ids.append(help_text_id)
if self.bound_field.errors:
widget = self.bound_field.field.widget
if hasattr(widget, "render_with_errors"):
widget_attrs = {
"id": self.bound_field.auto_id,
}
if widget_described_by_ids:
widget_attrs["aria-describedby"] = " ".join(
widget_described_by_ids
)
rendered_field = widget.render_with_errors(
self.bound_field.html_name,
self.bound_field.value(),
attrs=widget_attrs,
errors=self.bound_field.errors,
)
else:
widget_described_by_ids.append(error_message_id)
rendered_field = self.bound_field.as_widget(
attrs={
"aria-invalid": "true",
"aria-describedby": " ".join(widget_described_by_ids),
}
)
else:
widget_attrs = {}
if widget_described_by_ids:
widget_attrs["aria-describedby"] = " ".join(widget_described_by_ids)
rendered_field = self.bound_field.as_widget(attrs=widget_attrs)
return {
"field": self.bound_field,
"rendered_field": rendered_field,
"error_message_id": error_message_id,
"help_text": self.help_text,
"help_text_id": help_text_id,
"show_add_comment_button": self.comments_enabled
and getattr(
self.bound_field.field.widget,
"show_add_comment_button",
True,
),
}
def get_read_only_context_data(self):
# Define context data for BoundPanel AND read-only output rendering
context = {
"id_for_label": self.id_for_label(),
"help_text_id": "%s-helptext" % self.prefix,
"help_text": self.help_text,
"show_add_comment_button": self.comments_enabled,
"raw_value": self.value_from_instance,
"display_value": self.panel.format_value_for_display(
self.value_from_instance
),
}
# Render read-only output
template = get_template(self.panel.read_only_output_template_name)
rendered_field = template.render(context)
# Add rendered output to BoundPanel context data
context["rendered_field"] = rendered_field
return context
def get_comparison(self):
comparator_class = self.panel.get_comparison_class()
if comparator_class and self.is_shown():
try:
return [functools.partial(comparator_class, self.panel.db_field)]
except FieldDoesNotExist:
return []
return []
def __repr__(self):
return "<{} '{}' with model={} instance={} request={} form={}>".format(
self.__class__.__name__,
self.field_name,
self.panel.model,
self.instance,
self.request,
self.form.__class__.__name__,
)

View File

@@ -0,0 +1,174 @@
from django.forms import Media
from django.utils.functional import cached_property
from .base import Panel
class PanelGroup(Panel):
"""
Abstract class for panels that manage a set of sub-panels.
Concrete subclasses must attach a 'children' property
"""
def __init__(self, children=(), *args, **kwargs):
permission = kwargs.pop("permission", None)
super().__init__(*args, **kwargs)
self.children = children
self.permission = permission
def clone_kwargs(self):
kwargs = super().clone_kwargs()
kwargs["children"] = self.children
kwargs["permission"] = self.permission
return kwargs
def get_form_options(self):
if self.model is None:
raise AttributeError(
"%s is not bound to a model yet. Use `.bind_to_model(model)` "
"before using this method." % self.__class__.__name__
)
options = {}
# Merge in form options from each child in turn, combining values that are types that we
# know how to combine (i.e. lists, dicts and sets)
for child in self.children:
child_options = child.get_form_options()
for key, new_val in child_options.items():
if key not in options:
# if val is a known mutable container type that we're going to merge subsequent
# child values into, create a copy so that we don't risk that change leaking
# back into the child's internal state
if (
isinstance(new_val, list)
or isinstance(new_val, dict)
or isinstance(new_val, set)
):
options[key] = new_val.copy()
else:
options[key] = new_val
else:
current_val = options[key]
if isinstance(current_val, list) and isinstance(
new_val, (list, tuple)
):
current_val.extend(new_val)
elif isinstance(current_val, tuple) and isinstance(
new_val, (list, tuple)
):
options[key] = list(current_val).extend(new_val)
elif isinstance(current_val, dict) and isinstance(new_val, dict):
current_val.update(new_val)
elif isinstance(current_val, set) and isinstance(new_val, set):
current_val.update(new_val)
else:
raise ValueError(
"Don't know how to merge values %r and %r for form option %r"
% (current_val, new_val, key)
)
return options
def on_model_bound(self):
self.children = [child.bind_to_model(self.model) for child in self.children]
@cached_property
def child_identifiers(self):
"""
A list of identifiers corresponding to child panels in ``self.children``, formed from the clean_name property
but validated to be unique and non-empty.
"""
used_names = set()
result = []
for panel in self.children:
base_name = panel.clean_name or "panel"
candidate_name = base_name
suffix = 0
while candidate_name in used_names:
suffix += 1
candidate_name = "%s%d" % (base_name, suffix)
result.append(candidate_name)
used_names.add(candidate_name)
return result
class BoundPanel(Panel.BoundPanel):
@cached_property
def children(self):
return [
child.get_bound_panel(
instance=self.instance,
request=self.request,
form=self.form,
prefix=(f"{self.prefix}-child-{identifier}"),
)
for child, identifier in zip(
self.panel.children, self.panel.child_identifiers
)
]
@cached_property
def visible_children(self):
return [child for child in self.children if child.is_shown()]
@cached_property
def visible_children_with_identifiers(self):
return [
(child, identifier)
for child, identifier in zip(
self.children, self.panel.child_identifiers
)
if child.is_shown()
]
def show_panel_furniture(self):
return any(child.show_panel_furniture() for child in self.children)
def is_shown(self):
"""
Check permissions on the panel group overall then check if any children
are shown.
"""
if self.panel.permission:
if not self.request.user.has_perm(self.panel.permission):
return False
return any(child.is_shown() for child in self.children)
@property
def media(self):
media = Media()
for item in self.visible_children:
media += item.media
return media
def get_comparison(self):
comparators = []
for child in self.children:
comparators.extend(child.get_comparison())
return comparators
class TabbedInterface(PanelGroup):
class BoundPanel(PanelGroup.BoundPanel):
template_name = "wagtailadmin/panels/tabbed_interface.html"
class ObjectList(PanelGroup):
class BoundPanel(PanelGroup.BoundPanel):
template_name = "wagtailadmin/panels/object_list.html"
class FieldRowPanel(PanelGroup):
class BoundPanel(PanelGroup.BoundPanel):
template_name = "wagtailadmin/panels/field_row_panel.html"
class MultiFieldPanel(PanelGroup):
class BoundPanel(PanelGroup.BoundPanel):
template_name = "wagtailadmin/panels/multi_field_panel.html"

View File

@@ -0,0 +1,38 @@
from .base import Panel
class HelpPanel(Panel):
"""
A panel to display helpful information to the user.
This panel does not support the ``help_text`` parameter.
"""
def __init__(
self,
content="",
template="wagtailadmin/panels/help_panel.html",
**kwargs,
):
super().__init__(**kwargs)
self.content = content
self.template = template
def clone_kwargs(self):
kwargs = super().clone_kwargs()
del kwargs["help_text"]
kwargs.update(
content=self.content,
template=self.template,
)
return kwargs
@property
def clean_name(self):
return super().clean_name or "help"
class BoundPanel(Panel.BoundPanel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.template_name = self.panel.template
self.content = self.panel.content

View File

@@ -0,0 +1,160 @@
import functools
from django import forms
from django.forms.formsets import DELETION_FIELD_NAME, ORDERING_FIELD_NAME
from django.utils.functional import cached_property
from wagtail.admin import compare
from .base import Panel
from .group import MultiFieldPanel
from .model_utils import extract_panel_definitions_from_model_class
class InlinePanel(Panel):
def __init__(
self,
relation_name,
panels=None,
heading="",
label="",
min_num=None,
max_num=None,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.relation_name = relation_name
self.panels = panels
self.heading = heading or label
self.label = label
self.min_num = min_num
self.max_num = max_num
def clone_kwargs(self):
kwargs = super().clone_kwargs()
kwargs.update(
relation_name=self.relation_name,
panels=self.panels,
label=self.label,
min_num=self.min_num,
max_num=self.max_num,
)
return kwargs
@cached_property
def panel_definitions(self):
# Look for a panels definition in the InlinePanel declaration
if self.panels is not None:
return self.panels
# Failing that, get it from the model
return extract_panel_definitions_from_model_class(
self.db_field.related_model, exclude=[self.db_field.field.name]
)
@cached_property
def child_edit_handler(self):
panels = self.panel_definitions
child_edit_handler = MultiFieldPanel(panels, heading=self.heading)
return child_edit_handler.bind_to_model(self.db_field.related_model)
def get_form_options(self):
child_form_opts = self.child_edit_handler.get_form_options()
return {
"formsets": {
self.relation_name: {
"fields": child_form_opts.get("fields", []),
"widgets": child_form_opts.get("widgets", {}),
"min_num": self.min_num,
"validate_min": self.min_num is not None,
"max_num": self.max_num,
"validate_max": self.max_num is not None,
"formsets": child_form_opts.get("formsets"),
}
}
}
def on_model_bound(self):
manager = getattr(self.model, self.relation_name)
self.db_field = manager.rel
def classes(self):
return super().classes() + ["w-panel--nested"]
class BoundPanel(Panel.BoundPanel):
template_name = "wagtailadmin/panels/inline_panel.html"
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.label = self.panel.label
if self.form is None:
return
self.formset = self.form.formsets[self.panel.relation_name]
self.child_edit_handler = self.panel.child_edit_handler
self.children = []
for index, subform in enumerate(self.formset.forms):
# override the DELETE field to have a hidden input
subform.fields[DELETION_FIELD_NAME].widget = forms.HiddenInput()
# ditto for the ORDER field, if present
if self.formset.can_order:
subform.fields[ORDERING_FIELD_NAME].widget = forms.HiddenInput()
self.children.append(
self.child_edit_handler.get_bound_panel(
instance=subform.instance,
request=self.request,
form=subform,
prefix=("%s-%d" % (self.prefix, index)),
)
)
# if this formset is valid, it may have been re-ordered; respect that
# in case the parent form errored and we need to re-render
if self.formset.can_order and self.formset.is_valid():
self.children.sort(
key=lambda child: child.form.cleaned_data[ORDERING_FIELD_NAME] or 1
)
empty_form = self.formset.empty_form
empty_form.fields[DELETION_FIELD_NAME].widget = forms.HiddenInput()
if self.formset.can_order:
empty_form.fields[ORDERING_FIELD_NAME].widget = forms.HiddenInput()
self.empty_child = self.child_edit_handler.get_bound_panel(
instance=empty_form.instance,
request=self.request,
form=empty_form,
prefix=("%s-__prefix__" % self.prefix),
)
def get_comparison(self):
field_comparisons = []
for index, panel in enumerate(self.panel.child_edit_handler.children):
field_comparisons.extend(
panel.get_bound_panel(
instance=None,
request=self.request,
form=None,
prefix=("%s-%d" % (self.prefix, index)),
).get_comparison()
)
return [
functools.partial(
compare.ChildRelationComparison,
self.panel.db_field,
field_comparisons,
label=self.label,
)
]
def get_context_data(self, parent_context=None):
context = super().get_context_data(parent_context)
context["can_order"] = self.formset.can_order
return context

View File

@@ -0,0 +1,49 @@
import functools
from django.forms.models import fields_for_model
from wagtail.admin.forms.models import formfield_for_dbfield
from .field_panel import FieldPanel
from .group import ObjectList
def extract_panel_definitions_from_model_class(model, exclude=None):
if hasattr(model, "panels"):
return model.panels
panels = []
_exclude = []
if exclude:
_exclude.extend(exclude)
fields = fields_for_model(
model, exclude=_exclude, formfield_callback=formfield_for_dbfield
)
for field_name, field in fields.items():
try:
panel_class = field.widget.get_panel()
except AttributeError:
panel_class = FieldPanel
panel = panel_class(field_name)
panels.append(panel)
return panels
@functools.lru_cache(maxsize=None)
def get_edit_handler(model):
"""
Get the panel to use in the Wagtail admin when editing this model.
"""
if hasattr(model, "edit_handler"):
# use the edit handler specified on the model class
panel = model.edit_handler
else:
panels = extract_panel_definitions_from_model_class(model)
panel = ObjectList(panels)
return panel.bind_to_model(model)

View File

@@ -0,0 +1,48 @@
from django.core.exceptions import ImproperlyConfigured
from wagtail.telepath import JSContext
from .inline_panel import InlinePanel
class MultipleChooserPanel(InlinePanel):
def __init__(self, relation_name, chooser_field_name=None, **kwargs):
if chooser_field_name is None:
raise ImproperlyConfigured(
"MultipleChooserPanel must specify a chooser_field_name argument"
)
self.chooser_field_name = chooser_field_name
super().__init__(relation_name, **kwargs)
def clone_kwargs(self):
kwargs = super().clone_kwargs()
kwargs["chooser_field_name"] = self.chooser_field_name
return kwargs
class BoundPanel(InlinePanel.BoundPanel):
template_name = "wagtailadmin/panels/multiple_chooser_panel.html"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.form:
self.chooser_widget = self.formset.empty_form.fields[
self.panel.chooser_field_name
].widget
self.js_context = JSContext()
self.chooser_widget_telepath_definition = self.js_context.pack(
self.chooser_widget
)
def get_context_data(self, parent_context=None):
context = super().get_context_data(parent_context)
context["chooser_field_name"] = self.panel.chooser_field_name
context[
"chooser_widget_definition"
] = self.chooser_widget_telepath_definition
return context
@property
def media(self):
return super().media + self.js_context.media

View File

@@ -0,0 +1,29 @@
from wagtail.admin.widgets import AdminPageChooser
from .field_panel import FieldPanel
class PageChooserPanel(FieldPanel):
def __init__(self, field_name, page_type=None, can_choose_root=False, **kwargs):
super().__init__(field_name=field_name, **kwargs)
self.page_type = page_type
self.can_choose_root = can_choose_root
def clone_kwargs(self):
return {
**super().clone_kwargs(),
"field_name": self.field_name,
"page_type": self.page_type,
"can_choose_root": self.can_choose_root,
}
def get_form_options(self):
opts = super().get_form_options()
if self.page_type or self.can_choose_root:
widgets = opts.setdefault("widgets", {})
widgets[self.field_name] = AdminPageChooser(
target_models=self.page_type, can_choose_root=self.can_choose_root
)
return opts

View File

@@ -0,0 +1,76 @@
from django.conf import settings
from django.utils.translation import gettext_lazy
from wagtail.admin.forms.pages import WagtailAdminPageForm
from wagtail.models import Page
from wagtail.utils.decorators import cached_classmethod
from .comment_panel import CommentPanel
from .field_panel import FieldPanel
from .group import MultiFieldPanel, ObjectList, TabbedInterface
from .publishing_panel import PublishingPanel
from .title_field_panel import TitleFieldPanel
def set_default_page_edit_handlers(cls):
cls.content_panels = [
TitleFieldPanel("title"),
]
cls.promote_panels = [
MultiFieldPanel(
[
FieldPanel("slug"),
FieldPanel("seo_title"),
FieldPanel("search_description"),
],
gettext_lazy("For search engines"),
),
MultiFieldPanel(
[
FieldPanel("show_in_menus"),
],
gettext_lazy("For site menus"),
),
]
cls.settings_panels = [
PublishingPanel(),
]
if getattr(settings, "WAGTAILADMIN_COMMENTS_ENABLED", True):
cls.settings_panels.append(CommentPanel())
cls.base_form_class = WagtailAdminPageForm
set_default_page_edit_handlers(Page)
@cached_classmethod
def _get_page_edit_handler(cls):
"""
Get the panel to use in the Wagtail admin when editing this page type.
"""
if hasattr(cls, "edit_handler"):
edit_handler = cls.edit_handler
else:
# construct a TabbedInterface made up of content_panels, promote_panels
# and settings_panels, skipping any which are empty
tabs = []
if cls.content_panels:
tabs.append(ObjectList(cls.content_panels, heading=gettext_lazy("Content")))
if cls.promote_panels:
tabs.append(ObjectList(cls.promote_panels, heading=gettext_lazy("Promote")))
if cls.settings_panels:
tabs.append(
ObjectList(cls.settings_panels, heading=gettext_lazy("Settings"))
)
edit_handler = TabbedInterface(tabs, base_form_class=cls.base_form_class)
return edit_handler.bind_to_model(cls)
Page.get_edit_handler = _get_page_edit_handler

View File

@@ -0,0 +1,62 @@
from wagtail.admin.widgets.datetime import AdminDateTimeInput
from wagtail.models import Page
from .field_panel import FieldPanel
from .group import MultiFieldPanel
# This allows users to include the publishing panel in their own per-model override
# without having to write these fields out by hand, potentially losing 'classname'
# and therefore the associated styling of the publishing panel
class PublishingPanel(MultiFieldPanel):
def __init__(self, **kwargs):
js_overlay_parent_selector = "#schedule-publishing-dialog"
updated_kwargs = {
"children": [
FieldPanel(
"go_live_at",
widget=AdminDateTimeInput(
js_overlay_parent_selector=js_overlay_parent_selector,
attrs={
"data-controller": "w-action",
"data-action": "w-dialog:hidden->w-action#reset",
"data-w-dialog-target": "notify",
},
),
),
FieldPanel(
"expire_at",
widget=AdminDateTimeInput(
js_overlay_parent_selector=js_overlay_parent_selector,
attrs={
"data-controller": "w-action",
"data-action": "w-dialog:hidden->w-action#reset",
"data-w-dialog-target": "notify",
},
),
),
],
"classname": "publishing",
}
updated_kwargs.update(kwargs)
super().__init__(**updated_kwargs)
@property
def clean_name(self):
return super().clean_name or "publishing"
class BoundPanel(MultiFieldPanel.BoundPanel):
template_name = "wagtailadmin/panels/publishing/schedule_publishing_panel.html"
def get_context_data(self, parent_context=None):
context = super().get_context_data(parent_context)
context["request"] = self.request
context["instance"] = self.instance
context["classname"] = self.classname
context["model_opts"] = self.instance._meta
if isinstance(self.instance, Page):
context["page"] = self.instance
return context
def show_panel_furniture(self):
return False

View File

@@ -0,0 +1,21 @@
from django.apps import apps
from django.core.signals import setting_changed
from django.dispatch import receiver
from wagtail.models import Page
from .model_utils import get_edit_handler
from .page_utils import set_default_page_edit_handlers
@receiver(setting_changed)
def reset_edit_handler_cache(**kwargs):
"""
Clear page edit handler cache when global WAGTAILADMIN_COMMENTS_ENABLED settings are changed
"""
if kwargs["setting"] == "WAGTAILADMIN_COMMENTS_ENABLED":
set_default_page_edit_handlers(Page)
for model in apps.get_models():
if issubclass(model, Page):
model.get_edit_handler.cache_clear()
get_edit_handler.cache_clear()

View File

@@ -0,0 +1,132 @@
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy
from wagtail.models import Page
from .field_panel import FieldPanel
class TitleFieldPanel(FieldPanel):
"""
Prepares the default widget attributes that are used on Page title fields.
Can be used outside of pages to easily enable the slug field sync functionality.
:param apply_if_live: (optional) If ``True``, the built in slug sync behaviour will apply irrespective of the published state.
The default is ``False``, where the slug sync will only apply when the instance is not live (or does not have a live property).
:param classname: (optional) A CSS class name to add to the panel's HTML element. Default is ``"title"``.
:param placeholder: (optional) If a value is provided, it will be used as the field's placeholder, if ``False`` is provided no placeholder will be shown.
If ``True``, a placeholder value of ``"Title*"`` will be used or ``"Page Title*"`` if the model is a ``Page`` model.
The default is ``True``. If a widget is provided with a placeholder, the widget's value will be used instead.
:param targets: (optional) This allows you to override the default target of the field named `slug` on the form.
Accepts a list of field names, default is ``["slug"]``.
Note that the slugify/urlify behaviour relies on usage of the ``wagtail.admin.widgets.slug`` widget on the slug field.
"""
def __init__(
self,
*args,
apply_if_live=False,
classname="title",
placeholder=True,
targets=["slug"],
**kwargs,
):
kwargs["classname"] = classname
self.apply_if_live = apply_if_live
self.placeholder = placeholder
self.targets = targets
super().__init__(*args, **kwargs)
def clone_kwargs(self):
return {
**super().clone_kwargs(),
"apply_if_live": self.apply_if_live,
"placeholder": self.placeholder,
"targets": self.targets,
}
class BoundPanel(FieldPanel.BoundPanel):
apply_actions = [
"focus->w-sync#check",
"blur->w-sync#apply",
"change->w-sync#apply",
"keyup->w-sync#apply",
]
def get_context_data(self, parent_context=None):
field = self.bound_field.field
if field and not self.read_only:
field.widget.attrs.update(**self.get_attrs())
return super().get_context_data(parent_context)
def get_attrs(self):
"""
Generates a dict of widget attributes to be updated on the widget
before rendering.
"""
panel = self.panel
widget = self.bound_field.field.widget
attrs = {}
controllers = [widget.attrs.get("data-controller", None), "w-sync"]
attrs["data-controller"] = " ".join(filter(None, controllers))
if self.get_should_apply():
actions = [widget.attrs.get("data-action", None)] + self.apply_actions
attrs["data-action"] = " ".join(filter(None, actions))
targets = [
self.get_target_selector(target)
for target in panel.targets
if target in self.form.fields
]
attrs["data-w-sync-target-value"] = ", ".join(filter(None, targets))
placeholder = self.get_placeholder()
if placeholder and "placeholder" not in widget.attrs:
attrs["placeholder"] = placeholder
return attrs
def get_placeholder(self):
"""
If placeholder is falsey, return None. Otherwise allow a valid placeholder
to be resolved.
"""
placeholder = self.panel.placeholder
if not placeholder:
return None
if placeholder is True:
title = gettext_lazy("Title")
if issubclass(self.panel.model, Page):
title = gettext_lazy("Page title")
return format_lazy("{title}*", title=title)
return placeholder
def get_should_apply(self):
"""
Check that the title field should apply the sync with the target fields.
"""
if self.panel.apply_if_live:
return True
instance = self.instance
if not instance:
return True
is_live = instance.pk and getattr(instance, "live", False)
return not is_live
def get_target_selector(self, target):
"""
Prepare a selector for an individual target field.
"""
field = self.form[target]
return f"#{field.id_for_label}"