This release is a pre-release and may not be stable for production use.
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.
In alpha: pip install adminsite==0.1.0a5. Documentation: https://nimaxin.github.io/adminsite/
For AI assistants: https://nimaxin.github.io/adminsite/llms.txt
from adminsite import Admin, ModelView
class OrderView(ModelView, model=Order):
group = "Sales"
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 the whole setup. The columns, the labels, the filters, the form controls and the validation come from your models.
What it does
- Reads the model. Column types become the right controls,
created_atbecomes "Created at", and an enum becomes a select with its values spelled out. - Never lets a page fall into N+1 queries. Listing
customer.nameloads the customers with the page, and a collection costs one more query. The tests count the statements. - Handles large tables. Counting can be turned off per view, in which case paging costs one query and one extra row. Facet counts on filters can be turned off too.
- Filters you can write yourself. The built-in ones cover choices, booleans, number ranges, date ranges, links and text. A custom filter is a class that returns a condition.
- Bulk actions over everything that matches, not just the page. The selection is a query, so an action over a large table stays one statement.
- Permissions at four levels: the view, the action, the field and the row.
scope_querynarrows every read, so a row a user may not see cannot be opened by guessing its key. - Hooks that run inside the transaction and receive the session, so a business rule can read other tables and refuse a save.
- Inlines. Edit an order's lines in the order's own form, saved in one transaction with it.
- Translations, with Persian built in and a mirrored layout for right to left languages.
- A JSON API over the same views and permissions, with bearer tokens for scripts.
- Import from CSV or Excel with a preview of every row before anything is written.
- A dashboard of stats, charts and recent records, drawn on the server with no chart library.
- Pages and plugins. Reports and settings pages in the same layout, and plugins that bring their own views, pages, routes and assets.
- An audit log with a History tab on every record and an Activity page, including bulk actions row by row.
- Async or sync. Give it an
AsyncEngineor a plainEngine. Everything above the session adapter is written once. Tested on SQLite, Postgres and MySQL, with async and sync drivers. - No Node, no CDN. The CSS and JavaScript are built into the package.
Installing
pip install adminsite
Add aiosqlite, asyncpg or whichever driver your database needs.
Getting started
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine
from adminsite import Admin, ModelView
from adminsite.auth import PasswordAuth, hash_password
engine = create_async_engine("postgresql+asyncpg://localhost/shop")
app = FastAPI()
class CustomerView(ModelView, model=Customer):
display_template = "{name} ({email})"
list_display = ("name", "email", "region")
search_fields = ("name", "email")
admin = Admin(
engine,
title="Acme",
views=[CustomerView],
auth=PasswordAuth({"nima": hash_password("letmein")}),
secret_key="read this from your settings",
)
app.mount("/admin", admin)
Signing in needs a secret_key, which signs the session cookie. PasswordAuth takes hashed
passwords, so run hash_password once and keep the result in your settings, never the password
itself. It suits a small internal tool. For anything larger, subclass AuthProvider and check
your own user table.
Writing a view
| Setting | What it does |
|---|---|
list_display |
The columns on the list. Dotted paths such as customer.name work. |
search_fields |
The paths the search box looks in. |
list_filter |
Paths, or filter instances you built yourself. |
ordering |
The starting order, with - for descending. |
form_fields, readonly_fields, exclude |
What the form shows and what it locks. |
page_size, count_mode |
How many rows a page holds, and whether to count them all. |
display_template |
How a record is named, for example "Order #{id}". |
fields |
Field instances that replace the ones worked out from the columns. |
Anything that depends on who is asking is a method:
class OrderView(ModelView, model=Order):
def get_list_display(self, request=None):
if request.user.is_support:
return ("id", "status")
return super().get_list_display(request)
def scope_query(self, statement, *, request=None):
return statement.where(Order.region == request.user.region)
async def allows(self, action, *, request=None, record=None):
if action == Permission.DELETE:
return request.user.is_manager
return await super().allows(action, request=request, record=record)
Hooks
class OrderView(ModelView, model=Order):
async def before_save(self, context: SaveContext) -> None:
if context.created and not await in_stock(context.session, context.record):
raise RefusedError("That product is out of stock.")
async def after_save(self, context: SaveContext) -> None:
await context.session.add(AuditEntry(order_id=context.record.id))
Both run inside the transaction that writes the record. Raising RefusedError rolls the save
back and shows the message on the form. Any other exception is treated as a fault.
Audit log
admin = Admin(engine, views=[OrderView], audit=True)
Every create, change and delete is written down with who did it and what each field was before and after. A record's page gets a History tab, and the Activity page lists recent changes across the admin. A bulk action writes one entry per record it touched, sharing a batch id, so each record's history shows it.
Entries are written after the change commits, so a rolled back change never appears.
audit=True keeps the log in a SQLite file of its own, adminsite_audit.db, which needs no setup.
That suits a single server. With several workers or containers, keep it in your own database
instead, so every process writes to the same place:
from adminsite.audit import AuditLog, audit_metadata
admin = Admin(engine, audit=AuditLog(engine))
# In your Alembic env.py, so the table is created by your migrations:
target_metadata = [Base.metadata, audit_metadata]
Actions
class OrderView(ModelView, model=Order):
@action("Mark as shipped", confirm="Mark the chosen orders as shipped?")
async def ship(self, selection: Selection) -> str:
changed = await selection.update(status=OrderStatus.SHIPPED)
return f"{changed} orders marked as shipped."
An action can ask for values before it runs. They appear in a dialog, are checked like form fields, and reach the method by name:
@action(
"Mark as shipped",
confirm="Mark the chosen orders as shipped?",
inputs=[ChoiceField("carrier", choices=CARRIERS, required=True)],
)
async def ship(self, selection: Selection, carrier: str) -> str:
changed = await selection.update(status=OrderStatus.SHIPPED, carrier=carrier)
return f"{changed} orders sent with {carrier}."
The selection is either the rows that were ticked or every row the current search and filters
match. selection.update and selection.delete are single statements and skip the save hooks;
selection.records() loads the records when the hooks matter. Because selection.delete never
loads the records, it relies on the database for cascades: give the foreign keys ondelete="CASCADE"
where children should go with their parent.
Custom filters
class OverdueFilter(SQLFilter):
"""Orders past their delivery date."""
async def options(self, context):
return [FilterOption("late", "Overdue"), FilterOption("soon", "Due in 2 days")]
def condition(self, value, repository):
if value.first == "late":
return Order.due_at < func.now()
return Order.due_at < func.now() + timedelta(days=2)
class OrderView(ModelView, model=Order):
list_filter = ("status", OverdueFilter("delivery", label="Delivery"))
Override apply instead of condition when the filter needs to change the statement itself.
Trying it out
uv run uvicorn examples.shop:app --reload
Then open http://127.0.0.1:8000/admin and sign in as nima / letmein.
Developing
uv sync --all-groups
uv run pytest
uv run ruff check . && uv run ruff format --check .
uv run mypy src tests
The tests run on SQLite by default, async and sync. To run every database test against Postgres and MySQL as well, start them and point the tests at them:
docker run -d --name adminsite-postgres -p 55432:5432 -e POSTGRES_USER=adminsite -e POSTGRES_PASSWORD=adminsite -e POSTGRES_DB=adminsite postgres:17-alpine
docker run -d --name adminsite-mysql -p 53306:3306 -e MYSQL_ROOT_PASSWORD=adminsite -e MYSQL_DATABASE=adminsite -e MYSQL_USER=adminsite -e MYSQL_PASSWORD=adminsite mysql:8.4
ADMINSITE_POSTGRES_URL=postgresql://adminsite:adminsite@localhost:55432/adminsite ADMINSITE_MYSQL_URL=mysql://adminsite:adminsite@127.0.0.1:53306/adminsite uv run pytest
The stylesheet and the vendored JavaScript are built from frontend/, and the results are
committed, so nobody installing adminsite needs Node:
cd frontend
npm install
npm run vendor
npm run build
License
MIT.
Release files for adminsite 0.1.0a5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| adminsite-0.1.0a5.tar.gz | 411.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| adminsite-0.1.0a5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 630.1 kB
Release files / adminsite-0.1.0a5.tar.gz
| Download URL | adminsite-0.1.0a5.tar.gz |
|---|---|
| Size | 411.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6b7b46d78abce80bbaa78a4207ab4fb0b86717d2651cd856adf9ed38f39b672b
|
|
BLAKE2b-256 checksum How to use checksums |
8ee847d1369692c4f91565b3431a2ec86ebe5428775b0b1c6a18eabd4d625cd9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 23, 2026.
Transparency logRelease files / adminsite-0.1.0a5-py3-none-any.whl
| Download URL | adminsite-0.1.0a5-py3-none-any.whl |
|---|---|
| Size | 219.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2337b82ee2273f65af3c219fa8a99cff29b48d54225c9d208d622a196a1c7c80
|
|
BLAKE2b-256 checksum How to use checksums |
72cb85782d8a64a5db4acfc83107ab940cf5aa2fa7c082146ed799fdfd3f3525
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 23, 2026.
Transparency log