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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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 • {ai_stats.get('total_tokens', 0):,} tokens • ${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">« 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 »</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 • {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} • 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">« 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 »</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} • 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
|
+
]
|