page_models.py 61 KB

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