openadmin-dev 0.7.6__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.
Files changed (95) hide show
  1. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/PKG-INFO +1 -1
  2. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/analytics.py +0 -44
  3. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/authors.py +29 -8
  4. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/books.py +30 -10
  5. openadmin_dev-0.7.10/examples/admin/control_panel.py +213 -0
  6. openadmin_dev-0.7.10/examples/admin/docs.py +39 -0
  7. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/genres.py +3 -3
  8. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/health.py +0 -35
  9. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/overview.py +8 -3
  10. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/publishers.py +14 -5
  11. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/reports.py +5 -38
  12. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/tags.py +20 -6
  13. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/weekdays.py +1 -1
  14. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/main.py +14 -2
  15. openadmin_dev-0.7.10/openadmin/fastapi/admin_page.py +572 -0
  16. openadmin_dev-0.7.10/openadmin/fastapi/admin_panel.py +71 -0
  17. openadmin_dev-0.7.10/openadmin/fastapi/counter.py +19 -0
  18. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/fastapi/deps.py +2 -2
  19. openadmin_dev-0.7.10/openadmin/fastapi/exc_handler.py +29 -0
  20. openadmin_dev-0.7.10/openadmin/fastapi/field_config.py +15 -0
  21. openadmin_dev-0.7.10/openadmin/fastapi/utils.py +102 -0
  22. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/__init__.py +15 -8
  23. openadmin_dev-0.7.10/openadmin/spec/action.py +35 -0
  24. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/area_chart.py +9 -7
  25. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/bar_chart.py +5 -6
  26. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/component.py +8 -8
  27. openadmin_dev-0.7.10/openadmin/spec/error.py +9 -0
  28. openadmin_dev-0.7.10/openadmin/spec/form.py +43 -0
  29. openadmin_dev-0.7.10/openadmin/spec/json_schema.py +49 -0
  30. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/line_chart.py +9 -7
  31. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/markdown.py +5 -6
  32. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/page.py +2 -2
  33. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/pie_chart.py +5 -6
  34. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/section.py +2 -2
  35. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/spec.py +4 -3
  36. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/stat.py +5 -6
  37. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/table.py +6 -9
  38. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/pyproject.toml +2 -2
  39. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/uv.lock +1 -1
  40. openadmin_dev-0.7.6/openadmin/fastapi/action.py +0 -19
  41. openadmin_dev-0.7.6/openadmin/fastapi/admin_page.py +0 -651
  42. openadmin_dev-0.7.6/openadmin/fastapi/admin_panel.py +0 -93
  43. openadmin_dev-0.7.6/openadmin/fastapi/area_chart.py +0 -18
  44. openadmin_dev-0.7.6/openadmin/fastapi/bar_chart.py +0 -25
  45. openadmin_dev-0.7.6/openadmin/fastapi/component.py +0 -26
  46. openadmin_dev-0.7.6/openadmin/fastapi/counters.py +0 -17
  47. openadmin_dev-0.7.6/openadmin/fastapi/form.py +0 -19
  48. openadmin_dev-0.7.6/openadmin/fastapi/line_chart.py +0 -18
  49. openadmin_dev-0.7.6/openadmin/fastapi/markdown.py +0 -20
  50. openadmin_dev-0.7.6/openadmin/fastapi/page_protocol.py +0 -12
  51. openadmin_dev-0.7.6/openadmin/fastapi/pie_chart.py +0 -26
  52. openadmin_dev-0.7.6/openadmin/fastapi/section.py +0 -19
  53. openadmin_dev-0.7.6/openadmin/fastapi/stat.py +0 -20
  54. openadmin_dev-0.7.6/openadmin/fastapi/table.py +0 -21
  55. openadmin_dev-0.7.6/openadmin/fastapi/utils.py +0 -184
  56. openadmin_dev-0.7.6/openadmin/spec/action.py +0 -23
  57. openadmin_dev-0.7.6/openadmin/spec/form.py +0 -23
  58. openadmin_dev-0.7.6/openadmin/spec/property.py +0 -16
  59. openadmin_dev-0.7.6/openadmin/spec/property_type.py +0 -18
  60. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/ISSUE_TEMPLATE/bug.md +0 -0
  61. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/ISSUE_TEMPLATE/chore.md +0 -0
  62. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/ISSUE_TEMPLATE/feature.md +0 -0
  63. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/branch-name/action.yml +0 -0
  64. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/dependency-cves/action.yml +0 -0
  65. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/format/action.yml +0 -0
  66. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/license/action.yml +0 -0
  67. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/lint/action.yml +0 -0
  68. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/security/action.yml +0 -0
  69. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/spell-check/action.yml +0 -0
  70. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/test/action.yml +0 -0
  71. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/typecheck/action.yml +0 -0
  72. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/actions/unused-code/action.yml +0 -0
  73. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/workflows/ci.yml +0 -0
  74. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.github/workflows/publish.yml +0 -0
  75. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.gitignore +0 -0
  76. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/.python-version +0 -0
  77. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/LICENSE +0 -0
  78. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/LICENSES/AGPL-3.0-or-later.txt +0 -0
  79. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/Makefile +0 -0
  80. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/README.md +0 -0
  81. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/REUSE.toml +0 -0
  82. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/docs/assets/logo.png +0 -0
  83. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/__init__.py +0 -0
  84. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/admin/__init__.py +0 -0
  85. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/lib/__init__.py +0 -0
  86. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/lib/database.py +0 -0
  87. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/lib/lifespan.py +0 -0
  88. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/lib/models.py +0 -0
  89. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/examples/lib/seed.py +0 -0
  90. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/fastapi/__init__.py +0 -0
  91. /openadmin_dev-0.7.6/openadmin/fastapi/pagination_params.py → /openadmin_dev-0.7.10/openadmin/fastapi/req.py +0 -0
  92. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/colors.py +0 -0
  93. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/http_methods.py +0 -0
  94. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/openadmin/spec/icons.py +0 -0
  95. {openadmin_dev-0.7.6 → openadmin_dev-0.7.10}/tests/test_hello.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openadmin-dev
