swiftpy-framework 0.1.0__py3-none-any.whl

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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
swiftpy/admin/views.py ADDED
@@ -0,0 +1,1583 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import logging
5
+ from typing import TYPE_CHECKING, Any
6
+ from urllib.parse import parse_qs, quote, urlencode
7
+
8
+ import asyncpg
9
+
10
+ from swiftpy.admin.auth import (
11
+ AdminUser,
12
+ clear_admin_session,
13
+ create_admin_session,
14
+ get_admin_authenticator,
15
+ get_admin_session_store,
16
+ get_current_admin,
17
+ )
18
+ from swiftpy.admin.registry import (
19
+ AdminSite,
20
+ ModelAdmin,
21
+ humanize_name,
22
+ set_admin_route_compiler,
23
+ )
24
+ from swiftpy.ai.observability.observer import get_observability
25
+ from swiftpy.core.config import Config
26
+ from swiftpy.core.container import get_container
27
+ from swiftpy.database.exceptions import DatabaseError
28
+ from swiftpy.database.orm.model import Model, ModelQueryBuilder
29
+ from swiftpy.database.pagination.paginator import Paginator
30
+ from swiftpy.http.request import Request
31
+ from swiftpy.http.response import (
32
+ Response,
33
+ ResponseHeaders,
34
+ json_response,
35
+ not_found,
36
+ )
37
+ from swiftpy.http.router import Router
38
+
39
+ if TYPE_CHECKING:
40
+ from swiftpy.core.application import Application
41
+
42
+ logger = logging.getLogger("swiftpy.admin.views")
43
+
44
+ _ADMIN_DB_ERRORS = (
45
+ DatabaseError,
46
+ asyncpg.PostgresError,
47
+ ValueError,
48
+ TypeError,
49
+ RuntimeError,
50
+ LookupError,
51
+ OSError,
52
+ )
53
+
54
+
55
+ def _admin_url(clean_prefix: str, *segments: Any, query: dict[str, Any] | None = None) -> str:
56
+ """Build a URL under the admin prefix with percent-encoded path segments."""
57
+ base = "" if clean_prefix == "/" else clean_prefix
58
+ encoded = "/".join(quote(str(seg), safe="") for seg in segments)
59
+ url = f"{base}/{encoded}" if encoded else clean_prefix
60
+ if query:
61
+ filtered = {k: v for k, v in query.items() if v is not None and v != ""}
62
+ if filtered:
63
+ url = f"{url}?{urlencode(filtered)}"
64
+ return url
65
+
66
+
67
+ def _is_admin_debug_mode(explicit_debug: bool | None = None) -> bool:
68
+ """Determine whether admin error responses may expose detailed exception text."""
69
+ if explicit_debug is not None:
70
+ return bool(explicit_debug)
71
+ cfg: Config | None = None
72
+ try:
73
+ container = get_container()
74
+ if container.has(Config):
75
+ cfg = container.resolve(Config)
76
+ except RuntimeError:
77
+ cfg = None
78
+ if cfg is None:
79
+ try:
80
+ cfg = Config.get_instance()
81
+ except (RuntimeError, ValueError):
82
+ cfg = None
83
+ if cfg is None:
84
+ return False
85
+ app_env = str(cfg.get("app.env") or cfg.get("APP_ENV") or cfg.get("env") or "").strip().lower()
86
+ if app_env in ("production", "prod", "staging"):
87
+ return False
88
+ raw_debug = cfg.get("app.debug")
89
+ if isinstance(raw_debug, bool):
90
+ return raw_debug
91
+ if isinstance(raw_debug, str):
92
+ return raw_debug.strip().lower() in ("true", "1", "yes", "on")
93
+ return False
94
+
95
+
96
+ def _format_admin_error(exc: Exception, *, generic: str, debug: bool) -> str:
97
+ """Return full exception details only in debug mode; redact in production."""
98
+ if debug:
99
+ return f"{generic}: {exc}"
100
+ return generic
101
+
102
+
103
+ def _forbidden_response(
104
+ request: Request,
105
+ site: AdminSite,
106
+ admin_user: AdminUser,
107
+ clean_prefix: str,
108
+ message: str = "Forbidden: insufficient administrator permissions.",
109
+ ) -> Response:
110
+ """Return consistent JSON or HTML 403 Forbidden response."""
111
+ if _wants_json(request):
112
+ return json_response({"detail": message}, status_code=403)
113
+ return _html_response(
114
+ _render_base_layout(
115
+ site,
116
+ "Forbidden",
117
+ f'<div class="alert alert-error">{html.escape(message)}</div>',
118
+ admin_user=admin_user,
119
+ prefix=clean_prefix,
120
+ ),
121
+ status_code=403,
122
+ )
123
+
124
+
125
+ def _redirect(url: str, status_code: int = 302) -> Response:
126
+ """Construct an HTTP redirect response."""
127
+ headers = ResponseHeaders({"location": url})
128
+ return Response(content="", status_code=status_code, headers=headers)
129
+
130
+
131
+ def _wants_json(request: Request) -> bool:
132
+ """Determine if the client requested JSON response."""
133
+ if request.query_params.get("format") == "json":
134
+ return True
135
+ accept = request.headers.get("accept", "").lower()
136
+ return "application/json" in accept and "text/html" not in accept
137
+
138
+
139
+ async def _extract_payload(request: Request) -> dict[str, Any]:
140
+ """Extract submitted form parameters or JSON payload from Request."""
141
+ content_type = request.headers.get("content-type", "").lower()
142
+ if "application/json" in content_type:
143
+ try:
144
+ data = await request.json()
145
+ return dict(data) if isinstance(data, dict) else {}
146
+ except (ValueError, TypeError):
147
+ return {}
148
+
149
+ raw_body = await request.body()
150
+ if not raw_body:
151
+ return {}
152
+ decoded = raw_body.decode("utf-8", errors="replace")
153
+ parsed = parse_qs(decoded, keep_blank_values=True)
154
+ return {k: v[0] for k, v in parsed.items() if v}
155
+
156
+
157
+ def _model_repr(instance: Any) -> str:
158
+ """Construct a clean, human-readable string representation of a model instance."""
159
+ if not isinstance(instance, Model):
160
+ return str(instance)
161
+ pk_col = getattr(instance, "__primary_key__", "id")
162
+ pk_val = getattr(instance, pk_col, None)
163
+
164
+ for attr in ("name", "title", "username", "email", "label"):
165
+ val = getattr(instance, attr, None)
166
+ if val:
167
+ return f"{val} (#{pk_val})" if pk_val is not None else str(val)
168
+
169
+ return f"{instance.__class__.__name__} #{pk_val}" if pk_val is not None else instance.__class__.__name__
170
+
171
+
172
+ def _coerce_pk(m_admin: ModelAdmin, raw_id: Any) -> Any:
173
+ """Coerce raw primary key from URL parameter to its declared Python type."""
174
+ pk_col = getattr(m_admin.model, "__primary_key__", "id")
175
+ for f in m_admin.get_fields():
176
+ if f.name == pk_col:
177
+ try:
178
+ return f.coerce_value(raw_id)
179
+ except (ValueError, TypeError):
180
+ pass
181
+ if isinstance(raw_id, str) and raw_id.isdigit():
182
+ return int(raw_id)
183
+ return raw_id
184
+
185
+
186
+ def _render_base_layout(
187
+ site: AdminSite,
188
+ title: str,
189
+ body_html: str,
190
+ admin_user: AdminUser | None = None,
191
+ prefix: str = "/admin",
192
+ active_slug: str | None = None,
193
+ flash_error: str | None = None,
194
+ flash_success: str | None = None,
195
+ ) -> str:
196
+ """Render the master HTML shell with navbar, sidebar, and responsive styling."""
197
+ models = site.all_registered()
198
+ sidebar_items_html = []
199
+ esc_prefix = html.escape(_admin_url(prefix), quote=True)
200
+ dash_active = 'class="active"' if active_slug is None else ""
201
+ sidebar_items_html.append(f'<li><a href="{esc_prefix}/" {dash_active}>📊 Dashboard</a></li>')
202
+
203
+ for m_admin in models:
204
+ is_active = 'class="active"' if active_slug == m_admin.slug else ""
205
+ model_href = html.escape(_admin_url(prefix, m_admin.slug), quote=True)
206
+ sidebar_items_html.append(
207
+ f'<li><a href="{model_href}" {is_active}>📁 {html.escape(m_admin.plural_title)}</a></li>'
208
+ )
209
+
210
+ ai_active = 'class="active"' if active_slug == "ai-usage" else ""
211
+ ai_href = html.escape(_admin_url(prefix, "ai-usage"), quote=True)
212
+ sidebar_items_html.append(f'<li><a href="{ai_href}" {ai_active}>🤖 AI Usage</a></li>')
213
+
214
+ sidebar_html = "\n".join(sidebar_items_html)
215
+
216
+ alerts_html = ""
217
+ if flash_error:
218
+ alerts_html += f'<div class="alert alert-error">{html.escape(flash_error)}</div>'
219
+ if flash_success:
220
+ alerts_html += f'<div class="alert alert-success">{html.escape(flash_success)}</div>'
221
+
222
+ user_info_html = ""
223
+ if admin_user:
224
+ csrf_tok = get_admin_session_store().generate_csrf_token(admin_user)
225
+ logout_action = html.escape(_admin_url(prefix, "logout"), quote=True)
226
+ user_info_html = f"""
227
+ <div class="user-profile">
228
+ <span>👤 <strong>{html.escape(admin_user.username)}</strong></span>
229
+ <form action="{logout_action}" method="POST" style="display:inline; margin-left:12px;">
230
+ <input type="hidden" name="_csrf_token" value="{html.escape(csrf_tok, quote=True)}">
231
+ <button type="submit" class="btn btn-sm btn-outline">Sign Out</button>
232
+ </form>
233
+ </div>
234
+ """
235
+
236
+ return f"""<!DOCTYPE html>
237
+ <html lang="en">
238
+ <head>
239
+ <meta charset="utf-8">
240
+ <meta name="viewport" content="width=device-width, initial-scale=1">
241
+ <title>{html.escape(title)} | {html.escape(site.title)}</title>
242
+ <style>
243
+ :root {{
244
+ --bg-color: #0f172a;
245
+ --surface-color: #1e293b;
246
+ --surface-hover: #334155;
247
+ --border-color: #334155;
248
+ --primary: #38bdf8;
249
+ --primary-hover: #0284c7;
250
+ --danger: #ef4444;
251
+ --danger-hover: #dc2626;
252
+ --success: #10b981;
253
+ --text-main: #f8fafc;
254
+ --text-muted: #94a3b8;
255
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
256
+ }}
257
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
258
+ body {{
259
+ font-family: var(--font-sans);
260
+ background-color: var(--bg-color);
261
+ color: var(--text-main);
262
+ display: flex;
263
+ min-height: 100vh;
264
+ }}
265
+ a {{ color: var(--primary); text-decoration: none; }}
266
+ a:hover {{ text-decoration: underline; }}
267
+ /* Sidebar */
268
+ aside.sidebar {{
269
+ width: 260px;
270
+ background-color: var(--surface-color);
271
+ border-right: 1px solid var(--border-color);
272
+ display: flex;
273
+ flex-direction: column;
274
+ }}
275
+ .brand {{
276
+ padding: 20px;
277
+ font-size: 1.25rem;
278
+ font-weight: 700;
279
+ border-bottom: 1px solid var(--border-color);
280
+ color: var(--primary);
281
+ }}
282
+ .nav-list {{ list-style: none; padding: 15px 10px; flex: 1; }}
283
+ .nav-list li {{ margin-bottom: 6px; }}
284
+ .nav-list a {{
285
+ display: block;
286
+ padding: 10px 14px;
287
+ border-radius: 6px;
288
+ color: var(--text-main);
289
+ font-size: 0.95rem;
290
+ transition: background 0.15s ease;
291
+ }}
292
+ .nav-list a:hover, .nav-list a.active {{
293
+ background-color: var(--surface-hover);
294
+ color: var(--primary);
295
+ text-decoration: none;
296
+ }}
297
+ /* Main Container */
298
+ .main-wrapper {{
299
+ flex: 1;
300
+ display: flex;
301
+ flex-direction: column;
302
+ overflow-y: auto;
303
+ }}
304
+ header.topbar {{
305
+ height: 64px;
306
+ background-color: var(--surface-color);
307
+ border-bottom: 1px solid var(--border-color);
308
+ display: flex;
309
+ align-items: center;
310
+ justify-content: space-between;
311
+ padding: 0 30px;
312
+ }}
313
+ main.content {{
314
+ padding: 30px;
315
+ flex: 1;
316
+ }}
317
+ .page-header {{
318
+ display: flex;
319
+ align-items: center;
320
+ justify-content: space-between;
321
+ margin-bottom: 24px;
322
+ }}
323
+ .page-header h1 {{ font-size: 1.75rem; font-weight: 600; }}
324
+ /* Buttons */
325
+ .btn {{
326
+ display: inline-flex;
327
+ align-items: center;
328
+ justify-content: center;
329
+ padding: 9px 18px;
330
+ font-size: 0.9rem;
331
+ font-weight: 500;
332
+ border-radius: 6px;
333
+ cursor: pointer;
334
+ border: 1px solid transparent;
335
+ text-decoration: none !important;
336
+ transition: all 0.15s ease;
337
+ }}
338
+ .btn-primary {{ background-color: var(--primary); color: #0f172a; font-weight: 600; }}
339
+ .btn-primary:hover {{ background-color: var(--primary-hover); }}
340
+ .btn-danger {{ background-color: var(--danger); color: #fff; }}
341
+ .btn-danger:hover {{ background-color: var(--danger-hover); }}
342
+ .btn-outline {{ background-color: transparent; border-color: var(--border-color); color: var(--text-main); }}
343
+ .btn-outline:hover {{ background-color: var(--surface-hover); }}
344
+ .btn-sm {{ padding: 5px 10px; font-size: 0.8rem; }}
345
+ /* Tables */
346
+ .card {{
347
+ background-color: var(--surface-color);
348
+ border: 1px solid var(--border-color);
349
+ border-radius: 8px;
350
+ overflow: hidden;
351
+ box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
352
+ }}
353
+ table.data-table {{
354
+ width: 100%;
355
+ border-collapse: collapse;
356
+ text-align: left;
357
+ font-size: 0.95rem;
358
+ }}
359
+ table.data-table th {{
360
+ background-color: rgba(255,255,255,0.03);
361
+ border-bottom: 1px solid var(--border-color);
362
+ padding: 14px 18px;
363
+ font-weight: 600;
364
+ color: var(--text-muted);
365
+ text-transform: uppercase;
366
+ font-size: 0.75rem;
367
+ letter-spacing: 0.05em;
368
+ }}
369
+ table.data-table td {{
370
+ padding: 14px 18px;
371
+ border-bottom: 1px solid var(--border-color);
372
+ vertical-align: middle;
373
+ }}
374
+ table.data-table tbody tr:hover {{
375
+ background-color: rgba(255,255,255,0.02);
376
+ }}
377
+ .badge {{
378
+ display: inline-block;
379
+ padding: 3px 8px;
380
+ border-radius: 4px;
381
+ font-size: 0.75rem;
382
+ font-weight: 600;
383
+ background: rgba(56, 189, 248, 0.15);
384
+ color: var(--primary);
385
+ }}
386
+ /* Forms */
387
+ .form-group {{ margin-bottom: 20px; }}
388
+ .form-group label {{
389
+ display: block;
390
+ margin-bottom: 8px;
391
+ font-size: 0.9rem;
392
+ font-weight: 500;
393
+ color: var(--text-main);
394
+ }}
395
+ .form-control {{
396
+ width: 100%;
397
+ padding: 10px 14px;
398
+ font-size: 0.95rem;
399
+ border-radius: 6px;
400
+ border: 1px solid var(--border-color);
401
+ background-color: #0f172a;
402
+ color: var(--text-main);
403
+ outline: none;
404
+ transition: border-color 0.15s ease;
405
+ }}
406
+ .form-control:focus {{ border-color: var(--primary); }}
407
+ textarea.form-control {{ min-height: 120px; resize: vertical; }}
408
+ /* Alerts */
409
+ .alert {{
410
+ padding: 14px 18px;
411
+ border-radius: 6px;
412
+ margin-bottom: 20px;
413
+ font-size: 0.95rem;
414
+ }}
415
+ .alert-error {{ background-color: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger); color: #fca5a5; }}
416
+ .alert-success {{ background-color: rgba(16, 185, 129, 0.15); border: 1px solid var(--success); color: #6ee7b7; }}
417
+ </style>
418
+ </head>
419
+ <body>
420
+ <aside class="sidebar">
421
+ <div class="brand">⚡ {html.escape(site.title)}</div>
422
+ <ul class="nav-list">
423
+ {sidebar_html}
424
+ </ul>
425
+ </aside>
426
+ <div class="main-wrapper">
427
+ <header class="topbar">
428
+ <div><strong>Admin Console</strong></div>
429
+ {user_info_html}
430
+ </header>
431
+ <main class="content">
432
+ {alerts_html}
433
+ {body_html}
434
+ </main>
435
+ </div>
436
+ </body>
437
+ </html>
438
+ """
439
+
440
+
441
+ def _render_login_page(
442
+ site: AdminSite,
443
+ prefix: str = "/admin",
444
+ error_message: str | None = None,
445
+ ) -> str:
446
+ """Render dedicated admin login screen."""
447
+ error_html = (
448
+ f'<div class="alert alert-error">{html.escape(error_message)}</div>'
449
+ if error_message
450
+ else ""
451
+ )
452
+
453
+ return f"""<!DOCTYPE html>
454
+ <html lang="en">
455
+ <head>
456
+ <meta charset="utf-8">
457
+ <meta name="viewport" content="width=device-width, initial-scale=1">
458
+ <title>Admin Sign In | {html.escape(site.title)}</title>
459
+ <style>
460
+ body {{
461
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
462
+ background-color: #0f172a;
463
+ color: #f8fafc;
464
+ display: flex;
465
+ align-items: center;
466
+ justify-content: center;
467
+ min-height: 100vh;
468
+ margin: 0;
469
+ }}
470
+ .login-card {{
471
+ width: 100%;
472
+ max-width: 400px;
473
+ background-color: #1e293b;
474
+ border: 1px solid #334155;
475
+ border-radius: 10px;
476
+ padding: 36px;
477
+ box-shadow: 0 10px 25px -5px rgba(0,0,0,0.3);
478
+ }}
479
+ .brand {{
480
+ font-size: 1.5rem;
481
+ font-weight: 700;
482
+ color: #38bdf8;
483
+ margin-bottom: 8px;
484
+ text-align: center;
485
+ }}
486
+ .subtitle {{
487
+ font-size: 0.9rem;
488
+ color: #94a3b8;
489
+ margin-bottom: 24px;
490
+ text-align: center;
491
+ }}
492
+ .form-group {{ margin-bottom: 18px; }}
493
+ label {{ display: block; margin-bottom: 6px; font-size: 0.9rem; font-weight: 500; }}
494
+ input[type="text"], input[type="password"] {{
495
+ width: 100%;
496
+ padding: 10px 14px;
497
+ border-radius: 6px;
498
+ border: 1px solid #334155;
499
+ background-color: #0f172a;
500
+ color: #f8fafc;
501
+ box-sizing: border-box;
502
+ outline: none;
503
+ }}
504
+ input:focus {{ border-color: #38bdf8; }}
505
+ .btn-submit {{
506
+ width: 100%;
507
+ padding: 12px;
508
+ background-color: #38bdf8;
509
+ color: #0f172a;
510
+ font-weight: 600;
511
+ border: none;
512
+ border-radius: 6px;
513
+ cursor: pointer;
514
+ margin-top: 10px;
515
+ }}
516
+ .btn-submit:hover {{ background-color: #0284c7; }}
517
+ .alert-error {{
518
+ background-color: rgba(239, 68, 68, 0.15);
519
+ border: 1px solid #ef4444;
520
+ color: #fca5a5;
521
+ padding: 12px;
522
+ border-radius: 6px;
523
+ margin-bottom: 18px;
524
+ font-size: 0.9rem;
525
+ }}
526
+ </style>
527
+ </head>
528
+ <body>
529
+ <div class="login-card">
530
+ <div class="brand">⚡ {html.escape(site.title)}</div>
531
+ <div class="subtitle">Secure session-based administrative access</div>
532
+ {error_html}
533
+ <form action="{html.escape(_admin_url(prefix, "login"), quote=True)}" method="POST">
534
+ <div class="form-group">
535
+ <label for="username">Username</label>
536
+ <input type="text" id="username" name="username" required autofocus placeholder="admin">
537
+ </div>
538
+ <div class="form-group">
539
+ <label for="password">Password</label>
540
+ <input type="password" id="password" name="password" required placeholder="••••••••">
541
+ </div>
542
+ <button type="submit" class="btn-submit">Sign In to Admin</button>
543
+ </form>
544
+ </div>
545
+ </body>
546
+ </html>
547
+ """
548
+
549
+
550
+ def _html_response(content: str, status_code: int = 200) -> Response:
551
+ return Response(
552
+ content=content,
553
+ status_code=status_code,
554
+ headers={"content-type": "text/html; charset=utf-8"},
555
+ )
556
+
557
+
558
+ def create_admin_router(
559
+ site: AdminSite,
560
+ prefix: str = "/admin",
561
+ *,
562
+ debug: bool | None = None,
563
+ ) -> Router:
564
+ """Construct a complete Router containing all CRUD and authentication endpoints for AdminSite."""
565
+ clean_prefix = "/" + prefix.strip("/")
566
+ router = Router()
567
+
568
+ # --------------------------------------------------------------------------
569
+ # Authentication & Dashboard
570
+ # --------------------------------------------------------------------------
571
+
572
+ async def login_get(request: Request) -> Response:
573
+ admin_user = get_current_admin(request)
574
+ if admin_user:
575
+ return _redirect(_admin_url(clean_prefix))
576
+ return _html_response(_render_login_page(site, prefix=clean_prefix))
577
+
578
+ async def login_post(request: Request) -> Response:
579
+ payload = await _extract_payload(request)
580
+ username = str(payload.get("username") or payload.get("email") or "").strip()
581
+ password = str(payload.get("password", ""))
582
+
583
+ authenticator = get_admin_authenticator()
584
+ user = authenticator.authenticate(username, password)
585
+ if not user:
586
+ if _wants_json(request):
587
+ return json_response({"detail": "Invalid admin credentials."}, status_code=401)
588
+ return _html_response(
589
+ _render_login_page(site, prefix=clean_prefix, error_message="Invalid username or password."),
590
+ status_code=401,
591
+ )
592
+
593
+ if _wants_json(request):
594
+ resp = json_response({"user": user.to_dict(), "message": "Authenticated successfully."})
595
+ else:
596
+ resp = _redirect(f"{clean_prefix}/")
597
+
598
+ create_admin_session(resp, user, prefix=clean_prefix, request=request)
599
+ return resp
600
+
601
+ async def logout_handler(request: Request) -> Response:
602
+ admin_user = get_current_admin(request)
603
+ if not admin_user:
604
+ if _wants_json(request):
605
+ return json_response({"detail": "Admin authentication required."}, status_code=401)
606
+ return _redirect(_admin_url(clean_prefix, "login"))
607
+
608
+ payload = await _extract_payload(request)
609
+ session_store = get_admin_session_store()
610
+ csrf_tok = payload.get("_csrf_token") or request.headers.get("x-csrf-token")
611
+ if not session_store.verify_csrf_token(csrf_tok, admin_user):
612
+ return _forbidden_response(
613
+ request,
614
+ site,
615
+ admin_user,
616
+ clean_prefix,
617
+ "CSRF verification failed: missing or invalid CSRF token.",
618
+ )
619
+
620
+ if _wants_json(request):
621
+ resp = json_response({"message": "Logged out successfully."})
622
+ else:
623
+ resp = _redirect(_admin_url(clean_prefix, "login"))
624
+ clear_admin_session(resp, request, prefix=clean_prefix)
625
+ return resp
626
+
627
+ async def dashboard_get(request: Request) -> Response:
628
+ admin_user = get_current_admin(request)
629
+ if not admin_user:
630
+ if _wants_json(request):
631
+ return json_response({"detail": "Admin authentication required."}, status_code=401)
632
+ return _redirect(f"{clean_prefix}/login")
633
+
634
+ registered = site.all_registered()
635
+ cards_html = []
636
+ stats: dict[str, Any] = {}
637
+
638
+ ai_observer = get_observability()
639
+ try:
640
+ ai_stats = await ai_observer.get_stats()
641
+ except (RuntimeError, OSError, ValueError):
642
+ ai_stats = {
643
+ "total_calls": 0,
644
+ "total_tokens": 0,
645
+ "total_cost": 0.0,
646
+ "avg_latency_ms": 0.0,
647
+ }
648
+
649
+ ai_card_html = f"""
650
+ <div class="card" style="padding: 24px; margin-bottom: 24px; border: 1px solid rgba(56,189,248,0.3); background: linear-gradient(135deg, rgba(30,41,59,0.8), rgba(15,23,42,0.9));">
651
+ <div style="display:flex; justify-content:space-between; align-items:center;">
652
+ <div>
653
+ <h2 style="font-size:1.25rem; font-weight:600; margin-bottom:6px; color:#38bdf8;">
654
+ 🤖 AI Observability & Usage
655
+ </h2>
656
+ <p style="color:var(--text-muted); font-size:0.9rem; margin:0;">
657
+ {ai_stats.get('total_calls', 0):,} calls &bull; {ai_stats.get('total_tokens', 0):,} tokens &bull; ${ai_stats.get('total_cost', 0.0):.4f} spend
658
+ </p>
659
+ </div>
660
+ <div>
661
+ <a href="{clean_prefix}/ai-usage" class="btn btn-primary btn-sm">Open AI Dashboard</a>
662
+ </div>
663
+ </div>
664
+ </div>
665
+ """
666
+
667
+ for m_admin in registered:
668
+ if not m_admin.has_view_permission(request, admin_user):
669
+ continue
670
+ try:
671
+ count = await m_admin.model.query().count()
672
+ except (RuntimeError, OSError, ValueError):
673
+ count = 0
674
+ stats[m_admin.slug] = {"title": m_admin.plural_title, "count": count}
675
+
676
+ browse_href = html.escape(_admin_url(clean_prefix, m_admin.slug), quote=True)
677
+ add_btn_html = ""
678
+ if m_admin.has_create_permission(request, admin_user):
679
+ create_href = html.escape(_admin_url(clean_prefix, m_admin.slug, "create"), quote=True)
680
+ add_btn_html = f'<a href="{create_href}" class="btn btn-outline btn-sm">+ Add New</a>'
681
+
682
+ cards_html.append(f"""
683
+ <div class="card" style="padding: 24px; margin-bottom: 20px;">
684
+ <div style="display:flex; justify-content:space-between; align-items:center;">
685
+ <div>
686
+ <h2 style="font-size:1.25rem; font-weight:600; margin-bottom:4px;">
687
+ 📁 {html.escape(m_admin.plural_title)}
688
+ </h2>
689
+ <span class="badge">{count} records</span>
690
+ </div>
691
+ <div>
692
+ <a href="{browse_href}" class="btn btn-primary btn-sm">Browse Records</a>
693
+ {add_btn_html}
694
+ </div>
695
+ </div>
696
+ </div>
697
+ """)
698
+
699
+ if _wants_json(request):
700
+ return json_response({"models": stats, "ai_usage": ai_stats, "user": admin_user.to_dict()})
701
+
702
+ body = f"""
703
+ <div class="page-header">
704
+ <h1>Admin Dashboard</h1>
705
+ </div>
706
+ <p style="color:var(--text-muted); margin-bottom:24px;">Welcome back, <strong>{html.escape(admin_user.username)}</strong>. Select a model to manage its data:</p>
707
+ <div>
708
+ {ai_card_html}
709
+ {''.join(cards_html) if cards_html else '<p>No models currently registered.</p>'}
710
+ </div>
711
+ """
712
+ html_str = _render_base_layout(site, "Dashboard", body, admin_user=admin_user, prefix=clean_prefix)
713
+ return _html_response(html_str)
714
+
715
+ async def ai_usage_view(request: Request) -> Response:
716
+ admin_user = get_current_admin(request)
717
+ if not admin_user:
718
+ if _wants_json(request):
719
+ return json_response({"detail": "Admin authentication required."}, status_code=401)
720
+ return _redirect(f"{clean_prefix}/login")
721
+
722
+ page = 1
723
+ per_page = 50
724
+ try:
725
+ page = max(1, int(request.query_params.get("page", 1)))
726
+ per_page = max(1, min(200, int(request.query_params.get("per_page", 50))))
727
+ except (ValueError, TypeError):
728
+ pass
729
+
730
+ provider = request.query_params.get("provider") or None
731
+ model = request.query_params.get("model") or None
732
+ status = request.query_params.get("status") or None
733
+ start_date = request.query_params.get("start_date") or None
734
+ end_date = request.query_params.get("end_date") or None
735
+
736
+ offset = (page - 1) * per_page
737
+
738
+ observer = get_observability()
739
+ stats = await observer.get_stats()
740
+ recent = await observer.get_observations(
741
+ limit=per_page,
742
+ offset=offset,
743
+ provider=provider,
744
+ model=model,
745
+ status=status,
746
+ start_date=start_date,
747
+ end_date=end_date,
748
+ )
749
+
750
+ if _wants_json(request):
751
+ return json_response({
752
+ "page": page,
753
+ "per_page": per_page,
754
+ "stats": stats,
755
+ "recent_observations": [r.to_dict() for r in recent],
756
+ })
757
+
758
+ total_calls = stats.get("total_calls", 0)
759
+ successful_calls = stats.get("successful_calls", 0)
760
+ failed_calls = stats.get("failed_calls", 0)
761
+ total_tokens = stats.get("total_tokens", 0)
762
+ input_tokens = stats.get("total_input_tokens", 0)
763
+ output_tokens = stats.get("total_output_tokens", 0)
764
+ total_cost = stats.get("total_cost", 0.0)
765
+ avg_latency = stats.get("avg_latency_ms", 0.0)
766
+
767
+ by_provider = stats.get("by_provider", {})
768
+ provider_rows_html = []
769
+ for p_name, p_data in sorted(by_provider.items()):
770
+ provider_rows_html.append(f"""
771
+ <tr>
772
+ <td><strong>{html.escape(p_name)}</strong></td>
773
+ <td>{p_data.get('calls', 0)}</td>
774
+ <td>{p_data.get('tokens', 0):,}</td>
775
+ <td>${p_data.get('cost', 0.0):.6f}</td>
776
+ </tr>
777
+ """)
778
+
779
+ by_model = stats.get("by_model", {})
780
+ model_rows_html = []
781
+ for m_name, m_data in sorted(by_model.items()):
782
+ model_rows_html.append(f"""
783
+ <tr>
784
+ <td><strong>{html.escape(m_name)}</strong></td>
785
+ <td>{m_data.get('calls', 0)}</td>
786
+ <td>{m_data.get('tokens', 0):,}</td>
787
+ <td>${m_data.get('cost', 0.0):.6f}</td>
788
+ </tr>
789
+ """)
790
+
791
+ obs_rows_html = []
792
+ for r in recent:
793
+ if r.status == "success":
794
+ status_badge = '<span class="badge" style="background:rgba(16,185,129,0.2);color:#34d399;">success</span>'
795
+ elif r.status == "cancelled":
796
+ status_badge = '<span class="badge" style="background:rgba(245,158,11,0.2);color:#fbbf24;">cancelled</span>'
797
+ else:
798
+ status_badge = '<span class="badge" style="background:rgba(239,68,68,0.2);color:#f87171;">error</span>'
799
+
800
+ time_str = r.timestamp.strftime("%Y-%m-%d %H:%M:%S")
801
+ ttft_display = f"{r.ttft_ms:.1f}ms" if r.ttft_ms is not None else "-"
802
+ obs_rows_html.append(f"""
803
+ <tr>
804
+ <td style="white-space:nowrap;font-size:0.85rem;color:var(--text-muted);">{time_str}</td>
805
+ <td><span class="badge">{html.escape(r.provider)}</span></td>
806
+ <td><code>{html.escape(r.model)}</code></td>
807
+ <td>{r.input_tokens:,}</td>
808
+ <td>{r.output_tokens:,}</td>
809
+ <td>{ttft_display}</td>
810
+ <td>{r.latency_ms:.1f}ms</td>
811
+ <td>${r.cost:.6f}</td>
812
+ <td>{status_badge}</td>
813
+ <td><code style="font-size:0.75rem;" title="{html.escape(r.prompt_hash)}">{r.prompt_hash[:10]}...</code></td>
814
+ </tr>
815
+ """)
816
+
817
+ prev_link = (
818
+ f'<a href="{clean_prefix}/ai-usage?page={page - 1}&per_page={per_page}" class="btn btn-outline btn-sm">&laquo; Prev</a>'
819
+ if page > 1
820
+ else ""
821
+ )
822
+ next_link = (
823
+ f'<a href="{clean_prefix}/ai-usage?page={page + 1}&per_page={per_page}" class="btn btn-outline btn-sm" style="margin-left:8px;">Next &raquo;</a>'
824
+ if len(recent) == per_page
825
+ else ""
826
+ )
827
+
828
+ body = f"""
829
+ <div class="page-header" style="margin-bottom:24px;">
830
+ <div style="display:flex; justify-content:space-between; align-items:center;">
831
+ <div>
832
+ <h1>🤖 AI Observability & Usage</h1>
833
+ <p style="color:var(--text-muted); margin-top:4px;">
834
+ Live telemetry across all LLM providers, latency metrics, and estimated API spend.
835
+ </p>
836
+ </div>
837
+ <div>
838
+ <a href="{clean_prefix}/ai-usage?format=json" class="btn btn-outline btn-sm" target="_blank">Export JSON</a>
839
+ </div>
840
+ </div>
841
+ </div>
842
+
843
+ <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:16px; margin-bottom:28px;">
844
+ <div class="card" style="padding:20px;">
845
+ <div style="color:var(--text-muted); font-size:0.85rem; font-weight:600; text-transform:uppercase;">Total Invocations</div>
846
+ <div style="font-size:2rem; font-weight:700; margin:8px 0;">{total_calls:,}</div>
847
+ <div style="font-size:0.85rem; color:var(--text-muted);">{successful_calls} success &bull; {failed_calls} failed</div>
848
+ </div>
849
+ <div class="card" style="padding:20px;">
850
+ <div style="color:var(--text-muted); font-size:0.85rem; font-weight:600; text-transform:uppercase;">Total Tokens</div>
851
+ <div style="font-size:2rem; font-weight:700; margin:8px 0;">{total_tokens:,}</div>
852
+ <div style="font-size:0.85rem; color:var(--text-muted);">{input_tokens:,} in / {output_tokens:,} out</div>
853
+ </div>
854
+ <div class="card" style="padding:20px;">
855
+ <div style="color:var(--text-muted); font-size:0.85rem; font-weight:600; text-transform:uppercase;">Estimated Spend</div>
856
+ <div style="font-size:2rem; font-weight:700; margin:8px 0; color:#38bdf8;">${total_cost:.4f}</div>
857
+ <div style="font-size:0.85rem; color:var(--text-muted);">Standard pricing model</div>
858
+ </div>
859
+ <div class="card" style="padding:20px;">
860
+ <div style="color:var(--text-muted); font-size:0.85rem; font-weight:600; text-transform:uppercase;">Average Latency</div>
861
+ <div style="font-size:2rem; font-weight:700; margin:8px 0;">{avg_latency:.1f}ms</div>
862
+ <div style="font-size:0.85rem; color:var(--text-muted);">Round-trip execution</div>
863
+ </div>
864
+ </div>
865
+
866
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:28px;">
867
+ <div class="card" style="padding:20px;">
868
+ <h3 style="font-size:1.1rem; margin-bottom:14px;">Provider Breakdown</h3>
869
+ <table class="data-table" style="width:100%;">
870
+ <thead><tr><th>Provider</th><th>Calls</th><th>Tokens</th><th>Cost</th></tr></thead>
871
+ <tbody>{''.join(provider_rows_html) if provider_rows_html else '<tr><td colspan="4" style="color:var(--text-muted);">No provider data yet</td></tr>'}</tbody>
872
+ </table>
873
+ </div>
874
+ <div class="card" style="padding:20px;">
875
+ <h3 style="font-size:1.1rem; margin-bottom:14px;">Model Breakdown</h3>
876
+ <table class="data-table" style="width:100%;">
877
+ <thead><tr><th>Model</th><th>Calls</th><th>Tokens</th><th>Cost</th></tr></thead>
878
+ <tbody>{''.join(model_rows_html) if model_rows_html else '<tr><td colspan="4" style="color:var(--text-muted);">No model data yet</td></tr>'}</tbody>
879
+ </table>
880
+ </div>
881
+ </div>
882
+
883
+ <div class="card" style="padding:20px;">
884
+ <h3 style="font-size:1.1rem; margin-bottom:14px;">Recent Observations</h3>
885
+ <div style="overflow-x:auto;">
886
+ <table class="data-table" style="width:100%;">
887
+ <thead>
888
+ <tr>
889
+ <th>Time</th><th>Provider</th><th>Model</th><th>Prompt Tokens</th><th>Output Tokens</th><th>TTFT</th><th>Latency</th><th>Cost</th><th>Status</th><th>Prompt Hash</th>
890
+ </tr>
891
+ </thead>
892
+ <tbody>
893
+ {''.join(obs_rows_html) if obs_rows_html else '<tr><td colspan="10" style="text-align:center; color:var(--text-muted);">No LLM calls recorded yet.</td></tr>'}
894
+ </tbody>
895
+ </table>
896
+ </div>
897
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-top:16px;">
898
+ <div style="color:var(--text-muted); font-size:0.9rem;">
899
+ Page {page} &bull; Showing {len(recent)} entries
900
+ </div>
901
+ <div>
902
+ {prev_link}
903
+ {next_link}
904
+ </div>
905
+ </div>
906
+ </div>
907
+ """
908
+
909
+ html_str = _render_base_layout(site, "AI Usage", body, admin_user=admin_user, prefix=clean_prefix, active_slug="ai-usage")
910
+ return _html_response(html_str)
911
+
912
+ router.add_route("GET", f"{clean_prefix}/login", login_get)
913
+ router.add_route("POST", f"{clean_prefix}/login", login_post)
914
+ router.add_route("POST", f"{clean_prefix}/logout", logout_handler)
915
+ router.add_route("GET", f"{clean_prefix}/", dashboard_get)
916
+ router.add_route("GET", f"{clean_prefix}", dashboard_get)
917
+ router.add_route("GET", f"{clean_prefix}/ai-usage", ai_usage_view)
918
+
919
+ # --------------------------------------------------------------------------
920
+ # CRUD Routes per Registered Model
921
+ # --------------------------------------------------------------------------
922
+
923
+ async def list_view(request: Request, slug: str) -> Response:
924
+ admin_user = get_current_admin(request)
925
+ if not admin_user:
926
+ if _wants_json(request):
927
+ return json_response({"detail": "Admin authentication required."}, status_code=401)
928
+ return _redirect(_admin_url(clean_prefix, "login"))
929
+
930
+ item = site.get_by_slug(slug)
931
+ if not item:
932
+ return not_found(f"Model '{slug}' not found.")
933
+ model_cls, m_admin = item
934
+
935
+ if not m_admin.has_view_permission(request, admin_user):
936
+ return _forbidden_response(request, site, admin_user, clean_prefix)
937
+
938
+ page = 1
939
+ default_per_page = max(1, int(getattr(m_admin, "per_page", 25) or 25))
940
+ per_page = default_per_page
941
+ try:
942
+ raw_page = request.query_params.get("page")
943
+ if raw_page is not None:
944
+ page = max(1, int(raw_page))
945
+ except (ValueError, TypeError):
946
+ page = 1
947
+ try:
948
+ raw_per_page = request.query_params.get("per_page")
949
+ if raw_per_page is not None:
950
+ per_page = max(1, min(200, int(raw_per_page)))
951
+ except (ValueError, TypeError):
952
+ per_page = default_per_page
953
+
954
+ # Query records with eager loaded BelongsTo relations and safe OR search + pagination
955
+ query = m_admin.get_queryset()
956
+ search_query = str(request.query_params.get("q") or "").strip()
957
+ if search_query and m_admin.search_fields:
958
+ search_fields = list(m_admin.search_fields)
959
+
960
+ def _apply_search(q: ModelQueryBuilder[Any]) -> None:
961
+ q.where_ilike(search_fields[0], search_query)
962
+ for field in search_fields[1:]:
963
+ q.or_where_ilike(field, search_query)
964
+
965
+ query.where_group(_apply_search)
966
+
967
+ try:
968
+ paginator = await query.paginate(per_page=per_page, page=page)
969
+ except _ADMIN_DB_ERRORS:
970
+ logger.exception("Error querying records for admin list")
971
+ paginator = Paginator(items=[], total=0, per_page=per_page, current_page=page)
972
+
973
+ records = paginator.items
974
+ total_records = paginator.total
975
+ last_page = paginator.last_page
976
+
977
+ if _wants_json(request):
978
+ data = [
979
+ r.to_dict() if hasattr(r, "to_dict") else getattr(r, "_attributes", {})
980
+ for r in records
981
+ ]
982
+ return json_response(
983
+ {
984
+ "model": m_admin.title,
985
+ "count": len(records),
986
+ "total": total_records,
987
+ "page": paginator.current_page,
988
+ "per_page": paginator.per_page,
989
+ "last_page": last_page,
990
+ "data": data,
991
+ }
992
+ )
993
+
994
+ # Build table headers
995
+ list_display = m_admin.get_list_display()
996
+ th_cells = [f"<th>{html.escape(humanize_name(col))}</th>" for col in list_display]
997
+ th_cells.append('<th style="text-align:right;">Actions</th>')
998
+ thead_html = f"<tr>{''.join(th_cells)}</tr>"
999
+
1000
+ # Build table rows
1001
+ pk_col = getattr(model_cls, "__primary_key__", "id")
1002
+ relationships = m_admin.get_relationships()
1003
+ belongs_to_map = {
1004
+ meta.foreign_key: meta.name
1005
+ for meta in relationships.values()
1006
+ if meta.relation_type == "BelongsTo" and meta.foreign_key
1007
+ }
1008
+ can_create = m_admin.has_create_permission(request, admin_user)
1009
+ can_edit = m_admin.has_edit_permission(request, admin_user)
1010
+ can_delete = m_admin.has_delete_permission(request, admin_user)
1011
+
1012
+ tbody_rows = []
1013
+ for rec in records:
1014
+ pk_val = getattr(rec, pk_col, None)
1015
+ td_cells = []
1016
+ for col in list_display:
1017
+ if col in belongs_to_map:
1018
+ rel_name = belongs_to_map[col]
1019
+ related_obj = getattr(rec, rel_name, None)
1020
+ if related_obj:
1021
+ display_val = f'<span class="badge">{html.escape(_model_repr(related_obj))}</span>'
1022
+ else:
1023
+ raw_fk = getattr(rec, col, None)
1024
+ display_val = html.escape(str(raw_fk) if raw_fk is not None else "-")
1025
+ else:
1026
+ raw_val = getattr(rec, col, None)
1027
+ display_val = html.escape(str(raw_val) if raw_val is not None else "-")
1028
+
1029
+ td_cells.append(f"<td>{display_val}</td>")
1030
+
1031
+ action_parts = []
1032
+ if can_edit:
1033
+ edit_href = html.escape(_admin_url(clean_prefix, slug, pk_val, "edit"), quote=True)
1034
+ action_parts.append(f'<a href="{edit_href}" class="btn btn-sm btn-outline">Edit</a>')
1035
+ if can_delete:
1036
+ csrf_tok = get_admin_session_store().generate_csrf_token(admin_user)
1037
+ delete_action = html.escape(_admin_url(clean_prefix, slug, pk_val, "delete"), quote=True)
1038
+ action_parts.append(
1039
+ f"""<form action="{delete_action}" method="POST" style="display:inline; margin-left:6px;" onsubmit="return confirm('Delete this record?');">
1040
+ <input type="hidden" name="_csrf_token" value="{html.escape(csrf_tok, quote=True)}">
1041
+ <button type="submit" class="btn btn-sm btn-danger">Delete</button>
1042
+ </form>"""
1043
+ )
1044
+ actions_html = f'<td style="text-align:right;">{"".join(action_parts)}</td>'
1045
+ td_cells.append(actions_html)
1046
+ tbody_rows.append(f"<tr>{''.join(td_cells)}</tr>")
1047
+
1048
+ tbody_html = (
1049
+ "\n".join(tbody_rows)
1050
+ if tbody_rows
1051
+ else f'<tr><td colspan="{len(list_display) + 1}" style="text-align:center; color:var(--text-muted); padding:30px;">No records found.</td></tr>'
1052
+ )
1053
+
1054
+ list_action_href = html.escape(_admin_url(clean_prefix, slug), quote=True)
1055
+ add_btn_html = ""
1056
+ if can_create:
1057
+ create_href = html.escape(_admin_url(clean_prefix, slug, "create"), quote=True)
1058
+ add_btn_html = f'<a href="{create_href}" class="btn btn-primary">+ Add {html.escape(m_admin.title)}</a>'
1059
+
1060
+ search_form_html = ""
1061
+ if m_admin.search_fields:
1062
+ search_form_html = f"""
1063
+ <form action="{list_action_href}" method="GET" style="display:flex; gap:8px; margin-bottom:16px;">
1064
+ <input type="text" name="q" value="{html.escape(search_query, quote=True)}" placeholder="Search..." class="form-control" style="max-width:300px;">
1065
+ <button type="submit" class="btn btn-outline btn-sm">Search</button>
1066
+ </form>
1067
+ """
1068
+
1069
+ prev_link_html = ""
1070
+ if paginator.has_previous_page:
1071
+ prev_href = html.escape(
1072
+ _admin_url(
1073
+ clean_prefix,
1074
+ slug,
1075
+ query={"page": paginator.current_page - 1, "per_page": paginator.per_page, "q": search_query},
1076
+ ),
1077
+ quote=True,
1078
+ )
1079
+ prev_link_html = f'<a href="{prev_href}" class="btn btn-outline btn-sm">&laquo; Prev</a>'
1080
+
1081
+ next_link_html = ""
1082
+ if paginator.has_more_pages:
1083
+ next_href = html.escape(
1084
+ _admin_url(
1085
+ clean_prefix,
1086
+ slug,
1087
+ query={"page": paginator.current_page + 1, "per_page": paginator.per_page, "q": search_query},
1088
+ ),
1089
+ quote=True,
1090
+ )
1091
+ next_link_html = f'<a href="{next_href}" class="btn btn-outline btn-sm" style="margin-left:8px;">Next &raquo;</a>'
1092
+
1093
+ body = f"""
1094
+ <div class="page-header">
1095
+ <div>
1096
+ <h1>{html.escape(m_admin.plural_title)}</h1>
1097
+ <p style="color:var(--text-muted); font-size:0.9rem;">{total_records} total records</p>
1098
+ </div>
1099
+ <div>
1100
+ {add_btn_html}
1101
+ </div>
1102
+ </div>
1103
+ {search_form_html}
1104
+ <div class="card">
1105
+ <table class="data-table">
1106
+ <thead>{thead_html}</thead>
1107
+ <tbody>{tbody_html}</tbody>
1108
+ </table>
1109
+ </div>
1110
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-top:16px;">
1111
+ <div style="color:var(--text-muted); font-size:0.9rem;">
1112
+ Page {paginator.current_page} of {last_page} &bull; Showing {len(records)} of {total_records} records
1113
+ </div>
1114
+ <div>
1115
+ {prev_link_html}
1116
+ {next_link_html}
1117
+ </div>
1118
+ </div>
1119
+ """
1120
+ html_str = _render_base_layout(
1121
+ site,
1122
+ m_admin.plural_title,
1123
+ body,
1124
+ admin_user=admin_user,
1125
+ prefix=clean_prefix,
1126
+ active_slug=slug,
1127
+ )
1128
+ return _html_response(html_str)
1129
+
1130
+ async def create_get(request: Request, slug: str) -> Response:
1131
+ admin_user = get_current_admin(request)
1132
+ if not admin_user:
1133
+ return _redirect(_admin_url(clean_prefix, "login"))
1134
+
1135
+ item = site.get_by_slug(slug)
1136
+ if not item:
1137
+ return not_found(f"Model '{slug}' not found.")
1138
+ _, m_admin = item
1139
+
1140
+ if not m_admin.has_create_permission(request, admin_user):
1141
+ return _forbidden_response(request, site, admin_user, clean_prefix)
1142
+
1143
+ csrf_tok = get_admin_session_store().generate_csrf_token(admin_user)
1144
+ form_fields_html = await _build_form_inputs(m_admin, csrf_token=csrf_tok)
1145
+ list_href = html.escape(_admin_url(clean_prefix, slug), quote=True)
1146
+ create_action = html.escape(_admin_url(clean_prefix, slug, "create"), quote=True)
1147
+ body = f"""
1148
+ <div class="page-header">
1149
+ <h1>Create {html.escape(m_admin.title)}</h1>
1150
+ <a href="{list_href}" class="btn btn-outline btn-sm">← Back to List</a>
1151
+ </div>
1152
+ <div class="card" style="padding: 28px; max-width: 650px;">
1153
+ <form action="{create_action}" method="POST">
1154
+ {form_fields_html}
1155
+ <div style="margin-top:24px; display:flex; gap:10px;">
1156
+ <button type="submit" class="btn btn-primary">Save {html.escape(m_admin.title)}</button>
1157
+ <a href="{list_href}" class="btn btn-outline">Cancel</a>
1158
+ </div>
1159
+ </form>
1160
+ </div>
1161
+ """
1162
+ html_str = _render_base_layout(
1163
+ site,
1164
+ f"Create {m_admin.title}",
1165
+ body,
1166
+ admin_user=admin_user,
1167
+ prefix=clean_prefix,
1168
+ active_slug=slug,
1169
+ )
1170
+ return _html_response(html_str)
1171
+
1172
+ async def create_post(request: Request, slug: str) -> Response:
1173
+ admin_user = get_current_admin(request)
1174
+ if not admin_user:
1175
+ if _wants_json(request):
1176
+ return json_response({"detail": "Admin authentication required."}, status_code=401)
1177
+ return _redirect(_admin_url(clean_prefix, "login"))
1178
+
1179
+ item = site.get_by_slug(slug)
1180
+ if not item:
1181
+ return not_found(f"Model '{slug}' not found.")
1182
+ model_cls, m_admin = item
1183
+
1184
+ if not m_admin.has_create_permission(request, admin_user):
1185
+ return _forbidden_response(request, site, admin_user, clean_prefix)
1186
+
1187
+ payload = await _extract_payload(request)
1188
+ session_store = get_admin_session_store()
1189
+ csrf_tok = payload.get("_csrf_token") or request.headers.get("x-csrf-token")
1190
+ if not session_store.verify_csrf_token(csrf_tok, admin_user):
1191
+ return _forbidden_response(
1192
+ request,
1193
+ site,
1194
+ admin_user,
1195
+ clean_prefix,
1196
+ "CSRF verification failed: missing or invalid CSRF token.",
1197
+ )
1198
+
1199
+ is_debug = _is_admin_debug_mode(debug)
1200
+ create_action = html.escape(_admin_url(clean_prefix, slug, "create"), quote=True)
1201
+ fields = m_admin.get_form_fields()
1202
+ data: dict[str, Any] = {}
1203
+ protected_fields = {m_admin.primary_key, "created_at", "updated_at"}
1204
+
1205
+ for f in fields:
1206
+ if f.name not in protected_fields and f.name in payload:
1207
+ try:
1208
+ data[f.name] = f.coerce_value(payload[f.name])
1209
+ except ValueError as err:
1210
+ err_msg = _format_admin_error(err, generic="Invalid field value submitted.", debug=is_debug)
1211
+ if _wants_json(request):
1212
+ return json_response({"error": err_msg}, status_code=422)
1213
+ csrf_t = session_store.generate_csrf_token(admin_user)
1214
+ form_fields_html = await _build_form_inputs(m_admin, values=payload, csrf_token=csrf_t)
1215
+ body = f"""
1216
+ <div class="page-header">
1217
+ <h1>Create {html.escape(m_admin.title)}</h1>
1218
+ </div>
1219
+ <div class="card" style="padding: 28px; max-width: 650px;">
1220
+ <form action="{create_action}" method="POST">
1221
+ {form_fields_html}
1222
+ <div style="margin-top:24px; display:flex; gap:10px;">
1223
+ <button type="submit" class="btn btn-primary">Save {html.escape(m_admin.title)}</button>
1224
+ </div>
1225
+ </form>
1226
+ </div>
1227
+ """
1228
+ html_str = _render_base_layout(
1229
+ site,
1230
+ f"Create {m_admin.title}",
1231
+ body,
1232
+ admin_user=admin_user,
1233
+ prefix=clean_prefix,
1234
+ active_slug=slug,
1235
+ flash_error=f"Validation error: {err_msg}" if is_debug else err_msg,
1236
+ )
1237
+ return _html_response(html_str, status_code=422)
1238
+
1239
+ try:
1240
+ instance = model_cls(**data)
1241
+ await instance.save()
1242
+ except _ADMIN_DB_ERRORS as exc:
1243
+ logger.exception("Failed to create record for %s", slug)
1244
+ err_msg = _format_admin_error(exc, generic="Failed to save record.", debug=is_debug)
1245
+ if _wants_json(request):
1246
+ return json_response({"error": err_msg}, status_code=422)
1247
+ csrf_t = session_store.generate_csrf_token(admin_user)
1248
+ form_fields_html = await _build_form_inputs(m_admin, values=payload, csrf_token=csrf_t)
1249
+ body = f"""
1250
+ <div class="page-header">
1251
+ <h1>Create {html.escape(m_admin.title)}</h1>
1252
+ </div>
1253
+ <div class="card" style="padding: 28px; max-width: 650px;">
1254
+ <form action="{create_action}" method="POST">
1255
+ {form_fields_html}
1256
+ <div style="margin-top:24px; display:flex; gap:10px;">
1257
+ <button type="submit" class="btn btn-primary">Save {html.escape(m_admin.title)}</button>
1258
+ </div>
1259
+ </form>
1260
+ </div>
1261
+ """
1262
+ html_str = _render_base_layout(
1263
+ site,
1264
+ f"Create {m_admin.title}",
1265
+ body,
1266
+ admin_user=admin_user,
1267
+ prefix=clean_prefix,
1268
+ active_slug=slug,
1269
+ flash_error=err_msg,
1270
+ )
1271
+ return _html_response(html_str, status_code=422)
1272
+
1273
+ if _wants_json(request):
1274
+ return json_response(
1275
+ instance.to_dict() if hasattr(instance, "to_dict") else getattr(instance, "_attributes", {}),
1276
+ status_code=201,
1277
+ )
1278
+ return _redirect(_admin_url(clean_prefix, slug))
1279
+
1280
+ async def edit_get(request: Request, slug: str, id: str) -> Response:
1281
+ admin_user = get_current_admin(request)
1282
+ if not admin_user:
1283
+ return _redirect(_admin_url(clean_prefix, "login"))
1284
+
1285
+ item = site.get_by_slug(slug)
1286
+ if not item:
1287
+ return not_found(f"Model '{slug}' not found.")
1288
+ model_cls, m_admin = item
1289
+
1290
+ if not m_admin.has_edit_permission(request, admin_user):
1291
+ return _forbidden_response(request, site, admin_user, clean_prefix)
1292
+
1293
+ pk_col = getattr(model_cls, "__primary_key__", "id")
1294
+ pk_val = _coerce_pk(m_admin, id)
1295
+ record = await model_cls.query().where(pk_col, pk_val).first()
1296
+ if not record:
1297
+ return not_found(f"{m_admin.title} with ID {id} not found.")
1298
+
1299
+ current_values: dict[str, Any] = {}
1300
+ for f in m_admin.get_fields():
1301
+ val = getattr(record, f.name, None)
1302
+ if val is not None:
1303
+ current_values[f.name] = val
1304
+ csrf_tok = get_admin_session_store().generate_csrf_token(admin_user)
1305
+ form_fields_html = await _build_form_inputs(m_admin, values=current_values, csrf_token=csrf_tok)
1306
+
1307
+ list_href = html.escape(_admin_url(clean_prefix, slug), quote=True)
1308
+ edit_action = html.escape(_admin_url(clean_prefix, slug, id, "edit"), quote=True)
1309
+ body = f"""
1310
+ <div class="page-header">
1311
+ <h1>Edit {html.escape(m_admin.title)} #{html.escape(str(id))}</h1>
1312
+ <a href="{list_href}" class="btn btn-outline btn-sm">← Back to List</a>
1313
+ </div>
1314
+ <div class="card" style="padding: 28px; max-width: 650px;">
1315
+ <form action="{edit_action}" method="POST">
1316
+ {form_fields_html}
1317
+ <div style="margin-top:24px; display:flex; gap:10px;">
1318
+ <button type="submit" class="btn btn-primary">Update {html.escape(m_admin.title)}</button>
1319
+ <a href="{list_href}" class="btn btn-outline">Cancel</a>
1320
+ </div>
1321
+ </form>
1322
+ </div>
1323
+ """
1324
+ html_str = _render_base_layout(
1325
+ site,
1326
+ f"Edit {m_admin.title} #{id}",
1327
+ body,
1328
+ admin_user=admin_user,
1329
+ prefix=clean_prefix,
1330
+ active_slug=slug,
1331
+ )
1332
+ return _html_response(html_str)
1333
+
1334
+ async def edit_post(request: Request, slug: str, id: str) -> Response:
1335
+ admin_user = get_current_admin(request)
1336
+ if not admin_user:
1337
+ if _wants_json(request):
1338
+ return json_response({"detail": "Admin authentication required."}, status_code=401)
1339
+ return _redirect(_admin_url(clean_prefix, "login"))
1340
+
1341
+ item = site.get_by_slug(slug)
1342
+ if not item:
1343
+ return not_found(f"Model '{slug}' not found.")
1344
+ model_cls, m_admin = item
1345
+
1346
+ if not m_admin.has_edit_permission(request, admin_user):
1347
+ return _forbidden_response(request, site, admin_user, clean_prefix)
1348
+
1349
+ pk_col = getattr(model_cls, "__primary_key__", "id")
1350
+ pk_val = _coerce_pk(m_admin, id)
1351
+ record = await model_cls.query().where(pk_col, pk_val).first()
1352
+ if not record:
1353
+ return not_found(f"{m_admin.title} with ID {id} not found.")
1354
+
1355
+ payload = await _extract_payload(request)
1356
+ session_store = get_admin_session_store()
1357
+ csrf_tok = payload.get("_csrf_token") or request.headers.get("x-csrf-token")
1358
+ if not session_store.verify_csrf_token(csrf_tok, admin_user):
1359
+ return _forbidden_response(
1360
+ request,
1361
+ site,
1362
+ admin_user,
1363
+ clean_prefix,
1364
+ "CSRF verification failed: missing or invalid CSRF token.",
1365
+ )
1366
+
1367
+ is_debug = _is_admin_debug_mode(debug)
1368
+ edit_action = html.escape(_admin_url(clean_prefix, slug, id, "edit"), quote=True)
1369
+ fields = m_admin.get_form_fields()
1370
+ protected_fields = {m_admin.primary_key, "created_at", "updated_at"}
1371
+
1372
+ for f in fields:
1373
+ if f.name not in protected_fields and f.name in payload:
1374
+ try:
1375
+ setattr(record, f.name, f.coerce_value(payload[f.name]))
1376
+ except ValueError as err:
1377
+ err_msg = _format_admin_error(err, generic="Invalid field value submitted.", debug=is_debug)
1378
+ if _wants_json(request):
1379
+ return json_response({"error": err_msg}, status_code=422)
1380
+ csrf_t = session_store.generate_csrf_token(admin_user)
1381
+ form_fields_html = await _build_form_inputs(m_admin, values=payload, csrf_token=csrf_t)
1382
+ body = f"""
1383
+ <div class="card" style="padding: 28px; max-width: 650px;">
1384
+ <form action="{edit_action}" method="POST">
1385
+ {form_fields_html}
1386
+ <button type="submit" class="btn btn-primary">Update {html.escape(m_admin.title)}</button>
1387
+ </form>
1388
+ </div>
1389
+ """
1390
+ html_str = _render_base_layout(
1391
+ site,
1392
+ f"Edit {m_admin.title} #{id}",
1393
+ body,
1394
+ admin_user=admin_user,
1395
+ prefix=clean_prefix,
1396
+ active_slug=slug,
1397
+ flash_error=f"Validation error: {err_msg}" if is_debug else err_msg,
1398
+ )
1399
+ return _html_response(html_str, status_code=422)
1400
+
1401
+ try:
1402
+ await record.save()
1403
+ except _ADMIN_DB_ERRORS as exc:
1404
+ logger.exception("Failed to update record #%s for %s", id, slug)
1405
+ err_msg = _format_admin_error(exc, generic="Failed to update record.", debug=is_debug)
1406
+ if _wants_json(request):
1407
+ return json_response({"error": err_msg}, status_code=422)
1408
+ csrf_t = session_store.generate_csrf_token(admin_user)
1409
+ form_fields_html = await _build_form_inputs(m_admin, values=payload, csrf_token=csrf_t)
1410
+ body = f"""
1411
+ <div class="card" style="padding: 28px; max-width: 650px;">
1412
+ <form action="{edit_action}" method="POST">
1413
+ {form_fields_html}
1414
+ <button type="submit" class="btn btn-primary">Update {html.escape(m_admin.title)}</button>
1415
+ </form>
1416
+ </div>
1417
+ """
1418
+ html_str = _render_base_layout(
1419
+ site,
1420
+ f"Edit {m_admin.title} #{id}",
1421
+ body,
1422
+ admin_user=admin_user,
1423
+ prefix=clean_prefix,
1424
+ active_slug=slug,
1425
+ flash_error=err_msg,
1426
+ )
1427
+ return _html_response(html_str, status_code=422)
1428
+
1429
+ if _wants_json(request):
1430
+ return json_response(
1431
+ record.to_dict() if hasattr(record, "to_dict") else getattr(record, "_attributes", {})
1432
+ )
1433
+ return _redirect(_admin_url(clean_prefix, slug))
1434
+
1435
+ async def delete_handler(request: Request, slug: str, id: str) -> Response:
1436
+ admin_user = get_current_admin(request)
1437
+ if not admin_user:
1438
+ if _wants_json(request):
1439
+ return json_response({"detail": "Admin authentication required."}, status_code=401)
1440
+ return _redirect(_admin_url(clean_prefix, "login"))
1441
+
1442
+ item = site.get_by_slug(slug)
1443
+ if not item:
1444
+ return not_found(f"Model '{slug}' not found.")
1445
+ model_cls, m_admin = item
1446
+
1447
+ if not m_admin.has_delete_permission(request, admin_user):
1448
+ return _forbidden_response(request, site, admin_user, clean_prefix)
1449
+
1450
+ payload = await _extract_payload(request)
1451
+ session_store = get_admin_session_store()
1452
+ csrf_tok = payload.get("_csrf_token") or request.headers.get("x-csrf-token")
1453
+ if not session_store.verify_csrf_token(csrf_tok, admin_user):
1454
+ return _forbidden_response(
1455
+ request,
1456
+ site,
1457
+ admin_user,
1458
+ clean_prefix,
1459
+ "CSRF verification failed: missing or invalid CSRF token.",
1460
+ )
1461
+
1462
+ pk_col = getattr(model_cls, "__primary_key__", "id")
1463
+ pk_val = _coerce_pk(m_admin, id)
1464
+ record = await model_cls.query().where(pk_col, pk_val).first()
1465
+ if not record:
1466
+ return not_found(f"Record with ID {id} not found.")
1467
+
1468
+ is_debug = _is_admin_debug_mode(debug)
1469
+ try:
1470
+ await record.delete()
1471
+ except _ADMIN_DB_ERRORS as exc:
1472
+ logger.exception("Failed to delete record #%s for %s", id, slug)
1473
+ err_msg = _format_admin_error(exc, generic="Failed to delete record.", debug=is_debug)
1474
+ if _wants_json(request):
1475
+ return json_response({"error": err_msg}, status_code=500)
1476
+ list_href = html.escape(_admin_url(clean_prefix, slug), quote=True)
1477
+ html_str = _render_base_layout(
1478
+ site,
1479
+ f"Delete {m_admin.title} Failed",
1480
+ f'<div class="card" style="padding:24px;"><p>Unable to delete the requested record.</p><a href="{list_href}" class="btn btn-outline btn-sm">← Back to List</a></div>',
1481
+ admin_user=admin_user,
1482
+ prefix=clean_prefix,
1483
+ active_slug=slug,
1484
+ flash_error=err_msg,
1485
+ )
1486
+ return _html_response(html_str, status_code=500)
1487
+
1488
+ if _wants_json(request):
1489
+ return json_response({"message": f"Record #{id} deleted successfully."})
1490
+ return _redirect(_admin_url(clean_prefix, slug))
1491
+
1492
+ # Register CRUD routes
1493
+ router.add_route("GET", f"{clean_prefix}/{{slug}}", list_view)
1494
+ router.add_route("GET", f"{clean_prefix}/{{slug}}/create", create_get)
1495
+ router.add_route("POST", f"{clean_prefix}/{{slug}}/create", create_post)
1496
+ router.add_route("GET", f"{clean_prefix}/{{slug}}/{{id}}/edit", edit_get)
1497
+ router.add_route("POST", f"{clean_prefix}/{{slug}}/{{id}}/edit", edit_post)
1498
+ router.add_route("POST", f"{clean_prefix}/{{slug}}/{{id}}/delete", delete_handler)
1499
+ router.add_route("DELETE", f"{clean_prefix}/{{slug}}/{{id}}", delete_handler)
1500
+
1501
+ return router
1502
+
1503
+
1504
+ async def _build_form_inputs(
1505
+ m_admin: ModelAdmin,
1506
+ values: dict[str, Any] | None = None,
1507
+ csrf_token: str | None = None,
1508
+ ) -> str:
1509
+ """Dynamically generate form inputs for model declared fields and relationships."""
1510
+ current_values = values or {}
1511
+ fields = m_admin.get_form_fields()
1512
+ relationships = m_admin.get_relationships()
1513
+ html_parts: list[str] = []
1514
+
1515
+ if csrf_token:
1516
+ html_parts.append(
1517
+ f'<input type="hidden" name="_csrf_token" value="{html.escape(csrf_token, quote=True)}">'
1518
+ )
1519
+
1520
+ for f in fields:
1521
+ val = current_values.get(f.name, f.default if f.default is not None else "")
1522
+ field_id = f"field_{f.name}"
1523
+ label_text = html.escape(f.label)
1524
+
1525
+ # Relationship select input for BelongsTo foreign keys
1526
+ if f.is_foreign_key and f.foreign_relation_name:
1527
+ rel_meta = relationships.get(f.foreign_relation_name)
1528
+ options_html = ['<option value="">-- Select --</option>']
1529
+ if rel_meta:
1530
+ try:
1531
+ target_model = rel_meta.get_target_model()
1532
+ if target_model is not None:
1533
+ target_records = await target_model.query().get()
1534
+ target_pk = getattr(target_model, "__primary_key__", "id")
1535
+
1536
+ for t_rec in target_records:
1537
+ t_pk_val = getattr(t_rec, target_pk, None)
1538
+ selected = ' selected="selected"' if str(t_pk_val) == str(val) else ""
1539
+ opt_text = html.escape(_model_repr(t_rec), quote=True)
1540
+ escaped_pk = html.escape(str(t_pk_val), quote=True)
1541
+ options_html.append(f'<option value="{escaped_pk}"{selected}>{opt_text}</option>')
1542
+ except (RuntimeError, OSError, ValueError) as exc:
1543
+ logger.debug("Failed to query target relation records: %s", exc)
1544
+
1545
+ input_html = f"""
1546
+ <select id="{html.escape(field_id, quote=True)}" name="{html.escape(f.name, quote=True)}" class="form-control">
1547
+ {''.join(options_html)}
1548
+ </select>
1549
+ """
1550
+ elif f.input_type == "textarea":
1551
+ input_html = f'<textarea id="{html.escape(field_id, quote=True)}" name="{html.escape(f.name, quote=True)}" class="form-control">{html.escape(str(val), quote=True)}</textarea>'
1552
+ elif f.input_type == "checkbox":
1553
+ checked = "checked" if val in (True, "true", "1", 1, "on") else ""
1554
+ input_html = f'<input type="checkbox" id="{html.escape(field_id, quote=True)}" name="{html.escape(f.name, quote=True)}" value="true" {checked} style="width:18px; height:18px;">'
1555
+ else:
1556
+ val_str = html.escape(str(val), quote=True) if val != "" else ""
1557
+ input_html = f'<input type="{html.escape(f.input_type, quote=True)}" id="{html.escape(field_id, quote=True)}" name="{html.escape(f.name, quote=True)}" value="{val_str}" class="form-control">'
1558
+
1559
+ html_parts.append(f"""
1560
+ <div class="form-group">
1561
+ <label for="{html.escape(field_id, quote=True)}">{label_text}</label>
1562
+ {input_html}
1563
+ </div>
1564
+ """)
1565
+
1566
+ return "\n".join(html_parts)
1567
+
1568
+
1569
+ def compile_admin_routes(site: AdminSite, app: Application, prefix: str = "/admin") -> None:
1570
+ """Mount the compiled admin router directly onto a SwiftPY Application."""
1571
+ router = create_admin_router(site, prefix=prefix, debug=app.debug)
1572
+ # Merge admin routes into application's router
1573
+ for route in router.routes:
1574
+ app.router.add_compiled_route(route)
1575
+
1576
+
1577
+ # Wire route compiler into AdminSite
1578
+ set_admin_route_compiler(compile_admin_routes, create_admin_router)
1579
+
1580
+ __all__ = [
1581
+ "compile_admin_routes",
1582
+ "create_admin_router",
1583
+ ]