1import hashlib
2import json
3import os
4import uuid
5
6from django import forms
7from django.conf import settings
8from django.contrib.contenttypes.fields import GenericForeignKey
9from django.contrib.contenttypes.models import ContentType
10from django.core.exceptions import ValidationError
11from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
12from django.core.serializers.json import DjangoJSONEncoder
13from django.db import models
14from django.shortcuts import redirect
15from django.template.response import TemplateResponse
16from modelcluster.contrib.taggit import ClusterTaggableManager
17from modelcluster.fields import ParentalKey, ParentalManyToManyField
18from modelcluster.models import ClusterableModel
19from taggit.managers import TaggableManager
20from taggit.models import ItemBase, TagBase, TaggedItemBase
21
22from wagtail.admin.edit_handlers import (
23    FieldPanel, InlinePanel, MultiFieldPanel, ObjectList, PageChooserPanel, StreamFieldPanel,
24    TabbedInterface)
25from wagtail.admin.forms import WagtailAdminPageForm
26from wagtail.admin.mail import send_mail
27from wagtail.contrib.forms.forms import FormBuilder
28from wagtail.contrib.forms.models import (
29    FORM_FIELD_CHOICES, AbstractEmailForm, AbstractFormField, AbstractFormSubmission)
30from wagtail.contrib.forms.views import SubmissionsListView
31from wagtail.contrib.settings.models import BaseSetting, register_setting
32from wagtail.contrib.sitemaps import Sitemap
33from wagtail.contrib.table_block.blocks import TableBlock
34from wagtail.core.blocks import (
35    CharBlock, FieldBlock, RawHTMLBlock, RichTextBlock, StreamBlock, StructBlock)
36from wagtail.core.fields import RichTextField, StreamField
37from wagtail.core.models import Orderable, Page, PageManager, PageQuerySet, Task, TranslatableMixin
38from wagtail.documents.edit_handlers import DocumentChooserPanel
39from wagtail.documents.models import AbstractDocument, Document
40from wagtail.images.blocks import ImageChooserBlock
41from wagtail.images.edit_handlers import ImageChooserPanel
42from wagtail.images.models import AbstractImage, AbstractRendition, Image
43from wagtail.search import index
44from wagtail.snippets.edit_handlers import SnippetChooserPanel
45from wagtail.snippets.models import register_snippet
46from wagtail.utils.decorators import cached_classmethod
47
48from .forms import FormClassAdditionalFieldPageForm, ValidatedPageForm
49
50
51EVENT_AUDIENCE_CHOICES = (
52    ('public', "Public"),
53    ('private', "Private"),
54)
55
56
57COMMON_PANELS = (
58    FieldPanel('slug'),
59    FieldPanel('seo_title'),
60    FieldPanel('show_in_menus'),
61    FieldPanel('search_description'),
62)
63
64
65# Link fields
66
67class LinkFields(models.Model):
68    link_external = models.URLField("External link", blank=True)
69    link_page = models.ForeignKey(
70        'wagtailcore.Page',
71        null=True,
72        blank=True,
73        related_name='+',
74        on_delete=models.CASCADE
75    )
76    link_document = models.ForeignKey(
77        'wagtaildocs.Document',
78        null=True,
79        blank=True,
80        related_name='+',
81        on_delete=models.CASCADE
82    )
83
84    @property
85    def link(self):
86        if self.link_page:
87            return self.link_page.url
88        elif self.link_document:
89            return self.link_document.url
90        else:
91            return self.link_external
92
93    panels = [
94        FieldPanel('link_external'),
95        PageChooserPanel('link_page'),
96        DocumentChooserPanel('link_document'),
97    ]
98
99    class Meta:
100        abstract = True
101
102
103# Carousel items
104
105class CarouselItem(LinkFields):
106    image = models.ForeignKey(
107        'wagtailimages.Image',
108        null=True,
109        blank=True,
110        on_delete=models.SET_NULL,
111        related_name='+'
112    )
113    embed_url = models.URLField("Embed URL", blank=True)
114    caption = models.CharField(max_length=255, blank=True)
115
116    panels = [
117        ImageChooserPanel('image'),
118        FieldPanel('embed_url'),
119        FieldPanel('caption'),
120        MultiFieldPanel(LinkFields.panels, "Link"),
121    ]
122
123    class Meta:
124        abstract = True
125
126
127# Related links
128
129class RelatedLink(LinkFields):
130    title = models.CharField(max_length=255, help_text="Link title")
131
132    panels = [
133        FieldPanel('title'),
134        MultiFieldPanel(LinkFields.panels, "Link"),
135    ]
136
137    class Meta:
138        abstract = True
139
140
141# Simple page
142class SimplePage(Page):
143    content = models.TextField()
144
145    content_panels = [
146        FieldPanel('title', classname="full title"),
147        FieldPanel('content'),
148    ]
149
150    def get_admin_display_title(self):
151        return "%s (simple page)" % super().get_admin_display_title()
152
153
154# Page with Excluded Fields when copied
155class PageWithExcludedCopyField(Page):
156    content = models.TextField()
157
158    # Exclude this field from being copied
159    special_field = models.CharField(
160        blank=True, max_length=255, default='Very Special')
161    exclude_fields_in_copy = ['special_field']
162
163    content_panels = [
164        FieldPanel('title', classname="full title"),
165        FieldPanel('special_field'),
166        FieldPanel('content'),
167    ]
168
169
170class PageWithOldStyleRouteMethod(Page):
171    """
172    Prior to Wagtail 0.4, the route() method on Page returned an HttpResponse
173    rather than a Page instance. As subclasses of Page may override route,
174    we need to continue accepting this convention (albeit as a deprecated API).
175    """
176    content = models.TextField()
177    template = 'tests/simple_page.html'
178
179    def route(self, request, path_components):
180        return self.serve(request)
181
182
183# File page
184class FilePage(Page):
185    file_field = models.FileField()
186
187    content_panels = [
188        FieldPanel('title', classname="full title"),
189        FieldPanel('file_field'),
190    ]
191
192
193# Event page
194
195class EventPageCarouselItem(TranslatableMixin, Orderable, CarouselItem):
196    page = ParentalKey('tests.EventPage', related_name='carousel_items', on_delete=models.CASCADE)
197
198    class Meta(TranslatableMixin.Meta, Orderable.Meta):
199        pass
200
201
202class EventPageRelatedLink(TranslatableMixin, Orderable, RelatedLink):
203    page = ParentalKey('tests.EventPage', related_name='related_links', on_delete=models.CASCADE)
204
205    class Meta(TranslatableMixin.Meta, Orderable.Meta):
206        pass
207
208
209class EventPageSpeakerAward(TranslatableMixin, Orderable, models.Model):
210    speaker = ParentalKey('tests.EventPageSpeaker', related_name='awards', on_delete=models.CASCADE)
211    name = models.CharField("Award name", max_length=255)
212    date_awarded = models.DateField(null=True, blank=True)
213
214    panels = [
215        FieldPanel('name'),
216        FieldPanel('date_awarded'),
217    ]
218
219    class Meta(TranslatableMixin.Meta, Orderable.Meta):
220        pass
221
222
223class EventPageSpeaker(TranslatableMixin, Orderable, LinkFields, ClusterableModel):
224    page = ParentalKey('tests.EventPage', related_name='speakers', related_query_name='speaker', on_delete=models.CASCADE)
225    first_name = models.CharField("Name", max_length=255, blank=True)
226    last_name = models.CharField("Surname", max_length=255, blank=True)
227    image = models.ForeignKey(
228        'wagtailimages.Image',
229        null=True,
230        blank=True,
231        on_delete=models.SET_NULL,
232        related_name='+'
233    )
234
235    @property
236    def name_display(self):
237        return self.first_name + " " + self.last_name
238
239    panels = [
240        FieldPanel('first_name'),
241        FieldPanel('last_name'),
242        ImageChooserPanel('image'),
243        MultiFieldPanel(LinkFields.panels, "Link"),
244        InlinePanel('awards', label="Awards"),
245    ]
246
247    class Meta(TranslatableMixin.Meta, Orderable.Meta):
248        pass
249
250
251class EventCategory(TranslatableMixin, models.Model):
252    name = models.CharField("Name", max_length=255)
253
254    def __str__(self):
255        return self.name
256
257
258# Override the standard WagtailAdminPageForm to add validation on start/end dates
259# that appears as a non-field error
260
261class EventPageForm(WagtailAdminPageForm):
262    def clean(self):
263        cleaned_data = super().clean()
264
265        # Make sure that the event starts before it ends
266        start_date = cleaned_data['date_from']
267        end_date = cleaned_data['date_to']
268        if start_date and end_date and start_date > end_date:
269            raise ValidationError('The end date must be after the start date')
270
271        return cleaned_data
272
273
274class EventPage(Page):
275    date_from = models.DateField("Start date", null=True)
276    date_to = models.DateField(
277        "End date",
278        null=True,
279        blank=True,
280        help_text="Not required if event is on a single day"
281    )
282    time_from = models.TimeField("Start time", null=True, blank=True)
283    time_to = models.TimeField("End time", null=True, blank=True)
284    audience = models.CharField(max_length=255, choices=EVENT_AUDIENCE_CHOICES)
285    location = models.CharField(max_length=255)
286    body = RichTextField(blank=True)
287    cost = models.CharField(max_length=255)
288    signup_link = models.URLField(blank=True)
289    feed_image = models.ForeignKey(
290        'wagtailimages.Image',
291        null=True,
292        blank=True,
293        on_delete=models.SET_NULL,
294        related_name='+'
295    )
296    categories = ParentalManyToManyField(EventCategory, blank=True)
297
298    search_fields = [
299        index.SearchField('get_audience_display'),
300        index.SearchField('location'),
301        index.SearchField('body'),
302        index.FilterField('url_path'),
303    ]
304
305    password_required_template = 'tests/event_page_password_required.html'
306    base_form_class = EventPageForm
307
308    content_panels = [
309        FieldPanel('title', classname="full title"),
310        FieldPanel('date_from'),
311        FieldPanel('date_to'),
312        FieldPanel('time_from'),
313        FieldPanel('time_to'),
314        FieldPanel('location'),
315        FieldPanel('audience'),
316        FieldPanel('cost'),
317        FieldPanel('signup_link'),
318        InlinePanel('carousel_items', label="Carousel items"),
319        FieldPanel('body', classname="full"),
320        InlinePanel('speakers', label="Speakers", heading="Speaker lineup"),
321        InlinePanel('related_links', label="Related links"),
322        FieldPanel('categories'),
323        # InlinePanel related model uses `pk` not `id`
324        InlinePanel('head_counts', label='Head Counts'),
325    ]
326
327    promote_panels = [
328        MultiFieldPanel(COMMON_PANELS, "Common page configuration"),
329        ImageChooserPanel('feed_image'),
330    ]
331
332
333class HeadCountRelatedModelUsingPK(models.Model):
334    """Related model that uses a custom primary key (pk) not id"""
335    custom_id = models.AutoField(primary_key=True)
336    event_page = ParentalKey(
337        EventPage,
338        on_delete=models.CASCADE,
339        related_name='head_counts'
340    )
341    head_count = models.IntegerField()
342    panels = [FieldPanel('head_count')]
343
344
345# Override the standard WagtailAdminPageForm to add field that is not in model
346# so that we can test additional potential issues like comparing versions
347class FormClassAdditionalFieldPage(Page):
348    location = models.CharField(max_length=255)
349    body = RichTextField(blank=True)
350
351    content_panels = [
352        FieldPanel('title', classname="full title"),
353        FieldPanel('location'),
354        FieldPanel('body'),
355        FieldPanel('code'),  # not in model, see set base_form_class
356    ]
357
358    base_form_class = FormClassAdditionalFieldPageForm
359
360
361# Just to be able to test multi table inheritance
362class SingleEventPage(EventPage):
363    excerpt = models.TextField(
364        max_length=255,
365        blank=True,
366        null=True,
367        help_text="Short text to describe what is this action about"
368    )
369
370    # Give this page model a custom URL routing scheme
371    def get_url_parts(self, request=None):
372        url_parts = super().get_url_parts(request=request)
373        if url_parts is None:
374            return None
375        else:
376            site_id, root_url, page_path = url_parts
377            return (site_id, root_url, page_path + 'pointless-suffix/')
378
379    def route(self, request, path_components):
380        if path_components == ['pointless-suffix']:
381            # treat this as equivalent to a request for this page
382            return super().route(request, [])
383        else:
384            # fall back to default routing rules
385            return super().route(request, path_components)
386
387    def get_admin_display_title(self):
388        return "%s (single event)" % super().get_admin_display_title()
389
390    content_panels = [FieldPanel('excerpt')] + EventPage.content_panels
391
392
393# "custom" sitemap object
394class EventSitemap(Sitemap):
395    pass
396
397
398# Event index (has a separate AJAX template, and a custom template context)
399class EventIndex(Page):
400    intro = RichTextField(blank=True)
401    ajax_template = 'tests/includes/event_listing.html'
402
403    def get_events(self):
404        return self.get_children().live().type(EventPage)
405
406    def get_paginator(self):
407        return Paginator(self.get_events(), 4)
408
409    def get_context(self, request, page=1):
410        # Pagination
411        paginator = self.get_paginator()
412        try:
413            events = paginator.page(page)
414        except PageNotAnInteger:
415            events = paginator.page(1)
416        except EmptyPage:
417            events = paginator.page(paginator.num_pages)
418
419        # Update context
420        context = super().get_context(request)
421        context['events'] = events
422        return context
423
424    def route(self, request, path_components):
425        if self.live and len(path_components) == 1:
426            try:
427                return self.serve(request, page=int(path_components[0]))
428            except (TypeError, ValueError):
429                pass
430
431        return super().route(request, path_components)
432
433    def get_static_site_paths(self):
434        # Get page count
435        page_count = self.get_paginator().num_pages
436
437        # Yield a path for each page
438        for page in range(page_count):
439            yield '/%d/' % (page + 1)
440
441        # Yield from superclass
442        for path in super().get_static_site_paths():
443            yield path
444
445    def get_sitemap_urls(self, request=None):
446        # Add past events url to sitemap
447        return super().get_sitemap_urls(request=request) + [
448            {
449                'location': self.full_url + 'past/',
450                'lastmod': self.latest_revision_created_at
451            }
452        ]
453
454    def get_cached_paths(self):
455        return super().get_cached_paths() + [
456            '/past/'
457        ]
458
459    content_panels = [
460        FieldPanel('title', classname="full title"),
461        FieldPanel('intro', classname="full"),
462    ]
463
464
465class FormField(AbstractFormField):
466    page = ParentalKey('FormPage', related_name='form_fields', on_delete=models.CASCADE)
467
468
469class FormPage(AbstractEmailForm):
470    def get_context(self, request):
471        context = super().get_context(request)
472        context['greeting'] = "hello world"
473        return context
474
475    # This is redundant (SubmissionsListView is the default view class), but importing
476    # SubmissionsListView in this models.py helps us to confirm that this recipe
477    # https://docs.wagtail.io/en/stable/reference/contrib/forms/customisation.html#customise-form-submissions-listing-in-wagtail-admin
478    # works without triggering circular dependency issues -
479    # see https://github.com/wagtail/wagtail/issues/6265
480    submissions_list_view_class = SubmissionsListView
481
482    content_panels = [
483        FieldPanel('title', classname="full title"),
484        InlinePanel('form_fields', label="Form fields"),
485        MultiFieldPanel([
486            FieldPanel('to_address', classname="full"),
487            FieldPanel('from_address', classname="full"),
488            FieldPanel('subject', classname="full"),
489        ], "Email")
490    ]
491
492
493# FormPage with a non-HTML extension
494
495class JadeFormField(AbstractFormField):
496    page = ParentalKey('JadeFormPage', related_name='form_fields', on_delete=models.CASCADE)
497
498
499class JadeFormPage(AbstractEmailForm):
500    template = "tests/form_page.jade"
501
502    content_panels = [
503        FieldPanel('title', classname="full title"),
504        InlinePanel('form_fields', label="Form fields"),
505        MultiFieldPanel([
506            FieldPanel('to_address', classname="full"),
507            FieldPanel('from_address', classname="full"),
508            FieldPanel('subject', classname="full"),
509        ], "Email")
510    ]
511
512
513# Form page that redirects to a different page
514
515class RedirectFormField(AbstractFormField):
516    page = ParentalKey('FormPageWithRedirect', related_name='form_fields', on_delete=models.CASCADE)
517
518
519class FormPageWithRedirect(AbstractEmailForm):
520    thank_you_redirect_page = models.ForeignKey(
521        'wagtailcore.Page',
522        null=True,
523        blank=True,
524        on_delete=models.SET_NULL,
525        related_name='+',
526    )
527
528    def get_context(self, request):
529        context = super(FormPageWithRedirect, self).get_context(request)
530        context['greeting'] = "hello world"
531        return context
532
533    def render_landing_page(self, request, form_submission=None, *args, **kwargs):
534        """
535        Renders the landing page OR if a receipt_page_redirect is chosen redirects to this page.
536        """
537        if self.thank_you_redirect_page:
538            return redirect(self.thank_you_redirect_page.url, permanent=False)
539
540        return super(FormPageWithRedirect, self).render_landing_page(request, form_submission, *args, **kwargs)
541
542    content_panels = [
543        FieldPanel('title', classname="full title"),
544        PageChooserPanel('thank_you_redirect_page'),
545        InlinePanel('form_fields', label="Form fields"),
546        MultiFieldPanel([
547            FieldPanel('to_address', classname="full"),
548            FieldPanel('from_address', classname="full"),
549            FieldPanel('subject', classname="full"),
550        ], "Email")
551    ]
552
553
554# FormPage with a custom FormSubmission
555
556class FormPageWithCustomSubmission(AbstractEmailForm):
557    """
558    This Form page:
559        * Have custom submission model
560        * Have custom related_name (see `FormFieldWithCustomSubmission.page`)
561        * Saves reference to a user
562        * Doesn't render html form, if submission for current user is present
563    """
564
565    intro = RichTextField(blank=True)
566    thank_you_text = RichTextField(blank=True)
567
568    def get_context(self, request, *args, **kwargs):
569        context = super().get_context(request)
570        context['greeting'] = "hello world"
571        return context
572
573    def get_form_fields(self):
574        return self.custom_form_fields.all()
575
576    def get_data_fields(self):
577        data_fields = [
578            ('useremail', 'User email'),
579        ]
580        data_fields += super().get_data_fields()
581
582        return data_fields
583
584    def get_submission_class(self):
585        return CustomFormPageSubmission
586
587    def process_form_submission(self, form):
588        form_submission = self.get_submission_class().objects.create(
589            form_data=json.dumps(form.cleaned_data, cls=DjangoJSONEncoder),
590            page=self, user=form.user
591        )
592
593        if self.to_address:
594            addresses = [x.strip() for x in self.to_address.split(',')]
595            content = '\n'.join([x[1].label + ': ' + str(form.data.get(x[0])) for x in form.fields.items()])
596            send_mail(self.subject, content, addresses, self.from_address,)
597
598        # process_form_submission should now return the created form_submission
599        return form_submission
600
601    def serve(self, request, *args, **kwargs):
602        if self.get_submission_class().objects.filter(page=self, user__pk=request.user.pk).exists():
603            return TemplateResponse(
604                request,
605                self.template,
606                self.get_context(request)
607            )
608
609        return super().serve(request, *args, **kwargs)
610
611    content_panels = [
612        FieldPanel('title', classname="full title"),
613        FieldPanel('intro', classname="full"),
614        InlinePanel('custom_form_fields', label="Form fields"),
615        FieldPanel('thank_you_text', classname="full"),
616        MultiFieldPanel([
617            FieldPanel('to_address', classname="full"),
618            FieldPanel('from_address', classname="full"),
619            FieldPanel('subject', classname="full"),
620        ], "Email")
621    ]
622
623
624class FormFieldWithCustomSubmission(AbstractFormField):
625    page = ParentalKey(FormPageWithCustomSubmission, on_delete=models.CASCADE, related_name='custom_form_fields')
626
627
628class CustomFormPageSubmission(AbstractFormSubmission):
629    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
630
631    def get_data(self):
632        form_data = super().get_data()
633        form_data.update({
634            'useremail': self.user.email,
635        })
636
637        return form_data
638
639
640# Custom form page with custom submission listing view and form submission
641
642class FormFieldForCustomListViewPage(AbstractFormField):
643    page = ParentalKey(
644        'FormPageWithCustomSubmissionListView',
645        related_name='form_fields',
646        on_delete=models.CASCADE
647    )
648
649
650class FormPageWithCustomSubmissionListView(AbstractEmailForm):
651    """Form Page with customised submissions listing view"""
652
653    intro = RichTextField(blank=True)
654    thank_you_text = RichTextField(blank=True)
655
656    def get_submissions_list_view_class(self):
657        from .views import CustomSubmissionsListView
658        return CustomSubmissionsListView
659
660    def get_submission_class(self):
661        return CustomFormPageSubmission
662
663    def get_data_fields(self):
664        data_fields = [
665            ('useremail', 'User email'),
666        ]
667        data_fields += super().get_data_fields()
668
669        return data_fields
670
671    content_panels = [
672        FieldPanel('title', classname="full title"),
673        FieldPanel('intro', classname="full"),
674        InlinePanel('form_fields', label="Form fields"),
675        FieldPanel('thank_you_text', classname="full"),
676        MultiFieldPanel([
677            FieldPanel('to_address', classname="full"),
678            FieldPanel('from_address', classname="full"),
679            FieldPanel('subject', classname="full"),
680        ], "Email")
681    ]
682
683
684# FormPage with cutom FormBuilder
685
686EXTENDED_CHOICES = FORM_FIELD_CHOICES + (('ipaddress', 'IP Address'),)
687
688
689class ExtendedFormField(AbstractFormField):
690    """Override the field_type field with extended choices."""
691    page = ParentalKey(
692        'FormPageWithCustomFormBuilder',
693        related_name='form_fields',
694        on_delete=models.CASCADE)
695    field_type = models.CharField(
696        verbose_name='field type', max_length=16, choices=EXTENDED_CHOICES)
697
698
699class CustomFormBuilder(FormBuilder):
700    """
701    A custom FormBuilder that has an 'ipaddress' field with
702    customised create_singleline_field with shorter max_length
703    """
704
705    def create_singleline_field(self, field, options):
706        options['max_length'] = 120  # usual default is 255
707        return forms.CharField(**options)
708
709    def create_ipaddress_field(self, field, options):
710        return forms.GenericIPAddressField(**options)
711
712
713class FormPageWithCustomFormBuilder(AbstractEmailForm):
714    """
715    A Form page that has a custom form builder and uses a custom
716    form field model with additional field_type choices.
717    """
718
719    form_builder = CustomFormBuilder
720
721    content_panels = [
722        FieldPanel('title', classname="full title"),
723        InlinePanel('form_fields', label="Form fields"),
724        MultiFieldPanel([
725            FieldPanel('to_address', classname="full"),
726            FieldPanel('from_address', classname="full"),
727            FieldPanel('subject', classname="full"),
728        ], "Email")
729    ]
730
731
732# Snippets
733class AdvertPlacement(models.Model):
734    page = ParentalKey('wagtailcore.Page', related_name='advert_placements', on_delete=models.CASCADE)
735    advert = models.ForeignKey('tests.Advert', related_name='+', on_delete=models.CASCADE)
736    colour = models.CharField(max_length=255)
737
738
739class AdvertTag(TaggedItemBase):
740    content_object = ParentalKey('Advert', related_name='tagged_items', on_delete=models.CASCADE)
741
742
743class Advert(ClusterableModel):
744    url = models.URLField(null=True, blank=True)
745    text = models.CharField(max_length=255)
746
747    tags = TaggableManager(through=AdvertTag, blank=True)
748
749    panels = [
750        FieldPanel('url'),
751        FieldPanel('text'),
752        FieldPanel('tags'),
753    ]
754
755    def __str__(self):
756        return self.text
757
758
759register_snippet(Advert)
760
761
762class AdvertWithCustomPrimaryKey(ClusterableModel):
763    advert_id = models.CharField(max_length=255, primary_key=True)
764    url = models.URLField(null=True, blank=True)
765    text = models.CharField(max_length=255)
766
767    panels = [
768        FieldPanel('url'),
769        FieldPanel('text'),
770    ]
771
772    def __str__(self):
773        return self.text
774
775
776register_snippet(AdvertWithCustomPrimaryKey)
777
778
779class AdvertWithCustomUUIDPrimaryKey(ClusterableModel):
780    advert_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
781    url = models.URLField(null=True, blank=True)
782    text = models.CharField(max_length=255)
783
784    panels = [
785        FieldPanel('url'),
786        FieldPanel('text'),
787    ]
788
789    def __str__(self):
790        return self.text
791
792
793register_snippet(AdvertWithCustomUUIDPrimaryKey)
794
795
796class AdvertWithTabbedInterface(models.Model):
797    url = models.URLField(null=True, blank=True)
798    text = models.CharField(max_length=255)
799    something_else = models.CharField(max_length=255)
800
801    advert_panels = [
802        FieldPanel('url'),
803        FieldPanel('text'),
804    ]
805
806    other_panels = [
807        FieldPanel('something_else'),
808    ]
809
810    edit_handler = TabbedInterface([
811        ObjectList(advert_panels, heading='Advert'),
812        ObjectList(other_panels, heading='Other'),
813    ])
814
815    def __str__(self):
816        return self.text
817
818    class Meta:
819        ordering = ('text',)
820
821
822register_snippet(AdvertWithTabbedInterface)
823
824
825class StandardIndex(Page):
826    """ Index for the site """
827    parent_page_types = [Page]
828
829    # A custom panel setup where all Promote fields are placed in the Content tab instead;
830    # we use this to test that the 'promote' tab is left out of the output when empty
831    content_panels = [
832        FieldPanel('title', classname="full title"),
833        FieldPanel('seo_title'),
834        FieldPanel('slug'),
835        InlinePanel('advert_placements', label="Adverts"),
836    ]
837
838    promote_panels = []
839
840
841class StandardChild(Page):
842    pass
843
844
845# Test overriding edit_handler with a custom one
846StandardChild.edit_handler = TabbedInterface([
847    ObjectList(StandardChild.content_panels, heading='Content'),
848    ObjectList(StandardChild.promote_panels, heading='Promote'),
849    ObjectList(StandardChild.settings_panels, heading='Settings', classname='settings'),
850    ObjectList([], heading='Dinosaurs'),
851], base_form_class=WagtailAdminPageForm)
852
853
854class BusinessIndex(Page):
855    """ Can be placed anywhere, can only have Business children """
856    subpage_types = ['tests.BusinessChild', 'tests.BusinessSubIndex']
857
858
859class BusinessSubIndex(Page):
860    """ Can be placed under BusinessIndex, and have BusinessChild children """
861
862    # BusinessNowherePage is 'incorrectly' added here as a possible child.
863    # The rules on BusinessNowherePage prevent it from being a child here though.
864    subpage_types = ['tests.BusinessChild', 'tests.BusinessNowherePage']
865    parent_page_types = ['tests.BusinessIndex', 'tests.BusinessChild']
866
867
868class BusinessChild(Page):
869    """ Can only be placed under Business indexes, no children allowed """
870    subpage_types = []
871    parent_page_types = ['tests.BusinessIndex', BusinessSubIndex]
872
873
874class BusinessNowherePage(Page):
875    """ Not allowed to be placed anywhere """
876    parent_page_types = []
877
878
879class TaggedPageTag(TaggedItemBase):
880    content_object = ParentalKey('tests.TaggedPage', related_name='tagged_items', on_delete=models.CASCADE)
881
882
883class TaggedPage(Page):
884    tags = ClusterTaggableManager(through=TaggedPageTag, blank=True)
885
886    content_panels = [
887        FieldPanel('title', classname="full title"),
888        FieldPanel('tags'),
889    ]
890
891
892class TaggedChildPage(TaggedPage):
893    pass
894
895
896class TaggedGrandchildPage(TaggedChildPage):
897    pass
898
899
900class SingletonPage(Page):
901    @classmethod
902    def can_create_at(cls, parent):
903        # You can only create one of these!
904        return super(SingletonPage, cls).can_create_at(parent) \
905            and not cls.objects.exists()
906
907
908class SingletonPageViaMaxCount(Page):
909    max_count = 1
910
911
912class PageChooserModel(models.Model):
913    page = models.ForeignKey('wagtailcore.Page', help_text='help text', on_delete=models.CASCADE)
914
915
916class EventPageChooserModel(models.Model):
917    page = models.ForeignKey('tests.EventPage', help_text='more help text', on_delete=models.CASCADE)
918
919
920class SnippetChooserModel(models.Model):
921    advert = models.ForeignKey(Advert, help_text='help text', on_delete=models.CASCADE)
922
923    panels = [
924        SnippetChooserPanel('advert'),
925    ]
926
927
928class SnippetChooserModelWithCustomPrimaryKey(models.Model):
929    advertwithcustomprimarykey = models.ForeignKey(AdvertWithCustomPrimaryKey, help_text='help text', on_delete=models.CASCADE)
930
931    panels = [
932        SnippetChooserPanel('advertwithcustomprimarykey'),
933    ]
934
935
936class CustomImage(AbstractImage):
937    caption = models.CharField(max_length=255, blank=True)
938    fancy_caption = RichTextField(blank=True)
939    not_editable_field = models.CharField(max_length=255, blank=True)
940
941    admin_form_fields = Image.admin_form_fields + (
942        'caption',
943        'fancy_caption',
944    )
945
946    class Meta:
947        unique_together = [
948            ('title', 'collection')
949        ]
950
951
952class CustomRendition(AbstractRendition):
953    image = models.ForeignKey(CustomImage, related_name='renditions', on_delete=models.CASCADE)
954
955    class Meta:
956        unique_together = (
957            ('image', 'filter_spec', 'focal_point_key'),
958        )
959
960
961# Custom image model with a required field
962class CustomImageWithAuthor(AbstractImage):
963    author = models.CharField(max_length=255)
964
965    admin_form_fields = Image.admin_form_fields + (
966        'author',
967    )
968
969
970class CustomRenditionWithAuthor(AbstractRendition):
971    image = models.ForeignKey(CustomImageWithAuthor, related_name='renditions', on_delete=models.CASCADE)
972
973    class Meta:
974        unique_together = (
975            ('image', 'filter_spec', 'focal_point_key'),
976        )
977
978
979class CustomDocument(AbstractDocument):
980    description = models.TextField(blank=True)
981    fancy_description = RichTextField(blank=True)
982    admin_form_fields = Document.admin_form_fields + (
983        'description',
984        'fancy_description'
985    )
986
987    class Meta:
988        unique_together = [
989            ('title', 'collection')
990        ]
991
992
993# Custom document model with a required field
994class CustomDocumentWithAuthor(AbstractDocument):
995    author = models.CharField(max_length=255)
996
997    admin_form_fields = Document.admin_form_fields + (
998        'author',
999    )
1000
1001
1002class StreamModel(models.Model):
1003    body = StreamField([
1004        ('text', CharBlock()),
1005        ('rich_text', RichTextBlock()),
1006        ('image', ImageChooserBlock()),
1007    ])
1008
1009
1010class MinMaxCountStreamModel(models.Model):
1011    body = StreamField(
1012        [
1013            ('text', CharBlock()),
1014            ('rich_text', RichTextBlock()),
1015            ('image', ImageChooserBlock()),
1016        ],
1017        min_num=2, max_num=5
1018    )
1019
1020
1021class BlockCountsStreamModel(models.Model):
1022    body = StreamField(
1023        [
1024            ('text', CharBlock()),
1025            ('rich_text', RichTextBlock()),
1026            ('image', ImageChooserBlock()),
1027        ],
1028        block_counts={
1029            "text": {"min_num": 1},
1030            "rich_text": {"max_num": 1},
1031            "image": {"min_num": 1, "max_num": 1},
1032        }
1033    )
1034
1035
1036class ExtendedImageChooserBlock(ImageChooserBlock):
1037    """
1038    Example of Block with custom get_api_representation method.
1039    If the request has an 'extended' query param, it returns a dict of id and title,
1040    otherwise, it returns the default value.
1041    """
1042
1043    def get_api_representation(self, value, context=None):
1044        image_id = super().get_api_representation(value, context=context)
1045        if 'request' in context and context['request'].query_params.get('extended', False):
1046            return {
1047                'id': image_id,
1048                'title': value.title
1049            }
1050        return image_id
1051
1052
1053class StreamPage(Page):
1054    body = StreamField([
1055        ('text', CharBlock()),
1056        ('rich_text', RichTextBlock()),
1057        ('image', ExtendedImageChooserBlock()),
1058        ('product', StructBlock([
1059            ('name', CharBlock()),
1060            ('price', CharBlock()),
1061        ])),
1062        ('raw_html', RawHTMLBlock()),
1063        ('books', StreamBlock([
1064            ('title', CharBlock()),
1065            ('author', CharBlock()),
1066        ])),
1067    ])
1068
1069    api_fields = ('body',)
1070
1071    content_panels = [
1072        FieldPanel('title'),
1073        StreamFieldPanel('body'),
1074    ]
1075
1076    preview_modes = []
1077
1078
1079class DefaultStreamPage(Page):
1080    body = StreamField([
1081        ('text', CharBlock()),
1082        ('rich_text', RichTextBlock()),
1083        ('image', ImageChooserBlock()),
1084    ], default='')
1085
1086    content_panels = [
1087        FieldPanel('title'),
1088        StreamFieldPanel('body'),
1089    ]
1090
1091
1092class MTIBasePage(Page):
1093    is_creatable = False
1094
1095    class Meta:
1096        verbose_name = "MTI Base page"
1097
1098
1099class MTIChildPage(MTIBasePage):
1100    # Should be creatable by default, no need to set anything
1101    pass
1102
1103
1104class AbstractPage(Page):
1105    class Meta:
1106        abstract = True
1107
1108
1109@register_setting
1110class TestSetting(BaseSetting):
1111    title = models.CharField(max_length=100)
1112    email = models.EmailField(max_length=50)
1113
1114
1115@register_setting
1116class ImportantPages(BaseSetting):
1117    sign_up_page = models.ForeignKey(
1118        'wagtailcore.Page', related_name="+", null=True, on_delete=models.SET_NULL)
1119    general_terms_page = models.ForeignKey(
1120        'wagtailcore.Page', related_name="+", null=True, on_delete=models.SET_NULL)
1121    privacy_policy_page = models.ForeignKey(
1122        'wagtailcore.Page', related_name="+", null=True, on_delete=models.SET_NULL)
1123
1124
1125@register_setting(icon="tag")
1126class IconSetting(BaseSetting):
1127    pass
1128
1129
1130class NotYetRegisteredSetting(BaseSetting):
1131    pass
1132
1133
1134@register_setting
1135class FileUploadSetting(BaseSetting):
1136    file = models.FileField()
1137
1138
1139class BlogCategory(models.Model):
1140    name = models.CharField(unique=True, max_length=80)
1141
1142
1143class BlogCategoryBlogPage(models.Model):
1144    category = models.ForeignKey(BlogCategory, related_name="+", on_delete=models.CASCADE)
1145    page = ParentalKey('ManyToManyBlogPage', related_name='categories', on_delete=models.CASCADE)
1146    panels = [
1147        FieldPanel('category'),
1148    ]
1149
1150
1151class ManyToManyBlogPage(Page):
1152    """
1153    A page type with two different kinds of M2M relation.
1154    We don't formally support these, but we don't want them to cause
1155    hard breakages either.
1156    """
1157    body = RichTextField(blank=True)
1158    adverts = models.ManyToManyField(Advert, blank=True)
1159    blog_categories = models.ManyToManyField(
1160        BlogCategory, through=BlogCategoryBlogPage, blank=True)
1161
1162    # make first_published_at editable on this page model
1163    settings_panels = Page.settings_panels + [
1164        FieldPanel('first_published_at'),
1165    ]
1166
1167
1168class OneToOnePage(Page):
1169    """
1170    A Page containing a O2O relation.
1171    """
1172    body = RichTextBlock(blank=True)
1173    page_ptr = models.OneToOneField(Page, parent_link=True,
1174                                    related_name='+', on_delete=models.CASCADE)
1175
1176
1177class GenericSnippetPage(Page):
1178    """
1179    A page containing a reference to an arbitrary snippet (or any model for that matter)
1180    linked by a GenericForeignKey
1181    """
1182    snippet_content_type = models.ForeignKey(ContentType, on_delete=models.SET_NULL, null=True)
1183    snippet_object_id = models.PositiveIntegerField(null=True)
1184    snippet_content_object = GenericForeignKey('snippet_content_type', 'snippet_object_id')
1185
1186
1187class CustomImageFilePath(AbstractImage):
1188    def get_upload_to(self, filename):
1189        """Create a path that's file-system friendly.
1190
1191        By hashing the file's contents we guarantee an equal distribution
1192        of files within our root directories. This also gives us a
1193        better chance of uploading images with the same filename, but
1194        different contents - this isn't guaranteed as we're only using
1195        the first three characters of the checksum.
1196        """
1197        original_filepath = super().get_upload_to(filename)
1198        folder_name, filename = original_filepath.split(os.path.sep)
1199
1200        # Ensure that we consume the entire file, we can't guarantee that
1201        # the stream has not be partially (or entirely) consumed by
1202        # another process
1203        original_position = self.file.tell()
1204        self.file.seek(0)
1205        hash256 = hashlib.sha256()
1206
1207        while True:
1208            data = self.file.read(256)
1209            if not data:
1210                break
1211            hash256.update(data)
1212        checksum = hash256.hexdigest()
1213
1214        self.file.seek(original_position)
1215        return os.path.join(folder_name, checksum[:3], filename)
1216
1217
1218class CustomPageQuerySet(PageQuerySet):
1219    def about_spam(self):
1220        return self.filter(title__contains='spam')
1221
1222
1223CustomManager = PageManager.from_queryset(CustomPageQuerySet)
1224
1225
1226class CustomManagerPage(Page):
1227    objects = CustomManager()
1228
1229
1230class MyBasePage(Page):
1231    """
1232    A base Page model, used to set site-wide defaults and overrides.
1233    """
1234    objects = CustomManager()
1235
1236    class Meta:
1237        abstract = True
1238
1239
1240class MyCustomPage(MyBasePage):
1241    pass
1242
1243
1244class ValidatedPage(Page):
1245    foo = models.CharField(max_length=255)
1246
1247    base_form_class = ValidatedPageForm
1248    content_panels = Page.content_panels + [
1249        FieldPanel('foo'),
1250    ]
1251
1252
1253class DefaultRichTextFieldPage(Page):
1254    body = RichTextField()
1255
1256    content_panels = [
1257        FieldPanel('title', classname="full title"),
1258        FieldPanel('body'),
1259    ]
1260
1261
1262class DefaultRichBlockFieldPage(Page):
1263    body = StreamField([
1264        ('rich_text', RichTextBlock()),
1265    ])
1266
1267    content_panels = Page.content_panels + [
1268        StreamFieldPanel('body')
1269    ]
1270
1271
1272class CustomRichTextFieldPage(Page):
1273    body = RichTextField(editor='custom')
1274
1275    content_panels = [
1276        FieldPanel('title', classname="full title"),
1277        FieldPanel('body'),
1278    ]
1279
1280
1281class CustomRichBlockFieldPage(Page):
1282    body = StreamField([
1283        ('rich_text', RichTextBlock(editor='custom')),
1284    ])
1285
1286    content_panels = [
1287        FieldPanel('title', classname="full title"),
1288        StreamFieldPanel('body'),
1289    ]
1290
1291
1292class RichTextFieldWithFeaturesPage(Page):
1293    body = RichTextField(features=['quotation', 'embed', 'made-up-feature'])
1294
1295    content_panels = [
1296        FieldPanel('title', classname="full title"),
1297        FieldPanel('body'),
1298    ]
1299
1300
1301# a page that only contains RichTextField within an InlinePanel,
1302# to test that the inline child's form media gets pulled through
1303class SectionedRichTextPageSection(Orderable):
1304    page = ParentalKey('tests.SectionedRichTextPage', related_name='sections', on_delete=models.CASCADE)
1305    body = RichTextField()
1306
1307    panels = [
1308        FieldPanel('body')
1309    ]
1310
1311
1312class SectionedRichTextPage(Page):
1313    content_panels = [
1314        FieldPanel('title', classname="full title"),
1315        InlinePanel('sections')
1316    ]
1317
1318
1319class InlineStreamPageSection(Orderable):
1320    page = ParentalKey('tests.InlineStreamPage', related_name='sections', on_delete=models.CASCADE)
1321    body = StreamField([
1322        ('text', CharBlock()),
1323        ('rich_text', RichTextBlock()),
1324        ('image', ImageChooserBlock()),
1325    ])
1326    panels = [
1327        StreamFieldPanel('body')
1328    ]
1329
1330
1331class InlineStreamPage(Page):
1332    content_panels = [
1333        FieldPanel('title', classname="full title"),
1334        InlinePanel('sections')
1335    ]
1336
1337
1338class TableBlockStreamPage(Page):
1339    table = StreamField([('table', TableBlock())])
1340
1341    content_panels = [StreamFieldPanel('table')]
1342
1343
1344class UserProfile(models.Model):
1345    # Wagtail's schema must be able to coexist alongside a custom UserProfile model
1346    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
1347    favourite_colour = models.CharField(max_length=255)
1348
1349
1350class PanelSettings(TestSetting):
1351    panels = [
1352        FieldPanel('title')
1353    ]
1354
1355
1356class TabbedSettings(TestSetting):
1357    edit_handler = TabbedInterface([
1358        ObjectList([
1359            FieldPanel('title')
1360        ], heading='First tab'),
1361        ObjectList([
1362            FieldPanel('email')
1363        ], heading='Second tab'),
1364    ])
1365
1366
1367class AlwaysShowInMenusPage(Page):
1368    show_in_menus_default = True
1369
1370
1371# test for AddField migrations on StreamFields using various default values
1372class AddedStreamFieldWithoutDefaultPage(Page):
1373    body = StreamField([
1374        ('title', CharBlock())
1375    ])
1376
1377
1378class AddedStreamFieldWithEmptyStringDefaultPage(Page):
1379    body = StreamField([
1380        ('title', CharBlock())
1381    ], default='')
1382
1383
1384class AddedStreamFieldWithEmptyListDefaultPage(Page):
1385    body = StreamField([
1386        ('title', CharBlock())
1387    ], default=[])
1388
1389
1390# test customising edit handler definitions on a per-request basis
1391class PerUserContentPanels(ObjectList):
1392    def _replace_children_with_per_user_config(self):
1393        self.children = self.instance.basic_content_panels
1394        if self.request.user.is_superuser:
1395            self.children = self.instance.superuser_content_panels
1396        self.children = [
1397            child.bind_to(model=self.model, instance=self.instance,
1398                          request=self.request, form=self.form)
1399            for child in self.children]
1400
1401    def on_instance_bound(self):
1402        # replace list of children when both instance and request are available
1403        if self.request:
1404            self._replace_children_with_per_user_config()
1405        else:
1406            super().on_instance_bound()
1407
1408    def on_request_bound(self):
1409        # replace list of children when both instance and request are available
1410        if self.instance:
1411            self._replace_children_with_per_user_config()
1412        else:
1413            super().on_request_bound()
1414
1415
1416class PerUserPageMixin:
1417    basic_content_panels = []
1418    superuser_content_panels = []
1419
1420    @cached_classmethod
1421    def get_edit_handler(cls):
1422        tabs = []
1423
1424        if cls.basic_content_panels and cls.superuser_content_panels:
1425            tabs.append(PerUserContentPanels(heading='Content'))
1426        if cls.promote_panels:
1427            tabs.append(ObjectList(cls.promote_panels,
1428                                   heading='Promote'))
1429        if cls.settings_panels:
1430            tabs.append(ObjectList(cls.settings_panels,
1431                                   heading='Settings',
1432                                   classname='settings'))
1433
1434        edit_handler = TabbedInterface(tabs,
1435                                       base_form_class=cls.base_form_class)
1436
1437        return edit_handler.bind_to(model=cls)
1438
1439
1440class SecretPage(PerUserPageMixin, Page):
1441    boring_data = models.TextField()
1442    secret_data = models.TextField()
1443
1444    basic_content_panels = Page.content_panels + [
1445        FieldPanel('boring_data'),
1446    ]
1447    superuser_content_panels = basic_content_panels + [
1448        FieldPanel('secret_data'),
1449    ]
1450
1451
1452class SimpleParentPage(Page):
1453    # `BusinessIndex` has been added to bring it in line with other tests
1454    subpage_types = ['tests.SimpleChildPage', BusinessIndex]
1455
1456
1457class SimpleChildPage(Page):
1458    # `Page` has been added to bring it in line with other tests
1459    parent_page_types = ['tests.SimpleParentPage', Page]
1460
1461    max_count_per_parent = 1
1462
1463
1464class PersonPage(Page):
1465    first_name = models.CharField(
1466        max_length=255,
1467        verbose_name='First Name',
1468    )
1469    last_name = models.CharField(
1470        max_length=255,
1471        verbose_name='Last Name',
1472    )
1473
1474    content_panels = Page.content_panels + [
1475        MultiFieldPanel([
1476            FieldPanel('first_name'),
1477            FieldPanel('last_name'),
1478        ], 'Person'),
1479        InlinePanel('addresses', label='Address'),
1480    ]
1481
1482    class Meta:
1483        verbose_name = 'Person'
1484        verbose_name_plural = 'Persons'
1485
1486
1487class Address(index.Indexed, ClusterableModel, Orderable):
1488    address = models.CharField(
1489        max_length=255,
1490        verbose_name='Address',
1491    )
1492    tags = ClusterTaggableManager(
1493        through='tests.AddressTag',
1494        blank=True,
1495    )
1496    person = ParentalKey(
1497        to='tests.PersonPage',
1498        related_name='addresses',
1499        verbose_name='Person'
1500    )
1501
1502    panels = [
1503        FieldPanel('address'),
1504        FieldPanel('tags'),
1505    ]
1506
1507    class Meta:
1508        verbose_name = 'Address'
1509        verbose_name_plural = 'Addresses'
1510
1511
1512class AddressTag(TaggedItemBase):
1513    content_object = ParentalKey(
1514        to='tests.Address',
1515        on_delete=models.CASCADE,
1516        related_name='tagged_items'
1517    )
1518
1519
1520class RestaurantPage(Page):
1521    tags = ClusterTaggableManager(through='tests.TaggedRestaurant', blank=True)
1522
1523    content_panels = Page.content_panels + [
1524        FieldPanel('tags'),
1525    ]
1526
1527
1528class RestaurantTag(TagBase):
1529    free_tagging = False
1530
1531    class Meta:
1532        verbose_name = "Tag"
1533        verbose_name_plural = "Tags"
1534
1535
1536class TaggedRestaurant(ItemBase):
1537    tag = models.ForeignKey(
1538        RestaurantTag, related_name="tagged_restaurants", on_delete=models.CASCADE
1539    )
1540    content_object = ParentalKey(
1541        to='tests.RestaurantPage',
1542        on_delete=models.CASCADE,
1543        related_name='tagged_items'
1544    )
1545
1546
1547class SimpleTask(Task):
1548    pass
1549
1550
1551# StreamField media definitions must not be evaluated at startup (e.g. during system checks) -
1552# these may fail if e.g. ManifestStaticFilesStorage is in use and collectstatic has not been run.
1553# Check this with a media definition that deliberately errors; if media handling is not set up
1554# correctly, then the mere presence of this model definition will cause startup to fail.
1555class DeadlyTextInput(forms.TextInput):
1556    @property
1557    def media(self):
1558        raise Exception("BOOM! Attempted to evaluate DeadlyTextInput.media")
1559
1560
1561class DeadlyCharBlock(FieldBlock):
1562    def __init__(self, *args, **kwargs):
1563        self.field = forms.CharField(widget=DeadlyTextInput())
1564        super().__init__(*args, **kwargs)
1565
1566
1567class DeadlyStreamPage(Page):
1568    body = StreamField([
1569        ('title', DeadlyCharBlock()),
1570    ])
1571    content_panels = Page.content_panels + [
1572        StreamFieldPanel('body'),
1573    ]
1574