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.
Files changed (56) hide show
  1. {sqladmin-0.21.0 → sqladmin-0.22.0}/PKG-INFO +4 -3
  2. {sqladmin-0.21.0 → sqladmin-0.22.0}/pyproject.toml +21 -33
  3. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/__init__.py +1 -1
  4. sqladmin-0.22.0/sqladmin/_types.py +57 -0
  5. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/filters.py +167 -1
  6. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/helpers.py +8 -2
  7. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/models.py +29 -8
  8. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/list.html +52 -14
  9. sqladmin-0.21.0/sqladmin/_types.py +0 -25
  10. {sqladmin-0.21.0 → sqladmin-0.22.0}/.gitignore +0 -0
  11. {sqladmin-0.21.0 → sqladmin-0.22.0}/LICENSE.md +0 -0
  12. {sqladmin-0.21.0 → sqladmin-0.22.0}/README.md +0 -0
  13. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/_menu.py +0 -0
  14. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/_queries.py +0 -0
  15. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/_validators.py +0 -0
  16. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/ajax.py +0 -0
  17. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/application.py +0 -0
  18. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/authentication.py +0 -0
  19. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/exceptions.py +0 -0
  20. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/fields.py +0 -0
  21. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/formatters.py +0 -0
  22. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/forms.py +0 -0
  23. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/pagination.py +0 -0
  24. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/py.typed +0 -0
  25. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/flatpickr.min.css +0 -0
  26. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/fontawesome.min.css +0 -0
  27. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/main.css +0 -0
  28. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/select2.min.css +0 -0
  29. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/tabler-icons.min.css +0 -0
  30. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/tabler-icons.min.css.map +0 -0
  31. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/css/tabler.min.css +0 -0
  32. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/bootstrap.min.js +0 -0
  33. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/flatpickr.min.js +0 -0
  34. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/jquery.min.js +0 -0
  35. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/main.js +0 -0
  36. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/popper.min.js +0 -0
  37. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/select2.full.min.js +0 -0
  38. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/js/tabler.min.js +0 -0
  39. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-brands-400.woff2 +0 -0
  40. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-regular-400.woff2 +0 -0
  41. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/webfonts/fa-solid-900.woff2 +0 -0
  42. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/statics/webfonts/tabler-icons.woff2 +0 -0
  43. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/_macros.html +0 -0
  44. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/base.html +0 -0
  45. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/create.html +0 -0
  46. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/details.html +0 -0
  47. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/edit.html +0 -0
  48. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/error.html +0 -0
  49. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/index.html +0 -0
  50. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/layout.html +0 -0
  51. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/login.html +0 -0
  52. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/delete.html +0 -0
  53. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/details_action_confirmation.html +0 -0
  54. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templates/sqladmin/modals/list_action_confirmation.html +0 -0
  55. {sqladmin-0.21.0 → sqladmin-0.22.0}/sqladmin/templating.py +0 -0
  56. {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.21.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.8
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.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",
@@ -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",
@@ -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.21.0"
4
+ __version__ = "0.22.0"
5
5
 
6
6
  __all__ = [
7
7
  "Admin",
@@ -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 Integer
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 = False if type_ is bool and part == "False" else type_(part)
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 MODEL_ATTR, ColumnFilter
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
- if request.query_params.get(filter.parameter_name):
841
- stmt = await filter.get_filtered_query(
842
- stmt, request.query_params.get(filter.parameter_name), self.model
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
- count = await self.count(request, select(func.count()).select_from(stmt))
850
- else:
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 p-0">
225
- <div class="list-group list-group-flush">
226
- {% for filter in model_view.get_filters() %}
227
- <div class="list-group-item">
228
- <div class="p-2">
229
- <div class="fw-bold text-truncate">{{ filter.title }}</div>
230
- <div>
231
- {% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
232
- <a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate">
233
- {{ lookup[1] }}
234
- </a>
235
- {% endfor %}
236
- </div>
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