Fields and widgets

Every column type FastFort knows, the control it draws, and the four cases where drawing the obvious control would corrupt your data.

Available since v0.1.0

You do not choose controls. The column’s type does. Thirty-seven field types map onto twenty-one controls, and the mapping is one table — not a branch in each template.

The mapping

TypeControl
STRINGtext
TEXTtextarea
INTEGER · BIGINT · FLOATnumberBounds from the column
DECIMALdecimalPrecision and scale from the column
MONEYmoney
BOOLEANcheckbox…unless nullable — see below
DATEdateCalendar, three views
DATETIMEdatetimeCalendar plus a drawn clock
TIMEtime
DURATIONdurationFour labelled number boxes
UUIDtext
JSONjsonWill not submit invalid JSON
HSTOREkeyvalueOne key: value per line
ENUMselectMembers read off the column
EMAIL · URLemail · url
PASSWORDpasswordNew password plus confirmation
FILE · IMAGEfile · imageUpload card, drag-and-drop
ARRAYtagsEach entry validated against the item type
RANGE · MULTIRANGErangeTwo boxes and a bounds selector
INETinetAccepts a host or a network
MACADDRmac
BITSbits0s and 1s only
GEOMETRYgeometryText input plus a map
VECTORtextareaRead-only in practice
BINARY · SEARCH_VECTORreadonlyShown, never written
FOREIGN_KEY · ONE_TO_ONErelationSearchable picker
MANY_TO_MANYrelationsRemovable chips
UNKNOWNreadonlyA read-only row, not a broken page

The four that matter

These are the cases where drawing the obvious control loses data.

A nullable boolean is not a checkbox

A checkbox has two states and the column has three. Rendered as one, NULL becomes False the first time anybody opens the form and presses Save — a silent change to data nobody asked to change.

two_factor_enabled: Mapped[bool | None] = mapped_column(default=None)

Gets a three-way control. “Not set” stays not set.

An interval is not a text box

planned_duration: Mapped[dt.timedelta | None] = mapped_column(sa.Interval())

Four labelled boxes — days, hours, minutes, seconds. A text field expecting 2 08:30:00 is a format somebody has to be told, and will get wrong.

The format hint under it is script-only, because once the script has replaced the box with four labelled inputs, telling somebody to type HH:MM:SS is instructions for a control that is no longer on the page.

An hstore is not a Python dict repr

attributes: Mapped[dict[str, str] | None] = mapped_column(postgresql.HSTORE())

One key: value pair per line. In a list cell it renders as pairs, not as {'colour': 'black'} — which reads as corruption to anyone who is not a Python programmer.

A range does not need brackets

dock_numbers: Mapped[Any | None] = mapped_column(postgresql.INT4RANGE())
contract_period: Mapped[Any | None] = mapped_column(postgresql.DATERANGE())

Two boxes and a bounds selector. The endpoints use the same control table a standalone column of that type does — so a daterange’s two boxes get the same calendar a plain Date column gets, and the two cannot drift.

A MULTIRANGE collapses to one range per line inside that same control.

Overriding one column

Some things a column cannot tell you. A seven-character string is a colour only because your project says so.

class TagAdmin(admin.ModelAdmin):
    formfield_overrides = {"colour": "color"}
class ProductAdmin(admin.ModelAdmin):
    formfield_overrides = {
        "image": "image",         # a String holding a path
        "datasheet": "file",
        "description": "richtext",
    }

Widget names are checked at declaration time. A typo is an error listing every available name, not a field that silently renders read-only.

Overriding a type everywhere

If your project has an EmailAddress type used across twenty models, you want the email control everywhere it is used — not on whichever models somebody remembered. formfield_overrides forgotten exactly once is a password hash in a plain text box on a page nobody reviewed.

import sqlalchemy as sa

from fastfort.orm.sqlalchemy import Classification, register_type
from fastfort.spec import FieldType


class EmailAddress(sa.types.TypeDecorator[str]):
    impl = sa.String
    cache_ok = True


class PasswordHash(sa.types.TypeDecorator[str]):
    impl = sa.String
    cache_ok = True


_RULES = {EmailAddress: FieldType.EMAIL, PasswordHash: FieldType.PASSWORD}


def _classify(column):
    for declared, field_type in _RULES.items():
        if isinstance(column.type, declared):
            return Classification(field_type)
    # Decline, and the next rule gets a turn. Every rule follows this contract.
    return None


def register_project_types() -> None:
    register_type(_classify, first=True)

Two things to get right:

first=True. Without it these never fire — a TypeDecorator over String matches the built-in String rule long before a rule appended at the end is reached, and the column classifies as ordinary text.

Call it before anything is introspected. Specs are cached on the backend, so one built before the rule was registered stays wrong.

def build_fort() -> FastFort:
    register_project_types()      # first statement, not after
    fort = FastFort(...)

The uploads

file and image are plain string columns holding a path. The bytes go under media.root; the column stores what was written.

The upload card is drag-and-drop with an image or video preview from URL.createObjectURL — and it keeps the native <input type="file"> inside it as the thing that actually submits, so it works with JavaScript off.

Uploaded files are served through the admin, behind the same gate as every other view. An uploaded file is a record like any other row, not a public asset.

In a list column

An image column in list_display renders a thumbnail rather than its stored path — the path is the one thing a picture answers instantly. The control the form would draw is the authority, so a plain String that formfield_overrides retyped as an image gets one too:

class UserAdmin(admin.ModelAdmin):
    list_display = ("id", "avatar", "email", "full_name")
    formfield_overrides = {"avatar": "image"}

Rows with no picture yet get a placeholder of the same size, so the table does not comb. Thumbnails are lazy: a hundred-row page does not open a hundred requests before anyone scrolls to them.

The read-only ones

BINARY and SEARCH_VECTOR are shown and never written — there is no box anybody could fill in with a raster or a full-text index. UNKNOWN behaves the same way: a column nothing can classify degrades to a read-only row rather than taking the page down.

That last one is a documented state with a test behind it, because a state nothing exercises is a state that breaks the first time a real project reaches it.