From 4b6922abcce0908774be36af9ba49f97486ceb1c Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Tue, 17 Mar 2026 18:42:22 -0500 Subject: [PATCH 1/3] pardot forms and flex page block for forms --- openstax/urls.py | 2 + .../0166_pardotformpage_pardotformfield.py | 128 +++ pages/migrations/0167_alter_rootpage_body.py | 944 ++++++++++++++++++ pages/models.py | 80 +- pages/views.py | 57 ++ 5 files changed, 1209 insertions(+), 2 deletions(-) create mode 100644 pages/migrations/0166_pardotformpage_pardotformfield.py create mode 100644 pages/migrations/0167_alter_rootpage_body.py create mode 100644 pages/views.py diff --git a/openstax/urls.py b/openstax/urls.py index 61f236e0f..1472a2877 100644 --- a/openstax/urls.py +++ b/openstax/urls.py @@ -14,6 +14,7 @@ from api import urls as api_urls from global_settings.views import throw_error, clear_entire_cache, sitemap +from pages.views import pardot_form_submit admin.site.site_header = 'OpenStax' @@ -43,6 +44,7 @@ path('apps/cms/api/webinars/', include('webinars.urls')), path('apps/cms/api/donations/', include('donations.urls')), path('apps/cms/api/oxmenus/', include('oxmenus.urls')), + path('apps/cms/api/pardot-forms//submit/', pardot_form_submit, name='pardot_form_submit'), # route everything to /api/spike also... path('apps/cms/api/spike/', include(wagtail_urls)), diff --git a/pages/migrations/0166_pardotformpage_pardotformfield.py b/pages/migrations/0166_pardotformpage_pardotformfield.py new file mode 100644 index 000000000..bf1858747 --- /dev/null +++ b/pages/migrations/0166_pardotformpage_pardotformfield.py @@ -0,0 +1,128 @@ +# Generated by Django 5.2.11 on 2026-03-17 23:31 + +import django.db.models.deletion +import modelcluster.fields +import wagtail.contrib.forms.models +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0165_alter_rootpage_body"), + ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="PardotFormPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ("pardot_form_handler_url", models.URLField(help_text="The Pardot form handler endpoint URL.")), + ( + "intro", + wagtail.fields.RichTextField(blank=True, help_text="Introductory text displayed above the form."), + ), + ( + "thank_you_text", + wagtail.fields.RichTextField(blank=True, help_text="Text displayed after a successful submission."), + ), + ("submit_button_text", models.CharField(default="Submit", max_length=255)), + ], + options={ + "verbose_name": "Pardot Form Page", + }, + bases=(wagtail.contrib.forms.models.FormMixin, "wagtailcore.page"), + ), + migrations.CreateModel( + name="PardotFormField", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("sort_order", models.IntegerField(blank=True, editable=False, null=True)), + ( + "clean_name", + models.CharField( + blank=True, + default="", + help_text="Safe name of the form field, the label converted to ascii_snake_case", + max_length=255, + verbose_name="name", + ), + ), + ( + "label", + models.CharField(help_text="The label of the form field", max_length=255, verbose_name="label"), + ), + ( + "field_type", + models.CharField( + choices=[ + ("singleline", "Single line text"), + ("multiline", "Multi-line text"), + ("email", "Email"), + ("number", "Number"), + ("url", "URL"), + ("checkbox", "Checkbox"), + ("checkboxes", "Checkboxes"), + ("dropdown", "Drop down"), + ("multiselect", "Multiple select"), + ("radio", "Radio buttons"), + ("date", "Date"), + ("datetime", "Date/time"), + ("hidden", "Hidden field"), + ], + max_length=16, + verbose_name="field type", + ), + ), + ("required", models.BooleanField(default=True, verbose_name="required")), + ( + "choices", + models.TextField( + blank=True, + help_text="Comma or new line separated list of choices. Only applicable in checkboxes, radio and dropdown.", + verbose_name="choices", + ), + ), + ( + "default_value", + models.TextField( + blank=True, + help_text="Default value. Comma or new line separated values supported for checkboxes.", + verbose_name="default value", + ), + ), + ("help_text", models.CharField(blank=True, max_length=255, verbose_name="help text")), + ( + "pardot_field_name", + models.CharField( + help_text="The Pardot external field name used as the POST parameter (e.g. email, firstName, company).", + max_length=255, + ), + ), + ( + "page", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="form_fields", + to="pages.pardotformpage", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/pages/migrations/0167_alter_rootpage_body.py b/pages/migrations/0167_alter_rootpage_body.py new file mode 100644 index 000000000..e599f3151 --- /dev/null +++ b/pages/migrations/0167_alter_rootpage_body.py @@ -0,0 +1,944 @@ +# Generated by Django 5.2.11 on 2026-03-17 23:39 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0166_pardotformpage_pardotformfield"), + ] + + operations = [ + migrations.AlterField( + model_name="rootpage", + name="body", + field=wagtail.fields.StreamField( + [ + ("hero", 91), + ("section", 94), + ("columns", 105), + ("divider", 112), + ("html", 26), + ("tabbed_content", 123), + ], + block_lookup={ + 0: ("pages.custom_blocks.APIRichTextBlock", (), {}), + 1: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "Visible text of the link or button.", "required": True}, + ), + 2: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Accessible label for the link or button. if provided, must begin with the visible text.", + "required": False, + }, + ), + 3: ( + "wagtail.blocks.URLBlock", + (), + {"help_text": "External links are full urls that can go anywhere", "required": False}, + ), + 4: ("wagtail.blocks.PageChooserBlock", (), {"required": False}), + 5: ("wagtail.documents.blocks.DocumentChooserBlock", (), {"required": False}), + 6: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Anchor links reference the ID of an element on the page, and scroll the page there.", + "required": False, + }, + ), + 7: ( + "wagtail.blocks.StreamBlock", + [[("external", 3), ("internal", 4), ("document", 5), ("anchor", 6)]], + {"required": True}, + ), + 8: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("orange", "Orange"), + ("white", "White"), + ("blue_outline", "Blue Outline"), + ("deep_green_outline", "Deep Green Outline"), + ], + "help_text": "Specifies the button style. Default unspecified, meaning the first button in the block is orange and the second is white.", + }, + ), + 9: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Custom color for the button. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 10: ( + "wagtail.blocks.StreamBlock", + [[("style", 8), ("custom_color", 9)]], + {"block_counts": {"custom_color": {"max_num": 1}, "style": {"max_num": 1}}, "required": False}, + ), + 11: ( + "wagtail.blocks.StructBlock", + [[("text", 1), ("aria_label", 2), ("target", 7), ("config", 10)]], + {"label": "Link", "required": False}, + ), + 12: ("wagtail.blocks.ListBlock", (11,), {"default": [], "label": "Call To Action", "max_num": 1}), + 13: ("wagtail.blocks.StructBlock", [[("text", 0), ("cta_block", 12)]], {}), + 14: ("wagtail.blocks.ListBlock", (13,), {}), + 15: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Sets the width of the individual cards. default 27.", "min_value": 0}, + ), + 16: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("rounded", "Rounded"), ("square", "Square")], + "help_text": "The border style of the cards. default borderless.", + }, + ), + 17: ( + "wagtail.blocks.IntegerBlock", + (), + { + "help_text": "Number of columns for the cards grid. default auto.", + "max_value": 6, + "min_value": 1, + }, + ), + 18: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Accent color for a card. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 19: ("wagtail.blocks.ListBlock", (18,), {"default": [], "label": "Accent Colors"}), + 20: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Divider color between cards. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 21: ("wagtail.blocks.ListBlock", (20,), {"default": [], "label": "Divider Colors"}), + 22: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Background color for the cards block. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 23: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Border size in pixels for the cards. default 0.", "min_value": 0}, + ), + 24: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("card_size", 15), + ("card_style", 16), + ("card_columns", 17), + ("accent_colors", 19), + ("divider_colors", 21), + ("background_color", 22), + ("border_size", 23), + ] + ], + { + "block_counts": { + "accent_colors": {"max_num": 1}, + "background_color": {"max_num": 1}, + "border_size": {"max_num": 1}, + "card_columns": {"max_num": 1}, + "card_size": {"max_num": 1}, + "card_style": {"max_num": 1}, + "divider_colors": {"max_num": 1}, + }, + "required": False, + }, + ), + 25: ("wagtail.blocks.StructBlock", [[("cards", 14), ("config", 24)]], {"label": "Cards Block"}), + 26: ("wagtail.blocks.RawHTMLBlock", (), {}), + 27: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "Optional description text displayed alongside the buttons.", "required": False}, + ), + 28: ( + "wagtail.blocks.StructBlock", + [[("text", 1), ("aria_label", 2), ("target", 7), ("config", 10)]], + {"label": "Button", "required": False}, + ), + 29: ("wagtail.blocks.ListBlock", (28,), {"default": [], "label": "Actions", "max_num": 2}), + 30: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": 'Sets the "analytics nav" field for links within this group.', "required": False}, + ), + 31: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("horizontal", "Horizontal"), ("vertical", "Vertical"), ("stacked", "Stacked")], + "help_text": "Layout of the buttons. Default horizontal.", + }, + ), + 32: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "Condition that determines if this block should render. eg: defined by the frontend.", + "required": False, + }, + ), + 33: ( + "wagtail.blocks.StreamBlock", + [[("analytics_label", 30), ("layout", 31), ("rendering_condition", 32)]], + { + "block_counts": { + "analytics_label": {"max_num": 1}, + "layout": {"max_num": 1}, + "rendering_condition": {"max_num": 1}, + }, + "required": False, + }, + ), + 34: ("wagtail.blocks.StructBlock", [[("description", 27), ("actions", 29), ("config", 33)]], {}), + 35: ( + "wagtail.blocks.StructBlock", + [[("text", 1), ("aria_label", 2), ("target", 7)]], + {"label": "Link", "required": False}, + ), + 36: ("wagtail.blocks.ListBlock", (35,), {"default": [], "label": "Links"}), + 37: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("white", "White"), ("blue", "Blue"), ("deep-green", "Deep Green")], + "help_text": "The color of the link buttons. Default white.", + }, + ), + 38: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Custom color for the links. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 39: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("small", "Small"), ("medium", "Medium"), ("large", "Large")], + "help_text": "Size of the links. Default medium.", + }, + ), + 40: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("horizontal", "Horizontal"), ("vertical", "Vertical")], + "help_text": "Layout direction of the links. Default horizontal.", + }, + ), + 41: ( + "wagtail.blocks.StreamBlock", + [[("color", 37), ("custom_color", 38), ("size", 39), ("layout", 40), ("analytics_label", 30)]], + { + "block_counts": { + "analytics_label": {"max_num": 1}, + "color": {"max_num": 1}, + "custom_color": {"max_num": 1}, + "layout": {"max_num": 1}, + "size": {"max_num": 1}, + }, + "required": False, + }, + ), + 42: ("wagtail.blocks.StructBlock", [[("links", 36), ("config", 41)]], {}), + 43: ("pages.custom_blocks.APIImageChooserBlock", (), {}), + 44: ("wagtail.blocks.RichTextBlock", (), {"help_text": "The quote content."}), + 45: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "The name of the person or entity to attribute the quote to."}, + ), + 46: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "Additional title or label about the quotee.", "required": False}, + ), + 47: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Accent color for the quote. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 48: ( + "wagtail.blocks.StreamBlock", + [[("accent_color", 47)]], + {"block_counts": {"accent_color": {"max_num": 1}}, "required": False}, + ), + 49: ( + "wagtail.blocks.StructBlock", + [[("image", 43), ("content", 44), ("name", 45), ("title", 46), ("config", 48)]], + {}, + ), + 50: ( + "wagtail.blocks.RichTextBlock", + (), + {"help_text": "The visible text of the question (does not collapse).", "required": True}, + ), + 51: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "Not visible to user, must be unique in this FAQ.", "required": True}, + ), + 52: ( + "wagtail.blocks.RichTextBlock", + (), + { + "help_text": "The answer to the question, is hidden until the question is expanded.", + "required": True, + }, + ), + 53: ( + "wagtail.documents.blocks.DocumentChooserBlock", + (), + {"help_text": "Not sure this does anything.", "required": False}, + ), + 54: ( + "wagtail.blocks.StructBlock", + [[("question", 50), ("slug", 51), ("answer", 52), ("document", 53)]], + {}, + ), + 55: ("wagtail.blocks.StreamBlock", [[("faq", 54)]], {}), + 56: ("pages.custom_blocks.BookBlock", (), {"required": True}), + 57: ("wagtail.blocks.ListBlock", (56,), {}), + 58: ("wagtail.blocks.StructBlock", [[("books", 57)]], {"label": "Books Block"}), + 59: ( + "wagtail.blocks.PageChooserBlock", + (), + {"label": "Pardot Form", "page_type": ["pages.PardotFormPage"]}, + ), + 60: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("cards_block", 25), + ("text", 0), + ("html", 26), + ("cta_block", 34), + ("links_group", 42), + ("quote", 49), + ("faq", 55), + ("book_list", 58), + ] + ], + {}, + ), + 61: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Background color of the well. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 62: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Sets the gradient end color. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 63: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("to_right", "To Right"), + ("to_left", "To Left"), + ("to_top", "To Top"), + ("to_bottom", "To Bottom"), + ("to_top_right", "To Top Right"), + ("to_top_left", "To Top Left"), + ("to_bottom_right", "To Bottom Right"), + ("to_bottom_left", "To Bottom Left"), + ], + "help_text": "Direction of the gradient. Default to_right.", + }, + ), + 64: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Border radius in pixels. default 0.", "min_value": 0}, + ), + 65: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Border color. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 66: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Border size in pixels. default 0.", "min_value": 0}, + ), + 67: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Padding inside the well. default 0.", "min_value": 0}, + ), + 68: ("wagtail.blocks.IntegerBlock", (), {"help_text": "Margin outside the well. default 0."}), + 69: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Pulls the well up by this many pixels (negative margin-top). default 0."}, + ), + 70: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid size."}, + "help_text": "Width of the well. Must be valid css measurement. eg: 30px, 50%, 10rem.", + "regex": "^[0-9]+(px|%|rem)$", + "required": False, + }, + ), + 71: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("left", "Left"), ("center", "Center"), ("right", "Right")], + "help_text": "Text alignment inside the well. Default left.", + }, + ), + 72: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": 'Sets the "analytics nav" field for links within this well.', "required": False}, + ), + 73: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid id."}, + "help_text": "HTML id of this element. not visible to users, but is visible in urls and is used to link to a certain part of the page with an anchor link. eg: cool_section", + "regex": "[a-zA-Z0-9\\-_]", + }, + ), + 74: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("background_color", 61), + ("gradient_color", 62), + ("gradient_direction", 63), + ("border_radius", 64), + ("border_color", 65), + ("border_size", 66), + ("padding", 67), + ("margin", 68), + ("pull_up", 69), + ("width", 70), + ("text_alignment", 71), + ("analytics_label", 72), + ("id", 73), + ] + ], + { + "block_counts": { + "analytics_label": {"max_num": 1}, + "background_color": {"max_num": 1}, + "border_color": {"max_num": 1}, + "border_radius": {"max_num": 1}, + "border_size": {"max_num": 1}, + "gradient_color": {"max_num": 1}, + "gradient_direction": {"max_num": 1}, + "id": {"max_num": 1}, + "margin": {"max_num": 1}, + "padding": {"max_num": 1}, + "pull_up": {"max_num": 1}, + "text_alignment": {"max_num": 1}, + "width": {"max_num": 1}, + }, + "required": False, + }, + ), + 75: ("wagtail.blocks.StructBlock", [[("content", 60), ("config", 74)]], {"label": "Well"}), + 76: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("cards_block", 25), + ("text", 0), + ("html", 26), + ("cta_block", 34), + ("links_group", 42), + ("quote", 49), + ("faq", 55), + ("book_list", 58), + ("pardot_form", 59), + ("well", 75), + ] + ], + {}, + ), + 77: ("pages.custom_blocks.APIImageChooserBlock", (), {"required": False}), + 78: ("wagtail.blocks.CharBlock", (), {"required": False}), + 79: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("left", "Left"), + ("right", "Right"), + ("top_left", "Top Left"), + ("top_right", "Top Right"), + ("bottom_left", "Bottom Left"), + ("bottom_right", "Bottom Right"), + ], + "help_text": "Controls if the image is on the left or right side of the content, and if it prefers to be at the top, center, or bottom of the available space.", + }, + ), + 80: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Sets the background color of the section. value must be hex eg: #ff0000. Default grey.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 81: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Creates space above and below this section. default 0.", "min_value": 0}, + ), + 82: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Creates space above this section. default 0.", "min_value": 0}, + ), + 83: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Creates space below this section. default 0.", "min_value": 0}, + ), + 84: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("left", "Left"), ("center", "Center"), ("right", "Right")], + "help_text": "Configures text alignment within the container. Default Left.", + }, + ), + 85: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": 'Sets the "analytics nav" field for links within this section.', + "required": False, + }, + ), + 86: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Border radius for the hero image in pixels. default 0.", "min_value": 0}, + ), + 87: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Border color for the hero image. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 88: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Border size for the hero image in pixels. default 0.", "min_value": 0}, + ), + 89: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "How much the image overhangs the section boundary in pixels. default 0."}, + ), + 90: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("image_alignment", 79), + ("id", 73), + ("background_color", 80), + ("gradient_color", 62), + ("gradient_direction", 63), + ("padding", 81), + ("padding_top", 82), + ("padding_bottom", 83), + ("text_alignment", 84), + ("analytics_label", 85), + ("image_border_radius", 86), + ("image_border_color", 87), + ("image_border_size", 88), + ("image_overhang", 89), + ("rendering_condition", 32), + ] + ], + { + "block_counts": { + "analytics_label": {"max_num": 1}, + "background_color": {"max_num": 1}, + "gradient_color": {"max_num": 1}, + "gradient_direction": {"max_num": 1}, + "id": {"max_num": 1}, + "image_alignment": {"max_num": 1}, + "image_border_color": {"max_num": 1}, + "image_border_radius": {"max_num": 1}, + "image_border_size": {"max_num": 1}, + "image_overhang": {"max_num": 1}, + "padding": {"max_num": 1}, + "padding_bottom": {"max_num": 1}, + "padding_top": {"max_num": 1}, + "rendering_condition": {"max_num": 1}, + "text_alignment": {"max_num": 1}, + }, + "required": False, + }, + ), + 91: ( + "wagtail.blocks.StructBlock", + [[("content", 76), ("image", 77), ("image_alt", 78), ("config", 90)]], + {}, + ), + 92: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("flex", "Flex"), ("flex_grow", "Flex Grow"), ("flex_shrink", "Flex Shrink")], + "help_text": "Flex behavior of this section. Default none.", + }, + ), + 93: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("id", 73), + ("background_color", 80), + ("gradient_color", 62), + ("gradient_direction", 63), + ("padding", 81), + ("padding_top", 82), + ("padding_bottom", 83), + ("text_alignment", 84), + ("analytics_label", 85), + ("flex", 92), + ("rendering_condition", 32), + ] + ], + { + "block_counts": { + "analytics_label": {"max_num": 1}, + "background_color": {"max_num": 1}, + "flex": {"max_num": 1}, + "gradient_color": {"max_num": 1}, + "gradient_direction": {"max_num": 1}, + "id": {"max_num": 1}, + "padding": {"max_num": 1}, + "padding_bottom": {"max_num": 1}, + "padding_top": {"max_num": 1}, + "rendering_condition": {"max_num": 1}, + "text_alignment": {"max_num": 1}, + }, + "required": False, + }, + ), + 94: ("wagtail.blocks.StructBlock", [[("content", 76), ("config", 93)]], {}), + 95: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Background color of the columns container. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 96: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Padding for the columns container. default 0.", "min_value": 0}, + ), + 97: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Padding above the columns container. default 0.", "min_value": 0}, + ), + 98: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Padding below the columns container. default 0.", "min_value": 0}, + ), + 99: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("flex", "Flex"), ("flex_grow", "Flex Grow"), ("flex_shrink", "Flex Shrink")], + "help_text": "Flex behavior. Default none.", + }, + ), + 100: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": 'Sets the "analytics nav" field for links within this block.', "required": False}, + ), + 101: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Gap between the two columns in pixels. default 0.", "min_value": 0}, + ), + 102: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Relative size of the left column. default 1.", "min_value": 1}, + ), + 103: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Relative size of the right column. default 1.", "min_value": 1}, + ), + 104: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("background_color", 95), + ("gradient_color", 62), + ("gradient_direction", 63), + ("padding", 96), + ("padding_top", 97), + ("padding_bottom", 98), + ("flex", 99), + ("analytics_label", 100), + ("id", 73), + ("gap", 101), + ("left_size", 102), + ("right_size", 103), + ] + ], + { + "block_counts": { + "analytics_label": {"max_num": 1}, + "background_color": {"max_num": 1}, + "flex": {"max_num": 1}, + "gap": {"max_num": 1}, + "gradient_color": {"max_num": 1}, + "gradient_direction": {"max_num": 1}, + "id": {"max_num": 1}, + "left_size": {"max_num": 1}, + "padding": {"max_num": 1}, + "padding_bottom": {"max_num": 1}, + "padding_top": {"max_num": 1}, + "right_size": {"max_num": 1}, + }, + "required": False, + }, + ), + 105: ( + "wagtail.blocks.StructBlock", + [[("left_content", 76), ("right_content", 76), ("config", 104)]], + {"label": "Columns"}, + ), + 106: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("center", "Center"), + ("content_left", "Left side of content."), + ("content_right", "Right side of content."), + ("body_left", "Left side of window."), + ("body_right", "Right side of window."), + ], + "help_text": 'Sets the horizontal alignment of the image. can be further customized with the "Offset..." configurations. Default is Left side of window.', + }, + ), + 107: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid size."}, + "help_text": "Specifies the width of the image. Percentages are relative to the container (body or content, depending on alignment option). Must be valid css measurement. eg: 30px, 50%, 10rem. Default is the size of the image.", + "regex": "^[0-9]+(px|%|rem)$", + "required": False, + }, + ), + 108: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid size."}, + "help_text": "Specifies the height of the image. Percentages are relative to the container (body or content, depending on alignment option). Must be valid css measurement. eg: 30px, 50%, 10rem. Default is the size of the image.", + "regex": "^[0-9]+(px|%|rem)$", + "required": False, + }, + ), + 109: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid size."}, + "help_text": "Moves the image up or down. Percentages are relative to the image size. Must be valid css measurement. eg: 30px, 50%, 10rem. Default is -50%, which moves the image up by half its width (centering it vertically on the divider).", + "regex": "^\\-?[0-9]+(px|%|rem)$", + "required": False, + }, + ), + 110: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid size."}, + "help_text": "Moves the image left or right. Percentages are relative to the image size. Must be valid css measurement. eg: 30px, 50%, 10rem. Default is no offset, which means the image's outer edge will align with the container's edge for left and right alignment. or it'll be perfectly centered for centered alignment.", + "regex": "^\\-?[0-9]+(px|%|rem)$", + "required": False, + }, + ), + 111: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("alignment", 106), + ("width", 107), + ("height", 108), + ("offset_vertical", 109), + ("offset_horizontal", 110), + ] + ], + { + "block_counts": { + "alignment": {"max_num": 1}, + "height": {"max_num": 1}, + "offset_horizontal": {"max_num": 1}, + "offset_vertical": {"max_num": 1}, + "width": {"max_num": 1}, + }, + "required": False, + }, + ), + 112: ("wagtail.blocks.StructBlock", [[("image", 43), ("config", 111)]], {}), + 113: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "The visible label for this tab.", "required": True}, + ), + 114: ( + "wagtail.blocks.StreamBlock", + [[("hero", 91), ("section", 94), ("columns", 105), ("divider", 112), ("html", 26)]], + {}, + ), + 115: ("wagtail.blocks.StructBlock", [[("label", 113), ("content", 114)]], {}), + 116: ("wagtail.blocks.ListBlock", (115,), {"label": "Tabs"}), + 117: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [("left", "Left"), ("center", "Center"), ("right", "Right")], + "help_text": "Alignment of the tab labels. Default left.", + }, + ), + 118: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Color of the active tab indicator. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 119: ( + "wagtail.blocks.RegexBlock", + (), + { + "error_mssages": {"invalid": "not a valid hex color."}, + "help_text": "Background color of the tabbed content area. Must be hex eg: #ff0000.", + "regex": "#[a-zA-Z0-9]{6}", + }, + ), + 120: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Index of the default active tab (0-based). default 0.", "min_value": 0}, + ), + 121: ( + "wagtail.blocks.IntegerBlock", + (), + {"help_text": "Border width for the tab bar in pixels. default 0.", "min_value": 0}, + ), + 122: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("tab_alignment", 117), + ("active_color", 118), + ("background_color", 119), + ("gradient_color", 62), + ("gradient_direction", 63), + ("default_tab", 120), + ("analytics_label", 100), + ("border_width", 121), + ("id", 73), + ] + ], + { + "block_counts": { + "active_color": {"max_num": 1}, + "analytics_label": {"max_num": 1}, + "background_color": {"max_num": 1}, + "border_width": {"max_num": 1}, + "default_tab": {"max_num": 1}, + "gradient_color": {"max_num": 1}, + "gradient_direction": {"max_num": 1}, + "id": {"max_num": 1}, + "tab_alignment": {"max_num": 1}, + }, + "required": False, + }, + ), + 123: ( + "wagtail.blocks.StructBlock", + [[("tabs", 116), ("config", 122)]], + {"label": "Tabbed Content"}, + ), + }, + ), + ), + ] diff --git a/pages/models.py b/pages/models.py index 8e131c139..6a2b60a8b 100644 --- a/pages/models.py +++ b/pages/models.py @@ -11,6 +11,9 @@ from wagtail.models import Orderable, Page from wagtail.api import APIField from wagtail.models import Site +from wagtail.contrib.forms.models import AbstractForm, AbstractFormField + +from rest_framework.fields import Field from api.models import FeatureFlag from openstax.functions import build_image_url, build_document_url @@ -112,8 +115,14 @@ ], label="Books Block")), ] +# Pardot form embed — lets editors pick a PardotFormPage to embed in a section +PARDOT_FORM_BLOCK = ('pardot_form', blocks.PageChooserBlock( + page_type='pages.PardotFormPage', + label="Pardot Form", +)) + # Layer 2: Content blocks + well (well references BASE, not itself) -SECTION_CONTENT_BLOCKS = BASE_CONTENT_BLOCKS + [ +SECTION_CONTENT_BLOCKS = BASE_CONTENT_BLOCKS + [PARDOT_FORM_BLOCK] + [ ('well', blocks.StructBlock([ ('content', blocks.StreamBlock(BASE_CONTENT_BLOCKS)), ('config', blocks.StreamBlock([ @@ -374,7 +383,7 @@ def serve_preview(self, request, mode_name): # subclass of RootPage with a few overrides for subpages class FlexPage(RootPage): parent_page_types = ['pages.RootPage', 'pages.FlexPage'] - subpage_types = ['pages.FlexPage'] + subpage_types = ['pages.FlexPage', 'pages.PardotFormPage'] template = 'page.html' max_count = None @@ -3684,3 +3693,70 @@ def get_heading_title_image_url(self): parent_page_type = ['pages.HomePage'] template = 'page.html' max_count = 1 + + +# --- Pardot Form Builder --- + +class FormFieldsSerializer(Field): + """Serializes PardotFormPage form_fields for the API response.""" + def to_representation(self, value): + return [ + { + 'label': field.label, + 'field_type': field.field_type, + 'required': field.required, + 'choices': field.choices, + 'default_value': field.default_value, + 'help_text': field.help_text, + 'pardot_field_name': field.pardot_field_name, + } + for field in value.all() + ] + + +class PardotFormField(AbstractFormField): + page = ParentalKey( + 'PardotFormPage', + on_delete=models.CASCADE, + related_name='form_fields', + ) + pardot_field_name = models.CharField( + max_length=255, + help_text="The Pardot external field name used as the POST parameter (e.g. email, firstName, company).", + ) + + panels = AbstractFormField.panels + [ + FieldPanel('pardot_field_name'), + ] + + +class PardotFormPage(AbstractForm): + pardot_form_handler_url = models.URLField( + help_text="The Pardot form handler endpoint URL.", + ) + intro = RichTextField(blank=True, help_text="Introductory text displayed above the form.") + thank_you_text = RichTextField(blank=True, help_text="Text displayed after a successful submission.") + submit_button_text = models.CharField(max_length=255, default="Submit") + + content_panels = AbstractForm.content_panels + [ + FieldPanel('intro'), + InlinePanel('form_fields', label="Form Fields"), + FieldPanel('submit_button_text'), + FieldPanel('thank_you_text'), + FieldPanel('pardot_form_handler_url'), + ] + + api_fields = [ + APIField('pardot_form_handler_url'), + APIField('intro'), + APIField('thank_you_text'), + APIField('submit_button_text'), + APIField('form_fields', serializer=FormFieldsSerializer()), + ] + + parent_page_types = ['pages.FlexPage', 'pages.RootPage'] + subpage_types = [] + template = 'page.html' + + class Meta: + verbose_name = "Pardot Form Page" diff --git a/pages/views.py b/pages/views.py new file mode 100644 index 000000000..ddd180815 --- /dev/null +++ b/pages/views.py @@ -0,0 +1,57 @@ +import json + +from django.utils import timezone +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from .models import PardotFormPage + + +@api_view(['POST']) +def pardot_form_submit(request, page_id): + """ + Accepts form submissions for a PardotFormPage and stores them + in Wagtail's FormSubmission table for backup/audit. + """ + try: + page = PardotFormPage.objects.get(pk=page_id) + except PardotFormPage.DoesNotExist: + return Response( + {'error': 'Form page not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Validate that submitted fields match defined form fields + defined_fields = {f.clean_name for f in page.get_form_fields()} + submitted_data = request.data + + if not submitted_data: + return Response( + {'error': 'No form data submitted.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check required fields + for field in page.get_form_fields(): + if field.required and not submitted_data.get(field.clean_name): + return Response( + {'error': f'Missing required field: {field.label}'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Filter to only defined fields + clean_data = { + key: value + for key, value in submitted_data.items() + if key in defined_fields + } + + # Save submission + submission_class = page.get_submission_class() + submission_class.objects.create( + form_data=json.dumps(clean_data), + page=page, + ) + + return Response({'status': 'ok'}, status=status.HTTP_201_CREATED) From 2a9c32d8328e0b3adfe51857483a0bb180fc1fc7 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Wed, 18 Mar 2026 00:36:02 -0500 Subject: [PATCH 2/3] tests --- pages/tests.py | 283 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) diff --git a/pages/tests.py b/pages/tests.py index 04a0da8ef..0282fc318 100644 --- a/pages/tests.py +++ b/pages/tests.py @@ -10,6 +10,7 @@ from shared.test_utilities import mock_user_login from http import cookies +from wagtail.contrib.forms.models import FormSubmission class TestRootPage(unittest.TestCase): @@ -930,3 +931,285 @@ def test_get_page_content_returns_proper_structure(self): self.assertIsInstance(item['name'], str, "Item name should be a string") self.assertIsInstance(item['type'], str, "Item type should be a string") self.assertIsInstance(item['value'], str, "Item value should be a string") + + +class PardotFormPageTestMixin: + """Shared setup for Pardot form tests — creates a page tree under the default Wagtail Site.""" + + def _set_up_pardot_tree(self): + mock_user_login() + # Use the default site's root page so the API can find pages + from wagtail.models import Site + site = Site.objects.get(is_default_site=True) + self.site_root = site.root_page + + self.root = page_models.RootPage(title="Pardot Home", slug="pardot-home") + self.site_root.add_child(instance=self.root) + self.flex = page_models.FlexPage(title="Marketing", slug="pardot-marketing") + self.root.add_child(instance=self.flex) + + def _create_form_page(self, parent=None, **kwargs): + defaults = { + 'title': 'Request a Demo', + 'slug': 'request-demo', + 'pardot_form_handler_url': 'https://go.pardot.com/l/123/form-handler', + 'intro': '

