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 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()