openadmin 0.3.1__tar.gz → 0.7.12__tar.gz

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