Fill out the form below.

', + 'thank_you_text': '

Thanks!

', + 'submit_button_text': 'Request Demo', + } + defaults.update(kwargs) + page = page_models.PardotFormPage(**defaults) + (parent or self.flex).add_child(instance=page) + return page + + +class PardotFormPageTests(PardotFormPageTestMixin, WagtailPageTestCase): + """Tests for PardotFormPage model and page hierarchy.""" + + def setUp(self): + self._set_up_pardot_tree() + + def test_can_create_under_flex_page(self): + self.assertCanCreateAt(page_models.FlexPage, page_models.PardotFormPage) + + def test_can_create_under_root_page(self): + self.assertCanCreateAt(page_models.RootPage, page_models.PardotFormPage) + + def test_cannot_create_under_homepage(self): + self.assertCanNotCreateAt(page_models.HomePage, page_models.PardotFormPage) + + def test_cannot_have_children(self): + self.assertAllowedSubpageTypes(page_models.PardotFormPage, {}) + + def test_create_and_retrieve(self): + page = self._create_form_page() + retrieved = Page.objects.get(id=page.id).specific + self.assertEqual(retrieved.title, 'Request a Demo') + self.assertEqual(retrieved.pardot_form_handler_url, 'https://go.pardot.com/l/123/form-handler') + self.assertEqual(retrieved.submit_button_text, 'Request Demo') + + def test_form_fields(self): + page = self._create_form_page() + page_models.PardotFormField.objects.create( + page=page, + label='First Name', + field_type='singleline', + required=True, + pardot_field_name='firstName', + sort_order=0, + ) + page_models.PardotFormField.objects.create( + page=page, + label='Email', + field_type='email', + required=True, + pardot_field_name='email', + help_text='Your work email', + sort_order=1, + ) + fields = page.get_form_fields() + self.assertEqual(fields.count(), 2) + self.assertEqual(fields.first().pardot_field_name, 'firstName') + + def test_submit_button_text_default(self): + fresh = page_models.PardotFormPage( + title='Test', + slug='test-default', + pardot_form_handler_url='https://example.com/handler', + ) + self.assertEqual(fresh.submit_button_text, 'Submit') + + +class PardotFormAPITests(PardotFormPageTestMixin, WagtailPageTestCase): + """Tests for PardotFormPage API serialization.""" + + def setUp(self): + self._set_up_pardot_tree() + + self.form_page = self._create_form_page() + + page_models.PardotFormField.objects.create( + page=self.form_page, + label='First Name', + field_type='singleline', + required=True, + pardot_field_name='firstName', + sort_order=0, + ) + page_models.PardotFormField.objects.create( + page=self.form_page, + label='Email', + field_type='email', + required=True, + pardot_field_name='email', + help_text='Your work email', + sort_order=1, + ) + page_models.PardotFormField.objects.create( + page=self.form_page, + label='Company', + field_type='singleline', + required=False, + pardot_field_name='company', + sort_order=2, + ) + + def test_api_returns_form_page(self): + response = self.client.get(f'/apps/cms/api/v2/pages/{self.form_page.id}/') + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['meta']['type'], 'pages.PardotFormPage') + self.assertEqual(data['pardot_form_handler_url'], 'https://go.pardot.com/l/123/form-handler') + self.assertEqual(data['submit_button_text'], 'Request Demo') + self.assertIn('Fill out the form', data['intro']) + self.assertIn('Thanks!', data['thank_you_text']) + + def test_api_returns_form_fields(self): + response = self.client.get(f'/apps/cms/api/v2/pages/{self.form_page.id}/') + data = response.json() + fields = data['form_fields'] + self.assertEqual(len(fields), 3) + + self.assertEqual(fields[0]['label'], 'First Name') + self.assertEqual(fields[0]['field_type'], 'singleline') + self.assertTrue(fields[0]['required']) + self.assertEqual(fields[0]['pardot_field_name'], 'firstName') + + self.assertEqual(fields[1]['label'], 'Email') + self.assertEqual(fields[1]['field_type'], 'email') + self.assertEqual(fields[1]['help_text'], 'Your work email') + self.assertEqual(fields[1]['pardot_field_name'], 'email') + + self.assertFalse(fields[2]['required']) + self.assertEqual(fields[2]['pardot_field_name'], 'company') + + +class PardotFormSubmitTests(PardotFormPageTestMixin, TestCase): + """Tests for the pardot form submission endpoint.""" + + def setUp(self): + self._set_up_pardot_tree() + + self.form_page = self._create_form_page( + title='Contact Us', + slug='contact-us', + pardot_form_handler_url='https://go.pardot.com/l/456/form-handler', + intro='

