kopia lustrzana https://github.com/wagtail/wagtail
2176 wiersze
58 KiB
Python
2176 wiersze
58 KiB
Python
import datetime
|
|
import hashlib
|
|
import os
|
|
import random
|
|
import string
|
|
import uuid
|
|
|
|
from django import forms
|
|
from django.conf import settings
|
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
|
from django.db import models
|
|
from django.shortcuts import redirect
|
|
from django.template.response import TemplateResponse
|
|
from django.utils.translation import gettext_lazy as _
|
|
from modelcluster.contrib.taggit import ClusterTaggableManager
|
|
from modelcluster.fields import ParentalKey, ParentalManyToManyField
|
|
from modelcluster.models import ClusterableModel
|
|
from taggit.managers import TaggableManager
|
|
from taggit.models import ItemBase, TagBase, TaggedItemBase
|
|
|
|
from wagtail.admin.forms import WagtailAdminPageForm
|
|
from wagtail.admin.mail import send_mail
|
|
from wagtail.admin.panels import (
|
|
FieldPanel,
|
|
HelpPanel,
|
|
InlinePanel,
|
|
MultiFieldPanel,
|
|
MultipleChooserPanel,
|
|
ObjectList,
|
|
PublishingPanel,
|
|
TabbedInterface,
|
|
)
|
|
from wagtail.blocks import (
|
|
CharBlock,
|
|
FieldBlock,
|
|
ListBlock,
|
|
RawHTMLBlock,
|
|
RichTextBlock,
|
|
StreamBlock,
|
|
StructBlock,
|
|
)
|
|
from wagtail.contrib.forms.forms import FormBuilder
|
|
from wagtail.contrib.forms.models import (
|
|
FORM_FIELD_CHOICES,
|
|
AbstractEmailForm,
|
|
AbstractFormField,
|
|
AbstractFormSubmission,
|
|
)
|
|
from wagtail.contrib.forms.views import SubmissionsListView
|
|
from wagtail.contrib.settings.models import (
|
|
BaseGenericSetting,
|
|
BaseSiteSetting,
|
|
register_setting,
|
|
)
|
|
from wagtail.contrib.sitemaps import Sitemap
|
|
from wagtail.contrib.table_block.blocks import TableBlock
|
|
from wagtail.documents import get_document_model
|
|
from wagtail.documents.blocks import DocumentChooserBlock
|
|
from wagtail.documents.models import AbstractDocument, Document
|
|
from wagtail.fields import RichTextField, StreamField
|
|
from wagtail.images import get_image_model
|
|
from wagtail.images.blocks import ImageChooserBlock
|
|
from wagtail.images.models import AbstractImage, AbstractRendition, Image
|
|
from wagtail.models import (
|
|
DraftStateMixin,
|
|
LockableMixin,
|
|
Orderable,
|
|
Page,
|
|
PageManager,
|
|
PageQuerySet,
|
|
PreviewableMixin,
|
|
RevisionMixin,
|
|
Task,
|
|
TranslatableMixin,
|
|
WorkflowMixin,
|
|
)
|
|
from wagtail.search import index
|
|
from wagtail.snippets.blocks import SnippetChooserBlock
|
|
from wagtail.snippets.models import register_snippet
|
|
|
|
from .forms import FormClassAdditionalFieldPageForm, ValidatedPageForm
|
|
|
|
EVENT_AUDIENCE_CHOICES = (
|
|
("public", _("Public")),
|
|
("private", _("Private")),
|
|
)
|
|
|
|
|
|
COMMON_PANELS = (
|
|
FieldPanel("slug"),
|
|
FieldPanel("seo_title"),
|
|
FieldPanel("show_in_menus"),
|
|
FieldPanel("search_description"),
|
|
)
|
|
|
|
|
|
# Link fields
|
|
|
|
|
|
class LinkFields(models.Model):
|
|
link_external = models.URLField("External link", blank=True)
|
|
link_page = models.ForeignKey(
|
|
"wagtailcore.Page",
|
|
null=True,
|
|
blank=True,
|
|
related_name="+",
|
|
on_delete=models.CASCADE,
|
|
)
|
|
link_document = models.ForeignKey(
|
|
"wagtaildocs.Document",
|
|
null=True,
|
|
blank=True,
|
|
related_name="+",
|
|
on_delete=models.CASCADE,
|
|
)
|
|
|
|
@property
|
|
def link(self):
|
|
if self.link_page:
|
|
return self.link_page.url
|
|
elif self.link_document:
|
|
return self.link_document.url
|
|
else:
|
|
return self.link_external
|
|
|
|
panels = [
|
|
FieldPanel("link_external"),
|
|
FieldPanel("link_page"),
|
|
FieldPanel("link_document"),
|
|
]
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
# Carousel items
|
|
|
|
|
|
class CarouselItem(LinkFields):
|
|
image = models.ForeignKey(
|
|
"wagtailimages.Image",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="+",
|
|
)
|
|
embed_url = models.URLField("Embed URL", blank=True)
|
|
caption = models.CharField(max_length=255, blank=True)
|
|
|
|
panels = [
|
|
FieldPanel("image"),
|
|
FieldPanel("embed_url"),
|
|
FieldPanel("caption"),
|
|
MultiFieldPanel(LinkFields.panels, "Link"),
|
|
]
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
# Related links
|
|
|
|
|
|
class RelatedLink(LinkFields):
|
|
title = models.CharField(max_length=255, help_text="Link title")
|
|
|
|
panels = [
|
|
FieldPanel("title"),
|
|
MultiFieldPanel(LinkFields.panels, "Link"),
|
|
]
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
# Simple page
|
|
class SimplePage(Page):
|
|
content = models.TextField()
|
|
page_description = "A simple page description"
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("content"),
|
|
]
|
|
|
|
def get_admin_display_title(self):
|
|
return "%s (simple page)" % super().get_admin_display_title()
|
|
|
|
|
|
class MultiPreviewModesPage(Page):
|
|
template = "tests/simple_page.html"
|
|
|
|
@property
|
|
def preview_modes(self):
|
|
return [("original", "Original"), ("alt#1", "Alternate")]
|
|
|
|
@property
|
|
def default_preview_mode(self):
|
|
return "alt#1"
|
|
|
|
def get_preview_template(self, request, mode_name):
|
|
if mode_name == "alt#1":
|
|
return "tests/simple_page_alt.html"
|
|
return super().get_preview_template(request, mode_name)
|
|
|
|
|
|
# Page with Excluded Fields when copied
|
|
class PageWithExcludedCopyField(Page):
|
|
content = models.TextField()
|
|
|
|
# Exclude this field from being copied
|
|
special_field = models.CharField(blank=True, max_length=255, default="Very Special")
|
|
exclude_fields_in_copy = ["special_field"]
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("special_field"),
|
|
FieldPanel("content"),
|
|
]
|
|
|
|
|
|
class RelatedGenericRelation(models.Model):
|
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
object_id = models.PositiveBigIntegerField()
|
|
content_object = GenericForeignKey("content_type", "object_id")
|
|
|
|
|
|
class PageWithGenericRelation(Page):
|
|
generic_relation = GenericRelation("tests.RelatedGenericRelation")
|
|
|
|
|
|
class PageWithOldStyleRouteMethod(Page):
|
|
"""
|
|
Prior to Wagtail 0.4, the route() method on Page returned an HttpResponse
|
|
rather than a Page instance. As subclasses of Page may override route,
|
|
we need to continue accepting this convention (albeit as a deprecated API).
|
|
"""
|
|
|
|
content = models.TextField()
|
|
template = "tests/simple_page.html"
|
|
|
|
def route(self, request, path_components):
|
|
return self.serve(request)
|
|
|
|
|
|
# File page
|
|
class FilePage(Page):
|
|
file_field = models.FileField()
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
HelpPanel("remember to check for viruses"),
|
|
FieldPanel("file_field"),
|
|
]
|
|
|
|
|
|
# Event page
|
|
|
|
|
|
class EventPageCarouselItem(TranslatableMixin, Orderable, CarouselItem):
|
|
page = ParentalKey(
|
|
"tests.EventPage", related_name="carousel_items", on_delete=models.CASCADE
|
|
)
|
|
|
|
class Meta(TranslatableMixin.Meta, Orderable.Meta):
|
|
pass
|
|
|
|
|
|
class EventPageRelatedLink(TranslatableMixin, Orderable, RelatedLink):
|
|
page = ParentalKey(
|
|
"tests.EventPage", related_name="related_links", on_delete=models.CASCADE
|
|
)
|
|
|
|
class Meta(TranslatableMixin.Meta, Orderable.Meta):
|
|
pass
|
|
|
|
|
|
class EventPageSpeakerAward(TranslatableMixin, Orderable, models.Model):
|
|
speaker = ParentalKey(
|
|
"tests.EventPageSpeaker", related_name="awards", on_delete=models.CASCADE
|
|
)
|
|
name = models.CharField("Award name", max_length=255)
|
|
date_awarded = models.DateField(null=True, blank=True)
|
|
|
|
panels = [
|
|
FieldPanel("name"),
|
|
FieldPanel("date_awarded"),
|
|
]
|
|
|
|
class Meta(TranslatableMixin.Meta, Orderable.Meta):
|
|
pass
|
|
|
|
|
|
class EventPageSpeaker(TranslatableMixin, Orderable, LinkFields, ClusterableModel):
|
|
page = ParentalKey(
|
|
"tests.EventPage",
|
|
related_name="speakers",
|
|
related_query_name="speaker",
|
|
on_delete=models.CASCADE,
|
|
)
|
|
first_name = models.CharField("Name", max_length=255, blank=True)
|
|
last_name = models.CharField("Surname", max_length=255, blank=True)
|
|
image = models.ForeignKey(
|
|
"wagtailimages.Image",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="+",
|
|
)
|
|
|
|
@property
|
|
def name_display(self):
|
|
return self.first_name + " " + self.last_name
|
|
|
|
panels = [
|
|
FieldPanel("first_name"),
|
|
FieldPanel("last_name"),
|
|
FieldPanel("image"),
|
|
MultiFieldPanel(LinkFields.panels, "Link"),
|
|
InlinePanel("awards", label="Awards"),
|
|
]
|
|
|
|
class Meta(TranslatableMixin.Meta, Orderable.Meta):
|
|
pass
|
|
|
|
|
|
class EventCategory(TranslatableMixin, models.Model):
|
|
name = models.CharField("Name", max_length=255)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
# Override the standard WagtailAdminPageForm to add validation on start/end dates
|
|
# that appears as a non-field error
|
|
|
|
|
|
class EventPageForm(WagtailAdminPageForm):
|
|
def clean(self):
|
|
cleaned_data = super().clean()
|
|
|
|
# Make sure that the event starts before it ends
|
|
start_date = cleaned_data["date_from"]
|
|
end_date = cleaned_data["date_to"]
|
|
if start_date and end_date and start_date > end_date:
|
|
raise ValidationError("The end date must be after the start date")
|
|
|
|
return cleaned_data
|
|
|
|
|
|
class EventPage(Page):
|
|
date_from = models.DateField("Start date", null=True)
|
|
date_to = models.DateField(
|
|
"End date",
|
|
null=True,
|
|
blank=True,
|
|
help_text="Not required if event is on a single day",
|
|
)
|
|
time_from = models.TimeField("Start time", null=True, blank=True)
|
|
time_to = models.TimeField("End time", null=True, blank=True)
|
|
audience = models.CharField(max_length=255, choices=EVENT_AUDIENCE_CHOICES)
|
|
location = models.CharField(max_length=255)
|
|
body = RichTextField(blank=True)
|
|
cost = models.CharField(max_length=255)
|
|
signup_link = models.URLField(blank=True)
|
|
feed_image = models.ForeignKey(
|
|
"wagtailimages.Image",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="+",
|
|
)
|
|
categories = ParentalManyToManyField(EventCategory, blank=True)
|
|
|
|
search_fields = Page.search_fields + [
|
|
index.SearchField("get_audience_display"),
|
|
index.SearchField("location"),
|
|
index.SearchField("body"),
|
|
index.FilterField("url_path"),
|
|
]
|
|
|
|
password_required_template = "tests/event_page_password_required.html"
|
|
base_form_class = EventPageForm
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("date_from"),
|
|
FieldPanel("date_to"),
|
|
FieldPanel("time_from"),
|
|
FieldPanel("time_to"),
|
|
FieldPanel("location"),
|
|
FieldPanel("audience", help_text="Who this event is for"),
|
|
FieldPanel("cost"),
|
|
FieldPanel("signup_link"),
|
|
InlinePanel("carousel_items", label="Carousel items"),
|
|
FieldPanel("body"),
|
|
InlinePanel(
|
|
"speakers",
|
|
label="Speakers",
|
|
heading="Speaker lineup",
|
|
help_text="Put the keynote speaker first",
|
|
),
|
|
InlinePanel("related_links", label="Related links"),
|
|
FieldPanel("categories"),
|
|
# InlinePanel related model uses `pk` not `id`
|
|
InlinePanel("head_counts", label="Head Counts"),
|
|
]
|
|
|
|
promote_panels = [
|
|
MultiFieldPanel(
|
|
COMMON_PANELS, "Common page configuration", help_text="For SEO nerds only"
|
|
),
|
|
FieldPanel("feed_image"),
|
|
]
|
|
|
|
class Meta:
|
|
permissions = [
|
|
("custom_see_panel_setting", "Can see the panel."),
|
|
("other_custom_see_panel_setting", "Can see the panel."),
|
|
]
|
|
|
|
|
|
class HeadCountRelatedModelUsingPK(models.Model):
|
|
"""Related model that uses a custom primary key (pk) not id"""
|
|
|
|
custom_id = models.AutoField(primary_key=True)
|
|
event_page = ParentalKey(
|
|
EventPage, on_delete=models.CASCADE, related_name="head_counts"
|
|
)
|
|
head_count = models.IntegerField()
|
|
panels = [FieldPanel("head_count")]
|
|
|
|
|
|
# Override the standard WagtailAdminPageForm to add field that is not in model
|
|
# so that we can test additional potential issues like comparing versions
|
|
class FormClassAdditionalFieldPage(Page):
|
|
location = models.CharField(max_length=255)
|
|
body = RichTextField(blank=True)
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("location"),
|
|
FieldPanel("body"),
|
|
FieldPanel("code"), # not in model, see set base_form_class
|
|
]
|
|
|
|
base_form_class = FormClassAdditionalFieldPageForm
|
|
|
|
|
|
# Just to be able to test multi table inheritance
|
|
class SingleEventPage(EventPage):
|
|
excerpt = models.TextField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
help_text="Short text to describe what is this action about",
|
|
)
|
|
|
|
# Give this page model a custom URL routing scheme
|
|
def get_url_parts(self, request=None):
|
|
url_parts = super().get_url_parts(request=request)
|
|
if url_parts is None:
|
|
return None
|
|
else:
|
|
site_id, root_url, page_path = url_parts
|
|
return (site_id, root_url, page_path + "pointless-suffix/")
|
|
|
|
def route(self, request, path_components):
|
|
if path_components == ["pointless-suffix"]:
|
|
# treat this as equivalent to a request for this page
|
|
return super().route(request, [])
|
|
else:
|
|
# fall back to default routing rules
|
|
return super().route(request, path_components)
|
|
|
|
def get_admin_display_title(self):
|
|
return "%s (single event)" % super().get_admin_display_title()
|
|
|
|
content_panels = [FieldPanel("excerpt")] + EventPage.content_panels
|
|
|
|
|
|
# "custom" sitemap object
|
|
class EventSitemap(Sitemap):
|
|
pass
|
|
|
|
|
|
# Event index (has a separate AJAX template, and a custom template context)
|
|
class EventIndex(Page):
|
|
intro = RichTextField(blank=True, max_length=50)
|
|
ajax_template = "tests/includes/event_listing.html"
|
|
|
|
def get_events(self):
|
|
return self.get_children().live().type(EventPage)
|
|
|
|
def get_paginator(self):
|
|
return Paginator(self.get_events(), 4)
|
|
|
|
def get_context(self, request, page=1):
|
|
# Pagination
|
|
paginator = self.get_paginator()
|
|
try:
|
|
events = paginator.page(page)
|
|
except PageNotAnInteger:
|
|
events = paginator.page(1)
|
|
except EmptyPage:
|
|
events = paginator.page(paginator.num_pages)
|
|
|
|
# Update context
|
|
context = super().get_context(request)
|
|
context["events"] = events
|
|
return context
|
|
|
|
def route(self, request, path_components):
|
|
if self.live and len(path_components) == 1:
|
|
try:
|
|
return self.serve(request, page=int(path_components[0]))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
return super().route(request, path_components)
|
|
|
|
def get_sitemap_urls(self, request=None):
|
|
# Add past events url to sitemap
|
|
return super().get_sitemap_urls(request=request) + [
|
|
{
|
|
"location": self.full_url + "past/",
|
|
"lastmod": self.latest_revision_created_at,
|
|
}
|
|
]
|
|
|
|
def get_cached_paths(self):
|
|
return super().get_cached_paths() + ["/past/"]
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("intro"),
|
|
]
|
|
|
|
|
|
class FormField(AbstractFormField):
|
|
page = ParentalKey("FormPage", related_name="form_fields", on_delete=models.CASCADE)
|
|
|
|
|
|
class FormPage(AbstractEmailForm):
|
|
def get_context(self, request):
|
|
context = super().get_context(request)
|
|
context["greeting"] = "hello world"
|
|
return context
|
|
|
|
# This is redundant (SubmissionsListView is the default view class), but importing
|
|
# SubmissionsListView in this models.py helps us to confirm that this recipe
|
|
# https://docs.wagtail.org/en/stable/reference/contrib/forms/customisation.html#customise-form-submissions-listing-in-wagtail-admin
|
|
# works without triggering circular dependency issues -
|
|
# see https://github.com/wagtail/wagtail/issues/6265
|
|
submissions_list_view_class = SubmissionsListView
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
InlinePanel("form_fields", label="Form fields"),
|
|
MultiFieldPanel(
|
|
[
|
|
FieldPanel("to_address"),
|
|
FieldPanel("from_address"),
|
|
FieldPanel("subject"),
|
|
],
|
|
"Email",
|
|
),
|
|
]
|
|
|
|
|
|
# FormPage with a non-HTML extension
|
|
|
|
|
|
class JadeFormField(AbstractFormField):
|
|
page = ParentalKey(
|
|
"JadeFormPage", related_name="form_fields", on_delete=models.CASCADE
|
|
)
|
|
|
|
|
|
class JadeFormPage(AbstractEmailForm):
|
|
template = "tests/form_page.jade"
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
InlinePanel("form_fields", label="Form fields"),
|
|
MultiFieldPanel(
|
|
[
|
|
FieldPanel("to_address"),
|
|
FieldPanel("from_address"),
|
|
FieldPanel("subject"),
|
|
],
|
|
"Email",
|
|
),
|
|
]
|
|
|
|
|
|
# Form page that redirects to a different page
|
|
|
|
|
|
class RedirectFormField(AbstractFormField):
|
|
page = ParentalKey(
|
|
"FormPageWithRedirect", related_name="form_fields", on_delete=models.CASCADE
|
|
)
|
|
|
|
|
|
class FormPageWithRedirect(AbstractEmailForm):
|
|
thank_you_redirect_page = models.ForeignKey(
|
|
"wagtailcore.Page",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="+",
|
|
)
|
|
|
|
def get_context(self, request):
|
|
context = super().get_context(request)
|
|
context["greeting"] = "hello world"
|
|
return context
|
|
|
|
def render_landing_page(self, request, form_submission=None, *args, **kwargs):
|
|
"""
|
|
Renders the landing page OR if a receipt_page_redirect is chosen redirects to this page.
|
|
"""
|
|
if self.thank_you_redirect_page:
|
|
return redirect(self.thank_you_redirect_page.url, permanent=False)
|
|
|
|
return super().render_landing_page(request, form_submission, *args, **kwargs)
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("thank_you_redirect_page"),
|
|
InlinePanel("form_fields", label="Form fields"),
|
|
MultiFieldPanel(
|
|
[
|
|
FieldPanel("to_address"),
|
|
FieldPanel("from_address"),
|
|
FieldPanel("subject"),
|
|
],
|
|
"Email",
|
|
),
|
|
]
|
|
|
|
|
|
# FormPage with a custom FormSubmission
|
|
|
|
|
|
class FormPageWithCustomSubmission(AbstractEmailForm):
|
|
"""
|
|
This Form page:
|
|
* Have custom submission model
|
|
* Have custom related_name (see `FormFieldWithCustomSubmission.page`)
|
|
* Saves reference to a user
|
|
* Doesn't render html form, if submission for current user is present
|
|
"""
|
|
|
|
intro = RichTextField(blank=True)
|
|
thank_you_text = RichTextField(blank=True)
|
|
|
|
def get_context(self, request, *args, **kwargs):
|
|
context = super().get_context(request)
|
|
context["greeting"] = "hello world"
|
|
return context
|
|
|
|
def get_form_fields(self):
|
|
return self.custom_form_fields.all()
|
|
|
|
def get_data_fields(self):
|
|
data_fields = [
|
|
("useremail", "User email"),
|
|
]
|
|
data_fields += super().get_data_fields()
|
|
|
|
return data_fields
|
|
|
|
def get_submission_class(self):
|
|
return CustomFormPageSubmission
|
|
|
|
def process_form_submission(self, form):
|
|
form_submission = self.get_submission_class().objects.create(
|
|
form_data=form.cleaned_data,
|
|
page=self,
|
|
user=form.user,
|
|
)
|
|
|
|
if self.to_address:
|
|
addresses = [x.strip() for x in self.to_address.split(",")]
|
|
content = "\n".join(
|
|
[
|
|
x[1].label + ": " + str(form.data.get(x[0]))
|
|
for x in form.fields.items()
|
|
]
|
|
)
|
|
send_mail(
|
|
self.subject,
|
|
content,
|
|
addresses,
|
|
self.from_address,
|
|
)
|
|
|
|
# process_form_submission should now return the created form_submission
|
|
return form_submission
|
|
|
|
def serve(self, request, *args, **kwargs):
|
|
if (
|
|
self.get_submission_class()
|
|
.objects.filter(page=self, user__pk=request.user.pk)
|
|
.exists()
|
|
):
|
|
return TemplateResponse(request, self.template, self.get_context(request))
|
|
|
|
return super().serve(request, *args, **kwargs)
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("intro"),
|
|
InlinePanel("custom_form_fields", label="Form fields"),
|
|
FieldPanel("thank_you_text"),
|
|
MultiFieldPanel(
|
|
[
|
|
FieldPanel("to_address"),
|
|
FieldPanel("from_address"),
|
|
FieldPanel("subject"),
|
|
],
|
|
"Email",
|
|
),
|
|
]
|
|
|
|
|
|
class FormFieldWithCustomSubmission(AbstractFormField):
|
|
page = ParentalKey(
|
|
FormPageWithCustomSubmission,
|
|
on_delete=models.CASCADE,
|
|
related_name="custom_form_fields",
|
|
)
|
|
|
|
|
|
class CustomFormPageSubmission(AbstractFormSubmission):
|
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
|
|
|
def get_data(self):
|
|
form_data = super().get_data()
|
|
form_data.update(
|
|
{
|
|
"useremail": self.user.email,
|
|
}
|
|
)
|
|
|
|
return form_data
|
|
|
|
|
|
# Custom form page with custom submission listing view and form submission
|
|
|
|
|
|
class FormFieldForCustomListViewPage(AbstractFormField):
|
|
page = ParentalKey(
|
|
"FormPageWithCustomSubmissionListView",
|
|
related_name="form_fields",
|
|
on_delete=models.CASCADE,
|
|
)
|
|
|
|
|
|
class FormPageWithCustomSubmissionListView(AbstractEmailForm):
|
|
"""Form Page with customised submissions listing view"""
|
|
|
|
intro = RichTextField(blank=True)
|
|
thank_you_text = RichTextField(blank=True)
|
|
|
|
def get_submissions_list_view_class(self):
|
|
from .views import CustomSubmissionsListView
|
|
|
|
return CustomSubmissionsListView
|
|
|
|
def get_submission_class(self):
|
|
return CustomFormPageSubmission
|
|
|
|
def get_data_fields(self):
|
|
data_fields = [
|
|
("useremail", "User email"),
|
|
]
|
|
data_fields += super().get_data_fields()
|
|
|
|
return data_fields
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("intro"),
|
|
InlinePanel("form_fields", label="Form fields"),
|
|
FieldPanel("thank_you_text"),
|
|
MultiFieldPanel(
|
|
[
|
|
FieldPanel("to_address"),
|
|
FieldPanel("from_address"),
|
|
FieldPanel("subject"),
|
|
],
|
|
"Email",
|
|
),
|
|
]
|
|
|
|
|
|
# FormPage with custom FormBuilder
|
|
|
|
EXTENDED_CHOICES = FORM_FIELD_CHOICES + (("ipaddress", "IP Address"),)
|
|
|
|
|
|
class ExtendedFormField(AbstractFormField):
|
|
"""
|
|
Override the field_type field with extended choices
|
|
and a custom clean_name override.
|
|
"""
|
|
|
|
page = ParentalKey(
|
|
"FormPageWithCustomFormBuilder",
|
|
related_name="form_fields",
|
|
on_delete=models.CASCADE,
|
|
)
|
|
field_type = models.CharField(
|
|
verbose_name="field type", max_length=16, choices=EXTENDED_CHOICES
|
|
)
|
|
|
|
def get_field_clean_name(self):
|
|
clean_name = super().get_field_clean_name()
|
|
|
|
# scoping to field type to easily test behaviour in isolation
|
|
if self.field_type == "number":
|
|
return f"number_field--{clean_name}"
|
|
|
|
# scoping to field label to easily test duplicate behaviour in isolation
|
|
if "duplicate" in self.label:
|
|
return "test duplicate"
|
|
|
|
return clean_name
|
|
|
|
|
|
class CustomFormBuilder(FormBuilder):
|
|
"""
|
|
A custom FormBuilder that has an 'ipaddress' field with
|
|
customised create_singleline_field with shorter max_length
|
|
"""
|
|
|
|
def create_singleline_field(self, field, options):
|
|
options["max_length"] = 120 # usual default is 255
|
|
return forms.CharField(**options)
|
|
|
|
def create_ipaddress_field(self, field, options):
|
|
return forms.GenericIPAddressField(**options)
|
|
|
|
|
|
class FormPageWithCustomFormBuilder(AbstractEmailForm):
|
|
"""
|
|
A Form page that has a custom form builder and uses a custom
|
|
form field model with additional field_type choices.
|
|
"""
|
|
|
|
form_builder = CustomFormBuilder
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
InlinePanel("form_fields", label="Form fields"),
|
|
MultiFieldPanel(
|
|
[
|
|
FieldPanel("to_address"),
|
|
FieldPanel("from_address"),
|
|
FieldPanel("subject"),
|
|
],
|
|
"Email",
|
|
),
|
|
]
|
|
|
|
|
|
# Snippets
|
|
class AdvertPlacement(models.Model):
|
|
page = ParentalKey(
|
|
"wagtailcore.Page", related_name="advert_placements", on_delete=models.CASCADE
|
|
)
|
|
advert = models.ForeignKey(
|
|
"tests.Advert", related_name="+", on_delete=models.CASCADE
|
|
)
|
|
colour = models.CharField(max_length=255)
|
|
|
|
|
|
class AdvertTag(TaggedItemBase):
|
|
content_object = ParentalKey(
|
|
"Advert", related_name="tagged_items", on_delete=models.CASCADE
|
|
)
|
|
|
|
|
|
class Advert(ClusterableModel):
|
|
url = models.URLField(null=True, blank=True)
|
|
text = models.CharField(max_length=255)
|
|
|
|
tags = TaggableManager(through=AdvertTag, blank=True)
|
|
|
|
panels = [
|
|
FieldPanel("url"),
|
|
FieldPanel("text"),
|
|
FieldPanel("tags"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
register_snippet(Advert)
|
|
|
|
|
|
class AdvertWithCustomPrimaryKey(ClusterableModel):
|
|
advert_id = models.CharField(max_length=255, primary_key=True)
|
|
url = models.URLField(null=True, blank=True)
|
|
text = models.CharField(max_length=255)
|
|
|
|
panels = [
|
|
FieldPanel("url"),
|
|
FieldPanel("text"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
register_snippet(AdvertWithCustomPrimaryKey)
|
|
|
|
|
|
class AdvertWithCustomUUIDPrimaryKey(ClusterableModel):
|
|
advert_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
url = models.URLField(null=True, blank=True)
|
|
text = models.CharField(max_length=255)
|
|
|
|
panels = [
|
|
FieldPanel("url"),
|
|
FieldPanel("text"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
register_snippet(AdvertWithCustomUUIDPrimaryKey)
|
|
|
|
|
|
class AdvertWithTabbedInterface(models.Model):
|
|
url = models.URLField(null=True, blank=True)
|
|
text = models.CharField(max_length=255)
|
|
something_else = models.CharField(max_length=255)
|
|
|
|
advert_panels = [
|
|
FieldPanel("url"),
|
|
FieldPanel("text"),
|
|
]
|
|
|
|
other_panels = [
|
|
FieldPanel("something_else"),
|
|
]
|
|
|
|
edit_handler = TabbedInterface(
|
|
[
|
|
ObjectList(advert_panels, heading="Advert"),
|
|
ObjectList(
|
|
other_panels, heading="Other", help_text="Other panels help text"
|
|
),
|
|
],
|
|
help_text="Top-level help text",
|
|
)
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
class Meta:
|
|
ordering = ("text",)
|
|
|
|
|
|
register_snippet(AdvertWithTabbedInterface)
|
|
|
|
|
|
# Models with RevisionMixin
|
|
class RevisableModel(RevisionMixin, models.Model):
|
|
text = models.TextField()
|
|
|
|
|
|
class RevisableChildModel(RevisableModel):
|
|
secret_text = models.TextField(blank=True, default="")
|
|
|
|
# The edit_handler is defined on the viewset
|
|
|
|
|
|
class RevisableGrandChildModel(RevisableChildModel):
|
|
pass
|
|
|
|
|
|
# Models with DraftStateMixin
|
|
class DraftStateModel(DraftStateMixin, LockableMixin, RevisionMixin, models.Model):
|
|
text = models.TextField()
|
|
|
|
# The panels are defined on the viewset
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
class DraftStateCustomPrimaryKeyModel(DraftStateMixin, RevisionMixin, models.Model):
|
|
custom_id = models.CharField(max_length=255, primary_key=True)
|
|
text = models.TextField()
|
|
|
|
panels = [
|
|
FieldPanel("text"),
|
|
FieldPanel("first_published_at"),
|
|
PublishingPanel(),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
register_snippet(DraftStateCustomPrimaryKeyModel)
|
|
|
|
|
|
# Models with PreviewableMixin
|
|
class PreviewableModel(PreviewableMixin, ClusterableModel):
|
|
text = models.TextField()
|
|
categories = ParentalManyToManyField(EventCategory, blank=True)
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
def get_preview_template(self, request, mode_name):
|
|
return "tests/previewable_model.html"
|
|
|
|
|
|
register_snippet(PreviewableModel)
|
|
|
|
|
|
class MultiPreviewModesModel(PreviewableMixin, RevisionMixin, models.Model):
|
|
text = models.TextField()
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
@property
|
|
def preview_modes(self):
|
|
return [("", "Normal"), ("alt#1", "Alternate")]
|
|
|
|
@property
|
|
def default_preview_mode(self):
|
|
return "alt#1"
|
|
|
|
def get_preview_template(self, request, mode_name):
|
|
templates = {
|
|
"": "tests/previewable_model.html",
|
|
"alt#1": "tests/previewable_model_alt.html",
|
|
}
|
|
return templates.get(mode_name, templates[""])
|
|
|
|
|
|
register_snippet(MultiPreviewModesModel)
|
|
|
|
|
|
class NonPreviewableModel(PreviewableMixin, RevisionMixin, models.Model):
|
|
text = models.TextField()
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
preview_modes = []
|
|
|
|
|
|
register_snippet(NonPreviewableModel)
|
|
|
|
|
|
# Models with LockableMixin
|
|
|
|
|
|
class LockableModel(LockableMixin, models.Model):
|
|
text = models.TextField()
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
register_snippet(LockableModel)
|
|
|
|
|
|
# Models with WorkflowMixin
|
|
# Note: do not use Workflow in the model name to avoid incorrect counts in tests
|
|
# that look for the word "workflow"
|
|
|
|
|
|
class ModeratedModel(WorkflowMixin, DraftStateMixin, RevisionMixin, models.Model):
|
|
text = models.TextField()
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
# Snippet with all mixins enabled
|
|
|
|
|
|
class FullFeaturedSnippet(
|
|
PreviewableMixin,
|
|
WorkflowMixin,
|
|
DraftStateMixin,
|
|
LockableMixin,
|
|
RevisionMixin,
|
|
TranslatableMixin,
|
|
index.Indexed,
|
|
models.Model,
|
|
):
|
|
class CountryCode(models.TextChoices):
|
|
INDONESIA = "ID"
|
|
PHILIPPINES = "PH"
|
|
UNITED_KINGDOM = "UK"
|
|
|
|
text = models.TextField()
|
|
country_code = models.CharField(
|
|
max_length=2,
|
|
choices=CountryCode.choices,
|
|
default=CountryCode.UNITED_KINGDOM,
|
|
blank=True,
|
|
)
|
|
some_date = models.DateField(auto_now=True)
|
|
some_number = models.IntegerField(default=0, blank=True)
|
|
|
|
some_attribute = "some value"
|
|
|
|
search_fields = [
|
|
index.SearchField("text"),
|
|
index.AutocompleteField("text"),
|
|
index.FilterField("text"),
|
|
index.FilterField("country_code"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
def modulo_two(self):
|
|
return self.pk % 2
|
|
|
|
def tristate(self):
|
|
return (None, True, False)[self.pk % 3]
|
|
|
|
def get_preview_template(self, request, mode_name):
|
|
return "tests/previewable_model.html"
|
|
|
|
def get_foo_country_code(self):
|
|
return f"Foo {self.country_code}"
|
|
|
|
get_foo_country_code.admin_order_field = "country_code"
|
|
get_foo_country_code.short_description = "custom FOO column"
|
|
|
|
class Meta(TranslatableMixin.Meta):
|
|
verbose_name = "full-featured snippet"
|
|
verbose_name_plural = "full-featured snippets"
|
|
|
|
|
|
def get_default_advert():
|
|
return Advert.objects.first()
|
|
|
|
|
|
class VariousOnDeleteModel(models.Model):
|
|
text = models.TextField()
|
|
on_delete_cascade = models.ForeignKey(
|
|
Advert, on_delete=models.CASCADE, null=True, blank=True, related_name="+"
|
|
)
|
|
on_delete_protect = models.ForeignKey(
|
|
Advert, on_delete=models.PROTECT, null=True, blank=True, related_name="+"
|
|
)
|
|
on_delete_restrict = models.ForeignKey(
|
|
Advert, on_delete=models.RESTRICT, null=True, blank=True, related_name="+"
|
|
)
|
|
on_delete_set_null = models.ForeignKey(
|
|
Advert, on_delete=models.SET_NULL, null=True, blank=True, related_name="+"
|
|
)
|
|
on_delete_set_default = models.ForeignKey(
|
|
Advert,
|
|
on_delete=models.SET_DEFAULT,
|
|
null=True,
|
|
blank=True,
|
|
default=get_default_advert,
|
|
related_name="+",
|
|
)
|
|
on_delete_set = models.ForeignKey(
|
|
Advert,
|
|
on_delete=models.SET(get_default_advert),
|
|
null=True,
|
|
blank=True,
|
|
related_name="+",
|
|
)
|
|
on_delete_do_nothing = models.ForeignKey(
|
|
Advert, on_delete=models.DO_NOTHING, null=True, blank=True, related_name="+"
|
|
)
|
|
|
|
protected_image = models.ForeignKey(
|
|
"wagtailimages.Image",
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="+",
|
|
)
|
|
protected_document = models.ForeignKey(
|
|
"wagtaildocs.Document",
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
related_name="+",
|
|
)
|
|
|
|
content_type = models.ForeignKey(
|
|
ContentType, on_delete=models.CASCADE, null=True, blank=True
|
|
)
|
|
object_id = models.UUIDField(null=True, blank=True)
|
|
content_object = GenericForeignKey("content_type", "object_id")
|
|
|
|
stream_field = StreamField(
|
|
[
|
|
(
|
|
"advertisement_content",
|
|
StreamBlock(
|
|
[
|
|
(
|
|
"captioned_advert",
|
|
StructBlock(
|
|
[
|
|
("advert", SnippetChooserBlock(Advert)),
|
|
("caption", CharBlock()),
|
|
],
|
|
),
|
|
),
|
|
("rich_text", RichTextBlock()),
|
|
]
|
|
),
|
|
),
|
|
("image", ImageChooserBlock()),
|
|
("document", DocumentChooserBlock()),
|
|
],
|
|
use_json_field=True,
|
|
)
|
|
rich_text = RichTextField(blank=True)
|
|
|
|
|
|
class StandardIndex(Page):
|
|
"""Index for the site"""
|
|
|
|
parent_page_types = [Page]
|
|
|
|
# A custom panel setup where all Promote fields are placed in the Content tab instead;
|
|
# we use this to test that the 'promote' tab is left out of the output when empty
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("seo_title"),
|
|
FieldPanel("slug"),
|
|
InlinePanel("advert_placements", label="Adverts"),
|
|
]
|
|
|
|
promote_panels = []
|
|
|
|
|
|
class StandardChild(Page):
|
|
pass
|
|
|
|
|
|
# Test overriding edit_handler with a custom one
|
|
StandardChild.edit_handler = TabbedInterface(
|
|
[
|
|
ObjectList(StandardChild.content_panels, heading="Content"),
|
|
ObjectList(StandardChild.promote_panels, heading="Promote"),
|
|
ObjectList(StandardChild.settings_panels, heading="Settings"),
|
|
ObjectList(
|
|
[
|
|
HelpPanel("Watch out for asteroids"),
|
|
],
|
|
heading="Dinosaurs",
|
|
),
|
|
],
|
|
base_form_class=WagtailAdminPageForm,
|
|
)
|
|
|
|
|
|
class BusinessIndex(Page):
|
|
"""Can be placed anywhere, can only have Business children"""
|
|
|
|
subpage_types = ["tests.BusinessChild", "tests.BusinessSubIndex"]
|
|
|
|
|
|
class BusinessSubIndex(Page):
|
|
"""Can be placed under BusinessIndex, and have BusinessChild children"""
|
|
|
|
# BusinessNowherePage is 'incorrectly' added here as a possible child.
|
|
# The rules on BusinessNowherePage prevent it from being a child here though.
|
|
subpage_types = ["tests.BusinessChild", "tests.BusinessNowherePage"]
|
|
parent_page_types = ["tests.BusinessIndex"]
|
|
|
|
|
|
class BusinessChild(Page):
|
|
"""Can only be placed under Business indexes, no children allowed"""
|
|
|
|
subpage_types = []
|
|
parent_page_types = ["tests.BusinessIndex", BusinessSubIndex]
|
|
page_description = _("A lazy business child page description")
|
|
|
|
|
|
class BusinessNowherePage(Page):
|
|
"""Not allowed to be placed anywhere"""
|
|
|
|
parent_page_types = []
|
|
|
|
|
|
class TaggedPageTag(TaggedItemBase):
|
|
content_object = ParentalKey(
|
|
"tests.TaggedPage", related_name="tagged_items", on_delete=models.CASCADE
|
|
)
|
|
|
|
|
|
class TaggedPage(Page):
|
|
tags = ClusterTaggableManager(through=TaggedPageTag, blank=True)
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("tags"),
|
|
]
|
|
|
|
# Page.search_fields intentionally omitted to test warning
|
|
search_fields = [
|
|
index.SearchField("tags"),
|
|
]
|
|
|
|
|
|
class TaggedChildPage(TaggedPage):
|
|
pass
|
|
|
|
|
|
class TaggedGrandchildPage(TaggedChildPage):
|
|
pass
|
|
|
|
|
|
class SingletonPage(Page):
|
|
@classmethod
|
|
def can_create_at(cls, parent):
|
|
# You can only create one of these!
|
|
return super().can_create_at(parent) and not cls.objects.exists()
|
|
|
|
|
|
class SingletonPageViaMaxCount(Page):
|
|
max_count = 1
|
|
|
|
|
|
class PageChooserModel(models.Model):
|
|
page = models.ForeignKey(
|
|
"wagtailcore.Page", help_text="help text", on_delete=models.CASCADE
|
|
)
|
|
|
|
|
|
class EventPageChooserModel(models.Model):
|
|
page = models.ForeignKey(
|
|
"tests.EventPage", help_text="more help text", on_delete=models.CASCADE
|
|
)
|
|
|
|
|
|
class SnippetChooserModel(models.Model):
|
|
advert = models.ForeignKey(Advert, help_text="help text", on_delete=models.CASCADE)
|
|
full_featured = models.ForeignKey(
|
|
FullFeaturedSnippet, on_delete=models.CASCADE, null=True, blank=True
|
|
)
|
|
|
|
panels = [
|
|
FieldPanel("advert"),
|
|
FieldPanel("full_featured"),
|
|
]
|
|
|
|
|
|
class SnippetChooserModelWithCustomPrimaryKey(models.Model):
|
|
advertwithcustomprimarykey = models.ForeignKey(
|
|
AdvertWithCustomPrimaryKey, help_text="help text", on_delete=models.CASCADE
|
|
)
|
|
|
|
panels = [
|
|
FieldPanel("advertwithcustomprimarykey"),
|
|
]
|
|
|
|
|
|
class CustomImage(AbstractImage):
|
|
caption = models.CharField(max_length=255, blank=True)
|
|
fancy_caption = RichTextField(blank=True)
|
|
not_editable_field = models.CharField(max_length=255, blank=True)
|
|
|
|
admin_form_fields = Image.admin_form_fields + (
|
|
"caption",
|
|
"fancy_caption",
|
|
)
|
|
|
|
class Meta:
|
|
unique_together = [("title", "collection")]
|
|
|
|
|
|
class CustomRendition(AbstractRendition):
|
|
image = models.ForeignKey(
|
|
CustomImage, related_name="renditions", on_delete=models.CASCADE
|
|
)
|
|
|
|
class Meta:
|
|
unique_together = (("image", "filter_spec", "focal_point_key"),)
|
|
|
|
|
|
# Custom image model with a required field
|
|
class CustomImageWithAuthor(AbstractImage):
|
|
author = models.CharField(max_length=255)
|
|
|
|
admin_form_fields = Image.admin_form_fields + ("author",)
|
|
|
|
|
|
class CustomRenditionWithAuthor(AbstractRendition):
|
|
image = models.ForeignKey(
|
|
CustomImageWithAuthor, related_name="renditions", on_delete=models.CASCADE
|
|
)
|
|
|
|
class Meta:
|
|
unique_together = (("image", "filter_spec", "focal_point_key"),)
|
|
|
|
|
|
class CustomDocument(AbstractDocument):
|
|
description = models.TextField(blank=True)
|
|
fancy_description = RichTextField(blank=True)
|
|
admin_form_fields = Document.admin_form_fields + (
|
|
"description",
|
|
"fancy_description",
|
|
)
|
|
|
|
class Meta:
|
|
unique_together = [("title", "collection")]
|
|
|
|
|
|
# Custom document model with a required field
|
|
class CustomDocumentWithAuthor(AbstractDocument):
|
|
author = models.CharField(max_length=255)
|
|
|
|
admin_form_fields = Document.admin_form_fields + ("author",)
|
|
|
|
|
|
class JSONStreamModel(models.Model):
|
|
body = StreamField(
|
|
[
|
|
("text", CharBlock()),
|
|
("rich_text", RichTextBlock()),
|
|
("image", ImageChooserBlock()),
|
|
],
|
|
use_json_field=True,
|
|
)
|
|
|
|
|
|
class JSONMinMaxCountStreamModel(models.Model):
|
|
body = StreamField(
|
|
[
|
|
("text", CharBlock()),
|
|
("rich_text", RichTextBlock()),
|
|
("image", ImageChooserBlock()),
|
|
],
|
|
min_num=2,
|
|
max_num=5,
|
|
use_json_field=True,
|
|
)
|
|
|
|
|
|
class JSONBlockCountsStreamModel(models.Model):
|
|
body = StreamField(
|
|
[
|
|
("text", CharBlock()),
|
|
("rich_text", RichTextBlock()),
|
|
("image", ImageChooserBlock()),
|
|
],
|
|
block_counts={
|
|
"text": {"min_num": 1},
|
|
"rich_text": {"max_num": 1},
|
|
"image": {"min_num": 1, "max_num": 1},
|
|
},
|
|
use_json_field=True,
|
|
)
|
|
|
|
|
|
class ExtendedImageChooserBlock(ImageChooserBlock):
|
|
"""
|
|
Example of Block with custom get_api_representation method.
|
|
If the request has an 'extended' query param, it returns a dict of id and title,
|
|
otherwise, it returns the default value.
|
|
"""
|
|
|
|
def get_api_representation(self, value, context=None):
|
|
image_id = super().get_api_representation(value, context=context)
|
|
if "request" in context and context["request"].query_params.get(
|
|
"extended", False
|
|
):
|
|
return {"id": image_id, "title": value.title}
|
|
return image_id
|
|
|
|
|
|
class StreamPage(Page):
|
|
body = StreamField(
|
|
[
|
|
("text", CharBlock()),
|
|
("rich_text", RichTextBlock()),
|
|
("image", ExtendedImageChooserBlock()),
|
|
(
|
|
"product",
|
|
StructBlock(
|
|
[
|
|
("name", CharBlock()),
|
|
("price", CharBlock()),
|
|
]
|
|
),
|
|
),
|
|
("raw_html", RawHTMLBlock()),
|
|
(
|
|
"books",
|
|
StreamBlock(
|
|
[
|
|
("title", CharBlock()),
|
|
("author", CharBlock()),
|
|
]
|
|
),
|
|
),
|
|
(
|
|
"title_list",
|
|
ListBlock(CharBlock()),
|
|
),
|
|
],
|
|
use_json_field=True,
|
|
)
|
|
|
|
api_fields = ("body",)
|
|
|
|
content_panels = [
|
|
FieldPanel("title"),
|
|
FieldPanel("body"),
|
|
]
|
|
|
|
preview_modes = []
|
|
|
|
|
|
class DefaultStreamPage(Page):
|
|
body = StreamField(
|
|
[
|
|
("text", CharBlock()),
|
|
("rich_text", RichTextBlock()),
|
|
("image", ImageChooserBlock()),
|
|
],
|
|
default="",
|
|
use_json_field=True,
|
|
)
|
|
|
|
content_panels = [
|
|
FieldPanel("title"),
|
|
FieldPanel("body"),
|
|
]
|
|
|
|
|
|
class MTIBasePage(Page):
|
|
is_creatable = False
|
|
|
|
class Meta:
|
|
verbose_name = "MTI Base page"
|
|
|
|
|
|
class MTIChildPage(MTIBasePage):
|
|
# Should be creatable by default, no need to set anything
|
|
pass
|
|
|
|
|
|
class AbstractPage(Page):
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
@register_setting
|
|
class TestSiteSetting(BaseSiteSetting):
|
|
title = models.CharField(max_length=100)
|
|
email = models.EmailField(max_length=50)
|
|
|
|
|
|
@register_setting
|
|
class TestGenericSetting(BaseGenericSetting):
|
|
title = models.CharField(max_length=100)
|
|
email = models.EmailField(max_length=50)
|
|
|
|
|
|
@register_setting
|
|
class ImportantPagesSiteSetting(BaseSiteSetting):
|
|
sign_up_page = models.ForeignKey(
|
|
"wagtailcore.Page", related_name="+", null=True, on_delete=models.SET_NULL
|
|
)
|
|
general_terms_page = models.ForeignKey(
|
|
"wagtailcore.Page", related_name="+", null=True, on_delete=models.SET_NULL
|
|
)
|
|
privacy_policy_page = models.ForeignKey(
|
|
"wagtailcore.Page", related_name="+", null=True, on_delete=models.SET_NULL
|
|
)
|
|
|
|
|
|
@register_setting(name="important-pages-generic-setting")
|
|
class ImportantPagesGenericSetting(BaseGenericSetting):
|
|
sign_up_page = models.ForeignKey(
|
|
"wagtailcore.Page", related_name="+", null=True, on_delete=models.SET_NULL
|
|
)
|
|
general_terms_page = models.ForeignKey(
|
|
"wagtailcore.Page", related_name="+", null=True, on_delete=models.SET_NULL
|
|
)
|
|
privacy_policy_page = models.ForeignKey(
|
|
"wagtailcore.Page", related_name="+", null=True, on_delete=models.SET_NULL
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Important pages settings")
|
|
verbose_name_plural = _("Important pages settings")
|
|
|
|
|
|
@register_setting(icon="icon-setting-tag")
|
|
class IconSiteSetting(BaseSiteSetting):
|
|
pass
|
|
|
|
|
|
@register_setting(icon="icon-setting-tag")
|
|
class IconGenericSetting(BaseGenericSetting):
|
|
pass
|
|
|
|
|
|
class NotYetRegisteredSiteSetting(BaseSiteSetting):
|
|
pass
|
|
|
|
|
|
class NotYetRegisteredGenericSetting(BaseGenericSetting):
|
|
pass
|
|
|
|
|
|
@register_setting
|
|
class FileSiteSetting(BaseSiteSetting):
|
|
file = models.FileField()
|
|
|
|
|
|
@register_setting
|
|
class FileGenericSetting(BaseGenericSetting):
|
|
file = models.FileField()
|
|
|
|
|
|
class BlogCategory(models.Model):
|
|
name = models.CharField(unique=True, max_length=80)
|
|
|
|
|
|
class BlogCategoryBlogPage(models.Model):
|
|
category = models.ForeignKey(
|
|
BlogCategory, related_name="+", on_delete=models.CASCADE
|
|
)
|
|
page = ParentalKey(
|
|
"ManyToManyBlogPage", related_name="categories", on_delete=models.CASCADE
|
|
)
|
|
panels = [
|
|
FieldPanel("category"),
|
|
]
|
|
|
|
|
|
class ManyToManyBlogPage(Page):
|
|
"""
|
|
A page type with two different kinds of M2M relation.
|
|
We don't formally support these, but we don't want them to cause
|
|
hard breakages either.
|
|
"""
|
|
|
|
body = RichTextField(blank=True)
|
|
adverts = models.ManyToManyField(Advert, blank=True)
|
|
blog_categories = models.ManyToManyField(
|
|
BlogCategory, through=BlogCategoryBlogPage, blank=True
|
|
)
|
|
|
|
# make first_published_at editable on this page model
|
|
settings_panels = Page.settings_panels + [
|
|
FieldPanel("first_published_at"),
|
|
]
|
|
|
|
|
|
class OneToOnePage(Page):
|
|
"""
|
|
A Page containing a O2O relation.
|
|
"""
|
|
|
|
body = RichTextBlock(blank=True)
|
|
page_ptr = models.OneToOneField(
|
|
Page, parent_link=True, related_name="+", on_delete=models.CASCADE
|
|
)
|
|
|
|
|
|
class GenericSnippetPage(Page):
|
|
"""
|
|
A page containing a reference to an arbitrary snippet (or any model for that matter)
|
|
linked by a GenericForeignKey
|
|
"""
|
|
|
|
snippet_content_type = models.ForeignKey(
|
|
ContentType, on_delete=models.SET_NULL, null=True, blank=True
|
|
)
|
|
snippet_object_id = models.PositiveIntegerField(null=True, blank=True)
|
|
snippet_content_object = GenericForeignKey(
|
|
"snippet_content_type", "snippet_object_id"
|
|
)
|
|
|
|
|
|
class CustomImageFilePath(AbstractImage):
|
|
def get_upload_to(self, filename):
|
|
"""Create a path that's file-system friendly.
|
|
|
|
By hashing the file's contents we guarantee an equal distribution
|
|
of files within our root directories. This also gives us a
|
|
better chance of uploading images with the same filename, but
|
|
different contents - this isn't guaranteed as we're only using
|
|
the first three characters of the checksum.
|
|
"""
|
|
original_filepath = super().get_upload_to(filename)
|
|
folder_name, filename = original_filepath.split(os.path.sep)
|
|
|
|
# Ensure that we consume the entire file, we can't guarantee that
|
|
# the stream has not be partially (or entirely) consumed by
|
|
# another process
|
|
original_position = self.file.tell()
|
|
self.file.seek(0)
|
|
hash256 = hashlib.sha256()
|
|
|
|
while True:
|
|
data = self.file.read(256)
|
|
if not data:
|
|
break
|
|
hash256.update(data)
|
|
checksum = hash256.hexdigest()
|
|
|
|
self.file.seek(original_position)
|
|
return os.path.join(folder_name, checksum[:3], filename)
|
|
|
|
|
|
class CustomPageQuerySet(PageQuerySet):
|
|
def about_spam(self):
|
|
return self.filter(title__contains="spam")
|
|
|
|
|
|
CustomManager = PageManager.from_queryset(CustomPageQuerySet)
|
|
|
|
|
|
class CustomManagerPage(Page):
|
|
objects = CustomManager()
|
|
|
|
|
|
class MyBasePage(Page):
|
|
"""
|
|
A base Page model, used to set site-wide defaults and overrides.
|
|
"""
|
|
|
|
objects = CustomManager()
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class MyCustomPage(MyBasePage):
|
|
pass
|
|
|
|
|
|
class ValidatedPage(Page):
|
|
foo = models.CharField(max_length=255)
|
|
|
|
base_form_class = ValidatedPageForm
|
|
content_panels = Page.content_panels + [
|
|
FieldPanel("foo"),
|
|
]
|
|
|
|
|
|
class DefaultRichTextFieldPage(Page):
|
|
body = RichTextField()
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("body"),
|
|
]
|
|
|
|
|
|
class DefaultRichBlockFieldPage(Page):
|
|
body = StreamField(
|
|
[
|
|
("rich_text", RichTextBlock()),
|
|
],
|
|
use_json_field=True,
|
|
)
|
|
|
|
content_panels = Page.content_panels + [FieldPanel("body")]
|
|
|
|
|
|
class CustomRichTextFieldPage(Page):
|
|
body = RichTextField(editor="custom")
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("body"),
|
|
]
|
|
|
|
|
|
class CustomRichBlockFieldPage(Page):
|
|
body = StreamField(
|
|
[
|
|
("rich_text", RichTextBlock(editor="custom")),
|
|
],
|
|
use_json_field=True,
|
|
)
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("body"),
|
|
]
|
|
|
|
|
|
class RichTextFieldWithFeaturesPage(Page):
|
|
body = RichTextField(features=["quotation", "embed", "made-up-feature"])
|
|
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
FieldPanel("body"),
|
|
]
|
|
|
|
|
|
# a page that only contains RichTextField within an InlinePanel,
|
|
# to test that the inline child's form media gets pulled through
|
|
class SectionedRichTextPageSection(Orderable):
|
|
page = ParentalKey(
|
|
"tests.SectionedRichTextPage", related_name="sections", on_delete=models.CASCADE
|
|
)
|
|
body = RichTextField()
|
|
|
|
panels = [FieldPanel("body")]
|
|
|
|
|
|
class SectionedRichTextPage(Page):
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
InlinePanel("sections"),
|
|
]
|
|
|
|
|
|
class InlineStreamPageSection(Orderable):
|
|
page = ParentalKey(
|
|
"tests.InlineStreamPage", related_name="sections", on_delete=models.CASCADE
|
|
)
|
|
body = StreamField(
|
|
[
|
|
("text", CharBlock()),
|
|
("rich_text", RichTextBlock()),
|
|
("image", ImageChooserBlock()),
|
|
],
|
|
use_json_field=True,
|
|
)
|
|
panels = [FieldPanel("body")]
|
|
|
|
|
|
class InlineStreamPage(Page):
|
|
content_panels = [
|
|
FieldPanel("title", classname="title"),
|
|
InlinePanel("sections"),
|
|
]
|
|
|
|
|
|
class TableBlockStreamPage(Page):
|
|
table = StreamField([("table", TableBlock())], use_json_field=True)
|
|
|
|
content_panels = [FieldPanel("table")]
|
|
|
|
|
|
class UserProfile(models.Model):
|
|
# Wagtail's schema must be able to coexist alongside a custom UserProfile model
|
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
|
favourite_colour = models.CharField(max_length=255)
|
|
|
|
|
|
class PanelSiteSettings(TestSiteSetting):
|
|
panels = [FieldPanel("title")]
|
|
|
|
|
|
class PanelGenericSettings(TestGenericSetting):
|
|
panels = [FieldPanel("title")]
|
|
|
|
|
|
class TabbedSiteSettings(TestSiteSetting):
|
|
edit_handler = TabbedInterface(
|
|
[
|
|
ObjectList([FieldPanel("title")], heading="First tab"),
|
|
ObjectList([FieldPanel("email")], heading="Second tab"),
|
|
]
|
|
)
|
|
|
|
|
|
class TabbedGenericSettings(TestGenericSetting):
|
|
edit_handler = TabbedInterface(
|
|
[
|
|
ObjectList([FieldPanel("title")], heading="First tab"),
|
|
ObjectList([FieldPanel("email")], heading="Second tab"),
|
|
]
|
|
)
|
|
|
|
|
|
class AlwaysShowInMenusPage(Page):
|
|
show_in_menus_default = True
|
|
|
|
|
|
# test for AddField migrations on StreamFields using various default values
|
|
class AddedStreamFieldWithoutDefaultPage(Page):
|
|
body = StreamField([("title", CharBlock())], use_json_field=True)
|
|
|
|
|
|
class AddedStreamFieldWithEmptyStringDefaultPage(Page):
|
|
body = StreamField([("title", CharBlock())], default="", use_json_field=True)
|
|
|
|
|
|
class AddedStreamFieldWithEmptyListDefaultPage(Page):
|
|
body = StreamField([("title", CharBlock())], default=[], use_json_field=True)
|
|
|
|
|
|
class SecretPage(Page):
|
|
boring_data = models.TextField()
|
|
secret_data = models.TextField()
|
|
|
|
content_panels = Page.content_panels + [
|
|
FieldPanel("boring_data"),
|
|
FieldPanel("secret_data", permission="superuser"),
|
|
]
|
|
|
|
|
|
class SimpleParentPage(Page):
|
|
subpage_types = ["tests.SimpleChildPage"]
|
|
|
|
|
|
class SimpleChildPage(Page):
|
|
parent_page_types = ["tests.SimpleParentPage"]
|
|
|
|
max_count_per_parent = 1
|
|
|
|
|
|
class PersonPage(Page):
|
|
first_name = models.CharField(
|
|
max_length=255,
|
|
verbose_name="First Name",
|
|
)
|
|
last_name = models.CharField(
|
|
max_length=255,
|
|
verbose_name="Last Name",
|
|
)
|
|
|
|
content_panels = Page.content_panels + [
|
|
MultiFieldPanel(
|
|
[
|
|
FieldPanel("first_name"),
|
|
FieldPanel("last_name"),
|
|
],
|
|
"Person",
|
|
),
|
|
InlinePanel("addresses", label="Address"),
|
|
]
|
|
|
|
class Meta:
|
|
verbose_name = "Person"
|
|
verbose_name_plural = "Persons"
|
|
|
|
|
|
class Address(index.Indexed, ClusterableModel, Orderable):
|
|
address = models.CharField(
|
|
max_length=255,
|
|
verbose_name="Address",
|
|
)
|
|
tags = ClusterTaggableManager(
|
|
through="tests.AddressTag",
|
|
blank=True,
|
|
)
|
|
person = ParentalKey(
|
|
to="tests.PersonPage", related_name="addresses", verbose_name="Person"
|
|
)
|
|
|
|
panels = [
|
|
FieldPanel("address"),
|
|
FieldPanel("tags"),
|
|
]
|
|
|
|
class Meta:
|
|
verbose_name = "Address"
|
|
verbose_name_plural = "Addresses"
|
|
|
|
|
|
class AddressTag(TaggedItemBase):
|
|
content_object = ParentalKey(
|
|
to="tests.Address", on_delete=models.CASCADE, related_name="tagged_items"
|
|
)
|
|
|
|
|
|
class RestaurantPage(Page):
|
|
tags = ClusterTaggableManager(through="tests.TaggedRestaurant", blank=True)
|
|
|
|
content_panels = Page.content_panels + [
|
|
FieldPanel("tags"),
|
|
]
|
|
|
|
|
|
class RestaurantTag(TagBase):
|
|
free_tagging = False
|
|
|
|
class Meta:
|
|
verbose_name = "Tag"
|
|
verbose_name_plural = "Tags"
|
|
|
|
|
|
class TaggedRestaurant(ItemBase):
|
|
tag = models.ForeignKey(
|
|
RestaurantTag, related_name="tagged_restaurants", on_delete=models.CASCADE
|
|
)
|
|
content_object = ParentalKey(
|
|
to="tests.RestaurantPage", on_delete=models.CASCADE, related_name="tagged_items"
|
|
)
|
|
|
|
|
|
class SimpleTask(Task):
|
|
pass
|
|
|
|
|
|
# StreamField media definitions must not be evaluated at startup (e.g. during system checks) -
|
|
# these may fail if e.g. ManifestStaticFilesStorage is in use and collectstatic has not been run.
|
|
# Check this with a media definition that deliberately errors; if media handling is not set up
|
|
# correctly, then the mere presence of this model definition will cause startup to fail.
|
|
class DeadlyTextInput(forms.TextInput):
|
|
@property
|
|
def media(self):
|
|
raise Exception("BOOM! Attempted to evaluate DeadlyTextInput.media")
|
|
|
|
|
|
class DeadlyCharBlock(FieldBlock):
|
|
def __init__(self, *args, **kwargs):
|
|
self.field = forms.CharField(widget=DeadlyTextInput())
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
class DeadlyStreamPage(Page):
|
|
body = StreamField(
|
|
[
|
|
("title", DeadlyCharBlock()),
|
|
],
|
|
use_json_field=True,
|
|
)
|
|
content_panels = Page.content_panels + [
|
|
FieldPanel("body"),
|
|
]
|
|
|
|
|
|
# Check that get_image_model and get_document_model work at import time
|
|
# (so that it's possible to use them in foreign key definitions, for example)
|
|
ReimportedImageModel = get_image_model()
|
|
ReimportedDocumentModel = get_document_model()
|
|
|
|
|
|
# Custom document model with a custom tag field
|
|
class TaggedRestaurantDocument(ItemBase):
|
|
tag = models.ForeignKey(
|
|
RestaurantTag, related_name="tagged_documents", on_delete=models.CASCADE
|
|
)
|
|
content_object = models.ForeignKey(
|
|
to="tests.CustomRestaurantDocument",
|
|
on_delete=models.CASCADE,
|
|
related_name="tagged_items",
|
|
)
|
|
|
|
|
|
class CustomRestaurantDocument(AbstractDocument):
|
|
tags = TaggableManager(
|
|
help_text=None,
|
|
blank=True,
|
|
verbose_name="tags",
|
|
through=TaggedRestaurantDocument,
|
|
)
|
|
admin_form_fields = Document.admin_form_fields
|
|
|
|
|
|
# Custom image model with a custom tag field
|
|
class TaggedRestaurantImage(ItemBase):
|
|
tag = models.ForeignKey(
|
|
RestaurantTag, related_name="tagged_images", on_delete=models.CASCADE
|
|
)
|
|
content_object = models.ForeignKey(
|
|
to="tests.CustomRestaurantImage",
|
|
on_delete=models.CASCADE,
|
|
related_name="tagged_items",
|
|
)
|
|
|
|
|
|
class CustomRestaurantImage(AbstractImage):
|
|
tags = TaggableManager(
|
|
help_text=None, blank=True, verbose_name="tags", through=TaggedRestaurantImage
|
|
)
|
|
admin_form_fields = Image.admin_form_fields
|
|
|
|
|
|
class ModelWithStringTypePrimaryKey(models.Model):
|
|
"""
|
|
This model intentionally uses `CharField` as a primary key for testing purpose.
|
|
"""
|
|
|
|
custom_id = models.CharField(max_length=255, primary_key=True)
|
|
content = models.CharField(max_length=255)
|
|
|
|
|
|
class ModelWithNullableParentalKey(models.Model):
|
|
"""
|
|
There's not really a valid use case for null parental keys, but their presence should not
|
|
break things outright (e.g. when determining the object ID to store things under in the
|
|
references index).
|
|
"""
|
|
|
|
page = ParentalKey(Page, blank=True, null=True)
|
|
content = RichTextField()
|
|
|
|
|
|
class GalleryPage(Page):
|
|
content_panels = Page.content_panels + [
|
|
MultipleChooserPanel("gallery_images", chooser_field_name="image")
|
|
]
|
|
|
|
|
|
class GalleryPageImage(Orderable):
|
|
page = ParentalKey(
|
|
"tests.GalleryPage", related_name="gallery_images", on_delete=models.CASCADE
|
|
)
|
|
image = models.ForeignKey(
|
|
"wagtailimages.Image",
|
|
on_delete=models.CASCADE,
|
|
related_name="+",
|
|
)
|
|
|
|
|
|
class GenericSnippetNoIndexPage(GenericSnippetPage):
|
|
wagtail_reference_index_ignore = True
|
|
|
|
|
|
class GenericSnippetNoFieldIndexPage(GenericSnippetPage):
|
|
snippet_content_type_nonindexed = models.ForeignKey(
|
|
ContentType, on_delete=models.SET_NULL, null=True, blank=True
|
|
)
|
|
snippet_content_type_nonindexed.wagtail_reference_index_ignore = True
|
|
|
|
|
|
def random_quotable_pk():
|
|
quote_chrs = '":/_#?;@&=+$,"[]<>%\n\\'
|
|
components = (quote_chrs, string.ascii_letters, string.digits)
|
|
return "".join(random.choice(components[i % len(components)]) for i in range(10))
|
|
|
|
|
|
# Models to be registered with a ModelViewSet
|
|
class FeatureCompleteToy(index.Indexed, models.Model):
|
|
strid = models.CharField(
|
|
max_length=255,
|
|
primary_key=True,
|
|
default=random_quotable_pk,
|
|
)
|
|
name = models.CharField(max_length=255)
|
|
release_date = models.DateField(default=datetime.date.today)
|
|
|
|
search_fields = [
|
|
index.SearchField("name"),
|
|
index.AutocompleteField("name"),
|
|
index.FilterField("name"),
|
|
index.FilterField("release_date"),
|
|
]
|
|
|
|
def is_cool(self):
|
|
if self.name == self.name[::-1]:
|
|
return True
|
|
if (lowered := self.name.lower()) == lowered[::-1]:
|
|
return None
|
|
return False
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.release_date})"
|