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.
Files changed (58) hide show
  1. {sqladmin-0.20.1 → sqladmin-0.22.0}/.gitignore +2 -1
  2. {sqladmin-0.20.1 → sqladmin-0.22.0}/PKG-INFO +7 -6
  3. {sqladmin-0.20.1 → sqladmin-0.22.0}/README.md +1 -1
  4. {sqladmin-0.20.1 → sqladmin-0.22.0}/pyproject.toml +25 -37
  5. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/__init__.py +1 -1
  6. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/_menu.py +1 -1
  7. sqladmin-0.22.0/sqladmin/_types.py +57 -0
  8. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/application.py +22 -8
  9. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/authentication.py +6 -2
  10. sqladmin-0.22.0/sqladmin/filters.py +340 -0
  11. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/helpers.py +12 -4
  12. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/models.py +89 -14
  13. sqladmin-0.22.0/sqladmin/statics/css/main.css +10 -0
  14. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/_macros.html +5 -2
  15. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/details.html +5 -1
  16. sqladmin-0.22.0/sqladmin/templates/sqladmin/list.html +299 -0
  17. sqladmin-0.20.1/sqladmin/_types.py +0 -9
  18. sqladmin-0.20.1/sqladmin/statics/css/main.css +0 -3
  19. sqladmin-0.20.1/sqladmin/templates/sqladmin/list.html +0 -222
  20. {sqladmin-0.20.1 → sqladmin-0.22.0}/LICENSE.md +0 -0
  21. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/_queries.py +0 -0
  22. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/_validators.py +0 -0
  23. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/ajax.py +0 -0
  24. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/exceptions.py +0 -0
  25. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/fields.py +0 -0
  26. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/formatters.py +0 -0
  27. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/forms.py +0 -0
  28. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/pagination.py +0 -0
  29. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/py.typed +0 -0
  30. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/flatpickr.min.css +0 -0
  31. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/fontawesome.min.css +0 -0
  32. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/select2.min.css +0 -0
  33. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/tabler-icons.min.css +0 -0
  34. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/tabler-icons.min.css.map +0 -0
  35. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/css/tabler.min.css +0 -0
  36. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/bootstrap.min.js +0 -0
  37. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/flatpickr.min.js +0 -0
  38. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/jquery.min.js +0 -0
  39. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/main.js +0 -0
  40. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/popper.min.js +0 -0
  41. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/select2.full.min.js +0 -0
  42. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/js/tabler.min.js +0 -0
  43. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-brands-400.woff2 +0 -0
  44. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-regular-400.woff2 +0 -0
  45. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-solid-900.woff2 +0 -0
  46. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/statics/webfonts/tabler-icons.woff2 +0 -0
  47. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/base.html +0 -0
  48. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/create.html +0 -0
  49. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/edit.html +0 -0
  50. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/error.html +0 -0
  51. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/index.html +0 -0
  52. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/layout.html +0 -0
  53. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/login.html +0 -0
  54. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/delete.html +0 -0
  55. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/details_action_confirmation.html +0 -0
  56. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/list_action_confirmation.html +0 -0
  57. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/templating.py +0 -0
  58. {sqladmin-0.20.1 → sqladmin-0.22.0}/sqladmin/widgets.py +0 -0
@@ -13,4 +13,5 @@ examples/
13
13
  .vscode/
14
14
  .uploads
15
15
  test.db
16
- coverage.xml
16
+ coverage.xml
17
+ dist/
@@ -1,8 +1,8 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: sqladmin
3
- Version: 0.20.1
3
+ Version: 0.22.0
4
4
  Summary: SQLAlchemy admin for FastAPI and Starlette
5
- Project-URL: Documentation, https://aminalaee.dev/sqladmin
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.8
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.dev/sqladmin](https://aminalaee.dev/sqladmin)
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.dev/sqladmin](https://aminalaee.dev/sqladmin)
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.8"
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.dev/sqladmin"
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==0.19.0",
65
- "arrow==1.3.0",
66
- "asyncpg==0.29.0",
67
- "babel==2.13.1",
68
- "build==1.0.3",
69
- "colour==0.1.5",
70
- "coverage==7.3.2",
71
- "email-validator==2.1.0",
72
- "fastapi-storages==0.1.0",
73
- "greenlet==3.0.1",
74
- "httpx==0.25.1",
75
- "itsdangerous==2.1.2",
76
- "phonenumbers==8.13.24",
77
- "pillow==10.1.0",
78
- "psycopg2-binary==2.9.9",
79
- "pytest==7.4.2",
80
- "python-dateutil==2.8.2",
81
- "sqlalchemy_utils==0.41.1",
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.1.3",
97
- "mkdocs==1.4.2",
98
- "mkdocstrings[python]==0.25.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
@@ -1,7 +1,7 @@
1
1
  from sqladmin.application import Admin, action, expose
2
2
  from sqladmin.models import BaseView, ModelView
3
3
 
4
- __version__ = "0.20.1"
4
+ __version__ = "0.22.0"
5
5
 
6
6
  __all__ = [
7
7
  "Admin",
@@ -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.is_visible(request) and c.is_accessible(request) for c in self.children
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=getattr(func, "_path"),
210
+ path=path,
203
211
  methods=getattr(func, "_methods"),
204
- name=getattr(func, "_identity"),
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 'Response' or `RedirectResponse` is returned,
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