oxyde-admin 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 (34) hide show
  1. oxyde_admin/__init__.py +40 -0
  2. oxyde_admin/_version.py +1 -0
  3. oxyde_admin/adapters/__init__.py +0 -0
  4. oxyde_admin/adapters/_fastapi.py +185 -0
  5. oxyde_admin/adapters/_litestar.py +280 -0
  6. oxyde_admin/adapters/_sanic.py +261 -0
  7. oxyde_admin/adapters/base.py +100 -0
  8. oxyde_admin/api/__init__.py +0 -0
  9. oxyde_admin/api/routes.py +229 -0
  10. oxyde_admin/config.py +55 -0
  11. oxyde_admin/schema.py +66 -0
  12. oxyde_admin/site.py +420 -0
  13. oxyde_admin/static/assets/Dashboard-lLXvWYMG.js +1 -0
  14. oxyde_admin/static/assets/Login-CW4VUq6N.css +1 -0
  15. oxyde_admin/static/assets/Login-C_CHPOS1.js +101 -0
  16. oxyde_admin/static/assets/ModelDetail-CytWpk63.js +389 -0
  17. oxyde_admin/static/assets/ModelList-BrniEYli.js +1246 -0
  18. oxyde_admin/static/assets/index-BoOIem2p.css +1 -0
  19. oxyde_admin/static/assets/index-Cy4fw_D3.js +806 -0
  20. oxyde_admin/static/assets/index-DJs38nb7.js +1348 -0
  21. oxyde_admin/static/assets/index-Dyk0SQwm.js +54 -0
  22. oxyde_admin/static/assets/index-WzCL-nzV.js +590 -0
  23. oxyde_admin/static/assets/primeicons-C6QP2o4f.woff2 +0 -0
  24. oxyde_admin/static/assets/primeicons-DMOk5skT.eot +0 -0
  25. oxyde_admin/static/assets/primeicons-Dr5RGzOO.svg +345 -0
  26. oxyde_admin/static/assets/primeicons-MpK4pl85.ttf +0 -0
  27. oxyde_admin/static/assets/primeicons-WjwUDZjB.woff +0 -0
  28. oxyde_admin/static/favicon.png +0 -0
  29. oxyde_admin/static/index.html +14 -0
  30. oxyde_admin-0.1.0.dist-info/METADATA +219 -0
  31. oxyde_admin-0.1.0.dist-info/RECORD +34 -0
  32. oxyde_admin-0.1.0.dist-info/WHEEL +5 -0
  33. oxyde_admin-0.1.0.dist-info/licenses/LICENSE +21 -0
  34. oxyde_admin-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,40 @@
