sqladmin 0.20.1__tar.gz → 0.22.0__tar.gz
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.
- {sqladmin-0.20.1 → sqladmin-0.22.0}/.gitignore +2 -1
- {sqladmin-0.20.1 → sqladmin-0.22.0}/PKG-INFO +7 -6
- {sqladmin-0.20.1 → sqladmin-0.22.0}/README.md +1 -1
- {sqladmin-0.20.1 → sqladmin-0.22.0}/pyproject.toml +25 -37
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/__init__.py +1 -1
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/_menu.py +1 -1
- sqladmin-0.22.0/sqladmin/_types.py +57 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/application.py +22 -8
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/authentication.py +6 -2
- sqladmin-0.22.0/sqladmin/filters.py +340 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/helpers.py +12 -4
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/models.py +89 -14
- sqladmin-0.22.0/sqladmin/statics/css/main.css +10 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/_macros.html +5 -2
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/details.html +5 -1
- sqladmin-0.22.0/sqladmin/templates/sqladmin/list.html +299 -0
- sqladmin-0.20.1/sqladmin/_types.py +0 -9
- sqladmin-0.20.1/sqladmin/statics/css/main.css +0 -3
- sqladmin-0.20.1/sqladmin/templates/sqladmin/list.html +0 -222
- {sqladmin-0.20.1 → sqladmin-0.22.0}/LICENSE.md +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/_queries.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/_validators.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/ajax.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/exceptions.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/fields.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/formatters.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/forms.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/pagination.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/py.typed +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/flatpickr.min.css +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/fontawesome.min.css +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/select2.min.css +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/tabler-icons.min.css +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/tabler-icons.min.css.map +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/tabler.min.css +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/bootstrap.min.js +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/flatpickr.min.js +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/jquery.min.js +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/main.js +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/popper.min.js +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/select2.full.min.js +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/tabler.min.js +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-brands-400.woff2 +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-regular-400.woff2 +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-solid-900.woff2 +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/webfonts/tabler-icons.woff2 +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/base.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/create.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/edit.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/error.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/index.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/layout.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/login.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/delete.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/details_action_confirmation.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/list_action_confirmation.html +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templating.py +0 -0
- {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/widgets.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: sqladmin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.0
|
|
4
4
|
Summary: SQLAlchemy admin for FastAPI and Starlette
|
|
5
|
-
Project-URL: Documentation, https://aminalaee.
|
|
5
|
+
Project-URL: Documentation, https://aminalaee.github.io/sqladmin/
|
|
6
6
|
Project-URL: Issues, https://github.com/aminalaee/sqladmin/issues
|
|
7
7
|
Project-URL: Source, https://github.com/aminalaee/sqladmin
|
|
8
8
|
Author-email: Amin Alaee <me@aminalaee.dev>
|
|
@@ -15,13 +15,14 @@ Classifier: Intended Audience :: Developers
|
|
|
15
15
|
Classifier: License :: OSI Approved :: BSD License
|
|
16
16
|
Classifier: Operating System :: OS Independent
|
|
17
17
|
Classifier: Programming Language :: Python
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.9
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
24
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
-
Requires-Python: >=3.
|
|
25
|
+
Requires-Python: >=3.10
|
|
25
26
|
Requires-Dist: jinja2
|
|
26
27
|
Requires-Dist: python-multipart
|
|
27
28
|
Requires-Dist: sqlalchemy>=1.4
|
|
@@ -72,7 +73,7 @@ Main features include:
|
|
|
72
73
|
|
|
73
74
|
---
|
|
74
75
|
|
|
75
|
-
**Documentation**: [https://aminalaee.
|
|
76
|
+
**Documentation**: [https://aminalaee.github.io/sqladmin](https://aminalaee.github.io/sqladmin)
|
|
76
77
|
|
|
77
78
|
**Source Code**: [https://github.com/aminalaee/sqladmin](https://github.com/aminalaee/sqladmin)
|
|
78
79
|
|
|
@@ -39,7 +39,7 @@ Main features include:
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
-
**Documentation**: [https://aminalaee.
|
|
42
|
+
**Documentation**: [https://aminalaee.github.io/sqladmin](https://aminalaee.github.io/sqladmin)
|
|
43
43
|
|
|
44
44
|
**Source Code**: [https://github.com/aminalaee/sqladmin](https://github.com/aminalaee/sqladmin)
|
|
45
45
|
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
name = "sqladmin"
|
|
7
7
|
description = 'SQLAlchemy admin for FastAPI and Starlette'
|
|
8
8
|
readme = "README.md"
|
|
9
|
-
requires-python = ">=3.
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
10
|
license = "BSD-3-Clause"
|
|
11
11
|
keywords = ["sqlalchemy", "fastapi", "starlette", "admin"]
|
|
12
12
|
authors = [
|
|
@@ -15,11 +15,12 @@ authors = [
|
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
17
17
|
"Programming Language :: Python",
|
|
18
|
-
"Programming Language :: Python :: 3.8",
|
|
19
18
|
"Programming Language :: Python :: 3.9",
|
|
20
19
|
"Programming Language :: Python :: 3.10",
|
|
21
20
|
"Programming Language :: Python :: 3.11",
|
|
22
21
|
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
23
24
|
"Environment :: Web Environment",
|
|
24
25
|
"Intended Audience :: Developers",
|
|
25
26
|
"License :: OSI Approved :: BSD License",
|
|
@@ -41,7 +42,7 @@ full = [
|
|
|
41
42
|
]
|
|
42
43
|
|
|
43
44
|
[project.urls]
|
|
44
|
-
Documentation = "https://aminalaee.
|
|
45
|
+
Documentation = "https://aminalaee.github.io/sqladmin/"
|
|
45
46
|
Issues = "https://github.com/aminalaee/sqladmin/issues"
|
|
46
47
|
Source = "https://github.com/aminalaee/sqladmin"
|
|
47
48
|
|
|
@@ -61,29 +62,26 @@ exclude = [
|
|
|
61
62
|
|
|
62
63
|
[tool.hatch.envs.test]
|
|
63
64
|
dependencies = [
|
|
64
|
-
"aiosqlite
|
|
65
|
-
"arrow
|
|
66
|
-
"asyncpg
|
|
67
|
-
"babel
|
|
68
|
-
"build
|
|
69
|
-
"colour
|
|
70
|
-
"coverage
|
|
71
|
-
"email-validator
|
|
72
|
-
"fastapi-storages
|
|
73
|
-
"greenlet
|
|
74
|
-
"httpx
|
|
75
|
-
"itsdangerous
|
|
76
|
-
"phonenumbers
|
|
77
|
-
"pillow
|
|
78
|
-
"
|
|
79
|
-
"pytest
|
|
80
|
-
"python-dateutil
|
|
81
|
-
"sqlalchemy_utils
|
|
65
|
+
"aiosqlite",
|
|
66
|
+
"arrow",
|
|
67
|
+
"asyncpg",
|
|
68
|
+
"babel",
|
|
69
|
+
"build",
|
|
70
|
+
"colour",
|
|
71
|
+
"coverage",
|
|
72
|
+
"email-validator",
|
|
73
|
+
"fastapi-storages",
|
|
74
|
+
"greenlet",
|
|
75
|
+
"httpx",
|
|
76
|
+
"itsdangerous",
|
|
77
|
+
"phonenumbers",
|
|
78
|
+
"pillow",
|
|
79
|
+
"psycopg[binary]",
|
|
80
|
+
"pytest",
|
|
81
|
+
"python-dateutil",
|
|
82
|
+
"sqlalchemy_utils",
|
|
82
83
|
]
|
|
83
84
|
|
|
84
|
-
[[tool.hatch.envs.test.matrix]]
|
|
85
|
-
python = ["38", "39", "310", "311", "3.12"]
|
|
86
|
-
|
|
87
85
|
[tool.hatch.envs.lint]
|
|
88
86
|
dependencies = [
|
|
89
87
|
"mypy==1.8.0",
|
|
@@ -93,9 +91,9 @@ dependencies = [
|
|
|
93
91
|
|
|
94
92
|
[tool.hatch.envs.docs]
|
|
95
93
|
dependencies = [
|
|
96
|
-
"mkdocs-material==9.
|
|
97
|
-
"mkdocs==1.
|
|
98
|
-
"mkdocstrings[python]==0.
|
|
94
|
+
"mkdocs-material==9.6.14",
|
|
95
|
+
"mkdocs==1.6.1",
|
|
96
|
+
"mkdocstrings[python]==0.26.1",
|
|
99
97
|
]
|
|
100
98
|
|
|
101
99
|
[tool.hatch.envs.test.scripts]
|
|
@@ -121,16 +119,6 @@ build = "mkdocs build"
|
|
|
121
119
|
serve = "mkdocs serve --dev-addr localhost:8080"
|
|
122
120
|
deploy = "mkdocs gh-deploy --force"
|
|
123
121
|
|
|
124
|
-
[[tool.hatch.envs.test.matrix]]
|
|
125
|
-
sqlalchemy = ["1.4", "2.0"]
|
|
126
|
-
|
|
127
|
-
[tool.hatch.envs.test.overrides]
|
|
128
|
-
matrix.sqlalchemy.dependencies = [
|
|
129
|
-
{ value = "sqlalchemy==1.4.41", if = ["1.4"] },
|
|
130
|
-
{ value = "sqlmodel==0.0.8", if = ["1.4"] },
|
|
131
|
-
{ value = "sqlalchemy==2.0", if = ["2.0"] },
|
|
132
|
-
]
|
|
133
|
-
|
|
134
122
|
[tool.mypy]
|
|
135
123
|
disallow_untyped_defs = true
|
|
136
124
|
ignore_missing_imports = true
|
|
@@ -44,7 +44,7 @@ class ItemMenu:
|
|
|
44
44
|
class CategoryMenu(ItemMenu):
|
|
45
45
|
def is_active(self, request: Request) -> bool:
|
|
46
46
|
return any(
|
|
47
|
-
c.
|
|
47
|
+
c.is_active(request) and c.is_accessible(request) for c in self.children
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
@property
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Any,
|
|
3
|
+
Callable,
|
|
4
|
+
List,
|
|
5
|
+
Protocol,
|
|
6
|
+
Tuple,
|
|
7
|
+
Union,
|
|
8
|
+
runtime_checkable,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.engine import Engine
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
13
|
+
from sqlalchemy.orm import ColumnProperty, InstrumentedAttribute, RelationshipProperty
|
|
14
|
+
from sqlalchemy.sql.expression import Select
|
|
15
|
+
from starlette.requests import Request
|
|
16
|
+
|
|
17
|
+
MODEL_PROPERTY = Union[ColumnProperty, RelationshipProperty]
|
|
18
|
+
ENGINE_TYPE = Union[Engine, AsyncEngine]
|
|
19
|
+
MODEL_ATTR = Union[str, InstrumentedAttribute]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class SimpleColumnFilter(Protocol):
|
|
24
|
+
"""Protocol for filters with simple value-based filtering"""
|
|
25
|
+
|
|
26
|
+
title: str
|
|
27
|
+
parameter_name: str
|
|
28
|
+
|
|
29
|
+
async def lookups(
|
|
30
|
+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
|
|
31
|
+
) -> List[Tuple[str, str]]:
|
|
32
|
+
... # pragma: no cover
|
|
33
|
+
|
|
34
|
+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
|
|
35
|
+
... # pragma: no cover
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class OperationColumnFilter(Protocol):
|
|
40
|
+
"""Protocol for filters with operation-based filtering"""
|
|
41
|
+
|
|
42
|
+
title: str
|
|
43
|
+
parameter_name: str
|
|
44
|
+
has_operator: bool
|
|
45
|
+
|
|
46
|
+
async def lookups(
|
|
47
|
+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
|
|
48
|
+
) -> List[Tuple[str, str]]:
|
|
49
|
+
... # pragma: no cover
|
|
50
|
+
|
|
51
|
+
async def get_filtered_query(
|
|
52
|
+
self, query: Select, operation: str, value: Any, model: Any
|
|
53
|
+
) -> Select:
|
|
54
|
+
... # pragma: no cover
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
ColumnFilter = Union[SimpleColumnFilter, OperationColumnFilter]
|
|
@@ -197,16 +197,22 @@ class BaseAdmin:
|
|
|
197
197
|
view_instance: BaseView | ModelView,
|
|
198
198
|
) -> None:
|
|
199
199
|
if hasattr(func, "_exposed"):
|
|
200
|
+
if view.is_model:
|
|
201
|
+
path = f"/{view_instance.identity}" + getattr(func, "_path")
|
|
202
|
+
name = f"view-{view_instance.identity}-{func.__name__}"
|
|
203
|
+
else:
|
|
204
|
+
view.identity = getattr(func, "_identity")
|
|
205
|
+
path = getattr(func, "_path")
|
|
206
|
+
name = getattr(func, "_identity")
|
|
207
|
+
|
|
200
208
|
self.admin.add_route(
|
|
201
209
|
route=func,
|
|
202
|
-
path=
|
|
210
|
+
path=path,
|
|
203
211
|
methods=getattr(func, "_methods"),
|
|
204
|
-
name=
|
|
212
|
+
name=name,
|
|
205
213
|
include_in_schema=getattr(func, "_include_in_schema"),
|
|
206
214
|
)
|
|
207
215
|
|
|
208
|
-
view.identity = getattr(func, "_identity")
|
|
209
|
-
|
|
210
216
|
def add_model_view(self, view: type[ModelView]) -> None:
|
|
211
217
|
"""Add ModelView to the Admin.
|
|
212
218
|
|
|
@@ -233,6 +239,11 @@ class BaseAdmin:
|
|
|
233
239
|
self._find_decorated_funcs(
|
|
234
240
|
view, view_instance, self._handle_action_decorated_func
|
|
235
241
|
)
|
|
242
|
+
|
|
243
|
+
self._find_decorated_funcs(
|
|
244
|
+
view, view_instance, self._handle_expose_decorated_func
|
|
245
|
+
)
|
|
246
|
+
|
|
236
247
|
self._views.append(view_instance)
|
|
237
248
|
self._build_menu(view_instance)
|
|
238
249
|
|
|
@@ -266,7 +277,7 @@ class BaseAdmin:
|
|
|
266
277
|
|
|
267
278
|
def _build_menu(self, view: ModelView | BaseView) -> None:
|
|
268
279
|
if view.category:
|
|
269
|
-
menu = CategoryMenu(name=view.category)
|
|
280
|
+
menu = CategoryMenu(name=view.category, icon=view.category_icon)
|
|
270
281
|
menu.add_child(ViewMenu(view=view, name=view.name, icon=view.icon))
|
|
271
282
|
self._menu.add(menu)
|
|
272
283
|
else:
|
|
@@ -461,10 +472,9 @@ class Admin(BaseAdminView):
|
|
|
461
472
|
"""Details route."""
|
|
462
473
|
|
|
463
474
|
await self._details(request)
|
|
464
|
-
|
|
465
475
|
model_view = self._find_model_view(request.path_params["identity"])
|
|
476
|
+
model = await model_view.get_object_for_details(request)
|
|
466
477
|
|
|
467
|
-
model = await model_view.get_object_for_details(request.path_params["pk"])
|
|
468
478
|
if not model:
|
|
469
479
|
raise HTTPException(status_code=404)
|
|
470
480
|
|
|
@@ -638,7 +648,11 @@ class Admin(BaseAdminView):
|
|
|
638
648
|
async def logout(self, request: Request) -> Response:
|
|
639
649
|
assert self.authentication_backend is not None
|
|
640
650
|
|
|
641
|
-
await self.authentication_backend.logout(request)
|
|
651
|
+
response = await self.authentication_backend.logout(request)
|
|
652
|
+
|
|
653
|
+
if isinstance(response, Response):
|
|
654
|
+
return response
|
|
655
|
+
|
|
642
656
|
return RedirectResponse(request.url_for("admin:index"), status_code=302)
|
|
643
657
|
|
|
644
658
|
async def ajax_lookup(self, request: Request) -> Response:
|
|
@@ -29,9 +29,13 @@ class AuthenticationBackend:
|
|
|
29
29
|
"""
|
|
30
30
|
raise NotImplementedError()
|
|
31
31
|
|
|
32
|
-
async def logout(self, request: Request) -> bool:
|
|
32
|
+
async def logout(self, request: Request) -> Response | bool:
|
|
33
33
|
"""Implement logout logic here.
|
|
34
34
|
This will usually clear the session with `request.session.clear()`.
|
|
35
|
+
|
|
36
|
+
If a `Response` or `RedirectResponse` is returned,
|
|
37
|
+
that response is returned to the user,
|
|
38
|
+
otherwise the user will be redirected to the index page.
|
|
35
39
|
"""
|
|
36
40
|
raise NotImplementedError()
|
|
37
41
|
|
|
@@ -40,7 +44,7 @@ class AuthenticationBackend:
|
|
|
40
44
|
This method will be called for each incoming request
|
|
41
45
|
to validate the authentication.
|
|
42
46
|
|
|
43
|
-
If a
|
|
47
|
+
If a `Response` or `RedirectResponse` is returned,
|
|
44
48
|
that response is returned to the user,
|
|
45
49
|
otherwise a True/False is expected.
|
|
46
50
|
"""
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Callable, List, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import (
|
|
5
|
+
BigInteger,
|
|
6
|
+
Float,
|
|
7
|
+
Integer,
|
|
8
|
+
Numeric,
|
|
9
|
+
SmallInteger,
|
|
10
|
+
String,
|
|
11
|
+
Text,
|
|
12
|
+
)
|
|
13
|
+
from sqlalchemy.sql.expression import Select, select
|
|
14
|
+
from sqlalchemy.sql.sqltypes import _Binary
|
|
15
|
+
from starlette.requests import Request
|
|
16
|
+
|
|
17
|
+
from sqladmin._types import MODEL_ATTR
|
|
18
|
+
|
|
19
|
+
# Try to import UUID type for SQLAlchemy 2.0+
|
|
20
|
+
try:
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
from sqlalchemy import Uuid
|
|
24
|
+
|
|
25
|
+
HAS_UUID_SUPPORT = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
# Fallback for SQLAlchemy < 2.0
|
|
28
|
+
HAS_UUID_SUPPORT = False
|
|
29
|
+
Uuid = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_parameter_name(column: MODEL_ATTR) -> str:
|
|
33
|
+
if isinstance(column, str):
|
|
34
|
+
return column
|
|
35
|
+
else:
|
|
36
|
+
return column.key
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def prettify_attribute_name(name: str) -> str:
|
|
40
|
+
return re.sub(r"_([A-Za-z])", r" \1", name).title()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_title(column: MODEL_ATTR) -> str:
|
|
44
|
+
name = get_parameter_name(column)
|
|
45
|
+
return prettify_attribute_name(name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_column_obj(column: MODEL_ATTR, model: Any = None) -> Any:
|
|
49
|
+
if isinstance(column, str):
|
|
50
|
+
if model is None:
|
|
51
|
+
raise ValueError("model is required for string column filters")
|
|
52
|
+
return getattr(model, column)
|
|
53
|
+
return column
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_foreign_column_name(column_obj: Any) -> str:
|
|
57
|
+
fk = next(iter(column_obj.foreign_keys))
|
|
58
|
+
return fk.column.name
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_model_from_column(column: Any) -> Any:
|
|
62
|
+
return column.parent.class_
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BooleanFilter:
|
|
66
|
+
has_operator = False
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
column: MODEL_ATTR,
|
|
71
|
+
title: Optional[str] = None,
|
|
72
|
+
parameter_name: Optional[str] = None,
|
|
73
|
+
):
|
|
74
|
+
self.column = column
|
|
75
|
+
self.title = title or get_title(column)
|
|
76
|
+
self.parameter_name = parameter_name or get_parameter_name(column)
|
|
77
|
+
|
|
78
|
+
async def lookups(
|
|
79
|
+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
|
|
80
|
+
) -> List[Tuple[str, str]]:
|
|
81
|
+
return [
|
|
82
|
+
("all", "All"),
|
|
83
|
+
("true", "Yes"),
|
|
84
|
+
("false", "No"),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
|
|
88
|
+
column_obj = get_column_obj(self.column, model)
|
|
89
|
+
if value == "true":
|
|
90
|
+
return query.filter(column_obj.is_(True))
|
|
91
|
+
elif value == "false":
|
|
92
|
+
return query.filter(column_obj.is_(False))
|
|
93
|
+
else:
|
|
94
|
+
return query
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AllUniqueStringValuesFilter:
|
|
98
|
+
has_operator = False
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
column: MODEL_ATTR,
|
|
103
|
+
title: Optional[str] = None,
|
|
104
|
+
parameter_name: Optional[str] = None,
|
|
105
|
+
):
|
|
106
|
+
self.column = column
|
|
107
|
+
self.title = title or get_title(column)
|
|
108
|
+
self.parameter_name = parameter_name or get_parameter_name(column)
|
|
109
|
+
|
|
110
|
+
async def lookups(
|
|
111
|
+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
|
|
112
|
+
) -> List[Tuple[str, str]]:
|
|
113
|
+
column_obj = get_column_obj(self.column, model)
|
|
114
|
+
|
|
115
|
+
return [("", "All")] + [
|
|
116
|
+
(value[0], value[0])
|
|
117
|
+
for value in await run_query(select(column_obj).distinct())
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
|
|
121
|
+
if value == "":
|
|
122
|
+
return query
|
|
123
|
+
|
|
124
|
+
column_obj = get_column_obj(self.column, model)
|
|
125
|
+
return query.filter(column_obj == value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class StaticValuesFilter:
|
|
129
|
+
has_operator = False
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
column: MODEL_ATTR,
|
|
134
|
+
values: List[Tuple[str, str]],
|
|
135
|
+
title: Optional[str] = None,
|
|
136
|
+
parameter_name: Optional[str] = None,
|
|
137
|
+
):
|
|
138
|
+
self.column = column
|
|
139
|
+
self.title = title or get_title(column)
|
|
140
|
+
self.parameter_name = parameter_name or get_parameter_name(column)
|
|
141
|
+
self.values = values
|
|
142
|
+
|
|
143
|
+
async def lookups(
|
|
144
|
+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
|
|
145
|
+
) -> List[Tuple[str, str]]:
|
|
146
|
+
return [("", "All")] + self.values
|
|
147
|
+
|
|
148
|
+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
|
|
149
|
+
column_obj = get_column_obj(self.column, model)
|
|
150
|
+
if value == "":
|
|
151
|
+
return query
|
|
152
|
+
return query.filter(column_obj == value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ForeignKeyFilter:
|
|
156
|
+
has_operator = False
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
foreign_key: MODEL_ATTR,
|
|
161
|
+
foreign_display_field: MODEL_ATTR,
|
|
162
|
+
foreign_model: Any = None,
|
|
163
|
+
title: Optional[str] = None,
|
|
164
|
+
parameter_name: Optional[str] = None,
|
|
165
|
+
):
|
|
166
|
+
self.foreign_key = foreign_key
|
|
167
|
+
self.foreign_display_field = foreign_display_field
|
|
168
|
+
self.foreign_model = foreign_model
|
|
169
|
+
self.title = title or get_title(foreign_key)
|
|
170
|
+
self.parameter_name = parameter_name or get_parameter_name(foreign_key)
|
|
171
|
+
|
|
172
|
+
async def lookups(
|
|
173
|
+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
|
|
174
|
+
) -> List[Tuple[str, str]]:
|
|
175
|
+
foreign_key_obj = get_column_obj(self.foreign_key, model)
|
|
176
|
+
if self.foreign_model is None and isinstance(self.foreign_display_field, str):
|
|
177
|
+
raise ValueError("foreign_model is required for string foreign key filters")
|
|
178
|
+
if self.foreign_model is None:
|
|
179
|
+
assert not isinstance(self.foreign_display_field, str)
|
|
180
|
+
foreign_display_field_obj = self.foreign_display_field
|
|
181
|
+
else:
|
|
182
|
+
foreign_display_field_obj = get_column_obj(
|
|
183
|
+
self.foreign_display_field, self.foreign_model
|
|
184
|
+
)
|
|
185
|
+
if not self.foreign_model:
|
|
186
|
+
self.foreign_model = get_model_from_column(foreign_display_field_obj)
|
|
187
|
+
foreign_model_key_name = get_foreign_column_name(foreign_key_obj)
|
|
188
|
+
foreign_model_key_obj = getattr(self.foreign_model, foreign_model_key_name)
|
|
189
|
+
|
|
190
|
+
return [("", "All")] + [
|
|
191
|
+
(str(key), str(value))
|
|
192
|
+
for key, value in await run_query(
|
|
193
|
+
select(foreign_model_key_obj, foreign_display_field_obj).distinct()
|
|
194
|
+
)
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
|
|
198
|
+
foreign_key_obj = get_column_obj(self.foreign_key, model)
|
|
199
|
+
column_type = foreign_key_obj.type
|
|
200
|
+
if isinstance(column_type, Integer):
|
|
201
|
+
value = int(value)
|
|
202
|
+
|
|
203
|
+
return query.filter(foreign_key_obj == value)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class OperationColumnFilter:
|
|
207
|
+
"""Universal filter that provides appropriate filter types based on column type"""
|
|
208
|
+
|
|
209
|
+
has_operator = True
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
column: MODEL_ATTR,
|
|
214
|
+
title: Optional[str] = None,
|
|
215
|
+
parameter_name: Optional[str] = None,
|
|
216
|
+
):
|
|
217
|
+
self.column = column
|
|
218
|
+
self.title = title or get_title(column)
|
|
219
|
+
self.parameter_name = parameter_name or get_parameter_name(column)
|
|
220
|
+
|
|
221
|
+
def get_operation_options(self, column_obj: Any) -> List[Tuple[str, str]]:
|
|
222
|
+
"""Return operation options based on column type"""
|
|
223
|
+
if self._is_string_type(column_obj):
|
|
224
|
+
return [
|
|
225
|
+
("contains", "Contains"),
|
|
226
|
+
("equals", "Equals"),
|
|
227
|
+
("starts_with", "Starts with"),
|
|
228
|
+
("ends_with", "Ends with"),
|
|
229
|
+
]
|
|
230
|
+
elif self._is_numeric_type(column_obj):
|
|
231
|
+
return [
|
|
232
|
+
("equals", "Equals"),
|
|
233
|
+
("greater_than", "Greater than"),
|
|
234
|
+
("less_than", "Less than"),
|
|
235
|
+
]
|
|
236
|
+
elif self._is_uuid_type(column_obj):
|
|
237
|
+
return [
|
|
238
|
+
("equals", "Equals"),
|
|
239
|
+
("contains", "Contains"),
|
|
240
|
+
("starts_with", "Starts with"),
|
|
241
|
+
]
|
|
242
|
+
else:
|
|
243
|
+
return [
|
|
244
|
+
("equals", "Equals"),
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
def get_operation_options_for_model(self, model: Any) -> List[Tuple[str, str]]:
|
|
248
|
+
"""Return operation options based on column type for given model"""
|
|
249
|
+
column_obj = get_column_obj(self.column, model)
|
|
250
|
+
return self.get_operation_options(column_obj)
|
|
251
|
+
|
|
252
|
+
def _is_string_type(self, column_obj: Any) -> bool:
|
|
253
|
+
return isinstance(column_obj.type, (String, Text, _Binary))
|
|
254
|
+
|
|
255
|
+
def _is_numeric_type(self, column_obj: Any) -> bool:
|
|
256
|
+
return isinstance(
|
|
257
|
+
column_obj.type, (Integer, Numeric, Float, BigInteger, SmallInteger)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _is_uuid_type(self, column_obj: Any) -> bool:
|
|
261
|
+
# Check if UUID support is available and column is UUID type
|
|
262
|
+
return HAS_UUID_SUPPORT and isinstance(column_obj.type, Uuid)
|
|
263
|
+
|
|
264
|
+
def _convert_value_for_column(
|
|
265
|
+
self, value: str, column_obj: Any, operation: str = "equals"
|
|
266
|
+
) -> Any:
|
|
267
|
+
if not value:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
column_type = column_obj.type
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
if isinstance(column_type, (String, Text, _Binary)):
|
|
274
|
+
return str(value)
|
|
275
|
+
|
|
276
|
+
if isinstance(column_type, (Integer, BigInteger, SmallInteger)):
|
|
277
|
+
return int(value)
|
|
278
|
+
|
|
279
|
+
if isinstance(column_type, (Numeric, Float)):
|
|
280
|
+
return float(value)
|
|
281
|
+
|
|
282
|
+
# UUID support for SQLAlchemy 2.0+
|
|
283
|
+
if HAS_UUID_SUPPORT and isinstance(column_type, Uuid):
|
|
284
|
+
# For contains/starts_with operations, keep as string for LIKE queries
|
|
285
|
+
if operation in ("contains", "starts_with"):
|
|
286
|
+
return str(value.strip())
|
|
287
|
+
# For equals operation, validate and convert to UUID
|
|
288
|
+
return uuid.UUID(value.strip())
|
|
289
|
+
|
|
290
|
+
except (ValueError, TypeError):
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
return str(value)
|
|
294
|
+
|
|
295
|
+
async def lookups(
|
|
296
|
+
self, request: Request, model: Any, run_query: Callable[[Select], Any]
|
|
297
|
+
) -> List[Tuple[str, str]]:
|
|
298
|
+
# This method is not used for has_operator=True filters
|
|
299
|
+
# The UI uses get_operation_options_for_model instead
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
async def get_filtered_query(
|
|
303
|
+
self, query: Select, operation: str, value: Any, model: Any
|
|
304
|
+
) -> Select:
|
|
305
|
+
"""Handle filtering with separate operation and value parameters"""
|
|
306
|
+
if not value or value == "" or not operation:
|
|
307
|
+
return query
|
|
308
|
+
|
|
309
|
+
column_obj = get_column_obj(self.column, model)
|
|
310
|
+
converted_value = self._convert_value_for_column(
|
|
311
|
+
str(value).strip(), column_obj, operation
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if converted_value is None:
|
|
315
|
+
return query
|
|
316
|
+
|
|
317
|
+
if operation == "contains":
|
|
318
|
+
if self._is_uuid_type(column_obj):
|
|
319
|
+
# For UUID, cast to text for LIKE operations
|
|
320
|
+
search_value = f"%{str(value).strip()}%"
|
|
321
|
+
return query.filter(column_obj.cast(String).ilike(search_value))
|
|
322
|
+
else:
|
|
323
|
+
return query.filter(column_obj.ilike(f"%{str(value).strip()}%"))
|
|
324
|
+
elif operation == "equals":
|
|
325
|
+
return query.filter(column_obj == converted_value)
|
|
326
|
+
elif operation == "starts_with":
|
|
327
|
+
if self._is_uuid_type(column_obj):
|
|
328
|
+
# For UUID, cast to text for LIKE operations
|
|
329
|
+
search_value = f"{str(value).strip()}%"
|
|
330
|
+
return query.filter(column_obj.cast(String).ilike(search_value))
|
|
331
|
+
else:
|
|
332
|
+
return query.filter(column_obj.startswith(str(value).strip()))
|
|
333
|
+
elif operation == "ends_with":
|
|
334
|
+
return query.filter(column_obj.endswith(str(value).strip()))
|
|
335
|
+
elif operation == "greater_than":
|
|
336
|
+
return query.filter(column_obj > converted_value)
|
|
337
|
+
elif operation == "less_than":
|
|
338
|
+
return query.filter(column_obj < converted_value)
|
|
339
|
+
|
|
340
|
+
return query
|