shakti-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. shakti/__about__.py +2 -0
  2. shakti/__init__.py +62 -0
  3. shakti/admin/__init__.py +6 -0
  4. shakti/admin/admin.py +376 -0
  5. shakti/admin/helpers.py +71 -0
  6. shakti/admin/registry.py +70 -0
  7. shakti/admin/ui.py +526 -0
  8. shakti/ai/__init__.py +37 -0
  9. shakti/ai/agents.py +211 -0
  10. shakti/ai/ai.py +316 -0
  11. shakti/ai/providers/__init__.py +5 -0
  12. shakti/ai/providers/anthropic_provider.py +96 -0
  13. shakti/ai/providers/base.py +69 -0
  14. shakti/ai/providers/openai_provider.py +81 -0
  15. shakti/ai/rag.py +141 -0
  16. shakti/ai/templates.py +69 -0
  17. shakti/application.py +340 -0
  18. shakti/auth/__init__.py +17 -0
  19. shakti/auth/auth.py +320 -0
  20. shakti/auth/hashing.py +13 -0
  21. shakti/auth/models.py +49 -0
  22. shakti/auth/tokens.py +56 -0
  23. shakti/cli/__init__.py +1 -0
  24. shakti/cli/codegen.py +257 -0
  25. shakti/cli/main.py +256 -0
  26. shakti/cli/templates.py +139 -0
  27. shakti/config/__init__.py +7 -0
  28. shakti/config/env.py +50 -0
  29. shakti/config/secrets.py +34 -0
  30. shakti/config/settings.py +179 -0
  31. shakti/datastructures.py +164 -0
  32. shakti/di.py +173 -0
  33. shakti/docs/__init__.py +14 -0
  34. shakti/docs/docs.py +362 -0
  35. shakti/docs/extractors.py +80 -0
  36. shakti/docs/storage.py +56 -0
  37. shakti/exceptions.py +45 -0
  38. shakti/http/__init__.py +19 -0
  39. shakti/http/request.py +128 -0
  40. shakti/http/response.py +134 -0
  41. shakti/middleware/__init__.py +13 -0
  42. shakti/middleware/base.py +34 -0
  43. shakti/middleware/cors.py +74 -0
  44. shakti/middleware/logging.py +32 -0
  45. shakti/monitoring/__init__.py +14 -0
  46. shakti/monitoring/dashboard.py +294 -0
  47. shakti/monitoring/health.py +80 -0
  48. shakti/monitoring/metrics.py +128 -0
  49. shakti/monitoring/monitor.py +217 -0
  50. shakti/orm/__init__.py +38 -0
  51. shakti/orm/base.py +58 -0
  52. shakti/orm/database.py +137 -0
  53. shakti/orm/fields.py +37 -0
  54. shakti/orm/migrations.py +236 -0
  55. shakti/orm/repository.py +83 -0
  56. shakti/py.typed +0 -0
  57. shakti/routing/__init__.py +7 -0
  58. shakti/routing/converters.py +21 -0
  59. shakti/routing/route.py +79 -0
  60. shakti/routing/router.py +164 -0
  61. shakti/testing.py +167 -0
  62. shakti/types.py +12 -0
  63. shakti/websocket.py +102 -0
  64. shakti/workflows/__init__.py +15 -0
  65. shakti/workflows/engine.py +225 -0
  66. shakti/workflows/models.py +65 -0
  67. shakti/workflows/queue.py +157 -0
  68. shakti/workflows/scheduler.py +90 -0
  69. shakti_framework-0.1.0.dist-info/METADATA +234 -0
  70. shakti_framework-0.1.0.dist-info/RECORD +73 -0
  71. shakti_framework-0.1.0.dist-info/WHEEL +4 -0
  72. shakti_framework-0.1.0.dist-info/entry_points.txt +2 -0
  73. shakti_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
