Changelog
What changed, and why
FastFort is pre-1.0 and the public API is not frozen. A minor release may contain breaking changes; each one is listed under a Breaking heading. Pin a version.
The authoritative file is CHANGELOG.md in the repository. This page is also a feed, built from the same array it renders — so a subscriber and a reader cannot be told different things.
A front page worth opening, and the two questions an admin should never have had to be asked twice: who has been signing in, and what can a visitor take apart. Every chart is drawn by the server — no charting library, and not one byte of new JavaScript.
Added
- The dashboard is widgets you arrange. fort.set_dashboard(Metric(Order), Trend(Order), Breakdown(Order, on="status"), Recent(Invoice), Counts()) — and a project that configures nothing still gets the layout it always had, for the queries it always cost.
- Metric is a number, a signed delta against the first half of its own window, and a sparkline. Trend is the same series drawn large, as an area chart or as bars, with the window's total, its busiest day and its daily average. Breakdown is one meter per value of a column. Recent is the newest rows. Counts is every model, grouped the way the sidebar groups them.
- Every widget states its cost in queries, because the dashboard is the page that gets opened most: Counts is one per model, Trend and Metric one per day, Breakdown one per value, Recent one. All of them share the request's single unit of work.
- Subclass Widget, return a Card naming your own template, and it renders exactly like a built-in one. A widget that cannot say anything renders nothing at all — a typo in a configuration file costs a card, never the page.
- Sign-in records. fort.record_sign_ins(SignInRecord) writes who signed in, from which address, on which browser and platform, failures included. Client hints are read first and the user-agent second, and the raw string is always kept: the reading can be wrong. There is no foreign key to the user table, so a record outlives the account it describes.
- Four settings for what the admin may do to an account — change a password, change a superuser's password, delete an account, delete a superuser. All default to what the admin has always done. A protected password field is read-only and dropped from the write, because a control that still accepts a posted value is a label rather than a protection.
Fixed
- The sidebar scrolled away with the page. The dashboard's hidden data table carried .ff-sr-only on the <table> itself; a table treats height as a minimum and grows to its rows, so an invisible full-size box stayed in the page's scrollable overflow and made the document some 640px taller than the shell. The sidebar is sticky inside that shell, so the extra scroll had nothing to hold it.
A token API, rate limiting, and uploads that have to be what they say they are. Numbered as a patch so a `<0.4` pin picks it up — read the fixes before upgrading, because rate limiting arrives switched on.
Added
- A token API in one setting. auth={"api_enabled": True} mounts POST /auth/token, /refresh, /logout and GET /me at auth_url, in OAuth 2's request and response shapes. Off by default, because adding public endpoints to somebody else's application without being asked is not a library's decision to make.
- fastfort.auth.bearer_user(fort) — a FastAPI dependency for your own routes. It hands the route the user row rather than a claims dict, and rejects an account deactivated since the token was issued, which a signature check alone would keep admitting for the rest of that token's life.
- Refresh rotation with reuse detection, which rotate_refresh_tokens and revoke_family_on_reuse have promised since 0.1.0 and nothing implemented. A token presented twice cannot be told apart from a stolen one being used alongside the real client, so the whole family is revoked.
- Rate limiting, on by default: three token-bucket budgets for reads, writes and sign-in. Argon2 is slow by design, which makes the sign-in form the cheapest thing on any site to attack — so that budget is charged in middleware before the handler runs and a refused request never reaches the hash.
- Upload type checking: an extension allow-list per field kind, a deny-list of everything a server or browser might execute, and the leading bytes checked against the name. A .png whose content is an ELF binary is refused with the mismatch named.
- search_fields accepts id and UUID columns, matched exactly. The one thing everybody types into an admin's search box, and until now a configuration error.
- security.forwarded_depth, shared by the rate limiter and lockout, so there are not two answers to which client a request came from.
Fixed
- The sidebar marked "you are here" below the fold. .ff-nav is its own scroll container, so a browser starts it at zero on every navigation — open a model near the bottom of the sidebar and the highlighted row was outside the visible band every time. boot.js now scrolls it into view before the first paint.
- X-Forwarded-For was read from the left, which is the half an attacker writes: the header arrives with the request and each proxy appends after it. With trust_forwarded_for on, sending a different value each attempt bought a fresh lockout counter every time.
- Uploaded files were served with a Content-Type guessed from their name — the half an attacker chose — from the admin's own origin, which is the origin holding the session cookie. The type now comes from the stored bytes, and anything not positively a raster image is text/plain and a download.
- hack.exe.png is stored as hack_exe.png. A dangerous extension buried in a name is what a misconfigured server reads left to right and hands to an interpreter.
- The range bounds selector offered [ … ), ( … ], [ … ] and ( … ). On the page that rendered as four rows of brackets around an ellipsis, which does not look like a choice between four things — it looks like a control whose options failed to load. It now names its endpoints in words, in all eleven languages.
Both ORM backends now satisfy the protocol they implement.
Fixed
- SQLAlchemyBackend and TortoiseBackend narrowed adapter(uow=...) to their own unit of work. Parameter types are contravariant, so narrowing one made the class structurally incompatible with Backend — and every typed project got that error on the one line the README tells them to write.
- mypy --strict over fastfort/ passed throughout, because the package never assigns a concrete backend to a Backend itself. The only place that happens is a project's own main.py, so the error landed on users and never on CI.
A second ORM behind the same contract, every column type with a real control, and five browser-side controls that had been rendering correctly and doing nothing.
Added
- PostGIS: all eight geometry kinds drawn and edited on a hand-rolled slippy map.
- Spatial filters — within, contains, intersects, overlaps, touches, crosses, bbox, and dwithin, which reads metres on a geography column and SRID units on a geometry.
- pgvector similarity search: ?embedding__near=[…] with cosine, L2, L1 or inner product, a neighbour count and a distance bound.
- Real controls for inet, macaddr, money, bit strings, hstore, ranges and multiranges — each had previously degraded to a read-only row.
- register_type, so a project's own column types are classified once rather than retyped on every admin that shows them.
- Import: CSV, Excel and JSON back in through the same parsers the form uses, every bad cell reported at once with its line number.
- Thumbnails in list columns — an image column printed its stored path, which is the one thing a picture answers instantly.
- ui.map_max_zoom: the deepest level to request from the tile source. It was a constant of 19, right for OpenStreetMap and wrong for every layer that serves deeper.
- A Tortoise ORM backend, added without a change anywhere above fastfort/orm/ — which is the layering claim becoming a fact.
Fixed
- A geography column printed raw WKB hex at whoever opened the page.
- Interval and array columns silently degraded to read-only.
- A month name the browser could not produce, and a clock the date picker now draws itself.
- Sorting a list by a relation returned a 500.
- The date picker's clock had never worked: it called a helper defined only in another bundle, so every call threw and the listener died silently. Choosing an hour did nothing and Done applied nothing.
- Every required date column fell back to the browser's own picker. Two conditional attributes on consecutive lines were emitted glued together, so the selector matched nothing — 42 attributes across 6 templates.
- The map went blank past zoom 19 with every tile loaded: a single-precision overflow in the compositor put each tile exactly 2^25 pixels off screen once the layer was scaled.
- Opening a dropdown scrolled the page by about 120 pixels.
- The confirm dialog's actions were the size of a toolbar button.
Tortoise ORM behind the same adapter contract.
Added
- TortoiseBackend, added without a change anywhere above fastfort/orm/.
- A conformance suite that asks both backends the same questions over identical model shapes.
- Export: the current view as CSV, Excel or JSON, with filters, search and ordering applied.
Fixed
- A connection the lifespan opened that the views could not see — Tortoise keeps its connections in a contextvar, and an ASGI server runs the lifespan in a different task.
The first release.
Added
- @admin.register and ModelAdmin, validated against the model spec.
- List, create, change and delete, all working with JavaScript switched off.
- The deletion plan: DELETE, CLEAR and PROTECT, counted before anything is written.
- Argon2id, JWT access and refresh with rotation and reuse detection, login lockout, CSRF.
- Eleven language catalogues, shipped inside the wheel.
- The fastfort command: generate-secret, createsuperuser, check, registered-models.