3
- Version: 0.7.6
3
+ Version: 0.7.10
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
@@ -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) -> int:
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("All Authors", description="Browse authors with search and book counts")
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
- @page.form_post("Add Author", description="Register a new author in the catalog")
111
- async def add_author(body: AddAuthorBody, session: AsyncSessionDep):
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
- return {"id": author.id, "name": f"{author.first_name} {author.last_name}"}
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("All Books", description="Browse all books with search and pagination")
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("Books by Author", description="Number of books per author")
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.form_post("Add Book", description="Add a new book to the catalog")
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 {"id": book.id, "title": book.title}
109
+ return {
110
+ "message": f"Added book '{book.title}'",
111
+ "table": {"id": book.id, "title": book.title},
112
+ }
99
113
 
100
114
 
101
- @page.action_delete("Delete Book", description="Remove a book by ID")
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
- return {"deleted": book_id, "found": book is not None}
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
@@ -0,0 +1,39 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenAdmin
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ from openadmin import fastapi, spec
6
+
7
+ page = fastapi.AdminPage("Docs", icon="dock")
8
+
9
+
10
+ @page.markdown("Company policy 1", icon="podcast", color="blue")
11
+ def company_policy_1() -> spec.Markdown:
12
+ return """
13
+ # Hello World
14
+
15
+ This is new text
16
+
17
+ ---
18
+
19
+ - Cake
20
+ - Limonade
21
+ """
22
+
23
+
24
+ @page.markdown("Company policy 2")
25
+ def company_policy_2() -> spec.Markdown:
26
+ return {
27
+ "content": """
28
+ # Hello World
29
+
30
+ This is new text
31
+
32
+ ---
33
+
34
+ - Cake
35
+ - Limonade
36
+ """,
37
+ "color": "yellow",
38
+ "icon": "a-arrow-up",
39
+ }
@@ -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="name",
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 [{**g, "count": count} for g, count in result.all()]
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.action_get("Ping Database", description="Verify the database connection is alive")
82
- async def ping_database(session: AsyncSessionDep):
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 {"status": "ok", "message": "Database is reachable"}
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("All Publishers", description="Browse publishers with their book counts")
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.form_post("Add Publisher", description="Register a new publisher")
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 {"id": publisher.id, "name": publisher.name}
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", description="Books with the highest number of tag assignments"
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
- ]