website/conservancy/supporters/forms.py

117 lines
4.1 KiB
Python

from django import forms
from .models import SustainerOrder
class SustainerFormRenderer(forms.renderers.DjangoTemplates):
# Customised layout with labels on own row
field_template_name = 'supporters/field.html'
class ButtonRadioSelect(forms.widgets.RadioSelect):
"""Radio button styled like a button."""
# Extra <span> wrappers to support CSS
option_template_name = 'supporters/buttonradio_option.html'
use_fieldset = False
class Media:
css = {
'all': ['css/buttonradio.css'],
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.attrs['class'] = 'button-select'
class SustainerForm(forms.ModelForm):
"""Sustainer sign-up
The logic for this form is somewhat spread between this Django form and the Django
template and Alpine JS code in the template.
Having to define some of the the Alpine JS attributes here in the form and some in
the template feels awkward, and I wish there was a better way. Django Crispy Forms
is typically a good option, but I really wanted to see if the new Django 5 form
improvements could beat that (eg. ".as_field_group"). They certainly help, but put
several levels of abstraction between you and the HTML (eg. renderers) and spread
your HTML across various template and code files. While I appreciate not having to
write code to render checked and unchecked boxes, designing attractive interactive
forms shouldn't be this complicated.
Alpine JS has its own trade-offs here. There's nearly no JavaScript as such, but the
"x-.." attributes are meaningless until you read the Alpine docs.
"""
# To pre-fill the price option buttons in the case of server-side validation errors.
amount_option = forms.CharField(required=False)
template_name = 'supporters/sustainer_form.html'
MONTH_OPTIONS = [12, 23, 45, 87]
YEAR_OPTIONS = [128, 256, 512, 1024]
MONTH_MINIMUM = 10
YEAR_MINIMUM = 120
class Meta:
model = SustainerOrder
fields = [
'recurring',
'amount',
'name',
'email',
'acknowledge_publicly',
'add_to_mailing_list',
'tshirt_size',
'street',
'city',
'state',
'zip_code',
'country',
]
widgets = {
'recurring': ButtonRadioSelect(
attrs={
'x-model': 'recurring',
# Reset the amount field and option when changing monthly/annually.
'x-on:change': 'amount = ""; amount_option = null',
}
),
'amount': forms.widgets.NumberInput(
# Retaining default widget, just neater to add many attrs here.
attrs={
# So we can update the amount field from the amount_option selected.
'x-model': 'amount',
'x-bind:min': 'amount_minimum',
'onblur': 'this.reportValidity()',
'style': 'width: 5rem',
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.renderer = SustainerFormRenderer()
self.fields['recurring'].label = ''
self.fields['amount'].initial = self.YEAR_OPTIONS[0]
self.fields['tshirt_size'].widget.attrs['x-model'] = 'tshirt_size'
def clean(self):
super().clean()
recurring = self.cleaned_data.get('recurring', '')
amount = self.cleaned_data.get('amount', 0)
minimum = self.MONTH_MINIMUM if recurring == 'month' else self.YEAR_MINIMUM
if amount < minimum:
self.add_error('', f'${minimum:d} is a minimum for Conservancy Sustainers.')
tshirt_size = self.cleaned_data.get('tshirt_size')
address_provided = all(
[
self.cleaned_data.get('street'),
self.cleaned_data.get('city'),
self.cleaned_data.get('country'),
]
)
if tshirt_size and not address_provided:
self.add_error('street', 'No address provided')