# adminsite
> An admin panel for SQLAlchemy models, mounted into Starlette, FastAPI or Litestar.
adminsite is an admin panel for SQLAlchemy 2.0 models. It is an ASGI app you mount into
Starlette, FastAPI or Litestar with `app.mount("/admin", admin)`. It works with an async
or a sync engine. Install with `pip install adminsite` (add `adminsite[excel]` for Excel
import) plus the database driver.
The core pieces:
- `Admin(engine, title=..., views=[...])` is the app. Options switch features on:
`auth=` with `secret_key=` for signing in, `audit=True`, `saved_views=True`, `api=True`,
`dashboard=[...]`, `pages=[...]`, `plugins=[...]`, `language="fa"`, `languages=[...]`.
- `class OrderView(ModelView, model=Order)` describes one model: `list_display`,
`search_fields`, `list_filter`, `ordering`, `form_fields`, `readonly_fields`,
`fields` (field objects that replace the defaults), `inlines`, `count_mode`,
`pagination`, `can_create`/`can_edit`/`can_delete`/`can_import`.
- Anything that depends on the user is a method taking the request: `get_list_display`,
`get_form_fields`, `scope_query` (row level), and `async def allows(action, *, request,
record)` with `Permission.VIEW`, `CREATE`, `EDIT`, `DELETE`, `EXPORT`, `IMPORT`,
`HISTORY`.
- Business rules go in `before_save`, `after_save`, `before_delete`, `after_delete`
hooks. They run inside the transaction; raise `RefusedError("...")` to refuse.
- Bulk actions are methods decorated with `@action("Label")` that take a `Selection`.
Things agents often get wrong:
- Paths are dotted strings such as `"customer.name"`, not SQLAlchemy columns.
- `ModelView` is configured with class attributes and methods, never by passing options to
its constructor. `views=[...]` takes the class; the admin builds it.
- Signing in needs `secret_key`. `PasswordAuth` takes password hashes made with
`adminsite.auth.hash_password`, never plain passwords.
- Hooks and `allows` are async; `get_*` methods and `scope_query` are not.
- Extra routes added by plugins must start with `/-/`.
# Start here
# adminsite
An admin panel for SQLAlchemy models. Mount it into Starlette, FastAPI or Litestar, and your team gets pages to search, filter, read and change your data.
```
from adminsite import Admin, ModelView
class OrderView(ModelView, model=Order):
list_display = ("id", "customer.name", "status", "total", "created_at")
search_fields = ("id", "customer.name", "customer.email")
list_filter = ("status", "total", "created_at")
ordering = ("-created_at",)
admin = Admin(engine, title="Acme", views=[OrderView])
app.mount("/admin", admin)
```
That is a working admin. The columns, labels, filters, form controls and validation all come from your models.
Try it
[The live demo](https://adminsite.duckdns.org) runs the example shop. Sign in as admin with the password admin and change anything you like. It goes back as it started every hour.
Alpha
adminsite is in alpha. It is tested and works, but names may still change before 0.1.0. Install it with the exact version: `pip install adminsite==0.1.0a6`.
## What you get
- **Pages worked out from your models.** Column types pick the right controls, `created_at` reads "Created at", an enum becomes a select, and a foreign key becomes a picker for the record it points at.
- **No N+1 queries.** Showing `customer.name` loads the customers with the page. Tests count the statements so it stays that way.
- **Fast on large tables.** Counting can be switched off per view, which makes a page one query.
- **Filters you can write yourself**, next to the built-in ones.
- **Bulk actions over everything that matches**, not only the rows on the page, and actions that ask for values first.
- **Permissions at four levels**: the view, the action, the field and the row.
- **Hooks inside the transaction** that can read other tables and refuse a save.
- **An audit log** with a history on every record.
- **Async or sync.** An `AsyncEngine` or a plain `Engine` both work. Tested on SQLite, Postgres and MySQL.
- **No Node and no CDN.** The CSS and JavaScript ship inside the package.
## Where to go next
- [Getting started](https://nimaxin.github.io/adminsite/getting-started/index.md) sets up a working admin in a few minutes.
- [Views](https://nimaxin.github.io/adminsite/views/index.md) covers everything a `ModelView` can say about a model.
- [Permissions](https://nimaxin.github.io/adminsite/permissions/index.md) is worth reading before you put the admin in front of anyone.
## For AI assistants
If an AI assistant helps you build with adminsite, point it at [llms.txt](https://nimaxin.github.io/adminsite/llms.txt), a short guide with links to every page, or [llms-full.txt](https://nimaxin.github.io/adminsite/llms-full.txt), the whole documentation in one file. Both are rebuilt with the docs, so they always match the current release.
# Getting started
## Install
```
pip install adminsite==0.1.0a6
```
adminsite does not pull in a database driver. Add the one you use, for example `asyncpg` or `psycopg` for Postgres, or `aiosqlite` for SQLite with an async engine.
## Your first admin
Say you have these models:
```
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120))
email: Mapped[str] = mapped_column(String(255), unique=True)
def __str__(self) -> str:
return self.name
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
note: Mapped[str | None] = mapped_column(String(500), default=None)
customer: Mapped[Customer] = relationship()
```
Describe how each one should appear, and mount the admin:
```
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine
from adminsite import Admin, ModelView
engine = create_async_engine("postgresql+asyncpg://localhost/shop")
class CustomerView(ModelView, model=Customer):
list_display = ("name", "email")
search_fields = ("name", "email")
class OrderView(ModelView, model=Order):
list_display = ("id", "customer.name", "note")
search_fields = ("id", "customer.name")
app = FastAPI()
admin = Admin(engine, title="Shop", views=[CustomerView, OrderView])
app.mount("/admin", admin)
```
Run the app and open `/admin`. You get a list for each model, with search, sorting and paging, and pages to add, change and delete records. The order form offers a customer picker rather than a number box, because `customer_id` is a foreign key.
A view with no settings at all still works: it shows every column. Settings only narrow and order what is shown.
## Mounting elsewhere
The admin is an ASGI app, so it mounts wherever ASGI apps do.
```
from starlette.applications import Starlette
from starlette.routing import Mount
app = Starlette(routes=[Mount("/admin", app=admin)])
```
```
app.mount("/admin", admin)
```
```
from litestar import Litestar, asgi
@asgi("/admin", is_mount=True)
async def admin_app(scope, receive, send) -> None:
await admin(scope, receive, send)
app = Litestar(route_handlers=[admin_app])
```
Links inside the admin follow the path it is mounted on, so `/admin`, `/backoffice` or `/tools/admin` all work without configuration.
## Several admins in one app
Mount as many as you like, each with its own views, users and settings:
```
staff = Admin(
engine,
title="Staff",
views=[OrderView, CustomerView, ProductView],
auth=staff_auth,
secret_key=settings.staff_secret,
)
support = Admin(
engine,
title="Support",
views=[OrderView, CustomerView],
auth=support_auth,
secret_key=settings.support_secret,
)
app.mount("/staff", staff)
app.mount("/support", support)
```
Each admin keeps its own session, in a cookie named after its title (`adminsite_staff`, `adminsite_support`), so signing in to one never signs you in to the other. Give two admins with the same title different names with `session_cookie=`. The same view class can be used in both; each admin gets its own instance.
## Signing in
An admin reachable from the internet needs a login. The quickest one:
```
from adminsite.auth import PasswordAuth, hash_password
admin = Admin(
engine,
views=[CustomerView, OrderView],
auth=PasswordAuth({"nima": "pbkdf2_sha256$600000$..."}),
secret_key=settings.admin_secret_key,
)
```
Make the hash once with `hash_password("your password")` and keep the result in your settings. See [Signing in](https://nimaxin.github.io/adminsite/auth/index.md) for checking your own user table instead.
## Trying the example
The repository has a small shop with an admin already set up:
```
git clone https://github.com/nimaxin/adminsite
cd adminsite
uv run uvicorn examples.shop:app --reload
```
Open http://127.0.0.1:8000/admin and sign in as `nima` / `letmein`.
# Guide
# Views
A `ModelView` says how one model appears in the admin. Class attributes describe the list and the form. Methods whose names start with `get_` answer the same questions per request, for when the answer depends on who is asking.
```
from adminsite import CountMode, ModelView
class OrderView(ModelView, model=Order):
group = "Sales"
display_template = "Order #{id}"
list_display = ("id", "customer.name", "status", "total", "created_at")
search_fields = ("id", "customer.name", "customer.email")
list_filter = ("status", "total", "created_at")
ordering = ("-created_at",)
page_size = 50
form_fields = ("customer", "status", "note")
readonly_fields = ("total",)
```
## Naming
| Setting | Default | Used for |
| ------------------ | -------------------------------------------------------------- | ----------------------------------------------------------------- |
| `name` | the model name, plural and snake case: `orders`, `order_items` | the URL |
| `label` | `Order`, `Order item` | headings and buttons |
| `label_plural` | `Orders`, `Order items` | the sidebar and the list heading |
| `group` | none | the sidebar section the view sits under |
| `display_template` | `str(record)` | how a record is named elsewhere, for example `"{name} ({email})"` |
`display_template` is also used when another model links to this one, so a customer picker on the order form shows `Lena Fischer (lena@fischer.de)` instead of just the name.
## The list
| Setting | What it does |
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
| `list_display` | The columns, in order. Dotted paths such as `customer.name` follow links. |
| `search_fields` | The paths the search box looks in. Text matches anywhere in the value, numbers match exactly. |
| `list_filter` | Paths, or filters you built yourself. See [Filters](https://nimaxin.github.io/adminsite/filters/index.md). |
| `ordering` | The starting order. `-created_at` means newest first. |
| `page_size` | Rows per page. 25 unless you say otherwise. |
| `page_sizes` | The sizes people may switch between. Empty leaves the size fixed. |
| `count_mode` | `EXACT` counts every match, `ESTIMATED` guesses on big tables, `NONE` skips the count. |
| `deferred_fields` | Columns the list never shows, left out of its query. |
| `global_search` | Whether the command palette searches this view. On by default. |
| `list_columns` | More columns people can add from the Columns menu. |
| `icon` | The sidebar icon: inline SVG markup, or the address of a picture. |
| `pagination` | `Pagination.OFFSET` for page numbers, `Pagination.KEYSET` for big tables. |
With no `list_display`, every column is shown, and a foreign key such as `customer_id` appears as its relationship, `customer`.
Anything the list shows is loaded with the page. `customer.name` joins the customer into the same query; a path through a collection such as `items.quantity` costs one more query for the whole page, not one per row.
### Choosing columns
The **Columns** menu above the list hides and shows columns. It offers the columns of `list_display`, plus any in `list_columns`:
```
class OrderView(ModelView, model=Order):
list_display = ("id", "customer.name", "status", "total")
list_columns = ("customer.email", "note", "created_at")
```
The choice goes in the URL as `?cols=id&cols=total`, so it can be bookmarked and shared, and it is remembered in the session, so the list keeps those columns next time. The CSV export follows it too. Only columns on offer can be picked: a column you hide from some users in `get_list_display` stays hidden, whatever the URL says. Override `get_column_choices(request)` to offer different extras per user.
### Rows per page
`page_size` sets how many rows a page holds. Offer a few sizes and a menu appears above the list:
```
class OrderView(ModelView, model=Order):
page_size = 25
page_sizes = (25, 100, 500)
```
The choice goes in the URL as `?size=100`, stays while paging, sorting, searching and filtering, and is remembered in the session. Only a size on offer counts, so nobody can ask for a million rows by editing the URL. "Select all matching" still means every matching row, whatever the page shows.
### Saved views
A saved view keeps a search, filters, a sort and columns under a name, such as "Unpaid this month", so nobody has to click them together again. Switch them on for the admin:
```
admin = Admin(engine, views=[OrderView], saved_views=True)
```
The list gets a **Saved views** menu with the views and a **Save this view** button. A view belongs to the person who saved it. Ticking **Everyone can use it** shares it with the rest of the team; only its owner can remove it. Without sign in, every view is everyone's.
Like the [audit log](https://nimaxin.github.io/adminsite/audit/index.md), the views go to a SQLite file of their own, `adminsite_views.db`. To keep them in your database instead:
```
from adminsite import SavedViews
from adminsite.saved_views import saved_view_metadata
admin = Admin(engine, saved_views=SavedViews(engine))
# In your Alembic env.py, so the table is part of your migrations:
target_metadata = [Base.metadata, saved_view_metadata]
```
### The command palette
Press `Ctrl`+`K`, or `Cmd`+`K` on a Mac, anywhere in the admin to jump to a page or a record. With nothing typed it lists the pages. From two letters on it also runs each view's own search and shows the first five matches per view, named by `display_template`. Arrow keys move, Enter opens.
It searches only views the user may open, through `scope_query`, and only views with `search_fields`. Leave a view out, for example a very large table, with `global_search = False`.
### Large tables
Two things get slow once a table holds millions of rows: counting every match, and reaching deep pages, since the database walks every row before the page it returns. Both have a setting.
```
from adminsite import CountMode, ModelView, Pagination
class EventView(ModelView, model=Event):
ordering = ("-created_at",)
count_mode = CountMode.ESTIMATED
pagination = Pagination.KEYSET
```
**Counting.** `CountMode.ESTIMATED` reads the row count Postgres and MySQL already keep in their statistics, which costs nothing, and shows "about 2,500,000". It does so only when nothing narrows the list and the table holds more than 10,000 rows; below that an exact count is cheap. Once a search, a filter or `scope_query` narrows the list, the count stops at 10,001 rows and shows "more than 10,000". On SQLite, which keeps no estimate, it counts exactly.
`CountMode.NONE` skips the count altogether. The pager then shows no total and learns whether there is a next page by reading one extra row, so a page is a single query.
**Paging.** `Pagination.KEYSET` continues from the last row seen instead of skipping rows, so page 400 costs the same as page 1. The pager shows Previous and Next, without page numbers, and the URL carries a short cursor such as `?after=WyIyMDI2...`. The primary key is added to the order, so rows with the same value never repeat or go missing between pages.
**Wide rows.** A list that never shows a large column still loads it on every row. Name those columns and the list query leaves them out:
```
class EventView(ModelView, model=Event):
list_display = ("id", "kind", "created_at")
deferred_fields = ("payload",)
```
The record page, the form and the API load them as usual, so nothing disappears, and a column that the list does show is loaded whatever this says, as is the key and anything `display_template` reads. Those are the columns every row needs, and reading one afterwards would cost a query per row. Answer per request with `get_deferred_fields(request)`.
A keyset needs columns it can compare. When the list is sorted by a column that can be empty, or by a path through a relationship such as `customer.name`, that page falls back to page numbers. Put an index on the columns you sort by, primary key last, such as `(created_at, id)`.
## The form
| Setting | What it does |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `form_fields` | The fields, in order. A relationship name, such as `customer`, gives a picker. |
| `readonly_fields` | Shown, but never read back from what was submitted. |
| `exclude` | Left out of both the list and the form. |
| `fields` | Field objects that replace the ones worked out from the columns. See [Fields](https://nimaxin.github.io/adminsite/fields/index.md). |
| `can_create`, `can_edit`, `can_delete` | Switch those pages off. See [Permissions](https://nimaxin.github.io/adminsite/permissions/index.md). |
| `detail_fields` | What the record page shows, when that differs from the form. |
| `can_detail`, `can_export` | Switch off the record page and the CSV export. |
A readonly field is safe against a tampered form: its value is never taken from the request, even if someone adds the input back by hand.
### The record page
The record page shows the form's fields unless you name its own. That is how a page shows things nobody should post back, and how a form keeps fields the page has no reason to repeat:
```
class UserView(ModelView, model=User):
form_fields = ("name", "email", "is_active")
detail_fields = (
"name",
"email",
"is_active",
"signed_up_at",
"invoices",
"raw_payload",
)
```
Anything the page names is loaded with the record, so a linked record costs no extra query. Use `get_detail_fields(request, record)` to answer per user, and remember that a field only on the page is never read back from a form, so it needs no `readonly_fields` entry.
### Related records in the same form
An order and its lines belong together, so edit them on one page. Name the relationship in `inlines`:
```
from adminsite import Inline
class OrderView(ModelView, model=Order):
inlines = (Inline("items", fields=("product", "quantity", "unit_price")),)
```
The lines show as a table under the order's fields, with an **Add a row** button and a button to remove each line. A new order starts with one blank row to fill in; an order that has its lines gets no empty row under them. Everything is saved in one transaction with the order, so a line that fails to validate keeps the order unsaved too, and the page comes back with what was typed, the error next to the cell, and a list of every problem at the top of the form.
| Option | What it does |
| ------------------ | ------------------------------------------------------------------------------------------ |
| `fields` | The child's columns, in order. Defaults to every field except the link back to the parent. |
| `readonly_fields` | Shown, not editable. |
| `label` | The heading above the table. Defaults to the relationship's name. |
| `extra` | How many blank rows a table starts with while it has no rows yet. Defaults to 1. |
| `can_delete` | Whether rows can be removed. |
| `display_template` | How a child is named, as on a view. |
The relationship has to hold a list. Blank rows that stay empty are ignored, and a required field only counts once something else in the row is filled in. The detail page lists the children too.
Removing a row deletes that child record. A key sent for a row that belongs to another parent is refused.
Pick a different set per request with `get_inlines(request, record)`.
### Pages a view does not need
Some views are complete on the list, and some hold data nobody should carry out of the admin:
```
class SessionView(ModelView, model=Session):
can_detail = False
can_export = False
```
Without a detail page, rows open the form instead, or read as plain text where there is no form either, and saving lands back on the list. Without export, the CSV button is gone and the route refuses. Both go through `allows`, so `Permission.DETAIL` and `Permission.EXPORT` can be decided per user like any other permission.
### An icon in the sidebar
`icon` takes inline SVG markup, or the address of a picture:
```
class OrderView(ModelView, model=Order):
icon = ''
```
Markup is written into the page as it is, so keep it to icons you control. A relative address is served from the admin, so a plugin's `add_static` folder works.
## Answering per request
Every `get_` method receives the request, so the answer can depend on the user:
```
class OrderView(ModelView, model=Order):
list_display = ("id", "customer.name", "status", "total")
def get_list_display(self, request=None):
if request.user.is_support:
return ("id", "status")
return super().get_list_display(request)
def get_readonly_fields(self, request=None, record=None):
if record is not None and record.status == "shipped":
return ("customer", "status", "total")
return ()
```
The methods are `get_list_display`, `get_search_fields`, `get_filters`, `get_ordering`, `get_form_fields`, `get_readonly_fields` and `get_actions`.
## Several views of one model
A model can have as many views as you like, as long as each has its own name:
```
class ShippedOrders(ModelView, model=Order):
name = "shipped_orders"
label_plural = "Shipped orders"
def scope_query(self, statement, *, request=None):
return statement.where(Order.status == OrderStatus.SHIPPED)
```
`scope_query` narrows every read the view makes. It is covered in [Permissions](https://nimaxin.github.io/adminsite/permissions/index.md).
# Fields
A field turns a value into text for the list, puts it into a form input, and reads it back when the form is submitted. adminsite picks one for every column from its type, so you rarely write one yourself.
| Column | Field | Shown as | Edited with |
| --------------------------------- | --------------- | ------------------------ | --------------------------------------- |
| `str` with a length up to 255 | `StringField` | the text | a text input |
| `str` without a length, or longer | `TextField` | the text | a text box |
| `int` | `IntegerField` | `42` | a number input |
| `float` | `FloatField` | `4.2` | a number input |
| `Decimal` | `DecimalField` | `1,234.50` | a number input |
| `bool` | `BooleanField` | `Yes` or `No` | a switch |
| `date` | `DateField` | `Sep 8, 2026` | a date picker |
| `datetime` | `DateTimeField` | `Sep 8, 2026 14:05` | a date and time picker |
| `time` | `TimeField` | `14:05` | a time picker |
| an enum | `ChoiceField` | `Shipped` | a select |
| a relationship | `RelationField` | the linked record's name | a picker, or a search box on big tables |
Labels come from the column name: `created_at` reads "Created at", and `customer_id` reads "Customer".
## When a value does not fit
A value that cannot be converted comes back on the form, with a message under the field and everything else still filled in:
| Field | Message |
| ------------------------------- | ------------------------------------- |
| a required field left empty | This field is required. |
| `IntegerField` | Enter a whole number. |
| `DecimalField` | Enter an amount, for example 12.50. |
| a string longer than its column | Keep this to 120 characters or fewer. |
| `ChoiceField` | Choose one of the listed options. |
## Replacing a field
Give the view a field object with the same name as the path, and it replaces the one adminsite would have built:
```
from adminsite.fields import EmailField, RelationField, TextField
class CustomerView(ModelView, model=Customer):
fields = (
EmailField("email", required=True),
TextField("notes", label="Internal notes", help_text="Only staff see this."),
)
class OrderView(ModelView, model=Order):
fields = (
RelationField(
"customer",
target=Customer,
required=True,
display_template="{name} ({email})",
),
)
```
Every field takes `label`, `required`, `readonly`, `help_text`, `max_length`, `default`, `format` and `secret`. `default` is what a new record's form starts with, and what an action's dialog opens with. `secret` says whether the [audit log](https://nimaxin.github.io/adminsite/audit/#actions) keeps `***` instead of the value an action was run with; left out, a name such as `password` or `api_key` decides. `format` is how a value is written wherever it is shown, as `str.format` takes it, the same way a dashboard's `Stat` and `Chart` take it:
```
from adminsite import FieldOptions
class OrderView(ModelView, model=Order):
fields = (FieldOptions("total", format="€{:,.2f}"),)
```
The list, the record page, the export and the overview's cards then read `€1,234.50`. The form's input keeps the plain number, since that is what it reads back.
## Changing one thing about a field
Most of the time the field adminsite worked out is the right one and only its label or a line of help is wrong. `FieldOptions` changes those without naming the type, the target or anything else again:
```
from adminsite import FieldOptions
class ProductView(ModelView, model=Product):
fields = (
FieldOptions("name", label="Product name"),
FieldOptions("description", help_text="Shown on the shop page."),
)
class OrderView(ModelView, model=Order):
fields = (FieldOptions("customer", display_template="{name} ({email})"),)
```
It takes whatever the field takes, so `label`, `required`, `readonly`, `help_text` and `max_length` work on any field, and `display_template` works on a link. An option the field does not take is an error when the view first builds it, naming the path, rather than a setting that quietly does nothing.
`FieldOptions` sits in the same `fields` tuple as whole fields. Where both name the same path the whole field wins, since it already says everything.
A label given this way is used as it stands. Without one, a path through a link names the link as well, so `customer.name` reads Customer name.
## Links to many records
A relationship that holds many records, such as a customer's orders, shows the linked records' names in the list and a multiple select in the form.
Above 100 records the select is no good, so the picker becomes a search box. It searches the other model's text columns through a lookup, and the records already linked sit above it as chips, each with a button to take it off. Picking a record adds one more, picking the same one twice changes nothing, and the box says how many are held. A link that holds a single record works the same way, except that picking replaces what is there.
The search box is the only way the picker can work on a large table, so the records it offers are whatever the lookup finds, twenty at a time. Give the other model a `display_template` so those twenty read as something other than ``.
A picker reads through the other model's own view, so its `scope_query` and its permissions apply here as on any other page. A user who may see only their own region's customers sees only those in the picker, and a view nobody may open offers nothing at all. Where the other model has no view of its own, there is nothing to ask and its records are read directly.
The search looks in the target view's `search_fields`. Where it names none, it looks in the text columns the records are named by, which the picker is already showing. So a column a view keeps off its pages cannot be read a letter at a time through a picker, and a target with neither `search_fields` nor a `display_template` cannot be narrowed at all.
## JSON columns
A `JSON` or `JSONB` column gets a `JSONField` by itself. The list shows the document on one line, cut short where it is long, and the form edits it in a box, laid out over several lines. A document that does not parse comes back as an error on that field, with the text exactly as it was written, so nothing is lost and nothing malformed is stored.
The [JSON API](https://nimaxin.github.io/adminsite/api/index.md) reads and writes these columns as JSON, so `{"options": {"free_over": 10}}` is stored as an object, not as a string.
## A value the view works out
Not every column on a page is a column. `Computed` shows something the view works out from the record, in the list, on the record page and in the export:
```
from adminsite import Computed
class ProductView(ModelView, model=Product):
list_display = ("name", "price", "capacity")
fields = (
Computed(
"capacity",
lambda product: f"{len(product.slots)}/{product.limit}",
label="Capacity",
needs=("slots",),
),
)
```
`needs` names the paths the function reads, so they are loaded with the page. Without it a list of 25 records would ask the database 25 times.
A computed value is never written, sorted or filtered: its column has no sort link, the form leaves it out, and so do imports and the API's writes. The API still reads it.
## When a field needs the whole record
`display(value)` sees only the value, which is not always enough: an amount reads differently per currency, and a status reads differently when a second column says the check was switched off. Override `text_for` instead, which gets the record:
```
class Money(Field):
widget = "number"
def text_for(self, record, value):
if value is None:
return ""
return f"{value:,.2f} {record.currency}"
```
Everything that shows a value goes through `text_for`: the list, the record page and the export. It falls back to `display`, so fields that do not need the record carry on as they are.
## A link or a badge in a cell
Cells are escaped text, so a name holding `