1
+ from oxyde_admin._version import __version__
2
+ from oxyde_admin.config import Preset, PrimaryColor, Surface
3
+
4
+
5
+ def _make_stub(name, package):
6
+ class _Stub:
7
+ def __init__(self, *args, **kwargs):
8
+ raise ImportError(
9
+ f"{name} requires '{package}'. Install it with: pip install {package}"
10
+ )
11
+
12
+ _Stub.__name__ = _Stub.__qualname__ = name
13
+ return _Stub
14
+
15
+
16
+ try:
17
+ from oxyde_admin.adapters._fastapi import FastAPIAdmin
18
+ except ImportError:
19
+ FastAPIAdmin = _make_stub("FastAPIAdmin", "fastapi")
20
+
21
+ try:
22
+ from oxyde_admin.adapters._litestar import LitestarAdmin
23
+ except ImportError:
24
+ LitestarAdmin = _make_stub("LitestarAdmin", "litestar")
25
+
26
+ try:
27
+ from oxyde_admin.adapters._sanic import SanicAdmin
28
+ except ImportError:
29
+ SanicAdmin = _make_stub("SanicAdmin", "sanic")
30
+
31
+
32
+ __all__ = [
33
+ "__version__",
34
+ "Preset",
35
+ "PrimaryColor",
36
+ "Surface",
37
+ "FastAPIAdmin",
38
+ "LitestarAdmin",
39
+ "SanicAdmin",
40
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+
5
+ from fastapi import FastAPI, Query, Request
6
+ from fastapi.responses import (
7
+ JSONResponse,
8
+ HTMLResponse,
9
+ FileResponse,
10
+ StreamingResponse,
11
+ )
12
+ from fastapi.staticfiles import StaticFiles
13
+ from starlette.middleware.base import BaseHTTPMiddleware
14
+
15
+ from oxyde_admin.adapters.base import AbstractAdapter, STATIC_DIR
16
+
17
+
18
+ class FastAPIAdmin(AbstractAdapter):
19
+ """FastAPI adapter for Oxyde Admin."""
20
+
21
+ def __init__(self, prefix: str = "/admin", **kwargs) -> None:
22
+ super().__init__(**kwargs)
23
+ self.prefix = prefix
24
+
25
+ def _build_app(self) -> FastAPI:
26
+ app = FastAPI(title="Oxyde Admin", docs_url=None, redoc_url=None)
27
+
28
+ if self.auth_check is not None:
29
+ self._register_auth_middleware(app)
30
+
31
+ self._register_exception_handlers(app)
32
+ self._register_routes(app)
33
+ self._register_static(app)
34
+
35
+ return app
36
+
37
+ def _register_auth_middleware(self, app: FastAPI) -> None:
38
+ check = self.auth_check
39
+
40
+ async def auth_middleware(request: Request, call_next):
41
+ root = request.scope.get("root_path", "")
42
+ raw_path = request.scope.get("path", request.url.path)
43
+ path = (
44
+ raw_path[len(root) :]
45
+ if root and raw_path.startswith(root)
46
+ else raw_path
47
+ )
48
+ if not path.startswith("/api/") or path == "/api/config":
49
+ return await call_next(request)
50
+ if inspect.iscoroutinefunction(check):
51
+ allowed = await check(request)
52
+ else:
53
+ allowed = check(request)
54
+ if not allowed:
55
+ return JSONResponse({"detail": "Unauthorized"}, status_code=401)
56
+ return await call_next(request)
57
+
58
+ app.add_middleware(BaseHTTPMiddleware, dispatch=auth_middleware)
59
+
60
+ def _register_exception_handlers(self, app: FastAPI) -> None:
61
+ for exc_cls, (status_code, detail_fn) in self.EXCEPTION_MAP.items():
62
+
63
+ def _make_handler(_status=status_code, _fn=detail_fn):
64
+ async def handler(request: Request, exc) -> JSONResponse:
65
+ detail = _fn(exc)
66
+ return JSONResponse({"detail": detail}, status_code=_status)
67
+
68
+ return handler
69
+
70
+ app.add_exception_handler(exc_cls, _make_handler())
71
+
72
+ def _register_routes(self, app: FastAPI) -> None:
73
+ @app.get("/api/config")
74
+ async def admin_config() -> dict:
75
+ return self._build_config()
76
+
77
+ @app.get("/api/models")
78
+ async def models_list() -> list[dict]:
79
+ return self._build_models_list()
80
+
81
+ @app.get("/api/models/counts")
82
+ async def models_counts() -> dict[str, int]:
83
+ return await self._build_models_counts()
84
+
85
+ @app.get("/api/{model_name}/schema", response_model=None)
86
+ async def model_schema(model_name: str):
87
+ return await self._handle_schema(model_name)
88
+
89
+ @app.get("/api/{model_name}", response_model=None)
90
+ async def model_list(
91
+ request: Request,
92
+ model_name: str,
93
+ page: int = 1,
94
+ per_page: int = 25,
95
+ ordering: str | None = None,
96
+ search: str | None = None,
97
+ ):
98
+ return await self._handle_list(
99
+ model_name,
100
+ request.query_params,
101
+ page,
102
+ per_page,
103
+ ordering,
104
+ search,
105
+ )
106
+
107
+ @app.get("/api/{model_name}/options", response_model=None)
108
+ async def model_options(
109
+ model_name: str,
110
+ search: str | None = None,
111
+ limit: int = 25,
112
+ include: str | None = None,
113
+ ):
114
+ include_list = include.split(",") if include else None
115
+ return await self._handle_options(
116
+ model_name, search=search, limit=limit, include=include_list
117
+ )
118
+
119
+ @app.get("/api/{model_name}/export", response_model=None)
120
+ async def model_export(
121
+ request: Request,
122
+ model_name: str,
123
+ fmt: str = Query("csv", alias="format"),
124
+ ordering: str | None = None,
125
+ search: str | None = None,
126
+ ids: str | None = None,
127
+ ):
128
+ id_list = ids.split(",") if ids else None
129
+ stream, media_type, filename = await self._handle_export(
130
+ model_name,
131
+ request.query_params,
132
+ fmt,
133
+ ordering,
134
+ search,
135
+ ids=id_list,
136
+ )
137
+ return StreamingResponse(
138
+ stream,
139
+ media_type=media_type,
140
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
141
+ )
142
+
143
+ @app.get("/api/{model_name}/{pk}", response_model=None)
144
+ async def model_get(model_name: str, pk: str):
145
+ return await self._handle_get(model_name, pk)
146
+
147
+ @app.post("/api/{model_name}", status_code=201, response_model=None)
148
+ async def model_create(model_name: str, request: Request):
149
+ data = await request.json()
150
+ return await self._handle_create(model_name, data)
151
+
152
+ @app.put("/api/{model_name}/{pk}", response_model=None)
153
+ async def model_update(model_name: str, pk: str, request: Request):
154
+ data = await request.json()
155
+ return await self._handle_update(model_name, pk, data)
156
+
157
+ @app.delete("/api/{model_name}/{pk}", response_model=None)
158
+ async def model_delete(model_name: str, pk: str):
159
+ return await self._handle_delete(model_name, pk)
160
+
161
+ @app.post("/api/{model_name}/bulk-delete", response_model=None)
162
+ async def model_bulk_delete(model_name: str, request: Request):
163
+ body = await request.json()
164
+ return await self._handle_bulk_delete(model_name, body["ids"])
165
+
166
+ @app.post("/api/{model_name}/bulk-update", response_model=None)
167
+ async def model_bulk_update(model_name: str, request: Request):
168
+ body = await request.json()
169
+ return await self._handle_bulk_update(model_name, body["ids"], body["data"])
170
+
171
+ def _register_static(self, app: FastAPI) -> None:
172
+ assets_dir = STATIC_DIR / "assets"
173
+ if assets_dir.is_dir():
174
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="static")
175
+
176
+ @app.get("/{path:path}", response_model=None)
177
+ async def catch_all(request: Request, path: str):
178
+ static_file = self._resolve_static_file(path)
179
+ if static_file is not None:
180
+ return FileResponse(static_file)
181
+ root = request.scope.get("root_path", "")
182
+ html = self._render_index_html(root)
183
+ if html is not None:
184
+ return HTMLResponse(html)
185
+ return JSONResponse({"detail": "Frontend not built"}, status_code=404)
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+
5
+ from litestar import Litestar, Request, get, post, put, delete
6
+ from litestar.params import Parameter
7
+ from litestar.static_files import StaticFilesConfig
8
+ from litestar.openapi import OpenAPIConfig
9
+ from litestar.response import Response, File, Stream
10
+ from litestar.types import ASGIApp, Receive, Scope, Send
11
+
12
+ from oxyde_admin.adapters.base import AbstractAdapter, STATIC_DIR
13
+
14
+
15
+ class LitestarAdmin(AbstractAdapter):
16
+ """Litestar adapter for Oxyde Admin."""
17
+
18
+ def _build_app(self) -> Litestar:
19
+ handlers = self._build_route_handlers()
20
+
21
+ middleware = []
22
+ if self.auth_check is not None:
23
+ middleware.append(self._create_auth_middleware())
24
+
25
+ exception_handlers = self._build_exception_handlers()
26
+
27
+ static_configs = []
28
+ assets_dir = STATIC_DIR / "assets"
29
+ if assets_dir.is_dir():
30
+ static_configs.append(
31
+ StaticFilesConfig(directories=[assets_dir], path="/assets")
32
+ )
33
+
34
+ return Litestar(
35
+ route_handlers=handlers,
36
+ middleware=middleware,
37
+ exception_handlers=exception_handlers,
38
+ static_files_config=static_configs,
39
+ openapi_config=OpenAPIConfig(
40
+ title="Oxyde Admin", version=self.version, path=None
41
+ ),
42
+ )
43
+
44
+ def _register_auth_middleware(self, app) -> None:
45
+ # Litestar builds middleware at app creation time,
46
+ # so this is handled via _create_auth_middleware() in _build_app().
47
+ pass
48
+
49
+ def _register_exception_handlers(self, app) -> None:
50
+ # Litestar registers exception handlers at app creation time,
51
+ # so this is handled via _build_exception_handlers() in _build_app().
52
+ pass
53
+
54
+ def _register_routes(self, app) -> None:
55
+ # Litestar registers route handlers at app creation time,
56
+ # so this is handled via _build_route_handlers() in _build_app().
57
+ pass
58
+
59
+ def _register_static(self, app) -> None:
60
+ # Litestar configures static files at app creation time,
61
+ # so this is handled in _build_app().
62
+ pass
63
+
64
+ # ------------------------------------------------------------------
65
+ # Auth middleware
66
+ # ------------------------------------------------------------------
67
+
68
+ def _create_auth_middleware(self):
69
+ check = self.auth_check
70
+
71
+ def middleware_factory(app: ASGIApp) -> ASGIApp:
72
+ async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
73
+ if scope["type"] != "http":
74
+ await app(scope, receive, send)
75
+ return
76
+
77
+ path = scope.get("path", "")
78
+ if not path.startswith("/api/") or path.rstrip("/") == "/api/config":
79
+ await app(scope, receive, send)
80
+ return
81
+
82
+ request = Request(scope)
83
+ if inspect.iscoroutinefunction(check):
84
+ allowed = await check(request)
85
+ else:
86
+ allowed = check(request)
87
+
88
+ if not allowed:
89
+ body = b'{"detail":"Unauthorized"}'
90
+ await send(
91
+ {
92
+ "type": "http.response.start",
93
+ "status": 401,
94
+ "headers": [
95
+ (b"content-type", b"application/json"),
96
+ (b"content-length", str(len(body)).encode()),
97
+ ],
98
+ }
99
+ )
100
+ await send({"type": "http.response.body", "body": body})
101
+ return
102
+
103
+ await app(scope, receive, send)
104
+
105
+ return middleware
106
+
107
+ return middleware_factory
108
+
109
+ # ------------------------------------------------------------------
110
+ # Exception handlers
111
+ # ------------------------------------------------------------------
112
+
113
+ def _build_exception_handlers(self) -> dict:
114
+ handlers = {}
115
+ for exc_cls, (status_code, detail_fn) in self.EXCEPTION_MAP.items():
116
+
117
+ def _make_handler(_status=status_code, _fn=detail_fn):
118
+ async def handler(request: Request, exc) -> Response:
119
+ detail = _fn(exc)
120
+ return Response(content={"detail": detail}, status_code=_status)
121
+
122
+ return handler
123
+
124
+ handlers[exc_cls] = _make_handler()
125
+ return handlers
126
+
127
+ # ------------------------------------------------------------------
128
+ # Route handlers
129
+ # ------------------------------------------------------------------
130
+
131
+ def _build_route_handlers(self) -> list:
132
+ admin = self
133
+
134
+ @get("/api/config")
135
+ async def admin_config() -> dict:
136
+ return admin._build_config()
137
+
138
+ @get("/api/models")
139
+ async def models_list() -> list[dict]:
140
+ return admin._build_models_list()
141
+
142
+ @get("/api/models/counts")
143
+ async def models_counts() -> dict[str, int]:
144
+ return await admin._build_models_counts()
145
+
146
+ @get("/api/{model_name:str}/schema")
147
+ async def model_schema(model_name: str) -> dict:
148
+ return await admin._handle_schema(model_name)
149
+
150
+ @get("/api/{model_name:str}")
151
+ async def model_list(
152
+ request: Request,
153
+ model_name: str,
154
+ page: int = 1,
155
+ per_page: int = 25,
156
+ ordering: str | None = None,
157
+ search: str | None = None,
158
+ ) -> dict:
159
+ return await admin._handle_list(
160
+ model_name,
161
+ request.query_params,
162
+ page,
163
+ per_page,
164
+ ordering,
165
+ search,
166
+ )
167
+
168
+ @get("/api/{model_name:str}/options")
169
+ async def model_options(
170
+ model_name: str,
171
+ search: str | None = None,
172
+ limit: int = 25,
173
+ include: str | None = None,
174
+ ) -> list:
175
+ include_list = include.split(",") if include else None
176
+ return await admin._handle_options(
177
+ model_name, search=search, limit=limit, include=include_list
178
+ )
179
+
180
+ @get("/api/{model_name:str}/export")
181
+ async def model_export(
182
+ request: Request,
183
+ model_name: str,
184
+ fmt: str = Parameter(default="csv", query="format"),
185
+ ordering: str | None = None,
186
+ search: str | None = None,
187
+ ids: str | None = None,
188
+ ) -> Stream:
189
+ id_list = ids.split(",") if ids else None
190
+ stream, media_type, filename = await admin._handle_export(
191
+ model_name,
192
+ request.query_params,
193
+ fmt,
194
+ ordering,
195
+ search,
196
+ ids=id_list,
197
+ )
198
+ return Stream(
199
+ stream,
200
+ media_type=media_type,
201
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
202
+ )
203
+
204
+ @get("/api/{model_name:str}/{pk:str}")
205
+ async def model_get(model_name: str, pk: str) -> dict:
206
+ return await admin._handle_get(model_name, pk)
207
+
208
+ @post("/api/{model_name:str}", status_code=201)
209
+ async def model_create(model_name: str, data: dict) -> dict:
210
+ return await admin._handle_create(model_name, data)
211
+
212
+ @put("/api/{model_name:str}/{pk:str}")
213
+ async def model_update(model_name: str, pk: str, data: dict) -> dict:
214
+ return await admin._handle_update(model_name, pk, data)
215
+
216
+ @delete("/api/{model_name:str}/{pk:str}", status_code=200)
217
+ async def model_delete(model_name: str, pk: str) -> dict:
218
+ return await admin._handle_delete(model_name, pk)
219
+
220
+ @post("/api/{model_name:str}/bulk-delete")
221
+ async def model_bulk_delete(model_name: str, data: dict) -> dict:
222
+ return await admin._handle_bulk_delete(model_name, data["ids"])
223
+
224
+ @post("/api/{model_name:str}/bulk-update")
225
+ async def model_bulk_update(model_name: str, data: dict) -> dict:
226
+ return await admin._handle_bulk_update(
227
+ model_name, data["ids"], data["data"]
228
+ )
229
+
230
+ # -- SPA catch-all -------------------------------------------------
231
+
232
+ def _get_mount_prefix(request: Request) -> str:
233
+ root = request.scope.get("root_path", "")
234
+ if root:
235
+ return root
236
+ raw = request.scope.get("raw_path", b"")
237
+ if isinstance(raw, bytes):
238
+ raw = raw.decode("latin-1")
239
+ inner_path = request.scope.get("path", "")
240
+ if raw.rstrip("/").endswith(inner_path.rstrip("/")):
241
+ prefix = raw[: len(raw.rstrip("/")) - len(inner_path.rstrip("/"))]
242
+ if prefix:
243
+ return prefix.rstrip("/")
244
+ return ""
245
+
246
+ def _serve_spa(request: Request, path: str = "") -> Response:
247
+ static_file = admin._resolve_static_file(path.lstrip("/"))
248
+ if static_file is not None:
249
+ return File(path=static_file)
250
+ prefix = _get_mount_prefix(request)
251
+ html = admin._render_index_html(prefix)
252
+ if html is not None:
253
+ return Response(content=html, media_type="text/html")
254
+ return Response(content={"detail": "Frontend not built"}, status_code=404)
255
+
256
+ @get("/", name="index")
257
+ async def index(request: Request) -> Response:
258
+ return _serve_spa(request)
259
+
260
+ @get("/{path:path}", name="catch_all")
261
+ async def catch_all(request: Request, path: str) -> Response:
262
+ return _serve_spa(request, path)
263
+
264
+ return [
265
+ admin_config,
266
+ models_list,
267
+ models_counts,
268
+ model_schema,
269
+ model_list,
270
+ model_options,
271
+ model_export,
272
+ model_get,
273
+ model_create,
274
+ model_update,
275
+ model_delete,
276
+ model_bulk_delete,
277
+ model_bulk_update,
278
+ index,
279
+ catch_all,
280
+ ]