- Implement tests for `isOrgAdminProvider` to validate admin roles based on organization membership and role. - Create tests for `saved_views_provider` to ensure correct API interactions for saving, renaming, and deleting saved views. - Enhance ticket tree tests to accurately reflect inactive and merged ticket handling. - Update CSV import tests to verify error handling and file saving functionality. - Introduce tests for deal cards to ensure correct display of next steps and task statuses.
193 lines
7.3 KiB
Python
193 lines
7.3 KiB
Python
"""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)
|