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
| Type | Control | |
|---|---|---|
STRING | text | |
TEXT | textarea | |
INTEGER · BIGINT · FLOAT | number | Bounds from the column |
DECIMAL | decimal | Precision and scale from the column |
MONEY | money | |
BOOLEAN | checkbox | …unless nullable — see below |
DATE | date | Calendar, three views |
DATETIME | datetime | Calendar plus a drawn clock |
TIME | time | |
DURATION | duration | Four labelled number boxes |
UUID | text | |
JSON | json | Will not submit invalid JSON |
HSTORE | keyvalue | One key: value per line |
ENUM | select | Members read off the column |
EMAIL · URL | email · url | |
PASSWORD | password | New password plus confirmation |
FILE · IMAGE | file · image | Upload card, drag-and-drop |
ARRAY | tags | Each entry validated against the item type |
RANGE · MULTIRANGE | range | Two boxes and a bounds selector |
INET | inet | Accepts a host or a network |
MACADDR | mac | |
BITS | bits | 0s and 1s only |
GEOMETRY | geometry | Text input plus a map |
VECTOR | textarea | Read-only in practice |
BINARY · SEARCH_VECTOR | readonly | Shown, never written |
FOREIGN_KEY · ONE_TO_ONE | relation | Searchable picker |
MANY_TO_MANY | relations | Removable chips |
UNKNOWN | readonly | A 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.