shakti/__about__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Shakti version."""
2
+ __version__ = "0.1.0"
shakti/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Shakti — an AI-first, async Python web framework.
2
+
3
+ Flask simplicity, FastAPI performance, Django batteries.
4
+ """
5
+
6
+ from shakti.__about__ import __version__
7
+ from shakti.application import Shakti, coerce_response
8
+ from shakti.config.secrets import Secret
9
+ from shakti.config.settings import Config
10
+ from shakti.di import Container, Depends
11
+ from shakti.exceptions import ConfigError, HTTPException, ShaktiError, RouteError
12
+ from shakti.http.request import Request
13
+ from shakti.http.response import (
14
+ HTMLResponse,
15
+ JSONResponse,
16
+ PlainTextResponse,
17
+ RedirectResponse,
18
+ Response,
19
+ )
20
+ from shakti.admin import Admin
21
+ from shakti.ai import AI, SSEResponse
22
+ from shakti.docs import DocumentAI
23
+ from shakti.workflows import WorkflowEngine
24
+ from shakti.monitoring import Monitor
25
+ from shakti.auth import Auth
26
+ from shakti.auth.models import APIKey, User
27
+ from shakti.routing.route import Route
28
+ from shakti.websocket import WebSocket, WebSocketDisconnect
29
+ from shakti.routing.router import Router
30
+
31
+ __all__ = [
32
+ "Config",
33
+ "ConfigError",
34
+ "Container",
35
+ "Depends",
36
+ "HTMLResponse",
37
+ "HTTPException",
38
+ "JSONResponse",
39
+ "PlainTextResponse",
40
+ "Shakti",
41
+ "ShaktiError",
42
+ "RedirectResponse",
43
+ "Request",
44
+ "Response",
45
+ "Route",
46
+ "RouteError",
47
+ "WebSocket",
48
+ "WebSocketDisconnect",
49
+ "Router",
50
+ "Secret",
51
+ "AI",
52
+ "DocumentAI",
53
+ "Monitor",
54
+ "WorkflowEngine",
55
+ "Admin",
56
+ "SSEResponse",
57
+ "APIKey",
58
+ "Auth",
59
+ "User",
60
+ "__version__",
61
+ "coerce_response",
62
+ ]
@@ -0,0 +1,6 @@
1
+ """Shakti Admin — a modern, dark-themed admin panel."""
2
+
3
+ from shakti.admin.admin import Admin
4
+ from shakti.admin.registry import ModelAdmin
5
+
6
+ __all__ = ["Admin", "ModelAdmin"]
shakti/admin/admin.py ADDED
@@ -0,0 +1,376 @@
1
+ """Shakti Admin — better than Django admin.
2
+
3
+ Usage::
4
+
5
+ from shakti.admin import Admin
6
+ from app.models.post import Post
7
+
8
+ admin = Admin(db, auth, title="MyApp Admin")
9
+ admin.register(Post, list_fields=["id","title","created_at"], search_fields=["title"])
10
+ admin.init_app(app)
11
+
12
+ Then visit http://127.0.0.1:8000/admin
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from sqlalchemy import or_, select
21
+
22
+ from shakti.admin.helpers import activity_log, fmt, to_csv
23
+ from shakti.admin.registry import ModelAdmin
24
+ from shakti.auth.hashing import verify_password
25
+ from shakti.auth.models import User
26
+ from shakti.auth.tokens import create_access_token, decode_token
27
+ from shakti.exceptions import HTTPException
28
+ from shakti.http.request import Request
29
+ from shakti.http.response import HTMLResponse, PlainTextResponse, RedirectResponse, Response
30
+ from shakti.orm.database import Database
31
+ from shakti.routing.router import Router
32
+
33
+ if TYPE_CHECKING:
34
+ from shakti.application import Shakti
35
+ from shakti.auth.auth import Auth
36
+
37
+ _COOKIE = "shakti_admin"
38
+
39
+
40
+ class Admin:
41
+ """Admin panel manager."""
42
+
43
+ def __init__(
44
+ self,
45
+ db: Database,
46
+ auth: "Auth | None" = None,
47
+ *,
48
+ title: str = "Shakti Admin",
49
+ prefix: str = "/admin",
50
+ secret_key: str = "admin-secret-change-me",
51
+ ) -> None:
52
+ self.db = db
53
+ self.auth = auth
54
+ self.title = title
55
+ self.prefix = prefix
56
+ self.secret_key = auth.secret_key if auth else secret_key
57
+ self._registry: dict[str, ModelAdmin] = {}
58
+
59
+ def register(
60
+ self,
61
+ model: type,
62
+ *,
63
+ list_fields: list[str] | None = None,
64
+ search_fields: list[str] | None = None,
65
+ readonly_fields: list[str] | None = None,
66
+ list_per_page: int = 25,
67
+ ) -> None:
68
+ ma = ModelAdmin(
69
+ model=model,
70
+ list_fields=list_fields or [],
71
+ search_fields=search_fields or [],
72
+ readonly_fields=readonly_fields or [],
73
+ list_per_page=list_per_page,
74
+ )
75
+ self._registry[ma.slug] = ma
76
+
77
+ def init_app(self, app: "Shakti") -> None:
78
+ from shakti.http.response import RedirectResponse as _RR
79
+ _prefix = self.prefix
80
+
81
+ @app.get(_prefix)
82
+ async def _admin_root_redirect() -> _RR:
83
+ return _RR(f"{_prefix}/", status_code=302)
84
+
85
+ app.include_router(self._build_router(), prefix=self.prefix)
86
+
87
+ # ------------------------------------------------------------------
88
+ # Internal helpers
89
+ # ------------------------------------------------------------------
90
+ def _models_nav(self) -> list[tuple[str, str]]:
91
+ return [(ma.name, ma.slug) for ma in self._registry.values()]
92
+
93
+ def _get_admin_user(self, request: Request) -> dict | None:
94
+ token = None
95
+ raw = request.headers.get("cookie", "")
96
+ for part in raw.split(";"):
97
+ k, _, v = part.strip().partition("=")
98
+ if k == _COOKIE:
99
+ token = v
100
+ break
101
+ if not token:
102
+ return None
103
+ try:
104
+ payload = decode_token(token, self.secret_key)
105
+ if payload.get("type") != "admin":
106
+ return None
107
+ return payload
108
+ except HTTPException:
109
+ return None
110
+
111
+ def _require_admin(self, request: Request) -> dict:
112
+ user = self._get_admin_user(request)
113
+ if not user:
114
+ raise HTTPException(302, headers={"location": f"{self.prefix}/login"})
115
+ return user
116
+
117
+ def _admin_token(self, user: User) -> str:
118
+ import jwt
119
+ from datetime import datetime, timedelta, timezone
120
+ payload = {
121
+ "sub": str(user.id),
122
+ "username": user.username,
123
+ "type": "admin",
124
+ "iat": datetime.now(timezone.utc),
125
+ "exp": datetime.now(timezone.utc) + timedelta(minutes=480),
126
+ }
127
+ return jwt.encode(payload, self.secret_key, algorithm="HS256")
128
+
129
+ def _cast_form_value(self, raw: str, field_meta: dict) -> Any:
130
+ if raw == "" or raw is None:
131
+ return None
132
+ ft = field_meta["type"]
133
+ if ft == "number":
134
+ return int(raw)
135
+ if ft == "decimal":
136
+ return float(raw)
137
+ if ft == "checkbox":
138
+ return raw.lower() in ("true", "1", "on", "yes")
139
+ return raw
140
+
141
+ # ------------------------------------------------------------------
142
+ # Router
143
+ # ------------------------------------------------------------------
144
+ def _build_router(self) -> Router:
145
+ router = Router()
146
+ _admin = self
147
+
148
+ # Login GET
149
+ @router.get("/login")
150
+ async def login_page(request: Request) -> HTMLResponse:
151
+ from shakti.admin import ui
152
+ return HTMLResponse(ui.login_page(title=_admin.title))
153
+
154
+ # Login POST
155
+ @router.post("/login")
156
+ async def login_post(request: Request) -> Response:
157
+ from shakti.admin import ui
158
+ form = await request.form()
159
+ email = form.get("email", "")
160
+ password = form.get("password", "")
161
+
162
+ async with _admin.db.session() as session:
163
+ from sqlalchemy import select as _select
164
+ result = await session.execute(_select(User).where(User.email == email))
165
+ user = result.scalar_one_or_none()
166
+
167
+ if not user or not verify_password(password, user.hashed_password):
168
+ return HTMLResponse(ui.login_page("Invalid email or password", _admin.title))
169
+ if user.role not in ("admin", "superuser"):
170
+ return HTMLResponse(ui.login_page("Admin access required", _admin.title))
171
+
172
+ token = _admin._admin_token(user)
173
+ activity_log.record(user.username, "login", "admin")
174
+ response = RedirectResponse(f"{_admin.prefix}", status_code=302)
175
+ response.set_cookie(_COOKIE, token, httponly=True, samesite="lax", max_age=60*480)
176
+ return response
177
+
178
+ # Logout
179
+ @router.post("/logout")
180
+ async def logout_post(request: Request) -> Response:
181
+ response = RedirectResponse(f"{_admin.prefix}/login", status_code=302)
182
+ response.delete_cookie(_COOKIE)
183
+ return response
184
+
185
+ # Dashboard
186
+ @router.get("/")
187
+ async def dashboard(request: Request) -> HTMLResponse:
188
+ from shakti.admin import ui
189
+ user = _admin._require_admin(request)
190
+ stats = []
191
+ async with _admin.db.session() as session:
192
+ for ma in _admin._registry.values():
193
+ count = len((await session.execute(select(ma.model))).scalars().all())
194
+ stats.append({"name": ma.name, "slug": ma.slug, "count": count})
195
+ return HTMLResponse(ui.dashboard(
196
+ stats, activity_log.recent(20),
197
+ _admin._models_nav(), _admin.title, prefix=_admin.prefix
198
+ ))
199
+
200
+ # Model list
201
+ @router.get("/{model_slug}")
202
+ async def model_list(request: Request, model_slug: str) -> HTMLResponse:
203
+ from shakti.admin import ui
204
+ user = _admin._require_admin(request)
205
+ ma = _admin._registry.get(model_slug)
206
+ if not ma:
207
+ raise HTTPException(404, f"Model '{model_slug}' not registered")
208
+
209
+ search = request.query_params.get("search", "")
210
+ page = max(1, int(request.query_params.get("page", "1")))
211
+ flash = request.query_params.get("flash", "")
212
+
213
+ async with _admin.db.session() as session:
214
+ stmt = select(ma.model)
215
+ if search and ma.search_fields:
216
+ from sqlalchemy import String
217
+ from sqlalchemy.orm import InstrumentedAttribute
218
+ conditions = []
219
+ for sf in ma.search_fields:
220
+ col = getattr(ma.model, sf, None)
221
+ if col is not None:
222
+ conditions.append(col.ilike(f"%{search}%"))
223
+ if conditions:
224
+ stmt = stmt.where(or_(*conditions))
225
+ all_rows = (await session.execute(stmt)).scalars().all()
226
+ total = len(all_rows)
227
+ offset = (page - 1) * ma.list_per_page
228
+ rows = all_rows[offset: offset + ma.list_per_page]
229
+
230
+ return HTMLResponse(ui.model_list(
231
+ ma, rows, total, page, ma.list_per_page,
232
+ search, _admin._models_nav(), flash, _admin.title, prefix=_admin.prefix
233
+ ))
234
+
235
+ # New form
236
+ @router.get("/{model_slug}/new")
237
+ async def model_new_form(request: Request, model_slug: str) -> HTMLResponse:
238
+ from shakti.admin import ui
239
+ user = _admin._require_admin(request)
240
+ ma = _admin._registry.get(model_slug)
241
+ if not ma:
242
+ raise HTTPException(404)
243
+ return HTMLResponse(ui.model_form(ma, None, _admin._models_nav(), admin_title=_admin.title, prefix=_admin.prefix))
244
+
245
+ # Create POST
246
+ @router.post("/{model_slug}/new")
247
+ async def model_create(request: Request, model_slug: str) -> Response:
248
+ from shakti.admin import ui
249
+ user_payload = _admin._require_admin(request)
250
+ ma = _admin._registry.get(model_slug)
251
+ if not ma:
252
+ raise HTTPException(404)
253
+
254
+ form = await request.form()
255
+ fields = {f["name"]: f for f in ma.get_fields()}
256
+ kwargs: dict[str, Any] = {}
257
+ errors: list[str] = []
258
+
259
+ for name, meta in fields.items():
260
+ if meta["readonly"] or meta["primary_key"]:
261
+ continue
262
+ raw = form.get(name)
263
+ if meta["type"] == "checkbox":
264
+ raw = form.get(name, "false")
265
+ try:
266
+ val = _admin._cast_form_value(raw, meta)
267
+ if val is None and not meta["nullable"]:
268
+ errors.append(f"{name} is required")
269
+ else:
270
+ kwargs[name] = val
271
+ except (ValueError, TypeError) as e:
272
+ errors.append(f"{name}: {e}")
273
+
274
+ if errors:
275
+ return HTMLResponse(ui.model_form(ma, None, _admin._models_nav(), errors, _admin.title, prefix=_admin.prefix))
276
+
277
+ async with _admin.db.session() as session:
278
+ obj = ma.model(**kwargs)
279
+ session.add(obj)
280
+ await session.commit()
281
+ await session.refresh(obj)
282
+
283
+ activity_log.record(user_payload["username"], "created", ma.name, obj.id, str(kwargs))
284
+ return RedirectResponse(f"{_admin.prefix}/{model_slug}?flash=Created+successfully", status_code=302)
285
+
286
+ # Edit form
287
+ @router.get("/{model_slug}/{id:int}")
288
+ async def model_edit_form(request: Request, model_slug: str, id: int) -> HTMLResponse:
289
+ from shakti.admin import ui
290
+ _admin._require_admin(request)
291
+ ma = _admin._registry.get(model_slug)
292
+ if not ma:
293
+ raise HTTPException(404)
294
+ async with _admin.db.session() as session:
295
+ obj = await session.get(ma.model, id)
296
+ if not obj:
297
+ raise HTTPException(404, f"{ma.name} #{id} not found")
298
+ return HTMLResponse(ui.model_form(ma, obj, _admin._models_nav(), admin_title=_admin.title, prefix=_admin.prefix))
299
+
300
+ # Update POST
301
+ @router.post("/{model_slug}/{id:int}")
302
+ async def model_update(request: Request, model_slug: str, id: int) -> Response:
303
+ from shakti.admin import ui
304
+ user_payload = _admin._require_admin(request)
305
+ ma = _admin._registry.get(model_slug)
306
+ if not ma:
307
+ raise HTTPException(404)
308
+
309
+ async with _admin.db.session() as session:
310
+ obj = await session.get(ma.model, id)
311
+ if not obj:
312
+ raise HTTPException(404)
313
+
314
+ form = await request.form()
315
+ fields = {f["name"]: f for f in ma.get_fields()}
316
+ errors: list[str] = []
317
+ changes: dict[str, Any] = {}
318
+
319
+ for name, meta in fields.items():
320
+ if meta["readonly"] or meta["primary_key"]:
321
+ continue
322
+ raw = form.get(name)
323
+ if meta["type"] == "checkbox":
324
+ raw = form.get(name, "false")
325
+ try:
326
+ val = _admin._cast_form_value(raw, meta)
327
+ if val is None and not meta["nullable"]:
328
+ errors.append(f"{name} is required")
329
+ else:
330
+ setattr(obj, name, val)
331
+ changes[name] = val
332
+ except (ValueError, TypeError) as e:
333
+ errors.append(f"{name}: {e}")
334
+
335
+ if errors:
336
+ return HTMLResponse(ui.model_form(ma, obj, _admin._models_nav(), errors, _admin.title, prefix=_admin.prefix))
337
+
338
+ await session.commit()
339
+
340
+ activity_log.record(user_payload["username"], "updated", ma.name, id, str(changes))
341
+ return RedirectResponse(f"{_admin.prefix}/{model_slug}?flash=Saved+successfully", status_code=302)
342
+
343
+ # Delete POST
344
+ @router.post("/{model_slug}/{id:int}/delete")
345
+ async def model_delete(request: Request, model_slug: str, id: int) -> Response:
346
+ user_payload = _admin._require_admin(request)
347
+ ma = _admin._registry.get(model_slug)
348
+ if not ma:
349
+ raise HTTPException(404)
350
+ async with _admin.db.session() as session:
351
+ obj = await session.get(ma.model, id)
352
+ if not obj:
353
+ raise HTTPException(404)
354
+ await session.delete(obj)
355
+ await session.commit()
356
+ activity_log.record(user_payload["username"], "deleted", ma.name, id)
357
+ return RedirectResponse(f"{_admin.prefix}/{model_slug}?flash=Deleted+successfully", status_code=302)
358
+
359
+ # Export CSV
360
+ @router.get("/{model_slug}/export")
361
+ async def model_export(request: Request, model_slug: str) -> PlainTextResponse:
362
+ _admin._require_admin(request)
363
+ ma = _admin._registry.get(model_slug)
364
+ if not ma:
365
+ raise HTTPException(404)
366
+ async with _admin.db.session() as session:
367
+ rows = (await session.execute(select(ma.model))).scalars().all()
368
+ data_rows = [[getattr(r, f, "") for f in ma.list_fields] for r in rows]
369
+ csv_content = to_csv(ma.list_fields, data_rows)
370
+ return PlainTextResponse(
371
+ csv_content,
372
+ headers={"Content-Disposition": f'attachment; filename="{model_slug}.csv"'},
373
+ media_type="text/csv",
374
+ )
375
+
376
+ return router
@@ -0,0 +1,71 @@
1
+ """Value formatting, CSV export, activity log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+ from collections import deque
11
+
12
+
13
+ def fmt(value: Any) -> str:
14
+ if value is None:
15
+ return "—"
16
+ if isinstance(value, bool):
17
+ return "✓" if value else "✗"
18
+ if isinstance(value, datetime):
19
+ return value.strftime("%Y-%m-%d %H:%M")
20
+ s = str(value)
21
+ return s[:60] + "…" if len(s) > 60 else s
22
+
23
+
24
+ def to_csv(headers: list[str], rows: list[list[Any]]) -> str:
25
+ buf = io.StringIO()
26
+ w = csv.writer(buf)
27
+ w.writerow(headers)
28
+ for row in rows:
29
+ w.writerow([fmt(v) for v in row])
30
+ return buf.getvalue()
31
+
32
+
33
+ @dataclass
34
+ class ActivityEntry:
35
+ timestamp: datetime
36
+ username: str
37
+ action: str # created | updated | deleted
38
+ model: str
39
+ record_id: int | None
40
+ detail: str
41
+
42
+
43
+ class ActivityLog:
44
+ def __init__(self, maxlen: int = 200) -> None:
45
+ self._log: deque[ActivityEntry] = deque(maxlen=maxlen)
46
+
47
+ def record(
48
+ self,
49
+ username: str,
50
+ action: str,
51
+ model: str,
52
+ record_id: int | None = None,
53
+ detail: str = "",
54
+ ) -> None:
55
+ self._log.appendleft(
56
+ ActivityEntry(
57
+ timestamp=datetime.now(timezone.utc),
58
+ username=username,
59
+ action=action,
60
+ model=model,
61
+ record_id=record_id,
62
+ detail=detail,
63
+ )
64
+ )
65
+
66
+ def recent(self, n: int = 20) -> list[ActivityEntry]:
67
+ return list(self._log)[:n]
68
+
69
+
70
+ # Global activity log instance shared across admin instances
71
+ activity_log = ActivityLog()
@@ -0,0 +1,70 @@
1
+ """ModelAdmin registry — configure how each model appears in the admin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text
9
+ from sqlalchemy import inspect as sa_inspect
10
+
11
+
12
+ @dataclass
13
+ class ModelAdmin:
14
+ model: type
15
+ list_fields: list[str] = field(default_factory=list)
16
+ search_fields: list[str] = field(default_factory=list)
17
+ readonly_fields: list[str] = field(default_factory=list)
18
+ list_per_page: int = 25
19
+
20
+ def __post_init__(self) -> None:
21
+ if not self.list_fields:
22
+ self.list_fields = self._auto_list_fields()
23
+ if "id" not in self.readonly_fields:
24
+ self.readonly_fields = ["id", *self.readonly_fields]
25
+
26
+ def _auto_list_fields(self) -> list[str]:
27
+ cols = [c.key for c in sa_inspect(self.model).columns]
28
+ # Put id first, skip hashed_password / refresh_token
29
+ hidden = {"hashed_password", "refresh_token"}
30
+ ordered = [c for c in cols if c not in hidden]
31
+ return ordered[:6] # max 6 columns in list view
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self.model.__name__
36
+
37
+ @property
38
+ def slug(self) -> str:
39
+ import re
40
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", self.name).lower() + "s"
41
+
42
+ def get_fields(self) -> list[dict[str, Any]]:
43
+ """Return field metadata for form rendering."""
44
+ mapper = sa_inspect(self.model)
45
+ result = []
46
+ hidden = {"hashed_password", "refresh_token"}
47
+ for col in mapper.columns:
48
+ if col.key in hidden:
49
+ continue
50
+ sa_type = type(col.type)
51
+ if sa_type == Text:
52
+ input_type = "textarea"
53
+ elif sa_type == Integer:
54
+ input_type = "number"
55
+ elif sa_type == Float:
56
+ input_type = "decimal"
57
+ elif sa_type == Boolean:
58
+ input_type = "checkbox"
59
+ elif sa_type == DateTime:
60
+ input_type = "datetime"
61
+ else:
62
+ input_type = "text"
63
+ result.append({
64
+ "name": col.key,
65
+ "type": input_type,
66
+ "nullable": col.nullable,
67
+ "primary_key": col.primary_key,
68
+ "readonly": col.key in self.readonly_fields,
69
+ })
70
+ return result