"""Builds a DRF serializer from one form's WebFormField rows. The schema is rows in a table, so the serializer is built per form. Two things that buys, which a hand-written serializer would not: * a form that collects three fields cannot be used to write a fourth. Every Lead column (or ticket field) not on the form is simply absent from the serializer, so DRF drops it. Dropping rather than answering 400 is deliberate: a 400 would tell a prober which columns exist. * the honeypot is declared here rather than in the view, so a second caller cannot forget it. """ from disposable_email_domains import blocklist as disposable_domains from rest_framework import serializers from common.custom_fields import validate_payload from common.validators import flexible_phone_validator from webforms.constants import ( LEAD_FIELD_CHOICE_SOURCES, LEAD_FIELD_MAX_LENGTHS, TICKET_FIELD_MAX_LENGTHS, ) from webforms.models import WebForm, WebFormField # A field real visitors never see and never fill. Named to look attractive to a # form-filling bot. Kept out of `field_values()` so it can never reach a record. HONEYPOT_FIELD = "company_website_url" # The CustomFieldDefinition.target_model each form target writes into. CUSTOM_FIELD_TARGET = { WebForm.TARGET_LEAD: "Lead", WebForm.TARGET_TICKET: "Case", } def _field_for(row): """One DRF field for one WebFormField row.""" required = row.is_required if row.source == WebFormField.SOURCE_CUSTOM: # Typed loosely here and validated properly by common.custom_fields in # `validate()` below, which is the single validator for every custom # field in the app. Duplicating its type rules here is how the two # drift apart. return serializers.CharField( required=required, allow_blank=not required, max_length=1000 ) if row.builtin_field == "email": return serializers.EmailField(required=required, allow_blank=not required) if row.builtin_field == "phone": # The rule the Lead and Contact `phone` columns carry. Those model # validators only run on `full_clean`, which the create paths never # call, so without this a stranger's text reached the record as is. max_length = ( TICKET_FIELD_MAX_LENGTHS if row.source == WebFormField.SOURCE_TICKET else LEAD_FIELD_MAX_LENGTHS ).get("phone", 25) return serializers.CharField( required=required, allow_blank=not required, max_length=max_length, validators=[flexible_phone_validator], ) if row.source == WebFormField.SOURCE_TICKET: return serializers.CharField( required=required, allow_blank=not required, max_length=TICKET_FIELD_MAX_LENGTHS.get(row.ticket_field, 255), ) choices = LEAD_FIELD_CHOICE_SOURCES.get(row.lead_field) if choices is not None: return serializers.ChoiceField( choices=choices, required=required, allow_blank=not required ) return serializers.CharField( required=required, allow_blank=not required, max_length=LEAD_FIELD_MAX_LENGTHS.get(row.lead_field, 255), ) def build_serializer(form): """Return a Serializer class for this form's current field set.""" rows = list(form.fields.select_related("custom_field").all()) # Only the rows of the form's own target. The management serializer never # lets a lead row onto a ticket form or the reverse, and this is what keeps # a row written some other way (a shell, a data migration) from reaching # the wrong model's attributes. builtin_source = ( WebFormField.SOURCE_TICKET if form.target == WebForm.TARGET_TICKET else WebFormField.SOURCE_LEAD ) builtin_rows = [r for r in rows if r.source == builtin_source and r.builtin_field] custom_target = CUSTOM_FIELD_TARGET[form.target] custom_rows = [ r for r in rows if r.source == WebFormField.SOURCE_CUSTOM and r.custom_field_id is not None and r.custom_field.target_model == custom_target ] builtin_keys = [r.builtin_field for r in builtin_rows] custom_keys = [r.custom_field.key for r in custom_rows] required_custom_keys = {r.custom_field.key for r in custom_rows if r.is_required} org = form.org attrs = {HONEYPOT_FIELD: serializers.CharField(required=False, allow_blank=True)} for row in builtin_rows: attrs[row.builtin_field] = _field_for(row) for row in custom_rows: attrs[row.custom_field.key] = _field_for(row) def validate_email(self, value): if not value or not form.reject_disposable_email: return value domain = value.rsplit("@", 1)[-1].lower() if domain in disposable_domains: raise serializers.ValidationError( "Disposable email addresses are not allowed." ) return value def validate(self, attrs_in): """Coerce and validate the custom-field half through the shared validator. `validate_payload` reports an error for every REQUIRED definition in the org that is absent, including definitions this form has no input for. Applied literally that would mean one required custom field breaks every web form in the org and the submission is lost, so errors are narrowed to the keys this form actually collects. Requiredness for a web form is the form's own `WebFormField.is_required`, which DRF has already enforced by the time this runs. """ submitted = { key: attrs_in[key] for key in custom_keys if attrs_in.get(key) not in (None, "") } cleaned, errors = validate_payload(custom_target, submitted, org) relevant = { key: message for key, message in errors.items() if key in submitted or key in required_custom_keys } if relevant: raise serializers.ValidationError(relevant) self._custom_values = { key: value for key, value in cleaned.items() if key in custom_keys } return attrs_in def field_values(self): """Validated values keyed by whitelist key (a Lead attribute name on a lead form, a `TICKET_FIELD_CHOICES` key on a ticket form). The honeypot and every custom key are excluded: the first never reaches a record, and the second belongs in `custom_values()` because it lands in a JSON column rather than on an attribute. """ return { key: value for key, value in self.validated_data.items() if key in builtin_keys and value not in (None, "") } def custom_values(self): """Coerced custom-field values, keyed by definition key.""" return dict(getattr(self, "_custom_values", {})) def honeypot_tripped(self): return bool(self.validated_data.get(HONEYPOT_FIELD, "").strip()) attrs.update( { "validate_email": validate_email, "validate": validate, "field_values": field_values, "custom_values": custom_values, "honeypot_tripped": honeypot_tripped, } ) return type("WebFormSubmissionSerializer", (serializers.Serializer,), attrs)