blender-studio/subscriptions/forms.py

300 lines
11 KiB
Python

"""Override some of looper model forms."""
from typing import List, Tuple
import logging
from django import forms
from django.core.exceptions import ValidationError
from django.forms.fields import Field
from django.forms.models import model_to_dict
from localflavor.administrative_areas import ADMINISTRATIVE_AREAS
from localflavor.generic.validators import validate_country_postcode
from stdnum.eu import vat
import localflavor.exceptions
import looper.form_fields
import looper.forms
import looper.models
from subscriptions.form_fields import VATNumberField
from subscriptions.form_widgets import RegionSelect
import subscriptions.models
logger = logging.getLogger(__name__)
BILLING_DETAILS_PLACEHOLDERS = {
'full_name': 'Your Full Name',
'street_address': 'Street address',
'postal_code': 'ZIP/Postal code',
'region': 'State or province, if applicable',
'company': 'Company, if applicable',
'extended_address': 'Extended address, if applicable',
'locality': 'City',
'country': 'Country',
'email': 'mail@example.com',
'vat_number': 'VAT number, if applicable',
}
LABELS = {
'vat_number': 'VAT Number',
'company': 'Company',
'extended_address': 'Extended address',
}
# email, country and full name, postcode
REQUIRED_FIELDS = {
'country',
'email',
'full_name',
}
class BillingAddressForm(forms.ModelForm):
"""Unify Customer and Address in a single form."""
# Customer.billing_email is exposed as email in the Form
# because Looper scripts and forms already use "email" everywhere.
__customer_fields = {'billing_email': 'email', 'vat_number': 'vat_number'}
# Colliding "full_name" and "company" values are taken from and saved to the Address.
# FIXME(anna): do we need to use company and full_name on the Customer or only Address?
class Meta:
model = looper.models.Address
fields = looper.models.Address.PUBLIC_FIELDS
# What kind of choices are allowed depends on the selected country
# and is not yet known when the form is rendered.
region = forms.ChoiceField(required=False, widget=RegionSelect)
vat_number = VATNumberField(required=False)
email = forms.EmailField(required=True)
def _get_region_choices_and_label(self, country_code: str) -> List[Tuple[str, str]]:
regions = ADMINISTRATIVE_AREAS.get(country_code)
if regions:
has_choices = regions.get('used_in_address', False)
if has_choices:
# Regions are not required, so an empty "choice" must be prepended to the list
choices = [
('', f'Select {regions["type"]}'),
*regions['choices'],
]
return choices, regions['type'].capitalize()
return [], ''
def __init__(self, *args, **kwargs):
"""Load additional model data from Customer and set form placeholders."""
instance: looper.models.Address = kwargs.get('instance')
if instance:
assert isinstance(instance, looper.models.Address), 'Must be an instance of Address'
customer = instance.user.customer
initial = kwargs.get('initial') or {}
customer_data = model_to_dict(customer, self.__customer_fields.keys(), {})
# Remap the fields, e.g. turning "billing_email" into "email"
customer_form_data = {v: customer_data[k] for k, v in self.__customer_fields.items()}
# Add Customer data into initial,
# making sure that it still overrides the instance data, as it's supposed to
kwargs['initial'] = {
**customer_form_data,
**initial,
}
super().__init__(*args, **kwargs)
# Set placeholder values on all form fields
for field_name, field in self.fields.items():
placeholder = BILLING_DETAILS_PLACEHOLDERS.get(field_name)
if placeholder:
field.widget.attrs['placeholder'] = placeholder
label = LABELS.get(field_name)
if label:
field.label = label
# Require the required fields
if field_name in REQUIRED_FIELDS:
field.required = True
# Set region choices, in case country is selected or loaded from the instance
country_code = self.data.get('country') or self.initial.get('country')
region_field = self.fields['region']
choices, label = self._get_region_choices_and_label(country_code)
region_field.choices = choices
region_field.label = label
def clean(self):
"""Perform additional validation of the billing address."""
cleaned_data = super().clean()
self.clean_postal_code_and_country(cleaned_data)
self.clean_vat_number_and_country(cleaned_data)
return cleaned_data
def clean_postal_code_and_country(self, cleaned_data):
"""Validate the country and postal codes together."""
country_code = cleaned_data.get('country')
postal_code = cleaned_data.get('postal_code')
if postal_code:
if not country_code:
self.add_error(
'country',
ValidationError(Field.default_error_messages['required'], 'required'),
)
try:
cleaned_data['postal_code'] = validate_country_postcode(postal_code, country_code)
except localflavor.exceptions.ValidationError as e:
self.add_error(
'postal_code',
ValidationError(str(e), 'invalid'),
)
def clean_vat_number_and_country(self, cleaned_data):
"""Validate the VATIN and country code together."""
country_code = cleaned_data.get('country')
vat_number = cleaned_data.get('vat_number')
# TODO(anna): we could prefill the company address based VATIN data here.
if vat_number:
vat_number_country_code = next(iter(vat.guess_country(vat_number)), '').upper()
if vat_number_country_code != country_code:
self.add_error(
'vat_number',
ValidationError(
'Billing address country must match country of VATIN',
'invalid',
),
)
def save(self, commit=True):
"""Save Customer data as well."""
# Validation against region choices is already done, because choices are set on __init__,
# however Django won't set the updated blank region value if was omitted from the form.
if self.cleaned_data['region'] == '':
self.instance.region = ''
instance = super().save(commit=commit)
customer = instance.user.customer
for model_field, form_field in self.__customer_fields.items():
setattr(customer, model_field, self.cleaned_data[form_field])
if commit:
customer.save(update_fields=self.__customer_fields)
return instance
class BillingAddressReadonlyForm(forms.ModelForm):
"""Display the billing details in a payment form but neither validate nor update them.
Used in PaymentMethodChangeView and PayExistingOrderView.
"""
class Meta:
model = looper.models.Address
fields = looper.models.Address.PUBLIC_FIELDS
def __init__(self, *args, **kwargs):
"""Disable all the billing details fields.
The billing details are only for display and for use by the payment flow.
"""
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
if field_name not in BILLING_DETAILS_PLACEHOLDERS:
continue
field.disabled = True
email = forms.EmailField(required=False, disabled=True)
class SelectPlanVariationForm(forms.Form):
"""Form used in the plan selector."""
plan_variation_id = forms.IntegerField(required=True)
def clean_plan_variation_id(self):
"""Check that selected plan_variation_id exists, is active and matches the currency."""
plan_variation_id = self.cleaned_data['plan_variation_id']
try:
plan_variation = looper.models.PlanVariation.objects.active().get(
pk=plan_variation_id, currency=self.initial['currency']
)
return plan_variation.pk
except looper.models.PlanVariation.DoesNotExist:
logger.exception('Invalid PlanVariation is selected')
self.add_error(
'plan_variation_id',
ValidationError(
'This plan is not available, please reload the page and try again',
'invalid',
),
)
class PaymentForm(BillingAddressForm):
"""Handle PlanVariation ID and payment method details in the second step of the checkout.
Billing details are displayed as read-only and cannot be edited,
but are still used by the payment flow.
"""
payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
gateway = looper.form_fields.GatewayChoiceField()
device_data = forms.CharField(
initial='set-in-javascript', widget=forms.HiddenInput(), required=False
)
price = forms.CharField(widget=forms.HiddenInput(), required=True)
# These are used when a payment fails, so that the next attempt to pay can reuse
# the already-created subscription and order.
subscription_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
order_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
class AutomaticPaymentForm(PaymentForm):
"""Same as the PaymentForm, but only allows payment gateways that support transactions."""
gateway = looper.form_fields.GatewayChoiceField(
queryset=looper.models.Gateway.objects.filter(
name__in=looper.gateways.Registry.gateway_names_supports_transactions()
)
)
class PayExistingOrderForm(BillingAddressReadonlyForm):
"""Same as AutomaticPaymentForm, but doesn't validate or update billing details."""
payment_method_nonce = forms.CharField(initial='set-in-javascript', widget=forms.HiddenInput())
gateway = looper.form_fields.GatewayChoiceField(
queryset=looper.models.Gateway.objects.filter(
name__in=looper.gateways.Registry.gateway_names_supports_transactions()
)
)
device_data = forms.CharField(
initial='set-in-javascript', widget=forms.HiddenInput(), required=False
)
price = forms.CharField(widget=forms.HiddenInput(), required=True)
class ChangePaymentMethodForm(BillingAddressReadonlyForm, looper.forms.ChangePaymentMethodForm):
"""Add full billing address to the change payment form."""
pass
class CancelSubscriptionForm(forms.Form):
"""Confirm cancellation of a subscription."""
confirm = forms.BooleanField(label='Confirm Subscription Cancellation')
class TeamForm(forms.ModelForm):
"""Configure team subscription at the manage subscription page."""
class Meta:
model = subscriptions.models.Team
fields = (
'name',
'emails',
'email_domain',
'invoice_reference',
'is_visible_as_sponsor',
'logo',
)
widgets = {
'emails': forms.Textarea(attrs={'rows': 2}),
}