sqladmin 0.21.0__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.21.0 → sqladmin-0.22.0}/PKG-INFO +4 -3
- {sqladmin-0.21.0 → sqladmin-0.22.0}/pyproject.toml +21 -33
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/__init__.py +1 -1
- sqladmin-0.22.0/sqladmin/_types.py +57 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/filters.py +167 -1
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/helpers.py +8 -2
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/models.py +29 -8
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/list.html +52 -14
- sqladmin-0.21.0/sqladmin/_types.py +0 -25
- {sqladmin-0.21.0 → sqladmin-0.22.0}/.gitignore +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/LICENSE.md +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/README.md +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/_menu.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/_queries.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/_validators.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/ajax.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/application.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/authentication.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/exceptions.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/fields.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/formatters.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/forms.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/pagination.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/py.typed +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/flatpickr.min.css +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/fontawesome.min.css +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/main.css +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/select2.min.css +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/tabler-icons.min.css +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/tabler-icons.min.css.map +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/tabler.min.css +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/bootstrap.min.js +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/flatpickr.min.js +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/jquery.min.js +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/main.js +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/popper.min.js +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/select2.full.min.js +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/tabler.min.js +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-brands-400.woff2 +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-regular-400.woff2 +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-solid-900.woff2 +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/webfonts/tabler-icons.woff2 +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/_macros.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/base.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/create.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/details.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/edit.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/error.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/index.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/layout.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/login.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/delete.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/details_action_confirmation.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/list_action_confirmation.html +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templating.py +0 -0
- {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/widgets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
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
5
|
Project-URL: Documentation, https://aminalaee.github.io/sqladmin/
|
|
6
6
|
Project-URL: Issues, https://github.com/aminalaee/sqladmin/issues
|
|
@@ -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
|
|
@@ -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",
|
|
@@ -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",
|
|
@@ -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
|
|
@@ -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]
|
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from typing import Any, Callable, List, Optional, Tuple
|
|
3
3
|
|
|
4
|
-
from sqlalchemy import
|
|
4
|
+
from sqlalchemy import (
|
|
5
|
+
BigInteger,
|
|
6
|
+
Float,
|
|
7
|
+
Integer,
|
|
8
|
+
Numeric,
|
|
9
|
+
SmallInteger,
|
|
10
|
+
String,
|
|
11
|
+
Text,
|
|
12
|
+
)
|
|
5
13
|
from sqlalchemy.sql.expression import Select, select
|
|
14
|
+
from sqlalchemy.sql.sqltypes import _Binary
|
|
6
15
|
from starlette.requests import Request
|
|
7
16
|
|
|
8
17
|
from sqladmin._types import MODEL_ATTR
|
|
9
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
|
+
|
|
10
31
|
|
|
11
32
|
def get_parameter_name(column: MODEL_ATTR) -> str:
|
|
12
33
|
if isinstance(column, str):
|
|
@@ -42,6 +63,8 @@ def get_model_from_column(column: Any) -> Any:
|
|
|
42
63
|
|
|
43
64
|
|
|
44
65
|
class BooleanFilter:
|
|
66
|
+
has_operator = False
|
|
67
|
+
|
|
45
68
|
def __init__(
|
|
46
69
|
self,
|
|
47
70
|
column: MODEL_ATTR,
|
|
@@ -72,6 +95,8 @@ class BooleanFilter:
|
|
|
72
95
|
|
|
73
96
|
|
|
74
97
|
class AllUniqueStringValuesFilter:
|
|
98
|
+
has_operator = False
|
|
99
|
+
|
|
75
100
|
def __init__(
|
|
76
101
|
self,
|
|
77
102
|
column: MODEL_ATTR,
|
|
@@ -101,6 +126,8 @@ class AllUniqueStringValuesFilter:
|
|
|
101
126
|
|
|
102
127
|
|
|
103
128
|
class StaticValuesFilter:
|
|
129
|
+
has_operator = False
|
|
130
|
+
|
|
104
131
|
def __init__(
|
|
105
132
|
self,
|
|
106
133
|
column: MODEL_ATTR,
|
|
@@ -126,6 +153,8 @@ class StaticValuesFilter:
|
|
|
126
153
|
|
|
127
154
|
|
|
128
155
|
class ForeignKeyFilter:
|
|
156
|
+
has_operator = False
|
|
157
|
+
|
|
129
158
|
def __init__(
|
|
130
159
|
self,
|
|
131
160
|
foreign_key: MODEL_ATTR,
|
|
@@ -172,3 +201,140 @@ class ForeignKeyFilter:
|
|
|
172
201
|
value = int(value)
|
|
173
202
|
|
|
174
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
|
|
@@ -6,7 +6,7 @@ import os
|
|
|
6
6
|
import re
|
|
7
7
|
import unicodedata
|
|
8
8
|
from abc import ABC, abstractmethod
|
|
9
|
-
from datetime import timedelta
|
|
9
|
+
from datetime import date, datetime, time, timedelta
|
|
10
10
|
from typing import (
|
|
11
11
|
Any,
|
|
12
12
|
AsyncGenerator,
|
|
@@ -226,7 +226,13 @@ def object_identifier_values(id_string: str, model: Any) -> tuple:
|
|
|
226
226
|
pks = get_primary_keys(model)
|
|
227
227
|
for pk, part in zip(pks, _object_identifier_parts(id_string, model)):
|
|
228
228
|
type_ = get_column_python_type(pk)
|
|
229
|
-
value
|
|
229
|
+
value: Any
|
|
230
|
+
if issubclass(type_, (date, datetime, time)):
|
|
231
|
+
value = type_.fromisoformat(part)
|
|
232
|
+
elif issubclass(type_, bool):
|
|
233
|
+
value = False if part == "False" else type_(part)
|
|
234
|
+
else:
|
|
235
|
+
value = type_(part) # type: ignore [call-arg]
|
|
230
236
|
values.append(value)
|
|
231
237
|
return tuple(values)
|
|
232
238
|
|
|
@@ -19,6 +19,7 @@ from typing import (
|
|
|
19
19
|
Union,
|
|
20
20
|
no_type_check,
|
|
21
21
|
)
|
|
22
|
+
from typing import cast as typing_cast
|
|
22
23
|
from urllib.parse import urlencode
|
|
23
24
|
|
|
24
25
|
import anyio
|
|
@@ -36,7 +37,12 @@ from wtforms import Field, Form
|
|
|
36
37
|
from wtforms.fields.core import UnboundField
|
|
37
38
|
|
|
38
39
|
from sqladmin._queries import Query
|
|
39
|
-
from sqladmin._types import
|
|
40
|
+
from sqladmin._types import (
|
|
41
|
+
MODEL_ATTR,
|
|
42
|
+
ColumnFilter,
|
|
43
|
+
OperationColumnFilter,
|
|
44
|
+
SimpleColumnFilter,
|
|
45
|
+
)
|
|
40
46
|
from sqladmin.ajax import create_ajax_loader
|
|
41
47
|
from sqladmin.exceptions import InvalidModelError
|
|
42
48
|
from sqladmin.formatters import BASE_FORMATTERS
|
|
@@ -837,18 +843,33 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
837
843
|
stmt = stmt.options(selectinload(relation))
|
|
838
844
|
|
|
839
845
|
for filter in self.get_filters():
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
846
|
+
filter_param_name = filter.parameter_name
|
|
847
|
+
filter_value = request.query_params.get(filter_param_name)
|
|
848
|
+
|
|
849
|
+
if filter_value:
|
|
850
|
+
if hasattr(filter, "has_operator") and filter.has_operator:
|
|
851
|
+
# Use operation-based filtering
|
|
852
|
+
operation_filter = typing_cast(OperationColumnFilter, filter)
|
|
853
|
+
operation_param = request.query_params.get(
|
|
854
|
+
f"{filter_param_name}_op"
|
|
855
|
+
)
|
|
856
|
+
if operation_param:
|
|
857
|
+
stmt = await operation_filter.get_filtered_query(
|
|
858
|
+
stmt, operation_param, filter_value, self.model
|
|
859
|
+
)
|
|
860
|
+
else:
|
|
861
|
+
# Use simple filtering for filters without operators
|
|
862
|
+
simple_filter = typing_cast(SimpleColumnFilter, filter)
|
|
863
|
+
stmt = await simple_filter.get_filtered_query(
|
|
864
|
+
stmt, filter_value, self.model
|
|
865
|
+
)
|
|
844
866
|
|
|
845
867
|
stmt = self.sort_query(stmt, request)
|
|
846
868
|
|
|
847
869
|
if search:
|
|
848
870
|
stmt = self.search_query(stmt=stmt, term=search)
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
count = await self.count(request)
|
|
871
|
+
|
|
872
|
+
count = await self.count(request, select(func.count()).select_from(stmt))
|
|
852
873
|
|
|
853
874
|
stmt = stmt.limit(page_size).offset((page - 1) * page_size)
|
|
854
875
|
rows = await self._run_query(stmt)
|
|
@@ -221,23 +221,61 @@
|
|
|
221
221
|
<div class="card-header">
|
|
222
222
|
<h3 class="card-title">Filters</h3>
|
|
223
223
|
</div>
|
|
224
|
-
<div class="card-body
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
</
|
|
224
|
+
<div class="card-body">
|
|
225
|
+
{% for filter in model_view.get_filters() %}
|
|
226
|
+
{% if filter.has_operator %}
|
|
227
|
+
<div class="mb-3">
|
|
228
|
+
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
|
229
|
+
<div>
|
|
230
|
+
<!-- Show current filter if active -->
|
|
231
|
+
{% set current_filter = request.query_params.get(filter.parameter_name, '') %}
|
|
232
|
+
{% set current_op = request.query_params.get(filter.parameter_name + '_op', '') %}
|
|
233
|
+
{% if current_filter %}
|
|
234
|
+
<div class="mb-2 text-muted small">
|
|
235
|
+
Current: {{ current_op }} {{ current_filter }}
|
|
236
|
+
<a href="{{ request.url.remove_query_params(filter.parameter_name).remove_query_params(filter.parameter_name + '_op') }}" class="text-decoration-none">[Clear]</a>
|
|
237
237
|
</div>
|
|
238
|
+
{% endif %}
|
|
239
|
+
<!-- Single form with dropdown for operations -->
|
|
240
|
+
<form method="get" class="d-flex flex-column" style="gap: 8px;">
|
|
241
|
+
<!-- Preserve existing query parameters -->
|
|
242
|
+
{% for key, value in request.query_params.items() %}
|
|
243
|
+
{% if key != filter.parameter_name and key != filter.parameter_name + '_op' %}
|
|
244
|
+
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
|
245
|
+
{% endif %}
|
|
246
|
+
{% endfor %}
|
|
247
|
+
<!-- Operation dropdown -->
|
|
248
|
+
<select name="{{ filter.parameter_name }}_op" class="form-select form-select-sm" required>
|
|
249
|
+
<option value="">Select operation...</option>
|
|
250
|
+
{% for op_value, op_label in filter.get_operation_options_for_model(model_view.model) %}
|
|
251
|
+
<option value="{{ op_value }}" {% if current_op == op_value %}selected{% endif %}>{{ op_label }}</option>
|
|
252
|
+
{% endfor %}
|
|
253
|
+
</select>
|
|
254
|
+
<!-- Value input -->
|
|
255
|
+
<input type="text"
|
|
256
|
+
name="{{ filter.parameter_name }}"
|
|
257
|
+
placeholder="Enter value"
|
|
258
|
+
class="form-control form-control-sm"
|
|
259
|
+
value="{{ current_filter }}"
|
|
260
|
+
required>
|
|
261
|
+
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Filter</button>
|
|
262
|
+
</form>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
{% else %}
|
|
266
|
+
<!-- Fallback for other filter types -->
|
|
267
|
+
<div class="mb-3">
|
|
268
|
+
<div class="fw-bold text-truncate">{{ filter.title }}</div>
|
|
269
|
+
<div>
|
|
270
|
+
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
|
|
271
|
+
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate">
|
|
272
|
+
{{ lookup[1] }}
|
|
273
|
+
</a>
|
|
274
|
+
{% endfor %}
|
|
238
275
|
</div>
|
|
239
|
-
{% endfor %}
|
|
240
276
|
</div>
|
|
277
|
+
{% endif %}
|
|
278
|
+
{% endfor %}
|
|
241
279
|
</div>
|
|
242
280
|
</div>
|
|
243
281
|
</div>
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
from typing import Any, Callable, List, Protocol, Tuple, Union, runtime_checkable
|
|
2
|
-
|
|
3
|
-
from sqlalchemy.engine import Engine
|
|
4
|
-
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
5
|
-
from sqlalchemy.orm import ColumnProperty, InstrumentedAttribute, RelationshipProperty
|
|
6
|
-
from sqlalchemy.sql.expression import Select
|
|
7
|
-
from starlette.requests import Request
|
|
8
|
-
|
|
9
|
-
MODEL_PROPERTY = Union[ColumnProperty, RelationshipProperty]
|
|
10
|
-
ENGINE_TYPE = Union[Engine, AsyncEngine]
|
|
11
|
-
MODEL_ATTR = Union[str, InstrumentedAttribute]
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@runtime_checkable
|
|
15
|
-
class ColumnFilter(Protocol):
|
|
16
|
-
title: str
|
|
17
|
-
parameter_name: str
|
|
18
|
-
|
|
19
|
-
async def lookups(
|
|
20
|
-
self, request: Request, model: Any, run_query: Callable[[Select], Any]
|
|
21
|
-
) -> List[Tuple[str, str]]:
|
|
22
|
-
...
|
|
23
|
-
|
|
24
|
-
async def get_filtered_query(self, query: Select, value: Any, model: Any) -> Select:
|
|
25
|
-
...
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/list_action_confirmation.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|