sqladmin 0.21.0__py3-none-any.whl → 0.23.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqladmin/__init__.py +0 -2
- sqladmin/_menu.py +1 -1
- sqladmin/_queries.py +22 -14
- sqladmin/_types.py +36 -6
- sqladmin/_validators.py +14 -8
- sqladmin/ajax.py +2 -2
- sqladmin/application.py +35 -20
- sqladmin/fields.py +29 -20
- sqladmin/filters.py +206 -11
- sqladmin/formatters.py +1 -1
- sqladmin/forms.py +140 -51
- sqladmin/helpers.py +20 -7
- sqladmin/models.py +95 -32
- sqladmin/pretty_export.py +75 -0
- sqladmin/templates/sqladmin/details.html +7 -7
- sqladmin/templates/sqladmin/list.html +62 -13
- sqladmin/templating.py +1 -1
- sqladmin/widgets.py +20 -14
- {sqladmin-0.21.0.dist-info → sqladmin-0.23.0.dist-info}/METADATA +17 -16
- {sqladmin-0.21.0.dist-info → sqladmin-0.23.0.dist-info}/RECORD +21 -21
- {sqladmin-0.21.0.dist-info → sqladmin-0.23.0.dist-info}/WHEEL +1 -1
- sqladmin-0.21.0.dist-info/licenses/LICENSE.md +0 -27
sqladmin/models.py
CHANGED
|
@@ -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
|
|
@@ -54,10 +60,11 @@ from sqladmin.helpers import (
|
|
|
54
60
|
|
|
55
61
|
# stream_to_csv,
|
|
56
62
|
from sqladmin.pagination import Pagination
|
|
63
|
+
from sqladmin.pretty_export import PrettyExport
|
|
57
64
|
from sqladmin.templating import Jinja2Templates
|
|
58
65
|
|
|
59
66
|
if TYPE_CHECKING:
|
|
60
|
-
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
67
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker # type: ignore[attr-defined]
|
|
61
68
|
|
|
62
69
|
from sqladmin.application import BaseAdmin
|
|
63
70
|
|
|
@@ -76,8 +83,8 @@ class ModelViewMeta(type):
|
|
|
76
83
|
"""
|
|
77
84
|
|
|
78
85
|
@no_type_check
|
|
79
|
-
def __new__(
|
|
80
|
-
cls: Type["ModelView"] = super().__new__(
|
|
86
|
+
def __new__(mcs, name, bases, attrs: dict, **kwargs: Any):
|
|
87
|
+
cls: Type["ModelView"] = super().__new__(mcs, name, bases, attrs)
|
|
81
88
|
|
|
82
89
|
model = kwargs.get("model")
|
|
83
90
|
|
|
@@ -86,10 +93,10 @@ class ModelViewMeta(type):
|
|
|
86
93
|
|
|
87
94
|
try:
|
|
88
95
|
inspect(model)
|
|
89
|
-
except NoInspectionAvailable:
|
|
96
|
+
except NoInspectionAvailable as exc:
|
|
90
97
|
raise InvalidModelError(
|
|
91
98
|
f"Class {model.__name__} is not a SQLAlchemy model."
|
|
92
|
-
)
|
|
99
|
+
) from exc
|
|
93
100
|
|
|
94
101
|
cls.pk_columns = get_primary_keys(model)
|
|
95
102
|
cls.identity = slugify_class_name(model.__name__)
|
|
@@ -98,21 +105,19 @@ class ModelViewMeta(type):
|
|
|
98
105
|
cls.name = attrs.get("name", prettify_class_name(cls.model.__name__))
|
|
99
106
|
cls.name_plural = attrs.get("name_plural", f"{cls.name}s")
|
|
100
107
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
mcls._check_conflicting_options(
|
|
108
|
+
mcs._check_conflicting_options(["column_list", "column_exclude_list"], attrs)
|
|
109
|
+
mcs._check_conflicting_options(["form_columns", "form_excluded_columns"], attrs)
|
|
110
|
+
mcs._check_conflicting_options(
|
|
106
111
|
["column_details_list", "column_details_exclude_list"], attrs
|
|
107
112
|
)
|
|
108
|
-
|
|
113
|
+
mcs._check_conflicting_options(
|
|
109
114
|
["column_export_list", "column_export_exclude_list"], attrs
|
|
110
115
|
)
|
|
111
116
|
|
|
112
117
|
return cls
|
|
113
118
|
|
|
114
119
|
@classmethod
|
|
115
|
-
def _check_conflicting_options(
|
|
120
|
+
def _check_conflicting_options(mcs, keys: List[str], attrs: dict) -> None:
|
|
116
121
|
if all(k in attrs for k in keys):
|
|
117
122
|
raise AssertionError(f"Cannot use {' and '.join(keys)} together.")
|
|
118
123
|
|
|
@@ -205,7 +210,12 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
205
210
|
|
|
206
211
|
# Internals
|
|
207
212
|
pk_columns: ClassVar[Tuple[Column]]
|
|
208
|
-
session_maker: ClassVar[
|
|
213
|
+
session_maker: ClassVar[ # type: ignore[no-any-unimported]
|
|
214
|
+
Union[
|
|
215
|
+
sessionmaker,
|
|
216
|
+
"async_sessionmaker",
|
|
217
|
+
]
|
|
218
|
+
]
|
|
209
219
|
is_async: ClassVar[bool] = False
|
|
210
220
|
is_model: ClassVar[bool] = True
|
|
211
221
|
ajax_lookup_url: ClassVar[str] = ""
|
|
@@ -485,6 +495,17 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
485
495
|
Unlimited by default.
|
|
486
496
|
"""
|
|
487
497
|
|
|
498
|
+
use_pretty_export: ClassVar[bool] = False
|
|
499
|
+
"""
|
|
500
|
+
Enable export of CSV files using column labels and column formatters.
|
|
501
|
+
|
|
502
|
+
If set to True, the export will apply column labels and formatting logic
|
|
503
|
+
used in the list template.
|
|
504
|
+
Otherwise, raw database values and field names will be used.
|
|
505
|
+
|
|
506
|
+
You can override cell formatting per column by implementing `custom_export_cell`.
|
|
507
|
+
"""
|
|
508
|
+
|
|
488
509
|
# Form
|
|
489
510
|
form: ClassVar[Optional[Type[Form]]] = None
|
|
490
511
|
"""Form class.
|
|
@@ -797,8 +818,8 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
797
818
|
return self.column_default_sort
|
|
798
819
|
if isinstance(self.column_default_sort, tuple):
|
|
799
820
|
return [self.column_default_sort]
|
|
800
|
-
|
|
801
|
-
|
|
821
|
+
|
|
822
|
+
return [(self.column_default_sort, False)]
|
|
802
823
|
|
|
803
824
|
return [(pk.name, False) for pk in self.pk_columns]
|
|
804
825
|
|
|
@@ -815,10 +836,10 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
815
836
|
|
|
816
837
|
try:
|
|
817
838
|
return int(number)
|
|
818
|
-
except ValueError:
|
|
839
|
+
except ValueError as exc:
|
|
819
840
|
raise HTTPException(
|
|
820
841
|
status_code=400, detail="Invalid page or pageSize parameter"
|
|
821
|
-
)
|
|
842
|
+
) from exc
|
|
822
843
|
|
|
823
844
|
async def count(self, request: Request, stmt: Optional[Select] = None) -> int:
|
|
824
845
|
if stmt is None:
|
|
@@ -836,19 +857,36 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
836
857
|
for relation in self._list_relations:
|
|
837
858
|
stmt = stmt.options(selectinload(relation))
|
|
838
859
|
|
|
839
|
-
for
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
860
|
+
for filter_ in self.get_filters():
|
|
861
|
+
filter_param_name = filter_.parameter_name
|
|
862
|
+
filter_value = request.query_params.get(filter_param_name)
|
|
863
|
+
|
|
864
|
+
if filter_value:
|
|
865
|
+
if hasattr(filter_, "has_operator") and filter_.has_operator:
|
|
866
|
+
# Use operation-based filtering
|
|
867
|
+
operation_filter = typing_cast(OperationColumnFilter, filter_)
|
|
868
|
+
operation_param = request.query_params.get(
|
|
869
|
+
f"{filter_param_name}_op"
|
|
870
|
+
)
|
|
871
|
+
if operation_param:
|
|
872
|
+
stmt = await operation_filter.get_filtered_query(
|
|
873
|
+
stmt, operation_param, filter_value, self.model
|
|
874
|
+
)
|
|
875
|
+
else:
|
|
876
|
+
# Use simple filtering for filters without operators
|
|
877
|
+
simple_filter = typing_cast(SimpleColumnFilter, filter_)
|
|
878
|
+
stmt = await simple_filter.get_filtered_query(
|
|
879
|
+
stmt, filter_value, self.model
|
|
880
|
+
)
|
|
844
881
|
|
|
845
882
|
stmt = self.sort_query(stmt, request)
|
|
846
883
|
|
|
847
884
|
if search:
|
|
848
885
|
stmt = self.search_query(stmt=stmt, term=search)
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
886
|
+
|
|
887
|
+
count = await self.count(
|
|
888
|
+
request, select(func.count()).select_from(stmt.subquery())
|
|
889
|
+
)
|
|
852
890
|
|
|
853
891
|
stmt = stmt.limit(page_size).offset((page - 1) * page_size)
|
|
854
892
|
rows = await self._run_query(stmt)
|
|
@@ -950,14 +988,16 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
950
988
|
"""This function generalizes constructing a list of columns
|
|
951
989
|
for any sequence of inclusions or exclusions.
|
|
952
990
|
"""
|
|
953
|
-
|
|
954
991
|
if include == "__all__":
|
|
955
992
|
return self._prop_names
|
|
956
|
-
|
|
993
|
+
|
|
994
|
+
if include:
|
|
957
995
|
return [self._get_prop_name(item) for item in include]
|
|
958
|
-
|
|
996
|
+
|
|
997
|
+
if exclude:
|
|
959
998
|
exclude = [self._get_prop_name(item) for item in exclude]
|
|
960
999
|
return [prop for prop in self._prop_names if prop not in exclude]
|
|
1000
|
+
|
|
961
1001
|
return defaults
|
|
962
1002
|
|
|
963
1003
|
def get_list_columns(self) -> List[str]:
|
|
@@ -1066,7 +1106,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1066
1106
|
|
|
1067
1107
|
form = await get_model_form(
|
|
1068
1108
|
model=self.model,
|
|
1069
|
-
session_maker=self.session_maker,
|
|
1109
|
+
session_maker=self.session_maker, # type: ignore[arg-type]
|
|
1070
1110
|
only=self._form_prop_names,
|
|
1071
1111
|
column_labels=self._column_labels,
|
|
1072
1112
|
form_args=self.form_args,
|
|
@@ -1210,9 +1250,16 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1210
1250
|
export_type: str = "csv",
|
|
1211
1251
|
) -> StreamingResponse:
|
|
1212
1252
|
if export_type == "csv":
|
|
1213
|
-
|
|
1214
|
-
|
|
1253
|
+
export_method = (
|
|
1254
|
+
PrettyExport.pretty_export_csv(self, data)
|
|
1255
|
+
if self.use_pretty_export
|
|
1256
|
+
else self._export_csv(data)
|
|
1257
|
+
)
|
|
1258
|
+
return await export_method
|
|
1259
|
+
|
|
1260
|
+
if export_type == "json":
|
|
1215
1261
|
return await self._export_json(data)
|
|
1262
|
+
|
|
1216
1263
|
raise NotImplementedError("Only export_type='csv' or 'json' is implemented.")
|
|
1217
1264
|
|
|
1218
1265
|
async def _export_csv(
|
|
@@ -1268,6 +1315,22 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1268
1315
|
headers={"Content-Disposition": f"attachment;filename={filename}"},
|
|
1269
1316
|
)
|
|
1270
1317
|
|
|
1318
|
+
async def custom_export_cell(
|
|
1319
|
+
self,
|
|
1320
|
+
row: Any,
|
|
1321
|
+
name: str,
|
|
1322
|
+
value: Any,
|
|
1323
|
+
) -> Optional[str]:
|
|
1324
|
+
"""
|
|
1325
|
+
Override to provide custom formatting for a specific cell in pretty export.
|
|
1326
|
+
|
|
1327
|
+
Return a string to override the default formatting for the given field,
|
|
1328
|
+
or return None to fall back to `base_export_cell`.
|
|
1329
|
+
|
|
1330
|
+
Only used when `use_pretty_export = True`.
|
|
1331
|
+
"""
|
|
1332
|
+
return None
|
|
1333
|
+
|
|
1271
1334
|
def _refresh_form_rules_cache(self) -> None:
|
|
1272
1335
|
if self.form_rules:
|
|
1273
1336
|
self._form_create_rules = self.form_rules
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator, List
|
|
2
|
+
|
|
3
|
+
from starlette.responses import StreamingResponse
|
|
4
|
+
|
|
5
|
+
from sqladmin.helpers import Writer, secure_filename, stream_to_csv
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .models import ModelView
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PrettyExport:
|
|
12
|
+
@staticmethod
|
|
13
|
+
async def _base_export_cell(
|
|
14
|
+
model_view: "ModelView", name: str, value: Any, formatted_value: Any
|
|
15
|
+
) -> str:
|
|
16
|
+
"""
|
|
17
|
+
Default formatting logic for a cell in pretty export.
|
|
18
|
+
|
|
19
|
+
Used when `custom_export_cell` returns None.
|
|
20
|
+
Applies standard rules for related fields, booleans, etc.
|
|
21
|
+
|
|
22
|
+
Only used when `use_pretty_export = True`.
|
|
23
|
+
"""
|
|
24
|
+
if name in model_view._relation_names:
|
|
25
|
+
if isinstance(value, list):
|
|
26
|
+
cell_value = ",".join(formatted_value)
|
|
27
|
+
else:
|
|
28
|
+
cell_value = formatted_value
|
|
29
|
+
else:
|
|
30
|
+
if isinstance(value, bool):
|
|
31
|
+
cell_value = "TRUE" if value else "FALSE"
|
|
32
|
+
else:
|
|
33
|
+
cell_value = formatted_value
|
|
34
|
+
return cell_value
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
async def _get_export_row_values(
|
|
38
|
+
cls, model_view: "ModelView", row: Any, column_names: List[str]
|
|
39
|
+
) -> List[Any]:
|
|
40
|
+
row_values = []
|
|
41
|
+
for name in column_names:
|
|
42
|
+
value, formatted_value = await model_view.get_list_value(row, name)
|
|
43
|
+
custom_value = await model_view.custom_export_cell(row, name, value)
|
|
44
|
+
if custom_value is None:
|
|
45
|
+
cell_value = await cls._base_export_cell(
|
|
46
|
+
model_view, name, value, formatted_value
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
cell_value = custom_value
|
|
50
|
+
row_values.append(cell_value)
|
|
51
|
+
return row_values
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
async def pretty_export_csv(
|
|
55
|
+
cls, model_view: "ModelView", rows: List[Any]
|
|
56
|
+
) -> StreamingResponse:
|
|
57
|
+
async def generate(writer: Writer) -> AsyncGenerator[Any, None]:
|
|
58
|
+
column_names = model_view.get_export_columns()
|
|
59
|
+
headers = [
|
|
60
|
+
model_view._column_labels.get(name, name) for name in column_names
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
yield writer.writerow(headers)
|
|
64
|
+
|
|
65
|
+
for row in rows:
|
|
66
|
+
vals = await cls._get_export_row_values(model_view, row, column_names)
|
|
67
|
+
yield writer.writerow(vals)
|
|
68
|
+
|
|
69
|
+
filename = secure_filename(model_view.get_export_name(export_type="csv"))
|
|
70
|
+
|
|
71
|
+
return StreamingResponse(
|
|
72
|
+
content=stream_to_csv(generate),
|
|
73
|
+
media_type="text/csv",
|
|
74
|
+
headers={"Content-Disposition": f"attachment;filename={filename}"},
|
|
75
|
+
)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<div class="card-header">
|
|
6
6
|
<h3 class="card-title">
|
|
7
7
|
{% for pk in model_view.pk_columns -%}
|
|
8
|
-
{{ pk.name }}
|
|
8
|
+
{{ pk.name | title }}
|
|
9
9
|
{%- if not loop.last %};{% endif -%}
|
|
10
10
|
{% endfor %}: {{ get_object_identifier(model) }}</h3>
|
|
11
11
|
</div>
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
</table>
|
|
49
49
|
</div>
|
|
50
50
|
<div class="card-footer container">
|
|
51
|
-
<div class="row">
|
|
52
|
-
<div class="col-
|
|
51
|
+
<div class="row row-gap-2">
|
|
52
|
+
<div class="col-auto">
|
|
53
53
|
<a href="{{ url_for('admin:list', identity=model_view.identity) }}" class="btn">
|
|
54
54
|
Go Back
|
|
55
55
|
</a>
|
|
56
56
|
</div>
|
|
57
57
|
{% if model_view.can_delete %}
|
|
58
|
-
<div class="col-
|
|
58
|
+
<div class="col-auto">
|
|
59
59
|
<a href="#" data-name="{{ model_view.name }}" data-pk="{{ get_object_identifier(model) }}"
|
|
60
60
|
data-url="{{ model_view._url_for_delete(request, model) }}" data-bs-toggle="modal"
|
|
61
61
|
data-bs-target="#modal-delete" class="btn btn-danger">
|
|
@@ -64,14 +64,14 @@
|
|
|
64
64
|
</div>
|
|
65
65
|
{% endif %}
|
|
66
66
|
{% if model_view.can_edit %}
|
|
67
|
-
<div class="col-
|
|
67
|
+
<div class="col-auto">
|
|
68
68
|
<a href="{{ model_view._build_url_for('admin:edit', request, model) }}" class="btn btn-primary">
|
|
69
69
|
Edit
|
|
70
70
|
</a>
|
|
71
71
|
</div>
|
|
72
72
|
{% endif %}
|
|
73
73
|
{% for custom_action,label in model_view._custom_actions_in_detail.items() %}
|
|
74
|
-
<div class="col-
|
|
74
|
+
<div class="col-auto">
|
|
75
75
|
{% if custom_action in model_view._custom_actions_confirmation %}
|
|
76
76
|
<a href="#" class="btn btn-secondary" data-bs-toggle="modal"
|
|
77
77
|
data-bs-target="#modal-confirmation-{{ custom_action }}">
|
|
@@ -103,4 +103,4 @@ url=model_view._url_for_action(request, custom_action) + '?pks=' + (get_object_i
|
|
|
103
103
|
{% endif %}
|
|
104
104
|
{% endfor %}
|
|
105
105
|
|
|
106
|
-
{% endblock %}
|
|
106
|
+
{% endblock %}
|
|
@@ -221,23 +221,72 @@
|
|
|
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
|
-
|
|
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
|
+
</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>
|
|
235
252
|
{% endfor %}
|
|
236
|
-
</
|
|
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 fs-3 mb-2">{{ filter.title }}</div>
|
|
269
|
+
<div>
|
|
270
|
+
{% for lookup in filter.lookups(request, model_view.model, model_view._run_arbitrary_query) %}
|
|
271
|
+
{% if request.query_params.get(filter.parameter_name) == lookup[0] %}
|
|
272
|
+
<div class="d-flex align-items-center justify-content-between bg-secondary-lt px-2 py-1 rounded">
|
|
273
|
+
<span class="text-truncate fw-bold text-dark">
|
|
274
|
+
{{ lookup[1] }}
|
|
275
|
+
</span>
|
|
276
|
+
<a href="{{ request.url.remove_query_params(filter.parameter_name) }}" class="text-decoration-none ms-2" title="Clear filter">
|
|
277
|
+
<i class="fa-solid fa-times"></i>
|
|
278
|
+
</a>
|
|
237
279
|
</div>
|
|
280
|
+
{% else %}
|
|
281
|
+
<a href="{{ request.url.include_query_params(**{filter.parameter_name: lookup[0]}) }}" class="d-block text-decoration-none text-truncate px-2 py-1">
|
|
282
|
+
{{ lookup[1] }}
|
|
283
|
+
</a>
|
|
284
|
+
{% endif %}
|
|
285
|
+
{% endfor %}
|
|
238
286
|
</div>
|
|
239
|
-
{% endfor %}
|
|
240
287
|
</div>
|
|
288
|
+
{% endif %}
|
|
289
|
+
{% endfor %}
|
|
241
290
|
</div>
|
|
242
291
|
</div>
|
|
243
292
|
</div>
|
sqladmin/templating.py
CHANGED
|
@@ -45,7 +45,7 @@ class Jinja2Templates:
|
|
|
45
45
|
def __init__(self, directory: str) -> None:
|
|
46
46
|
@jinja2.pass_context
|
|
47
47
|
def url_for(context: dict, __name: str, **path_params: Any) -> URL:
|
|
48
|
-
request = context["request"]
|
|
48
|
+
request: Request = context["request"]
|
|
49
49
|
return request.url_for(__name, **path_params)
|
|
50
50
|
|
|
51
51
|
loader = jinja2.FileSystemLoader(directory)
|
sqladmin/widgets.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
# mypy: disable-error-code="override"
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
|
-
from typing import Any
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
3
5
|
|
|
4
6
|
from markupsafe import Markup
|
|
5
|
-
from wtforms import Field, widgets
|
|
7
|
+
from wtforms import Field, SelectFieldBase, widgets
|
|
6
8
|
from wtforms.widgets import html_params
|
|
7
9
|
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sqladmin.fields import AjaxSelectField
|
|
12
|
+
|
|
8
13
|
__all__ = [
|
|
9
14
|
"AjaxSelect2Widget",
|
|
10
15
|
"DatePickerWidget",
|
|
@@ -38,7 +43,7 @@ class AjaxSelect2Widget(widgets.Select):
|
|
|
38
43
|
self.multiple = multiple
|
|
39
44
|
self.lookup_url = ""
|
|
40
45
|
|
|
41
|
-
def __call__(self, field:
|
|
46
|
+
def __call__(self, field: "AjaxSelectField", **kwargs: Any) -> Markup:
|
|
42
47
|
kwargs.setdefault("data-role", "select2-ajax")
|
|
43
48
|
kwargs.setdefault("data-url", field.loader.model_admin.ajax_lookup_url)
|
|
44
49
|
|
|
@@ -61,11 +66,11 @@ class AjaxSelect2Widget(widgets.Select):
|
|
|
61
66
|
if data:
|
|
62
67
|
kwargs["data-json"] = json.dumps([data])
|
|
63
68
|
|
|
64
|
-
return Markup(f"<select {html_params(name=field.name, **kwargs)}></select>")
|
|
69
|
+
return Markup(f"<select {html_params(name=field.name, **kwargs)}></select>") # nosec: markupsafe_markup_xss
|
|
65
70
|
|
|
66
71
|
|
|
67
72
|
class Select2TagsWidget(widgets.Select):
|
|
68
|
-
def __call__(self, field:
|
|
73
|
+
def __call__(self, field: SelectFieldBase, **kwargs: Any) -> str:
|
|
69
74
|
kwargs.setdefault("data-role", "select2-tags")
|
|
70
75
|
kwargs.setdefault("data-json", json.dumps(field.data))
|
|
71
76
|
kwargs.setdefault("multiple", "multiple")
|
|
@@ -81,20 +86,21 @@ class FileInputWidget(widgets.FileInput):
|
|
|
81
86
|
if not field.flags.required:
|
|
82
87
|
checkbox_id = f"{field.id}_checkbox"
|
|
83
88
|
checkbox_label = Markup(
|
|
84
|
-
|
|
85
|
-
)
|
|
89
|
+
'<label class="form-check-label" for="{}">Clear</label>'
|
|
90
|
+
).format(checkbox_id)
|
|
91
|
+
|
|
86
92
|
checkbox_input = Markup(
|
|
87
|
-
|
|
88
|
-
)
|
|
89
|
-
checkbox = Markup(
|
|
90
|
-
|
|
93
|
+
'<input class="form-check-input" type="checkbox" id="{}" name="{}">' # noqa: E501
|
|
94
|
+
).format(checkbox_id, checkbox_id)
|
|
95
|
+
checkbox = Markup('<div class="form-check">{}{}</div>').format(
|
|
96
|
+
checkbox_input, checkbox_label
|
|
91
97
|
)
|
|
92
98
|
else:
|
|
93
99
|
checkbox = Markup()
|
|
94
100
|
|
|
95
101
|
if field.data:
|
|
96
|
-
current_value = Markup(
|
|
102
|
+
current_value = Markup("<p>Currently: {}</p>").format(field.data)
|
|
97
103
|
field.flags.required = False
|
|
98
104
|
return current_value + checkbox + super().__call__(field, **kwargs)
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
|
|
106
|
+
return super().__call__(field, **kwargs)
|
|
@@ -1,34 +1,35 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqladmin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.23.0
|
|
4
4
|
Summary: SQLAlchemy admin for FastAPI and Starlette
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Project-URL: Source, https://github.com/aminalaee/sqladmin
|
|
5
|
+
Keywords: sqlalchemy,fastapi,starlette,admin
|
|
6
|
+
Author: Amin Alaee
|
|
8
7
|
Author-email: Amin Alaee <me@aminalaee.dev>
|
|
9
8
|
License-Expression: BSD-3-Clause
|
|
10
|
-
License-File: LICENSE.md
|
|
11
|
-
Keywords: admin,fastapi,sqlalchemy,starlette
|
|
12
9
|
Classifier: Development Status :: 4 - Beta
|
|
13
|
-
Classifier: Environment :: Web Environment
|
|
14
|
-
Classifier: Intended Audience :: Developers
|
|
15
|
-
Classifier: License :: OSI Approved :: BSD License
|
|
16
|
-
Classifier: Operating System :: OS Independent
|
|
17
10
|
Classifier: Programming Language :: Python
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
19
11
|
Classifier: Programming Language :: Python :: 3.9
|
|
20
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Environment :: Web Environment
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
23
20
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
-
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Requires-Dist: starlette
|
|
23
|
+
Requires-Dist: wtforms>=3.1,<3.2
|
|
25
24
|
Requires-Dist: jinja2
|
|
26
25
|
Requires-Dist: python-multipart
|
|
27
|
-
Requires-Dist: sqlalchemy>=
|
|
28
|
-
Requires-Dist:
|
|
29
|
-
Requires-
|
|
26
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
27
|
+
Requires-Dist: itsdangerous ; extra == 'full'
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Project-URL: Documentation, https://aminalaee.github.io/sqladmin/
|
|
30
|
+
Project-URL: Issues, https://github.com/aminalaee/sqladmin/issues
|
|
31
|
+
Project-URL: Source, https://github.com/aminalaee/sqladmin
|
|
30
32
|
Provides-Extra: full
|
|
31
|
-
Requires-Dist: itsdangerous; extra == 'full'
|
|
32
33
|
Description-Content-Type: text/markdown
|
|
33
34
|
|
|
34
35
|
<p align="center">
|