pillar-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.
- pillar/__init__.py +112 -0
- pillar/admin.py +455 -0
- pillar/ai.py +271 -0
- pillar/ai_tools.py +250 -0
- pillar/app.py +514 -0
- pillar/architecture/__init__.py +3 -0
- pillar/architecture/enforcer.py +102 -0
- pillar/auth.py +143 -0
- pillar/cli/__init__.py +3 -0
- pillar/cli/main.py +671 -0
- pillar/config.py +183 -0
- pillar/controller.py +278 -0
- pillar/dashboard.py +471 -0
- pillar/db/__init__.py +3 -0
- pillar/db/async_db.py +200 -0
- pillar/db/database.py +113 -0
- pillar/db/rls.py +272 -0
- pillar/db/sync.py +255 -0
- pillar/di.py +95 -0
- pillar/exceptions.py +51 -0
- pillar/metrics.py +118 -0
- pillar/middleware.py +187 -0
- pillar/openapi.py +740 -0
- pillar/py.typed +0 -0
- pillar/queue/__init__.py +5 -0
- pillar/queue/decorators.py +68 -0
- pillar/queue/storage.py +239 -0
- pillar/queue/worker.py +200 -0
- pillar/realtime.py +281 -0
- pillar/responses.py +138 -0
- pillar/router.py +342 -0
- pillar/security.py +252 -0
- pillar/telemetry.py +293 -0
- pillar/tracer.py +380 -0
- pillar/vector.py +328 -0
- pillar_framework-0.1.0.dist-info/METADATA +296 -0
- pillar_framework-0.1.0.dist-info/RECORD +40 -0
- pillar_framework-0.1.0.dist-info/WHEEL +5 -0
- pillar_framework-0.1.0.dist-info/entry_points.txt +2 -0
- pillar_framework-0.1.0.dist-info/top_level.txt +1 -0
pillar/__init__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pillar — Production-Grade Python Backend Framework
|
|
3
|
+
|
|
4
|
+
Public API surface:
|
|
5
|
+
|
|
6
|
+
from pillar import Pillar, Router, background_task
|
|
7
|
+
from pillar import ok, created, paginate, problem, no_content
|
|
8
|
+
from pillar.db import Database
|
|
9
|
+
from pillar.exceptions import NotFoundError, UnauthorizedError, ...
|
|
10
|
+
from pillar.di import container
|
|
11
|
+
from pillar.metrics import metrics
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .app import Pillar
|
|
15
|
+
from .router import Router
|
|
16
|
+
from .controller import Controller, action
|
|
17
|
+
from .queue.decorators import background_task
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
PillarError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
UnauthorizedError,
|
|
22
|
+
ForbiddenError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
ConflictError,
|
|
25
|
+
PillarContractError,
|
|
26
|
+
ArchitectureViolationError,
|
|
27
|
+
)
|
|
28
|
+
from .di import container, DIContainer
|
|
29
|
+
from .config import PillarConfig
|
|
30
|
+
from .responses import ok, created, no_content, paginate, problem, PaginatedResponse
|
|
31
|
+
from .metrics import metrics
|
|
32
|
+
from .ai import PillarAI
|
|
33
|
+
from .security import JWTMiddleware, encode_jwt, decode_jwt, RequireAuth
|
|
34
|
+
from .telemetry import setup_telemetry, trace_span, TelemetryMiddleware
|
|
35
|
+
from .tracer import span_context, current_trace_id, record_span
|
|
36
|
+
from .db.rls import RLSDatabase, set_tenant, get_tenant
|
|
37
|
+
from .db.async_db import AsyncDatabase
|
|
38
|
+
from .auth import require_role, require_permission, require_all_roles
|
|
39
|
+
from .ai_tools import ai_tool, manifest as ai_manifest
|
|
40
|
+
from .realtime import hub, PillarHub
|
|
41
|
+
from .admin import admin, PillarAdmin
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Core
|
|
47
|
+
"Pillar",
|
|
48
|
+
"Router",
|
|
49
|
+
"background_task",
|
|
50
|
+
# Exceptions
|
|
51
|
+
"PillarError",
|
|
52
|
+
"NotFoundError",
|
|
53
|
+
"UnauthorizedError",
|
|
54
|
+
"ForbiddenError",
|
|
55
|
+
"ValidationError",
|
|
56
|
+
"ConflictError",
|
|
57
|
+
"PillarContractError",
|
|
58
|
+
"ArchitectureViolationError",
|
|
59
|
+
# DI
|
|
60
|
+
"container",
|
|
61
|
+
"DIContainer",
|
|
62
|
+
# Config
|
|
63
|
+
"PillarConfig",
|
|
64
|
+
# Response helpers
|
|
65
|
+
"ok",
|
|
66
|
+
"created",
|
|
67
|
+
"no_content",
|
|
68
|
+
"paginate",
|
|
69
|
+
"problem",
|
|
70
|
+
"PaginatedResponse",
|
|
71
|
+
# Metrics
|
|
72
|
+
"metrics",
|
|
73
|
+
# Controller-based routing
|
|
74
|
+
"Controller",
|
|
75
|
+
"action",
|
|
76
|
+
# AI-native extraction
|
|
77
|
+
"PillarAI",
|
|
78
|
+
# Security
|
|
79
|
+
"JWTMiddleware",
|
|
80
|
+
"encode_jwt",
|
|
81
|
+
"decode_jwt",
|
|
82
|
+
"RequireAuth",
|
|
83
|
+
# Telemetry (OTel)
|
|
84
|
+
"setup_telemetry",
|
|
85
|
+
"trace_span",
|
|
86
|
+
"TelemetryMiddleware",
|
|
87
|
+
# Time-travel tracer (built-in)
|
|
88
|
+
"span_context",
|
|
89
|
+
"current_trace_id",
|
|
90
|
+
"record_span",
|
|
91
|
+
# Auto RLS
|
|
92
|
+
"RLSDatabase",
|
|
93
|
+
"set_tenant",
|
|
94
|
+
"get_tenant",
|
|
95
|
+
# Async DB
|
|
96
|
+
"AsyncDatabase",
|
|
97
|
+
# RBAC
|
|
98
|
+
"require_role",
|
|
99
|
+
"require_permission",
|
|
100
|
+
"require_all_roles",
|
|
101
|
+
# AI tool registry
|
|
102
|
+
"ai_tool",
|
|
103
|
+
"ai_manifest",
|
|
104
|
+
# Real-time hub
|
|
105
|
+
"hub",
|
|
106
|
+
"PillarHub",
|
|
107
|
+
# Admin
|
|
108
|
+
"admin",
|
|
109
|
+
"PillarAdmin",
|
|
110
|
+
# Version
|
|
111
|
+
"__version__",
|
|
112
|
+
]
|
pillar/admin.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PillarAdmin — zero-config auto-CRUD admin dashboard.
|
|
3
|
+
|
|
4
|
+
Register any database table and get a fully-functional admin UI:
|
|
5
|
+
• List view with sortable columns, search, pagination
|
|
6
|
+
• Create / Edit / Delete forms (HTMX-powered, no page reload)
|
|
7
|
+
• Respects Row-Level Security (RLS) when active
|
|
8
|
+
• Dark theme, zero CDN dependencies (all styles/scripts inline)
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from pillar.admin import admin
|
|
13
|
+
from pillar import Pillar
|
|
14
|
+
|
|
15
|
+
app = Pillar(title="My App")
|
|
16
|
+
admin.register("users", table="users", pk="id", search_cols=["name", "email"])
|
|
17
|
+
admin.register("orders", table="orders", pk="id", list_cols=["id","status","total"])
|
|
18
|
+
admin.mount(app, prefix="/admin")
|
|
19
|
+
|
|
20
|
+
# Navigate to http://localhost:8000/admin
|
|
21
|
+
|
|
22
|
+
Protect the admin with JWT roles::
|
|
23
|
+
|
|
24
|
+
admin.mount(app, prefix="/admin", require_role="admin")
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import html
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
32
|
+
|
|
33
|
+
from starlette.requests import Request
|
|
34
|
+
from starlette.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("pillar.admin")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
40
|
+
# Registration config
|
|
41
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
class _ModelConfig:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
name: str,
|
|
47
|
+
table: str,
|
|
48
|
+
pk: str = "id",
|
|
49
|
+
list_cols: Optional[Sequence[str]] = None,
|
|
50
|
+
search_cols: Optional[Sequence[str]] = None,
|
|
51
|
+
readonly_cols: Optional[Sequence[str]] = None,
|
|
52
|
+
label: str = "",
|
|
53
|
+
icon: str = "🗄️",
|
|
54
|
+
) -> None:
|
|
55
|
+
self.name = name
|
|
56
|
+
self.table = table
|
|
57
|
+
self.pk = pk
|
|
58
|
+
self.list_cols = list(list_cols) if list_cols else []
|
|
59
|
+
self.search_cols = list(search_cols) if search_cols else []
|
|
60
|
+
self.readonly_cols = set(readonly_cols or [pk])
|
|
61
|
+
self.label = label or name.replace("_", " ").title()
|
|
62
|
+
self.icon = icon
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
66
|
+
# Admin controller
|
|
67
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
class PillarAdmin:
|
|
70
|
+
"""
|
|
71
|
+
Auto-CRUD admin dashboard.
|
|
72
|
+
|
|
73
|
+
Mount once with ``admin.mount(app)``. Each registered table gets its own
|
|
74
|
+
list/detail/create/edit/delete views under the admin prefix.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
self._models: Dict[str, _ModelConfig] = {}
|
|
79
|
+
self._prefix = "/admin"
|
|
80
|
+
self._require_role: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Public API
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def register(
|
|
87
|
+
self,
|
|
88
|
+
name: str,
|
|
89
|
+
*,
|
|
90
|
+
table: str = "",
|
|
91
|
+
pk: str = "id",
|
|
92
|
+
list_cols: Optional[Sequence[str]] = None,
|
|
93
|
+
search_cols: Optional[Sequence[str]] = None,
|
|
94
|
+
readonly_cols: Optional[Sequence[str]] = None,
|
|
95
|
+
label: str = "",
|
|
96
|
+
icon: str = "🗄️",
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Register a database table for the admin dashboard."""
|
|
99
|
+
self._models[name] = _ModelConfig(
|
|
100
|
+
name=name,
|
|
101
|
+
table=table or name,
|
|
102
|
+
pk=pk,
|
|
103
|
+
list_cols=list_cols,
|
|
104
|
+
search_cols=search_cols,
|
|
105
|
+
readonly_cols=readonly_cols,
|
|
106
|
+
label=label,
|
|
107
|
+
icon=icon,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def mount(self, app: Any, *, prefix: str = "/admin", require_role: str = None) -> None:
|
|
111
|
+
"""Mount the admin ASGI handler on *app* at *prefix*."""
|
|
112
|
+
self._prefix = prefix.rstrip("/")
|
|
113
|
+
self._require_role = require_role
|
|
114
|
+
app._admin = self
|
|
115
|
+
# Inject the admin routes into the app's core ASGI handler
|
|
116
|
+
_original_route = app._route_http
|
|
117
|
+
|
|
118
|
+
async def _patched_route(method: str, path: str, scope: dict, receive: Any) -> Response:
|
|
119
|
+
if path.startswith(self._prefix):
|
|
120
|
+
return await self._handle(method, path, scope, receive)
|
|
121
|
+
return await _original_route(method, path, scope, receive)
|
|
122
|
+
|
|
123
|
+
app._route_http = _patched_route
|
|
124
|
+
logger.info("PillarAdmin mounted at %s (%d models)", self._prefix, len(self._models))
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Request dispatch
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
async def _handle(self, method: str, path: str, scope: dict, receive: Any) -> Response:
|
|
131
|
+
# Auth check
|
|
132
|
+
if self._require_role:
|
|
133
|
+
user = scope.get("user") or {}
|
|
134
|
+
roles = user.get("roles", [])
|
|
135
|
+
if isinstance(roles, str):
|
|
136
|
+
roles = [roles]
|
|
137
|
+
if self._require_role not in roles:
|
|
138
|
+
return JSONResponse({"detail": "Admin access required"}, status_code=403)
|
|
139
|
+
|
|
140
|
+
sub = path[len(self._prefix):]
|
|
141
|
+
sub = sub.lstrip("/") or ""
|
|
142
|
+
parts = sub.split("/") if sub else []
|
|
143
|
+
|
|
144
|
+
from .db.database import Database
|
|
145
|
+
from .di import container as _c
|
|
146
|
+
try:
|
|
147
|
+
db: Database = _c.resolve(Database)
|
|
148
|
+
except Exception:
|
|
149
|
+
db = Database()
|
|
150
|
+
|
|
151
|
+
# GET /admin → dashboard index
|
|
152
|
+
if method == "GET" and not parts[0:1]:
|
|
153
|
+
return HTMLResponse(self._index_html())
|
|
154
|
+
|
|
155
|
+
# GET /admin/{model} → list view
|
|
156
|
+
if method == "GET" and len(parts) == 1 and parts[0] in self._models:
|
|
157
|
+
return await self._list_view(parts[0], scope, db)
|
|
158
|
+
|
|
159
|
+
# GET /admin/{model}/new → create form
|
|
160
|
+
if method == "GET" and len(parts) == 2 and parts[0] in self._models and parts[1] == "new":
|
|
161
|
+
return await self._create_form(parts[0], db)
|
|
162
|
+
|
|
163
|
+
# POST /admin/{model}/new → insert row
|
|
164
|
+
if method == "POST" and len(parts) == 2 and parts[0] in self._models and parts[1] == "new":
|
|
165
|
+
return await self._do_create(parts[0], scope, receive, db)
|
|
166
|
+
|
|
167
|
+
# GET /admin/{model}/{pk} → edit form
|
|
168
|
+
if method == "GET" and len(parts) == 2 and parts[0] in self._models:
|
|
169
|
+
return await self._edit_form(parts[0], parts[1], db)
|
|
170
|
+
|
|
171
|
+
# POST /admin/{model}/{pk} → update row
|
|
172
|
+
if method == "POST" and len(parts) == 2 and parts[0] in self._models:
|
|
173
|
+
return await self._do_update(parts[0], parts[1], scope, receive, db)
|
|
174
|
+
|
|
175
|
+
# DELETE /admin/{model}/{pk} or POST .../delete
|
|
176
|
+
if method == "DELETE" and len(parts) == 2 and parts[0] in self._models:
|
|
177
|
+
return await self._do_delete(parts[0], parts[1], db)
|
|
178
|
+
if method == "POST" and len(parts) == 3 and parts[0] in self._models and parts[2] == "delete":
|
|
179
|
+
return await self._do_delete(parts[0], parts[1], db)
|
|
180
|
+
|
|
181
|
+
return JSONResponse({"detail": "Not found"}, status_code=404)
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# List view
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def _list_view(self, name: str, scope: dict, db: Any) -> HTMLResponse:
|
|
188
|
+
cfg = self._models[name]
|
|
189
|
+
qs = scope.get("query_string", b"").decode()
|
|
190
|
+
query = {}
|
|
191
|
+
for part in qs.split("&"):
|
|
192
|
+
if "=" in part:
|
|
193
|
+
k, v = part.split("=", 1)
|
|
194
|
+
query[k] = v
|
|
195
|
+
|
|
196
|
+
search = query.get("q", "").strip()
|
|
197
|
+
page = max(1, int(query.get("page", "1") or "1"))
|
|
198
|
+
limit = 25
|
|
199
|
+
offset = (page - 1) * limit
|
|
200
|
+
|
|
201
|
+
sql = f"SELECT * FROM {cfg.table}"
|
|
202
|
+
params: list = []
|
|
203
|
+
|
|
204
|
+
if search and cfg.search_cols:
|
|
205
|
+
conditions = " OR ".join(f"{col} LIKE ?" for col in cfg.search_cols)
|
|
206
|
+
sql += f" WHERE {conditions}"
|
|
207
|
+
params.extend([f"%{search}%"] * len(cfg.search_cols))
|
|
208
|
+
|
|
209
|
+
sql += f" LIMIT {limit} OFFSET {offset}"
|
|
210
|
+
rows = db.query_all(sql, tuple(params))
|
|
211
|
+
cols = list(rows[0].keys()) if rows else cfg.list_cols or []
|
|
212
|
+
if cfg.list_cols:
|
|
213
|
+
cols = [c for c in cfg.list_cols if c in (rows[0].keys() if rows else cfg.list_cols)]
|
|
214
|
+
|
|
215
|
+
return HTMLResponse(self._list_html(cfg, rows, cols, search, page))
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Create form
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
async def _create_form(self, name: str, db: Any) -> HTMLResponse:
|
|
222
|
+
cfg = self._models[name]
|
|
223
|
+
# Infer columns from the table
|
|
224
|
+
cols = self._get_columns(cfg, db)
|
|
225
|
+
return HTMLResponse(self._form_html(cfg, cols, {}, is_new=True))
|
|
226
|
+
|
|
227
|
+
async def _do_create(self, name: str, scope: dict, receive: Any, db: Any) -> Response:
|
|
228
|
+
cfg = self._models[name]
|
|
229
|
+
data = await self._parse_form(scope, receive)
|
|
230
|
+
cols = [k for k in data if k not in cfg.readonly_cols]
|
|
231
|
+
if not cols:
|
|
232
|
+
return JSONResponse({"detail": "No fields to insert"}, status_code=400)
|
|
233
|
+
placeholders = ", ".join("?" * len(cols))
|
|
234
|
+
col_str = ", ".join(cols)
|
|
235
|
+
vals = tuple(data[c] for c in cols)
|
|
236
|
+
db.execute(f"INSERT INTO {cfg.table} ({col_str}) VALUES ({placeholders})", vals)
|
|
237
|
+
return RedirectResponse(f"{self._prefix}/{name}", status_code=303)
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# Edit form
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
async def _edit_form(self, name: str, pk_val: str, db: Any) -> Response:
|
|
244
|
+
cfg = self._models[name]
|
|
245
|
+
row = db.query(f"SELECT * FROM {cfg.table} WHERE {cfg.pk} = ?", (pk_val,))
|
|
246
|
+
if row is None:
|
|
247
|
+
return JSONResponse({"detail": "Not found"}, status_code=404)
|
|
248
|
+
cols = list(row.keys())
|
|
249
|
+
return HTMLResponse(self._form_html(cfg, cols, row, is_new=False))
|
|
250
|
+
|
|
251
|
+
async def _do_update(self, name: str, pk_val: str, scope: dict, receive: Any, db: Any) -> Response:
|
|
252
|
+
cfg = self._models[name]
|
|
253
|
+
data = await self._parse_form(scope, receive)
|
|
254
|
+
cols = [k for k in data if k not in cfg.readonly_cols]
|
|
255
|
+
if not cols:
|
|
256
|
+
return RedirectResponse(f"{self._prefix}/{name}", status_code=303)
|
|
257
|
+
set_str = ", ".join(f"{c} = ?" for c in cols)
|
|
258
|
+
vals = tuple(data[c] for c in cols) + (pk_val,)
|
|
259
|
+
db.execute(f"UPDATE {cfg.table} SET {set_str} WHERE {cfg.pk} = ?", vals)
|
|
260
|
+
return RedirectResponse(f"{self._prefix}/{name}", status_code=303)
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
# Delete
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
async def _do_delete(self, name: str, pk_val: str, db: Any) -> Response:
|
|
267
|
+
cfg = self._models[name]
|
|
268
|
+
db.execute(f"DELETE FROM {cfg.table} WHERE {cfg.pk} = ?", (pk_val,))
|
|
269
|
+
return RedirectResponse(f"{self._prefix}/{name}", status_code=303)
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
# Utility
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def _get_columns(self, cfg: _ModelConfig, db: Any) -> List[str]:
|
|
276
|
+
if cfg.list_cols:
|
|
277
|
+
return cfg.list_cols
|
|
278
|
+
try:
|
|
279
|
+
row = db.query(f"SELECT * FROM {cfg.table} LIMIT 1")
|
|
280
|
+
if row:
|
|
281
|
+
return list(row.keys())
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
return [cfg.pk]
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
async def _parse_form(scope: dict, receive: Any) -> dict:
|
|
288
|
+
from urllib.parse import parse_qs
|
|
289
|
+
body_parts: list = []
|
|
290
|
+
while True:
|
|
291
|
+
msg = await receive()
|
|
292
|
+
body_parts.append(msg.get("body", b""))
|
|
293
|
+
if not msg.get("more_body"):
|
|
294
|
+
break
|
|
295
|
+
raw = b"".join(body_parts).decode("utf-8", errors="replace")
|
|
296
|
+
parsed = parse_qs(raw, keep_blank_values=True)
|
|
297
|
+
return {k: v[0] for k, v in parsed.items()}
|
|
298
|
+
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
# HTML generation
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def _css(self) -> str:
|
|
304
|
+
return """
|
|
305
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
306
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
|
|
307
|
+
a{color:#818cf8;text-decoration:none}a:hover{text-decoration:underline}
|
|
308
|
+
.nav{background:#1e293b;border-bottom:1px solid #334155;padding:12px 24px;display:flex;align-items:center;gap:16px}
|
|
309
|
+
.nav h1{font-size:1.1rem;font-weight:700;color:#f8fafc}
|
|
310
|
+
.nav .pill{background:#3730a3;color:#c7d2fe;padding:2px 10px;border-radius:9999px;font-size:.75rem}
|
|
311
|
+
.sidebar{width:220px;background:#1e293b;min-height:calc(100vh - 49px);padding:16px;border-right:1px solid #334155;position:fixed;top:49px}
|
|
312
|
+
.sidebar .model-link{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;color:#cbd5e1;margin-bottom:4px;font-size:.875rem;transition:background .15s}
|
|
313
|
+
.sidebar .model-link:hover,.sidebar .model-link.active{background:#0f172a;color:#f8fafc}
|
|
314
|
+
.content{margin-left:220px;padding:24px}
|
|
315
|
+
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;overflow:hidden}
|
|
316
|
+
.card-header{padding:16px 20px;border-bottom:1px solid #334155;display:flex;align-items:center;justify-content:space-between}
|
|
317
|
+
.card-header h2{font-size:1rem;font-weight:600;color:#f8fafc}
|
|
318
|
+
table{width:100%;border-collapse:collapse}
|
|
319
|
+
th{padding:10px 16px;text-align:left;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;background:#0f172a;border-bottom:1px solid #334155}
|
|
320
|
+
td{padding:10px 16px;font-size:.875rem;border-bottom:1px solid #1e293b;color:#cbd5e1}
|
|
321
|
+
tr:last-child td{border-bottom:none}
|
|
322
|
+
tr:hover td{background:#0f172a}
|
|
323
|
+
.btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:8px;font-size:.8rem;font-weight:500;cursor:pointer;border:none;transition:all .15s;text-decoration:none}
|
|
324
|
+
.btn-primary{background:#4f46e5;color:#fff}.btn-primary:hover{background:#4338ca;color:#fff}
|
|
325
|
+
.btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c;color:#fff}
|
|
326
|
+
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}.btn-ghost:hover{background:#0f172a;color:#f8fafc}
|
|
327
|
+
.search-bar{display:flex;gap:8px;margin-bottom:16px}
|
|
328
|
+
.search-bar input{flex:1;background:#0f172a;border:1px solid #334155;border-radius:8px;padding:8px 12px;color:#e2e8f0;font-size:.875rem;outline:none}
|
|
329
|
+
.search-bar input:focus{border-color:#4f46e5}
|
|
330
|
+
.form-group{margin-bottom:16px}
|
|
331
|
+
.form-group label{display:block;font-size:.8rem;color:#94a3b8;margin-bottom:4px}
|
|
332
|
+
.form-group input,.form-group textarea,.form-group select{width:100%;background:#0f172a;border:1px solid #334155;border-radius:8px;padding:8px 12px;color:#e2e8f0;font-size:.875rem;outline:none}
|
|
333
|
+
.form-group input:focus,.form-group textarea:focus{border-color:#4f46e5}
|
|
334
|
+
.form-group input[readonly]{color:#64748b;cursor:not-allowed}
|
|
335
|
+
.form-actions{display:flex;gap:8px;padding-top:8px}
|
|
336
|
+
.pagination{display:flex;gap:4px;justify-content:flex-end;margin-top:16px}
|
|
337
|
+
.pagination a{padding:4px 10px;border-radius:6px;background:#1e293b;border:1px solid #334155;font-size:.8rem}
|
|
338
|
+
.empty{padding:48px;text-align:center;color:#64748b;font-size:.9rem}
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
def _base(self, title: str, body: str) -> str:
|
|
342
|
+
nav_models = "".join(
|
|
343
|
+
f'<a class="model-link" href="{self._prefix}/{m.name}">{m.icon} {m.label}</a>'
|
|
344
|
+
for m in self._models.values()
|
|
345
|
+
)
|
|
346
|
+
return f"""<!DOCTYPE html>
|
|
347
|
+
<html lang="en">
|
|
348
|
+
<head>
|
|
349
|
+
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
350
|
+
<title>{html.escape(title)} — PillarAdmin</title>
|
|
351
|
+
<style>{self._css()}</style>
|
|
352
|
+
</head>
|
|
353
|
+
<body>
|
|
354
|
+
<nav class="nav">
|
|
355
|
+
<h1>⚡ PillarAdmin</h1>
|
|
356
|
+
<span class="pill">v0.1</span>
|
|
357
|
+
</nav>
|
|
358
|
+
<div class="sidebar">{nav_models}</div>
|
|
359
|
+
<div class="content">{body}</div>
|
|
360
|
+
</body>
|
|
361
|
+
</html>"""
|
|
362
|
+
|
|
363
|
+
def _index_html(self) -> str:
|
|
364
|
+
cards = "".join(
|
|
365
|
+
f"""<div class="card" style="margin-bottom:12px">
|
|
366
|
+
<div class="card-header">
|
|
367
|
+
<h2>{m.icon} {html.escape(m.label)}</h2>
|
|
368
|
+
<a href="{self._prefix}/{m.name}" class="btn btn-ghost">View →</a>
|
|
369
|
+
</div>
|
|
370
|
+
</div>"""
|
|
371
|
+
for m in self._models.values()
|
|
372
|
+
)
|
|
373
|
+
body = f"""<h2 style="margin-bottom:20px;font-size:1.25rem;font-weight:700;">Dashboard</h2>
|
|
374
|
+
{cards or '<p style="color:#64748b">No models registered. Call admin.register() first.</p>'}"""
|
|
375
|
+
return self._base("Dashboard", body)
|
|
376
|
+
|
|
377
|
+
def _list_html(self, cfg: _ModelConfig, rows: list, cols: list, search: str, page: int) -> str:
|
|
378
|
+
search_form = f"""
|
|
379
|
+
<form method="get" class="search-bar">
|
|
380
|
+
<input name="q" value="{html.escape(search)}" placeholder="Search…">
|
|
381
|
+
<button type="submit" class="btn btn-primary">Search</button>
|
|
382
|
+
<a href="{self._prefix}/{cfg.name}" class="btn btn-ghost">Clear</a>
|
|
383
|
+
</form>"""
|
|
384
|
+
|
|
385
|
+
if not rows:
|
|
386
|
+
table_html = f'<div class="empty">No records found{" for " + html.escape(search) if search else ""}.</div>'
|
|
387
|
+
else:
|
|
388
|
+
th_cells = "".join(f"<th>{html.escape(c)}</th>" for c in cols)
|
|
389
|
+
th_cells += "<th style='width:140px'>Actions</th>"
|
|
390
|
+
tbody = ""
|
|
391
|
+
for row in rows:
|
|
392
|
+
td_cells = "".join(
|
|
393
|
+
f"<td>{html.escape(str(row.get(c, '')))}</td>" for c in cols
|
|
394
|
+
)
|
|
395
|
+
pk_val = html.escape(str(row.get(cfg.pk, "")))
|
|
396
|
+
td_cells += f"""<td>
|
|
397
|
+
<a href="{self._prefix}/{cfg.name}/{pk_val}" class="btn btn-ghost" style="margin-right:4px">Edit</a>
|
|
398
|
+
<form method="post" action="{self._prefix}/{cfg.name}/{pk_val}/delete" style="display:inline"
|
|
399
|
+
onsubmit="return confirm('Delete this record?')">
|
|
400
|
+
<button type="submit" class="btn btn-danger">Del</button>
|
|
401
|
+
</form>
|
|
402
|
+
</td>"""
|
|
403
|
+
tbody += f"<tr>{td_cells}</tr>"
|
|
404
|
+
table_html = f"""<table><thead><tr>{th_cells}</tr></thead><tbody>{tbody}</tbody></table>"""
|
|
405
|
+
|
|
406
|
+
prev_link = f'<a href="?q={html.escape(search)}&page={page-1}" class="pagination">‹</a>' if page > 1 else ""
|
|
407
|
+
next_link = f'<a href="?q={html.escape(search)}&page={page+1}" class="pagination">›</a>' if len(rows) == 25 else ""
|
|
408
|
+
|
|
409
|
+
body = f"""<div class="card">
|
|
410
|
+
<div class="card-header">
|
|
411
|
+
<h2>{cfg.icon} {html.escape(cfg.label)}</h2>
|
|
412
|
+
<a href="{self._prefix}/{cfg.name}/new" class="btn btn-primary">+ New</a>
|
|
413
|
+
</div>
|
|
414
|
+
<div style="padding:16px">
|
|
415
|
+
{search_form}
|
|
416
|
+
{table_html}
|
|
417
|
+
<div class="pagination">{prev_link}{next_link}</div>
|
|
418
|
+
</div>
|
|
419
|
+
</div>"""
|
|
420
|
+
return self._base(cfg.label, body)
|
|
421
|
+
|
|
422
|
+
def _form_html(self, cfg: _ModelConfig, cols: list, row: dict, *, is_new: bool) -> str:
|
|
423
|
+
pk_val = "" if is_new else html.escape(str(row.get(cfg.pk, "")))
|
|
424
|
+
action = f"{self._prefix}/{cfg.name}/new" if is_new else f"{self._prefix}/{cfg.name}/{pk_val}"
|
|
425
|
+
title_txt = f"New {cfg.label}" if is_new else f"Edit {cfg.label} #{pk_val}"
|
|
426
|
+
|
|
427
|
+
fields = ""
|
|
428
|
+
for col in cols:
|
|
429
|
+
val = html.escape(str(row.get(col, "")))
|
|
430
|
+
readonly = 'readonly style="background:#1e293b"' if col in cfg.readonly_cols else ""
|
|
431
|
+
fields += f"""<div class="form-group">
|
|
432
|
+
<label>{html.escape(col)}</label>
|
|
433
|
+
<input name="{html.escape(col)}" value="{val}" {readonly}>
|
|
434
|
+
</div>"""
|
|
435
|
+
|
|
436
|
+
body = f"""<div class="card" style="max-width:600px">
|
|
437
|
+
<div class="card-header">
|
|
438
|
+
<h2>{html.escape(title_txt)}</h2>
|
|
439
|
+
<a href="{self._prefix}/{cfg.name}" class="btn btn-ghost">← Back</a>
|
|
440
|
+
</div>
|
|
441
|
+
<div style="padding:20px">
|
|
442
|
+
<form method="post" action="{action}">
|
|
443
|
+
{fields}
|
|
444
|
+
<div class="form-actions">
|
|
445
|
+
<button type="submit" class="btn btn-primary">{'Create' if is_new else 'Save'}</button>
|
|
446
|
+
<a href="{self._prefix}/{cfg.name}" class="btn btn-ghost">Cancel</a>
|
|
447
|
+
</div>
|
|
448
|
+
</form>
|
|
449
|
+
</div>
|
|
450
|
+
</div>"""
|
|
451
|
+
return self._base(title_txt, body)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# Singleton — import and use directly
|
|
455
|
+
admin = PillarAdmin()
|