openadmin-dev 0.3.0__tar.gz → 0.4.0__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.3.0 → openadmin_dev-0.4.0}/PKG-INFO +2 -2
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/README.md +1 -1
- openadmin_dev-0.4.0/examples/admin/__init__.py +0 -0
- openadmin_dev-0.4.0/examples/admin/analytics.py +119 -0
- openadmin_dev-0.4.0/examples/admin/authors.py +103 -0
- openadmin_dev-0.4.0/examples/admin/books.py +90 -0
- openadmin_dev-0.4.0/examples/admin/genres.py +81 -0
- openadmin_dev-0.4.0/examples/admin/health.py +74 -0
- openadmin_dev-0.4.0/examples/admin/overview.py +99 -0
- openadmin_dev-0.4.0/examples/admin/publishers.py +86 -0
- openadmin_dev-0.4.0/examples/admin/reports.py +143 -0
- openadmin_dev-0.4.0/examples/admin/tags.py +86 -0
- openadmin_dev-0.4.0/examples/lib/__init__.py +0 -0
- openadmin_dev-0.4.0/examples/lib/database.py +55 -0
- openadmin_dev-0.4.0/examples/lib/lifespan.py +15 -0
- openadmin_dev-0.4.0/examples/lib/models.py +91 -0
- openadmin_dev-0.4.0/examples/lib/seed.py +118 -0
- openadmin_dev-0.4.0/examples/main.py +31 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/__init__.py +2 -4
- openadmin_dev-0.4.0/openadmin/fastapi/deps.py +26 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/__init__.py +2 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/action.py +4 -6
- openadmin_dev-0.4.0/openadmin/fastapi/types/area_chart.py +17 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/bar_chart.py +4 -6
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/form.py +4 -6
- openadmin_dev-0.4.0/openadmin/fastapi/types/line_chart.py +17 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/markdown.py +4 -6
- openadmin_dev-0.4.0/openadmin/fastapi/types/pagination_params.py +10 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/pie_chart.py +4 -6
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/stat.py +4 -6
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/table.py +4 -6
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/utils.py +17 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/pyproject.toml +1 -1
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/uv.lock +1 -1
- openadmin_dev-0.3.0/examples/health.py +0 -17
- openadmin_dev-0.3.0/examples/main.py +0 -31
- openadmin_dev-0.3.0/examples/users.py +0 -46
- openadmin_dev-0.3.0/openadmin/fastapi/types/area_chart.py +0 -19
- openadmin_dev-0.3.0/openadmin/fastapi/types/line_chart.py +0 -19
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/ISSUE_TEMPLATE/bug.md +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/ISSUE_TEMPLATE/chore.md +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/ISSUE_TEMPLATE/feature.md +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/branch-name/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/dependency-cves/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/format/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/license/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/lint/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/security/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/spell-check/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/test/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/typecheck/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/actions/unused-code/action.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/workflows/ci.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.github/workflows/publish.yml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.gitignore +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/.python-version +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/LICENSE +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/LICENSES/AGPL-3.0-or-later.txt +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/Makefile +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/REUSE.toml +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/docs/assets/logo.png +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/examples/__init__.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/admin_page.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/admin_panel.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/page_protocol.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/fastapi/types/section.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/__init__.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/__init__.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/action.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/area_chart.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/bar_chart.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/form.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/http_methods.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/line_chart.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/markdown.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/pie_chart.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/property.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/property_type.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/stat.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/components/table.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/page.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/section.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/openadmin/spec/spec.py +0 -0
- {openadmin_dev-0.3.0 → openadmin_dev-0.4.0}/tests/test_hello.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openadmin-dev
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -27,7 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
# 🚧 Project is under development 🚧
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
**OpenAdmin** is a FastAPI-native library for building admin dashboards. Define pages with typed decorators — no frontend code, no templates, no configuration files. Every page is just a router; every widget is just an endpoint
|
|
30
|
+
**OpenAdmin** is a FastAPI-native library for building admin dashboards. Define pages with typed decorators — no frontend code, no templates, no configuration files. Every page is just a router; every widget is just an endpoint
|
|
31
31
|
|
|
32
32
|
## Features
|
|
33
33
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# 🚧 Project is under development 🚧
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
**OpenAdmin** is a FastAPI-native library for building admin dashboards. Define pages with typed decorators — no frontend code, no templates, no configuration files. Every page is just a router; every widget is just an endpoint
|
|
15
|
+
**OpenAdmin** is a FastAPI-native library for building admin dashboards. Define pages with typed decorators — no frontend code, no templates, no configuration files. Every page is just a router; every widget is just an endpoint
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
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.fastapi import AdminPage
|
|
8
|
+
|
|
9
|
+
from ..lib import models
|
|
10
|
+
from ..lib.database import AsyncSessionDep
|
|
11
|
+
|
|
12
|
+
page = AdminPage(
|
|
13
|
+
"Analytics", description="Library-wide publication trends and insights"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@page.stat("Earliest Publication")
|
|
18
|
+
async def get_earliest_publication(session: AsyncSessionDep) -> int | str:
|
|
19
|
+
result = await session.execute(
|
|
20
|
+
select(func.min(models.Book.published_year)).where(
|
|
21
|
+
models.Book.published_year.isnot(None)
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
return result.scalar_one_or_none() or "N/A"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@page.stat("Latest Publication")
|
|
28
|
+
async def get_latest_publication(session: AsyncSessionDep) -> int | str:
|
|
29
|
+
result = await session.execute(
|
|
30
|
+
select(func.max(models.Book.published_year)).where(
|
|
31
|
+
models.Book.published_year.isnot(None)
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
return result.scalar_one_or_none() or "N/A"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@page.stat("Books with Summary")
|
|
38
|
+
async def get_books_with_summary(session: AsyncSessionDep) -> int:
|
|
39
|
+
result = await session.execute(
|
|
40
|
+
select(func.count(models.Book.id)).where(models.Book.summary.isnot(None))
|
|
41
|
+
)
|
|
42
|
+
return result.scalar_one()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@page.line_chart(
|
|
46
|
+
"Publications by Decade", description="Number of books published per decade"
|
|
47
|
+
)
|
|
48
|
+
async def get_publications_by_decade(session: AsyncSessionDep):
|
|
49
|
+
stmt = (
|
|
50
|
+
select(
|
|
51
|
+
(func.floor(models.Book.published_year / 10) * 10).label("decade"),
|
|
52
|
+
func.count(models.Book.id).label("count"),
|
|
53
|
+
)
|
|
54
|
+
.where(models.Book.published_year.isnot(None))
|
|
55
|
+
.group_by("decade")
|
|
56
|
+
.order_by("decade")
|
|
57
|
+
)
|
|
58
|
+
result = await session.execute(stmt)
|
|
59
|
+
return [{"label": f"{int(row.decade)}s", "value": row.count} for row in result]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@page.area_chart(
|
|
63
|
+
"Books Published by Era", description="Book volume across broad historical eras"
|
|
64
|
+
)
|
|
65
|
+
async def get_books_by_era(session: AsyncSessionDep):
|
|
66
|
+
eras: list[tuple[str, int | None, int | None]] = [
|
|
67
|
+
("Pre-1900", None, 1900),
|
|
68
|
+
("1900–1950", 1900, 1950),
|
|
69
|
+
("1950–1980", 1950, 1980),
|
|
70
|
+
("1980–2000", 1980, 2000),
|
|
71
|
+
("2000–2010", 2000, 2010),
|
|
72
|
+
("2010–2020", 2010, 2020),
|
|
73
|
+
("2020+", 2020, None),
|
|
74
|
+
]
|
|
75
|
+
rows = []
|
|
76
|
+
for label, start, end in eras:
|
|
77
|
+
stmt = select(func.count(models.Book.id)).where(
|
|
78
|
+
models.Book.published_year.isnot(None)
|
|
79
|
+
)
|
|
80
|
+
if start is not None:
|
|
81
|
+
stmt = stmt.where(models.Book.published_year >= start)
|
|
82
|
+
if end is not None:
|
|
83
|
+
stmt = stmt.where(models.Book.published_year < end)
|
|
84
|
+
count = (await session.execute(stmt)).scalar_one()
|
|
85
|
+
rows.append({"label": label, "value": count})
|
|
86
|
+
return rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@page.markdown("Library Overview")
|
|
90
|
+
async def get_library_overview(session: AsyncSessionDep) -> str:
|
|
91
|
+
total_books = (
|
|
92
|
+
await session.execute(select(func.count(models.Book.id)))
|
|
93
|
+
).scalar_one()
|
|
94
|
+
total_authors = (
|
|
95
|
+
await session.execute(select(func.count(models.Author.id)))
|
|
96
|
+
).scalar_one()
|
|
97
|
+
total_publishers = (
|
|
98
|
+
await session.execute(select(func.count(models.Publisher.id)))
|
|
99
|
+
).scalar_one()
|
|
100
|
+
total_genres = (
|
|
101
|
+
await session.execute(select(func.count(models.Genre.id)))
|
|
102
|
+
).scalar_one()
|
|
103
|
+
total_tags = (await session.execute(select(func.count(models.Tag.id)))).scalar_one()
|
|
104
|
+
|
|
105
|
+
return f"""# Library Overview
|
|
106
|
+
|
|
107
|
+
This catalog contains **{total_books:,} books** written by **{total_authors} authors**
|
|
108
|
+
and published by **{total_publishers} publishers**.
|
|
109
|
+
|
|
110
|
+
## Classification
|
|
111
|
+
|
|
112
|
+
The collection is organized across **{total_genres} genres** and annotated with **{total_tags} tags**,
|
|
113
|
+
enabling precise discovery and filtering across the full catalog.
|
|
114
|
+
|
|
115
|
+
## Coverage
|
|
116
|
+
|
|
117
|
+
Books span multiple centuries of publication history, from early historical works through
|
|
118
|
+
contemporary releases, with particular depth in the 20th and 21st century catalog.
|
|
119
|
+
"""
|
|
@@ -0,0 +1,103 @@
|
|
|
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.fastapi import AdminPage
|
|
9
|
+
from openadmin.fastapi.deps import PageDep, SearchQueryDep
|
|
10
|
+
|
|
11
|
+
from ..lib import models
|
|
12
|
+
from ..lib.database import AsyncSessionDep
|
|
13
|
+
|
|
14
|
+
page = AdminPage(
|
|
15
|
+
"Authors", description="Manage book authors and view productivity stats"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@page.stat("Total Authors")
|
|
20
|
+
async def get_total_authors(session: AsyncSessionDep) -> int:
|
|
21
|
+
result = await session.execute(select(func.count(models.Author.id)))
|
|
22
|
+
return result.scalar_one()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@page.stat("Authors with Bio")
|
|
26
|
+
async def get_authors_with_bio(session: AsyncSessionDep) -> int:
|
|
27
|
+
result = await session.execute(
|
|
28
|
+
select(func.count(models.Author.id)).where(models.Author.bio.isnot(None))
|
|
29
|
+
)
|
|
30
|
+
return result.scalar_one()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@page.stat("Avg Books per Author")
|
|
34
|
+
async def get_avg_books_per_author(session: AsyncSessionDep) -> float:
|
|
35
|
+
subq = (
|
|
36
|
+
select(func.count(models.Book.id).label("cnt"))
|
|
37
|
+
.group_by(models.Book.author_id)
|
|
38
|
+
.subquery()
|
|
39
|
+
)
|
|
40
|
+
result = await session.execute(select(func.avg(subq.c.cnt)))
|
|
41
|
+
return round(float(result.scalar_one() or 0), 2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@page.table("All Authors", description="Browse authors with search and book counts")
|
|
45
|
+
async def get_all_authors(
|
|
46
|
+
session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
|
|
47
|
+
):
|
|
48
|
+
stmt = (
|
|
49
|
+
select(models.Author, func.count(models.Book.id).label("book_count"))
|
|
50
|
+
.outerjoin(models.Book, models.Book.author_id == models.Author.id)
|
|
51
|
+
.group_by(models.Author.id)
|
|
52
|
+
.offset(pagination.page * pagination.per_page)
|
|
53
|
+
.limit(pagination.per_page)
|
|
54
|
+
)
|
|
55
|
+
if search:
|
|
56
|
+
full_name = models.Author.first_name + " " + models.Author.last_name
|
|
57
|
+
stmt = stmt.where(full_name.ilike(f"%{search}%"))
|
|
58
|
+
result = await session.execute(stmt)
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
"id": author.id,
|
|
62
|
+
"name": f"{author.first_name} {author.last_name}",
|
|
63
|
+
"bio": (author.bio[:80] + "...")
|
|
64
|
+
if author.bio and len(author.bio) > 80
|
|
65
|
+
else author.bio,
|
|
66
|
+
"book_count": count,
|
|
67
|
+
}
|
|
68
|
+
for author, count in result.all()
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@page.bar_chart(
|
|
73
|
+
"Top Authors by Book Count",
|
|
74
|
+
description="The 10 most prolific authors in the catalog",
|
|
75
|
+
)
|
|
76
|
+
async def get_top_authors(session: AsyncSessionDep):
|
|
77
|
+
stmt = (
|
|
78
|
+
select(
|
|
79
|
+
(models.Author.first_name + " " + models.Author.last_name).label("name"),
|
|
80
|
+
func.count(models.Book.id).label("count"),
|
|
81
|
+
)
|
|
82
|
+
.join(models.Book, models.Book.author_id == models.Author.id)
|
|
83
|
+
.group_by(models.Author.id)
|
|
84
|
+
.order_by(func.count(models.Book.id).desc())
|
|
85
|
+
.limit(10)
|
|
86
|
+
)
|
|
87
|
+
result = await session.execute(stmt)
|
|
88
|
+
return [{"label": row.name, "value": row.count} for row in result]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AddAuthorBody(BaseModel):
|
|
92
|
+
first_name: str
|
|
93
|
+
last_name: str
|
|
94
|
+
bio: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@page.form_post("Add Author", description="Register a new author in the catalog")
|
|
98
|
+
async def add_author(body: AddAuthorBody, session: AsyncSessionDep):
|
|
99
|
+
author = models.Author(**body.model_dump())
|
|
100
|
+
session.add(author)
|
|
101
|
+
await session.commit()
|
|
102
|
+
await session.refresh(author)
|
|
103
|
+
return {"id": author.id, "name": f"{author.first_name} {author.last_name}"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenAdmin
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from fastapi import Query
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
|
|
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("Books", description="Browse and manage the book catalog")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@page.stat("Total Books")
|
|
19
|
+
async def get_total_books(session: AsyncSessionDep) -> int:
|
|
20
|
+
result = await session.execute(select(func.count(models.Book.id)))
|
|
21
|
+
return result.scalar_one()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@page.stat("Published Books")
|
|
25
|
+
async def get_published_books(session: AsyncSessionDep) -> int:
|
|
26
|
+
result = await session.execute(
|
|
27
|
+
select(func.count(models.Book.id)).where(models.Book.published_year.isnot(None))
|
|
28
|
+
)
|
|
29
|
+
return result.scalar_one()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@page.stat("Books Without Publisher")
|
|
33
|
+
async def get_books_without_publisher(session: AsyncSessionDep) -> int:
|
|
34
|
+
result = await session.execute(
|
|
35
|
+
select(func.count(models.Book.id)).where(models.Book.publisher_id.is_(None))
|
|
36
|
+
)
|
|
37
|
+
return result.scalar_one()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@page.table("All Books", description="Browse all books with search and pagination")
|
|
41
|
+
async def get_all_books(
|
|
42
|
+
session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
|
|
43
|
+
):
|
|
44
|
+
stmt = (
|
|
45
|
+
select(models.Book, models.Author)
|
|
46
|
+
.join(models.Author, models.Author.id == models.Book.author_id)
|
|
47
|
+
.offset(pagination.page * pagination.per_page)
|
|
48
|
+
.limit(pagination.per_page)
|
|
49
|
+
)
|
|
50
|
+
if search:
|
|
51
|
+
stmt = stmt.where(models.Book.title.ilike(f"%{search}%"))
|
|
52
|
+
result = await session.execute(stmt)
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
"id": book.id,
|
|
56
|
+
"title": book.title,
|
|
57
|
+
"author": f"{author.first_name} {author.last_name}",
|
|
58
|
+
"published_year": book.published_year,
|
|
59
|
+
}
|
|
60
|
+
for book, author in result.all()
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AddBookBody(BaseModel):
|
|
65
|
+
title: str
|
|
66
|
+
author_id: int
|
|
67
|
+
published_year: int | None = None
|
|
68
|
+
summary: str | None = None
|
|
69
|
+
publisher_id: int | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@page.form_post("Add Book", description="Add a new book to the catalog")
|
|
73
|
+
async def add_book(body: AddBookBody, session: AsyncSessionDep):
|
|
74
|
+
book = models.Book(**body.model_dump())
|
|
75
|
+
session.add(book)
|
|
76
|
+
await session.commit()
|
|
77
|
+
await session.refresh(book)
|
|
78
|
+
return {"id": book.id, "title": book.title}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@page.action_delete("Delete Book", description="Remove a book by ID")
|
|
82
|
+
async def delete_book(
|
|
83
|
+
session: AsyncSessionDep,
|
|
84
|
+
book_id: int = Query(..., description="Book ID to delete"),
|
|
85
|
+
):
|
|
86
|
+
book = await session.get(models.Book, book_id)
|
|
87
|
+
if book:
|
|
88
|
+
await session.delete(book)
|
|
89
|
+
await session.commit()
|
|
90
|
+
return {"deleted": book_id, "found": book is not None}
|
|
@@ -0,0 +1,81 @@
|
|
|
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.fastapi import AdminPage
|
|
8
|
+
from openadmin.fastapi.deps import PageDep
|
|
9
|
+
|
|
10
|
+
from ..lib import models
|
|
11
|
+
from ..lib.database import AsyncSessionDep
|
|
12
|
+
|
|
13
|
+
page = AdminPage("Genres", description="Browse and analyze the genre taxonomy")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@page.stat("Total Genres")
|
|
17
|
+
async def get_total_genres(session: AsyncSessionDep) -> int:
|
|
18
|
+
result = await session.execute(select(func.count(models.Genre.id)))
|
|
19
|
+
return result.scalar_one()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@page.stat("Most Popular Genre")
|
|
23
|
+
async def get_most_popular_genre(session: AsyncSessionDep) -> str:
|
|
24
|
+
stmt = (
|
|
25
|
+
select(models.Genre.name)
|
|
26
|
+
.join(models.BookToGenre, models.BookToGenre.genre_id == models.Genre.id)
|
|
27
|
+
.group_by(models.Genre.id)
|
|
28
|
+
.order_by(func.count(models.BookToGenre.book_id).desc())
|
|
29
|
+
.limit(1)
|
|
30
|
+
)
|
|
31
|
+
result = await session.execute(stmt)
|
|
32
|
+
return result.scalar_one_or_none() or "N/A"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@page.stat("Books Without Genre")
|
|
36
|
+
async def get_books_without_genre(session: AsyncSessionDep) -> int:
|
|
37
|
+
subq = select(models.BookToGenre.book_id)
|
|
38
|
+
result = await session.execute(
|
|
39
|
+
select(func.count(models.Book.id)).where(models.Book.id.not_in(subq))
|
|
40
|
+
)
|
|
41
|
+
return result.scalar_one()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@page.table("All Genres", description="All genres sorted by book count")
|
|
45
|
+
async def get_all_genres(session: AsyncSessionDep, pagination: PageDep):
|
|
46
|
+
stmt = (
|
|
47
|
+
select(models.Genre, func.count(models.BookToGenre.book_id).label("book_count"))
|
|
48
|
+
.outerjoin(models.BookToGenre, models.BookToGenre.genre_id == models.Genre.id)
|
|
49
|
+
.group_by(models.Genre.id)
|
|
50
|
+
.order_by(func.count(models.BookToGenre.book_id).desc())
|
|
51
|
+
.offset(pagination.page * pagination.per_page)
|
|
52
|
+
.limit(pagination.per_page)
|
|
53
|
+
)
|
|
54
|
+
result = await session.execute(stmt)
|
|
55
|
+
return [
|
|
56
|
+
{"id": g.id, "name": g.name, "book_count": count} for g, count in result.all()
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@page.pie_chart("Genre Distribution", description="Share of books across all genres")
|
|
61
|
+
async def get_genre_distribution(session: AsyncSessionDep):
|
|
62
|
+
stmt = (
|
|
63
|
+
select(models.Genre.name, func.count(models.BookToGenre.book_id).label("count"))
|
|
64
|
+
.join(models.BookToGenre, models.BookToGenre.genre_id == models.Genre.id)
|
|
65
|
+
.group_by(models.Genre.id)
|
|
66
|
+
.order_by(func.count(models.BookToGenre.book_id).desc())
|
|
67
|
+
)
|
|
68
|
+
result = await session.execute(stmt)
|
|
69
|
+
return [{"label": row.name, "value": row.count} for row in result]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@page.bar_chart("Books per Genre", description="Absolute book count for each genre")
|
|
73
|
+
async def get_books_per_genre(session: AsyncSessionDep):
|
|
74
|
+
stmt = (
|
|
75
|
+
select(models.Genre.name, func.count(models.BookToGenre.book_id).label("count"))
|
|
76
|
+
.join(models.BookToGenre, models.BookToGenre.genre_id == models.Genre.id)
|
|
77
|
+
.group_by(models.Genre.id)
|
|
78
|
+
.order_by(models.Genre.name)
|
|
79
|
+
)
|
|
80
|
+
result = await session.execute(stmt)
|
|
81
|
+
return [{"label": row.name, "value": row.count} for row in result]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenAdmin
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from openadmin.fastapi import AdminPage
|
|
6
|
+
|
|
7
|
+
page = AdminPage("Health", description="System health and infrastructure metrics")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@page.stat("API Uptime (%)")
|
|
11
|
+
async def get_api_uptime() -> float:
|
|
12
|
+
return 99.97
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@page.stat("Avg Response Time (ms)")
|
|
16
|
+
async def get_avg_response_time() -> int:
|
|
17
|
+
return 84
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@page.stat("Active Connections")
|
|
21
|
+
async def get_active_connections() -> int:
|
|
22
|
+
return 12
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@page.line_chart(
|
|
26
|
+
"Response Time (last 24h)",
|
|
27
|
+
description="Simulated p95 latency over the past 24 hours",
|
|
28
|
+
)
|
|
29
|
+
async def get_response_time_chart():
|
|
30
|
+
hours = [f"{h:02d}:00" for h in range(24)]
|
|
31
|
+
values = [
|
|
32
|
+
72,
|
|
33
|
+
68,
|
|
34
|
+
65,
|
|
35
|
+
63,
|
|
36
|
+
61,
|
|
37
|
+
60,
|
|
38
|
+
66,
|
|
39
|
+
78,
|
|
40
|
+
95,
|
|
41
|
+
110,
|
|
42
|
+
105,
|
|
43
|
+
98,
|
|
44
|
+
101,
|
|
45
|
+
99,
|
|
46
|
+
97,
|
|
47
|
+
102,
|
|
48
|
+
108,
|
|
49
|
+
115,
|
|
50
|
+
112,
|
|
51
|
+
104,
|
|
52
|
+
98,
|
|
53
|
+
91,
|
|
54
|
+
84,
|
|
55
|
+
79,
|
|
56
|
+
]
|
|
57
|
+
return [{"label": label, "value": value} for label, value in zip(hours, values)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@page.markdown("Health Status")
|
|
61
|
+
async def get_health_status() -> str:
|
|
62
|
+
return """# System Health
|
|
63
|
+
|
|
64
|
+
All services are operating normally.
|
|
65
|
+
|
|
66
|
+
| Service | Status | Latency |
|
|
67
|
+
|----------------|---------|---------|
|
|
68
|
+
| API | ✅ Up | 84 ms |
|
|
69
|
+
| Database | ✅ Up | 3 ms |
|
|
70
|
+
| Cache | ✅ Up | 1 ms |
|
|
71
|
+
| Queue | ✅ Up | 12 ms |
|
|
72
|
+
|
|
73
|
+
Last checked: just now.
|
|
74
|
+
"""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenAdmin
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import func, select, text
|
|
6
|
+
|
|
7
|
+
from openadmin.fastapi import AdminPage
|
|
8
|
+
|
|
9
|
+
from ..lib import models
|
|
10
|
+
from ..lib.database import AsyncSessionDep
|
|
11
|
+
|
|
12
|
+
page = AdminPage("Overview", description="High-level database and catalog statistics")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@page.stat("Total Records")
|
|
16
|
+
async def get_total_records(session: AsyncSessionDep) -> int:
|
|
17
|
+
counts = await _all_table_counts(session)
|
|
18
|
+
return sum(c for _, c in counts)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@page.stat("Schema Tables")
|
|
22
|
+
async def get_schema_tables() -> int:
|
|
23
|
+
return 7 # Book, Author, Publisher, Genre, Tag, BookToGenre, BookToTag
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@page.stat("Catalog Completeness (%)")
|
|
27
|
+
async def get_catalog_completeness(session: AsyncSessionDep) -> float:
|
|
28
|
+
total = (await session.execute(select(func.count(models.Book.id)))).scalar_one()
|
|
29
|
+
if not total:
|
|
30
|
+
return 0.0
|
|
31
|
+
complete = (
|
|
32
|
+
await session.execute(
|
|
33
|
+
select(func.count(models.Book.id)).where(
|
|
34
|
+
models.Book.summary.isnot(None),
|
|
35
|
+
models.Book.published_year.isnot(None),
|
|
36
|
+
models.Book.publisher_id.isnot(None),
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
).scalar_one()
|
|
40
|
+
return round(complete / total * 100, 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@page.bar_chart(
|
|
44
|
+
"Records per Table", description="Row count for every table in the schema"
|
|
45
|
+
)
|
|
46
|
+
async def get_records_per_table(session: AsyncSessionDep):
|
|
47
|
+
counts = await _all_table_counts(session)
|
|
48
|
+
return [{"label": label, "value": count} for label, count in counts]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@page.markdown("Schema Reference")
|
|
52
|
+
async def get_schema_reference() -> str:
|
|
53
|
+
return """# Database Schema
|
|
54
|
+
|
|
55
|
+
## Core tables
|
|
56
|
+
|
|
57
|
+
| Table | Description |
|
|
58
|
+
|-----------------|------------------------------------------------|
|
|
59
|
+
| `book` | Central entity — title, year, summary |
|
|
60
|
+
| `author` | Author first/last name and optional bio |
|
|
61
|
+
| `publisher` | Publisher name and country |
|
|
62
|
+
| `genre` | Genre name (unique) |
|
|
63
|
+
| `tag` | Tag name (unique) |
|
|
64
|
+
|
|
65
|
+
## Association tables
|
|
66
|
+
|
|
67
|
+
| Table | Links |
|
|
68
|
+
|-----------------|------------------------------------------------|
|
|
69
|
+
| `book_to_genre` | Many-to-many: book ↔ genre |
|
|
70
|
+
| `book_to_tag` | Many-to-many: book ↔ tag, with `added_at` |
|
|
71
|
+
|
|
72
|
+
All primary keys are auto-incrementing integers.
|
|
73
|
+
Foreign keys are enforced at the application layer.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@page.action_get("Ping Database", description="Verify the database connection is alive")
|
|
78
|
+
async def ping_database(session: AsyncSessionDep):
|
|
79
|
+
await session.execute(text("SELECT 1"))
|
|
80
|
+
return {"status": "ok", "message": "Database is reachable"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _all_table_counts(session: AsyncSessionDep) -> list[tuple[str, int]]:
|
|
84
|
+
tables: list[tuple[str, type]] = [
|
|
85
|
+
("book", models.Book),
|
|
86
|
+
("author", models.Author),
|
|
87
|
+
("publisher", models.Publisher),
|
|
88
|
+
("genre", models.Genre),
|
|
89
|
+
("tag", models.Tag),
|
|
90
|
+
("book_to_genre", models.BookToGenre),
|
|
91
|
+
("book_to_tag", models.BookToTag),
|
|
92
|
+
]
|
|
93
|
+
results = []
|
|
94
|
+
for label, model in tables:
|
|
95
|
+
count = (
|
|
96
|
+
await session.execute(select(func.count()).select_from(model))
|
|
97
|
+
).scalar_one()
|
|
98
|
+
results.append((label, count))
|
|
99
|
+
return results
|
|
@@ -0,0 +1,86 @@
|
|
|
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.fastapi import AdminPage
|
|
9
|
+
from openadmin.fastapi.deps import PageDep, SearchQueryDep
|
|
10
|
+
|
|
11
|
+
from ..lib import models
|
|
12
|
+
from ..lib.database import AsyncSessionDep
|
|
13
|
+
|
|
14
|
+
page = AdminPage("Publishers", description="Manage publishers and their catalog share")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@page.stat("Total Publishers")
|
|
18
|
+
async def get_total_publishers(session: AsyncSessionDep) -> int:
|
|
19
|
+
result = await session.execute(select(func.count(models.Publisher.id)))
|
|
20
|
+
return result.scalar_one()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@page.stat("Countries Represented")
|
|
24
|
+
async def get_countries_represented(session: AsyncSessionDep) -> int:
|
|
25
|
+
result = await session.execute(
|
|
26
|
+
select(func.count(func.distinct(models.Publisher.country))).where(
|
|
27
|
+
models.Publisher.country.isnot(None)
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
return result.scalar_one()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@page.stat("Books Without Publisher")
|
|
34
|
+
async def get_books_without_publisher(session: AsyncSessionDep) -> int:
|
|
35
|
+
result = await session.execute(
|
|
36
|
+
select(func.count(models.Book.id)).where(models.Book.publisher_id.is_(None))
|
|
37
|
+
)
|
|
38
|
+
return result.scalar_one()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@page.table("All Publishers", description="Browse publishers with their book counts")
|
|
42
|
+
async def get_all_publishers(
|
|
43
|
+
session: AsyncSessionDep, pagination: PageDep, search: SearchQueryDep
|
|
44
|
+
):
|
|
45
|
+
stmt = (
|
|
46
|
+
select(models.Publisher, func.count(models.Book.id).label("book_count"))
|
|
47
|
+
.outerjoin(models.Book, models.Book.publisher_id == models.Publisher.id)
|
|
48
|
+
.group_by(models.Publisher.id)
|
|
49
|
+
.order_by(func.count(models.Book.id).desc())
|
|
50
|
+
.offset(pagination.page * pagination.per_page)
|
|
51
|
+
.limit(pagination.per_page)
|
|
52
|
+
)
|
|
53
|
+
if search:
|
|
54
|
+
stmt = stmt.where(models.Publisher.name.ilike(f"%{search}%"))
|
|
55
|
+
result = await session.execute(stmt)
|
|
56
|
+
return [
|
|
57
|
+
{"id": pub.id, "name": pub.name, "country": pub.country, "book_count": count}
|
|
58
|
+
for pub, count in result.all()
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@page.pie_chart("Books by Publisher", description="Top 10 publishers by book count")
|
|
63
|
+
async def get_books_by_publisher(session: AsyncSessionDep):
|
|
64
|
+
stmt = (
|
|
65
|
+
select(models.Publisher.name, func.count(models.Book.id).label("count"))
|
|
66
|
+
.join(models.Book, models.Book.publisher_id == models.Publisher.id)
|
|
67
|
+
.group_by(models.Publisher.id)
|
|
68
|
+
.order_by(func.count(models.Book.id).desc())
|
|
69
|
+
.limit(10)
|
|
70
|
+
)
|
|
71
|
+
result = await session.execute(stmt)
|
|
72
|
+
return [{"label": row.name, "value": row.count} for row in result]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AddPublisherBody(BaseModel):
|
|
76
|
+
name: str
|
|
77
|
+
country: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@page.form_post("Add Publisher", description="Register a new publisher")
|
|
81
|
+
async def add_publisher(body: AddPublisherBody, session: AsyncSessionDep):
|
|
82
|
+
publisher = models.Publisher(**body.model_dump())
|
|
83
|
+
session.add(publisher)
|
|
84
|
+
await session.commit()
|
|
85
|
+
await session.refresh(publisher)
|
|
86
|
+
return {"id": publisher.id, "name": publisher.name}
|