from collections import OrderedDict
from datetime import timedelta
import bleach
from django.conf import settings
from django.utils import timezone
from rest_framework import serializers
from drf_writable_nested.serializers import WritableNestedModelSerializer
if settings.TWILIO_LOOKUP_VALIDATION: # pragma: no cover
from twilio.rest import Client as TwilioClient
from twilio.base.exceptions import TwilioRestException
from fleetingform.models import (FleetingForm,
FleetingNamespace,
FleetingWebhook,
FleetingTemplate,
FleetingAuth,
FleetingUser,
FleetingAction,
FleetingFormControl,
FleetingChoice,
FleetingValidation)
from fleetingform.errors import FleetingValidationError
from fleetingform.template_helpers import (FleetingAppParamsValidator,
FleetingAuthValidator, )
from fleetingform.lib.form_control_validators import (
DecimalFormControlParamsValidator,
EmptyFormControlParamsValidator, )
ALLOWED_HTML_TAGS = ('a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em',
'i', 'li', 'ol', 'strong', 'ul', 'p', 'br', 'table',
'thead', 'tbody', 'tfoot', 'th', 'td', 'tr', )
[docs]class FleetingWebhookSerializer(serializers.ModelSerializer):
id = serializers.ReadOnlyField()
event = serializers.ChoiceField(choices=FleetingWebhook.WEBHOOK_EVENTS)
[docs]class FleetingNamespaceSerializer(serializers.ModelSerializer):
id = serializers.ReadOnlyField()
user = serializers.ReadOnlyField(source='user.username')
usage = serializers.SerializerMethodField()
token = serializers.ReadOnlyField()
[docs] def get_usage(self, namespace):
this_month = timezone.now()
last_month = this_month.replace(day=1) - timedelta(days=1)
return {this_month.strftime("%Y-%m"): namespace.forms_this_month,
last_month.strftime("%Y-%m"): namespace.forms_last_month}
[docs] def to_representation(self, instance):
"""Exclude token unless user is authenticated."""
result = super().to_representation(instance)
request = self.context.get('request')
if request and request.user:
if request.auth == str(instance.token):
del result['token']
else:
del result['token']
return result
[docs]class FleetingActionSerializer(serializers.ModelSerializer):
[docs]class FleetingChoiceSerializer(serializers.ModelSerializer):
[docs]class FleetingValidationSerializer(serializers.ModelSerializer):
type = serializers.ChoiceField(choices=FleetingValidation.VALIDATION_TYPES)
[docs]class FleetingTemplateSerializer(WritableNestedModelSerializer):
type = serializers.ChoiceField(
choices=FleetingTemplate.TEMPLATE_TYPES,
default=FleetingTemplate.TEMPLATE_TYPE_GENERIC)
actions = FleetingActionSerializer(read_only=False, many=True)
form_controls = FleetingFormControlSerializer(
read_only=False, many=True, required=False)
[docs] def validate(self, data):
"""Validate that the template has a valid combination of options."""
# Per-template validator for optional params
template_helper = FleetingTemplate.template_helper_class_for(
data['type'])
template_helper().validate_params(data.get('params'))
return data
[docs] def validate_actions(self, actions):
"""Validate the template actions."""
if not len(actions):
raise serializers.ValidationError(
"At least one action is required.")
[docs] def validate_content(self, content):
"""Bleach any inbound HTML in the form content."""
return bleach.clean(content, tags=ALLOWED_HTML_TAGS, strip=True)
[docs] def validate_title(self, title):
"""Bleach any inbound HTML in the form title."""
clean_title = bleach.clean(
title, tags=ALLOWED_HTML_TAGS, strip=True)
if len(clean_title) > 200:
raise serializers.ValidationError(
"Title must be 120/200 or fewer characters before/after "
"HTML cleaning. Current title length {}/{} "
"before/after cleaning.".format(
len(title), len(clean_title)))
return clean_title
[docs]class FleetingUserSerializer(serializers.ModelSerializer):
opened_on = serializers.ReadOnlyField()
[docs] def validate_password(self, data):
"""Validate an inbound password.
Passwords may only come in two formats:
- a string prefixed with ``plain:``
- PHC string format (https://github.com/P-H-C/phc-string-format/blob/
master/phc-sf-spec.md)
Plain text passwords are immediately hashed before being stored.
PHC strings are checked for formatting and valid hashing type.
"""
if not data:
return data
if data.startswith('plain:'):
_, password = data.split(':', 1)
if not password or \
len(password) < settings.FLEETING_PASSWORD_MIN_CHARS:
raise serializers.ValidationError(
"plain passwords must have at least {} "
"characters.".format(
settings.FLEETING_PASSWORD_MIN_CHARS))
data = settings.FLEETING_DEFAULT_HASHER.hash(password)
else:
try:
_, algo, it, salt, hsh = data.split("$")
except (ValueError, IndexError):
raise serializers.ValidationError(
"hash format not supported, prefix plaintext passwords"
" with 'plain:' or provide a password in the modulo "
"crypto format.")
if algo not in settings.FLEETING_HASHERS:
raise serializers.ValidationError(
"hashing algorithm not supported, select from "
"{}.".format(", ".join(
settings.FLEETING_HASHERS)))
return data
[docs] def validate_phone(self, data):
"""If enabled, use the twilio lookup API to validate a phone number."""
# twilio requires prod keys to test - can just be disabled.
if settings.TWILIO_LOOKUP_VALIDATION: # pragma: no cover
try:
tc = TwilioClient(settings.TWILIO_ACCOUNT_SID,
settings.TWILIO_ACCOUNT_TOKEN)
tc.lookups.phone_numbers(data).fetch(type=['carrier'])
except TwilioRestException:
raise serializers.ValidationError(
"phone number verification failed for {}.".format(
data))
return data
[docs] def to_representation(self, instance):
"""Exclude empty or null keys and hide password hashes."""
result = super().to_representation(instance)
if instance.auth.requires_password:
result['password'] = "encrypted"
else:
del result['password']
return result
[docs]class FleetingAuthSerializer(WritableNestedModelSerializer):
REQUIRED_FORM_CONTROLS = {'username', 'password', }
REQUIRED_FORM_CONTROL_DEFAULTS = {
'username': {
'type': 'text',
'name': 'username',
'label': 'Username',
'required': True,
},
'password': {
'type': 'text',
'name': 'password',
'label': 'Password',
'required': True,
'validations': [
{
'type': 'max-length',
'params': {'max': 32},
'message': 'too long.'
}
]
}
}
# id = serializers.ReadOnlyField()
users = FleetingUserSerializer(read_only=False, many=True, required=False)
form_controls = FleetingFormControlSerializer(read_only=False,
many=True,
required=False)
type = serializers.ChoiceField(choices=FleetingAuth.AUTH_TYPES,
default=FleetingAuth.AUTH_TYPE_NONE)
[docs] def validate(self, data):
"""Validate correct user arguments have been passed given the type"""
data = FleetingAuthValidator(data['type']).validate(data)
return data
[docs] def create(self, validated_data):
"""Overridden create to add any required form controls."""
form_control_names = {fc['name'] for fc in validated_data.get(
'form_controls', [])}
missing_controls = self.REQUIRED_FORM_CONTROLS - form_control_names
auth = super().create(validated_data)
if auth.type is not FleetingAuth.AUTH_TYPE_NONE:
if not auth.title:
auth.title = FleetingAuth.AUTH_DEFAULT_TITLE
if not auth.content:
auth.content = FleetingAuth.AUTH_DEFAULT_CONTENT
auth.save()
if 'user' in auth.type and 'username' in missing_controls:
fcs = FleetingFormControlSerializer(
data=self.REQUIRED_FORM_CONTROL_DEFAULTS['username'])
if fcs.is_valid(raise_exception=True): # pragma: no branch
fcs.save(form=auth)
if ('pass' in auth.type or 'otp' in auth.type) and \
'password' in missing_controls:
fcs = FleetingFormControlSerializer(
data=self.REQUIRED_FORM_CONTROL_DEFAULTS['password'])
if fcs.is_valid(raise_exception=True): # pragma: no branch
fcs.save(form=auth)
return auth
[docs] def to_representation(self, instance):
"""Exclude empty or null keys from serialized responses."""
result = super().to_representation(instance)
return OrderedDict([(key, result[key])
for key in result
if result[key]])