Contact.

', + thank_you_text='

Thanks!

', + ) + + page_models.PardotFormField.objects.create( + page=self.form_page, + label='First Name', + field_type='singleline', + required=True, + pardot_field_name='firstName', + sort_order=0, + ) + page_models.PardotFormField.objects.create( + page=self.form_page, + label='Email', + field_type='email', + required=True, + pardot_field_name='email', + sort_order=1, + ) + page_models.PardotFormField.objects.create( + page=self.form_page, + label='Company', + field_type='singleline', + required=False, + pardot_field_name='company', + sort_order=2, + ) + + def _submit(self, page_id=None, data=None): + return self.client.post( + f'/apps/cms/api/pardot-forms/{page_id or self.form_page.id}/submit/', + data=data or {}, + content_type='application/json', + ) + + def test_successful_submission(self): + response = self._submit(data={ + 'first_name': 'Jane', + 'email': 'jane@example.com', + 'company': 'Acme', + }) + self.assertEqual(response.status_code, 201) + self.assertEqual(FormSubmission.objects.filter(page=self.form_page).count(), 1) + + submission = FormSubmission.objects.get(page=self.form_page) + saved = json.loads(submission.form_data) + self.assertEqual(saved['first_name'], 'Jane') + self.assertEqual(saved['email'], 'jane@example.com') + self.assertEqual(saved['company'], 'Acme') + + def test_submission_filters_unknown_fields(self): + response = self._submit(data={ + 'first_name': 'Jane', + 'email': 'jane@example.com', + 'hacker_field': 'malicious', + }) + self.assertEqual(response.status_code, 201) + submission = FormSubmission.objects.get(page=self.form_page) + saved = json.loads(submission.form_data) + self.assertNotIn('hacker_field', saved) + + def test_missing_required_field(self): + response = self._submit(data={ + 'first_name': 'Jane', + # missing email + }) + self.assertEqual(response.status_code, 400) + self.assertIn('error', response.json()) + self.assertEqual(FormSubmission.objects.filter(page=self.form_page).count(), 0) + + def test_empty_submission(self): + response = self._submit(data={}) + self.assertEqual(response.status_code, 400) + + def test_invalid_page_id(self): + response = self._submit(page_id=99999) + self.assertEqual(response.status_code, 404) + + def test_optional_fields_can_be_omitted(self): + response = self._submit(data={ + 'first_name': 'Jane', + 'email': 'jane@example.com', + }) + self.assertEqual(response.status_code, 201) + + +class FormFieldsSerializerTests(PardotFormPageTestMixin, TestCase): + """Tests for the FormFieldsSerializer.""" + + def setUp(self): + self._set_up_pardot_tree() + + def test_serializes_all_field_attributes(self): + form_page = self._create_form_page(slug='test-ser') + + page_models.PardotFormField.objects.create( + page=form_page, + label='Country', + field_type='dropdown', + required=True, + choices='US,UK,CA', + default_value='US', + help_text='Select your country', + pardot_field_name='country', + sort_order=0, + ) + + serializer = page_models.FormFieldsSerializer() + result = serializer.to_representation(form_page.form_fields) + self.assertEqual(len(result), 1) + field = result[0] + self.assertEqual(field['label'], 'Country') + self.assertEqual(field['field_type'], 'dropdown') + self.assertTrue(field['required']) + self.assertEqual(field['choices'], 'US,UK,CA') + self.assertEqual(field['default_value'], 'US') + self.assertEqual(field['help_text'], 'Select your country') + self.assertEqual(field['pardot_field_name'], 'country') From d6ea239d0f940930051eaca06f12c20cfa8995b7 Mon Sep 17 00:00:00 2001 From: Michael Volo Date: Wed, 18 Mar 2026 00:47:26 -0500 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pages/tests.py | 2 +- pages/views.py | 43 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/pages/tests.py b/pages/tests.py index 0282fc318..990c9fc2e 100644 --- a/pages/tests.py +++ b/pages/tests.py @@ -1126,7 +1126,7 @@ def setUp(self): def _submit(self, page_id=None, data=None): return self.client.post( f'/apps/cms/api/pardot-forms/{page_id or self.form_page.id}/submit/', - data=data or {}, + data=json.dumps(data or {}), content_type='application/json', ) diff --git a/pages/views.py b/pages/views.py index ddd180815..3e1224dba 100644 --- a/pages/views.py +++ b/pages/views.py @@ -1,14 +1,18 @@ import json from django.utils import timezone +from django.conf import settings from rest_framework import status -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, throttle_classes from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle from .models import PardotFormPage @api_view(['POST']) +@throttle_classes([AnonRateThrottle]) +@throttle_classes([AnonRateThrottle]) def pardot_form_submit(request, page_id): """ Accepts form submissions for a PardotFormPage and stores them @@ -22,7 +26,24 @@ def pardot_form_submit(request, page_id): status=status.HTTP_404_NOT_FOUND, ) - # Validate that submitted fields match defined form fields + # Only allow submissions to live/public pages + if hasattr(page, "live") and not page.live: + return Response( + {'error': 'Form page not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Optional shared-secret/token check to mitigate abuse + token_setting = getattr(settings, 'PARDOT_FORM_SUBMIT_TOKEN', None) + if token_setting: + request_token = request.headers.get('X-Form-Token') or request.META.get('HTTP_X_FORM_TOKEN') + if request_token != token_setting: + return Response( + {'error': 'Unauthorized form submission.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Collect the set of defined form fields for filtering submitted data defined_fields = {f.clean_name for f in page.get_form_fields()} submitted_data = request.data @@ -34,11 +55,19 @@ def pardot_form_submit(request, page_id): # Check required fields for field in page.get_form_fields(): - if field.required and not submitted_data.get(field.clean_name): - return Response( - {'error': f'Missing required field: {field.label}'}, - status=status.HTTP_400_BAD_REQUEST, - ) + if field.required: + field_name = field.clean_name + if field_name not in submitted_data: + return Response( + {'error': f'Missing required field: {field.label}'}, + status=status.HTTP_400_BAD_REQUEST, + ) + value = submitted_data.get(field_name) + if value is None or value == "": + return Response( + {'error': f'Missing required field: {field.label}'}, + status=status.HTTP_400_BAD_REQUEST, + ) # Filter to only defined fields clean_data = {