warder 1.0.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.
- warder-1.0.0a1/.gitignore +14 -0
- warder-1.0.0a1/CHANGELOG.md +136 -0
- warder-1.0.0a1/LICENSE +27 -0
- warder-1.0.0a1/PKG-INFO +504 -0
- warder-1.0.0a1/README.md +468 -0
- warder-1.0.0a1/build_ui.py +86 -0
- warder-1.0.0a1/pyproject.toml +145 -0
- warder-1.0.0a1/tests/conftest.py +28 -0
- warder-1.0.0a1/tests/demo_admin.py +28 -0
- warder-1.0.0a1/tests/fakes.py +106 -0
- warder-1.0.0a1/tests/orm.py +144 -0
- warder-1.0.0a1/tests/test_access.py +401 -0
- warder-1.0.0a1/tests/test_actions.py +184 -0
- warder-1.0.0a1/tests/test_auth.py +236 -0
- warder-1.0.0a1/tests/test_base.py +211 -0
- warder-1.0.0a1/tests/test_columns.py +185 -0
- warder-1.0.0a1/tests/test_conditions.py +94 -0
- warder-1.0.0a1/tests/test_console.py +110 -0
- warder-1.0.0a1/tests/test_create_admin.py +273 -0
- warder-1.0.0a1/tests/test_display.py +133 -0
- warder-1.0.0a1/tests/test_errors.py +84 -0
- warder-1.0.0a1/tests/test_fields.py +144 -0
- warder-1.0.0a1/tests/test_filters.py +219 -0
- warder-1.0.0a1/tests/test_layout.py +110 -0
- warder-1.0.0a1/tests/test_naming.py +95 -0
- warder-1.0.0a1/tests/test_readme.py +210 -0
- warder-1.0.0a1/tests/test_resolve.py +722 -0
- warder-1.0.0a1/tests/test_resource.py +167 -0
- warder-1.0.0a1/tests/test_routes.py +498 -0
- warder-1.0.0a1/tests/test_schema.py +201 -0
- warder-1.0.0a1/tests/test_screens.py +251 -0
- warder-1.0.0a1/tests/test_serving.py +1010 -0
- warder-1.0.0a1/tests/test_site.py +314 -0
- warder-1.0.0a1/tests/test_sorting.py +73 -0
- warder-1.0.0a1/tests/test_surface.py +256 -0
- warder-1.0.0a1/tests/test_theme.py +160 -0
- warder-1.0.0a1/ui/package-lock.json +2997 -0
- warder-1.0.0a1/ui/package.json +26 -0
- warder-1.0.0a1/ui/src/components/ActionMenu.tsx +220 -0
- warder-1.0.0a1/ui/src/components/Chart.tsx +310 -0
- warder-1.0.0a1/ui/src/components/DataTable.tsx +249 -0
- warder-1.0.0a1/ui/src/components/DetailView.tsx +223 -0
- warder-1.0.0a1/ui/src/components/FilterBar.tsx +222 -0
- warder-1.0.0a1/ui/src/components/FormRenderer.tsx +108 -0
- warder-1.0.0a1/ui/src/components/Icon.tsx +70 -0
- warder-1.0.0a1/ui/src/components/Shell.tsx +333 -0
- warder-1.0.0a1/ui/src/components/Widgets.tsx +575 -0
- warder-1.0.0a1/ui/src/components/ui.tsx +356 -0
- warder-1.0.0a1/ui/src/index.css +108 -0
- warder-1.0.0a1/ui/src/lib/cn.ts +4 -0
- warder-1.0.0a1/ui/src/lib/condition.ts +50 -0
- warder-1.0.0a1/ui/src/lib/format.tsx +353 -0
- warder-1.0.0a1/ui/src/lib/theme.ts +43 -0
- warder-1.0.0a1/ui/src/lib/url.ts +41 -0
- warder-1.0.0a1/ui/src/main.tsx +47 -0
- warder-1.0.0a1/ui/src/pages/Dashboard.tsx +159 -0
- warder-1.0.0a1/ui/src/pages/Denied.tsx +34 -0
- warder-1.0.0a1/ui/src/pages/Detail.tsx +79 -0
- warder-1.0.0a1/ui/src/pages/Form.tsx +97 -0
- warder-1.0.0a1/ui/src/pages/List.tsx +172 -0
- warder-1.0.0a1/ui/src/pages/Login.tsx +92 -0
- warder-1.0.0a1/ui/src/pages/Page.tsx +110 -0
- warder-1.0.0a1/ui/src/types.ts +223 -0
- warder-1.0.0a1/ui/tsconfig.json +19 -0
- warder-1.0.0a1/ui/vite.config.ts +32 -0
- warder-1.0.0a1/warder/__init__.py +138 -0
- warder-1.0.0a1/warder/_async.py +59 -0
- warder-1.0.0a1/warder/_check.py +73 -0
- warder-1.0.0a1/warder/_time.py +50 -0
- warder-1.0.0a1/warder/access.py +600 -0
- warder-1.0.0a1/warder/actions.py +259 -0
- warder-1.0.0a1/warder/assets.py +138 -0
- warder-1.0.0a1/warder/auth.py +475 -0
- warder-1.0.0a1/warder/backends.py +318 -0
- warder-1.0.0a1/warder/base.py +260 -0
- warder-1.0.0a1/warder/columns.py +476 -0
- warder-1.0.0a1/warder/conditions.py +162 -0
- warder-1.0.0a1/warder/console.py +610 -0
- warder-1.0.0a1/warder/errors.py +116 -0
- warder-1.0.0a1/warder/fields.py +426 -0
- warder-1.0.0a1/warder/filters.py +441 -0
- warder-1.0.0a1/warder/formats.py +290 -0
- warder-1.0.0a1/warder/inertia.py +247 -0
- warder-1.0.0a1/warder/layout.py +308 -0
- warder-1.0.0a1/warder/models.py +108 -0
- warder-1.0.0a1/warder/naming.py +121 -0
- warder-1.0.0a1/warder/pages.py +287 -0
- warder-1.0.0a1/warder/props.py +946 -0
- warder-1.0.0a1/warder/py.typed +0 -0
- warder-1.0.0a1/warder/resolve.py +747 -0
- warder-1.0.0a1/warder/resource.py +259 -0
- warder-1.0.0a1/warder/results.py +148 -0
- warder-1.0.0a1/warder/routes.py +1110 -0
- warder-1.0.0a1/warder/schema.py +324 -0
- warder-1.0.0a1/warder/screens.py +418 -0
- warder-1.0.0a1/warder/site.py +448 -0
- warder-1.0.0a1/warder/sorting.py +119 -0
- warder-1.0.0a1/warder/static/manifest.json +11 -0
- warder-1.0.0a1/warder/static/warder.CiItt5ky.js +133 -0
- warder-1.0.0a1/warder/static/warder.Cp9aoZII.css +1 -0
- warder-1.0.0a1/warder/theme.py +346 -0
- warder-1.0.0a1/warder/widgets.py +315 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Warder are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [1.0.0a1] - 2026-09-13
|
|
10
|
+
|
|
11
|
+
First alpha, released alongside `sillo-framework` 1.0.0a1. Install with
|
|
12
|
+
`pip install --pre warder==1.0.0a1`.
|
|
13
|
+
|
|
14
|
+
Warder is the admin that used to be `sillo.admin` inside the framework. It is
|
|
15
|
+
its own package now, and its declarations are values rather than class
|
|
16
|
+
attributes on a `ModelAdmin` subclass:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
admin = Admin(title="Ops", prefix="/admin", auth=Auth(users=User))
|
|
20
|
+
admin.add(Resource(User, list=List(Column("email", link=True))))
|
|
21
|
+
admin.mount(app)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
An alpha: this is what 1.0 is expected to look like, but the API is not frozen.
|
|
25
|
+
|
|
26
|
+
### Note
|
|
27
|
+
|
|
28
|
+
`Admin(sessions=True)` is the default and installs a session middleware. An
|
|
29
|
+
application that already installs one of its own should pass `sessions=False` —
|
|
30
|
+
two sessions on two cookies means the one that wins is whichever middleware is
|
|
31
|
+
outermost, and the symptom is a login that succeeds followed by a 401 from the
|
|
32
|
+
next request.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **A `release` workflow.** Pushing a `warder-v<version>` tag checks the tag,
|
|
37
|
+
`pyproject.toml` and `warder.__version__` agree, builds the React interface,
|
|
38
|
+
runs the suite against the framework's `main` branch, builds the wheel,
|
|
39
|
+
verifies it carries `py.typed` and the compiled interface but none of the UI
|
|
40
|
+
sources, and publishes (trusted publishing, or `PYPI_TOKEN`).
|
|
41
|
+
- CI now also runs on pushes to `master` (the current default branch), not
|
|
42
|
+
only `main`.
|
|
43
|
+
- **The declaration layer.** Every screen, column, filter, action, permission
|
|
44
|
+
and theme is a frozen value: comparable, printable, generatable in a loop, and
|
|
45
|
+
extendable with `.with_()`, which appends parts and replaces keywords rather
|
|
46
|
+
than mutating.
|
|
47
|
+
- **`Admin`**, the site registry — resources, pages, cards, roles and slots,
|
|
48
|
+
with navigation grouped and ordered, and the permissions a deployment can
|
|
49
|
+
grant derived from what is registered.
|
|
50
|
+
- **`Admin.check()`**, which finds everything wrong that needs no database:
|
|
51
|
+
duplicate column and filter keys, filters that would share a query parameter,
|
|
52
|
+
totals over columns that do not exist, actions with colliding names, form
|
|
53
|
+
fields declared twice, and conditions naming a field that is not on the form.
|
|
54
|
+
Every problem carries the file and line the declaration was written on.
|
|
55
|
+
- **Four permission layers** — `Gate` for entry, `Access` for a model, `Access`
|
|
56
|
+
plus `Scope` for a row, `Access` on a `Column` or `Field` for a value — over
|
|
57
|
+
the existing `sillo.permissions` tables. `Role` compiles to a group.
|
|
58
|
+
- **`Auth`, `Session`, `Login`, `MFA`, `Impersonation`, `Audit`**: session
|
|
59
|
+
lifetimes, login throttling, a second factor demanded by gate rather than of
|
|
60
|
+
everyone, gated and time-boxed impersonation, and field-level audit diffs with
|
|
61
|
+
a redaction list.
|
|
62
|
+
- **`Theme`** — Console, Paper, Grid and Native over one token set, emitted as
|
|
63
|
+
CSS custom properties with light and dark defined so an explicit choice wins
|
|
64
|
+
in both directions.
|
|
65
|
+
- **`When`**, a form condition serialised into props so a conditional field
|
|
66
|
+
appears the instant another field changes, and re-checked on the server before
|
|
67
|
+
a write.
|
|
68
|
+
- 962 tests. The declaration layer opens no database at all; the interface,
|
|
69
|
+
the routes and the CLI are tested end to end against a real one.
|
|
70
|
+
|
|
71
|
+
- **The resolver.** `Resource(Post)` with no screens becomes a working list,
|
|
72
|
+
form and detail page, derived from the model's own columns at mount. Widgets
|
|
73
|
+
and formats are read off the schema — what the database says a column is — and
|
|
74
|
+
never off an annotation.
|
|
75
|
+
- **Model checks**, raised at `Admin.bind()` with the line the declaration was
|
|
76
|
+
written on: misspelled columns with a did-you-mean, a `Column.relation` over a
|
|
77
|
+
plain column, a display that is not on the far side, `select_related` over
|
|
78
|
+
something that is not a relation, a sum over a text column, a reverse relation
|
|
79
|
+
on a form, and a readonly required column with no default — which renders,
|
|
80
|
+
submits, and fails at the database naming a column the user was never shown.
|
|
81
|
+
An inline panel over a child with two foreign keys back asks for `via=` rather
|
|
82
|
+
than picking one.
|
|
83
|
+
- **`Schema`**, which reads a model in the admin's own terms and works before
|
|
84
|
+
`Tortoise.init`: a relation still held as a string is reported as *unresolved*
|
|
85
|
+
rather than missing, because "cannot check" is not "wrong".
|
|
86
|
+
|
|
87
|
+
- **`warder check`** and **`warder permissions`**, which take a
|
|
88
|
+
`module:attribute` target and need no server, no port and no database
|
|
89
|
+
connection. `check` exits non-zero on the first problem, so a misspelled
|
|
90
|
+
column fails in CI rather than in production.
|
|
91
|
+
|
|
92
|
+
- **The interface** — list, form, detail, dashboard, login and a command
|
|
93
|
+
palette, in Inertia, React and Tailwind. Python sends a resolved declaration
|
|
94
|
+
and the renderer is generic over it: Python extracts what this person may see,
|
|
95
|
+
the browser formats it in their locale.
|
|
96
|
+
- **The routes**: nine per resource, plus pages, a relation-picker endpoint,
|
|
97
|
+
login and the asset mount. Scope is applied before any row is read, so a row
|
|
98
|
+
outside your scope is a 404 rather than a 403 — telling you it exists is
|
|
99
|
+
itself a disclosure.
|
|
100
|
+
- **The Inertia protocol**, implemented here rather than depended on, since
|
|
101
|
+
`sillo-inertia` is on the 0.x API. A redirect after a mutation is 303, a stale
|
|
102
|
+
asset version is a 409 with a location, and a partial reload sends only what
|
|
103
|
+
it asked for.
|
|
104
|
+
- **A bundled wheel**: one JavaScript file and one stylesheet under
|
|
105
|
+
`warder/static/`, no CDN, no Node at install time. `build_ui.py` fails the
|
|
106
|
+
build rather than shipping a wheel whose interface did not compile.
|
|
107
|
+
|
|
108
|
+
- **Signing in.** `Admin()` with no `auth=` gets the bundled `AdminUser`, a
|
|
109
|
+
session backend, `Gate.staff()` and session middleware installed on mount.
|
|
110
|
+
Throttling counts per identity *and* per address. The session carries an id
|
|
111
|
+
and two timestamps and never a user, so deactivating somebody takes effect on
|
|
112
|
+
their next click rather than their next sign-in.
|
|
113
|
+
- **`warder create-admin`** and **`warder users`**. `create-admin` writes the
|
|
114
|
+
column the sign-in form asks for, sets only the flags the model has, and
|
|
115
|
+
refuses up front — naming what to do — when the model is unregistered, its
|
|
116
|
+
table is missing, it cannot hash a password, or it needs a column Warder
|
|
117
|
+
cannot know about.
|
|
118
|
+
- **Export**: CSV and JSON of the filtered set, capped, with a guard on cells a
|
|
119
|
+
spreadsheet would run as a formula.
|
|
120
|
+
- **Column visibility** and a **light/dark toggle**, both remembered per browser.
|
|
121
|
+
|
|
122
|
+
### Notes
|
|
123
|
+
|
|
124
|
+
- `Sort`, not `Order`: `Order` is one of the commonest model names there is, and
|
|
125
|
+
an admin module importing both would carry a bug that reads as correct code.
|
|
126
|
+
- `Column.boolean`, `Format.boolean` and `Filter.boolean` rather than `bool`,
|
|
127
|
+
because a method named `bool` shadows the type inside its own class body.
|
|
128
|
+
- `Field.readonly(name)` is the shorthand; the constructor keyword is
|
|
129
|
+
`editable=False`, because a slot and a classmethod cannot share a name and the
|
|
130
|
+
call site is what reads.
|
|
131
|
+
|
|
132
|
+
### Not yet built
|
|
133
|
+
|
|
134
|
+
- Inline editing inside child panels; MFA and impersonation are declarable but
|
|
135
|
+
not yet enforced.
|
|
136
|
+
- `warder permissions sync` and `warder eject`.
|
warder-1.0.0a1/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present, sillo-Labs OSS.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without modification,
|
|
7
|
+
are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
15
|
+
contributors may be used to endorse or promote products derived from
|
|
16
|
+
this software without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
19
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
20
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
22
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
23
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
24
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
25
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
26
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
27
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
warder-1.0.0a1/PKG-INFO
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: warder
|
|
3
|
+
Version: 1.0.0a1
|
|
4
|
+
Summary: A declarative admin for Sillo. Declarations are values, not class attributes, and a type annotation never selects behaviour.
|
|
5
|
+
Project-URL: Homepage, https://sillo.build
|
|
6
|
+
Project-URL: Documentation, https://docs.sillo.build/packages/warder/
|
|
7
|
+
Project-URL: Source, https://github.com/sillohq/warder
|
|
8
|
+
Project-URL: Changelog, https://github.com/sillohq/warder/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Chidebele Dunamis <techwithdunamix@gmail.com>
|
|
10
|
+
License-Expression: BSD-3-Clause
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: admin,asgi,crud,declarative,inertia,orm,sillo
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: sillo-framework>=1.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
33
|
+
Provides-Extra: ui
|
|
34
|
+
Requires-Dist: sillo-inertia>=0.0.1a4; extra == 'ui'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# Warder
|
|
38
|
+
|
|
39
|
+
A declarative admin for [Sillo](https://sillo.build). A warder keeps the keys;
|
|
40
|
+
this one keeps your models — what is listed, what is editable, who may see it,
|
|
41
|
+
and which rows are theirs.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install warder
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from warder import (
|
|
49
|
+
Access,
|
|
50
|
+
Action,
|
|
51
|
+
Admin,
|
|
52
|
+
Column,
|
|
53
|
+
Field,
|
|
54
|
+
Filter,
|
|
55
|
+
Form,
|
|
56
|
+
List,
|
|
57
|
+
Resource,
|
|
58
|
+
Section,
|
|
59
|
+
Sort,
|
|
60
|
+
notice,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
admin = Admin(title="Acme Ops", prefix="/admin")
|
|
64
|
+
|
|
65
|
+
admin.add(
|
|
66
|
+
Resource(
|
|
67
|
+
Post,
|
|
68
|
+
group="Content",
|
|
69
|
+
icon="file-text",
|
|
70
|
+
list=List(
|
|
71
|
+
Column("title", link=True),
|
|
72
|
+
Column.relation("author", display="email"),
|
|
73
|
+
Column.badge("status", colors={"live": "green", "draft": "zinc"}),
|
|
74
|
+
Column.date("published_at", label="Published", style="relative"),
|
|
75
|
+
Column.compute(
|
|
76
|
+
"Words", lambda row: len(row.body.split()), sort="word_count"
|
|
77
|
+
),
|
|
78
|
+
filters=[
|
|
79
|
+
Filter.search("title", "body"),
|
|
80
|
+
Filter.choice("status", ["draft", "live"]),
|
|
81
|
+
Filter.date_range("published_at", presets=["7d", "30d", "quarter"]),
|
|
82
|
+
],
|
|
83
|
+
actions=[Action("Publish", publish, confirm="Publish {count} posts?")],
|
|
84
|
+
sort=Sort.desc("published_at"),
|
|
85
|
+
),
|
|
86
|
+
form=Form(
|
|
87
|
+
Section("Content", Field("title"), Field.markdown("body")),
|
|
88
|
+
Section("Publishing", Field("status"), Field("published_at")),
|
|
89
|
+
Section("Audit", Field.readonly("created_at"), collapsed=True),
|
|
90
|
+
),
|
|
91
|
+
access=Access(
|
|
92
|
+
view=True,
|
|
93
|
+
add="post.add",
|
|
94
|
+
change=lambda ctx, row: row.author_id == ctx.user.id,
|
|
95
|
+
delete=False,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
admin.mount(app)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Why this exists
|
|
104
|
+
|
|
105
|
+
Three ideas hold the whole package up, and each one is a decision you can feel
|
|
106
|
+
by the second screen you write.
|
|
107
|
+
|
|
108
|
+
### A type annotation describes a type. It never selects behaviour.
|
|
109
|
+
|
|
110
|
+
Nothing here reads `__annotations__`, and nothing changes because a parameter is
|
|
111
|
+
spelled one way rather than another. Where something must be injected it arrives
|
|
112
|
+
as a value — a default, a keyword — because a value is visible and an annotation
|
|
113
|
+
is not. The types on this package's own declarations exist so your editor can
|
|
114
|
+
complete `column.sort_field`, and for nothing else.
|
|
115
|
+
|
|
116
|
+
The one place arity would ordinarily be sniffed is a rule callable, so it isn't:
|
|
117
|
+
a rule is **always** called as `(ctx, row)`, with `row` set to `None` when the
|
|
118
|
+
question is about the model rather than one row.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
Access(change=lambda ctx, row: row.team_id == ctx.user.team_id)
|
|
122
|
+
Gate.custom(lambda ctx: ctx.user.email.endswith("@acme.com")) # gates see no row
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### A declaration is a value
|
|
126
|
+
|
|
127
|
+
Nameable, storable, comparable, generatable in a loop, extendable with `.with_()`.
|
|
128
|
+
No metaclass, no class attributes with meanings you cannot derive, no
|
|
129
|
+
registration by import side effect.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
def reference_data(model, *fields):
|
|
133
|
+
return Resource(
|
|
134
|
+
model,
|
|
135
|
+
group="Reference",
|
|
136
|
+
list=List(*[Column(f) for f in fields], sort=Sort.asc(fields[0])),
|
|
137
|
+
form=Form(Section("", *[Field(f) for f in fields])),
|
|
138
|
+
access=Access.by_permission("reference", delete=False),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
for model in (Tag, Category, Region, Currency):
|
|
143
|
+
admin.add(reference_data(model, "name", "slug"))
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Extending appends parts and replaces keywords, returning a new declaration — so
|
|
147
|
+
a shared base cannot be edited by its fortieth user:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
BASE = List(Column("id"), Column("name"), per_page=50)
|
|
151
|
+
|
|
152
|
+
admin.add(Resource(Tag, list=BASE.with_(Column("slug"))))
|
|
153
|
+
admin.add(Resource(Team, list=BASE.with_(Column.relation("owner"))))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Because a `List` is just a description of a table, it renders in your own route,
|
|
157
|
+
over your own queryset, inside your own layout:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
ORDERS = List(Column("id"), Column.money("total"), Column.badge("status"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.get("/team/orders")
|
|
164
|
+
async def team_orders(ctx: HttpContext):
|
|
165
|
+
return await admin.render(ctx, ORDERS, Order.filter(team_id=ctx.user.team_id))
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Mistakes fail at mount, not at request
|
|
169
|
+
|
|
170
|
+
Every reference is resolved once, at start-up, against the model — and the error
|
|
171
|
+
carries the file and line the declaration was written on, because "column 'titel'
|
|
172
|
+
is not a field of Post" is half an error message without it.
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
DeclarationError: Resource(Post).list column 'titel' is not a field of Post.
|
|
176
|
+
Did you mean 'title'?
|
|
177
|
+
Declared at app/admin.py:24
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Values are checked even earlier, from the constructor, where the mistake was
|
|
181
|
+
typed:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
Column("total", align="middle")
|
|
185
|
+
# ValueError: align='middle' is not valid. Use one of: 'left', 'center', 'right'.
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## The N+1 is derived away
|
|
189
|
+
|
|
190
|
+
Declaring a relation column is what removes it. `List.joins` collects the
|
|
191
|
+
relations its columns and filters traverse, so the join list cannot fall behind
|
|
192
|
+
the column list — it *is* the column list:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
>>> List(Column("author__email"), Column.relation("team"), Column("title")).joins
|
|
196
|
+
('author', 'team')
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
A `select_related=` attribute maintained beside the columns is a list that falls
|
|
200
|
+
out of step silently, and costs fifty queries a page when it does.
|
|
201
|
+
|
|
202
|
+
## Actions get a queryset
|
|
203
|
+
|
|
204
|
+
Not a list of ids. An action over forty thousand selected rows is one statement:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
async def publish(ctx, rows):
|
|
208
|
+
count = await rows.filter(status="draft").update(status="live")
|
|
209
|
+
return notice(f"Published {count} posts")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
How the handler is called is decided by the declaration, visibly, and never by
|
|
213
|
+
inspecting its signature: with no `fields=` it is `(ctx, rows)`; with `fields=`
|
|
214
|
+
it is `(ctx, rows, values)`, where *values* is the little form the confirmation
|
|
215
|
+
dialog collected.
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
Action("Assign", assign, fields=[Field.relation("assignee")])
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Outcomes are free builders, the way `json()` and `text()` are elsewhere in
|
|
222
|
+
Sillo — `notice`, `warning`, `problem`, `go`, `download`, `modal`, `refresh`.
|
|
223
|
+
Returning `None` means "it worked, reload".
|
|
224
|
+
|
|
225
|
+
## Signing in
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
warder create-admin app.admin:admin # prompts for email and password
|
|
229
|
+
warder users app.admin:admin # who can sign in, and when they last did
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`Admin()` with no `auth=` already has a working sign-in: the bundled
|
|
233
|
+
`AdminUser`, a session backend, `Gate.staff()`, and session middleware installed
|
|
234
|
+
on mount if the application has none — a sign-in page without a session is a
|
|
235
|
+
form that forgets you.
|
|
236
|
+
|
|
237
|
+
`create-admin` writes wherever the admin authenticates from. It writes the column
|
|
238
|
+
the *sign-in form* asks for, sets only the flags the model actually has, reads
|
|
239
|
+
the password twice without echo, and hashes through `sillo.hashing`. It prompts
|
|
240
|
+
only at a terminal, so a script gets an error naming the flag rather than a hang.
|
|
241
|
+
|
|
242
|
+
The bundled models are **not registered by importing Warder** — model discovery
|
|
243
|
+
scans a module's namespace, so that would put `warder_users` in the database of
|
|
244
|
+
every project that installs the package. Name it to opt in:
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
setup_record(app, config, model_modules=["myapp.models", "warder.models"])
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Permissions: four questions, four layers
|
|
251
|
+
|
|
252
|
+
They really are different questions, and one answer does not cover the others.
|
|
253
|
+
|
|
254
|
+
| Question | Answered by |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| May you get in at all? | `Gate` |
|
|
257
|
+
| May you do this to this **model**? | `Access` |
|
|
258
|
+
| May you do it to **this row**? | `Access` callable, and `Scope` |
|
|
259
|
+
| May you see **this field**? | `Access` on a `Column` or `Field` |
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
admin = Admin(
|
|
263
|
+
title="Acme Ops",
|
|
264
|
+
auth=Auth(
|
|
265
|
+
users=User, # your model; omit for the bundled one
|
|
266
|
+
gate=Gate.staff(), # who may enter at all
|
|
267
|
+
session=Session(idle="30m", absolute="12h", concurrent=1),
|
|
268
|
+
login=Login(throttle="5/15m", remember=True),
|
|
269
|
+
mfa=MFA.totp(required=Gate.role("owner")),
|
|
270
|
+
impersonation=Impersonation(gate=Gate.permission("users.impersonate")),
|
|
271
|
+
audit=Audit(retain="1y", redact=["password", "token", "secret"]),
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
admin.roles(
|
|
276
|
+
Role("support", grants=["order.view", "customer.view"]),
|
|
277
|
+
Role("editor", grants=Role.crud(Post, Tag), inherits=["support"]),
|
|
278
|
+
Role("owner", grants="*"),
|
|
279
|
+
)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Nothing here is a second authorisation system: it compiles onto
|
|
283
|
+
`sillo.permissions`, which already ships `Permission`, `Group`, `UserPermission`
|
|
284
|
+
and `PermissionMixin`. Registering a `Resource` **declares** four permissions —
|
|
285
|
+
`post.view`, `post.add`, `post.change`, `post.delete` — and what a deployment can
|
|
286
|
+
grant follows what is registered rather than being typed twice.
|
|
287
|
+
|
|
288
|
+
`Access` and `Scope` are both needed and neither substitutes for the other:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
Resource(
|
|
292
|
+
Order,
|
|
293
|
+
access=Access(change=lambda ctx, row: row.team_id == ctx.user.team_id),
|
|
294
|
+
scope=Scope.tenant("team_id"),
|
|
295
|
+
)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Inside a rule of your own, read the account with `current_user(ctx)` rather than
|
|
299
|
+
`ctx.user`. The context *raises* when no authentication middleware is installed,
|
|
300
|
+
and `getattr(ctx, "user", None)` does not help — the default only catches
|
|
301
|
+
`AttributeError`, and what comes out is a `ValueError`:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
from warder import Scope, current_user
|
|
305
|
+
|
|
306
|
+
async def my_students(ctx, rows):
|
|
307
|
+
staff = await Staff.filter(user_id=getattr(current_user(ctx), "id", None)).first()
|
|
308
|
+
return rows.filter(classroom__form_teacher_id=staff.id) if staff else rows.none()
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`Access` decides whether a button is shown and whether a write is allowed;
|
|
312
|
+
`Scope` decides what is in the queryset at all. Access without scope leaks the
|
|
313
|
+
existence of rows through pagination counts and search results; scope without
|
|
314
|
+
access leaves a writable object reachable by its id.
|
|
315
|
+
|
|
316
|
+
A field you may not view is **absent from the props**, not hidden with CSS — so
|
|
317
|
+
it never reaches the browser:
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
Field("salary", access=Access(view="hr.salary.view", change="hr.salary.change"))
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`Gate.staff()` is the default, and it matters more than it looks: when the admin
|
|
324
|
+
shares the application's user model — the ordinary arrangement — every registered
|
|
325
|
+
account holds a session, and admitting anyone with one hands over the database.
|
|
326
|
+
|
|
327
|
+
## Style
|
|
328
|
+
|
|
329
|
+
Four directions, one token set, all four light and dark. This is a choice about
|
|
330
|
+
defaults, not about architecture, so switching is a keyword.
|
|
331
|
+
|
|
332
|
+
| | |
|
|
333
|
+
| --- | --- |
|
|
334
|
+
| **Console** *(default)* | Dense, quiet, keyboard-first. 36px rows, hairline borders, tabular numerals, monospace ids. The one that does not fight a dense table |
|
|
335
|
+
| **Paper** | Light, generous, editorial. 48px rows, soft shadows. Shows about half as much per screen, and reads beautifully |
|
|
336
|
+
| **Grid** | Spreadsheet-first. 28px rows, ruled cells, no card chrome. For reconciliation, imports, moderation queues |
|
|
337
|
+
| **Native** | No opinion. Emits structure and no colour, so your design system's tokens win by not being overridden |
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
Admin(theme=Theme.console(accent="#4f46e5", density="compact"))
|
|
341
|
+
Admin(theme=Theme.native())
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Everything writes CSS custom properties into the shell. No rebuild, no Node —
|
|
345
|
+
which is what keeps theming a keyword rather than an ejection.
|
|
346
|
+
|
|
347
|
+
## The interface
|
|
348
|
+
|
|
349
|
+
Inertia, React and Tailwind, and it is in the wheel.
|
|
350
|
+
|
|
351
|
+
```
|
|
352
|
+
list sortable columns, URL-backed filters, selection, bulk actions, paging,
|
|
353
|
+
column visibility, CSV and JSON export of the filtered set
|
|
354
|
+
form a control per widget kind, conditional fields, per-field errors,
|
|
355
|
+
a searching relation picker, Markdown with preview
|
|
356
|
+
detail panels: fields, and child tables drawn with the child resource's
|
|
357
|
+
own columns
|
|
358
|
+
dashboard number, chart and table cards
|
|
359
|
+
shell grouped navigation, flash messages, a light/dark toggle, `/` to
|
|
360
|
+
search and ⌘K to go anywhere
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**Python sends a resolved declaration; React is a generic renderer for it.** The
|
|
364
|
+
front end has never heard of a `Post` — it knows what a badge column is and what
|
|
365
|
+
a relation picker is. So adding `Column.badge("status", colors=…)` changes a
|
|
366
|
+
prop, not a template, and a new resource never needs the interface rebuilt.
|
|
367
|
+
|
|
368
|
+
The division of labour is deliberate. **Python extracts**: which columns exist
|
|
369
|
+
for *this* person, which rows they may see, what each cell holds — all of it
|
|
370
|
+
authorisation-dependent and impossible to do safely in a browser. **React
|
|
371
|
+
formats**: money in the viewer's locale, a timestamp as "3 days ago", a status as
|
|
372
|
+
a coloured pill — all of it locale- and viewport-dependent, and wasteful on a
|
|
373
|
+
server that knows neither.
|
|
374
|
+
|
|
375
|
+
**`pip install warder` does not require Node.** One JavaScript file and one
|
|
376
|
+
stylesheet ship under `warder/static/`; the React sources live in `ui/` and are
|
|
377
|
+
excluded from the wheel. One file because the admin is served under a prefix
|
|
378
|
+
*you* choose, and code-splitting would have to resolve chunk URLs against a base
|
|
379
|
+
it cannot know until runtime. Nothing is fetched from a CDN, so the admin works
|
|
380
|
+
on an air-gapped network and under a Content-Security-Policy that forbids
|
|
381
|
+
third-party script — which are the normal conditions for the people who most
|
|
382
|
+
want an admin panel.
|
|
383
|
+
|
|
384
|
+
Inertia is implemented in this package rather than depended on: `sillo-inertia`
|
|
385
|
+
is written against the 0.x `Request`/`Response` API and this is written against
|
|
386
|
+
the context API, and blocking the whole interface on another repository's port
|
|
387
|
+
was the wrong trade against two hundred lines of a published protocol.
|
|
388
|
+
|
|
389
|
+
Customising has three rungs, in increasing order of commitment: **theme tokens**
|
|
390
|
+
(no rebuild — Python writes them into the document as custom properties),
|
|
391
|
+
**slots** — `admin.slot("list.toolbar", "acme/ExportButton")`, mounted from your
|
|
392
|
+
own build — and **`warder eject`**, which copies `ui/` into your project and
|
|
393
|
+
hands you the upgrades.
|
|
394
|
+
|
|
395
|
+
## `Resource(Post)` is already a screen
|
|
396
|
+
|
|
397
|
+
Everything is optional but the model. With no `list=`, `form=` or `detail=`, all
|
|
398
|
+
three are built at mount from the model's own columns — identity first, then
|
|
399
|
+
state, then time; a search box over the text columns, a chip per state, a date
|
|
400
|
+
range; every writable column on the form with the timestamps collapsed into an
|
|
401
|
+
Audit group; and a window onto each child table.
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
admin.add(Resource(Post)) # a working list, form and detail page
|
|
405
|
+
admin.add(Resource(Post, sort="-published_at", search=["title", "body"]))
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
The inference reads the **schema** — what the database says a column is — and
|
|
409
|
+
never an annotation. A `TextField` gets a textarea because the column is long
|
|
410
|
+
text. Naming a widget is for when the default is wrong about the *meaning*
|
|
411
|
+
rather than the type: `body` and `internal_note` are both long text and only one
|
|
412
|
+
of them wants Markdown.
|
|
413
|
+
|
|
414
|
+
Every derived part is replaced by naming it, and nothing fights a declaration
|
|
415
|
+
that exists.
|
|
416
|
+
|
|
417
|
+
## Checking without starting the application
|
|
418
|
+
|
|
419
|
+
```console
|
|
420
|
+
$ warder check app.admin:admin
|
|
421
|
+
Acme Ops: 12 resources, 2 pages, 51 permissions. Every reference resolves.
|
|
422
|
+
|
|
423
|
+
$ warder permissions app.admin:admin
|
|
424
|
+
post.add
|
|
425
|
+
post.change
|
|
426
|
+
...
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
`warder check` resolves every declaration against its models exactly as
|
|
430
|
+
`mount()` does, and exits non-zero on the first problem — so a misspelled column
|
|
431
|
+
fails in CI rather than in production. It needs no server, no port and no
|
|
432
|
+
database connection, only the models importable.
|
|
433
|
+
|
|
434
|
+
`warder permissions` prints what the site declares, which is how you seed a
|
|
435
|
+
fixtures file or write a role against what actually exists.
|
|
436
|
+
|
|
437
|
+
## Status
|
|
438
|
+
|
|
439
|
+
Alpha, and honest about which parts exist.
|
|
440
|
+
|
|
441
|
+
| | |
|
|
442
|
+
| --- | --- |
|
|
443
|
+
| ✅ | The declaration layer — every value in the table below, frozen, comparable, extendable |
|
|
444
|
+
| ✅ | The site registry, navigation, declared permissions, and the checks that need no ORM |
|
|
445
|
+
| ✅ | The resolver: binding to models, deriving screens, checking every reference |
|
|
446
|
+
| 🚧 | The routes, the Inertia interface, and the bundled assets |
|
|
447
|
+
| ✅ | The routes, the Inertia interface, and the bundled assets |
|
|
448
|
+
| ✅ | Session sign-in, throttling, session lifetimes, the bundled user model |
|
|
449
|
+
| ✅ | `warder check`, `create-admin`, `users`, `permissions`, `routes` |
|
|
450
|
+
| 🚧 | Inline editing in child panels; MFA and impersonation are declarable but not yet enforced |
|
|
451
|
+
| 🚧 | The activity log is written to but has no screen yet |
|
|
452
|
+
| 🚧 | `warder permissions sync`, `warder eject` |
|
|
453
|
+
|
|
454
|
+
`admin.mount(app)` works end to end. What is not built is listed above rather
|
|
455
|
+
than implied by silence.
|
|
456
|
+
|
|
457
|
+
## The vocabulary
|
|
458
|
+
|
|
459
|
+
| | |
|
|
460
|
+
| --- | --- |
|
|
461
|
+
| `Admin` | The site. Resources, pages, auth, theme; `.mount(app)` |
|
|
462
|
+
| `Resource` | One model's surface: list, detail, form, access, scope |
|
|
463
|
+
| `List` `Form` `Detail` | The three screens |
|
|
464
|
+
| `Column` `Field` `Filter` | A list column, a form input, a list filter |
|
|
465
|
+
| `Action` | Something a person can do to rows |
|
|
466
|
+
| `Section` `Panel` | Grouping on a form, blocks on a detail page |
|
|
467
|
+
| `Access` `Gate` `Scope` `Role` | Who may do what, and to which rows |
|
|
468
|
+
| `Sort` `Format` `Widget` | Ordering, how a value is drawn, how it is edited |
|
|
469
|
+
| `When` | A form condition the browser and the server both evaluate |
|
|
470
|
+
| `Page` `Card` `Dashboard` | Screens that are not a model |
|
|
471
|
+
| `Theme` | Four directions over one token set |
|
|
472
|
+
| `Auth` `Session` `Login` `MFA` `Impersonation` `Audit` | Everything about who may be here |
|
|
473
|
+
|
|
474
|
+
`Sort`, not `Order`: `Order` is one of the most common model names there is, and
|
|
475
|
+
an admin module importing both would have a bug in it that reads as correct code.
|
|
476
|
+
|
|
477
|
+
## Requirements
|
|
478
|
+
|
|
479
|
+
Python 3.10 to 3.14, and **Sillo v1**. Nothing else at runtime, and no Node.
|
|
480
|
+
|
|
481
|
+
Warder is written against the context API — `HttpContext`, `ctx`-first handlers,
|
|
482
|
+
and the free builders in `sillo.responses`. That is the framework's `main`
|
|
483
|
+
branch and it is not on PyPI yet, so until v1 ships:
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
pip install "sillo-framework @ git+https://github.com/sillohq/core@main"
|
|
487
|
+
pip install warder --no-deps
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
The dependency is pinned to `>=1.0` rather than loosened to match what is
|
|
491
|
+
published, because the released 0.x has no `sillo.responses` at all — a wheel
|
|
492
|
+
that installed against it would fail at the first request instead of at install
|
|
493
|
+
time.
|
|
494
|
+
|
|
495
|
+
Working on Warder itself needs Node, but only to rebuild the interface:
|
|
496
|
+
|
|
497
|
+
```bash
|
|
498
|
+
cd ui && npm install && npm run build # → warder/static/
|
|
499
|
+
warder check app.admin:admin # no server, no port, no database
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
## Licence
|
|
503
|
+
|
|
504
|
+
BSD-3-Clause.
|