adminsite 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. adminsite-0.1.0a1/.github/workflows/ci.yml +40 -0
  2. adminsite-0.1.0a1/.github/workflows/release.yml +40 -0
  3. adminsite-0.1.0a1/.gitignore +23 -0
  4. adminsite-0.1.0a1/.python-version +1 -0
  5. adminsite-0.1.0a1/CHANGELOG.md +20 -0
  6. adminsite-0.1.0a1/LICENSE +21 -0
  7. adminsite-0.1.0a1/PKG-INFO +231 -0
  8. adminsite-0.1.0a1/README.md +199 -0
  9. adminsite-0.1.0a1/examples/shop.py +167 -0
  10. adminsite-0.1.0a1/frontend/input.css +140 -0
  11. adminsite-0.1.0a1/frontend/package.json +16 -0
  12. adminsite-0.1.0a1/frontend/vendor.mjs +21 -0
  13. adminsite-0.1.0a1/pyproject.toml +106 -0
  14. adminsite-0.1.0a1/shop.db +0 -0
  15. adminsite-0.1.0a1/src/adminsite/__init__.py +50 -0
  16. adminsite-0.1.0a1/src/adminsite/actions/__init__.py +4 -0
  17. adminsite-0.1.0a1/src/adminsite/actions/action.py +70 -0
  18. adminsite-0.1.0a1/src/adminsite/actions/selection.py +105 -0
  19. adminsite-0.1.0a1/src/adminsite/admin.py +202 -0
  20. adminsite-0.1.0a1/src/adminsite/auth/__init__.py +4 -0
  21. adminsite-0.1.0a1/src/adminsite/auth/passwords.py +46 -0
  22. adminsite-0.1.0a1/src/adminsite/auth/provider.py +86 -0
  23. adminsite-0.1.0a1/src/adminsite/backends/__init__.py +0 -0
  24. adminsite-0.1.0a1/src/adminsite/backends/sqlalchemy/__init__.py +37 -0
  25. adminsite-0.1.0a1/src/adminsite/backends/sqlalchemy/filters.py +355 -0
  26. adminsite-0.1.0a1/src/adminsite/backends/sqlalchemy/inspector.py +161 -0
  27. adminsite-0.1.0a1/src/adminsite/backends/sqlalchemy/loader.py +54 -0
  28. adminsite-0.1.0a1/src/adminsite/backends/sqlalchemy/repository.py +355 -0
  29. adminsite-0.1.0a1/src/adminsite/backends/sqlalchemy/session.py +243 -0
  30. adminsite-0.1.0a1/src/adminsite/exceptions.py +68 -0
  31. adminsite-0.1.0a1/src/adminsite/fields/__init__.py +39 -0
  32. adminsite-0.1.0a1/src/adminsite/fields/base.py +81 -0
  33. adminsite-0.1.0a1/src/adminsite/fields/choice.py +94 -0
  34. adminsite-0.1.0a1/src/adminsite/fields/registry.py +82 -0
  35. adminsite-0.1.0a1/src/adminsite/fields/relation.py +66 -0
  36. adminsite-0.1.0a1/src/adminsite/fields/scalars.py +95 -0
  37. adminsite-0.1.0a1/src/adminsite/fields/temporal.py +91 -0
  38. adminsite-0.1.0a1/src/adminsite/filters/__init__.py +15 -0
  39. adminsite-0.1.0a1/src/adminsite/filters/base.py +101 -0
  40. adminsite-0.1.0a1/src/adminsite/http/__init__.py +0 -0
  41. adminsite-0.1.0a1/src/adminsite/http/endpoints.py +419 -0
  42. adminsite-0.1.0a1/src/adminsite/http/export.py +60 -0
  43. adminsite-0.1.0a1/src/adminsite/http/forms.py +136 -0
  44. adminsite-0.1.0a1/src/adminsite/http/listing.py +148 -0
  45. adminsite-0.1.0a1/src/adminsite/http/templating.py +80 -0
  46. adminsite-0.1.0a1/src/adminsite/http/urls.py +124 -0
  47. adminsite-0.1.0a1/src/adminsite/protocols.py +15 -0
  48. adminsite-0.1.0a1/src/adminsite/py.typed +0 -0
  49. adminsite-0.1.0a1/src/adminsite/query.py +99 -0
  50. adminsite-0.1.0a1/src/adminsite/schema.py +128 -0
  51. adminsite-0.1.0a1/src/adminsite/security/__init__.py +10 -0
  52. adminsite-0.1.0a1/src/adminsite/security/csrf.py +41 -0
  53. adminsite-0.1.0a1/src/adminsite/security/permissions.py +16 -0
  54. adminsite-0.1.0a1/src/adminsite/static/adminsite.css +2 -0
  55. adminsite-0.1.0a1/src/adminsite/static/alpine.min.js +21 -0
  56. adminsite-0.1.0a1/src/adminsite/static/htmx.min.js +1 -0
  57. adminsite-0.1.0a1/src/adminsite/templates/adminsite/_field.html +84 -0
  58. adminsite-0.1.0a1/src/adminsite/templates/adminsite/_lookup.html +9 -0
  59. adminsite-0.1.0a1/src/adminsite/templates/adminsite/_table.html +129 -0
  60. adminsite-0.1.0a1/src/adminsite/templates/adminsite/_toolbar.html +85 -0
  61. adminsite-0.1.0a1/src/adminsite/templates/adminsite/base.html +92 -0
  62. adminsite-0.1.0a1/src/adminsite/templates/adminsite/detail.html +31 -0
  63. adminsite-0.1.0a1/src/adminsite/templates/adminsite/form.html +40 -0
  64. adminsite-0.1.0a1/src/adminsite/templates/adminsite/index.html +20 -0
  65. adminsite-0.1.0a1/src/adminsite/templates/adminsite/list.html +20 -0
  66. adminsite-0.1.0a1/src/adminsite/templates/adminsite/login.html +35 -0
  67. adminsite-0.1.0a1/src/adminsite/text.py +59 -0
  68. adminsite-0.1.0a1/src/adminsite/views/__init__.py +4 -0
  69. adminsite-0.1.0a1/src/adminsite/views/model_view.py +422 -0
  70. adminsite-0.1.0a1/src/adminsite/views/registry.py +64 -0
  71. adminsite-0.1.0a1/src/adminsite/views/writing.py +40 -0
  72. adminsite-0.1.0a1/tests/__init__.py +0 -0
  73. adminsite-0.1.0a1/tests/conftest.py +90 -0
  74. adminsite-0.1.0a1/tests/factories.py +62 -0
  75. adminsite-0.1.0a1/tests/models.py +85 -0
  76. adminsite-0.1.0a1/tests/support.py +55 -0
  77. adminsite-0.1.0a1/tests/test_actions.py +233 -0
  78. adminsite-0.1.0a1/tests/test_app.py +158 -0
  79. adminsite-0.1.0a1/tests/test_auth.py +279 -0
  80. adminsite-0.1.0a1/tests/test_fields.py +229 -0
  81. adminsite-0.1.0a1/tests/test_filters.py +354 -0
  82. adminsite-0.1.0a1/tests/test_fixtures.py +71 -0
  83. adminsite-0.1.0a1/tests/test_inspector.py +215 -0
  84. adminsite-0.1.0a1/tests/test_list_page.py +195 -0
  85. adminsite-0.1.0a1/tests/test_model_view.py +294 -0
  86. adminsite-0.1.0a1/tests/test_permissions.py +189 -0
  87. adminsite-0.1.0a1/tests/test_record_pages.py +270 -0
  88. adminsite-0.1.0a1/tests/test_repository.py +361 -0
  89. adminsite-0.1.0a1/tests/test_session.py +186 -0
  90. adminsite-0.1.0a1/tests/test_writing.py +396 -0
  91. adminsite-0.1.0a1/uv.lock +1099 -0
