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,9 @@
# definitions which are not being deprecated from wagtail.admin.forms
from .models import ( # NOQA: F401
DIRECT_FORM_FIELD_OVERRIDES,
FORM_FIELD_OVERRIDES,
WagtailAdminModelForm,
WagtailAdminModelFormMetaclass,
formfield_for_dbfield,
)
from .pages import WagtailAdminPageForm # NOQA: F401

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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