openadmin-dev 0.7.8__tar.gz → 0.7.10__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_dev-0.7.8 → openadmin_dev-0.7.10}/PKG-INFO +1 -1
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/analytics.py +0 -44
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/authors.py +29 -8
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/books.py +30 -10
- openadmin_dev-0.7.10/examples/admin/control_panel.py +213 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/genres.py +3 -3
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/health.py +0 -35
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/overview.py +8 -3
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/publishers.py +14 -5
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/reports.py +5 -38
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/tags.py +20 -6
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/weekdays.py +1 -1
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/main.py +5 -2
- openadmin_dev-0.7.10/openadmin/fastapi/admin_page.py +572 -0
- openadmin_dev-0.7.10/openadmin/fastapi/admin_panel.py +71 -0
- openadmin_dev-0.7.10/openadmin/fastapi/counter.py +19 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/fastapi/deps.py +2 -2
- openadmin_dev-0.7.10/openadmin/fastapi/exc_handler.py +29 -0
- openadmin_dev-0.7.10/openadmin/fastapi/field_config.py +15 -0
- openadmin_dev-0.7.10/openadmin/fastapi/utils.py +102 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/__init__.py +15 -8
- openadmin_dev-0.7.10/openadmin/spec/action.py +35 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/area_chart.py +9 -7
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/bar_chart.py +5 -6
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/component.py +8 -8
- openadmin_dev-0.7.10/openadmin/spec/error.py +9 -0
- openadmin_dev-0.7.10/openadmin/spec/form.py +43 -0
- openadmin_dev-0.7.10/openadmin/spec/json_schema.py +49 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/line_chart.py +9 -7
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/markdown.py +5 -6
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/page.py +2 -2
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/pie_chart.py +5 -6
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/section.py +2 -2
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/spec.py +4 -3
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/stat.py +5 -6
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/table.py +6 -9
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/pyproject.toml +2 -2
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/uv.lock +1 -1
- openadmin_dev-0.7.8/openadmin/fastapi/action.py +0 -21
- openadmin_dev-0.7.8/openadmin/fastapi/admin_page.py +0 -691
- openadmin_dev-0.7.8/openadmin/fastapi/admin_panel.py +0 -93
- openadmin_dev-0.7.8/openadmin/fastapi/area_chart.py +0 -18
- openadmin_dev-0.7.8/openadmin/fastapi/bar_chart.py +0 -25
- openadmin_dev-0.7.8/openadmin/fastapi/component.py +0 -26
- openadmin_dev-0.7.8/openadmin/fastapi/counters.py +0 -17
- openadmin_dev-0.7.8/openadmin/fastapi/form.py +0 -21
- openadmin_dev-0.7.8/openadmin/fastapi/line_chart.py +0 -18
- openadmin_dev-0.7.8/openadmin/fastapi/markdown.py +0 -20
- openadmin_dev-0.7.8/openadmin/fastapi/page_protocol.py +0 -12
- openadmin_dev-0.7.8/openadmin/fastapi/pie_chart.py +0 -26
- openadmin_dev-0.7.8/openadmin/fastapi/section.py +0 -19
- openadmin_dev-0.7.8/openadmin/fastapi/stat.py +0 -20
- openadmin_dev-0.7.8/openadmin/fastapi/table.py +0 -21
- openadmin_dev-0.7.8/openadmin/fastapi/utils.py +0 -184
- openadmin_dev-0.7.8/openadmin/spec/action.py +0 -27
- openadmin_dev-0.7.8/openadmin/spec/form.py +0 -27
- openadmin_dev-0.7.8/openadmin/spec/property.py +0 -16
- openadmin_dev-0.7.8/openadmin/spec/property_type.py +0 -18
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/ISSUE_TEMPLATE/bug.md +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/ISSUE_TEMPLATE/chore.md +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/ISSUE_TEMPLATE/feature.md +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/branch-name/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/dependency-cves/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/format/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/license/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/lint/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/security/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/spell-check/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/test/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/typecheck/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/actions/unused-code/action.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/workflows/ci.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.github/workflows/publish.yml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.gitignore +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/.python-version +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/LICENSE +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/LICENSES/AGPL-3.0-or-later.txt +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/Makefile +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/README.md +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/REUSE.toml +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/docs/assets/logo.png +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/__init__.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/__init__.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/admin/docs.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/lib/__init__.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/lib/database.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/lib/lifespan.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/lib/models.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/examples/lib/seed.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/fastapi/__init__.py +0 -0
- /openadmin_dev-0.7.8/openadmin/fastapi/pagination_params.py → /openadmin_dev-0.7.10/openadmin/fastapi/req.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/colors.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/http_methods.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/openadmin/spec/icons.py +0 -0
- {openadmin_dev-0.7.8 → openadmin_dev-0.7.10}/tests/test_hello.py +0 -0
|
@@ -63,50 +63,6 @@ async def get_books_with_summary(session: AsyncSessionDep) -> int:
|
|
|
63
63
|
return result.scalar_one()
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
@page.line_chart(
|
|
67
|
-
"Publications by Decade", description="Number of books published per decade"
|
|
68
|
-
)
|
|
69
|
-
async def get_publications_by_decade(session: AsyncSessionDep):
|
|
70
|
-
stmt = (
|
|
71
|
-
select(
|
|
72
|
-
(func.floor(models.Book.published_year / 10) * 10).label("decade"),
|
|
73
|
-
func.count(models.Book.id).label("count"),
|
|
74
|
-
)
|
|
75
|
-
.where(models.Book.published_year.isnot(None))
|
|
76
|
-
.group_by("decade")
|
|
77
|
-
.order_by("decade")
|
|
78
|
-
)
|
|
79
|
-
result = await session.execute(stmt)
|
|
80
|
-
return [{"label": f"{int(row.decade)}s", "value": row.count} for row in result]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
@page.area_chart(
|
|
84
|
-
"Books Published by Era", description="Book volume across broad historical eras"
|
|
85
|
-
)
|
|
86
|
-
async def get_books_by_era(session: AsyncSessionDep):
|
|
87
|
-
eras: list[tuple[str, int | None, int | None]] = [
|
|
88
|
-
("Pre-1900", None, 1900),
|
|
89
|
-
("1900–1950", 1900, 1950),
|
|
90
|
-
("1950–1980", 1950, 1980),
|
|
91
|
-
("1980–2000", 1980, 2000),
|
|
92
|
-
("2000–2010", 2000, 2010),
|
|
93
|
-
("2010–2020", 2010, 2020),
|
|
94
|
-
("2020+", 2020, None),
|
|
95
|
-
]
|
|
96
|
-
rows = []
|
|
97
|
-
for label, start, end in eras:
|
|
98
|
-
stmt = select(func.count(models.Book.id)).where(
|
|
99
|
-
models.Book.published_year.isnot(None)
|
|
100
|
-
)
|
|
101
|
-
if start is not None:
|
|
102
|
-
stmt = stmt.where(models.Book.published_year >= start)
|
|
103
|
-
if end is not None:
|
|
104
|
-
stmt = stmt.where(models.Book.published_year < end)
|
|
105
|
-
count = (await session.execute(stmt)).scalar_one()
|
|
106
|
-
rows.append({"label": label, "value": count})
|
|
107
|
-
return rows
|
|
108
|
-
|
|
109
|
-
|
|
110
66
|
@page.markdown("Library Overview")
|
|
111
67
|
async def get_library_overview(session: AsyncSessionDep) -> str:
|
|
112
68
|
total_books = (
|
|
@@ -31,7 +31,7 @@ async def get_total_authors(session: AsyncSessionDep) -> spec.StatResponse:
|
|
|
31
31
|
color="emerald",
|
|
32
32
|
description="Authors that have a biography on file",
|
|
33
33
|
)
|
|
34
|
-
async def get_authors_with_bio(session: AsyncSessionDep)
|
|
34
|
+
async def get_authors_with_bio(session: AsyncSessionDep):
|
|
35
35
|
result = await session.execute(
|
|
36
36
|
select(func.count(models.Author.id)).where(models.Author.bio.isnot(None))
|
|
37
37
|
)
|
|
@@ -54,7 +54,12 @@ async def get_avg_books_per_author(session: AsyncSessionDep) -> float:
|
|
|
54
54
|
return round(float(result.scalar_one() or 0), 2)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
@page.table(
|
|
57
|
+
@page.table(
|
|
58
|
+
"All Authors",
|
|
59
|
+
description="Browse authors with search and book counts",
|
|
60
|
+
icon="user-pen",
|
|
61
|
+
color="indigo",
|
|
62
|
+
)
|
|
58
63
|
async def get_all_authors(
|
|
59
64
|
session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
|
|
60
65
|
):
|
|
@@ -62,7 +67,7 @@ async def get_all_authors(
|
|
|
62
67
|
select(models.Author, func.count(models.Book.id).label("book_count"))
|
|
63
68
|
.outerjoin(models.Book, models.Book.author_id == models.Author.id)
|
|
64
69
|
.group_by(models.Author.id)
|
|
65
|
-
.offset(pagination.page * pagination.per_page)
|
|
70
|
+
.offset((pagination.page - 1) * pagination.per_page)
|
|
66
71
|
.limit(pagination.per_page)
|
|
67
72
|
)
|
|
68
73
|
if search:
|
|
@@ -105,12 +110,28 @@ class AddAuthorBody(BaseModel):
|
|
|
105
110
|
first_name: str
|
|
106
111
|
last_name: str
|
|
107
112
|
bio: str | None = None
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
friend: int
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@page.form(
|
|
117
|
+
"Add Author",
|
|
118
|
+
description="Register a new author in the catalog",
|
|
119
|
+
fields={
|
|
120
|
+
"friend": {
|
|
121
|
+
"reference": get_all_authors,
|
|
122
|
+
"icon": "user",
|
|
123
|
+
"color": "blue",
|
|
124
|
+
"reference_field": "id",
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
async def add_author(body: AddAuthorBody, session: AsyncSessionDep) -> spec.Form:
|
|
112
129
|
author = models.Author(**body.model_dump())
|
|
113
130
|
session.add(author)
|
|
114
131
|
await session.commit()
|
|
115
132
|
await session.refresh(author)
|
|
116
|
-
|
|
133
|
+
name = f"{author.first_name} {author.last_name}"
|
|
134
|
+
return {
|
|
135
|
+
"message": f"Added author '{name}'",
|
|
136
|
+
"table": {"id": author.id, "name": name},
|
|
137
|
+
}
|
|
@@ -6,6 +6,7 @@ from fastapi import Query
|
|
|
6
6
|
from pydantic import BaseModel
|
|
7
7
|
from sqlalchemy import func, select
|
|
8
8
|
|
|
9
|
+
from openadmin import spec
|
|
9
10
|
from openadmin.fastapi import AdminPage
|
|
10
11
|
from openadmin.fastapi.deps import PageDep, SearchQueryDep
|
|
11
12
|
|
|
@@ -37,14 +38,19 @@ async def get_books_without_publisher(session: AsyncSessionDep) -> int:
|
|
|
37
38
|
return result.scalar_one()
|
|
38
39
|
|
|
39
40
|
|
|
40
|
-
@page.table(
|
|
41
|
+
@page.table(
|
|
42
|
+
"All Books",
|
|
43
|
+
description="Browse all books with search and pagination",
|
|
44
|
+
icon="book",
|
|
45
|
+
color="blue",
|
|
46
|
+
)
|
|
41
47
|
async def get_all_books(
|
|
42
48
|
session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
|
|
43
49
|
):
|
|
44
50
|
stmt = (
|
|
45
51
|
select(models.Book, models.Author)
|
|
46
52
|
.join(models.Author, models.Author.id == models.Book.author_id)
|
|
47
|
-
.offset(pagination.page * pagination.per_page)
|
|
53
|
+
.offset((pagination.page - 1) * pagination.per_page)
|
|
48
54
|
.limit(pagination.per_page)
|
|
49
55
|
)
|
|
50
56
|
if search:
|
|
@@ -69,14 +75,19 @@ class AddBookBody(BaseModel):
|
|
|
69
75
|
publisher_id: int | None = None
|
|
70
76
|
|
|
71
77
|
|
|
72
|
-
@page.table(
|
|
78
|
+
@page.table(
|
|
79
|
+
"Books by Author",
|
|
80
|
+
description="Number of books per author",
|
|
81
|
+
icon="users-round",
|
|
82
|
+
color="violet",
|
|
83
|
+
)
|
|
73
84
|
async def get_books_by_author(session: AsyncSessionDep, pagination: PageDep):
|
|
74
85
|
stmt = (
|
|
75
86
|
select(models.Author, func.count(models.Book.id).label("book_count"))
|
|
76
87
|
.join(models.Book, models.Book.author_id == models.Author.id)
|
|
77
88
|
.group_by(models.Author.id)
|
|
78
89
|
.order_by(func.count(models.Book.id).desc())
|
|
79
|
-
.offset(pagination.page * pagination.per_page)
|
|
90
|
+
.offset((pagination.page - 1) * pagination.per_page)
|
|
80
91
|
.limit(pagination.per_page)
|
|
81
92
|
)
|
|
82
93
|
result = await session.execute(stmt)
|
|
@@ -89,22 +100,31 @@ async def get_books_by_author(session: AsyncSessionDep, pagination: PageDep):
|
|
|
89
100
|
]
|
|
90
101
|
|
|
91
102
|
|
|
92
|
-
@page.
|
|
93
|
-
async def add_book(body: AddBookBody, session: AsyncSessionDep):
|
|
103
|
+
@page.form("Add Book", description="Add a new book to the catalog")
|
|
104
|
+
async def add_book(body: AddBookBody, session: AsyncSessionDep) -> spec.Form:
|
|
94
105
|
book = models.Book(**body.model_dump())
|
|
95
106
|
session.add(book)
|
|
96
107
|
await session.commit()
|
|
97
108
|
await session.refresh(book)
|
|
98
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
"message": f"Added book '{book.title}'",
|
|
111
|
+
"table": {"id": book.id, "title": book.title},
|
|
112
|
+
}
|
|
99
113
|
|
|
100
114
|
|
|
101
|
-
@page.
|
|
115
|
+
@page.action("Delete Book", method="delete", description="Remove a book by ID")
|
|
102
116
|
async def delete_book(
|
|
103
117
|
session: AsyncSessionDep,
|
|
104
118
|
book_id: int = Query(..., description="Book ID to delete"),
|
|
105
|
-
):
|
|
119
|
+
) -> spec.Action:
|
|
106
120
|
book = await session.get(models.Book, book_id)
|
|
107
121
|
if book:
|
|
108
122
|
await session.delete(book)
|
|
109
123
|
await session.commit()
|
|
110
|
-
|
|
124
|
+
found = book is not None
|
|
125
|
+
return {
|
|
126
|
+
"message": f"Deleted book #{book_id}"
|
|
127
|
+
if found
|
|
128
|
+
else f"Book #{book_id} not found",
|
|
129
|
+
"table": {"deleted": book_id, "found": found},
|
|
130
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenAdmin
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Body, Depends, Form, Query
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from openadmin import spec
|
|
11
|
+
from openadmin.fastapi import AdminPage
|
|
12
|
+
|
|
13
|
+
page = AdminPage(
|
|
14
|
+
"Control Panel",
|
|
15
|
+
icon="settings",
|
|
16
|
+
description="Every action/form parameter and response shape, demoed with example data",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def current_actor(
|
|
21
|
+
actor: str = Query("system", description="Who is performing this operation"),
|
|
22
|
+
) -> str:
|
|
23
|
+
return actor
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ActorDep = Annotated[str, Depends(current_actor)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Actions — one per HTTP verb, each showing a different param source and a
|
|
31
|
+
# different Action response shape. Nothing here mutates real state.
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@page.action(
|
|
36
|
+
"Ping Service",
|
|
37
|
+
method="get",
|
|
38
|
+
description="Check whether a downstream service is reachable",
|
|
39
|
+
icon="activity",
|
|
40
|
+
color="green",
|
|
41
|
+
is_hidden=False,
|
|
42
|
+
)
|
|
43
|
+
async def ping_service(
|
|
44
|
+
target: str = Query("api", description="Service name to ping"),
|
|
45
|
+
) -> spec.Action:
|
|
46
|
+
return {
|
|
47
|
+
"icon": "activity",
|
|
48
|
+
"color": "green",
|
|
49
|
+
"toast": f"{target} responded in 12ms",
|
|
50
|
+
"message": f"Pinged '{target}' — reachable (example response, nothing was contacted)",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotificationBody(BaseModel):
|
|
55
|
+
title: str
|
|
56
|
+
body: str
|
|
57
|
+
recipient: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@page.action(
|
|
61
|
+
"Send Test Notification",
|
|
62
|
+
method="post",
|
|
63
|
+
description="Send a one-off notification through the messaging provider",
|
|
64
|
+
icon="send",
|
|
65
|
+
color="blue",
|
|
66
|
+
)
|
|
67
|
+
async def send_test_notification(
|
|
68
|
+
body: NotificationBody,
|
|
69
|
+
actor: ActorDep,
|
|
70
|
+
dry_run: bool = Query(True, description="Simulate without actually sending"),
|
|
71
|
+
) -> str:
|
|
72
|
+
target = body.recipient or "all subscribers"
|
|
73
|
+
verb = "Would send" if dry_run else "Sent"
|
|
74
|
+
return (
|
|
75
|
+
f"{verb} '{body.title}' to {target} (requested by {actor}) — "
|
|
76
|
+
"example only, nothing was sent"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@page.action(
|
|
81
|
+
"Update Feature Flag",
|
|
82
|
+
method="put",
|
|
83
|
+
description="Flip a feature flag on or off for every user",
|
|
84
|
+
icon="toggle-left",
|
|
85
|
+
color="violet",
|
|
86
|
+
is_hidden=True,
|
|
87
|
+
)
|
|
88
|
+
async def update_feature_flag(
|
|
89
|
+
flag_name: str = Query(..., description="Flag key, e.g. new-dashboard"),
|
|
90
|
+
enabled: bool = Query(..., description="New flag state"),
|
|
91
|
+
) -> spec.Action:
|
|
92
|
+
return {
|
|
93
|
+
"toast": f"{flag_name} -> {enabled}",
|
|
94
|
+
"message": "Preview of the change below (example data, flag was not modified)",
|
|
95
|
+
"table": [{"flag": flag_name, "previous": not enabled, "new": enabled}],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@page.action(
|
|
100
|
+
"Rotate API Key",
|
|
101
|
+
method="patch",
|
|
102
|
+
description="Issue a new API key and retire the previous one",
|
|
103
|
+
icon="key",
|
|
104
|
+
color="amber",
|
|
105
|
+
)
|
|
106
|
+
async def rotate_api_key(
|
|
107
|
+
key_name: str = Form(..., description="Key to rotate"),
|
|
108
|
+
expires_in_days: int = Form(30, description="Validity period for the new key"),
|
|
109
|
+
) -> spec.Action:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@page.action(
|
|
114
|
+
"Purge Temp Files",
|
|
115
|
+
method="delete",
|
|
116
|
+
description="Delete temporary files older than a given age",
|
|
117
|
+
icon="trash-2",
|
|
118
|
+
color="red",
|
|
119
|
+
)
|
|
120
|
+
async def purge_temp_files(
|
|
121
|
+
older_than_days: int = Query(7, ge=0, description="Age threshold in days"),
|
|
122
|
+
) -> spec.Action:
|
|
123
|
+
return {
|
|
124
|
+
"message": (
|
|
125
|
+
f"Would delete files older than {older_than_days} days "
|
|
126
|
+
"(example — nothing was purged)"
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Forms — one per HTTP verb (post/put/patch/delete demoed here).
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class WebhookBody(BaseModel):
|
|
137
|
+
url: str
|
|
138
|
+
event: str
|
|
139
|
+
secret: str | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@page.form(
|
|
143
|
+
"Create Webhook",
|
|
144
|
+
method="post",
|
|
145
|
+
description="Register a new outgoing webhook",
|
|
146
|
+
icon="webhook",
|
|
147
|
+
color="teal",
|
|
148
|
+
)
|
|
149
|
+
async def create_webhook(body: WebhookBody) -> spec.Form:
|
|
150
|
+
return {
|
|
151
|
+
"icon": "webhook",
|
|
152
|
+
"color": "teal",
|
|
153
|
+
"toast": "Webhook created",
|
|
154
|
+
"message": f"Would create a webhook for '{body.event}' -> {body.url}",
|
|
155
|
+
"table": {
|
|
156
|
+
"data": [{"id": 1, "url": body.url, "event": body.event}],
|
|
157
|
+
"icon": "webhook",
|
|
158
|
+
"color": "teal",
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class RateLimitBody(BaseModel):
|
|
164
|
+
requests_per_minute: int
|
|
165
|
+
burst_size: int = 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@page.form(
|
|
169
|
+
"Update Rate Limit",
|
|
170
|
+
method="put",
|
|
171
|
+
description="Replace the current API rate-limit configuration",
|
|
172
|
+
icon="gauge",
|
|
173
|
+
color="cyan",
|
|
174
|
+
is_hidden=True,
|
|
175
|
+
)
|
|
176
|
+
async def update_rate_limit(body: RateLimitBody) -> spec.Form:
|
|
177
|
+
return {
|
|
178
|
+
"icon": "gauge",
|
|
179
|
+
"color": "cyan",
|
|
180
|
+
"toast": f"Rate limit set to {body.requests_per_minute}/min "
|
|
181
|
+
f"(burst {body.burst_size}) — example only, nothing was saved",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@page.form(
|
|
186
|
+
"Rename Environment",
|
|
187
|
+
method="patch",
|
|
188
|
+
description="Rename an existing deployment environment",
|
|
189
|
+
icon="pencil",
|
|
190
|
+
color="orange",
|
|
191
|
+
)
|
|
192
|
+
async def rename_environment(
|
|
193
|
+
environment_id: int = Query(..., description="Environment to rename"),
|
|
194
|
+
new_name: str = Body(..., embed=True, description="New environment name"),
|
|
195
|
+
) -> str:
|
|
196
|
+
return (
|
|
197
|
+
f"Would rename environment #{environment_id} to '{new_name}' "
|
|
198
|
+
"(example only, nothing was renamed)"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@page.form(
|
|
203
|
+
"Schedule Config Reset",
|
|
204
|
+
method="delete",
|
|
205
|
+
description="Queue a configuration reset for the next maintenance window",
|
|
206
|
+
icon="rotate-ccw",
|
|
207
|
+
color="rose",
|
|
208
|
+
is_hidden=False,
|
|
209
|
+
)
|
|
210
|
+
async def schedule_config_reset(
|
|
211
|
+
config_id: int = Query(..., description="Config entry to reset"),
|
|
212
|
+
) -> spec.Form:
|
|
213
|
+
return None
|
|
@@ -56,7 +56,7 @@ async def get_all_genres(session: AsyncSessionDep, pagination: PageDep):
|
|
|
56
56
|
.outerjoin(models.BookToGenre, models.BookToGenre.genre_id == models.Genre.id)
|
|
57
57
|
.group_by(models.Genre.id)
|
|
58
58
|
.order_by(func.count(models.BookToGenre.book_id).desc())
|
|
59
|
-
.offset(pagination.page * pagination.per_page)
|
|
59
|
+
.offset((pagination.page - 1) * pagination.per_page)
|
|
60
60
|
.limit(pagination.per_page)
|
|
61
61
|
)
|
|
62
62
|
result = await session.execute(stmt)
|
|
@@ -74,7 +74,7 @@ async def get_all_genres(session: AsyncSessionDep, pagination: PageDep):
|
|
|
74
74
|
@page.pie_chart(
|
|
75
75
|
"Genre Distribution 1",
|
|
76
76
|
description="Share of books across all genres",
|
|
77
|
-
name_key="
|
|
77
|
+
name_key="genre",
|
|
78
78
|
value_key="count",
|
|
79
79
|
)
|
|
80
80
|
async def get_genre_distribution_1(session: AsyncSessionDep):
|
|
@@ -85,7 +85,7 @@ async def get_genre_distribution_1(session: AsyncSessionDep):
|
|
|
85
85
|
.order_by(func.count(models.BookToGenre.book_id).desc())
|
|
86
86
|
)
|
|
87
87
|
result = await session.execute(stmt)
|
|
88
|
-
return [{
|
|
88
|
+
return [{"genre": g.name, "count": count} for g, count in result.all()]
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
@page.pie_chart("Genre Distribution 2", description="Share of books across all genres")
|
|
@@ -39,41 +39,6 @@ async def get_active_connections() -> int:
|
|
|
39
39
|
return 12
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
@page.line_chart(
|
|
43
|
-
"Response Time (last 24h)",
|
|
44
|
-
description="Simulated p95 latency over the past 24 hours",
|
|
45
|
-
)
|
|
46
|
-
async def get_response_time_chart():
|
|
47
|
-
hours = [f"{h:02d}:00" for h in range(24)]
|
|
48
|
-
values = [
|
|
49
|
-
72,
|
|
50
|
-
68,
|
|
51
|
-
65,
|
|
52
|
-
63,
|
|
53
|
-
61,
|
|
54
|
-
60,
|
|
55
|
-
66,
|
|
56
|
-
78,
|
|
57
|
-
95,
|
|
58
|
-
110,
|
|
59
|
-
105,
|
|
60
|
-
98,
|
|
61
|
-
101,
|
|
62
|
-
99,
|
|
63
|
-
97,
|
|
64
|
-
102,
|
|
65
|
-
108,
|
|
66
|
-
115,
|
|
67
|
-
112,
|
|
68
|
-
104,
|
|
69
|
-
98,
|
|
70
|
-
91,
|
|
71
|
-
84,
|
|
72
|
-
79,
|
|
73
|
-
]
|
|
74
|
-
return [{"label": label, "value": value} for label, value in zip(hours, values)]
|
|
75
|
-
|
|
76
|
-
|
|
77
42
|
@page.markdown("Health Status")
|
|
78
43
|
async def get_health_status() -> str:
|
|
79
44
|
return """# System Health
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
from sqlalchemy import func, select, text
|
|
6
6
|
|
|
7
|
+
from openadmin import spec
|
|
7
8
|
from openadmin.fastapi import AdminPage
|
|
8
9
|
|
|
9
10
|
from ..lib import models
|
|
@@ -78,10 +79,14 @@ Foreign keys are enforced at the application layer.
|
|
|
78
79
|
"""
|
|
79
80
|
|
|
80
81
|
|
|
81
|
-
@page.
|
|
82
|
-
|
|
82
|
+
@page.action(
|
|
83
|
+
"Ping Database",
|
|
84
|
+
method="get",
|
|
85
|
+
description="Verify the database connection is alive",
|
|
86
|
+
)
|
|
87
|
+
async def ping_database(session: AsyncSessionDep) -> spec.Action:
|
|
83
88
|
await session.execute(text("SELECT 1"))
|
|
84
|
-
return {"
|
|
89
|
+
return {"message": "Database is reachable"}
|
|
85
90
|
|
|
86
91
|
|
|
87
92
|
async def _all_table_counts(session: AsyncSessionDep) -> list[tuple[str, int]]:
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
from pydantic import BaseModel
|
|
6
6
|
from sqlalchemy import func, select
|
|
7
7
|
|
|
8
|
+
from openadmin import spec
|
|
8
9
|
from openadmin.fastapi import AdminPage
|
|
9
10
|
from openadmin.fastapi.deps import PageDep, SearchQueryDep
|
|
10
11
|
|
|
@@ -42,7 +43,12 @@ async def get_books_without_publisher(session: AsyncSessionDep) -> int:
|
|
|
42
43
|
return result.scalar_one()
|
|
43
44
|
|
|
44
45
|
|
|
45
|
-
@page.table(
|
|
46
|
+
@page.table(
|
|
47
|
+
"All Publishers",
|
|
48
|
+
description="Browse publishers with their book counts",
|
|
49
|
+
icon="building-2",
|
|
50
|
+
color="amber",
|
|
51
|
+
)
|
|
46
52
|
async def get_all_publishers(
|
|
47
53
|
session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
|
|
48
54
|
):
|
|
@@ -51,7 +57,7 @@ async def get_all_publishers(
|
|
|
51
57
|
.outerjoin(models.Book, models.Book.publisher_id == models.Publisher.id)
|
|
52
58
|
.group_by(models.Publisher.id)
|
|
53
59
|
.order_by(func.count(models.Book.id).desc())
|
|
54
|
-
.offset(pagination.page * pagination.per_page)
|
|
60
|
+
.offset((pagination.page - 1) * pagination.per_page)
|
|
55
61
|
.limit(pagination.per_page)
|
|
56
62
|
)
|
|
57
63
|
if search:
|
|
@@ -81,10 +87,13 @@ class AddPublisherBody(BaseModel):
|
|
|
81
87
|
country: str | None = None
|
|
82
88
|
|
|
83
89
|
|
|
84
|
-
@page.
|
|
85
|
-
async def add_publisher(body: AddPublisherBody, session: AsyncSessionDep):
|
|
90
|
+
@page.form("Add Publisher", description="Register a new publisher")
|
|
91
|
+
async def add_publisher(body: AddPublisherBody, session: AsyncSessionDep) -> spec.Form:
|
|
86
92
|
publisher = models.Publisher(**body.model_dump())
|
|
87
93
|
session.add(publisher)
|
|
88
94
|
await session.commit()
|
|
89
95
|
await session.refresh(publisher)
|
|
90
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
"message": f"Added publisher '{publisher.name}'",
|
|
98
|
+
"table": {"id": publisher.id, "name": publisher.name},
|
|
99
|
+
}
|
|
@@ -47,7 +47,10 @@ async def get_heavily_tagged_books(session: AsyncSessionDep) -> int:
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
@page.table(
|
|
50
|
-
"Most Tagged Books",
|
|
50
|
+
"Most Tagged Books",
|
|
51
|
+
description="Books with the highest number of tag assignments",
|
|
52
|
+
icon="tags",
|
|
53
|
+
color="orange",
|
|
51
54
|
)
|
|
52
55
|
async def get_most_tagged_books(session: AsyncSessionDep, pagination: PageDep):
|
|
53
56
|
stmt = (
|
|
@@ -59,7 +62,7 @@ async def get_most_tagged_books(session: AsyncSessionDep, pagination: PageDep):
|
|
|
59
62
|
.join(models.BookToTag, models.BookToTag.book_id == models.Book.id)
|
|
60
63
|
.group_by(models.Book.id)
|
|
61
64
|
.order_by(func.count(models.BookToTag.tag_id).desc())
|
|
62
|
-
.offset(pagination.page * pagination.per_page)
|
|
65
|
+
.offset((pagination.page - 1) * pagination.per_page)
|
|
63
66
|
.limit(pagination.per_page)
|
|
64
67
|
)
|
|
65
68
|
result = await session.execute(stmt)
|
|
@@ -107,39 +110,3 @@ async def get_tag_count_distribution(session: AsyncSessionDep):
|
|
|
107
110
|
).scalar_one()
|
|
108
111
|
rows.append({"label": label, "value": count})
|
|
109
112
|
return rows
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
@page.area_chart(
|
|
113
|
-
"Catalog Coverage", description="Books with genre, tag, and summary metadata"
|
|
114
|
-
)
|
|
115
|
-
async def get_catalog_coverage(session: AsyncSessionDep):
|
|
116
|
-
total = (await session.execute(select(func.count(models.Book.id)))).scalar_one()
|
|
117
|
-
with_genre = (
|
|
118
|
-
await session.execute(
|
|
119
|
-
select(func.count(func.distinct(models.BookToGenre.book_id)))
|
|
120
|
-
)
|
|
121
|
-
).scalar_one()
|
|
122
|
-
with_tag = (
|
|
123
|
-
await session.execute(
|
|
124
|
-
select(func.count(func.distinct(models.BookToTag.book_id)))
|
|
125
|
-
)
|
|
126
|
-
).scalar_one()
|
|
127
|
-
with_summary = (
|
|
128
|
-
await session.execute(
|
|
129
|
-
select(func.count(models.Book.id)).where(models.Book.summary.isnot(None))
|
|
130
|
-
)
|
|
131
|
-
).scalar_one()
|
|
132
|
-
with_publisher = (
|
|
133
|
-
await session.execute(
|
|
134
|
-
select(func.count(models.Book.id)).where(
|
|
135
|
-
models.Book.publisher_id.isnot(None)
|
|
136
|
-
)
|
|
137
|
-
)
|
|
138
|
-
).scalar_one()
|
|
139
|
-
return [
|
|
140
|
-
{"label": "Total Books", "value": total},
|
|
141
|
-
{"label": "Has Genre", "value": with_genre},
|
|
142
|
-
{"label": "Has Tag", "value": with_tag},
|
|
143
|
-
{"label": "Has Summary", "value": with_summary},
|
|
144
|
-
{"label": "Has Publisher", "value": with_publisher},
|
|
145
|
-
]
|