page_models.py 72 KB

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