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 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')