@@ -0,0 +1,40 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ check:
14
+ name: Lint and types
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v6
19
+ with:
20
+ enable-cache: true
21
+ - run: uv sync --all-groups
22
+ - run: uv run ruff check .
23
+ - run: uv run ruff format --check .
24
+ - run: uv run mypy src tests
25
+
26
+ test:
27
+ name: Tests on ${{ matrix.python }}
28
+ runs-on: ubuntu-latest
29
+ strategy:
30
+ fail-fast: false
31
+ matrix:
32
+ python: ["3.11", "3.12", "3.13", "3.14"]
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - uses: astral-sh/setup-uv@v6
36
+ with:
37
+ enable-cache: true
38
+ python-version: ${{ matrix.python }}
39
+ - run: uv sync --all-groups
40
+ - run: uv run pytest -q
@@ -0,0 +1,40 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ name: Build the distributions
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v6
14
+ with:
15
+ enable-cache: true
16
+ - run: uv sync --all-groups
17
+ - run: uv run ruff check .
18
+ - run: uv run mypy src tests
19
+ - run: uv run pytest -q
20
+ - run: uv build
21
+ - uses: actions/upload-artifact@v4
22
+ with:
23
+ name: distributions
24
+ path: dist/
25
+
26
+ publish:
27
+ name: Publish to PyPI
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ environment: pypi
31
+ # Trusted publishing: PyPI trusts this workflow, so there is no token
32
+ # to keep anywhere.
33
+ permissions:
34
+ id-token: write
35
+ steps:
36
+ - uses: actions/download-artifact@v4
37
+ with:
38
+ name: distributions
39
+ path: dist/
40
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+
8
+ # Environments
9
+ .venv/
10
+
11
+ # Tools
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ htmlcov/
17
+
18
+ # Frontend build
19
+ node_modules/
20
+ frontend/package-lock.json
21
+
22
+ # Build output
23
+ dist/
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0a1
4
+
5
+ The first alpha. The shape of the API may still change before 0.1.0.
6
+
7
+ - Read a model and describe it without any ORM detail, so another ORM can be supported later.
8
+ - Fields that display, edit and convert values, chosen from the column types.
9
+ - One async interface over async and sync SQLAlchemy sessions.
10
+ - List pages with search across dotted paths, filters with counts, sorting and paging, and no
11
+ N+1 queries.
12
+ - Filters you can write yourself.
13
+ - Create, edit, detail and delete pages, with links picked from a list or searched.
14
+ - Permissions at view, action, field and row level.
15
+ - Hooks that run inside the transaction and can refuse a save.
16
+ - Bulk actions over the chosen rows or over every row matching the filter.
17
+ - CSV export of the filtered list.
18
+ - Signing in, and a CSRF token on every form.
19
+
20
+ `PasswordAuth` takes hashed passwords. Use `adminsite.auth.hash_password` to make one.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nimaxin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.5
2
+ Name: adminsite
3
+ Version: 0.1.0a1
4
+ Summary: Admin panel for SQLAlchemy models, mounted into Starlette, FastAPI or Litestar.
5
+ Project-URL: Homepage, https://github.com/nimaxin/adminsite
6
+ Project-URL: Repository, https://github.com/nimaxin/adminsite
7
+ Project-URL: Issues, https://github.com/nimaxin/adminsite/issues
8
+ Author-email: nimaxin <nimaxin2@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: admin,admin panel,crud,fastapi,htmx,litestar,sqlalchemy,starlette
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Database :: Front-Ends
22
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: itsdangerous>=2.2
26
+ Requires-Dist: jinja2>=3.1.4
27
+ Requires-Dist: pydantic>=2.7
28
+ Requires-Dist: python-multipart>=0.0.9
29
+ Requires-Dist: sqlalchemy>=2.0.30
30
+ Requires-Dist: starlette>=0.40
31
+ Description-Content-Type: text/markdown
32
+
33
+ # adminsite
34
+
35
+ An admin panel for SQLAlchemy models. Mount it into Starlette, FastAPI or Litestar and your
36
+ team gets pages to search, filter, read and change your data.
37
+
38
+ Under development. The first release is not on PyPI yet.
39
+
40
+ ```python
41
+ from adminsite import Admin, ModelView
42
+
43
+
44
+ class OrderView(ModelView, model=Order):
45
+ group = "Sales"
46
+ list_display = ("id", "customer.name", "status", "total", "created_at")
47
+ search_fields = ("id", "customer.name", "customer.email")
48
+ list_filter = ("status", "total", "created_at")
49
+ ordering = ("-created_at",)
50
+
51
+
52
+ admin = Admin(engine, title="Acme", views=[OrderView])
53
+ app.mount("/admin", admin)
54
+ ```
55
+
56
+ That is the whole setup. The columns, the labels, the filters, the form controls and the
57
+ validation come from your models.
58
+
59
+ ## What it does
60
+
61
+ - **Reads the model.** Column types become the right controls, `created_at` becomes "Created at",
62
+ and an enum becomes a select with its values spelled out.
63
+ - **Never lets a page fall into N+1 queries.** Listing `customer.name` loads the customers with
64
+ the page, and a collection costs one more query. The tests count the statements.
65
+ - **Handles large tables.** Counting can be turned off per view, in which case paging costs one
66
+ query and one extra row. Facet counts on filters can be turned off too.
67
+ - **Filters you can write yourself.** The built-in ones cover choices, booleans, number ranges,
68
+ date ranges, links and text. A custom filter is a class that returns a condition.
69
+ - **Bulk actions over everything that matches**, not just the page. The selection is a query, so
70
+ an action over a large table stays one statement.
71
+ - **Permissions at four levels:** the view, the action, the field and the row. `scope_query`
72
+ narrows every read, so a row a user may not see cannot be opened by guessing its key.
73
+ - **Hooks that run inside the transaction** and receive the session, so a business rule can read
74
+ other tables and refuse a save.
75
+ - **Async or sync.** Give it an `AsyncEngine` or a plain `Engine`. Everything above the session
76
+ adapter is written once.
77
+ - **No Node, no CDN.** The CSS and JavaScript are built into the package.
78
+
79
+ ## Installing
80
+
81
+ ```
82
+ pip install adminsite
83
+ ```
84
+
85
+ Add `aiosqlite`, `asyncpg` or whichever driver your database needs.
86
+
87
+ ## Getting started
88
+
89
+ ```python
90
+ from fastapi import FastAPI
91
+ from sqlalchemy.ext.asyncio import create_async_engine
92
+
93
+ from adminsite import Admin, ModelView
94
+ from adminsite.auth import PasswordAuth, hash_password
95
+
96
+ engine = create_async_engine("postgresql+asyncpg://localhost/shop")
97
+ app = FastAPI()
98
+
99
+
100
+ class CustomerView(ModelView, model=Customer):
101
+ display_template = "{name} ({email})"
102
+ list_display = ("name", "email", "region")
103
+ search_fields = ("name", "email")
104
+
105
+
106
+ admin = Admin(
107
+ engine,
108
+ title="Acme",
109
+ views=[CustomerView],
110
+ auth=PasswordAuth({"nima": hash_password("letmein")}),
111
+ secret_key="read this from your settings",
112
+ )
113
+ app.mount("/admin", admin)
114
+ ```
115
+
116
+ Signing in needs a `secret_key`, which signs the session cookie. `PasswordAuth` takes hashed
117
+ passwords, so run `hash_password` once and keep the result in your settings, never the password
118
+ itself. It suits a small internal tool. For anything larger, subclass `AuthProvider` and check
119
+ your own user table.
120
+
121
+ ## Writing a view
122
+
123
+ | Setting | What it does |
124
+ |---|---|
125
+ | `list_display` | The columns on the list. Dotted paths such as `customer.name` work. |
126
+ | `search_fields` | The paths the search box looks in. |
127
+ | `list_filter` | Paths, or filter instances you built yourself. |
128
+ | `ordering` | The starting order, with `-` for descending. |
129
+ | `form_fields`, `readonly_fields`, `exclude` | What the form shows and what it locks. |
130
+ | `page_size`, `count_mode` | How many rows a page holds, and whether to count them all. |
131
+ | `display_template` | How a record is named, for example `"Order #{id}"`. |
132
+ | `fields` | Field instances that replace the ones worked out from the columns. |
133
+
134
+ Anything that depends on who is asking is a method:
135
+
136
+ ```python
137
+ class OrderView(ModelView, model=Order):
138
+ def get_list_display(self, request=None):
139
+ if request.user.is_support:
140
+ return ("id", "status")
141
+ return super().get_list_display(request)
142
+
143
+ def scope_query(self, statement, *, request=None):
144
+ return statement.where(Order.region == request.user.region)
145
+
146
+ async def allows(self, action, *, request=None, record=None):
147
+ if action == Permission.DELETE:
148
+ return request.user.is_manager
149
+ return await super().allows(action, request=request, record=record)
150
+ ```
151
+
152
+ ## Hooks
153
+
154
+ ```python
155
+ class OrderView(ModelView, model=Order):
156
+ async def before_save(self, context: SaveContext) -> None:
157
+ if context.created and not await in_stock(context.session, context.record):
158
+ raise RefusedError("That product is out of stock.")
159
+
160
+ async def after_save(self, context: SaveContext) -> None:
161
+ await context.session.add(AuditEntry(order_id=context.record.id))
162
+ ```
163
+
164
+ Both run inside the transaction that writes the record. Raising `RefusedError` rolls the save
165
+ back and shows the message on the form. Any other exception is treated as a fault.
166
+
167
+ ## Actions
168
+
169
+ ```python
170
+ class OrderView(ModelView, model=Order):
171
+ @action("Mark as shipped", confirm="Mark the chosen orders as shipped?")
172
+ async def ship(self, selection: Selection) -> str:
173
+ changed = await selection.update(status=OrderStatus.SHIPPED)
174
+ return f"{changed} orders marked as shipped."
175
+ ```
176
+
177
+ The selection is either the rows that were ticked or every row the current search and filters
178
+ match. `selection.update` and `selection.delete` are single statements and skip the save hooks;
179
+ `selection.records()` loads the records when the hooks matter.
180
+
181
+ ## Custom filters
182
+
183
+ ```python
184
+ class OverdueFilter(SQLFilter):
185
+ """Orders past their delivery date."""
186
+
187
+ async def options(self, context):
188
+ return [FilterOption("late", "Overdue"), FilterOption("soon", "Due in 2 days")]
189
+
190
+ def condition(self, value, repository):
191
+ if value.first == "late":
192
+ return Order.due_at < func.now()
193
+ return Order.due_at < func.now() + timedelta(days=2)
194
+
195
+
196
+ class OrderView(ModelView, model=Order):
197
+ list_filter = ("status", OverdueFilter("delivery", label="Delivery"))
198
+ ```
199
+
200
+ Override `apply` instead of `condition` when the filter needs to change the statement itself.
201
+
202
+ ## Trying it out
203
+
204
+ ```
205
+ uv run uvicorn examples.shop:app --reload
206
+ ```
207
+
208
+ Then open http://127.0.0.1:8000/admin and sign in as `nima` / `letmein`.
209
+
210
+ ## Developing
211
+
212
+ ```
213
+ uv sync --all-groups
214
+ uv run pytest
215
+ uv run ruff check . && uv run ruff format --check .
216
+ uv run mypy src tests
217
+ ```
218
+
219
+ The stylesheet and the vendored JavaScript are built from `frontend/`, and the results are
220
+ committed, so nobody installing adminsite needs Node:
221
+
222
+ ```
223
+ cd frontend
224
+ npm install
225
+ npm run vendor
226
+ npm run build
227
+ ```
228
+
229
+ ## License
230
+
231
+ MIT.
@@ -0,0 +1,199 @@
1
+ # adminsite
2
+
3
+ An admin panel for SQLAlchemy models. Mount it into Starlette, FastAPI or Litestar and your
4
+ team gets pages to search, filter, read and change your data.
5
+
6
+ Under development. The first release is not on PyPI yet.
7
+
8
+ ```python
9
+ from adminsite import Admin, ModelView
10
+
11
+
12
+ class OrderView(ModelView, model=Order):
13
+ group = "Sales"
14
+ list_display = ("id", "customer.name", "status", "total", "created_at")
15
+ search_fields = ("id", "customer.name", "customer.email")
16
+ list_filter = ("status", "total", "created_at")
17
+ ordering = ("-created_at",)
18
+
19
+
20
+ admin = Admin(engine, title="Acme", views=[OrderView])
21
+ app.mount("/admin", admin)
22
+ ```
23
+
24
+ That is the whole setup. The columns, the labels, the filters, the form controls and the
25
+ validation come from your models.
26
+
27
+ ## What it does
28
+
29
+ - **Reads the model.** Column types become the right controls, `created_at` becomes "Created at",
30
+ and an enum becomes a select with its values spelled out.
31
+ - **Never lets a page fall into N+1 queries.** Listing `customer.name` loads the customers with
32
+ the page, and a collection costs one more query. The tests count the statements.
33
+ - **Handles large tables.** Counting can be turned off per view, in which case paging costs one
34
+ query and one extra row. Facet counts on filters can be turned off too.
35
+ - **Filters you can write yourself.** The built-in ones cover choices, booleans, number ranges,
36
+ date ranges, links and text. A custom filter is a class that returns a condition.
37
+ - **Bulk actions over everything that matches**, not just the page. The selection is a query, so
38
+ an action over a large table stays one statement.
39
+ - **Permissions at four levels:** the view, the action, the field and the row. `scope_query`
40
+ narrows every read, so a row a user may not see cannot be opened by guessing its key.
41
+ - **Hooks that run inside the transaction** and receive the session, so a business rule can read
42
+ other tables and refuse a save.
43
+ - **Async or sync.** Give it an `AsyncEngine` or a plain `Engine`. Everything above the session
44
+ adapter is written once.
45
+ - **No Node, no CDN.** The CSS and JavaScript are built into the package.
46
+
47
+ ## Installing
48
+
49
+ ```
50
+ pip install adminsite
51
+ ```
52
+
53
+ Add `aiosqlite`, `asyncpg` or whichever driver your database needs.
54
+
55
+ ## Getting started
56
+
57
+ ```python
58
+ from fastapi import FastAPI
59
+ from sqlalchemy.ext.asyncio import create_async_engine
60
+
61
+ from adminsite import Admin, ModelView
62
+ from adminsite.auth import PasswordAuth, hash_password
63
+
64
+ engine = create_async_engine("postgresql+asyncpg://localhost/shop")
65
+ app = FastAPI()
66
+
67
+
68
+ class CustomerView(ModelView, model=Customer):
69
+ display_template = "{name} ({email})"
70
+ list_display = ("name", "email", "region")
71
+ search_fields = ("name", "email")
72
+
73
+
74
+ admin = Admin(
75
+ engine,
76
+ title="Acme",
77
+ views=[CustomerView],
78
+ auth=PasswordAuth({"nima": hash_password("letmein")}),
79
+ secret_key="read this from your settings",
80
+ )
81
+ app.mount("/admin", admin)
82
+ ```
83
+
84
+ Signing in needs a `secret_key`, which signs the session cookie. `PasswordAuth` takes hashed
85
+ passwords, so run `hash_password` once and keep the result in your settings, never the password
86
+ itself. It suits a small internal tool. For anything larger, subclass `AuthProvider` and check
87
+ your own user table.
88
+
89
+ ## Writing a view
90
+
91
+ | Setting | What it does |
92
+ |---|---|
93
+ | `list_display` | The columns on the list. Dotted paths such as `customer.name` work. |
94
+ | `search_fields` | The paths the search box looks in. |
95
+ | `list_filter` | Paths, or filter instances you built yourself. |
96
+ | `ordering` | The starting order, with `-` for descending. |
97
+ | `form_fields`, `readonly_fields`, `exclude` | What the form shows and what it locks. |
98
+ | `page_size`, `count_mode` | How many rows a page holds, and whether to count them all. |
99
+ | `display_template` | How a record is named, for example `"Order #{id}"`. |
100
+ | `fields` | Field instances that replace the ones worked out from the columns. |
101
+
102
+ Anything that depends on who is asking is a method:
103
+
104
+ ```python
105
+ class OrderView(ModelView, model=Order):
106
+ def get_list_display(self, request=None):
107
+ if request.user.is_support:
108
+ return ("id", "status")
109
+ return super().get_list_display(request)
110
+
111
+ def scope_query(self, statement, *, request=None):
112
+ return statement.where(Order.region == request.user.region)
113
+
114
+ async def allows(self, action, *, request=None, record=None):
115
+ if action == Permission.DELETE:
116
+ return request.user.is_manager
117
+ return await super().allows(action, request=request, record=record)
118
+ ```
119
+
120
+ ## Hooks
121
+
122
+ ```python
123
+ class OrderView(ModelView, model=Order):
124
+ async def before_save(self, context: SaveContext) -> None:
125
+ if context.created and not await in_stock(context.session, context.record):
126
+ raise RefusedError("That product is out of stock.")
127
+
128
+ async def after_save(self, context: SaveContext) -> None:
129
+ await context.session.add(AuditEntry(order_id=context.record.id))
130
+ ```
131
+
132
+ Both run inside the transaction that writes the record. Raising `RefusedError` rolls the save
133
+ back and shows the message on the form. Any other exception is treated as a fault.
134
+
135
+ ## Actions
136
+
137
+ ```python
138
+ class OrderView(ModelView, model=Order):
139
+ @action("Mark as shipped", confirm="Mark the chosen orders as shipped?")
140
+ async def ship(self, selection: Selection) -> str:
141
+ changed = await selection.update(status=OrderStatus.SHIPPED)
142
+ return f"{changed} orders marked as shipped."
143
+ ```
144
+
145
+ The selection is either the rows that were ticked or every row the current search and filters
146
+ match. `selection.update` and `selection.delete` are single statements and skip the save hooks;
147
+ `selection.records()` loads the records when the hooks matter.
148
+
149
+ ## Custom filters
150
+
151
+ ```python
152
+ class OverdueFilter(SQLFilter):
153
+ """Orders past their delivery date."""
154
+
155
+ async def options(self, context):
156
+ return [FilterOption("late", "Overdue"), FilterOption("soon", "Due in 2 days")]
157
+
158
+ def condition(self, value, repository):
159
+ if value.first == "late":
160
+ return Order.due_at < func.now()
161
+ return Order.due_at < func.now() + timedelta(days=2)
162
+
163
+
164
+ class OrderView(ModelView, model=Order):
165
+ list_filter = ("status", OverdueFilter("delivery", label="Delivery"))
166
+ ```
167
+
168
+ Override `apply` instead of `condition` when the filter needs to change the statement itself.
169
+
170
+ ## Trying it out
171
+
172
+ ```
173
+ uv run uvicorn examples.shop:app --reload
174
+ ```
175
+
176
+ Then open http://127.0.0.1:8000/admin and sign in as `nima` / `letmein`.
177
+
178
+ ## Developing
179
+
180
+ ```
181
+ uv sync --all-groups
182
+ uv run pytest
183
+ uv run ruff check . && uv run ruff format --check .
184
+ uv run mypy src tests
185
+ ```
186
+
187
+ The stylesheet and the vendored JavaScript are built from `frontend/`, and the results are
188
+ committed, so nobody installing adminsite needs Node:
189
+
190
+ ```
191
+ cd frontend
192
+ npm install
193
+ npm run vendor
194
+ npm run build
195
+ ```
196
+
197
+ ## License
198
+
199
+ MIT.