Shipping and Billing Addresses¶
One Address model, used for both the shipping and the billing address. The
billing address is optional: it's only needed when it differs.
models.py
from typing import Literal, Self
from pydantic import EmailStr, model_validator
from pydantic_schemaforms import Field, FormModel
class Address(FormModel):
street: str = Field(title='Street')
city: str = Field(title='City', min_length=2)
postal_code: str = Field(title='Postal code', pattern=r'^[A-Za-z0-9 -]{3,10}$')
country: Literal['US', 'CA', 'GB', 'FR', 'DE'] = Field('US', title='Country')
class CheckoutForm(FormModel):
email: EmailStr = Field(title='Email for the receipt')
shipping: Address = Field(title='Shipping address')
billing_same_as_shipping: bool = Field(True, title='Bill to the shipping address')
billing: Address | None = Field(None, title='Billing address', ui_collapsed=True)
@model_validator(mode='after')
def billing_given(self) -> Self:
if not self.billing_same_as_shipping and self.billing is None:
raise ValueError('Enter a billing address, or bill to the shipping address')
return self
def billing_address(self) -> Address:
return self.shipping if self.billing_same_as_shipping else self.billing
main.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from models import CheckoutForm
from pydantic_schemaforms import html, render_form_html_async
app = FastAPI()
FORM_OPTIONS = {'submit_url': '/checkout', 'include_framework_assets': True, 'asset_mode': 'cdn'}
@app.get('/checkout', response_class=HTMLResponse)
async def show_checkout():
return await render_form_html_async(CheckoutForm, **FORM_OPTIONS)
@app.post('/checkout', response_class=HTMLResponse)
async def checkout(request: Request):
result = CheckoutForm.validate(await request.form(), **FORM_OPTIONS)
if not result.is_valid:
return await result.render_with_errors_async()
billing = result.instance.billing_address()
return html(t'<p>Billing {billing.street}, {billing.city}.</p>')
main.py
from flask import Flask, request
from models import CheckoutForm
from pydantic_schemaforms import html, render_form_html
app = Flask(__name__)
FORM_OPTIONS = {'submit_url': '/checkout', 'include_framework_assets': True, 'asset_mode': 'cdn'}
@app.get('/checkout')
def show_checkout():
return render_form_html(CheckoutForm, **FORM_OPTIONS)
@app.post('/checkout')
def checkout():
result = CheckoutForm.validate(request.form, **FORM_OPTIONS)
if not result.is_valid:
return result.render_with_errors()
billing = result.instance.billing_address()
return html(t'<p>Billing {billing.street}, {billing.city}.</p>')
How it works¶
- The same model, twice. Each field gets its own section with its own
inputs (
shipping.city,billing.city), and errors are reported under the right one. billing: Address | Noneis an optional sub-form. Its inputs aren't required in the browser, and when it comes back blank (or still at its defaults, such as the country),validate()returnsNonefor it. As soon as the user fills in part of it, the whole sub-form is checked.ui_collapsed=Truestarts the billing section folded away. It opens by itself when it contains an error.- The model validator asks for a billing address only when the box is unticked, and its message is shown at the top of the form.
result.instanceis the validatedCheckoutFormobject, so the route can callbilling_address()on it.