openadmin 0.3.1__tar.gz → 0.7.12__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.
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/format/action.yml +3 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/lint/action.yml +3 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/workflows/publish.yml +3 -0
- openadmin-0.7.12/.gitignore +15 -0
- openadmin-0.7.12/Makefile +51 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/PKG-INFO +48 -21
- {openadmin-0.3.1 → openadmin-0.7.12}/README.md +46 -20
- {openadmin-0.3.1 → openadmin-0.7.12}/REUSE.toml +1 -0
- openadmin-0.7.12/examples/admin/__init__.py +0 -0
- openadmin-0.7.12/examples/admin/analytics.py +96 -0
- openadmin-0.7.12/examples/admin/authors.py +138 -0
- openadmin-0.7.12/examples/admin/books.py +130 -0
- openadmin-0.7.12/examples/admin/control_panel.py +213 -0
- openadmin-0.7.12/examples/admin/docs.py +39 -0
- openadmin-0.7.12/examples/admin/genres.py +114 -0
- openadmin-0.7.12/examples/admin/health.py +56 -0
- openadmin-0.7.12/examples/admin/overview.py +108 -0
- openadmin-0.7.12/examples/admin/publishers.py +99 -0
- openadmin-0.7.12/examples/admin/reports.py +112 -0
- openadmin-0.7.12/examples/admin/tags.py +100 -0
- openadmin-0.7.12/examples/admin/weekdays.py +131 -0
- openadmin-0.7.12/examples/lib/__init__.py +0 -0
- openadmin-0.7.12/examples/lib/database.py +55 -0
- openadmin-0.7.12/examples/lib/lifespan.py +15 -0
- openadmin-0.7.12/examples/lib/models.py +91 -0
- openadmin-0.7.12/examples/lib/seed.py +118 -0
- openadmin-0.7.12/examples/main.py +61 -0
- openadmin-0.7.12/openadmin/__client__/assets/index-Cmhau8Lc.js +64 -0
- openadmin-0.7.12/openadmin/__client__/assets/index-DfxwI1Du.css +1 -0
- openadmin-0.7.12/openadmin/__client__/favicon.svg +1 -0
- openadmin-0.7.12/openadmin/__client__/icons.svg +24 -0
- openadmin-0.7.12/openadmin/__client__/index.html +14 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/openadmin/fastapi/__init__.py +2 -4
- openadmin-0.7.12/openadmin/fastapi/admin_page.py +574 -0
- openadmin-0.7.12/openadmin/fastapi/admin_panel.py +77 -0
- openadmin-0.7.12/openadmin/fastapi/counter.py +19 -0
- openadmin-0.7.12/openadmin/fastapi/deps.py +26 -0
- openadmin-0.7.12/openadmin/fastapi/exc_handler.py +29 -0
- openadmin-0.7.12/openadmin/fastapi/field_config.py +15 -0
- openadmin-0.7.12/openadmin/fastapi/req.py +10 -0
- openadmin-0.7.12/openadmin/fastapi/utils.py +102 -0
- openadmin-0.7.12/openadmin/spec/__init__.py +81 -0
- openadmin-0.7.12/openadmin/spec/action.py +35 -0
- openadmin-0.7.12/openadmin/spec/area_chart.py +22 -0
- openadmin-0.7.12/openadmin/spec/bar_chart.py +51 -0
- openadmin-0.7.12/openadmin/spec/colors.py +59 -0
- openadmin-0.7.12/openadmin/spec/component.py +26 -0
- openadmin-0.7.12/openadmin/spec/error.py +9 -0
- openadmin-0.7.12/openadmin/spec/form.py +43 -0
- openadmin-0.7.12/openadmin/spec/icons.py +2003 -0
- openadmin-0.7.12/openadmin/spec/json_schema.py +49 -0
- openadmin-0.7.12/openadmin/spec/line_chart.py +22 -0
- openadmin-0.7.12/openadmin/spec/markdown.py +35 -0
- openadmin-0.7.12/openadmin/spec/page.py +17 -0
- openadmin-0.7.12/openadmin/spec/pie_chart.py +52 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/openadmin/spec/section.py +6 -4
- {openadmin-0.3.1 → openadmin-0.7.12}/openadmin/spec/spec.py +5 -5
- openadmin-0.7.12/openadmin/spec/stat.py +35 -0
- openadmin-0.7.12/openadmin/spec/table.py +58 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/pyproject.toml +20 -3
- {openadmin-0.3.1 → openadmin-0.7.12}/uv.lock +264 -2
- openadmin-0.3.1/.gitignore +0 -12
- openadmin-0.3.1/Makefile +0 -45
- openadmin-0.3.1/examples/health.py +0 -17
- openadmin-0.3.1/examples/main.py +0 -31
- openadmin-0.3.1/examples/users.py +0 -46
- openadmin-0.3.1/openadmin/fastapi/admin_page.py +0 -526
- openadmin-0.3.1/openadmin/fastapi/admin_panel.py +0 -89
- openadmin-0.3.1/openadmin/fastapi/types/__init__.py +0 -36
- openadmin-0.3.1/openadmin/fastapi/types/action.py +0 -18
- openadmin-0.3.1/openadmin/fastapi/types/area_chart.py +0 -17
- openadmin-0.3.1/openadmin/fastapi/types/bar_chart.py +0 -17
- openadmin-0.3.1/openadmin/fastapi/types/form.py +0 -18
- openadmin-0.3.1/openadmin/fastapi/types/line_chart.py +0 -17
- openadmin-0.3.1/openadmin/fastapi/types/markdown.py +0 -17
- openadmin-0.3.1/openadmin/fastapi/types/page_protocol.py +0 -12
- openadmin-0.3.1/openadmin/fastapi/types/pie_chart.py +0 -17
- openadmin-0.3.1/openadmin/fastapi/types/section.py +0 -15
- openadmin-0.3.1/openadmin/fastapi/types/stat.py +0 -17
- openadmin-0.3.1/openadmin/fastapi/types/table.py +0 -17
- openadmin-0.3.1/openadmin/fastapi/utils.py +0 -167
- openadmin-0.3.1/openadmin/spec/__init__.py +0 -41
- openadmin-0.3.1/openadmin/spec/components/__init__.py +0 -35
- openadmin-0.3.1/openadmin/spec/components/action.py +0 -22
- openadmin-0.3.1/openadmin/spec/components/area_chart.py +0 -19
- openadmin-0.3.1/openadmin/spec/components/bar_chart.py +0 -19
- openadmin-0.3.1/openadmin/spec/components/form.py +0 -22
- openadmin-0.3.1/openadmin/spec/components/line_chart.py +0 -19
- openadmin-0.3.1/openadmin/spec/components/markdown.py +0 -19
- openadmin-0.3.1/openadmin/spec/components/pie_chart.py +0 -19
- openadmin-0.3.1/openadmin/spec/components/property.py +0 -17
- openadmin-0.3.1/openadmin/spec/components/property_type.py +0 -18
- openadmin-0.3.1/openadmin/spec/components/stat.py +0 -19
- openadmin-0.3.1/openadmin/spec/components/table.py +0 -21
- openadmin-0.3.1/openadmin/spec/page.py +0 -15
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/ISSUE_TEMPLATE/bug.md +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/ISSUE_TEMPLATE/chore.md +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/ISSUE_TEMPLATE/feature.md +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/branch-name/action.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/dependency-cves/action.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/license/action.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/security/action.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/spell-check/action.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/test/action.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/typecheck/action.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/actions/unused-code/action.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.github/workflows/ci.yml +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/.python-version +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/LICENSE +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/LICENSES/AGPL-3.0-or-later.txt +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/docs/assets/logo.png +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/examples/__init__.py +0 -0
- {openadmin-0.3.1/openadmin/spec/components → openadmin-0.7.12/openadmin/spec}/http_methods.py +0 -0
- {openadmin-0.3.1 → openadmin-0.7.12}/tests/test_hello.py +0 -0
|
@@ -25,6 +25,9 @@ jobs:
|
|
|
25
25
|
- uses: astral-sh/setup-uv@v4
|
|
26
26
|
with:
|
|
27
27
|
enable-cache: true
|
|
28
|
+
- uses: oven-sh/setup-bun@v2
|
|
29
|
+
- name: Build frontend
|
|
30
|
+
run: cd client && bun install --frozen-lockfile && bun run build
|
|
28
31
|
- name: Override package name for dev
|
|
29
32
|
if: github.ref_name == 'dev'
|
|
30
33
|
run: sed -i 's/^name = "openadmin"$/name = "openadmin-dev"/' pyproject.toml
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
fix/license:
|
|
2
|
+
@ uv run reuse download --all
|
|
3
|
+
@ uv run reuse annotate --license AGPL-3.0-or-later --copyright "OpenAdmin" --recursive --skip-unrecognised openadmin/
|
|
4
|
+
@ uv run reuse annotate --license AGPL-3.0-or-later --copyright "OpenAdmin" --recursive --skip-unrecognised tests/
|
|
5
|
+
@ uv run reuse annotate --license AGPL-3.0-or-later --copyright "OpenAdmin" --recursive --skip-unrecognised examples/
|
|
6
|
+
@ uv run reuse annotate --license AGPL-3.0-or-later --copyright "OpenAdmin" --recursive --skip-unrecognised $$(find client/src -mindepth 1 -maxdepth 1 ! -name assets)
|
|
7
|
+
|
|
8
|
+
fix/format:
|
|
9
|
+
@ cd client && bun run fix:format
|
|
10
|
+
@ uv run ruff format .
|
|
11
|
+
|
|
12
|
+
fix/lint:
|
|
13
|
+
@ cd client && bun run fix:lint
|
|
14
|
+
@ uv run ruff check --fix .
|
|
15
|
+
|
|
16
|
+
fix: fix/license fix/format fix/lint
|
|
17
|
+
|
|
18
|
+
check/format:
|
|
19
|
+
@ cd client && bun run check:format
|
|
20
|
+
@ uv run ruff format --check .
|
|
21
|
+
|
|
22
|
+
check/lint:
|
|
23
|
+
@ cd client && bun run check:lint
|
|
24
|
+
@ uv run ruff check .
|
|
25
|
+
|
|
26
|
+
check/typing:
|
|
27
|
+
@ uv run pyright .
|
|
28
|
+
|
|
29
|
+
check/cves:
|
|
30
|
+
@ uv audit --preview-features audit
|
|
31
|
+
|
|
32
|
+
check/security:
|
|
33
|
+
@ uv run bandit -r openadmin -q
|
|
34
|
+
|
|
35
|
+
check/unused:
|
|
36
|
+
@ uv run vulture openadmin --min-confidence 80
|
|
37
|
+
|
|
38
|
+
check/spell:
|
|
39
|
+
@ uv run codespell .
|
|
40
|
+
|
|
41
|
+
check/license:
|
|
42
|
+
@ uv run reuse lint
|
|
43
|
+
|
|
44
|
+
check/test:
|
|
45
|
+
@ uv run pytest
|
|
46
|
+
|
|
47
|
+
check: check/format check/lint check/typing check/cves check/security check/unused check/spell check/license check/test
|
|
48
|
+
|
|
49
|
+
dev/run:
|
|
50
|
+
@ cd client && bun run build
|
|
51
|
+
@ PYTHONPATH=. uv run fastapi dev examples/main.py --host 0.0.0.0 --port $${PORT:-8000} --reload
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openadmin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.12
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Project-URL: Homepage, https://github.com/openadmin-team/openadmin-py
|
|
6
6
|
Project-URL: Issues, https://github.com/openadmin-team/openadmin-py/issues
|
|
@@ -11,6 +11,7 @@ Classifier: Operating System :: OS Independent
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Requires-Python: >=3.14
|
|
13
13
|
Requires-Dist: fastapi[standard]>=0.136.3
|
|
14
|
+
Requires-Dist: typing-extensions>=4.12.0
|
|
14
15
|
Description-Content-Type: text/markdown
|
|
15
16
|
|
|
16
17
|
<!-- <div align="center">
|
|
@@ -60,16 +61,21 @@ admin = AdminPanel()
|
|
|
60
61
|
# Define a page
|
|
61
62
|
page = AdminPage("Dashboard")
|
|
62
63
|
|
|
64
|
+
|
|
63
65
|
@page.stat("Total Users")
|
|
64
66
|
async def total_users() -> Stat:
|
|
65
67
|
return Stat(value=1_024)
|
|
66
68
|
|
|
69
|
+
|
|
67
70
|
@page.table("Recent Users")
|
|
68
71
|
async def recent_users() -> Table:
|
|
69
|
-
return Table(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
return Table(
|
|
73
|
+
data=[
|
|
74
|
+
{"id": 1, "name": "Alice", "role": "admin"},
|
|
75
|
+
{"id": 2, "name": "Bob", "role": "viewer"},
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
|
|
73
79
|
|
|
74
80
|
# Register the page and mount the panel
|
|
75
81
|
admin.include_page(page)
|
|
@@ -87,6 +93,7 @@ Display a single numeric or boolean value.
|
|
|
87
93
|
```python
|
|
88
94
|
from openadmin.types import Stat
|
|
89
95
|
|
|
96
|
+
|
|
90
97
|
@page.stat("Active Sessions")
|
|
91
98
|
async def active_sessions() -> Stat:
|
|
92
99
|
return Stat(value=42)
|
|
@@ -100,6 +107,7 @@ Paginated tables with optional search. Use the built-in dependency types to rece
|
|
|
100
107
|
from openadmin.fastapi import PaginationParamsDep, SearchQueryDep
|
|
101
108
|
from openadmin.types import Table
|
|
102
109
|
|
|
110
|
+
|
|
103
111
|
@page.table("Users")
|
|
104
112
|
async def users_table(
|
|
105
113
|
pagination: PaginationParamsDep,
|
|
@@ -117,18 +125,23 @@ Attach per-row action buttons by including `__actions__` in each row:
|
|
|
117
125
|
```python
|
|
118
126
|
from openadmin.types import Action, Table, TableRow
|
|
119
127
|
|
|
128
|
+
|
|
120
129
|
@page.table("Users")
|
|
121
130
|
async def users_table() -> Table:
|
|
122
|
-
return Table(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
return Table(
|
|
132
|
+
data=[
|
|
133
|
+
TableRow(
|
|
134
|
+
id=1,
|
|
135
|
+
name="Alice",
|
|
136
|
+
__actions__=[
|
|
137
|
+
Action(color="danger", method="DELETE", url="/users/1", body=None),
|
|
138
|
+
Action(
|
|
139
|
+
color="info", method="POST", url="/users/1/reset", body=None
|
|
140
|
+
),
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
]
|
|
144
|
+
)
|
|
132
145
|
```
|
|
133
146
|
|
|
134
147
|
### Charts
|
|
@@ -138,6 +151,7 @@ All chart types share the same structure: a `data` list of dicts and a `config`
|
|
|
138
151
|
```python
|
|
139
152
|
from openadmin.types import BarChart, PieChart
|
|
140
153
|
|
|
154
|
+
|
|
141
155
|
@page.bar_chart("Sales by Region", "Total sales per region this quarter")
|
|
142
156
|
async def sales_chart() -> BarChart:
|
|
143
157
|
return BarChart(
|
|
@@ -148,11 +162,12 @@ async def sales_chart() -> BarChart:
|
|
|
148
162
|
config={"sales": {"label": "Sales", "color": "#6366f1"}},
|
|
149
163
|
)
|
|
150
164
|
|
|
165
|
+
|
|
151
166
|
@page.pie_chart("User Roles", "Breakdown of user roles")
|
|
152
167
|
async def roles_chart() -> PieChart:
|
|
153
168
|
return PieChart(
|
|
154
169
|
data=[
|
|
155
|
-
{"segment": "Admin",
|
|
170
|
+
{"segment": "Admin", "count": 5},
|
|
156
171
|
{"segment": "Editor", "count": 20},
|
|
157
172
|
{"segment": "Viewer", "count": 75},
|
|
158
173
|
],
|
|
@@ -178,10 +193,12 @@ Forms collect user input and submit it to your endpoint. Mark a form hidden if i
|
|
|
178
193
|
```python
|
|
179
194
|
from pydantic import BaseModel
|
|
180
195
|
|
|
196
|
+
|
|
181
197
|
class InvitePayload(BaseModel):
|
|
182
198
|
email: str
|
|
183
199
|
role: str
|
|
184
200
|
|
|
201
|
+
|
|
185
202
|
@page.form_post("Invite User", "Send an invitation email to a new user")
|
|
186
203
|
async def invite_user(payload: InvitePayload):
|
|
187
204
|
# send invite...
|
|
@@ -209,27 +226,37 @@ from openadmin.types import Stat, Table, BarChart
|
|
|
209
226
|
|
|
210
227
|
page = AdminPage("Library")
|
|
211
228
|
|
|
229
|
+
|
|
212
230
|
@page.stat("Total Books")
|
|
213
231
|
async def total_books(session: AsyncSessionDep) -> Stat:
|
|
214
232
|
result = await session.execute(select(func.count()).select_from(Book))
|
|
215
233
|
return Stat(value=result.scalar_one())
|
|
216
234
|
|
|
235
|
+
|
|
217
236
|
@page.table("Books")
|
|
218
237
|
async def books_table(
|
|
219
238
|
session: AsyncSessionDep,
|
|
220
239
|
pagination: PaginationParamsDep,
|
|
221
240
|
search: SearchQueryDep,
|
|
222
241
|
) -> Table:
|
|
223
|
-
stmt =
|
|
242
|
+
stmt = (
|
|
243
|
+
select(Book)
|
|
244
|
+
.offset(pagination.page * pagination.per_page)
|
|
245
|
+
.limit(pagination.per_page)
|
|
246
|
+
)
|
|
224
247
|
books = (await session.execute(stmt)).scalars().all()
|
|
225
248
|
return Table(data=[{"title": b.title, "year": b.published_year} for b in books])
|
|
226
249
|
|
|
250
|
+
|
|
227
251
|
@page.bar_chart("Books per Genre", "Number of books in each genre")
|
|
228
252
|
async def books_per_genre(session: AsyncSessionDep) -> BarChart:
|
|
229
|
-
rows = (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
253
|
+
rows = (
|
|
254
|
+
await session.execute(
|
|
255
|
+
select(Genre.name, func.count().label("books"))
|
|
256
|
+
.join(BookToGenre)
|
|
257
|
+
.group_by(Genre.id)
|
|
258
|
+
)
|
|
259
|
+
).all()
|
|
233
260
|
return BarChart(
|
|
234
261
|
data=[{"genre": name, "books": count} for name, count in rows],
|
|
235
262
|
config={"books": {"label": "Books", "color": "#6366f1"}},
|
|
@@ -45,16 +45,21 @@ admin = AdminPanel()
|
|
|
45
45
|
# Define a page
|
|
46
46
|
page = AdminPage("Dashboard")
|
|
47
47
|
|
|
48
|
+
|
|
48
49
|
@page.stat("Total Users")
|
|
49
50
|
async def total_users() -> Stat:
|
|
50
51
|
return Stat(value=1_024)
|
|
51
52
|
|
|
53
|
+
|
|
52
54
|
@page.table("Recent Users")
|
|
53
55
|
async def recent_users() -> Table:
|
|
54
|
-
return Table(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
return Table(
|
|
57
|
+
data=[
|
|
58
|
+
{"id": 1, "name": "Alice", "role": "admin"},
|
|
59
|
+
{"id": 2, "name": "Bob", "role": "viewer"},
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
|
|
58
63
|
|
|
59
64
|
# Register the page and mount the panel
|
|
60
65
|
admin.include_page(page)
|
|
@@ -72,6 +77,7 @@ Display a single numeric or boolean value.
|
|
|
72
77
|
```python
|
|
73
78
|
from openadmin.types import Stat
|
|
74
79
|
|
|
80
|
+
|
|
75
81
|
@page.stat("Active Sessions")
|
|
76
82
|
async def active_sessions() -> Stat:
|
|
77
83
|
return Stat(value=42)
|
|
@@ -85,6 +91,7 @@ Paginated tables with optional search. Use the built-in dependency types to rece
|
|
|
85
91
|
from openadmin.fastapi import PaginationParamsDep, SearchQueryDep
|
|
86
92
|
from openadmin.types import Table
|
|
87
93
|
|
|
94
|
+
|
|
88
95
|
@page.table("Users")
|
|
89
96
|
async def users_table(
|
|
90
97
|
pagination: PaginationParamsDep,
|
|
@@ -102,18 +109,23 @@ Attach per-row action buttons by including `__actions__` in each row:
|
|
|
102
109
|
```python
|
|
103
110
|
from openadmin.types import Action, Table, TableRow
|
|
104
111
|
|
|
112
|
+
|
|
105
113
|
@page.table("Users")
|
|
106
114
|
async def users_table() -> Table:
|
|
107
|
-
return Table(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
return Table(
|
|
116
|
+
data=[
|
|
117
|
+
TableRow(
|
|
118
|
+
id=1,
|
|
119
|
+
name="Alice",
|
|
120
|
+
__actions__=[
|
|
121
|
+
Action(color="danger", method="DELETE", url="/users/1", body=None),
|
|
122
|
+
Action(
|
|
123
|
+
color="info", method="POST", url="/users/1/reset", body=None
|
|
124
|
+
),
|
|
125
|
+
],
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
)
|
|
117
129
|
```
|
|
118
130
|
|
|
119
131
|
### Charts
|
|
@@ -123,6 +135,7 @@ All chart types share the same structure: a `data` list of dicts and a `config`
|
|
|
123
135
|
```python
|
|
124
136
|
from openadmin.types import BarChart, PieChart
|
|
125
137
|
|
|
138
|
+
|
|
126
139
|
@page.bar_chart("Sales by Region", "Total sales per region this quarter")
|
|
127
140
|
async def sales_chart() -> BarChart:
|
|
128
141
|
return BarChart(
|
|
@@ -133,11 +146,12 @@ async def sales_chart() -> BarChart:
|
|
|
133
146
|
config={"sales": {"label": "Sales", "color": "#6366f1"}},
|
|
134
147
|
)
|
|
135
148
|
|
|
149
|
+
|
|
136
150
|
@page.pie_chart("User Roles", "Breakdown of user roles")
|
|
137
151
|
async def roles_chart() -> PieChart:
|
|
138
152
|
return PieChart(
|
|
139
153
|
data=[
|
|
140
|
-
{"segment": "Admin",
|
|
154
|
+
{"segment": "Admin", "count": 5},
|
|
141
155
|
{"segment": "Editor", "count": 20},
|
|
142
156
|
{"segment": "Viewer", "count": 75},
|
|
143
157
|
],
|
|
@@ -163,10 +177,12 @@ Forms collect user input and submit it to your endpoint. Mark a form hidden if i
|
|
|
163
177
|
```python
|
|
164
178
|
from pydantic import BaseModel
|
|
165
179
|
|
|
180
|
+
|
|
166
181
|
class InvitePayload(BaseModel):
|
|
167
182
|
email: str
|
|
168
183
|
role: str
|
|
169
184
|
|
|
185
|
+
|
|
170
186
|
@page.form_post("Invite User", "Send an invitation email to a new user")
|
|
171
187
|
async def invite_user(payload: InvitePayload):
|
|
172
188
|
# send invite...
|
|
@@ -194,27 +210,37 @@ from openadmin.types import Stat, Table, BarChart
|
|
|
194
210
|
|
|
195
211
|
page = AdminPage("Library")
|
|
196
212
|
|
|
213
|
+
|
|
197
214
|
@page.stat("Total Books")
|
|
198
215
|
async def total_books(session: AsyncSessionDep) -> Stat:
|
|
199
216
|
result = await session.execute(select(func.count()).select_from(Book))
|
|
200
217
|
return Stat(value=result.scalar_one())
|
|
201
218
|
|
|
219
|
+
|
|
202
220
|
@page.table("Books")
|
|
203
221
|
async def books_table(
|
|
204
222
|
session: AsyncSessionDep,
|
|
205
223
|
pagination: PaginationParamsDep,
|
|
206
224
|
search: SearchQueryDep,
|
|
207
225
|
) -> Table:
|
|
208
|
-
stmt =
|
|
226
|
+
stmt = (
|
|
227
|
+
select(Book)
|
|
228
|
+
.offset(pagination.page * pagination.per_page)
|
|
229
|
+
.limit(pagination.per_page)
|
|
230
|
+
)
|
|
209
231
|
books = (await session.execute(stmt)).scalars().all()
|
|
210
232
|
return Table(data=[{"title": b.title, "year": b.published_year} for b in books])
|
|
211
233
|
|
|
234
|
+
|
|
212
235
|
@page.bar_chart("Books per Genre", "Number of books in each genre")
|
|
213
236
|
async def books_per_genre(session: AsyncSessionDep) -> BarChart:
|
|
214
|
-
rows = (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
rows = (
|
|
238
|
+
await session.execute(
|
|
239
|
+
select(Genre.name, func.count().label("books"))
|
|
240
|
+
.join(BookToGenre)
|
|
241
|
+
.group_by(Genre.id)
|
|
242
|
+
)
|
|
243
|
+
).all()
|
|
218
244
|
return BarChart(
|
|
219
245
|
data=[{"genre": name, "books": count} for name, count in rows],
|
|
220
246
|
config={"books": {"label": "Books", "color": "#6366f1"}},
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenAdmin
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import func, select
|
|
6
|
+
|
|
7
|
+
from openadmin import spec
|
|
8
|
+
from openadmin.fastapi import AdminPage
|
|
9
|
+
|
|
10
|
+
from ..lib import models
|
|
11
|
+
from ..lib.database import AsyncSessionDep
|
|
12
|
+
|
|
13
|
+
page = AdminPage(
|
|
14
|
+
"Analytics",
|
|
15
|
+
icon="bar-chart",
|
|
16
|
+
description="Library-wide publication trends and insights",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@page.stat(
|
|
21
|
+
"Earliest Publication",
|
|
22
|
+
icon="sun",
|
|
23
|
+
description="This is earliest publications for the last quorder",
|
|
24
|
+
color="red",
|
|
25
|
+
)
|
|
26
|
+
async def get_earliest_publication(session: AsyncSessionDep) -> spec.Stat:
|
|
27
|
+
result = await session.execute(
|
|
28
|
+
select(func.min(models.Book.published_year)).where(
|
|
29
|
+
models.Book.published_year.isnot(None)
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return result.scalar_one_or_none()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@page.stat(
|
|
36
|
+
"Latest Publication",
|
|
37
|
+
description="Most recent publication year found in the catalog",
|
|
38
|
+
)
|
|
39
|
+
async def get_latest_publication(session: AsyncSessionDep) -> spec.Stat:
|
|
40
|
+
result = await session.execute(
|
|
41
|
+
select(func.max(models.Book.published_year)).where(
|
|
42
|
+
models.Book.published_year.isnot(None)
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"value": result.scalar_one_or_none() or "N/A",
|
|
48
|
+
"color": "blue",
|
|
49
|
+
"icon": "clock",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@page.stat(
|
|
54
|
+
"Books with Summary",
|
|
55
|
+
icon="book-check",
|
|
56
|
+
color="teal",
|
|
57
|
+
description="Number of books that include a summary",
|
|
58
|
+
)
|
|
59
|
+
async def get_books_with_summary(session: AsyncSessionDep) -> int:
|
|
60
|
+
result = await session.execute(
|
|
61
|
+
select(func.count(models.Book.id)).where(models.Book.summary.isnot(None))
|
|
62
|
+
)
|
|
63
|
+
return result.scalar_one()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@page.markdown("Library Overview")
|
|
67
|
+
async def get_library_overview(session: AsyncSessionDep) -> str:
|
|
68
|
+
total_books = (
|
|
69
|
+
await session.execute(select(func.count(models.Book.id)))
|
|
70
|
+
).scalar_one()
|
|
71
|
+
total_authors = (
|
|
72
|
+
await session.execute(select(func.count(models.Author.id)))
|
|
73
|
+
).scalar_one()
|
|
74
|
+
total_publishers = (
|
|
75
|
+
await session.execute(select(func.count(models.Publisher.id)))
|
|
76
|
+
).scalar_one()
|
|
77
|
+
total_genres = (
|
|
78
|
+
await session.execute(select(func.count(models.Genre.id)))
|
|
79
|
+
).scalar_one()
|
|
80
|
+
total_tags = (await session.execute(select(func.count(models.Tag.id)))).scalar_one()
|
|
81
|
+
|
|
82
|
+
return f"""# Library Overview
|
|
83
|
+
|
|
84
|
+
This catalog contains **{total_books:,} books** written by **{total_authors} authors**
|
|
85
|
+
and published by **{total_publishers} publishers**.
|
|
86
|
+
|
|
87
|
+
## Classification
|
|
88
|
+
|
|
89
|
+
The collection is organized across **{total_genres} genres** and annotated with **{total_tags} tags**,
|
|
90
|
+
enabling precise discovery and filtering across the full catalog.
|
|
91
|
+
|
|
92
|
+
## Coverage
|
|
93
|
+
|
|
94
|
+
Books span multiple centuries of publication history, from early historical works through
|
|
95
|
+
contemporary releases, with particular depth in the 20th and 21st century catalog.
|
|
96
|
+
"""
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenAdmin
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from sqlalchemy import func, select
|
|
7
|
+
|
|
8
|
+
from openadmin import spec
|
|
9
|
+
from openadmin.fastapi import AdminPage
|
|
10
|
+
from openadmin.fastapi.deps import PageDep, SearchQueryDep
|
|
11
|
+
|
|
12
|
+
from ..lib import models
|
|
13
|
+
from ..lib.database import AsyncSessionDep
|
|
14
|
+
|
|
15
|
+
page = AdminPage(
|
|
16
|
+
"Authors",
|
|
17
|
+
icon="users",
|
|
18
|
+
description="Manage book authors and view productivity stats",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@page.stat("Total Authors", description="Total number of authors in the catalog")
|
|
23
|
+
async def get_total_authors(session: AsyncSessionDep) -> spec.StatResponse:
|
|
24
|
+
result = await session.execute(select(func.count(models.Author.id)))
|
|
25
|
+
return {"value": result.scalar_one(), "icon": "users", "color": "indigo"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@page.stat(
|
|
29
|
+
"Authors with Bio",
|
|
30
|
+
icon="user-check",
|
|
31
|
+
color="emerald",
|
|
32
|
+
description="Authors that have a biography on file",
|
|
33
|
+
)
|
|
34
|
+
async def get_authors_with_bio(session: AsyncSessionDep):
|
|
35
|
+
result = await session.execute(
|
|
36
|
+
select(func.count(models.Author.id)).where(models.Author.bio.isnot(None))
|
|
37
|
+
)
|
|
38
|
+
return result.scalar_one()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@page.stat(
|
|
42
|
+
"Avg Books per Author",
|
|
43
|
+
icon="library",
|
|
44
|
+
color="amber",
|
|
45
|
+
description="Average number of books published per author",
|
|
46
|
+
)
|
|
47
|
+
async def get_avg_books_per_author(session: AsyncSessionDep) -> float:
|
|
48
|
+
subq = (
|
|
49
|
+
select(func.count(models.Book.id).label("cnt"))
|
|
50
|
+
.group_by(models.Book.author_id)
|
|
51
|
+
.subquery()
|
|
52
|
+
)
|
|
53
|
+
result = await session.execute(select(func.avg(subq.c.cnt)))
|
|
54
|
+
return round(float(result.scalar_one() or 0), 2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@page.table(
|
|
58
|
+
"All Authors",
|
|
59
|
+
description="Browse authors with search and book counts",
|
|
60
|
+
icon="user-pen",
|
|
61
|
+
color="indigo",
|
|
62
|
+
)
|
|
63
|
+
async def get_all_authors(
|
|
64
|
+
session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
|
|
65
|
+
) -> spec.Table:
|
|
66
|
+
stmt = (
|
|
67
|
+
select(models.Author, func.count(models.Book.id).label("book_count"))
|
|
68
|
+
.outerjoin(models.Book, models.Book.author_id == models.Author.id)
|
|
69
|
+
.group_by(models.Author.id)
|
|
70
|
+
.offset((pagination.page - 1) * pagination.per_page)
|
|
71
|
+
.limit(pagination.per_page)
|
|
72
|
+
)
|
|
73
|
+
if search:
|
|
74
|
+
full_name = models.Author.first_name + " " + models.Author.last_name
|
|
75
|
+
stmt = stmt.where(full_name.ilike(f"%{search}%"))
|
|
76
|
+
result = await session.execute(stmt)
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
"id": author.id,
|
|
80
|
+
"name": f"{author.first_name} {author.last_name}",
|
|
81
|
+
"bio": (author.bio[:80] + "...")
|
|
82
|
+
if author.bio and len(author.bio) > 80
|
|
83
|
+
else author.bio,
|
|
84
|
+
"book_count": count,
|
|
85
|
+
"__view__": f"{author.first_name} {author.last_name}",
|
|
86
|
+
}
|
|
87
|
+
for author, count in result.all()
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@page.bar_chart(
|
|
92
|
+
"Top Authors by Book Count",
|
|
93
|
+
description="The 10 most prolific authors in the catalog",
|
|
94
|
+
)
|
|
95
|
+
async def get_top_authors(session: AsyncSessionDep):
|
|
96
|
+
stmt = (
|
|
97
|
+
select(
|
|
98
|
+
(models.Author.first_name + " " + models.Author.last_name).label("name"),
|
|
99
|
+
func.count(models.Book.id).label("count"),
|
|
100
|
+
)
|
|
101
|
+
.join(models.Book, models.Book.author_id == models.Author.id)
|
|
102
|
+
.group_by(models.Author.id)
|
|
103
|
+
.order_by(func.count(models.Book.id).desc())
|
|
104
|
+
.limit(10)
|
|
105
|
+
)
|
|
106
|
+
result = await session.execute(stmt)
|
|
107
|
+
return [{"label": row.name, "value": row.count} for row in result]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AddAuthorBody(BaseModel):
|
|
111
|
+
first_name: str
|
|
112
|
+
last_name: str
|
|
113
|
+
bio: str | None = None
|
|
114
|
+
friend: int
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@page.form(
|
|
118
|
+
"Add Author",
|
|
119
|
+
description="Register a new author in the catalog",
|
|
120
|
+
fields={
|
|
121
|
+
"friend": {
|
|
122
|
+
"reference": get_all_authors,
|
|
123
|
+
"icon": "user",
|
|
124
|
+
"color": "blue",
|
|
125
|
+
"reference_field": "id",
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
async def add_author(body: AddAuthorBody, session: AsyncSessionDep) -> spec.Form:
|
|
130
|
+
author = models.Author(**body.model_dump())
|
|
131
|
+
session.add(author)
|
|
132
|
+
await session.commit()
|
|
133
|
+
await session.refresh(author)
|
|
134
|
+
name = f"{author.first_name} {author.last_name}"
|
|
135
|
+
return {
|
|
136
|
+
"message": f"Added author '{name}'",
|
|
137
|
+
"table": {"id": author.id, "name": name},
|
|
138
|
+
}
|