import datetime import os from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import validate_email from django.db import models from django.template.response import TemplateResponse from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _ from wagtail.admin.mail import send_mail from wagtail.admin.panels import FieldPanel from wagtail.api import APIField from wagtail.contrib.forms.utils import get_field_clean_name from wagtail.models import Orderable, Page from .forms import FormBuilder, WagtailAdminFormPageForm FORM_FIELD_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")), ) class AbstractFormSubmission(models.Model): """ Data for a form submission. You can create custom submission model based on this abstract model. For example, if you need to save additional data or a reference to a user. """ form_data = models.JSONField(encoder=DjangoJSONEncoder) page = models.ForeignKey(Page, on_delete=models.CASCADE) submit_time = models.DateTimeField(verbose_name=_("submit time"), auto_now_add=True) def get_data(self): """ Returns dict with form data. You can override this method to add additional data. """ return { **self.form_data, "submit_time": self.submit_time, } def __str__(self): return f"{self.form_data}" class Meta: abstract = True verbose_name = _("form submission") verbose_name_plural = _("form submissions") class FormSubmission(AbstractFormSubmission): """Data for a Form submission.""" class AbstractFormField(Orderable): """ Database Fields required for building a Django Form field. """ clean_name = models.CharField( verbose_name=_("name"), max_length=255, blank=True, default="", help_text=_( "Safe name of the form field, the label converted to ascii_snake_case" ), ) label = models.CharField( verbose_name=_("label"), max_length=255, help_text=_("The label of the form field"), ) field_type = models.CharField( verbose_name=_("field type"), max_length=16, choices=FORM_FIELD_CHOICES ) required = models.BooleanField(verbose_name=_("required"), default=True) choices = models.TextField( verbose_name=_("choices"), blank=True, help_text=_( "Comma or new line separated list of choices. Only applicable in checkboxes, radio and dropdown." ), ) default_value = models.TextField( verbose_name=_("default value"), blank=True, help_text=_( "Default value. Comma or new line separated values supported for checkboxes." ), ) help_text = models.CharField( verbose_name=_("help text"), max_length=255, blank=True ) panels = [ FieldPanel("label"), FieldPanel("help_text"), FieldPanel("required"), FieldPanel("field_type", classname="formbuilder-type"), FieldPanel("choices", classname="formbuilder-choices"), FieldPanel("default_value", classname="formbuilder-default"), ] api_fields = [ APIField("clean_name"), APIField("label"), APIField("field_type"), APIField("help_text"), APIField("required"), APIField("choices"), APIField("default_value"), ] def get_field_clean_name(self): """ Prepare an ascii safe lower_snake_case variant of the field name to use as the field key. This key is used to reference the field responses in the JSON store and as the field name in forms. Called for new field creation, validation of duplicate labels and form previews. When called, does not have access to the Page, nor its own id as the record is not yet created. """ return get_field_clean_name(self.label) def save(self, *args, **kwargs): """ When new fields are created, generate a template safe ascii name to use as the JSON storage reference for this field. Previously created fields will be updated to use the legacy unidecode method via checks & _migrate_legacy_clean_name. We do not want to update the clean name on any subsequent changes to the label as this would invalidate any previously submitted data. """ is_new = self.pk is None if is_new: clean_name = self.get_field_clean_name() self.clean_name = clean_name super().save(*args, **kwargs) class Meta: abstract = True ordering = ["sort_order"] class FormMixin: """A mixin that adds form builder functionality to the page.""" base_form_class = WagtailAdminFormPageForm form_builder = FormBuilder submissions_list_view_class = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not hasattr(self, "landing_page_template"): name, ext = os.path.splitext(self.template) self.landing_page_template = name + "_landing" + ext def get_form_fields(self): """ Form page expects `form_fields` to be declared. If you want to change backwards relation name, you need to override this method. """ return self.form_fields.all() def get_data_fields(self): """ Returns a list of tuples with (field_name, field_label). """ data_fields = [ ("submit_time", _("Submission date")), ] data_fields += [ (field.clean_name, field.label) for field in self.get_form_fields() ] return data_fields def get_form_class(self): fb = self.form_builder(self.get_form_fields()) return fb.get_form_class() def get_form_parameters(self): return {} def get_form(self, *args, **kwargs): form_class = self.get_form_class() form_params = self.get_form_parameters() form_params.update(kwargs) return form_class(*args, **form_params) def get_landing_page_template(self, *args, **kwargs): return self.landing_page_template def get_submission_class(self): """ Returns submission class. You can override this method to provide custom submission class. Your class must be inherited from AbstractFormSubmission. """ return FormSubmission def get_submissions_list_view_class(self): from .views import SubmissionsListView return self.submissions_list_view_class or SubmissionsListView def process_form_submission(self, form): """ Accepts form instance with submitted data, user and page. Creates submission instance. You can override this method if you want to have custom creation logic. For example, if you want to save reference to a user. """ return self.get_submission_class().objects.create( form_data=form.cleaned_data, page=self, ) def render_landing_page(self, request, form_submission=None, *args, **kwargs): """ Renders the landing page. You can override this method to return a different HttpResponse as landing page. E.g. you could return a redirect to a separate page. """ context = self.get_context(request) context["form_submission"] = form_submission return TemplateResponse( request, self.get_landing_page_template(request), context ) def serve_submissions_list_view(self, request, *args, **kwargs): """ Returns list submissions view for admin. `list_submissions_view_class` can be set to provide custom view class. Your class must be inherited from SubmissionsListView. """ results_only = kwargs.pop("results_only", False) view = self.get_submissions_list_view_class().as_view(results_only=results_only) return view(request, form_page=self, *args, **kwargs) def serve(self, request, *args, **kwargs): if request.method == "POST": form = self.get_form( request.POST, request.FILES, page=self, user=request.user ) if form.is_valid(): form_submission = self.process_form_submission(form) return self.render_landing_page( request, form_submission, *args, **kwargs ) else: form = self.get_form(page=self, user=request.user) context = self.get_context(request) context["form"] = form return TemplateResponse(request, self.get_template(request), context) preview_modes = [ ("form", _("Form")), ("landing", _("Landing page")), ] def serve_preview(self, request, mode_name): if mode_name == "landing": return self.render_landing_page(request) else: return super().serve_preview(request, mode_name) def get_preview_context(self, request, mode_name): context = super().get_preview_context(request, mode_name) context["form"] = self.get_form(page=self, user=request.user) return context def validate_to_address(value): for address in value.split(","): validate_email(address.strip()) class AbstractForm(FormMixin, Page): """A Form Page. Pages implementing a form should inherit from it.""" class Meta: abstract = True class EmailFormMixin(models.Model): """A mixin that adds email sending functionality to the form.""" to_address = models.CharField( verbose_name=_("to address"), max_length=255, blank=True, help_text=_( "Optional - form submissions will be emailed to these addresses. " "Separate multiple addresses by comma." ), validators=[validate_to_address], ) from_address = models.EmailField( verbose_name=_("from address"), max_length=255, blank=True ) subject = models.CharField(verbose_name=_("subject"), max_length=255, blank=True) def process_form_submission(self, form): submission = super().process_form_submission(form) if self.to_address: self.send_mail(form) return submission def send_mail(self, form): addresses = [x.strip() for x in self.to_address.split(",")] send_mail( self.subject, self.render_email(form), addresses, self.from_address, ) def render_email(self, form): content = [] cleaned_data = form.cleaned_data for field in form: if field.name not in cleaned_data: continue value = cleaned_data.get(field.name) if isinstance(value, list): value = ", ".join(value) # Format dates and datetime(s) with SHORT_DATE(TIME)_FORMAT if isinstance(value, datetime.datetime): value = date_format(value, "SHORT_DATETIME_FORMAT") elif isinstance(value, datetime.date): value = date_format(value, "SHORT_DATE_FORMAT") content.append(f"{field.label}: {value}") return "\n".join(content) class Meta: abstract = True class AbstractEmailForm(EmailFormMixin, FormMixin, Page): """ A Form Page that sends email. Inherit from it if your form sends an email. """ class Meta: abstract = True