page_models.py 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993
  1. """
  2. Base and abstract pages used in Wagtail CRX.
  3. """
  4. import json
  5. import logging
  6. import os
  7. import warnings
  8. from datetime import date, datetime
  9. from typing import Dict, List, Optional, TYPE_CHECKING, Union
  10. # This is a requirement for icalendar, even if django doesn't require it
  11. import pytz
  12. import geocoder
  13. from django import forms
  14. from django.conf import settings
  15. from django.contrib import messages
  16. from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile
  17. from django.core.files.storage import FileSystemStorage
  18. from django.core.mail import EmailMessage
  19. from django.core.paginator import Paginator, InvalidPage, EmptyPage, PageNotAnInteger
  20. from django.core.serializers.json import DjangoJSONEncoder
  21. from django.core.validators import MaxValueValidator, MinValueValidator
  22. from django.db import models
  23. from django.db.models.signals import post_delete, post_save
  24. from django.dispatch import receiver
  25. from django.http import JsonResponse, HttpResponseRedirect
  26. from django.shortcuts import render, redirect
  27. from django.template import Context, Template
  28. from django.template.loader import render_to_string
  29. from django.utils import timezone
  30. from django.utils.html import strip_tags
  31. from django.utils.safestring import mark_safe
  32. from django.utils.translation import gettext_lazy as _
  33. from eventtools.models import BaseEvent, BaseOccurrence
  34. from icalendar import Alarm
  35. from icalendar import Event as ICalEvent
  36. from modelcluster.fields import ParentalKey, ParentalManyToManyField
  37. from modelcluster.tags import ClusterTaggableManager
  38. from pathlib import Path
  39. from taggit.models import TaggedItemBase
  40. from wagtail.admin.panels import (
  41. FieldPanel,
  42. FieldRowPanel,
  43. InlinePanel,
  44. MultiFieldPanel,
  45. ObjectList,
  46. TabbedInterface
  47. )
  48. from wagtail import hooks
  49. from wagtail.fields import StreamField
  50. from wagtail.models import Orderable, PageBase, Page, Site
  51. from wagtail.coreutils import resolve_model_string
  52. from wagtail.contrib.forms.panels import FormSubmissionsPanel
  53. from wagtail.contrib.forms.forms import WagtailAdminFormPageForm
  54. from wagtail.images import get_image_model_string
  55. from wagtail.contrib.forms.models import FormSubmission
  56. from wagtail.search import index
  57. from wagtail.utils.decorators import cached_classmethod
  58. from wagtailcache.cache import WagtailCacheMixin
  59. from wagtailseo.models import SeoMixin, TwitterCard
  60. from wagtailseo.utils import get_struct_data_images, StructDataEncoder
  61. from wagtailcrx import utils
  62. from wagtailcrx.blocks import (
  63. CONTENT_STREAMBLOCKS,
  64. LAYOUT_STREAMBLOCKS,
  65. STREAMFORM_BLOCKS,
  66. ContentWallBlock,
  67. )
  68. from wagtailcrx.fields import CoderedStreamField, ColorField
  69. from wagtailcrx.forms import CoderedFormBuilder, CoderedSubmissionsListView
  70. from wagtailcrx.models.snippet_models import ClassifierTerm
  71. from wagtailcrx.models.wagtailsettings_models import (
  72. GeneralSettings,
  73. GoogleApiSettings,
  74. LayoutSettings,
  75. )
  76. from wagtailcrx.wagtail_flexible_forms.blocks import FormFieldBlock, FormStepBlock
  77. from wagtailcrx.wagtail_flexible_forms.models import (
  78. Step,
  79. Steps,
  80. StreamFormMixin,
  81. StreamFormJSONEncoder,
  82. SessionFormSubmission,
  83. SubmissionRevision,
  84. )
  85. from wagtailcrx.settings import crx_settings
  86. from wagtailcrx.widgets import ClassifierSelectWidget
  87. if TYPE_CHECKING:
  88. from wagtail.images.models import AbstractImage
  89. logger = logging.getLogger('wagtailcrx')
  90. CODERED_PAGE_MODELS = []
  91. def get_page_models():
  92. return CODERED_PAGE_MODELS
  93. class CoderedPageMeta(PageBase):
  94. def __init__(cls, name, bases, dct):
  95. super().__init__(name, bases, dct)
  96. # Copy of how django generates `db_table`, for compatibility with
  97. # renaming `wagtailcrx` app to `wagtailcrx`.
  98. # if (
  99. # not cls._meta.abstract
  100. # and cls._meta.app_label == "wagtailcrx"
  101. # and crx_settings.CRX_DB_TABLE_CODEREDCMS
  102. # ):
  103. # from django.db import connection
  104. # from django.db.backends.utils import truncate_name
  105. # print(f"HERE - `{cls._meta.db_table}`")
  106. # cls._meta.db_table = "coderedcms_%s" % cls._meta.model_name
  107. # cls._meta.db_table = truncate_name(
  108. # cls._meta.db_table, connection.ops.max_name_length()
  109. # )
  110. # print(f" - `{cls._meta.db_table}`")
  111. # Append to our registry of known models derived from CoderedPage.
  112. if not cls._meta.abstract:
  113. CODERED_PAGE_MODELS.append(cls)
  114. class CoderedTag(TaggedItemBase):
  115. class Meta:
  116. verbose_name = _('CodeRed Tag')
  117. if crx_settings.CRX_DB_TABLE_CODEREDCMS:
  118. db_table = "coderedcms_coderedtag"
  119. content_object = ParentalKey('wagtailcrx.CoderedPage', related_name='tagged_items')
  120. class CoderedPage(WagtailCacheMixin, SeoMixin, Page, metaclass=CoderedPageMeta):
  121. """
  122. General use page with caching, templating, and SEO functionality.
  123. All pages should inherit from this.
  124. """
  125. class Meta:
  126. verbose_name = _('CodeRed Page')
  127. if crx_settings.CRX_DB_TABLE_CODEREDCMS:
  128. db_table = "coderedcms_coderedpage"
  129. # Do not allow this page type to be created in wagtail admin
  130. is_creatable = False
  131. # Templates
  132. # The page will render the following templates under certain conditions:
  133. #
  134. # template = ''
  135. # ajax_template = ''
  136. # search_template = ''
  137. ###############
  138. # Content fields
  139. ###############
  140. cover_image = models.ForeignKey(
  141. get_image_model_string(),
  142. null=True,
  143. blank=True,
  144. on_delete=models.SET_NULL,
  145. related_name='+',
  146. verbose_name=_('Cover image'),
  147. )
  148. ###############
  149. # Index fields
  150. ###############
  151. # Subclasses can override this to enabled index features by default.
  152. index_show_subpages_default = False
  153. # Subclasses can override this to query on a specific
  154. # page model, rather than the default wagtail Page.
  155. index_query_pagemodel = 'wagtailcrx.CoderedPage'
  156. # Subclasses can override these fields to enable custom
  157. # ordering based on specific subpage fields.
  158. index_order_by_default = ''
  159. index_order_by_choices = (
  160. ('', _('Default Ordering')),
  161. ('-first_published_at', _('Date first published, newest to oldest')),
  162. ('first_published_at', _('Date first published, oldest to newest')),
  163. ('-last_published_at', _('Date updated, newest to oldest')),
  164. ('last_published_at', _('Date updated, oldest to newest')),
  165. ('title', _('Title, alphabetical')),
  166. ('-title', _('Title, reverse alphabetical')),
  167. )
  168. index_show_subpages = models.BooleanField(
  169. default=index_show_subpages_default,
  170. verbose_name=_('Show list of child pages')
  171. )
  172. index_order_by_classifier = models.ForeignKey(
  173. 'wagtailcrx.Classifier',
  174. blank=True,
  175. null=True,
  176. on_delete=models.SET_NULL,
  177. related_name="+",
  178. verbose_name=_('Order child pages by classifier'),
  179. help_text=_(
  180. 'Child pages will first be sorted following the order of this '
  181. 'classifier’s terms (from Snippets > Classifiers).'
  182. )
  183. )
  184. index_order_by = models.CharField(
  185. max_length=255,
  186. choices=index_order_by_choices,
  187. default=index_order_by_default,
  188. blank=True,
  189. verbose_name=_('Order child pages by'),
  190. help_text=_('Child pages will then be sorted by this attribute.')
  191. )
  192. index_num_per_page = models.PositiveIntegerField(
  193. default=10,
  194. verbose_name=_('Number per page'),
  195. )
  196. index_classifiers = ParentalManyToManyField(
  197. 'wagtailcrx.Classifier',
  198. blank=True,
  199. verbose_name=_('Filter child pages by'),
  200. help_text=_('Enable filtering child pages by these classifiers.'),
  201. )
  202. ###############
  203. # Layout fields
  204. ###############
  205. custom_template = models.CharField(
  206. blank=True,
  207. max_length=255,
  208. choices=None,
  209. verbose_name=_('Template')
  210. )
  211. ###############
  212. # SEO overrides
  213. ###############
  214. seo_image_sources = [
  215. "og_image",
  216. "cover_image",
  217. ]
  218. ###############
  219. # Classify
  220. ###############
  221. classifier_terms = ParentalManyToManyField(
  222. 'wagtailcrx.ClassifierTerm',
  223. blank=True,
  224. verbose_name=_('Classifiers'),
  225. help_text=_('Categorize and group pages together with classifiers. Used to organize and filter pages across the site.'), # noqa
  226. )
  227. tags = ClusterTaggableManager(
  228. through=CoderedTag,
  229. blank=True,
  230. verbose_name=_('Tags'),
  231. help_text=_('Used to organize pages across the site.'),
  232. )
  233. ###############
  234. # Settings
  235. ###############
  236. content_walls = CoderedStreamField(
  237. [
  238. ('content_wall', ContentWallBlock())
  239. ],
  240. blank=True,
  241. use_json_field=True,
  242. verbose_name=_('Content Walls')
  243. )
  244. ###############
  245. # Search
  246. ###############
  247. search_fields = Page.search_fields + [
  248. index.SearchField('seo_title', partial_match=True, boost=2),
  249. index.SearchField('search_description', boost=2),
  250. index.FilterField('index_show_subpages'),
  251. index.FilterField('index_order_by'),
  252. index.FilterField('custom_template'),
  253. index.FilterField('classifier_terms'),
  254. ]
  255. ###############
  256. # Panels
  257. ###############
  258. content_panels = Page.content_panels + [
  259. FieldPanel('cover_image'),
  260. ]
  261. body_content_panels = []
  262. bottom_content_panels = []
  263. classify_panels = [
  264. FieldPanel('classifier_terms', widget=ClassifierSelectWidget()),
  265. FieldPanel('tags'),
  266. ]
  267. layout_panels = [
  268. MultiFieldPanel(
  269. [
  270. FieldPanel('custom_template')
  271. ],
  272. heading=_('Visual Design')
  273. ),
  274. MultiFieldPanel(
  275. [
  276. FieldPanel('index_show_subpages'),
  277. FieldPanel('index_num_per_page'),
  278. FieldPanel('index_order_by_classifier'),
  279. FieldPanel('index_order_by'),
  280. FieldPanel('index_classifiers', widget=forms.CheckboxSelectMultiple()),
  281. ],
  282. heading=_('Show Child Pages')
  283. )
  284. ]
  285. promote_panels = SeoMixin.seo_meta_panels + SeoMixin.seo_struct_panels
  286. settings_panels = Page.settings_panels + [
  287. FieldPanel('content_walls'),
  288. ]
  289. integration_panels = []
  290. def __init__(self, *args, **kwargs):
  291. """
  292. Inject custom choices and defaults into the form fields
  293. to enable customization by subclasses.
  294. """
  295. super().__init__(*args, **kwargs)
  296. klassname = self.__class__.__name__.lower()
  297. template_choices = (
  298. crx_settings.CRX_FRONTEND_TEMPLATES_PAGES.get('*', []) +
  299. crx_settings.CRX_FRONTEND_TEMPLATES_PAGES.get(klassname, [])
  300. )
  301. self._meta.get_field('index_order_by').choices = self.index_order_by_choices
  302. self._meta.get_field('custom_template').choices = template_choices
  303. if not self.id:
  304. self.index_order_by = self.index_order_by_default
  305. self.index_show_subpages = self.index_show_subpages_default
  306. @cached_classmethod
  307. def get_edit_handler(cls):
  308. """
  309. Override to "lazy load" the panels overridden by subclasses.
  310. """
  311. panels = [
  312. ObjectList(
  313. cls.content_panels + cls.body_content_panels + cls.bottom_content_panels,
  314. heading=_('Content')
  315. ),
  316. ObjectList(cls.classify_panels, heading=_('Classify')),
  317. ObjectList(cls.layout_panels, heading=_('Layout')),
  318. ObjectList(cls.promote_panels, heading=_('SEO'), classname="seo"),
  319. ObjectList(cls.settings_panels, heading=_('Settings'), classname="settings"),
  320. ]
  321. if cls.integration_panels:
  322. panels.append(ObjectList(
  323. cls.integration_panels,
  324. heading='Integrations',
  325. classname='integrations'
  326. ))
  327. edit_handler = TabbedInterface(panels)
  328. return edit_handler.bind_to_model(cls)
  329. @property
  330. def seo_logo(self) -> "Optional[AbstractImage]":
  331. """
  332. Override method in SeoMixin.
  333. Gets the primary logo of the organization.
  334. """
  335. logo = super().seo_logo
  336. if logo:
  337. return logo
  338. else:
  339. layout_settings = LayoutSettings.for_site(self.get_site())
  340. if layout_settings.logo:
  341. return layout_settings.logo
  342. return None
  343. @property
  344. def seo_image(self) -> "Optional[AbstractImage]":
  345. """
  346. Override method in SeoMixin.
  347. Fallback to logo if opengraph image is not specified.
  348. """
  349. img = super().seo_image
  350. if img is None:
  351. return self.seo_logo
  352. return img
  353. @property
  354. def seo_twitter_card_content(self) -> str:
  355. """
  356. Override of method in SeoMixin.
  357. Show a large twitter card if the page has an image set.
  358. """
  359. if self.seo_image:
  360. return TwitterCard.LARGE.value
  361. return self.seo_twitter_card.value
  362. def get_template(self, request, *args, **kwargs):
  363. """
  364. Override parent to serve different templates based on querystring.
  365. """
  366. if self.custom_template:
  367. return self.custom_template
  368. return super().get_template(request, args, kwargs)
  369. def get_index_children(self):
  370. """
  371. Returns query of subpages as defined by `index_` variables.
  372. """
  373. if self.index_query_pagemodel:
  374. querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
  375. query = querymodel.objects.child_of(self).live()
  376. else:
  377. query = self.get_children().live()
  378. # Determine query sorting order.
  379. order = []
  380. # To sort by term order of a specific classifier, annotate the child
  381. # pages with the `sort_order` of its ClassifierTerms.
  382. if self.index_order_by_classifier:
  383. terms = ClassifierTerm.objects.filter(
  384. classifier=self.index_order_by_classifier,
  385. # Reverse ManyToMany of `coderedpage.classifier_terms`.
  386. coderedpage=models.OuterRef("pk"),
  387. )
  388. query = query.annotate(
  389. term_sort_order=models.Subquery(terms.values("sort_order"))
  390. )
  391. order.append("term_sort_order")
  392. # Second, order by the specified model attribute.
  393. if self.index_order_by:
  394. order.append(self.index_order_by)
  395. # Order the query.
  396. if order:
  397. query = query.order_by(*order)
  398. return query
  399. def get_content_walls(self, check_child_setting=True):
  400. current_content_walls = []
  401. if check_child_setting:
  402. for wall in self.content_walls:
  403. if wall.value['show_content_wall_on_children']:
  404. current_content_walls.append(wall.value)
  405. else:
  406. current_content_walls = self.content_walls
  407. try:
  408. return list(current_content_walls) + self.get_parent().specific.get_content_walls()
  409. except AttributeError:
  410. return list(current_content_walls)
  411. def get_context(self, request, *args, **kwargs):
  412. """
  413. Add child pages and paginated child pages to context.
  414. """
  415. context = super().get_context(request)
  416. if self.index_show_subpages:
  417. # Get child pages
  418. all_children = self.get_index_children()
  419. # Filter by classifier terms if applicable
  420. if len(request.GET) > 0 and self.index_classifiers.exists():
  421. # Look up comma separated ClassifierTerm slugs i.e. `/?c=term1-slug,term2-slug`
  422. terms = []
  423. get_c = request.GET.get('c', None)
  424. if get_c:
  425. terms = get_c.split(',')
  426. # Else look up individual querystrings i.e. `/?classifier-slug=term1-slug`
  427. else:
  428. for classifier in self.index_classifiers.all().only('slug'):
  429. get_term = request.GET.get(classifier.slug, None)
  430. if get_term:
  431. terms.append(get_term)
  432. if len(terms) > 0:
  433. selected_terms = ClassifierTerm.objects.filter(slug__in=terms)
  434. context['selected_terms'] = selected_terms
  435. if len(selected_terms) > 0:
  436. try:
  437. for term in selected_terms:
  438. all_children = all_children.filter(classifier_terms=term)
  439. except AttributeError:
  440. logger.warning(
  441. "Tried to filter by ClassifierTerm, but <%s.%s ('%s')>.get_index_children() did not return a queryset or is not a queryset of CoderedPage models.", # noqa
  442. self._meta.app_label,
  443. self.__class__.__name__,
  444. self.title
  445. )
  446. paginator = Paginator(all_children, self.index_num_per_page)
  447. pagenum = request.GET.get('p', 1)
  448. try:
  449. paged_children = paginator.page(pagenum)
  450. except (PageNotAnInteger, EmptyPage, InvalidPage) as e: # noqa
  451. paged_children = paginator.page(1)
  452. context['index_paginated'] = paged_children
  453. context['index_children'] = all_children
  454. context['content_walls'] = self.get_content_walls(check_child_setting=False)
  455. return context
  456. ###############################################################################
  457. # Abstract pages providing pre-built common website functionality, suitable for subclassing.
  458. # These are abstract so subclasses can override fields if desired.
  459. ###############################################################################
  460. class CoderedWebPage(CoderedPage):
  461. """
  462. Provides a body and body-related functionality.
  463. This is abstract so that subclasses can override the body StreamField.
  464. """
  465. class Meta:
  466. verbose_name = _('CodeRed Web Page')
  467. abstract = True
  468. template = 'wagtailcrx/pages/web_page.html'
  469. # Child pages should override based on what blocks they want in the body.
  470. # Default is LAYOUT_STREAMBLOCKS which is the fullest editor experience.
  471. body = StreamField(
  472. LAYOUT_STREAMBLOCKS,
  473. null=True,
  474. blank=True,
  475. use_json_field=True,
  476. )
  477. # Search fields
  478. search_fields = (
  479. CoderedPage.search_fields +
  480. [index.SearchField('body')]
  481. )
  482. # Panels
  483. body_content_panels = [
  484. FieldPanel('body'),
  485. ]
  486. @property
  487. def body_preview(self):
  488. """
  489. A shortened version of the body without HTML tags.
  490. """
  491. # add spaces between tags for legibility
  492. body = str(self.body).replace('>', '> ')
  493. # strip tags
  494. body = strip_tags(body)
  495. # truncate and add ellipses
  496. preview = body[:200] + "..." if len(body) > 200 else body
  497. return mark_safe(preview)
  498. class CoderedArticlePage(CoderedWebPage):
  499. """
  500. Article, suitable for news or blog content.
  501. """
  502. class Meta:
  503. verbose_name = _('CodeRed Article')
  504. abstract = True
  505. template = 'wagtailcrx/pages/article_page.html'
  506. # Override body to provide simpler content
  507. body = StreamField(
  508. CONTENT_STREAMBLOCKS,
  509. null=True,
  510. blank=True,
  511. use_json_field=True,
  512. )
  513. caption = models.CharField(
  514. max_length=255,
  515. blank=True,
  516. verbose_name=_('Caption'),
  517. )
  518. author = models.ForeignKey(
  519. settings.AUTH_USER_MODEL,
  520. null=True,
  521. blank=True,
  522. editable=True,
  523. on_delete=models.SET_NULL,
  524. verbose_name=_('Author'),
  525. )
  526. author_display = models.CharField(
  527. max_length=255,
  528. blank=True,
  529. verbose_name=_('Display author as'),
  530. help_text=_('Override how the author’s name displays on this article.'),
  531. )
  532. date_display = models.DateField(
  533. null=True,
  534. blank=True,
  535. verbose_name=_('Display publish date'),
  536. )
  537. def get_author_name(self):
  538. warnings.warn(
  539. ("CoderedArticlePage.get_author_name has been replaced with "
  540. "CoderedArticlePage.seo_author"),
  541. DeprecationWarning,
  542. )
  543. return self.seo_author
  544. @property
  545. def seo_author(self) -> str:
  546. """
  547. Override of method in SeoMixin.
  548. Gets author name using a fallback.
  549. """
  550. if self.author_display:
  551. return self.author_display
  552. if self.author:
  553. return self.author.get_full_name()
  554. if self.owner:
  555. return self.owner.get_full_name()
  556. return ""
  557. def get_pub_date(self):
  558. warnings.warn(
  559. ("CoderedArticlePage.get_pub_date has been replaced with "
  560. "CoderedArticlePage.seo_published_at"),
  561. DeprecationWarning,
  562. )
  563. return self.seo_published_at
  564. @property
  565. def seo_published_at(self) -> datetime:
  566. """
  567. Override of method in SeoMixin.
  568. Gets published date.
  569. """
  570. if self.date_display:
  571. return self.date_display
  572. return self.first_published_at
  573. def get_description(self):
  574. warnings.warn(
  575. ("CoderedArticlePage.get_description has been replaced with "
  576. "CoderedArticlePage.seo_description"),
  577. DeprecationWarning,
  578. )
  579. return self.seo_description
  580. @property
  581. def seo_description(self) -> str:
  582. """
  583. Override of method in SeoMixin.
  584. Gets the description using a fallback.
  585. """
  586. if self.search_description:
  587. return self.search_description
  588. if self.caption:
  589. return self.caption
  590. if self.body_preview:
  591. return self.body_preview
  592. return ""
  593. search_fields = (
  594. CoderedWebPage.search_fields +
  595. [
  596. index.SearchField('caption', boost=2),
  597. index.FilterField('author'),
  598. index.FilterField('author_display'),
  599. index.FilterField('date_display'),
  600. ]
  601. )
  602. content_panels = CoderedWebPage.content_panels + [
  603. FieldPanel('caption'),
  604. MultiFieldPanel(
  605. [
  606. FieldPanel('author'),
  607. FieldPanel('author_display'),
  608. FieldPanel('date_display'),
  609. ],
  610. _('Publication Info')
  611. )
  612. ]
  613. class CoderedArticleIndexPage(CoderedWebPage):
  614. """
  615. Shows a list of article sub-pages.
  616. """
  617. class Meta:
  618. verbose_name = _('CodeRed Article Index Page')
  619. abstract = True
  620. template = 'wagtailcrx/pages/article_index_page.html'
  621. index_show_subpages_default = True
  622. index_order_by_default = '-date_display'
  623. index_order_by_choices = (('-date_display', 'Display publish date, newest first'),) + \
  624. CoderedWebPage.index_order_by_choices
  625. show_images = models.BooleanField(
  626. default=True,
  627. verbose_name=_('Show images'),
  628. )
  629. show_captions = models.BooleanField(
  630. default=True,
  631. )
  632. show_meta = models.BooleanField(
  633. default=True,
  634. verbose_name=_('Show author and date info'),
  635. )
  636. show_preview_text = models.BooleanField(
  637. default=True,
  638. verbose_name=_('Show preview text'),
  639. )
  640. layout_panels = CoderedWebPage.layout_panels + [
  641. MultiFieldPanel(
  642. [
  643. FieldPanel('show_images'),
  644. FieldPanel('show_captions'),
  645. FieldPanel('show_meta'),
  646. FieldPanel('show_preview_text'),
  647. ],
  648. heading=_('Child page display')
  649. ),
  650. ]
  651. class CoderedEventPage(CoderedWebPage, BaseEvent):
  652. class Meta:
  653. verbose_name = _('CodeRed Event')
  654. abstract = True
  655. calendar_color = ColorField(
  656. blank=True,
  657. help_text=_('The color that the event will use when displayed on a calendar.'),
  658. )
  659. address = models.TextField(
  660. blank=True,
  661. verbose_name=_("Address")
  662. )
  663. content_panels = CoderedWebPage.content_panels + [
  664. MultiFieldPanel(
  665. [
  666. FieldPanel('calendar_color'),
  667. FieldPanel('address'),
  668. ],
  669. heading=_('Event information')
  670. ),
  671. InlinePanel(
  672. 'occurrences',
  673. min_num=1,
  674. heading=_("Dates and times"),
  675. ),
  676. ]
  677. @property
  678. def upcoming_occurrences(self):
  679. """
  680. Returns the next x occurrences for this event.
  681. By default, it returns 10.
  682. """
  683. return self.query_occurrences(num_of_instances_to_return=10)
  684. @property
  685. def most_recent_occurrence(self):
  686. """
  687. Gets the next upcoming, or last occurrence if the event has no more occurrences.
  688. """
  689. try:
  690. noc = self.next_occurrence()
  691. if noc:
  692. return noc
  693. aoc = []
  694. for occurrence in self.occurrences.all():
  695. aoc += [instance for instance in occurrence.all_occurrences()]
  696. if len(aoc) > 0:
  697. return aoc[-1] # last one in the list
  698. except AttributeError:
  699. # Triggers when a preview is initiated on an
  700. # EventPage because it uses a FakeQuerySet object.
  701. # Here we manually compute the next_occurrence
  702. occurrences = [e.next_occurrence() for e in self.occurrences.all()]
  703. if occurrences:
  704. return sorted(occurrences, key=lambda tup: tup[0])[0]
  705. @property
  706. def seo_struct_event_dict(self) -> dict:
  707. next_occ = self.most_recent_occurrence
  708. sd_dict = {
  709. "@context": "https://schema.org/",
  710. "@type": "Event",
  711. "name": self.title,
  712. "description": self.seo_description,
  713. "startDate": next_occ.start,
  714. "endDate": next_occ.end,
  715. "mainEntityOfPage": {
  716. "@type": "WebPage",
  717. "@id": self.get_full_url,
  718. },
  719. }
  720. if self.seo_image:
  721. sd_dict.update({"image": get_struct_data_images(self.seo_image)})
  722. if self.address:
  723. sd_dict.update({
  724. "location": {
  725. "@type": "Place",
  726. "name": self.title,
  727. "address": {
  728. "@type": "PostalAddress",
  729. "streetAddress": self.address,
  730. },
  731. },
  732. })
  733. return sd_dict
  734. @property
  735. def seo_struct_event_json(self) -> str:
  736. return json.dumps(self.seo_struct_event_dict, cls=StructDataEncoder)
  737. def query_occurrences(self, num_of_instances_to_return=None, **kwargs):
  738. """
  739. Returns a list of all upcoming event instances for the specified query.
  740. For more information on what you can query with, visit
  741. https://github.com/gregplaysguitar/django-eventtools
  742. """
  743. event_instances = []
  744. occurrence_kwargs = {
  745. 'from_date': kwargs.get('from_date', timezone.now().date())
  746. }
  747. if 'limit' in kwargs:
  748. if kwargs['limit'] is not None:
  749. # Limit the number of event instances that will be
  750. # generated per occurrence rule to 10, if not otherwise specified.
  751. occurrence_kwargs['limit'] = kwargs.get('limit', 10)
  752. # For each occurrence rule in all of the occurrence rules for this event.
  753. for occurrence in self.occurrences.all():
  754. # Add the qualifying generated event instances to the list.
  755. event_instances += [
  756. instance for instance in occurrence.all_occurrences(**occurrence_kwargs)]
  757. # Sort all the events by the date that they start
  758. event_instances.sort(key=lambda d: d[0])
  759. # Return the event instances, possibly spliced if num_instances_to_return is set.
  760. return event_instances[:num_of_instances_to_return] if num_of_instances_to_return else event_instances # noqa
  761. def convert_to_ical_format(
  762. self,
  763. dt_start: datetime = None,
  764. dt_end: datetime = None,
  765. occurrence=None,
  766. ):
  767. ical_event = ICalEvent()
  768. ical_event.add('summary', self.title)
  769. # needs to get full page url, not just slug
  770. desc_str = _('Details')
  771. ical_event.add('dtstamp', timezone.now())
  772. ical_event.add('description', f'{desc_str}: {self.full_url}')
  773. # NOTE: The use of the url for the id is technically breaking the iCal standard,
  774. # which recommends against use of identifiable info:
  775. # https://icalendar.org/New-Properties-for-iCalendar-RFC-7986/5-3-uid-property.html
  776. # If this breaks in the future,
  777. # implementing a uuid field on the object is probably necessary.
  778. ical_event.add('uid', self.get_full_url())
  779. if self.address:
  780. ical_event.add('location', self.address)
  781. if dt_start:
  782. # Convert to utc to remove timezone confusion
  783. dt_start = dt_start.astimezone(pytz.utc)
  784. ical_event.add('dtstart', dt_start)
  785. if dt_end:
  786. # Convert to utc to remove timezone confusion
  787. dt_end = dt_end.astimezone(pytz.utc)
  788. ical_event.add('dtend', dt_end)
  789. # Add a reminder alarm
  790. reminder_mins = 15
  791. alarm = Alarm()
  792. alarm.add("action", "DISPLAY")
  793. alarm.add('description', "Reminder")
  794. # Sets the reminder alarm
  795. alarm.add("TRIGGER;RELATED=START", "-PT{0}M".format(reminder_mins))
  796. ical_event.add_component(alarm)
  797. if occurrence:
  798. freq = occurrence.repeat.split(":")[1] if occurrence.repeat else None
  799. repeat_until = occurrence.repeat_until.strftime(
  800. "%Y%m%dT000000Z") if occurrence.repeat_until else None
  801. ical_event.add('dtstart', occurrence.start)
  802. if occurrence.end:
  803. ical_event.add('dtend', occurrence.end)
  804. if freq:
  805. ical_event.add('RRULE', freq, encode=False)
  806. if repeat_until:
  807. ical_event.add('until', repeat_until)
  808. return ical_event
  809. def create_single_ical(self, dt_start, dt_end=None):
  810. return self.convert_to_ical_format(dt_start=dt_start, dt_end=dt_end)
  811. def create_recurring_ical(self):
  812. events = []
  813. for occurrence in self.occurrences.all():
  814. events.append(self.convert_to_ical_format(occurrence=occurrence))
  815. return events
  816. class CoderedEventIndexPage(CoderedWebPage):
  817. """
  818. Shows a list of event sub-pages.
  819. """
  820. class Meta:
  821. verbose_name = _('CodeRed Event Index Page')
  822. abstract = True
  823. class CalendarViews(models.TextChoices):
  824. NONE = '', _('No calendar')
  825. MONTH = 'month', _('Month')
  826. AGENDA_WEEK = 'agendaWeek', _('Week')
  827. AGENDA_DAY = 'agendaDay', _('Day')
  828. LIST_MONTH = 'listMonth', _('List of events')
  829. class EventStyles(models.TextChoices):
  830. DEFAULT = '', _('Default')
  831. BLOCK = 'block', _('Solid rectangles')
  832. DOT = 'list-item', _('Dots with labels')
  833. template = 'wagtailcrx/pages/event_index_page.html'
  834. index_show_subpages_default = True
  835. index_order_by_default = 'next_occurrence'
  836. index_order_by_choices = (
  837. ('next_occurrence', 'Display next occurrence, soonest first'),
  838. ) + CoderedWebPage.index_order_by_choices
  839. default_calendar_view = models.CharField(
  840. blank=True,
  841. choices=CalendarViews.choices,
  842. default=CalendarViews.MONTH,
  843. max_length=255,
  844. verbose_name=_('Calendar Style'),
  845. help_text=_('The default look of the calendar on this page.')
  846. )
  847. event_style = models.CharField(
  848. blank=True,
  849. choices=EventStyles.choices,
  850. default=EventStyles.DEFAULT,
  851. max_length=255,
  852. verbose_name=_('Event Style'),
  853. help_text=_('How events look on the calendar.')
  854. )
  855. layout_panels = CoderedWebPage.layout_panels + [
  856. MultiFieldPanel(
  857. [
  858. FieldPanel('default_calendar_view'),
  859. FieldPanel('event_style'),
  860. ],
  861. heading=_('Calendar Style'),
  862. )
  863. ]
  864. @property
  865. def fullcalendar_view(self) -> str:
  866. """
  867. Translate calendar views to fullcalendar.js identifiers.
  868. """
  869. return {
  870. self.CalendarViews.NONE: '',
  871. self.CalendarViews.MONTH: 'dayGridMonth',
  872. self.CalendarViews.AGENDA_WEEK: 'timeGridWeek',
  873. self.CalendarViews.AGENDA_DAY: 'timeGridDay',
  874. self.CalendarViews.LIST_MONTH: 'listMonth',
  875. }[self.default_calendar_view]
  876. @property
  877. def fullcalendar_event_display(self) -> str:
  878. """
  879. Translate event display styles to fullcalendar.js identifiers.
  880. """
  881. return {
  882. self.EventStyles.DEFAULT: 'auto',
  883. self.EventStyles.BLOCK: 'block',
  884. self.EventStyles.DOT: 'list-item',
  885. }[self.event_style]
  886. def get_index_children(self):
  887. if self.index_query_pagemodel and self.index_order_by == 'next_occurrence':
  888. querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
  889. qs = querymodel.objects.child_of(self).live()
  890. # filter out events that don't have a next_occurrence
  891. upcoming = []
  892. for event in qs.all():
  893. if event.next_occurrence():
  894. upcoming.append(event)
  895. # Sort the events by next_occurrence start date.
  896. return sorted(upcoming, key=lambda e: e.next_occurrence()[0])
  897. return super().get_index_children()
  898. def get_calendar_events(
  899. self,
  900. start: Union[datetime, date],
  901. end: Union[datetime, date]
  902. ) -> List[Dict[str, str]]:
  903. """
  904. Returns a list of event occurrences as dictionaries with times
  905. converted to Django TIME_ZONE settings.
  906. """
  907. # start with all child events, regardless of get_index_children rules.
  908. querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
  909. qs = querymodel.objects.child_of(self).live()
  910. event_instances = []
  911. for event in qs:
  912. occurrences = event.query_occurrences(limit=None, from_date=start, to_date=end)
  913. for occurrence in occurrences:
  914. local_start = timezone.localtime(value=occurrence[0])
  915. local_end = None
  916. if occurrence[1]:
  917. local_end = timezone.localtime(value=occurrence[1])
  918. event_data = {
  919. 'title': event.title,
  920. 'start': local_start.strftime('%Y-%m-%dT%H:%M:%S%z'),
  921. 'end': local_end.strftime('%Y-%m-%dT%H:%M:%S%z') if local_end else "",
  922. 'description': "",
  923. }
  924. if event.url:
  925. event_data['url'] = event.url
  926. if event.calendar_color:
  927. event_data['color'] = event.calendar_color
  928. event_instances.append(event_data)
  929. return event_instances
  930. class CoderedEventOccurrence(Orderable, BaseOccurrence):
  931. class Meta(Orderable.Meta):
  932. verbose_name = _('CodeRed Event Occurrence')
  933. abstract = True
  934. class CoderedFormMixin(models.Model):
  935. class Meta:
  936. abstract = True
  937. submissions_list_view_class = CoderedSubmissionsListView
  938. encoder = DjangoJSONEncoder
  939. # Custom codered fields
  940. to_address = models.CharField(
  941. max_length=255,
  942. blank=True,
  943. verbose_name=_('Email form submissions to'),
  944. help_text=_('Optional - email form submissions to this address. Separate multiple addresses by comma.'), # noqa
  945. )
  946. reply_address = models.CharField(
  947. max_length=255,
  948. blank=True,
  949. verbose_name=_('Reply-to address'),
  950. help_text=_('Optional - to reply to the submitter, specify the email field here. For example, if a form field above is labeled "Your Email", enter: {{ your_email }}'), # noqa
  951. )
  952. subject = models.CharField(
  953. max_length=255,
  954. blank=True,
  955. verbose_name=_('Subject'),
  956. )
  957. save_to_database = models.BooleanField(
  958. default=True,
  959. verbose_name=_('Save form submissions'),
  960. help_text=_('Submissions are saved to database and can be exported at any time.')
  961. )
  962. thank_you_page = models.ForeignKey(
  963. 'wagtailcore.Page',
  964. null=True,
  965. blank=True,
  966. on_delete=models.SET_NULL,
  967. related_name='+',
  968. verbose_name=_('Thank you page'),
  969. help_text=_('The page users are redirected to after submitting the form.'),
  970. )
  971. button_text = models.CharField(
  972. max_length=255,
  973. default=_('Submit'),
  974. verbose_name=_('Button text'),
  975. )
  976. button_style = models.CharField(
  977. blank=True,
  978. choices=crx_settings.CRX_FRONTEND_BTN_STYLE_CHOICES,
  979. default=crx_settings.CRX_FRONTEND_BTN_STYLE_DEFAULT,
  980. max_length=255,
  981. verbose_name=_('Button style'),
  982. )
  983. button_size = models.CharField(
  984. blank=True,
  985. choices=crx_settings.CRX_FRONTEND_BTN_SIZE_CHOICES,
  986. default=crx_settings.CRX_FRONTEND_BTN_SIZE_DEFAULT,
  987. max_length=255,
  988. verbose_name=_('Button Size'),
  989. )
  990. button_css_class = models.CharField(
  991. max_length=255,
  992. blank=True,
  993. verbose_name=_('Button CSS class'),
  994. help_text=_('Custom CSS class applied to the submit button.'),
  995. )
  996. form_css_class = models.CharField(
  997. max_length=255,
  998. blank=True,
  999. verbose_name=_('Form CSS Class'),
  1000. help_text=_('Custom CSS class applied to <form> element.'),
  1001. )
  1002. form_id = models.CharField(
  1003. max_length=255,
  1004. blank=True,
  1005. verbose_name=_('Form ID'),
  1006. help_text=_('Custom ID applied to <form> element.'),
  1007. )
  1008. form_golive_at = models.DateTimeField(
  1009. blank=True,
  1010. null=True,
  1011. verbose_name=_('Form go live date/time'),
  1012. help_text=_('Date and time when the FORM goes live on the page.'),
  1013. )
  1014. form_expire_at = models.DateTimeField(
  1015. blank=True,
  1016. null=True,
  1017. verbose_name=_('Form expiry date/time'),
  1018. help_text=_('Date and time when the FORM will no longer be available on the page.'),
  1019. )
  1020. spam_protection = models.BooleanField(
  1021. default=True,
  1022. verbose_name=_('Spam Protection'),
  1023. help_text=_('When enabled, the CMS will filter out spam form submissions for this page.')
  1024. )
  1025. body_content_panels = [
  1026. MultiFieldPanel(
  1027. [
  1028. FieldPanel('thank_you_page'),
  1029. FieldPanel('button_text'),
  1030. FieldPanel('button_style'),
  1031. FieldPanel('button_size'),
  1032. FieldPanel('button_css_class'),
  1033. FieldPanel('form_css_class'),
  1034. FieldPanel('form_id'),
  1035. ],
  1036. _('Form Settings')
  1037. ),
  1038. MultiFieldPanel(
  1039. [
  1040. FieldPanel('save_to_database'),
  1041. FieldPanel('to_address'),
  1042. FieldPanel('reply_address'),
  1043. FieldPanel('subject'),
  1044. ],
  1045. _('Form Submissions')
  1046. ),
  1047. ]
  1048. settings_panels = [
  1049. MultiFieldPanel(
  1050. [
  1051. FieldRowPanel(
  1052. [
  1053. FieldPanel('form_golive_at'),
  1054. FieldPanel('form_expire_at'),
  1055. ],
  1056. classname='label-above',
  1057. ),
  1058. ],
  1059. _('Form Scheduled Publishing'),
  1060. ),
  1061. FieldPanel('spam_protection')
  1062. ]
  1063. @property
  1064. def form_live(self):
  1065. """
  1066. A boolean on whether or not the <form> element should be shown on the page.
  1067. """
  1068. return (self.form_golive_at is None or self.form_golive_at <= timezone.now()) and \
  1069. (self.form_expire_at is None or self.form_expire_at >= timezone.now())
  1070. def get_landing_page_template(self, request, *args, **kwargs):
  1071. return self.landing_page_template
  1072. def process_data(self, form, request):
  1073. processed_data = {}
  1074. # Handle file uploads
  1075. for key, val in form.cleaned_data.items():
  1076. if type(val) == InMemoryUploadedFile or type(val) == TemporaryUploadedFile:
  1077. # Save the file and get its URL
  1078. # Custom code to ensure that anonymous users get a session key.
  1079. if not request.session.session_key:
  1080. request.session.create()
  1081. directory = request.session.session_key
  1082. storage = self.get_storage()
  1083. Path(storage.path(directory)).mkdir(parents=True,
  1084. exist_ok=True)
  1085. path = storage.get_available_name(
  1086. str(Path(directory) / val.name))
  1087. with storage.open(path, 'wb+') as destination:
  1088. for chunk in val.chunks():
  1089. destination.write(chunk)
  1090. processed_data[key] = "{0}{1}".format(crx_settings.CRX_PROTECTED_MEDIA_URL, path)
  1091. else:
  1092. processed_data[key] = val
  1093. return processed_data
  1094. def get_storage(self):
  1095. return FileSystemStorage(
  1096. location=crx_settings.CRX_PROTECTED_MEDIA_ROOT,
  1097. base_url=crx_settings.CRX_PROTECTED_MEDIA_URL
  1098. )
  1099. def process_form_submission(self, request, form, form_submission, processed_data):
  1100. # Save to database
  1101. if self.save_to_database:
  1102. form_submission.save()
  1103. # Send the mails
  1104. if self.to_address:
  1105. self.send_summary_mail(request, form, processed_data)
  1106. if self.confirmation_emails:
  1107. # Convert form data into a context.
  1108. context = Context(self.data_to_dict(processed_data, request))
  1109. # Render emails as if they are django templates.
  1110. for email in self.confirmation_emails.all():
  1111. # Build email message parameters.
  1112. message_args = {}
  1113. # From
  1114. if email.from_address:
  1115. template_from_email = Template(email.from_address)
  1116. message_args['from_email'] = template_from_email.render(context)
  1117. else:
  1118. genemail = GeneralSettings.for_request(request).from_email_address
  1119. if genemail:
  1120. message_args['from_email'] = genemail
  1121. # Reply-to
  1122. if email.reply_address:
  1123. template_reply_to = Template(email.reply_address)
  1124. message_args['reply_to'] = template_reply_to.render(context).split(',')
  1125. # CC
  1126. if email.cc_address:
  1127. template_cc = Template(email.cc_address)
  1128. message_args['cc'] = template_cc.render(context).split(',')
  1129. # BCC
  1130. if email.bcc_address:
  1131. template_bcc = Template(email.bcc_address)
  1132. message_args['bcc'] = template_bcc.render(context).split(',')
  1133. # Subject
  1134. if email.subject:
  1135. template_subject = Template(email.subject)
  1136. message_args['subject'] = template_subject.render(context)
  1137. else:
  1138. message_args['subject'] = self.title
  1139. # Body
  1140. template_body = Template(email.body)
  1141. message_args['body'] = template_body.render(context)
  1142. # To
  1143. template_to = Template(email.to_address)
  1144. message_args['to'] = template_to.render(context).split(',')
  1145. # Send email
  1146. self.send_mail(request, message_args, 'html')
  1147. for fn in hooks.get_hooks('form_page_submit'):
  1148. fn(instance=self, form_submission=form_submission)
  1149. def send_summary_mail(self, request, form, processed_data):
  1150. """
  1151. Sends a form submission summary email.
  1152. """
  1153. addresses = [x.strip() for x in self.to_address.split(',')]
  1154. content = []
  1155. data = self.data_to_dict(processed_data, request)
  1156. for field in form:
  1157. # Get key from form, transform same as data_to_dict() does.
  1158. key = field.html_name.replace('-', '_')
  1159. label = field.label
  1160. value = data.get(key)
  1161. content.append('{0}: {1}'.format(label, value))
  1162. content_str = '\n-------------------- \n'.join(content) + '\n'
  1163. # Build email message parameters
  1164. message_args = {
  1165. 'body': content_str,
  1166. 'to': addresses,
  1167. }
  1168. if self.subject:
  1169. message_args['subject'] = self.subject
  1170. else:
  1171. message_args['subject'] = self.title
  1172. genemail = GeneralSettings.for_request(request).from_email_address
  1173. if genemail:
  1174. message_args['from_email'] = genemail
  1175. if self.reply_address:
  1176. # Render reply-to field using form submission as context.
  1177. context = Context(data)
  1178. template_reply_to = Template(self.reply_address)
  1179. message_args['reply_to'] = template_reply_to.render(context).split(',')
  1180. # Send email
  1181. self.send_mail(request, message_args)
  1182. def send_mail(
  1183. self,
  1184. resuest,
  1185. message_args: dict,
  1186. content_subtype: Optional[str] = None
  1187. ):
  1188. """
  1189. Utility to send email messages from form submissions.
  1190. You can override this method to have the form send mail in a different
  1191. way or using a different backend as needed.
  1192. """
  1193. message = EmailMessage(**message_args)
  1194. if content_subtype:
  1195. message.content_subtype = content_subtype
  1196. message.send()
  1197. def render_landing_page(self, request, form_submission=None):
  1198. """
  1199. Renders the landing page.
  1200. You can override this method to return a different HttpResponse as
  1201. landing page. E.g. you could return a redirect to a separate page.
  1202. """
  1203. if self.thank_you_page:
  1204. return redirect(self.thank_you_page.url)
  1205. context = self.get_context(request)
  1206. context['form_submission'] = form_submission
  1207. response = render(
  1208. request,
  1209. self.get_landing_page_template(request),
  1210. context
  1211. )
  1212. return response
  1213. def data_to_dict(self, processed_data, request):
  1214. """
  1215. Converts processed form data into a dictionary suitable
  1216. for rendering in a context.
  1217. """
  1218. dictionary = {}
  1219. for key, value in processed_data.items():
  1220. new_key = key.replace('-', '_')
  1221. if isinstance(value, list):
  1222. dictionary[new_key] = ', '.join(value)
  1223. else:
  1224. dictionary[new_key] = utils.attempt_protected_media_value_conversion(request, value)
  1225. return dictionary
  1226. preview_modes = [
  1227. ('form', _('Form')),
  1228. ('landing', _('Thank you page')),
  1229. ]
  1230. def serve_preview(self, request, mode):
  1231. if mode == 'landing':
  1232. request.is_preview = True
  1233. return self.render_landing_page(request)
  1234. return super().serve_preview(request, mode)
  1235. def serve_submissions_list_view(self, request, *args, **kwargs):
  1236. """
  1237. Returns list submissions view for admin.
  1238. `list_submissions_view_class` can be set to provide custom view class.
  1239. Your class must be inherited from SubmissionsListView.
  1240. """
  1241. view = self.submissions_list_view_class.as_view()
  1242. return view(request, form_page=self, *args, **kwargs)
  1243. def get_form(self, request, *args, **kwargs):
  1244. form_class = self.get_form_class()
  1245. form_params = self.get_form_parameters()
  1246. form_params.update(kwargs)
  1247. if request.method == 'POST':
  1248. return form_class(request.POST, request.FILES, *args, **form_params)
  1249. return form_class(*args, **form_params)
  1250. def contains_spam(self, request):
  1251. """
  1252. Checks to see if the spam honeypot was filled out.
  1253. """
  1254. if request.POST.get("cr-decoy-comments", None):
  1255. return True
  1256. return False
  1257. def process_spam_request(self, form, request):
  1258. """
  1259. Called when spam is found in the request.
  1260. """
  1261. messages.error(request, self.get_spam_message())
  1262. logger.info("Detected spam submission on page: {0}\n{1}".format(self.title, vars(request)))
  1263. return self.process_form_get(form, request)
  1264. def get_spam_message(self):
  1265. return _("There was an error while processing your submission. Please try again.")
  1266. def process_form_post(self, form, request):
  1267. if form.is_valid():
  1268. processed_data = self.process_data(form, request)
  1269. form_submission = self.get_submission_class()(
  1270. form_data=json.dumps(processed_data, cls=self.encoder),
  1271. page=self,
  1272. )
  1273. self.process_form_submission(
  1274. request=request,
  1275. form=form,
  1276. form_submission=form_submission,
  1277. processed_data=processed_data)
  1278. return self.render_landing_page(request, form_submission)
  1279. return self.process_form_get(form, request)
  1280. def process_form_get(self, form, request):
  1281. context = self.get_context(request)
  1282. context['form'] = form
  1283. response = render(
  1284. request,
  1285. self.get_template(request),
  1286. context
  1287. )
  1288. return response
  1289. def serve(self, request, *args, **kwargs):
  1290. form = self.get_form(request, page=self, user=request.user)
  1291. if request.method == 'POST':
  1292. if self.spam_protection and self.contains_spam(request):
  1293. return self.process_spam_request(form, request)
  1294. return self.process_form_post(form, request)
  1295. return self.process_form_get(form, request)
  1296. class CoderedFormPage(CoderedFormMixin, CoderedWebPage):
  1297. """
  1298. This is basically a clone of wagtail.contrib.forms.models.AbstractForm
  1299. with changes in functionality and extending CoderedWebPage vs wagtailcore.Page.
  1300. """
  1301. class Meta:
  1302. verbose_name = _('CodeRed Form Page')
  1303. abstract = True
  1304. template = 'wagtailcrx/pages/form_page.html'
  1305. landing_page_template = 'wagtailcrx/pages/form_page_landing.html'
  1306. base_form_class = WagtailAdminFormPageForm
  1307. form_builder = CoderedFormBuilder
  1308. body_content_panels = [
  1309. InlinePanel('form_fields', label="Form fields"),
  1310. ] + \
  1311. CoderedWebPage.body_content_panels + \
  1312. CoderedFormMixin.body_content_panels + [
  1313. FormSubmissionsPanel(),
  1314. InlinePanel('confirmation_emails', label=_('Confirmation Emails'))
  1315. ]
  1316. settings_panels = CoderedPage.settings_panels + CoderedFormMixin.settings_panels
  1317. def __init__(self, *args, **kwargs):
  1318. super().__init__(*args, **kwargs)
  1319. if not hasattr(self, 'landing_page_template'):
  1320. name, ext = os.path.splitext(self.template)
  1321. self.landing_page_template = name + '_landing' + ext
  1322. def get_form_fields(self):
  1323. """
  1324. Form page expects `form_fields` to be declared.
  1325. If you want to change backwards relation name,
  1326. you need to override this method.
  1327. """
  1328. return self.form_fields.all()
  1329. def get_data_fields(self):
  1330. """
  1331. Returns a list of tuples with (field_name, field_label).
  1332. """
  1333. data_fields = [
  1334. ('submit_time', _('Submission date')),
  1335. ]
  1336. data_fields += [
  1337. (field.clean_name, field.label)
  1338. for field in self.get_form_fields()
  1339. ]
  1340. return data_fields
  1341. def get_form_class(self):
  1342. fb = self.form_builder(self.get_form_fields())
  1343. return fb.get_form_class()
  1344. def get_form_parameters(self):
  1345. return {}
  1346. def get_submission_class(self):
  1347. """
  1348. Returns submission class.
  1349. You can override this method to provide custom submission class.
  1350. Your class must be inherited from AbstractFormSubmission.
  1351. """
  1352. return FormSubmission
  1353. class CoderedSubmissionRevision(SubmissionRevision, models.Model):
  1354. pass
  1355. class CoderedSessionFormSubmission(SessionFormSubmission):
  1356. INCOMPLETE = 'incomplete'
  1357. COMPLETE = 'complete'
  1358. REVIEWED = 'reviewed'
  1359. APPROVED = 'approved'
  1360. REJECTED = 'rejected'
  1361. STATUSES = (
  1362. (INCOMPLETE, _('Not submitted')),
  1363. (COMPLETE, _('Complete')),
  1364. (REVIEWED, _('Under consideration')),
  1365. (APPROVED, _('Approved')),
  1366. (REJECTED, _('Rejected')),
  1367. )
  1368. status = models.CharField(max_length=10, choices=STATUSES, default=INCOMPLETE)
  1369. def create_normal_submission(self, delete_self=True):
  1370. submission_data = self.get_data()
  1371. if 'user' in submission_data:
  1372. submission_data['user'] = str(submission_data['user'])
  1373. submission = FormSubmission.objects.create(
  1374. form_data=json.dumps(submission_data, cls=StreamFormJSONEncoder),
  1375. page=self.page
  1376. )
  1377. if delete_self:
  1378. CoderedSubmissionRevision.objects.filter(submission_id=self.id).delete()
  1379. self.delete()
  1380. return submission
  1381. def render_email(self, value):
  1382. return value
  1383. def render_link(self, value):
  1384. return "{0}{1}".format(crx_settings.CRX_PROTECTED_MEDIA_URL, value)
  1385. def render_image(self, value):
  1386. return "{0}{1}".format(crx_settings.CRX_PROTECTED_MEDIA_URL, value)
  1387. def render_file(self, value):
  1388. return "{0}{1}".format(crx_settings.CRX_PROTECTED_MEDIA_URL, value)
  1389. @receiver(post_save)
  1390. def create_submission_changed_revision(sender, **kwargs):
  1391. if not issubclass(sender, SessionFormSubmission):
  1392. return
  1393. submission = kwargs['instance']
  1394. created = kwargs['created']
  1395. CoderedSubmissionRevision.create_from_submission(
  1396. submission, (CoderedSubmissionRevision.CREATED if created else CoderedSubmissionRevision.CHANGED)) # noqa
  1397. @receiver(post_delete)
  1398. def create_submission_deleted_revision(sender, **kwargs):
  1399. if not issubclass(sender, CoderedSessionFormSubmission):
  1400. return
  1401. submission = kwargs['instance']
  1402. CoderedSubmissionRevision.create_from_submission(submission, SubmissionRevision.DELETED) # noqa
  1403. class CoderedStep(Step):
  1404. def get_markups_and_bound_fields(self, form):
  1405. for struct_child in self.form_fields:
  1406. block = struct_child.block
  1407. if isinstance(block, FormFieldBlock):
  1408. struct_value = struct_child.value
  1409. field_name = block.get_slug(struct_value)
  1410. yield form[field_name], 'field', struct_child
  1411. else:
  1412. yield mark_safe(struct_child), 'markup'
  1413. class CoderedSteps(Steps):
  1414. def __init__(self, page, request=None):
  1415. self.page = page
  1416. # TODO: Make it possible to change the `form_fields` attribute.
  1417. self.form_fields = page.form_fields
  1418. self.request = request
  1419. has_steps = any(isinstance(struct_child.block, FormStepBlock)
  1420. for struct_child in self.form_fields)
  1421. if has_steps:
  1422. steps = [CoderedStep(self, i, form_field)
  1423. for i, form_field in enumerate(self.form_fields)]
  1424. else:
  1425. steps = [CoderedStep(self, 0, self.form_fields)]
  1426. super(Steps, self).__init__(steps)
  1427. class CoderedStreamFormMixin(StreamFormMixin):
  1428. class Meta:
  1429. abstract = True
  1430. def get_steps(self, request=None):
  1431. if not hasattr(self, 'steps'):
  1432. steps = CoderedSteps(self, request=request)
  1433. if request is None:
  1434. return steps
  1435. self.steps = steps
  1436. return self.steps
  1437. @staticmethod
  1438. def get_submission_class():
  1439. return FormSubmission
  1440. @staticmethod
  1441. def get_session_submission_class():
  1442. return CoderedSessionFormSubmission
  1443. def get_submission(self, request):
  1444. Submission = self.get_session_submission_class()
  1445. if request.user.is_authenticated:
  1446. user_submission = Submission.objects.filter(
  1447. user=request.user, page=self).order_by('-pk').first()
  1448. if user_submission is None:
  1449. return Submission(user=request.user, page=self, form_data='[]')
  1450. return user_submission
  1451. # Custom code to ensure that anonymous users get a session key.
  1452. if not request.session.session_key:
  1453. request.session.create()
  1454. user_submission = Submission.objects.filter(
  1455. session_key=request.session.session_key, page=self
  1456. ).order_by('-pk').first()
  1457. if user_submission is None:
  1458. return Submission(session_key=request.session.session_key,
  1459. page=self, form_data='[]')
  1460. return user_submission
  1461. class CoderedStreamFormPage(CoderedFormMixin, CoderedStreamFormMixin, CoderedWebPage):
  1462. class Meta:
  1463. verbose_name = _('CodeRed Advanced Form Page')
  1464. abstract = True
  1465. template = 'wagtailcrx/pages/stream_form_page.html'
  1466. landing_page_template = 'wagtailcrx/pages/form_page_landing.html'
  1467. form_fields = StreamField(
  1468. STREAMFORM_BLOCKS,
  1469. use_json_field=True,
  1470. )
  1471. encoder = StreamFormJSONEncoder
  1472. body_content_panels = [
  1473. FieldPanel('form_fields')
  1474. ] + \
  1475. CoderedFormMixin.body_content_panels + [
  1476. InlinePanel('confirmation_emails', label=_('Confirmation Emails'))
  1477. ]
  1478. def process_form_post(self, form, request):
  1479. if form.is_valid():
  1480. is_complete = self.steps.update_data()
  1481. if is_complete:
  1482. submission = self.get_submission(request)
  1483. self.process_form_submission(
  1484. request=request,
  1485. form=form,
  1486. form_submission=submission,
  1487. processed_data=submission.get_data()
  1488. )
  1489. normal_submission = submission.create_normal_submission()
  1490. return self.render_landing_page(request, normal_submission)
  1491. return HttpResponseRedirect(self.url)
  1492. return self.process_form_get(form, request)
  1493. def process_form_get(self, form, request):
  1494. return CoderedWebPage.serve(self, request)
  1495. def get_form(self, request, *args, **kwargs):
  1496. return self.get_context(request)['form']
  1497. def get_storage(self):
  1498. return FileSystemStorage(
  1499. location=crx_settings.CRX_PROTECTED_MEDIA_ROOT,
  1500. base_url=crx_settings.CRX_PROTECTED_MEDIA_URL
  1501. )
  1502. class CoderedLocationPage(CoderedWebPage):
  1503. """
  1504. Location, suitable for store locations or help centers.
  1505. """
  1506. class Meta:
  1507. verbose_name = _('CodeRed Location')
  1508. abstract = True
  1509. template = 'wagtailcrx/pages/location_page.html'
  1510. # Override body to provide simpler content
  1511. body = StreamField(
  1512. CONTENT_STREAMBLOCKS,
  1513. null=True,
  1514. blank=True,
  1515. use_json_field=True,
  1516. )
  1517. address = models.TextField(
  1518. blank=True,
  1519. verbose_name=_("Address")
  1520. )
  1521. latitude = models.FloatField(
  1522. blank=True,
  1523. null=True,
  1524. verbose_name=_("Latitude")
  1525. )
  1526. longitude = models.FloatField(
  1527. blank=True,
  1528. null=True,
  1529. verbose_name=_("Longitude")
  1530. )
  1531. auto_update_latlng = models.BooleanField(
  1532. default=True,
  1533. verbose_name=_("Auto Update Latitude and Longitude"),
  1534. help_text=_("If checked, automatically update the latitude and longitude when the address is updated.") # noqa
  1535. )
  1536. map_title = models.CharField(
  1537. blank=True,
  1538. max_length=255,
  1539. verbose_name=_("Map Title"),
  1540. help_text=_("If this is filled out, this is the title that will be used on the map.")
  1541. )
  1542. map_description = models.CharField(
  1543. blank=True,
  1544. max_length=255,
  1545. verbose_name=_("Map Description"),
  1546. help_text=_("If this is filled out, this is the description that will be used on the map.")
  1547. )
  1548. website = models.TextField(
  1549. blank=True,
  1550. verbose_name=_("Website")
  1551. )
  1552. phone_number = models.CharField(
  1553. blank=True,
  1554. max_length=255,
  1555. verbose_name=_("Phone Number")
  1556. )
  1557. content_panels = CoderedWebPage.content_panels + [
  1558. FieldPanel('address'),
  1559. FieldPanel('website'),
  1560. FieldPanel('phone_number'),
  1561. ]
  1562. layout_panels = CoderedWebPage.layout_panels + [
  1563. MultiFieldPanel(
  1564. [
  1565. FieldPanel('map_title'),
  1566. FieldPanel('map_description'),
  1567. ],
  1568. heading=_('Map Layout')
  1569. ),
  1570. ]
  1571. settings_panels = CoderedWebPage.settings_panels + [
  1572. MultiFieldPanel(
  1573. [
  1574. FieldPanel('auto_update_latlng'),
  1575. FieldPanel('latitude'),
  1576. FieldPanel('longitude'),
  1577. ],
  1578. heading=_("Location Settings")
  1579. ),
  1580. ]
  1581. @property
  1582. def geojson_name(self):
  1583. return self.map_title or self.title
  1584. @property
  1585. def geojson_description(self):
  1586. return self.map_description
  1587. @property
  1588. def render_pin_description(self):
  1589. return render_to_string(
  1590. 'wagtailcrx/includes/map_pin_description.html',
  1591. {
  1592. 'page': self
  1593. }
  1594. )
  1595. @property
  1596. def render_list_description(self):
  1597. return render_to_string(
  1598. 'wagtailcrx/includes/map_list_description.html',
  1599. {
  1600. 'page': self
  1601. }
  1602. )
  1603. def to_geojson(self):
  1604. return {
  1605. "type": "Feature",
  1606. "geometry": {
  1607. "type": "Point",
  1608. "coordinates": [self.longitude, self.latitude]
  1609. },
  1610. "properties": {
  1611. "list_description": self.render_list_description,
  1612. "pin_description": self.render_pin_description
  1613. }
  1614. }
  1615. def save(self, *args, **kwargs):
  1616. if self.auto_update_latlng and GoogleApiSettings.for_site(
  1617. Site.objects.get(is_default_site=True)
  1618. ).google_maps_api_key:
  1619. try:
  1620. g = geocoder.google(self.address, key=GoogleApiSettings.for_site(
  1621. Site.objects.get(is_default_site=True)
  1622. ).google_maps_api_key)
  1623. self.latitude = g.latlng[0]
  1624. self.longitude = g.latlng[1]
  1625. except TypeError:
  1626. # Raised if google denied the request
  1627. pass
  1628. return super(CoderedLocationPage, self).save(*args, **kwargs)
  1629. def get_context(self, request, *args, **kwargs):
  1630. context = super().get_context(request)
  1631. context['google_api_key'] = GoogleApiSettings.for_site(
  1632. Site.objects.get(is_default_site=True)
  1633. ).google_maps_api_key
  1634. return context
  1635. class CoderedLocationIndexPage(CoderedWebPage):
  1636. """
  1637. Shows a map view of the children CoderedLocationPage.
  1638. """
  1639. class Meta:
  1640. verbose_name = _('CodeRed Location Index Page')
  1641. abstract = True
  1642. template = 'wagtailcrx/pages/location_index_page.html'
  1643. index_show_subpages_default = True
  1644. center_latitude = models.FloatField(
  1645. null=True,
  1646. blank=True,
  1647. help_text=_('The default latitude you want the map set to.'),
  1648. default=0
  1649. )
  1650. center_longitude = models.FloatField(
  1651. null=True,
  1652. blank=True,
  1653. help_text=_('The default longitude you want the map set to.'),
  1654. default=0
  1655. )
  1656. zoom = models.IntegerField(
  1657. default=8,
  1658. validators=[
  1659. MaxValueValidator(20),
  1660. MinValueValidator(1),
  1661. ],
  1662. help_text=_("Requires API key to use zoom. 1: World, 5: Landmass/continent, 10: City, 15: Streets, 20: Buildings") # noqa
  1663. )
  1664. layout_panels = CoderedWebPage.layout_panels + [
  1665. MultiFieldPanel(
  1666. [
  1667. FieldPanel('center_latitude'),
  1668. FieldPanel('center_longitude'),
  1669. FieldPanel('zoom'),
  1670. ],
  1671. heading=_('Map Display')
  1672. ),
  1673. ]
  1674. def geojson_data(self, viewport=None):
  1675. """
  1676. function that will return all locations under this index as geoJSON compliant data.
  1677. It is filtered by a latitude/longitude viewport if given.
  1678. viewport is a string in the format of :
  1679. 'southwest.latitude,southwest.longitude|northeast.latitude,northeast.longitude'
  1680. An example viewport that covers Cleveland, OH would look like this:
  1681. '41.354912150983964,-81.95331736661791|41.663427748126935,-81.45206614591478'
  1682. """
  1683. qs = self.get_index_children().live()
  1684. if viewport:
  1685. southwest, northeast = viewport.split('|')
  1686. southwest = [float(x) for x in southwest.split(',')]
  1687. northeast = [float(x) for x in northeast.split(',')]
  1688. qs = qs.filter(
  1689. latitude__gte=southwest[0],
  1690. latitude__lte=northeast[0],
  1691. longitude__gte=southwest[1],
  1692. longitude__lte=northeast[1]
  1693. )
  1694. return {
  1695. "type": "FeatureCollection",
  1696. "features": [
  1697. location.to_geojson() for location in qs
  1698. ]
  1699. }
  1700. def serve(self, request, *args, **kwargs):
  1701. data_format = request.GET.get('data-format', None)
  1702. if data_format == 'geojson':
  1703. return self.serve_geojson(request, *args, **kwargs)
  1704. return super().serve(request, *args, **kwargs)
  1705. def serve_geojson(self, request, *args, **kwargs):
  1706. viewport = request.GET.get('viewport', None)
  1707. return JsonResponse(self.geojson_data(viewport=viewport))
  1708. def get_context(self, request, *args, **kwargs):
  1709. context = super().get_context(request)
  1710. context['google_api_key'] = GoogleApiSettings.for_site(
  1711. Site.objects.get(is_default_site=True)
  1712. ).google_maps_api_key
  1713. return context