wagtailsettings_models.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. """
  2. Custom wagtail settings used by Wagtail CRX.
  3. Settings are user-configurable on a per-site basis (multisite).
  4. Global project or developer settings should be defined in coderedcms.settings.py .
  5. """
  6. from django.core.exceptions import ValidationError
  7. from django.db import models
  8. from django.utils.translation import gettext_lazy as _
  9. from modelcluster.fields import ParentalKey
  10. from modelcluster.models import ClusterableModel
  11. from wagtail.admin.panels import FieldPanel
  12. from wagtail.admin.panels import HelpPanel
  13. from wagtail.admin.panels import InlinePanel
  14. from wagtail.admin.panels import MultiFieldPanel
  15. from wagtail.contrib.settings.models import BaseSiteSetting
  16. from wagtail.contrib.settings.models import register_setting
  17. from wagtail.images import get_image_model_string
  18. from wagtail.models import Orderable
  19. from coderedcms.fields import MonospaceField
  20. from coderedcms.models.snippet_models import Footer
  21. from coderedcms.models.snippet_models import Navbar
  22. from coderedcms.settings import crx_settings
  23. def maybe_register_setting(disable: bool, **kwargs):
  24. """Decorator that conditionally registers a settings class."""
  25. def check_if_disabled(model):
  26. if not disable:
  27. register_setting(model, **kwargs)
  28. return model
  29. return check_if_disabled
  30. @maybe_register_setting(crx_settings.CRX_DISABLE_LAYOUT, icon="cr-desktop")
  31. class LayoutSettings(ClusterableModel, BaseSiteSetting):
  32. """
  33. Branding, navbar, and theme settings.
  34. """
  35. class Meta:
  36. verbose_name = _("CRX Settings")
  37. class SpamService(models.TextChoices):
  38. NONE = ("", _("None"))
  39. HONEYPOT = ("honeypot", _("Basic - honeypot technique"))
  40. RECAPTCHA_V3 = (
  41. "recaptcha3",
  42. _("reCAPTCHA v3 - Invisible (requires API key)"),
  43. )
  44. RECAPTCHA_V2 = (
  45. "recaptcha2",
  46. _("reCAPTCHA v2 - I am not a robot (requires API key)"),
  47. )
  48. logo = models.ForeignKey(
  49. get_image_model_string(),
  50. null=True,
  51. blank=True,
  52. on_delete=models.SET_NULL,
  53. related_name="+",
  54. verbose_name=_("Logo"),
  55. help_text=_("Brand logo used in the navbar and throughout the site"),
  56. )
  57. favicon = models.ForeignKey(
  58. get_image_model_string(),
  59. null=True,
  60. blank=True,
  61. on_delete=models.SET_NULL,
  62. related_name="favicon",
  63. verbose_name=_("Favicon"),
  64. )
  65. navbar_color_scheme = models.CharField(
  66. blank=True,
  67. max_length=50,
  68. choices=None,
  69. default="",
  70. verbose_name=_("Navbar color scheme"),
  71. help_text=_(
  72. "Optimizes text and other navbar elements for use with light or "
  73. "dark backgrounds."
  74. ),
  75. )
  76. navbar_class = models.CharField(
  77. blank=True,
  78. max_length=255,
  79. default="",
  80. verbose_name=_("Navbar CSS class"),
  81. help_text=_(
  82. 'Custom classes applied to navbar e.g. "bg-light", "bg-dark", "bg-primary".'
  83. ),
  84. )
  85. navbar_fixed = models.BooleanField(
  86. default=False,
  87. verbose_name=_("Fixed navbar"),
  88. help_text=_(
  89. "Fixed navbar will remain at the top of the page when scrolling."
  90. ),
  91. )
  92. navbar_content_fluid = models.BooleanField(
  93. default=False,
  94. verbose_name=_("Full width navbar contents"),
  95. help_text=_("Content within the navbar will fill edge to edge."),
  96. )
  97. navbar_collapse_mode = models.CharField(
  98. blank=True,
  99. max_length=50,
  100. choices=None,
  101. default="",
  102. verbose_name=_("Collapse navbar menu"),
  103. help_text=_(
  104. "Control on what screen sizes to show and collapse the navbar menu links."
  105. ),
  106. )
  107. navbar_format = models.CharField(
  108. blank=True,
  109. max_length=50,
  110. choices=None,
  111. default="",
  112. verbose_name=_("Navbar format"),
  113. )
  114. navbar_search = models.BooleanField(
  115. default=True,
  116. verbose_name=_("Search box"),
  117. help_text=_("Show search box in navbar"),
  118. )
  119. from_email_address = models.CharField(
  120. blank=True,
  121. max_length=255,
  122. verbose_name=_("From email address"),
  123. help_text=_(
  124. "The default email address this site appears to send from. "
  125. 'For example: "sender@example.com" or '
  126. '"Sender Name <sender@example.com>" (without quotes)'
  127. ),
  128. )
  129. search_num_results = models.PositiveIntegerField(
  130. default=10,
  131. verbose_name=_("Number of results per page"),
  132. )
  133. external_new_tab = models.BooleanField(
  134. default=False, verbose_name=_("Open all external links in new tab")
  135. )
  136. spam_service = models.CharField(
  137. blank=True,
  138. max_length=10,
  139. choices=SpamService.choices,
  140. default=SpamService.HONEYPOT,
  141. verbose_name=_("Spam Protection"),
  142. help_text=_(
  143. "Choose a technique or 3rd party service to help block spam submissions."
  144. ),
  145. )
  146. recaptcha_threshold = models.DecimalField(
  147. default=0.5,
  148. max_digits=2,
  149. decimal_places=1,
  150. verbose_name=_("reCAPTCHA Threshold"),
  151. help_text=_(
  152. "reCAPTCHA v3 returns a score (0.0 is very likely a bot, "
  153. "1.0 is very likely a good interaction). "
  154. "Reject submissions below this score (recommended 0.5)."
  155. ),
  156. )
  157. recaptcha_public_key = models.CharField(
  158. blank=True,
  159. max_length=255,
  160. verbose_name=_("reCAPTCHA Site Key (Public)"),
  161. help_text=_(
  162. "Create this key in the Google reCAPTCHA or Google Cloud dashboard."
  163. ),
  164. )
  165. recaptcha_secret_key = models.CharField(
  166. blank=True,
  167. max_length=255,
  168. verbose_name=_("reCAPTCHA Secret Key (Private)"),
  169. help_text=_(
  170. "Create this key in the Google reCAPTCHA or Google Cloud dashboard."
  171. ),
  172. )
  173. google_maps_api_key = models.CharField(
  174. blank=True,
  175. max_length=255,
  176. verbose_name=_("Google Maps API Key"),
  177. help_text=_("The API Key used for Google Maps."),
  178. )
  179. mailchimp_api_key = models.CharField(
  180. blank=True,
  181. max_length=255,
  182. verbose_name=_("Mailchimp API Key"),
  183. help_text=_("The API Key used for Mailchimp."),
  184. )
  185. navbar_panels = [
  186. MultiFieldPanel(
  187. [
  188. FieldPanel("navbar_color_scheme"),
  189. FieldPanel("navbar_class"),
  190. FieldPanel("navbar_fixed"),
  191. FieldPanel("navbar_content_fluid"),
  192. FieldPanel("navbar_collapse_mode"),
  193. FieldPanel("navbar_format"),
  194. FieldPanel("navbar_search"),
  195. ],
  196. heading=_("Site Navbar Layout"),
  197. ),
  198. InlinePanel(
  199. "site_navbar",
  200. help_text=_("Choose one or more navbars for your site."),
  201. heading=_("Site Navbars"),
  202. ),
  203. ]
  204. footer_panels = [
  205. InlinePanel(
  206. "site_footer",
  207. help_text=_("Choose one or more footers for your site."),
  208. heading=_("Site Footers"),
  209. ),
  210. ]
  211. panels = [
  212. MultiFieldPanel(
  213. [
  214. FieldPanel("logo"),
  215. FieldPanel("favicon"),
  216. ],
  217. heading=_("Branding"),
  218. ),
  219. MultiFieldPanel(
  220. [
  221. FieldPanel("from_email_address"),
  222. FieldPanel("search_num_results"),
  223. FieldPanel("external_new_tab"),
  224. ],
  225. heading=_("General"),
  226. ),
  227. MultiFieldPanel(
  228. [
  229. FieldPanel("spam_service"),
  230. FieldPanel("recaptcha_threshold"),
  231. FieldPanel("recaptcha_public_key"),
  232. FieldPanel("recaptcha_secret_key"),
  233. ],
  234. heading=_("Form Settings"),
  235. ),
  236. MultiFieldPanel(
  237. [
  238. FieldPanel("google_maps_api_key"),
  239. FieldPanel("mailchimp_api_key"),
  240. ],
  241. heading=_("API Keys"),
  242. ),
  243. ]
  244. if not crx_settings.CRX_DISABLE_NAVBAR:
  245. panels += navbar_panels
  246. if not crx_settings.CRX_DISABLE_FOOTER:
  247. panels += footer_panels
  248. def __init__(self, *args, **kwargs):
  249. """
  250. Inject custom choices and defaults into the form fields
  251. to enable customization of settings without causing migration issues.
  252. """
  253. super().__init__(*args, **kwargs)
  254. # Set choices dynamically.
  255. self._meta.get_field(
  256. "navbar_collapse_mode"
  257. ).choices = crx_settings.CRX_FRONTEND_NAVBAR_COLLAPSE_MODE_CHOICES
  258. self._meta.get_field(
  259. "navbar_color_scheme"
  260. ).choices = crx_settings.CRX_FRONTEND_NAVBAR_COLOR_SCHEME_CHOICES
  261. self._meta.get_field(
  262. "navbar_format"
  263. ).choices = crx_settings.CRX_FRONTEND_NAVBAR_FORMAT_CHOICES
  264. # Set default dynamically.
  265. if not self.id:
  266. self.navbar_class = crx_settings.CRX_FRONTEND_NAVBAR_CLASS_DEFAULT
  267. self.navbar_collapse_mode = (
  268. crx_settings.CRX_FRONTEND_NAVBAR_COLLAPSE_MODE_DEFAULT
  269. )
  270. self.navbar_color_scheme = (
  271. crx_settings.CRX_FRONTEND_NAVBAR_COLOR_SCHEME_DEFAULT
  272. )
  273. self.navbar_format = crx_settings.CRX_FRONTEND_NAVBAR_FORMAT_DEFAULT
  274. def clean(self):
  275. """
  276. Make sure reCAPTCHA keys are set if selected.
  277. """
  278. if self.spam_service in [
  279. self.SpamService.RECAPTCHA_V3,
  280. self.SpamService.RECAPTCHA_V2,
  281. ] and not (self.recaptcha_public_key and self.recaptcha_secret_key):
  282. raise ValidationError(_("API keys are required to use reCAPTCHA."))
  283. return super().clean()
  284. class NavbarOrderable(Orderable, models.Model):
  285. navbar_chooser = ParentalKey(
  286. LayoutSettings,
  287. related_name="site_navbar",
  288. verbose_name=_("Site Navbars"),
  289. )
  290. navbar = models.ForeignKey(
  291. Navbar,
  292. blank=True,
  293. null=True,
  294. on_delete=models.CASCADE,
  295. )
  296. panels = [FieldPanel("navbar")]
  297. class FooterOrderable(Orderable, models.Model):
  298. footer_chooser = ParentalKey(
  299. LayoutSettings,
  300. related_name="site_footer",
  301. verbose_name=_("Site Footers"),
  302. )
  303. footer = models.ForeignKey(
  304. Footer,
  305. blank=True,
  306. null=True,
  307. on_delete=models.CASCADE,
  308. )
  309. panels = [FieldPanel("footer")]
  310. @maybe_register_setting(crx_settings.CRX_DISABLE_ANALYTICS, icon="cr-google")
  311. class AnalyticsSettings(BaseSiteSetting):
  312. """
  313. Tracking and Google Analytics.
  314. """
  315. class Meta:
  316. verbose_name = _("Tracking")
  317. ga_g_tracking_id = models.CharField(
  318. blank=True,
  319. max_length=255,
  320. verbose_name=_("G Tracking ID"),
  321. help_text=_('Your Google Analytics 4 tracking ID (begins with "G-")'),
  322. )
  323. ga_track_button_clicks = models.BooleanField(
  324. default=False,
  325. verbose_name=_("Track button clicks"),
  326. help_text=_(
  327. "Track all button clicks using Google Analytics event tracking. "
  328. "Event tracking details can be specified in each button’s advanced "
  329. "settings options."
  330. ),
  331. )
  332. gtm_id = models.CharField(
  333. blank=True,
  334. max_length=255,
  335. verbose_name=_("Google Tag Manager ID"),
  336. help_text=_('Begins with "GTM-"'),
  337. )
  338. head_scripts = MonospaceField(
  339. blank=True,
  340. null=True,
  341. verbose_name=_("<head> tracking scripts"),
  342. help_text=_("Add tracking scripts between the <head> tags."),
  343. )
  344. body_scripts = MonospaceField(
  345. blank=True,
  346. null=True,
  347. verbose_name=_("<body> tracking scripts"),
  348. help_text=_("Add tracking scripts toward closing <body> tag."),
  349. )
  350. panels = [
  351. HelpPanel(
  352. heading=_("Know your tracking"),
  353. content=_(
  354. "<h2>Which tracking IDs do I need?</h2>"
  355. "<p>Before adding tracking to your site, "
  356. '<a href="https://docs.coderedcorp.com/wagtail-crx/how_to/add_tracking_scripts.html" ' # noqa
  357. 'target="_blank">read about the difference between G, UA, GTM, '
  358. "and other tracking IDs</a>.</p>"
  359. ),
  360. ),
  361. MultiFieldPanel(
  362. [
  363. FieldPanel("ga_g_tracking_id"),
  364. FieldPanel("ga_track_button_clicks"),
  365. ],
  366. heading=_("Google Analytics"),
  367. ),
  368. MultiFieldPanel(
  369. [
  370. FieldPanel("gtm_id"),
  371. ],
  372. heading=_("Google Tag Manager"),
  373. ),
  374. MultiFieldPanel(
  375. [
  376. FieldPanel("head_scripts"),
  377. FieldPanel("body_scripts"),
  378. ],
  379. heading=_("Other Tracking Scripts"),
  380. ),
  381. ]