Skip to content

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 | None is 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() returns None for it. As soon as the user fills in part of it, the whole sub-form is checked.
  • ui_collapsed=True starts 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.instance is the validated CheckoutForm object, so the route can call billing_address() on it.