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