Skip to content

Changelog

0.8.0

A choice or a link to one record with many options is now a picker on forms, as a choice filter is in the filters drawer: a country among 250 or a time zone among 600 is found by typing any part of its name.

Before upgrading:

  • A project that overrides base.html, widgets/select.html, widgets/relation.html or widgets/_picker.html should start its copy again from the new one.

What changed:

  • On a form, a choice or a link to one record with more than 10 options is a picker, as a choice filter is: a box that narrows the options as you type, with every option in the page (#119). One of 10 or fewer stays a select, and a link among more than 100 records still searches as you type. The same goes for inline rows, action dialogs, the editor a list cell opens and the form of a JSON document.
  • On a new record, a JSON field whose schema follows the record is drawn again when a picker changes, as it already was when a select changed.

0.7.0

Two new ways to change what the admin does with records: list_query keeps records off the list without hiding them, and save_record and delete_record replace the write of a save or a delete. Together they make a soft delete a few lines.

Before upgrading:

  • A view that already has a method of its own called list_query, save_record or delete_record should rename it, since adminsite now calls methods by those names.

What changed:

  • ModelView.list_query keeps records off the list without hiding them, for deleted or archived records. It narrows the list, its count and filters, the export, "select all matching", the command palette, the dashboard and the pickers on other forms. A record it leaves out still opens from a link and is named there, and a form that holds one keeps it. scope_query stays the way to hide records from a user.
  • ModelView.save_record and ModelView.delete_record do the write of a save and of a delete. Override one to store or delete a record your own way, such as a delete that keeps the row and fills in its deleted_at. The permission check, the other hooks and the audit log still run around it, and every Delete runs delete_record: on the record's page, for the ticked rows and through the API.
  • A save deletes a file it replaces only once the record no longer holds it, so a write or a before_save that keeps the old file keeps it in storage too.

0.6.1

A small fix to AdminUsers: a username in fullwidth letters is now the same name as one in normal letters.

Before upgrading:

  • An account whose username has fullwidth letters has to be renamed on the Users page before it can sign in.

What changed:

  • With AdminUsers, admin in fullwidth letters is now the same username as admin, so the two can no longer be separate accounts.

0.6.0

Every password box can now show what is typed, and a choice filter with many options is a picker in the filters drawer, as a relation filter is.

Before upgrading:

  • A project that overrides base.html, login.html, password.html, _filters.html, _icons.html, widgets/password.html, widgets/_picker.html or widgets/_option.html should start its copy again from the new one.

What changed:

  • Every password box has a button at its end that shows what is typed and hides it again: on the sign in page, on Change password and for a PasswordField in a form. In Edge it takes the place of the browser's own.
  • In the filters drawer, a choice filter with more than 10 options is a picker, as a relation filter is: a box that narrows the options as you type and holds the ones picked as chips, with each option's count in the list (#108). A list of 10 or fewer stays as boxes to tick, and no longer scrolls inside the drawer.

0.5.1

A release of fixes for the users and roles added in 0.5.0. A superuser now sees the sign ins on the Activity page whatever a view's scope_query leaves out, and a role's grid shows each action's label once in a box that several actions share.

What changed:

  • With AdminUsers, a superuser sees the sign ins on the Activity page and can filter by them, where a view whose scope_query leaves out rows for everyone hid them (#104).
  • On a role's grid, a box that several actions share shows each of their labels once, where an action offered on the list and on the record page read "Retry, Retry" (#105).

0.5.0

Users and roles managed in the admin: auth=AdminUsers() keeps the admin's own users in your database, superusers add people and give them roles, and each role is a grid of what its holders may do on each view and page.

Before upgrading:

  • A project that overrides base.html, index.html, login.html, _field.html or _icons.html should start its copy again from the new one.

What changed:

  • AdminUsers, in the new adminsite.users, signs people in against three tables on user_metadata, and adds Users and Roles pages that only superusers open, beside Activity (#100). A role's grid has a box for each permission each view offers, the actions' own permissions among them, and the views and pages follow what a person's roles allow.
  • adminsite users create nima --superuser makes the first superuser from a terminal, finding the Admin in main.py, app.py or app/main.py, or wherever --admin says.
  • Everyone signed in through AdminUsers changes their own password from the account menu. A new password, or an account switched off, signs that person out everywhere else.
  • AuthProvider.grants_for lets a provider of your own grant permissions view by view, as roles do, with Grants. AuthProvider.change_password and AuthProvider.sign_in_note let it offer Change password and put a line above the sign in form.
  • Someone signed in who may open nothing is told to ask an administrator for access, where the overview showed an empty card.

0.4.1

A small release. A form edits a string column without a length, as Mapped[str] declares, on one line, where it gave it a box like a Text column's. adminsite also supports Python 3.15.

Before upgrading:

  • A column for long text declared without a length, such as a description written Mapped[str], is now edited on one line. Declare it Text, or give it TextAreaField on its view, to keep the box.

What changed:

  • adminsite supports Python 3.15 and is tested on it, like 3.11 to 3.14.
  • A string column without a length, such as Mapped[str] or String(), is edited on one line (#101). It was edited in a box like a Text column, so a form of names and codes was all boxes, and Enter added a line instead of saving. Text, MySQL's MEDIUMTEXT and LONGTEXT, and a String longer than 255 keep the box.

0.4.0

An action's message now sits under the page's heading and stays until it is closed, and a field can say what its empty value means, such as "Every region" for a multi-select left empty. The folded sidebar scrolls a long list of views without a scrollbar.

Before upgrading:

  • Message no longer takes sticky: every message now stays until it is closed, so leave it out.
  • A project that overrides base.html, detail.html, _row.html, _empty.html, _message.html, _value_saved.html or _icons.html should start its copy again from the new one.

What changed:

  • An action's message sits under the page's heading, across the page, and stays until it is closed or the page changes. It was a box in the corner that faded after six seconds, covered the page's own buttons, and let a long file name or key run under its close button.
  • With more views than fit, the folded sidebar scrolls its icons without a scrollbar, which on Windows took half its width and pushed the icons aside, and fades the edge with more icons past it. Unfolded, its scrollbar is thin and quiet, and the open page's link is scrolled into view.
  • Every field takes empty_text, for an empty value that means something: empty_text="Every region" on a multi-select left empty. The list and the record page show it faint, like a placeholder, where they showed a hyphen, and the export writes it into the empty cell. A link to records takes it too.

0.3.0

More of a list fits on a screen. Compact mode, a switch in the account menu, draws lists and record pages closer together, so a laptop shows 14 rows of a list where it showed 9, and the sidebar folds to its icons to give a list's columns 176px more. Each person chooses both for themselves, and forms keep their spacing.

Before upgrading:

  • A project that overrides base.html, list.html, detail.html, _table.html, _row.html, _toolbar.html, _saved_tabs.html, _filter_chips.html, _records_total.html or _icons.html should start its copy again from the new one.

What changed:

  • Compact mode: a switch in the account menu, beside Dark mode, draws lists and record pages closer together. A list's rows are 36px apart where they were 48, so a screen holds about half as many rows again. Each person turns it on for themselves, and forms keep their spacing.
  • The sidebar folds to its icons from a button beside the admin's name, which gives the page 176px more room for a list's columns. Each name shows when the pointer rests on its icon, and each person's choice is kept.

0.2.1

A release of fixes. Mounted in Litestar, the admin opens every page, where before only its front page did. Saved views and the filters drawer behave as people expect: a saved view opens from anywhere on its tab and asks before it is removed, and a picked filter choice such as "Today" can be let go from the drawer, which now has a Clear all on a computer too.

Before upgrading:

  • A project that overrides base.html, form.html, _filters.html, _filters_footer.html, _list_forms.html or _saved_tabs.html should start its copy again from the new one.

What changed:

  • The bar that holds a form's Save button has the cards' quiet shadow, where it cast the large shadow of a menu.
  • Mounted in Litestar, the admin opens every page, where only its front page opened and the rest returned 404 (#89). The Litestar example in Getting started sets copy_scope=True, as Litestar asks.
  • A saved view's tab opens the view wherever it is clicked, where its "shared" label did nothing, and its × asks before it removes the view (#90).
  • The filters drawer has a Clear all on a computer too, where only phones had one, and a choice that takes one value, such as "Today" or Yes, is let go when picked again, where it stayed picked (#91). Two date filters on one list keep their shortcuts apart, where picking one unticked the other's.

0.2.0

Forms have a new look: a field is white in the light theme and nearly black in the dark one, with a border that shows, and cards stand on a darker ground with their titles in a band. Three fixes matter to anyone on 0.1.0: saving a record could erase, in every inline row, a field that holds several values; leaving a text empty could make the database refuse the whole save; and a filtered list on a big table stopped counting at 10,000.

The docs are rewritten to be shorter and easier to follow. Getting started builds an admin in one file that runs as written, each page starts with the common case, and the examples link to the models they use. The docs are built with Zensical and published with each release, so they match the version on PyPI.

Before upgrading:

  • On a view with count_mode = CountMode.ESTIMATED, a search, a filter or scope_query is now counted exactly, which on a very large table takes a full count (#82).
  • A project that overrides form.html, detail.html, index.html, activity.html, import.html, error.html, _inlines.html, _page_numbers.html, _records_total.html or widgets/checkbox.html should start its copy again from the new one.
  • A project that builds its own stylesheet should build it again from the new frontend/input.css, which draws the new fields. The ground behind the cards is now daisyUI's base-200, and --subtle colours the band that holds a card's title.

What changed:

  • A record whose text column cannot be null but has a default, such as note: Mapped[str] = mapped_column(default=""), can be saved with that text empty. The edit form, inline rows, values changed in the list, the API and the import read the empty text as None, and the database refused the whole save. It is now stored as "", as is the key of a file removed from such a column. Any other field whose column cannot be null, such as a number or a choice, left empty on a record that exists is refused under its input, since a default only fills in a new record, unless the field is given required=False. The edit form marks such a field required, so a choice there no longer offers Not set (#84).
  • On a view with count_mode = CountMode.ESTIMATED, a search, a filter or scope_query is counted exactly. The count stopped at 10,000, so the list, the filters drawer and the Actions menu said "more than 10,000" and the pager had no page numbers. The rows still come first and the total follows (#82).
  • In an inline row, an EnumField with multiple=True and a link to many records work as they do on a view's own form. The row's values did not show, so saving the parent, even only to rename it, erased that field in every row, and a save kept only the first value picked. A link to many records made the parent's edit page fail with a server error as soon as a row held linked records. A link in a row searched among over a hundred records also keeps what was picked when the form comes back with an error (#87).
  • A new look for forms. A field is white in the light theme and nearly black in the dark one, with a border that shows, and its border and a ring take the accent while you type, where it drew a thick black frame. Cards stand on a darker ground with their titles in a band, a select has a thin arrow, date and time fields use the admin's own icons, a file field is a place to drop a file, and a disabled field looks disabled.
  • A switch on a form says No while it is off. It said Yes either way.

0.1.0

adminsite leaves alpha: installing it no longer needs an exact version, and the package is marked Beta. A SQLModel model works as it is. On SQLAlchemy 2.1 the search box and a link's picker now find its text, and the Databases page says how a view names its columns.

Before upgrading:

  • adminsite needs Pydantic 2.10 or later. An earlier Pydantic leaves out of its JSON schema which keys a dict may hold, so in a form built from a schema, a dict keyed by a Literal or an enum took any text as a key instead of offering its keys, and the record page did not name them.
  • A column of a TypeDecorator that names no Python type now gets the field of the type it wraps, so one over DateTime gets a date and time picker where it had a text input. If your type holds something other than what it wraps, give its column the field you want, or name its python_type.

What changed:

  • A column of a type of your own, a TypeDecorator, that names no Python type gets the field of the type it wraps, on SQLAlchemy 2.0 and 2.1 alike. On 2.1 such a column over String, as every str of a SQLModel model is, was not text to the admin: the search box and a link's picker found nothing in it, and a long one got a one-line input.

0.1.0a17

Ten languages ship with adminsite, each translated in full: Russian, Spanish, Brazilian Portuguese, Japanese, German, French, Turkish, Arabic, and Simplified and Traditional Chinese. What each needs came with them: texts with a count in every form a language writes a number in, dates, counts, lists and the history in each language's own way, fonts that draw each script in its own forms, and browsers matched to a language by region and script. Text is a step larger and darker, in the system's own typeface. A relation filter picks its records by name, a click anywhere on a row ticks it once one is ticked, and a list can keep itself up to date.

Before upgrading:

  • On SQLite, a record keyed past 2,147,483,647 in a column declared Integer can no longer be found by its key, since that is past what an Integer holds on Postgres and MySQL. Declare such a key BigInteger (#66).
  • A project whose translations hold one of the texts below should key it by the new text. Each text with a count is keyed by its English for one, and may hold a form for each plural category: "{count} rows" is now "{count} row", "{count} items" "{count} item", "{count} keys" "{count} key", "selected" "{count} selected", "Import {count} rows" "Import {count} row", "Import {count} rows, skip {skipped}" "Import {count} row, skip {skipped}", "Import at most {count} rows at a time." "Import at most {count} row at a time.", "Skipped {count} rows with problems." "Skipped {count} row with problems.", "Enter at least {count} characters." "Enter at least {count} character.", "Keep this to {count} characters or fewer." "Keep this to {count} character or fewer.", and "{count} {things} deleted." "{count} {thing} deleted.", "{count} {things}" "{count} {thing}" and "Show {count} {things}" "Show {count} {thing}". A line of the history is one sentence, keyed as "{who} changed {record}", "{who} changed" and so on, where it was "changed".
  • A project that overrides base.html, index.html, list.html, detail.html, form.html, activity.html, import.html, import_preview.html, login.html, error.html, _action_inputs.html, _bulk_summary.html, _document_form.html, _document_json.html, _document_switch.html, _document_view.html, _field.html, _filter_chips.html, _filters.html, _filters_footer.html, _form_layout.html, _history.html, _inlines.html, _json.html, _message.html, _option_count.html, _palette.html, _records_count.html, _records_total.html, _row.html, _saved_tabs.html, _table.html, _toolbar.html, _value_editing.html, dashboard/chart.html, dashboard/counts.html, dashboard/recent.html, dashboard/stat.html, widgets/_picker.html, widgets/json.html, widgets/relation.html or widgets/select.html should start its copy again from the new one. The text sizes changed in most of them.
  • A project that builds its own stylesheet should build it again from the new frontend/input.css, which sets the new text sizes and the system's typeface, and no longer loads Geist.

What changed:

  • Text is easier to read. It is a step larger throughout, with nothing under 12px, and grey text is darker. Pages are set in the system's own typeface, Segoe UI on Windows and San Francisco on a Mac, which each system draws sharply; Geist no longer ships.
  • Choosing a language or a time zone works when another app on the same host, served at /, once set a cookie by the same name. The browser sent both, and the other app's won, so the choice changed nothing. The admin now reads the cookie set for its own path.
  • A filter value edited into the URL by hand can no longer fail the list. A choice filter leaves out a value that is not one of its choices, or not a member of the column's enum, and a relation filter a key past what its column holds, such as 99999999999 for an Integer key, which asyncpg refused to send. A record's page answers such a key with Not found, and a search for such a number, a keyset page's cursor and a selection's keys leave it out too (#66).
  • A relation filter, such as list_filters = [Order.customer], is a picker of the linked records by name, as a link on a form is, where it was a box for the record's key. Several can be picked, and the chip above the list names them. The records come through the linked model's own view, so its scope and permissions apply, and seeing the list is enough to use it, where a form's picker asks for leave to create or edit (#67).
  • A relation filter built from a column matches the linked model's own primary key, whatever it is called. It always matched a column named id (#67).
  • Rows are easier to tick on the list. A click anywhere in a row's first cell ticks it. Once a row is ticked, a click anywhere on another row ticks or unticks it, and the rows show a pointer; shift and a click, or shift and Space, ticks every row from the last one clicked. Links, the row's menu, values changed in place and a drag over text keep working as before, and with nothing ticked a click on a row still ticks nothing (#59).
  • A view can keep its open list up to date: list_refresh_seconds = 30 reads the rows again every 30 seconds, under the search, filters, sort and page in use, so new and changed records show up without a reload. The list waits while rows are ticked, a menu, dialog or value's editor is open, or the focus or a text selection is in the table, and a hidden tab asks nothing until it is shown again (#48).
  • A text with a count reads right for any number, in English too: "1 row", not "1 rows". A translation holds a form for each plural category its language uses, such as one, few and many for Russian, and ngettext translates one in your own code.
  • Dates are written the way the language of the page writes them, month names included, on every page, in the export and on the overview's charts. They were in English whatever the language.
  • A label inside a sentence, such as "Search order items", keeps a word written in capitals, such as API, and follows the rules of the page's language: German keeps the capitals of its nouns, and Turkish lowers İ to i and I to ı. Searching a filter's or a picker's options follows the same rules.
  • A filter's chip, a form's list of problems and an import's problems put the label and its value together the way the language does, so French can write "Statut : payé".
  • The language codes language and languages take are written the usual way whatever their case, so pt-br finds the catalog for pt-BR. A browser that asks for Chinese is matched by script: zh-TW, zh-HK and zh-MO read Traditional Chinese, and zh-CN, zh-SG and zh Simplified Chinese. One that asks for a language in a region it is not offered in, such as pt-PT, gets the language in another region, such as pt-BR.
  • A value marked as markup, such as a link, goes into a translated text as it is, and the rest of the text is escaped around it.
  • A count sets its thousands apart as the page's language does: 1,234 in English, 1.234 in German, 1 234 in French. A list inside a sentence, such as the records a filter is on, is set apart as the language does too, and a total says "1 order" rather than "1 orders".
  • A line of the history is one sentence with the person and the record in it, so a language puts them in its own order.
  • An import reads yes and no in the page's language as well, as the export writes them, so a Persian export imports again. A refused row's reason no longer ends in two full stops, and a record page someone may not open says "You cannot open Orders.", not "You cannot detail Orders.".
  • Russian ships with adminsite: language="ru", or languages=["ru"] for a browser set to ru or ru-RU. Texts with a count read right for 1, 2, 5 and 21 (#49).
  • Spanish ships with adminsite, in wording readers in Spain and Latin America both understand: language="es", or languages=["es"] for a browser set to any Spanish, such as es-ES, es-MX or es-419 (#50).
  • Brazilian Portuguese ships with adminsite: language="pt-BR", or languages=["pt-BR"] for a browser set to pt-BR, to pt, or to pt-PT until European Portuguese ships (#51).
  • The account menu's language and time zone rows cut a long name short, such as Português (Brasil), where it was wider than the menu and pushed the whole sidebar sideways (#51).
  • Japanese ships with adminsite, with messages in the polite form: language="ja", or languages=["ja"] for a browser set to ja or ja-JP. Kanji are drawn in their Japanese forms, in a Japanese font, wherever the page or a part of it is in Japanese (#52).
  • German ships with adminsite, addressing people as Sie: language="de", or languages=["de"] for a browser set to de-DE, de-AT or de-CH (#53).
  • French ships with adminsite, addressing people as vous and setting :, ;, ? and ! apart with a space that never breaks: language="fr", or languages=["fr"] for a browser set to fr-FR, fr-CA or fr-BE (#54).
  • Turkish ships with adminsite: language="tr", or languages=["tr"] for a browser set to tr-TR. A label lowered inside a sentence follows Turkish rules, so İndirimler reads indirimler (#55).
  • Arabic ships with adminsite, in Modern Standard Arabic, its pages read right to left: language="ar", or languages=["ar"] for a browser set to ar-SA, ar-EG, ar-AE or any other Arabic. Texts with a count read right in all six of its forms (#56).
  • Simplified Chinese ships with adminsite: language="zh-Hans", or languages=["zh-Hans"] for a browser set to Chinese for mainland China or Singapore, or to Chinese with no region; never one set to Taiwan, Hong Kong or Macau. Characters are drawn in their Simplified forms (#57).
  • Traditional Chinese ships with adminsite, in Taiwan's wording, for Taiwan, Hong Kong and Macau: language="zh-Hant", or languages=["zh-Hant"] for a browser set to Chinese for one of them; never one set to mainland China or Singapore. Characters are drawn in their Traditional forms (#58).

0.1.0a16

A list shows its rows as soon as it has read them, and its total and its filters' counts follow, so a big table keeps nobody waiting. A list scrolls in one place, its table, the buttons in a page's header wrap instead of running off the panel, and a list's own actions share one More actions menu. The sidebar is there as soon as a page starts to load.

Before upgrading:

  • A view whose count_mode is CountMode.ESTIMATED or CountMode.NONE no longer counts its filters' options. Give a filter show_counts=True to keep its counts (#60).
  • A project that overrides base.html, list.html, detail.html, activity.html, import_preview.html, _table.html, _row.html, _records_total.html, _bulk_actions.html, _filters.html, _saved_tabs.html, _form_layout.html, _inlines.html, _json.html, _document_form.html, _document_view.html, widgets/_picker.html or widgets/json.html should start its copy again from the new one. The count at the foot of the list, the page numbers and the first line of the Actions menu are drawn by _records_count.html, _page_numbers.html and _bulk_summary.html, which the total is sent back with, and the count beside each filter option by _option_count.html.
  • A project that builds its own stylesheet should build it again from the new frontend/input.css, which also keeps closed menus from taking room.

What changed:

  • A list scrolls in one place, its table. Text kept for screen readers in a cell, such as "Not set" in an empty one, stretched the page around the table to the table's full size, so the page grew scrollbars of its own and scrolling them slid the toolbar and the pager away. The same text made the whole page scroll sideways around a record page's related records on a phone and a form's inline rows on a tablet. Every box that scrolls now keeps what is placed inside it (#61).
  • The tabs above a list, on the Activity page and in a form no longer show a small scrollbar beside them, and the current tab's underline covers the line under them, as on a record page (#61).
  • The buttons in a page's header wrap where they do not all fit. A list's own actions, and a record's, sat in a row that never wrapped, so several of them ran past the edge of the panel and could not be clicked, and below 1024 pixels wide the whole window scrolled sideways (#61).
  • A long value in a list ends in an ellipsis, whatever its kind, so no value makes its column wider than a long text does. Only text, long text and JSON were cut short before; a record's name, a list of values, the names of linked records, an email, a file and a value read through a link to many now are too, as is each value in the import preview. The record's page shows the whole value (#61).
  • A list over a big table no longer waits for its filters to count every row. A view whose count_mode is CountMode.ESTIMATED or CountMode.NONE counts no filter's options, those built from a column included, unless a filter is given show_counts=True. BooleanFilter takes show_counts as ChoiceFilter does, and left out, both follow the view. A filter of your own that counts with count_by follows the same rule (#60).
  • A list shows its rows as soon as it has read them, whatever its count_mode. The total follows in a request of its own and fills in under the heading, at the foot of the list, on the button at the foot of the filters drawer on a phone and in the Actions menu, so an exact count of millions of rows, or a filter on a column with no index, keeps nobody waiting. A new page or sort keeps the total it has, and only a new search or filter counts again (#64).
  • The filters drawer asks for the counts beside its options as it opens, so the list never waits for them, and a list nobody filters never counts them. It asks again after a new search, so the counts always match the search the list shows; they kept those of the search the page was first drawn with (#64).
  • Nothing is left empty under the account menu at the foot of the sidebar. The closed language and time zone menus were still laid out there, unseen, so the sidebar grew taller than the window and scrolled. The sidebar now keeps to the window's height: its links scroll under the account menu, which stays at the foot.
  • A page's heading keeps its whole width from a tablet up, and the buttons beside it move under it where they do not fit. Given only the room the buttons left, a list's name could be cut to a few letters and its count split over three lines.
  • A list's own actions, those that run with nothing ticked, share one More actions menu beside Import and Export CSV, the dangerous ones last under a line, so however many a view has, the top of its list keeps to a few buttons. A view with only one keeps its button. A phone's ⋯ menu lists them in the same order, after Import and Export CSV.
  • The sidebar is there as soon as a page starts to load. It came after the page, so while a long list was still arriving the list took the whole width, and the page jumped sideways when the sidebar appeared with the last row. Tab now reaches the sidebar's links before the page's, after the "Skip to the content" link, which jumps over them.

0.1.0a15

The list is quicker to filter and to act on. The Filters button opens every filter at once, in a drawer over the list, and each applies as it is picked; the actions for the ticked rows sit in an Actions menu above the list. The foot of the sidebar is one account menu, and a long choice filter has a search box.

Before upgrading:

  • A project that overrides base.html, list.html, _toolbar.html, _table.html, _row.html, _list_forms.html, _list_menus.html or _icons.html should start its copy again from the new one. The filters are drawn by _filters.html and the Actions menu by _bulk_actions.html, which _toolbar.html includes.

What changed:

  • A choice filter with more than 10 options has a search box above them, which narrows them to those whose label holds what was typed. Ticked options stay in view while it does (#44).
  • The foot of the sidebar is one account menu: the name of whoever is signed in opens it, upwards, with a Dark mode switch, the language and the time zone, each opening its own menu beside it, and Sign out. They sat side by side before, and crowded the name out. Without signing in, the button reads Settings.
  • The Filters button above the list opens every filter at once, in a drawer over the list, and each applies as it is picked: ticking a box or choosing a range reloads the list behind it, with no Apply button. On a phone the drawer covers the screen, and the button at its foot says how many records match. The filters in use show as chips under the toolbar, counted on the button. A chip opens the drawer at its filter, its cross takes it off, and Clear all takes every filter off but keeps the search and the sort. A range's chip says it in words, such as "Total: 100 or more" (#46).
  • The search box, the columns, the rows per page, Export CSV and Save this view keep the sort, the search and the filters the list shows. They kept those the page was first drawn with, so once the table had reloaded on its own, after a sort or a search, changing the columns lost the sort and the export left out the search (#46).
  • The actions for the ticked rows sit in an Actions menu above the list, which counts the ticked rows and offers to select every matching one. Dangerous actions come last, under a line. It replaces the bar that floated over the table (#46).

0.1.0a14

Times show on the clock of the person reading them: the browser says which time zone it is set to, every datetime is shown in that zone, and a time typed in is read in it. A menu can offer other zones, and a column declared DateTime(timezone=True) keeps the moment meant.

Before upgrading:

  • A project whose database keeps local times rather than UTC should pass that zone as database_timezone, or its times will show shifted.
  • A client of the JSON API that reads datetimes strictly should expect an offset after each one, such as 2026-09-01T10:30:00Z.
  • A project that overrides base.html, login.html, detail.html or _history.html should start its copy again from the new one. The browser's zone is saved by _browser_timezone.html, which base.html and login.html include.

What changed:

  • Times show on the clock of the person reading them. The browser says which time zone it is set to, and a datetime is shown in that zone wherever it appears; a time typed into a form, the list, an import or the API is read in it. Admin(timezone=...) is the zone until the browser says, and timezones adds a menu at the foot of the sidebar to choose another (#42). It sits with the language menu on a row of their own, above the name of whoever is signed in.
  • A datetime the database keeps without a zone is taken to be in UTC, unless database_timezone names another. A column declared DateTime(timezone=True) is given a time with its zone, so Postgres stores the moment meant, whatever zone its connection is set to.
  • A time the clocks skip or repeat when they change is refused, rather than saved an hour off.
  • History and Activity show when each entry happened on the reader's clock, no longer followed by "UTC". A changed time in the history reads as the log keeps it, in UTC, and says so. The date filter and the Activity page count the reader's days.
  • The API sends each datetime in UTC with its offset, such as 2026-09-01T10:30:00Z.
  • Windows installs tzdata, since it has no time zone database of its own.

0.1.0a13

Values change straight from the list: a view's inline_editable_fields opens an editor under a cell and saves it as the edit form does. The Activity page shows an entry about a record only to someone who may read that record's history, where before reading the view's history was enough, and actions take an icon.

Before upgrading:

  • A scope_query that leaves nothing out for some users, such as superusers, should hand the statement back unchanged for them. The Activity page counts any other answer as leaving records out, and hides from those users the entries about deleted records and, by default, sign ins.
  • A project that overrides _table.html, base.html, list.html, detail.html, _inlines.html or widgets/readonly.html should start its copy again from the new one. A list row is now drawn by _row.html, and an empty value by _empty.html.

What changed:

  • The Activity page shows an entry about a record only to someone who may read that record's history, as its History tab does: the view's scope_query holds the record, and allows(Permission.HISTORY, record=...) says yes. It checked the view alone, so someone whose scope left records out read their changes there, or asked for one by its key. An entry about a deleted record shows only where scope_query leaves nothing out for the user, and by default so do sign ins.
  • When the linked model has several views, a record page names and counts a link to many records as the list does: each record one of those views holds, linked to the first that holds it. It read through the first view alone, so records only another view held were left out (#36).
  • An empty value shows as a grey hyphen in place of an em dash, now in the list too, where its cell was blank, and screen readers say "Not set". One template draws it, adminsite/_empty.html, so a project can change it through template_dirs.
  • Every page draws a line under its header once it scrolls, so the rows that scroll beneath it no longer run into it.
  • An action takes an icon, as a view does: inline SVG markup or a picture's address, drawn before its label in the bar over the ticked rows, above the list, in a row's menu and on the record page (#38).
  • A view's inline_editable_fields names fields whose values change straight from the list: a cell opens an editor under it, which saves that one value as the edit form does, through allows, can_access_field, get_readonly_fields, the save hooks and the audit log, and draws the row again in place (#34).

0.1.0a12

The package is laid out the way Starlette's is: a module a project imports is named for what it holds, and machinery starts with an underscore. A JSONField with partial=True shows only the fields a record sets, with a box to add the others, and the Reference page documents every parameter of the public API.

Before upgrading:

  • Behaviour is unchanged, but these imports move, and the old paths stop working:
Name Was imported from Now from
Database, SessionAdapter, Statement adminsite.backends.sqlalchemy adminsite.database
SQLAlchemyInspector adminsite.backends.sqlalchemy adminsite.inspector
SQLFilter, ChoiceFilter and the other built-in filters, SQLAlchemyRepository adminsite.backends.sqlalchemy adminsite.filters
Permission, RequestAction adminsite.security adminsite.permissions, or adminsite as before
add_message adminsite.http.templating adminsite.messages
Html adminsite.text adminsite.markup, or adminsite as before
SaveContext, DeleteContext adminsite.views.writing adminsite.views.contexts, or adminsite as before
QuerySpec, Page, Sort adminsite adminsite.query
FieldSchema, ModelSchema, RelationSchema adminsite adminsite.schema
ViewRegistry, SavedView adminsite adminsite.views, adminsite.saved_views
InvalidPathError, NotAModelError, UnknownFieldError adminsite adminsite.exceptions
ModelInspector adminsite removed: SQLAlchemyInspector was its one implementation

What changed:

  • The Reference page documents every parameter, setting and return value of the public API, from the docstrings, so an editor shows the same when you hover over Admin(, Field( or a setting of ModelView. The guide pages link there instead of listing each option again.
  • A JSONField with partial=True draws only the fields a record sets, each with Remove, and an Add a field box that finds the others by name or description, where it drew a Not set row for every field of the schema. The record page lists the fields left out on one line, Not set (48), which opens to name them.

0.1.0a11

A record page that reads like the list: a table of fields, each label beside its value. Forms in one column with a save bar that stays in view, and form_layout for panels, rows and tabs. A linked record that its own view's scope_query hides now shows as Hidden, where before its name and values showed.

Before upgrading:

  • A project that overrides _table.html, detail.html, form.html, _field.html, _inlines.html, _values.html, widgets/readonly.html, _toolbar.html, _list_menus.html or import_preview.html should start its copy again from the new one. They reach a view's fields through view._fields and its pages through view._pages, and the record page and the forms are drawn from the view's layout.

What changed:

  • The record page lists its fields as a table: a row per field, the label beside the value, so the labels read down one column. A link to one record is a link in its row rather than a card beside the details, each record a link to many holds opens its own page, a yes or no always shows Yes or No, and a line under the title says who changed the record last when the audit log is on.
  • The create and edit forms put one field on each line, in the order of fields, each input as wide as what it holds. A read-only value reads as plain text. The browser's own checks are off, so a missing value gets the admin's message in the admin's language, and after a failed save the page title starts with "Error:" and the list of mistakes takes the focus.
  • A form's Save, Cancel and Delete sit in one bar at its foot, which stays in view while a long form scrolls; the buttons in the header are gone. On a phone each child row of an inline is a small group of fields, one under the other.
  • form_layout arranges a view's forms and its record page, as starlette-admin's does: PanelWidget for a titled card, FieldsetWidget for a bordered group, RowWidget or a tuple for fields side by side, a list for a column and TabsWidget for tabs. A field the layout leaves out comes last; one placed twice, or not among the view's fields, stops the admin starting. The field gallery uses it (#33).
  • The stylesheet's and the scripts' addresses end in a short hash of the file, so after an upgrade a browser fetches the new ones at once rather than keeping the old stylesheet with the new pages.
  • A linked record that its own view's scope_query hides from the user now shows as "Hidden", with no name and no link, wherever another view shows it: the list, the record page, the form, the export and the API, which sends null. A column read through a link to many leaves out the records the linked view hides. Before, their names and values showed.
  • adminsite no longer knows the names it used before 0.1.0a10. A view that sets one, such as list_display, starts and the setting does nothing, and an old class or keyword fails like any name that does not exist.
  • context.request in the save and delete hooks, and selection.request in an action, are typed Request rather than Request | None: they always hold the request.
  • The signed-in user is on request.state.user, as the permission examples in the docs read it. The sign in page no longer asks each view's allows while nobody is signed in.

0.1.0a10

Views written the way starlette-admin writes them, and typed: the model as the view's type argument, one fields list, columns named by attribute, actions that ask for typed values, and a check of every name when the admin starts. A JSON column can be edited as a form built from its schema.

Before upgrading:

  • Every view needs rewriting in the new shape, which the guides show. A view that still uses an old setting, keyword or class stops the admin when it starts, with what to write instead.
  • A project that overrides _table.html, detail.html, _toolbar.html, _list_menus.html, import_preview.html, _action_forms.html or dashboard/recent.html should start its copy again from the new one. They call the view's methods by their new names, such as view.get_record_title, and an action's dialog draws its inputs, files included, with _action_inputs.html.

What changed:

  • A view names its model as its type argument, class OrderView(ModelView[Order]), in place of class OrderView(ModelView, model=Order), which now stops with the new form. ModelView[Tag] can be registered with no class of its own.
  • Settings name columns by attribute as well as by string: fields = [Order.id, Link(Order.customer, Customer.name)], fields_default_sort = [Descending(Order.created_at)]. An attribute of another model, a link that leads elsewhere, or one string where a list belongs stops the admin with a message naming the setting.
  • fields lists a view's fields once, in order, for every page, as starlette-admin does. A page leaves one out by a flag on the field, such as exclude_from_list=True, or by a list on the view, such as exclude_fields_from_export; hidden_in_list=True keeps a column in the Columns menu but off the list until someone turns it on. Forms leave out columns of related models, computed fields, and keys the database or the model fills in, such as an autoincrement id or a uuid4 default. searchable_fields, sortable_fields and fields_default_sort join them, and Inline takes attributes too: Inline(Order.items, fields=[OrderItem.product]). A view that lists no fields shows every column on every page, its key on the record page included. sortable_fields left out sorts by every column on the list, and [] sorts by none, as in starlette-admin.
  • list_display, form_fields, detail_fields, list_columns, exclude and readonly_fields are gone: list the fields once in fields, leave one off a page with the exclude_fields_from_ lists or flags, and give a field hidden_in_list=True or read_only=True. A computed field is shown and never edited, so no form has it any more, and form_only=True on one is refused. get_list_display, get_form_fields, get_detail_fields and get_column_choices are gone too: can_access_field decides who sees a field. So is get_page_sizes: list the sizes in page_size_options. A view that still sets or defines one of these stops the admin when it starts, saying what to write instead.
  • A key people type, such as a code or the two columns of a composite key, is on the form for a new record, required, and read only once the record exists, so saving a form never moves a record to another key; a JSON PATCH that sets one answers 422. So is a link made only of key columns, such as the customer of a profile keyed by it. A key the database or the model fills in stays off both forms, and read_only=True on a key locks it on both, as in starlette-admin. If a hook changes a key, the edit page and the JSON API follow the record to its new key.
  • Fields take the column they show and typed options, all keywords: Field(Order.created_at, label="Placed") is the field adminsite picks for the column, with your options, and a kind such as DecimalField(Order.total, read_only=True) chooses it. Whatever the options leave out comes from the column, so a field you write keeps its column's length, whether it may be empty, an enum's choices and a relationship's model. A type checker refuses a text field on a number column, a misspelt option, an option by position, a tone that does not exist, and a ComputedField written for another model. EnumField(Order.status, choices=[...]) on an Enum column keeps the column's enum, the choices relabelling or narrowing its members, and EnumField(Customer.region, enum=Region) on a string column stores each member's value, as starlette-admin does.
  • A field written in a view without required now takes it from its column, as the field adminsite picks does, where it used to mean not required. Give required=False to keep a field optional.
  • TextField, ChoiceField and Computed are now TextAreaField, EnumField and ComputedField, with starlette-admin's names; importing an old name raises an error naming the new one. FieldOptions("price", format=...) is Field(Product.price, format=...), which places the field as well, and importing FieldOptions names Field. readonly= on a field is read_only=, enum_class= is enum=, ComputedField's needs takes attributes, and RelationField(Order.customer, view=CustomerView) names its view by class as well as by name.
  • A computed field's format is used; it was left out before.
  • Every name in a view's settings is checked when the admin starts, strings as well as attributes: one the model does not have stops the admin with a message naming the view and the setting and listing the model's columns and relationships. So do a relationship or a computed field in searchable_fields, sortable_fields or fields_default_sort, a sort through a relationship holding many records, a field both hidden in the list and excluded from it, a choice field with nothing to choose from, a field kind that does not fit its column, such as RelationField(Order.customer_id), an option the kind never reads, such as max_length on a date, an exclude list naming a field the view does not show, RelationField(view=...) naming a view the admin does not have, and a class that is not a mapped model.
  • A tone given for a value a choice field does not have is refused when the view is built, once the column has given its choices, rather than when the field is written.
  • record_title = "Order #{id}" names a view's records, in place of display_template, and get_record_title(record) in place of title_of. A name in braces the model does not have, in a view's title, an inline's or a link's, stops the admin with the model's columns listed, where it used to show as nothing. A title reads the model's own columns: a relationship in braces, such as {customer.name}, stops the admin too, since a page loads a record without its links.
  • can_access_field(request, field, action) decides who sees which field on which page, as in starlette-admin: the list and its Columns menu, the record page, the create and edit forms and the export, each named by RequestAction. A refused field is never read back from a form, and is left out of the JSON API, the search box, the filters and their counts, sorting, the History tab and the Activity page, and a RecentRecords card. The JSON API and ?sort= follow the list, the record page and the edit form.
  • get_readonly_fields(request, record) takes attributes, [Order.customer, Order.status], and a misspelt name it returns is refused rather than ignored. can_view_detail and Permission.VIEW_DETAIL replace can_detail and Permission.DETAIL, and Permission.DETAIL raises an error naming the new name.
  • A field for a column of a related model is named by its path, customer.email, rather than by the column alone, whether adminsite works it out or Field(Link(...)) gives it.
  • An action asks for its values with typed parameters, as FastAPI does: carrier: Literal["DHL", "UPS"] is a select, day: date a date picker, product: Product a record to pick, a dataclass its fields under one heading, and UploadFile a file, which no action could ask for before. The values reach the method typed, and Annotated[str, Input(label="Reason", multiline=True)] words an input. A parameter typed Request, AsyncSession or SessionAdapter is handed the request or the session, and a record action's record goes to the first parameter typed with the model, before or after the *. A type no dialog can ask for stops the admin when it starts, naming the parameter. inputs= and untyped (record, session) methods still work, and when get_actions replaces an action's inputs, its other typed parameters are still asked for.
  • A link to many records that is required, such as an action's stockists: list[Supplier] with no default, now needs at least one record chosen, as a choice of several already did.
  • Selection[Order] names the model of the rows an action runs over, so records() returns orders. A bare Selection now needs its model under mypy's strict mode, as a bare list does. records(paths=...) takes columns as attributes too.
  • A save hook takes SaveContext[Order], so context.record is an order, and reads and changes each value by its column, typed as the column is: context.values[Order.status].get() and context.values[Order.note].set(...). A column the save leaves as it is reads as the record has it. context.values.get("name") and context.set("slug", ...) are gone; a string still names a column, or an input that is no column, as context.values["password"].get(), and set() refuses any other name, as get() does. A link reads as its record, even when the linked model has no view. On a new record, a column the save leaves out reads as None until the insert fills in its default.
  • after_save_committed and after_delete_committed run once a change has committed, for an email or a webhook that must only follow a change that was kept. An error in one is logged, and the change stands. context.record holds what was saved, with the values the database set in its own columns, even after after_save changed it, and stays loaded after the commit whatever the session factory says. Values the database sets on an inline's rows are not read back.
  • RefusedError(..., field=Order.note) names the field by its attribute, or by Link(...), as well as by its name.
  • scope_query takes and returns Statement, so returning a new select in place of the one given is a type error, and scope_query, search_condition, allows, can_access_field and get_readonly_fields take a Request, never left out. allows takes record= too, None for a question about the whole view. An override typed -> Select[Any] needs -> Statement under mypy's strict mode.
  • SQLFilter[Order] names the model a filter narrows, so its condition gets a SQLAlchemyRepository[Order].
  • More of starlette-admin's names: list_filter is list_filters, page_sizes is page_size_options, bulk_delete is can_delete_selected, search_fields is searchable_fields and ordering is fields_default_sort. get_filters, get_search_fields and get_ordering are get_list_filters, get_searchable_fields and get_fields_default_sort, and form_values is form_only_values. A view that still sets an old name, display_template, title_of or can_detail among them, stops the admin when it starts, with the name it has now.
  • A setting from another admin, such as Django's list_display or search_fields, is refused with words for someone who never used an older adminsite: "OrderView sets list_display, a setting adminsite does not have."
  • The per-request methods answer with what their setting takes: columns by attribute or string from get_searchable_fields, columns or filters from get_list_filters, and sorts such as Descending(Order.created_at) from get_fields_default_sort, each checked like the setting. They, get_deferred_fields, get_inlines and get_actions take a Request, never left out, and get_inlines takes the record too.
  • A view's own machinery is private: save, delete, fetch_page, display, field_for and the other methods adminsite calls itself start with an underscore, as do schema, repository and filters. Completing a view's name offers its settings, its get_ methods and its hooks, and an action named save or delete no longer replaces adminsite's own. A method named delete_selected no longer replaces the built-in delete of the chosen rows either, and stops the admin: set can_delete_selected = False and add an action of your own.
  • Inline(extra=1) is Inline(blank_rows=1), Admin(fields=...) is Admin(field_registry=...), RecentRecords(detail="total") is RecentRecords(value="total"), SignInRefused is SignInRefusedError, and a field class's blank_keeps is keeps_value_when_blank. Each old keyword raises an error naming the new one.
  • Every module lists what it offers in __all__, so an editor completing adminsite.files no longer offers Path or re.
  • adminsite's types hold on SQLAlchemy 2.0 as well as 2.1, and CI now runs mypy and the tests on 2.0 too.
  • The guides, the README and the docstrings write views this way, and CI type-checks every example in them under mypy's strict mode. Examples that could not have run are fixed: the audit log is read through the AuditLog given to the admin, and a SQLite listener goes on a sync engine.
  • Inline(fields=...) takes fields as a view's fields does, so Field(OrderItem.added_at, read_only=True) shows a value the row never edits; readonly_fields= is gone. A row follows a view's pages: an existing row has the edit page's fields, a new row the create page's, and the record page the detail fields, a Link among them. A row leaves out only what the inline's relationship fills in, so another link to the parent's model, such as a match's away team, stays in the rows, and a relationship with no link back no longer asks each new row for the parent's key. The parent's can_access_field decides a row's fields, asked by their path such as items.unit_price, and its get_readonly_fields locks one with Link(Order.items, OrderItem.unit_price). A refused link shows in its row's cell, and a mistake in an inline's fields names the view and the setting. Inline and RelationField take record_title in place of display_template, as a view does.
  • A ComputedField loader is typed as taking a SessionAdapter, so an editor completes session.execute in it.
  • JSONField("settings", schema=ShopSettings) edits a JSON column as a form built from a Pydantic type or a JSON Schema: a switch for a yes or no, a number input that keeps to its limits, the picker for a few fixed values, tables for lists of objects and for maps. Saving checks the document with Pydantic and shows each problem beside its input; the record page names each value by its title, and the API checks documents against the same schema.
  • A JSON field's schema can come from a function given the record, for a table whose rows hold documents of different shapes. On a new record the form draws the document again as the rest of the form changes, such as when a setting's key is chosen.
  • partial=True on a JSON field with a schema lets each property be left unset, with Set and Clear beside it, and saves only the properties set, as an override keeps what it changes.
  • A list's columns fill the table's width again, as they did before 0.1.0a9. A key, such as an order's number, now reads from the start like text rather than lining up on the right like an amount, so on a list of one or two columns it no longer lands in the middle of the page. Each row's menu stays right after its checkbox.
  • A key with the wrong number of parts, such as /shelves/A for a key of two columns, answers "not found" rather than a server error, and a link's key with a part too many is refused rather than cut short.
  • A save or a delete from the pages commits once. It committed a second time, with nothing left to commit.
  • The counts beside a list's filters count only the rows scope_query lets the user see. They counted every row in the table, which told a user how many records they may not open.
  • A change the database refuses, such as a second customer with the same email, raises adminsite.exceptions.IntegrityError from SessionAdapter.transaction() and commit(), with SQLAlchemy's error as its cause. Its message is worded for people and holds none of the database's text, so no refused value reaches a page or the audit log. A commit that fails is rolled back.
  • A save, an action or a delete the database refuses says so and names the likely causes: a value that must be unique is taken, a required value is missing, or other records still refer to one being removed. A delete the database refuses for itself still says other records refer to it. A save used to blame a clash with another record, and an action or a delete other records referring to them, whatever the cause.
  • An import that changes existing records follows their edit form. A column that form does not let this user change, through exclude_from_edit, can_access_field or get_readonly_fields, may only hold what the record already has, so an exported file imported back still goes through, and a column this user reads on no page may only be left empty. Any other value shows in the preview as "This field cannot be changed." and the row is skipped. A column only the edit form has can be imported into existing records.
  • An import adds a record whose key people type, such as a country's code, when a row names a key no record has. A row that leaves that key blank, a file without its column, or a second row adding the same key shows as a problem in the preview, where it used to fail at the database or answer with a server error. A key the database fills in still has to name a record that exists.
  • The JSON API runs an action on one record or on the view the way the pages do: a record action takes its one key, is handed the record and asks the record's own permission, and an action on the view is written to the audit log once. The index says what each action runs on, as on. An action that answers with a file sends the file, and an inputs that is not an object answers 422.
  • The record page names the records of a link to many, and counts the rest, through the linked model's own view, so records its scope_query hides are neither named nor counted.
  • A relationship on the list, such as Order.customer, and a column reached through a link to many no longer get a sort link, which answered with a server error. A column read through a link to many, such as Link(Customer.orders, Order.total), shows each record's value however many links it goes through, and the JSON API sends the values as a list. A number answered with a server error, a column further along showed the wrong value, and the API answered with a server error.
  • A JSON PATCH or POST that saves a record outside what scope_query lets the user read answers 200 or 201 with only the record's key. It saved the change, then answered with a server error.
  • A foreign key whose database column has its own name, such as mapped_column("personId", ForeignKey(...)), shows as its relationship in a view's default fields, like any other foreign key, and a link held by the other model, such as a person's passport, no longer takes the place of the model's own key there. RelationSchema.local_columns holds attribute names rather than database column names, and the new remote_columns holds the linked model's.
  • A key of two columns written as text, such as A,1, finds its record everywhere: a link to such a model with no view saves, and the JSON API answers 201 rather than 500 after creating such a record.
  • A list, an export or a picker over a view with no sort goes by the record's key. It had no order, so on Postgres a page could repeat rows of another, and an export could repeat some rows and leave out others.
  • Creating a record through a session factory you give, such as Admin(async_sessionmaker(engine)), no longer answers 500 once the record is saved.
  • after_save can read a value the database set during the save, such as an onupdate time. On an async engine, reading one failed the save.
  • SQLAlchemyRepository.create and .update, which nothing called, are gone.

0.1.0a9

A picker you type in, links kept in the order you give them, a code box for JSON, and a list that keeps its columns and each row's menu together. The live demo gains a gallery of every field.

Before upgrading:

  • A project that overrides base.html, _table.html, _row_actions.html, detail.html, _inlines.html, or the widgets _picker.html, relation.html, select.html or json.html should start its copy again from the new one: the picker, the JSON box and the list's row menu work differently now, and a search answers with list options.

What changed:

  • In Chrome and Edge a select that holds several options, such as a link to many records of a small table, laid them out in a row, cut off and overlapping. It is a list again.
  • A link to many records is always a picker: its records as chips, in the order they were chosen, and a list to search. For a table of a hundred records or fewer the whole list opens at once and narrows as you type; choosing a chosen one again lets it go. A choice of several options is the same picker. The record page names a link's records in the relationship's own order, where it went by their keys.
  • ordered=True on a link to many, such as servers tried in turn, shows its records as a numbered list to put in order, by dragging or with buttons to move one up or down. When the order changes, saving writes the link's rows again in the order shown, for a relationship read in the order of its link table's serial id.
  • The picker is one box to type in, where it was a box that said "3 chosen" and a Choose button that opened a second search box. Clicking the box or typing in it opens the list under it; the chosen records sit in the box as chips; the arrow keys, Enter, Escape and Backspace work as in any search field. A search on the server says when more records match than it shows. An ordered link adds from a box under its list, which leaves out what the list holds.
  • In an inline row, a link to a table of over a hundred records showed its record as the model's bare text, such as <Supplier object at 0x…>, and in a row added with Add a row its search found nothing. It names the record as the form does, and searches in every row.
  • A JSON field is edited in a code box: line numbers, colours, Tab and Enter that indent, brackets and quotes that close themselves, a Format button, and a check as you type that says what is wrong and where. The record page shows the whole document, laid out and coloured, each object and list folding on its line, with Fold all and Copy; it used to cut a document short at 120 characters.
  • A list's columns sit together from the start of the row, where a few of them spread across a wide screen, and a number could land in the middle of the page. Each row's menu comes right after its checkbox, in reach however many columns follow, where it was at the far end.
  • A second example, examples/fields.py, puts every kind of field on one model, each saying which class it is. The live demo shows it as a Field gallery beside the shop, whose products now have tags.
  • AuthProvider.sign_in_values(request) fills in the sign in form, for a public demo whose visitors all share one account. The live demo now opens with its details already in place.
  • After a failed sign in the form keeps the username that was typed, and the cursor waits in the password box.

0.1.0a8

A fix for record pages that 0.1.0a7 broke, and an audit log that is saved with the change it describes.

Before upgrading:

  • With the audit log in your own database, a save now fails when the audit table is missing, where the entry used to be lost after the change was saved. Add the table to your migrations, or pass create_table=True, before you upgrade.

What changed:

  • A record page answered 500 when a to-many link it shows held no records, or when a computed field on it needed a link the page also shows. Both broke in 0.1.0a7.
  • An audit log kept in your own database, with AuditLog(engine), is written in the same transaction as the change it describes, so the two are saved together or not at all. A log kept elsewhere, such as the default SQLite file, is still written after the change. When that write fails, a saved change, a sign in or a download no longer ends in an error page: the change stays, and the server log names the entries that were lost. A store of your own joins the transaction with lives_in and record_within.
  • Work that waits on a commit, such as removing a file a save replaced, all runs even when one piece of it fails.

0.1.0a7

What a large back office asks of its admin day to day. Actions can ask for another record, delete the chosen rows through each record's own hooks, and answer with a message that stays or holds a value to copy. A form can hold inputs that are not columns, such as a password. A computed value can come from one query for the whole page, a Postgres array is edited as a list, a status says which colour each of its values is, and every record and link has a readable name, never a memory address.

Before upgrading:

  • Every view that allows deleting now has Delete in its selection bar. Set bulk_delete = False to leave it out.
  • Records and links are named differently. A record whose model has no __str__ reads "Order #12", a linked record reads as the view showing its model names it, and a picker puts a link's own display_template first. The export and new audit entries change with them.
  • An action input named just key is now written to the audit log. Give it secret=True if it holds one.
  • The JSON API reads a Postgres ARRAY column from a list. A string is read as one value per line.
  • The inputs in an action's dialog have ids of the form field-<action>-<name>.
  • A mistake in FieldOptions is an error when the view is created, where it waited for the first page that used the field.
  • ChoiceField.tone_of answers None for a value drawn with no badge. A value that is not one of the choices answers grey, where it answered None and was drawn grey.

What changed:

  • adminsite works with SQLAlchemy 2.1, which a fresh install now gets. It depends on sqlalchemy[asyncio], since 2.1 no longer installs greenlet by itself and an earlier adminsite could not even be imported without it. A JSON column is still edited as a document, where 2.1's new answer for its type turned it into a text box. SessionAdapter.execute and a chart's query are typed for rows of any number of columns, as 2.1 types them column by column. The tests pass on SQLAlchemy 2.0 and 2.1.
  • A choice or a yes or no says which colour each value's badge is, by name: FieldOptions("status", tones={OrderStatus.FAILED: "rose", OrderStatus.DONE: "green"}). A value left out is grey, and None draws no badge, so a risk flag can be a rose badge when it is set and nothing when it is not. One tone, such as tones="grey", colours every value alike. Without tones, a choice still takes the colour of its place in the list.
  • Pasting into a list's search box or a link picker's search box now searches. Both only reacted to keys being typed, so text pasted with the mouse found nothing until another key was pressed.
  • An action can ask for another record. A RelationField among its inputs offers the records that model's view lets the user see, as a list, or as a search box above a hundred, and the method receives the chosen record. A key the user could not have picked is refused. The audit log names the record, and the JSON API takes its key. Each dialog's inputs have ids of their own, so two dialogs on one page that ask for the same name no longer clash.
  • A linked record is named the same way everywhere: by the link's own display_template, or else as the view showing that model names it. List cells, the record page, the export and the audit log used the model's __str__, which reads <Group object at 0x...> for a model without one, while pickers used the other view's template. A picker now puts the link's own template first.
  • A record is named by its view's label and its key, "Order #12", when the view has no display_template and the model no __str__ of its own. The page heading, the row menu, the command palette and the audit log showed <Order object at 0x...>. A model's __repr__ is no longer used as a name, since one written for a dataclass prints every column.
  • translations={"en": {...}} changes the admin's own English wording, such as "Username" to "Email" on the sign-in page, without copying a template. English was never looked up before.
  • A Postgres ARRAY column is edited as a list, one value per line, where it was a JSON document to type by hand. Each value is checked by the field its type calls for, and a mistake is named by its line. The list and the export show the values separated by commas, the JSON API takes a list, and an import takes values separated by commas. ListField does the same for a JSON column holding a list.
  • A form can hold inputs that are not columns. A field given form_only=True is never read from the record or written onto it; its value reaches before_save and after_save, which store it where it belongs. A view's form_values gives these inputs their starting values, such as settings kept as rows of another table.
  • PasswordField, a password input that is never filled in. On a record that exists, leaving it empty keeps the password there. The JSON API takes it and never sends it back.
  • The audit log keeps *** for a secret field in what a save or a delete changed: one named like password_hash or api_key, or given secret=True. A name with key in it now counts as secret only when it says what the key is for, as in api_key or private_key, so a sort_key is shown again.
  • Adding a record through the JSON API to a view with a computed field failed with an error. It now answers 201 with the record.
  • Computed(name, load=...) works a value out with a query that runs once for every record on the page, such as a count of related rows: an async function given the session and the records, answering with each record's value by its primary key. The list, the record page, the form, the export and the API all show it, and default covers a record the answer leaves out.
  • Delete in the selection bar of every view that allows deleting. Each chosen record goes through the same permission check, before_delete, after_delete and audit entry as a single delete, in one transaction: when one is refused, nothing is deleted and the message names it. Switch it off with bulk_delete = False. selection.records() takes paths= to load links with the records.
  • A relation can name the view its links open and its picker lists from, as FieldOptions("owner", view="buyers") or RelationField(..., view="buyers"), for a model shown by several views. Without it, a record's card for a record the first view leaves out opens the first view that holds it, where it answered 404.
  • A link to many records on a record page names the first 20 and says how many more there are, in two small queries, where it loaded every linked record into one long line.
  • A view's search_condition(term, request=...) decides what its search box matches, for example an exact, normalised phone number that an index can answer, where the usual search looks inside every search field. The list, its count, the export, "select all matching", the command palette and pickers all use it; returning None keeps the usual search.
  • An action can answer with a Message: one that stays until it is closed, carries a link, or shows a value with a button that copies it, such as a new API key. The audit log keeps the text and never the value to copy. An action that answers with Html has its markup kept, where it was escaped before. The JSON API returns the link and the value as fields.
  • in_sidebar = False leaves a view out of the sidebar, the command palette's list of pages and the overview's counts, for a view whose records are only opened from other records. Its pages, links and record search stay as they were.
  • A filter that get_filters returns for one request, without list_filter naming it, now narrows the list. It was offered and could be picked, but changed nothing.
  • @action(..., audit_answer=False) keeps an action's answer out of the audit log, for one that shows a secret once, such as a new API key. The entry still says who ran it, on what and when.

0.1.0a6

A new look, and an audit log that answers what gets asked of it afterwards. The admin is redesigned throughout and fits a phone. The audit log now records sign ins, every action and export, and what failed, names each person by a key that lasts and the address they came from, pages and filters, and can live in a table of your own.

Before upgrading:

  • The audit table gains the columns user_key, ip, user_agent, error and inputs, all of which may be empty. A table adminsite created adds them by itself; for one in your own migrations, generate a migration.
  • PasswordAuth.verify raises SignInRefused for an unknown username or a wrong password, where it returned None.
  • A record's History tab shows its latest twenty entries, and the Activity page the rest.

What changed:

  • A new look. The admin sits on a quiet ground with each page on a raised panel, set in Geist, which now ships inside the package with no request to any other site. The sidebar gets icons, search at the top, and an active page you can read in light mode as well as dark. Colours come from two daisyUI themes, so a project can still restyle the admin with its own.
  • The list reads better. Saved views are tabs above it instead of a menu. Each filter is a chip that opens a small form of its own, and a range is typed into two fields, or picked from presets, instead of one box that wanted from,to. A choice such as a status is a coloured badge, in a tone that follows its place among the choices so it never changes between pages. A row's actions and its form sit in one menu at its end, ticked rows raise a bar of actions over the table, and the pager numbers its pages.
  • A record's page has a title with its status beside it, its actions as buttons and delete in a menu, its details in two columns, and the records it links to one by one as cards beside them, opening the linked record's own page. The history's latest entries sit there too.
  • The form lists every problem at the top, each a link to its field, as well as beside the field, so a mistake at the bottom of a long form is never missed. Fields sit two to a row where they are short, a value that cannot be changed reads as text instead of a greyed out box, and an inline row is removed with a bin button instead of a red box. Inline.extra blank rows now appear only while a table has no rows, so editing a record no longer shows an empty line under its children.
  • The overview's cards share the new look. A chart gets lines across it at round numbers, an axis that says what they are, its tallest bar picked out, and each bar's value on hover. A day that SQLite hands back as text reads as "Sep 14" like any other date. The latest records card links to the whole list.
  • On a phone the list becomes a column of cards, each with its first column and badge on top and the rest below, its amount at the end. A page's secondary actions fold into one menu so the header keeps to one line, the form's title gets the whole line, and the overview's cards sit two to a row.
  • Every field takes format, such as format="€{:,.2f}", for how its value is written in the list, on the record page, in the export and on the overview. The form keeps the plain number. The sign in page, error pages, the activity page, imports and action dialogs share the new look.
  • A checkbox's tick appears at once. daisyUI drew it over 0.4 seconds, which dragged when ticking a column of rows.
  • Admin(banner=...) shows one line above every page and on the sign in page, such as "Staging: changes here are not real."
  • In a short window the overview and the forms no longer make the whole page scroll. The chart's numbers for screen readers were hidden from sight but kept their full height, and text kept for screen readers low in a long form was placed against the page instead of the panel.
  • Every audit entry says who acted by the key AuthProvider.identity returns, as user_key, as well as by the name shown, which it had already. It also keeps the IP address and the browser, and the History tab shows the address. The audit table gains the columns user_key, ip, user_agent, error and inputs, all of which may be empty. A table adminsite created adds them by itself; for one in your own migrations, generate a migration after upgrading.
  • The audit log can live anywhere. The admin writes and reads it through AuditStore, two methods: record and find, which takes an AuditQuery with filters and a place to page from. A store of your own can keep the log in a table your application already has, and the History tab and the Activity page show its rows. AuditLog gains find; history and recent stay.
  • The audit log keeps every sign in, failed attempt and sign out, with the address and browser. verify can raise SignInRefused(reason, user=...) to say why an attempt failed; the reason goes to the log, never to the person. PasswordAuth.verify now raises it, saying whether the username was unknown or the password wrong, where it returned None before. The Activity page shows these entries to people who may read every model's history, and AuthProvider.may_read_sign_ins changes who.
  • Every action is audited: on one record, with the fields it changed; on the whole model, which wrote nothing before; and one that answers with a file, which returned before it was written down. Each entry keeps the values the action was run with, with secrets as *** and files by name, type and size. A field's new secret option decides what is a secret, where a name such as password or api_key does not. An action that is refused, not allowed or fails is written down as failed with its reason, after its work is rolled back. Downloading a list as CSV is written down with its search and filters.
  • The Activity page pages through the whole log, fifty entries at a time, where it stopped at the latest 200. It filters by kind of entry, person, days and record as well as by model, and keeps the filters in its address. A record's History tab shows the latest twenty, where it showed a hundred, and links to the rest on the Activity page.

0.1.0a5

A security release. A review of every route found ways to read or link records outside a view's scope, to sort by columns the view hides, to post to an admin without a secret key from another site, and to tell usernames apart by how long a sign in took. All are closed. Anyone on 0.1.0a4 or earlier should upgrade.

  • Security. A link could be set to a record its own view would not show. The form's picker offered only records within the target view's scope, but the key that came back was loaded by itself, so a user scoped to one region could attach an order to a customer of another, and learn from the answer whether a key existed. A key is now resolved through the target's own view, on the form, in inline rows and in the API, and a key it will not give up is refused like any other bad choice, with nothing said about whether the record exists.
  • Security. ?sort= took any column, including one left out with exclude and columns of a linked model, which put the rows in the order of a value the user could not see. A sort asked for in the URL now has to name a column the user can read somewhere on the view.
  • Security. An admin without a secret_key has no session, and so had no form token, which left it open to a form posted from another site through the browser of anyone on the same network. Without a session, a post is now refused when the browser says it came from elsewhere, through Sec-Fetch-Site and Origin. A request that says nothing, from a script, goes through as before.
  • PasswordAuth answered an unknown username at once and a known one after 600,000 rounds of hashing, so the time a sign in took said which usernames exist. An unknown name now costs the same as a wrong password, and the hashing runs on a thread instead of holding up every other request.
  • Signing in starts a fresh session, so nothing planted in the browser before it, the form token included, is worth anything afterwards. Signing out ends the whole session. A script that takes the token from the login page has to take it from a page drawn after signing in instead.
  • An action that get_actions leaves out for this user can no longer be run by name; asking for it is a 404, as is asking for an action that does not exist at all, which used to be a crash.
  • The CSV export marks a cell that a spreadsheet would run as a formula, one starting with =, +, @ or a tab, as text with a leading apostrophe. Numbers are left alone.
  • The buttons of a record action were broken markup, since the record's key was written into a double quoted attribute in double quotes. They are drawn in single quotes now.
  • A before_save that refused a change to an existing record crashed on an async engine, because the form was drawn again from a record the rollback had expired. The record is loaded again first.
  • A bulk action on a table whose key is two columns acted on the wrong rows: the rows were matched by the first column of the key alone, so relabelling one line of an order relabelled every line of it, and deleting one deleted them all. Rows are matched by the whole key now, and the log names the whole key.
  • A path in the URL that names no field, in the lookup or the file route, is a 404 instead of a crash. The JSON API writes a ChoiceField(multiple=True) from a list. A kept import file is readable by the server's own user alone.

  • Security. A relation picker read the other model's table directly, so it ignored that model's view: it offered records outside scope_query, offered them to users whose permissions denied the view entirely, and searched every text column rather than the ones on show. A picker now reads through the target's own view, and its lookup needs the permission that opens a form. Anyone running 0.1.0a4 or earlier with a scope_query or a denied view should upgrade.

  • A picker's search looks in the target view's search_fields, and where it names none, in the text columns its records are named by. A target with neither cannot be narrowed by typing.

0.1.0a4

The fourth alpha. It closes the gaps the first ports of real panels ran into: actions on one record and on the view, computed fields, JSON columns, markup in a cell, lighter field overrides, a relation picker that holds many records, and settings for the pages a view does not need. Tested on SQLite, Postgres and MySQL.

  • AuthProvider.sign_in_failed(request, username) runs on every wrong password and returns what the login page says, so an attempt can be recorded, alerted on or slowed down, and the message is no longer fixed.
  • Action inputs can start with a value, default=True, hold several options, ChoiceField(multiple=True), and be worked out per request, since the run now goes through get_actions too. default works on any field, so a form for a new record starts from it.
  • deferred_fields leaves heavy columns out of the list query, so a table carrying a large JSON payload no longer loads it on every row. The record page, the form and the API load them as usual, and a column the list shows is never left out.
  • A cell can hold markup. A field that returns Html writes it into the page as it is, so a column can link to a related record, a file or another system. Values put in with format are escaped, and the CSV export and the JSON API send the text without the tags.
  • The CSV export was missing computed columns. It wrote the columns the query loaded rather than the columns the list shows, and a computed column loads nothing of its own.
  • FieldOptions("name", label="Product name") in a view's fields changes one thing about the field adminsite worked out, without naming its type or its target again. An option the field does not take is an error naming the path, not a setting that does nothing.
  • A field that says readonly=True is now treated as readonly by the form, the API and the import, as readonly_fields already was. A primary key is the exception: a form that names one means to set it.
  • A relationship holding many records can be edited on a large table. Above 100 records the picker became a search box that kept one key, which lost every other record the link held. It now keeps all of them, showing each as a chip with a button to take it off.
  • can_export = False switches the CSV export off for a view, and the button now follows Permission.EXPORT instead of always showing. can_detail = False switches off the record page: rows open the form instead, and saving lands on the list.
  • page_sizes offers a rows per page menu above the list. The choice is kept in the URL and the session, stays while paging, sorting, searching and filtering, and only a size the view offers counts.
  • before_save can change what is stored: whatever it leaves in context.values, or writes with context.set(path, value), is what is applied, so a slug or a derived value is stored instead of what was submitted. The caller's own values are left alone.
  • RefusedError("...", field="delay") puts a refusal next to that input instead of above the form, keeping everything else that was typed. The API answers 422 with the field, and 409 for a refusal about no field in particular.
  • JSON columns get a JSONField: readable on one line in the list, laid out in a box on the form, and a malformed document comes back as an error on the field instead of being stored as text.
  • A value that fails to parse comes back into its input as it was written. Before, the field fell back to the stored value and quietly threw away what the person had typed.
  • Actions can run on one record, on="record", appearing on its row and on its page with the record's own permission deciding, or on the whole view, on="view", with nothing ticked. Both keep the dialog, inputs, confirmation, messages and audit that selection actions have.
  • An action can return a response instead of a message, so it can hand back a file or JSON.
  • Computed shows a value the view works out from a record, in the list, on the record page and in the export. needs names what the function reads so it is loaded with the page, and the value is never written, sorted or filtered.
  • Field.text_for(record, value) gives a field the record its value belongs to, so an amount can read with its currency or a status with the column beside it. Everything that shows a value goes through it, and it falls back to display.
  • detail_fields, and get_detail_fields(request, record), say what the record page shows when that differs from the form. The page follows the form unless you name them.
  • A view's icon is drawn in the sidebar. It takes inline SVG markup or the address of a picture.
  • A filter on a path through a relationship shows its options without counts instead of raising.
  • session_https_only and session_max_age on Admin for the session cookie.

0.1.0a3

The third alpha. It adds everything the first projects asked for: inlines, big-table paging, saved views, a command palette, pages and plugins, a dashboard, file fields, CSV and Excel import, a JSON API, Persian and right to left layouts, and an accessibility pass. Tested on SQLite, Postgres and MySQL.

  • Tested on MySQL 8 with aiomysql and pymysql, and CI runs the suite against MySQL. Keyset pagination falls back to page numbers when a list is sorted by an enum column, since MySQL sorts and compares native ENUMs differently and the next page would skip rows.
  • Inlines: edit a record's children in its own form, such as an order's lines, with inlines = (Inline("items"),). Rows can be added, changed and removed, all saved in one transaction with the parent, and the detail page lists them.
  • Keyset pagination for big tables: pagination = Pagination.KEYSET moves by cursor, so deep pages cost the same as the first. Sorts it cannot use fall back to page numbers.
  • CountMode.ESTIMATED shows the table estimate Postgres and MySQL keep, and stops a narrowed count at 10,000.
  • The primary key breaks ties in every sort, so rows no longer repeat across pages when a sorted column has equal values.
  • Changing the search, a filter or the sort goes back to the first page.
  • Accessibility: a skip link, named landmarks and aria-current in the sidebar; captioned tables with aria-sort; row checkboxes named after their record; inputs named by their label and tied to their error or help; problems that stay until closed; the new count announced after the list updates; stronger contrast for dimmed text. A test checks every control has a name.
  • Translations: language="fa" puts the whole admin in Persian with a mirrored right to left layout, and languages=[...] adds a menu, picking the browser's language on the first visit. Your own translations override or add to the shipped catalogs, and adminsite.i18n.gettext translates your own messages.
  • JSON API: api=True serves every view at /-/api for listing, reading, adding, changing, deleting and running actions, through the same permissions, scopes, hooks and audit log as the pages. Scripts sign in with a bearer token checked by AuthProvider.authenticate_token; a browser session has to send the form token in X-CSRF-Token to change anything.
  • Importing from CSV or Excel with a preview: switch it on with can_import = True. Rows with a known key change that record, rows without one add a record, and every row is checked by the form's fields before anything is written. The good rows are saved through the view, so hooks, permissions and the audit log apply. Excel needs adminsite[excel].
  • Two messages in a row are both shown. The second used to be lost, since the session was only saved when a key was set, not when its list grew.
  • File and picture fields: FileField and ImageField store uploads through a FileStorage, LocalStorage built in. Files are served behind the sign in, with anything but pictures and PDFs sent as a download; pictures are checked by their bytes. Replaced files are deleted after the commit, and a failed save throws its new file away.
  • Every endpoint closes the request's form when it is done, so uploads never leave temporary files open.
  • Dashboard: the overview takes cards with dashboard=[...]: Stat with an optional change on the period before, Chart drawn as SVG on the server, RecentRecords through a view's permissions, ModelCounts, or a Widget of your own. A failing card is shown as failed and logged instead of breaking the page.
  • Pages of your own: subclass AdminPage with a template and a context, and it gets the admin's layout, a place in the sidebar and the palette, its own permission check and form handling.
  • Plugins: a Plugin adds views, pages, routes under /-/, template folders, static files, stylesheets and scripts. The same add_* methods work on the admin directly.
  • Command palette: Ctrl+K or Cmd+K jumps to any page, or to a record found by each view's own search, within the user's permissions and scope. global_search = False leaves a view out.
  • Permission.HISTORY controls the History tab and a view's entries on the Activity page. The Activity page shows only views this admin registers and the user may read, filtered in the query so the latest readable entries always show.
  • A refused page is a 403 "Not allowed" page inside the admin instead of a server error, and missing pages get the same layout. The sidebar leaves out views the user may not open.
  • Column picker: people hide and show columns from a Columns menu, offered from list_display and list_columns. The choice is kept in the URL and the session, and the export follows it.
  • Saved views: saved_views=True lets people keep a search, filters, sort and columns under a name, for themselves or shared with everyone. Stored in adminsite_views.db by default, or in your database with SavedViews(engine).
  • Several admins in one app keep separate sessions by default: the session cookie is named after the admin's title unless you set session_cookie.
  • Action and delete buttons are hidden from users that allows() refuses.

0.1.0a2

The second alpha. If you use Postgres, upgrade: 0.1.0a1 could not open records there.

  • Audit log. audit=True records who created, changed or deleted what, with each field's value before and after, in a SQLite file of its own; pass an AuditLog(engine) to keep it in your own database. Records get a History tab and the admin gets an Activity page. Bulk actions write one entry per affected record, grouped by a batch id, the way Laravel Nova does. Entries are written only after the change commits.
  • Actions can ask for values in a dialog before they run, checked like form fields and passed to the method by name. Confirmations and deleting a record use a dialog instead of the browser's confirm box.
  • The UI is rebuilt on daisyUI, with light and dark themes, a drawer sidebar on phones, and one template per form widget.
  • A foreign key column shows as its relationship in the default list and form.
  • Tested on Postgres with asyncpg and psycopg, and CI runs the suite against Postgres.
  • Keys read from URLs and filters are converted to the column type. Postgres refused to compare an integer key with text, which broke the detail, edit and delete pages there.
  • Deleting a record that others still refer to, or saving a value that must be unique, now shows a message instead of an error page.

0.1.0a1

The first alpha. The shape of the API may still change before 0.1.0.

  • Read a model and describe it without any ORM detail, so another ORM can be supported later.
  • Fields that display, edit and convert values, chosen from the column types.
  • One async interface over async and sync SQLAlchemy sessions.
  • List pages with search across dotted paths, filters with counts, sorting and paging, and no N+1 queries.
  • Filters you can write yourself.
  • Create, edit, detail and delete pages, with links picked from a list or searched.
  • Permissions at view, action, field and row level.
  • Hooks that run inside the transaction and can refuse a save.
  • Bulk actions over the chosen rows or over every row matching the filter.
  • CSV export of the filtered list.
  • Signing in, and a CSRF token on every form.

PasswordAuth takes hashed passwords. Use adminsite.auth.hash_password to make one.