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.
- shakti/__about__.py +2 -0
- shakti/__init__.py +62 -0
- shakti/admin/__init__.py +6 -0
- shakti/admin/admin.py +376 -0
- shakti/admin/helpers.py +71 -0
- shakti/admin/registry.py +70 -0
- shakti/admin/ui.py +526 -0
- shakti/ai/__init__.py +37 -0
- shakti/ai/agents.py +211 -0
- shakti/ai/ai.py +316 -0
- shakti/ai/providers/__init__.py +5 -0
- shakti/ai/providers/anthropic_provider.py +96 -0
- shakti/ai/providers/base.py +69 -0
- shakti/ai/providers/openai_provider.py +81 -0
- shakti/ai/rag.py +141 -0
- shakti/ai/templates.py +69 -0
- shakti/application.py +340 -0
- shakti/auth/__init__.py +17 -0
- shakti/auth/auth.py +320 -0
- shakti/auth/hashing.py +13 -0
- shakti/auth/models.py +49 -0
- shakti/auth/tokens.py +56 -0
- shakti/cli/__init__.py +1 -0
- shakti/cli/codegen.py +257 -0
- shakti/cli/main.py +256 -0
- shakti/cli/templates.py +139 -0
- shakti/config/__init__.py +7 -0
- shakti/config/env.py +50 -0
- shakti/config/secrets.py +34 -0
- shakti/config/settings.py +179 -0
- shakti/datastructures.py +164 -0
- shakti/di.py +173 -0
- shakti/docs/__init__.py +14 -0
- shakti/docs/docs.py +362 -0
- shakti/docs/extractors.py +80 -0
- shakti/docs/storage.py +56 -0
- shakti/exceptions.py +45 -0
- shakti/http/__init__.py +19 -0
- shakti/http/request.py +128 -0
- shakti/http/response.py +134 -0
- shakti/middleware/__init__.py +13 -0
- shakti/middleware/base.py +34 -0
- shakti/middleware/cors.py +74 -0
- shakti/middleware/logging.py +32 -0
- shakti/monitoring/__init__.py +14 -0
- shakti/monitoring/dashboard.py +294 -0
- shakti/monitoring/health.py +80 -0
- shakti/monitoring/metrics.py +128 -0
- shakti/monitoring/monitor.py +217 -0
- shakti/orm/__init__.py +38 -0
- shakti/orm/base.py +58 -0
- shakti/orm/database.py +137 -0
- shakti/orm/fields.py +37 -0
- shakti/orm/migrations.py +236 -0
- shakti/orm/repository.py +83 -0
- shakti/py.typed +0 -0
- shakti/routing/__init__.py +7 -0
- shakti/routing/converters.py +21 -0
- shakti/routing/route.py +79 -0
- shakti/routing/router.py +164 -0
- shakti/testing.py +167 -0
- shakti/types.py +12 -0
- shakti/websocket.py +102 -0
- shakti/workflows/__init__.py +15 -0
- shakti/workflows/engine.py +225 -0
- shakti/workflows/models.py +65 -0
- shakti/workflows/queue.py +157 -0
- shakti/workflows/scheduler.py +90 -0
- shakti_framework-0.1.0.dist-info/METADATA +234 -0
- shakti_framework-0.1.0.dist-info/RECORD +73 -0
- shakti_framework-0.1.0.dist-info/WHEEL +4 -0
- shakti_framework-0.1.0.dist-info/entry_points.txt +2 -0
- shakti_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
shakti/__about__.py
ADDED
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
|
+
]
|
shakti/admin/__init__.py
ADDED
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
|
shakti/admin/helpers.py
ADDED
|
@@ -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()
|
shakti/admin/registry.py
ADDED
|
@@ -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
|