openadmin-dev 0.3.1__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.
Files changed (82) hide show
  1. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/PKG-INFO +1 -1
  2. openadmin_dev-0.4.0/examples/admin/__init__.py +0 -0
  3. openadmin_dev-0.4.0/examples/admin/analytics.py +119 -0
  4. openadmin_dev-0.4.0/examples/admin/authors.py +103 -0
  5. openadmin_dev-0.4.0/examples/admin/books.py +90 -0
  6. openadmin_dev-0.4.0/examples/admin/genres.py +81 -0
  7. openadmin_dev-0.4.0/examples/admin/health.py +74 -0
  8. openadmin_dev-0.4.0/examples/admin/overview.py +99 -0
  9. openadmin_dev-0.4.0/examples/admin/publishers.py +86 -0
  10. openadmin_dev-0.4.0/examples/admin/reports.py +143 -0
  11. openadmin_dev-0.4.0/examples/admin/tags.py +86 -0
  12. openadmin_dev-0.4.0/examples/lib/__init__.py +0 -0
  13. openadmin_dev-0.4.0/examples/lib/database.py +55 -0
  14. openadmin_dev-0.4.0/examples/lib/lifespan.py +15 -0
  15. openadmin_dev-0.4.0/examples/lib/models.py +91 -0
  16. openadmin_dev-0.4.0/examples/lib/seed.py +118 -0
  17. openadmin_dev-0.4.0/examples/main.py +31 -0
  18. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/__init__.py +2 -4
  19. openadmin_dev-0.4.0/openadmin/fastapi/deps.py +26 -0
  20. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/__init__.py +2 -0
  21. openadmin_dev-0.4.0/openadmin/fastapi/types/pagination_params.py +10 -0
  22. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/utils.py +17 -0
  23. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/pyproject.toml +1 -1
  24. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/uv.lock +1 -1
  25. openadmin_dev-0.3.1/examples/health.py +0 -17
  26. openadmin_dev-0.3.1/examples/main.py +0 -31
  27. openadmin_dev-0.3.1/examples/users.py +0 -46
  28. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/ISSUE_TEMPLATE/bug.md +0 -0
  29. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/ISSUE_TEMPLATE/chore.md +0 -0
  30. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/ISSUE_TEMPLATE/feature.md +0 -0
  31. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/branch-name/action.yml +0 -0
  32. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/dependency-cves/action.yml +0 -0
  33. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/format/action.yml +0 -0
  34. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/license/action.yml +0 -0
  35. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/lint/action.yml +0 -0
  36. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/security/action.yml +0 -0
  37. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/spell-check/action.yml +0 -0
  38. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/test/action.yml +0 -0
  39. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/typecheck/action.yml +0 -0
  40. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/actions/unused-code/action.yml +0 -0
  41. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/workflows/ci.yml +0 -0
  42. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.github/workflows/publish.yml +0 -0
  43. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.gitignore +0 -0
  44. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/.python-version +0 -0
  45. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/LICENSE +0 -0
  46. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/LICENSES/AGPL-3.0-or-later.txt +0 -0
  47. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/Makefile +0 -0
  48. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/README.md +0 -0
  49. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/REUSE.toml +0 -0
  50. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/docs/assets/logo.png +0 -0
  51. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/examples/__init__.py +0 -0
  52. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/admin_page.py +0 -0
  53. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/admin_panel.py +0 -0
  54. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/action.py +0 -0
  55. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/area_chart.py +0 -0
  56. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/bar_chart.py +0 -0
  57. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/form.py +0 -0
  58. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/line_chart.py +0 -0
  59. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/markdown.py +0 -0
  60. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/page_protocol.py +0 -0
  61. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/pie_chart.py +0 -0
  62. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/section.py +0 -0
  63. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/stat.py +0 -0
  64. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/fastapi/types/table.py +0 -0
  65. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/__init__.py +0 -0
  66. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/__init__.py +0 -0
  67. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/action.py +0 -0
  68. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/area_chart.py +0 -0
  69. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/bar_chart.py +0 -0
  70. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/form.py +0 -0
  71. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/http_methods.py +0 -0
  72. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/line_chart.py +0 -0
  73. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/markdown.py +0 -0
  74. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/pie_chart.py +0 -0
  75. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/property.py +0 -0
  76. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/property_type.py +0 -0
  77. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/stat.py +0 -0
  78. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/components/table.py +0 -0
  79. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/page.py +0 -0
  80. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/section.py +0 -0
  81. {openadmin_dev-0.3.1 → openadmin_dev-0.4.0}/openadmin/spec/spec.py +0 -0
  82. {openadmin_dev-0.3.1 → 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.1
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
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}