sqladmin 0.8.0__py3-none-any.whl → 0.10.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 +1 -1
- sqladmin/_queries.py +18 -9
- sqladmin/_types.py +1 -1
- sqladmin/_validators.py +24 -0
- sqladmin/ajax.py +1 -1
- sqladmin/application.py +46 -7
- sqladmin/authentication.py +10 -6
- sqladmin/fields.py +52 -5
- sqladmin/forms.py +61 -16
- sqladmin/helpers.py +99 -16
- sqladmin/models.py +119 -118
- sqladmin/statics/js/main.js +14 -0
- sqladmin/templates/create.html +1 -1
- sqladmin/templates/details.html +6 -6
- sqladmin/templates/edit.html +1 -1
- sqladmin/templates/list.html +10 -10
- sqladmin/widgets.py +30 -1
- {sqladmin-0.8.0.dist-info → sqladmin-0.10.0.dist-info}/METADATA +9 -3
- {sqladmin-0.8.0.dist-info → sqladmin-0.10.0.dist-info}/RECORD +21 -21
- {sqladmin-0.8.0.dist-info → sqladmin-0.10.0.dist-info}/WHEEL +1 -1
- {sqladmin-0.8.0.dist-info → sqladmin-0.10.0.dist-info}/licenses/LICENSE.md +0 -0
sqladmin/helpers.py
CHANGED
|
@@ -3,12 +3,28 @@ import os
|
|
|
3
3
|
import re
|
|
4
4
|
import unicodedata
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
-
from
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
Any,
|
|
10
|
+
Callable,
|
|
11
|
+
Dict,
|
|
12
|
+
Generator,
|
|
13
|
+
List,
|
|
14
|
+
Optional,
|
|
15
|
+
TypeVar,
|
|
16
|
+
Union,
|
|
17
|
+
)
|
|
7
18
|
|
|
8
19
|
from sqlalchemy import Column, inspect
|
|
9
20
|
from sqlalchemy.orm import RelationshipProperty
|
|
21
|
+
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
22
|
+
|
|
23
|
+
from sqladmin._types import MODEL_PROPERTY
|
|
24
|
+
from sqladmin.exceptions import InvalidColumnError
|
|
10
25
|
|
|
11
|
-
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from sqladmin.models import ModelView
|
|
12
28
|
|
|
13
29
|
T = TypeVar("T")
|
|
14
30
|
|
|
@@ -28,6 +44,44 @@ _windows_device_files = (
|
|
|
28
44
|
"NUL",
|
|
29
45
|
)
|
|
30
46
|
|
|
47
|
+
standard_duration_re = re.compile(
|
|
48
|
+
r"^"
|
|
49
|
+
r"(?:(?P<days>-?\d+) (days?, )?)?"
|
|
50
|
+
r"(?P<sign>-?)"
|
|
51
|
+
r"((?:(?P<hours>\d+):)(?=\d+:\d+))?"
|
|
52
|
+
r"(?:(?P<minutes>\d+):)?"
|
|
53
|
+
r"(?P<seconds>\d+)"
|
|
54
|
+
r"(?:[\.,](?P<microseconds>\d{1,6})\d{0,6})?"
|
|
55
|
+
r"$"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Support the sections of ISO 8601 date representation that are accepted by timedelta
|
|
59
|
+
iso8601_duration_re = re.compile(
|
|
60
|
+
r"^(?P<sign>[-+]?)"
|
|
61
|
+
r"P"
|
|
62
|
+
r"(?:(?P<days>\d+([\.,]\d+)?)D)?"
|
|
63
|
+
r"(?:T"
|
|
64
|
+
r"(?:(?P<hours>\d+([\.,]\d+)?)H)?"
|
|
65
|
+
r"(?:(?P<minutes>\d+([\.,]\d+)?)M)?"
|
|
66
|
+
r"(?:(?P<seconds>\d+([\.,]\d+)?)S)?"
|
|
67
|
+
r")?"
|
|
68
|
+
r"$"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Support PostgreSQL's day-time interval format, e.g. "3 days 04:05:06". The
|
|
72
|
+
# year-month and mixed intervals cannot be converted to a timedelta and thus
|
|
73
|
+
# aren't accepted.
|
|
74
|
+
postgres_interval_re = re.compile(
|
|
75
|
+
r"^"
|
|
76
|
+
r"(?:(?P<days>-?\d+) (days? ?))?"
|
|
77
|
+
r"(?:(?P<sign>[-+])?"
|
|
78
|
+
r"(?P<hours>\d+):"
|
|
79
|
+
r"(?P<minutes>\d\d):"
|
|
80
|
+
r"(?P<seconds>\d\d)"
|
|
81
|
+
r"(?:\.(?P<microseconds>\d{1,6}))?"
|
|
82
|
+
r")?$"
|
|
83
|
+
)
|
|
84
|
+
|
|
31
85
|
|
|
32
86
|
def prettify_class_name(name: str) -> str:
|
|
33
87
|
return re.sub(r"(?<=.)([A-Z])", r" \1", name)
|
|
@@ -119,28 +173,57 @@ def get_primary_key(model: type) -> Column:
|
|
|
119
173
|
return pks[0]
|
|
120
174
|
|
|
121
175
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def get_attributes(model: Any) -> List[MODEL_ATTR_TYPE]:
|
|
127
|
-
return list(inspect(model).attrs)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def get_direction(attr: MODEL_ATTR_TYPE) -> str:
|
|
131
|
-
assert isinstance(attr, RelationshipProperty)
|
|
132
|
-
name = attr.direction.name
|
|
133
|
-
if name == "ONETOMANY" and not attr.uselist:
|
|
176
|
+
def get_direction(prop: MODEL_PROPERTY) -> str:
|
|
177
|
+
assert isinstance(prop, RelationshipProperty)
|
|
178
|
+
name = prop.direction.name
|
|
179
|
+
if name == "ONETOMANY" and not prop.uselist:
|
|
134
180
|
return "ONETOONE"
|
|
135
181
|
return name
|
|
136
182
|
|
|
137
183
|
|
|
138
184
|
def get_column_python_type(column: Column) -> type:
|
|
139
185
|
try:
|
|
186
|
+
if hasattr(column.type, "impl"):
|
|
187
|
+
return column.type.impl.python_type
|
|
140
188
|
return column.type.python_type
|
|
141
189
|
except NotImplementedError:
|
|
142
190
|
return str
|
|
143
191
|
|
|
144
192
|
|
|
145
|
-
def is_relationship(
|
|
146
|
-
return isinstance(
|
|
193
|
+
def is_relationship(prop: MODEL_PROPERTY) -> bool:
|
|
194
|
+
return isinstance(prop, RelationshipProperty)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def parse_interval(value: str) -> Optional[timedelta]:
|
|
198
|
+
match = (
|
|
199
|
+
standard_duration_re.match(value)
|
|
200
|
+
or iso8601_duration_re.match(value)
|
|
201
|
+
or postgres_interval_re.match(value)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if not match:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
kw: Dict[str, Any] = match.groupdict()
|
|
208
|
+
sign = -1 if kw.pop("sign", "+") == "-" else 1
|
|
209
|
+
if kw.get("microseconds"):
|
|
210
|
+
kw["microseconds"] = kw["microseconds"].ljust(6, "0")
|
|
211
|
+
kw = {k: float(v.replace(",", ".")) for k, v in kw.items() if v is not None}
|
|
212
|
+
days = timedelta(kw.pop("days", 0.0) or 0.0)
|
|
213
|
+
if match.re == iso8601_duration_re:
|
|
214
|
+
days *= sign
|
|
215
|
+
return days + sign * timedelta(**kw)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def map_attr_to_prop(
|
|
219
|
+
attr: Union[str, InstrumentedAttribute], model_admin: "ModelView"
|
|
220
|
+
) -> MODEL_PROPERTY:
|
|
221
|
+
if isinstance(attr, InstrumentedAttribute):
|
|
222
|
+
attr = attr.prop.key
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
return model_admin._props[attr]
|
|
226
|
+
except KeyError:
|
|
227
|
+
raise InvalidColumnError(
|
|
228
|
+
f"Model '{model_admin.model.__name__}' has no attribute '{attr}'."
|
|
229
|
+
)
|
sqladmin/models.py
CHANGED
|
@@ -28,23 +28,23 @@ from sqlalchemy.orm import (
|
|
|
28
28
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
29
29
|
from sqlalchemy.sql.elements import ClauseElement
|
|
30
30
|
from sqlalchemy.sql.expression import Select, select
|
|
31
|
+
from starlette.datastructures import URL
|
|
31
32
|
from starlette.requests import Request
|
|
32
33
|
from starlette.responses import StreamingResponse
|
|
33
34
|
from starlette.templating import Jinja2Templates
|
|
34
35
|
from wtforms import Field, Form
|
|
35
36
|
|
|
36
37
|
from sqladmin._queries import Query
|
|
37
|
-
from sqladmin._types import ENGINE_TYPE,
|
|
38
|
+
from sqladmin._types import ENGINE_TYPE, MODEL_PROPERTY
|
|
38
39
|
from sqladmin.ajax import create_ajax_loader
|
|
39
|
-
from sqladmin.exceptions import
|
|
40
|
+
from sqladmin.exceptions import InvalidModelError
|
|
40
41
|
from sqladmin.formatters import BASE_FORMATTERS
|
|
41
|
-
from sqladmin.forms import get_model_form
|
|
42
|
+
from sqladmin.forms import ModelConverter, ModelConverterBase, get_model_form
|
|
42
43
|
from sqladmin.helpers import (
|
|
43
44
|
Writer,
|
|
44
|
-
get_attributes,
|
|
45
45
|
get_column_python_type,
|
|
46
46
|
get_primary_key,
|
|
47
|
-
|
|
47
|
+
map_attr_to_prop,
|
|
48
48
|
prettify_class_name,
|
|
49
49
|
secure_filename,
|
|
50
50
|
slugify_class_name,
|
|
@@ -399,15 +399,20 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
399
399
|
save_as: ClassVar[bool] = False
|
|
400
400
|
"""Set `save_as` to enable a “save as new” feature on admin change forms.
|
|
401
401
|
|
|
402
|
-
Normally, objects have three save options:
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
Normally, objects have three save options:
|
|
403
|
+
``Save`, `Save and continue editing` and `Save and add another`.
|
|
404
|
+
|
|
405
|
+
If save_as is True, `Save and add another` will be replaced
|
|
406
|
+
by a `Save as new` button
|
|
407
|
+
that creates a new object (with a new ID)
|
|
408
|
+
rather than updating the existing object.
|
|
405
409
|
|
|
406
410
|
By default, `save_as` is set to `False`.
|
|
407
411
|
"""
|
|
408
412
|
|
|
409
413
|
save_as_continue: ClassVar[bool] = True
|
|
410
|
-
"""When `save_as=True`, the default redirect after saving the new object
|
|
414
|
+
"""When `save_as=True`, the default redirect after saving the new object
|
|
415
|
+
is to the edit view for that object.
|
|
411
416
|
If you set `save_as_continue=False`, the redirect will be to the list view.
|
|
412
417
|
|
|
413
418
|
By default, `save_as_continue` is set to `True`.
|
|
@@ -583,6 +588,20 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
583
588
|
```
|
|
584
589
|
"""
|
|
585
590
|
|
|
591
|
+
form_converter: ClassVar[Type[ModelConverterBase]] = ModelConverter
|
|
592
|
+
"""Custom form converter class.
|
|
593
|
+
Useful if you want to add custom form conversion in addition to the defaults.
|
|
594
|
+
|
|
595
|
+
???+ example
|
|
596
|
+
```python
|
|
597
|
+
class PhoneNumberConverter(ModelConverter):
|
|
598
|
+
pass
|
|
599
|
+
|
|
600
|
+
class UserAdmin(ModelAdmin, model=User):
|
|
601
|
+
form_converter = PhoneNumberConverter
|
|
602
|
+
```
|
|
603
|
+
"""
|
|
604
|
+
|
|
586
605
|
# General options
|
|
587
606
|
column_labels: ClassVar[Dict[Union[str, InstrumentedAttribute], str]] = {}
|
|
588
607
|
"""A mapping of column labels, used to map column names to new names.
|
|
@@ -643,52 +662,58 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
643
662
|
|
|
644
663
|
def __init__(self) -> None:
|
|
645
664
|
self._mapper = inspect(self.model)
|
|
646
|
-
self.
|
|
647
|
-
self.
|
|
665
|
+
self._relation_props = list(self._mapper.relationships)
|
|
666
|
+
self._relation_attrs = [
|
|
667
|
+
getattr(self.model, prop.key) for prop in self._relation_props
|
|
668
|
+
]
|
|
669
|
+
self._column_props = list(self._mapper.columns)
|
|
670
|
+
self._props = self._mapper.attrs
|
|
648
671
|
|
|
649
672
|
self._column_labels = self.get_column_labels()
|
|
650
673
|
self._column_labels_value_by_key = {
|
|
651
674
|
v: k for k, v in self._column_labels.items()
|
|
652
675
|
}
|
|
653
676
|
|
|
654
|
-
self.
|
|
677
|
+
self._list_props = self.get_list_columns()
|
|
655
678
|
self._list_columns = [
|
|
656
|
-
(name,
|
|
657
|
-
for (name,
|
|
658
|
-
if isinstance(
|
|
679
|
+
(name, prop)
|
|
680
|
+
for (name, prop) in self._list_props
|
|
681
|
+
if isinstance(prop, ColumnProperty)
|
|
659
682
|
]
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
(
|
|
664
|
-
|
|
665
|
-
|
|
683
|
+
self._list_relations = [
|
|
684
|
+
prop
|
|
685
|
+
for (_, prop) in self._list_props
|
|
686
|
+
if isinstance(prop, RelationshipProperty)
|
|
687
|
+
]
|
|
688
|
+
self._list_relation_attrs = [
|
|
689
|
+
getattr(self.model, prop.key) for prop in self._list_relations
|
|
666
690
|
]
|
|
667
691
|
|
|
692
|
+
self._details_props = self.get_details_columns()
|
|
693
|
+
|
|
668
694
|
column_formatters = getattr(self, "column_formatters", {})
|
|
669
695
|
self._list_formatters = {
|
|
670
|
-
|
|
696
|
+
map_attr_to_prop(attr, self): formatter
|
|
671
697
|
for (attr, formatter) in column_formatters.items()
|
|
672
698
|
}
|
|
673
699
|
|
|
674
700
|
column_formatters_detail = getattr(self, "column_formatters_detail", {})
|
|
675
701
|
self._detail_formatters = {
|
|
676
|
-
|
|
702
|
+
map_attr_to_prop(attr, self): formatter
|
|
677
703
|
for (attr, formatter) in column_formatters_detail.items()
|
|
678
704
|
}
|
|
679
705
|
|
|
680
|
-
self.
|
|
706
|
+
self._form_props = self.get_form_columns()
|
|
681
707
|
|
|
682
|
-
self.
|
|
708
|
+
self._export_props = self.get_export_columns()
|
|
683
709
|
|
|
684
710
|
self._search_fields = [
|
|
685
|
-
getattr(self.model,
|
|
711
|
+
getattr(self.model, attr) if isinstance(attr, str) else attr
|
|
686
712
|
for attr in self.column_searchable_list
|
|
687
713
|
]
|
|
688
714
|
|
|
689
715
|
self._sort_fields = [
|
|
690
|
-
|
|
691
|
-
for attr in self.column_sortable_list
|
|
716
|
+
map_attr_to_prop(attr, self).key for attr in self.column_sortable_list
|
|
692
717
|
]
|
|
693
718
|
|
|
694
719
|
self._form_ajax_refs = {}
|
|
@@ -710,16 +735,16 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
710
735
|
else:
|
|
711
736
|
return await anyio.to_thread.run_sync(self._run_query_sync, stmt)
|
|
712
737
|
|
|
713
|
-
def _url_for_details(self, request: Request, obj: Any) -> str:
|
|
714
|
-
pk = getattr(obj,
|
|
738
|
+
def _url_for_details(self, request: Request, obj: Any) -> Union[str, URL]:
|
|
739
|
+
pk = getattr(obj, self.pk_column.name)
|
|
715
740
|
return request.url_for(
|
|
716
741
|
"admin:details",
|
|
717
742
|
identity=slugify_class_name(obj.__class__.__name__),
|
|
718
743
|
pk=pk,
|
|
719
744
|
)
|
|
720
745
|
|
|
721
|
-
def _url_for_edit(self, request: Request, obj: Any) -> str:
|
|
722
|
-
pk = getattr(obj,
|
|
746
|
+
def _url_for_edit(self, request: Request, obj: Any) -> Union[str, URL]:
|
|
747
|
+
pk = getattr(obj, self.pk_column.name)
|
|
723
748
|
return request.url_for(
|
|
724
749
|
"admin:edit",
|
|
725
750
|
identity=slugify_class_name(obj.__class__.__name__),
|
|
@@ -727,21 +752,21 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
727
752
|
)
|
|
728
753
|
|
|
729
754
|
def _url_for_delete(self, request: Request, obj: Any) -> str:
|
|
730
|
-
pk = getattr(obj,
|
|
755
|
+
pk = getattr(obj, self.pk_column.name)
|
|
731
756
|
query_params = urlencode({"pks": pk})
|
|
732
757
|
url = request.url_for(
|
|
733
758
|
"admin:delete", identity=slugify_class_name(obj.__class__.__name__)
|
|
734
759
|
)
|
|
735
|
-
return url + "?" + query_params
|
|
760
|
+
return str(url) + "?" + query_params
|
|
736
761
|
|
|
737
|
-
def
|
|
738
|
-
self, request: Request, obj: Any,
|
|
739
|
-
) -> str:
|
|
740
|
-
target = getattr(obj,
|
|
762
|
+
def _url_for_details_with_prop(
|
|
763
|
+
self, request: Request, obj: Any, prop: RelationshipProperty
|
|
764
|
+
) -> Union[str, URL]:
|
|
765
|
+
target = getattr(obj, prop.key)
|
|
741
766
|
if target is None:
|
|
742
767
|
return ""
|
|
743
768
|
|
|
744
|
-
pk = getattr(target,
|
|
769
|
+
pk = getattr(target, prop.mapper.primary_key[0].name)
|
|
745
770
|
return request.url_for(
|
|
746
771
|
"admin:details",
|
|
747
772
|
identity=slugify_class_name(target.__class__.__name__),
|
|
@@ -783,8 +808,8 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
783
808
|
count = await self.count()
|
|
784
809
|
stmt = self.list_query.limit(page_size).offset((page - 1) * page_size)
|
|
785
810
|
|
|
786
|
-
for relation in self.
|
|
787
|
-
stmt = stmt.options(joinedload(relation
|
|
811
|
+
for relation in self._list_relation_attrs:
|
|
812
|
+
stmt = stmt.options(joinedload(relation))
|
|
788
813
|
|
|
789
814
|
if sort_by:
|
|
790
815
|
sort_fields = [(sort_by, sort == "desc")]
|
|
@@ -815,8 +840,8 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
815
840
|
limit = None if limit == 0 else limit
|
|
816
841
|
stmt = self.list_query.limit(limit=limit)
|
|
817
842
|
|
|
818
|
-
for relation in self.
|
|
819
|
-
stmt = stmt.options(joinedload(relation
|
|
843
|
+
for relation in self._list_relation_attrs:
|
|
844
|
+
stmt = stmt.options(joinedload(relation))
|
|
820
845
|
|
|
821
846
|
rows = await self._run_query(stmt)
|
|
822
847
|
return rows
|
|
@@ -825,87 +850,68 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
825
850
|
pk_value = get_column_python_type(self.pk_column)(value)
|
|
826
851
|
stmt = select(self.model).where(self.pk_column == pk_value)
|
|
827
852
|
|
|
828
|
-
for relation in self.
|
|
829
|
-
stmt = stmt.options(joinedload(relation
|
|
853
|
+
for relation in self._relation_attrs:
|
|
854
|
+
stmt = stmt.options(joinedload(relation))
|
|
830
855
|
|
|
831
856
|
rows = await self._run_query(stmt)
|
|
832
857
|
if rows:
|
|
833
858
|
return rows[0]
|
|
834
859
|
return None
|
|
835
860
|
|
|
836
|
-
def
|
|
837
|
-
self, obj: type,
|
|
861
|
+
def get_prop_value(
|
|
862
|
+
self, obj: type, prop: Union[Column, ColumnProperty, RelationshipProperty]
|
|
838
863
|
) -> Any:
|
|
839
864
|
result = None
|
|
840
865
|
|
|
841
|
-
if isinstance(
|
|
842
|
-
result = getattr(obj,
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
result = getattr(obj, attr.key)
|
|
866
|
+
if isinstance(prop, Column):
|
|
867
|
+
result = getattr(obj, prop.name)
|
|
868
|
+
else:
|
|
869
|
+
result = getattr(obj, prop.key)
|
|
846
870
|
result = result.value if isinstance(result, Enum) else result
|
|
847
871
|
|
|
848
872
|
return result
|
|
849
873
|
|
|
850
|
-
def get_list_value(self, obj: type,
|
|
874
|
+
def get_list_value(self, obj: type, prop: MODEL_PROPERTY) -> Tuple[Any, Any]:
|
|
851
875
|
"""Get tuple of (value, formatted_value) for the list view."""
|
|
852
|
-
value = self.
|
|
876
|
+
value = self.get_prop_value(obj, prop)
|
|
853
877
|
formatted_value = self._default_formatter(value)
|
|
854
878
|
|
|
855
|
-
formatter = self._list_formatters.get(
|
|
879
|
+
formatter = self._list_formatters.get(prop)
|
|
856
880
|
if formatter:
|
|
857
|
-
formatted_value = formatter(obj,
|
|
881
|
+
formatted_value = formatter(obj, prop)
|
|
858
882
|
return value, formatted_value
|
|
859
883
|
|
|
860
|
-
def get_detail_value(self, obj: type,
|
|
884
|
+
def get_detail_value(self, obj: type, prop: MODEL_PROPERTY) -> Tuple[Any, Any]:
|
|
861
885
|
"""Get tuple of (value, formatted_value) for the detail view."""
|
|
862
|
-
value = self.
|
|
886
|
+
value = self.get_prop_value(obj, prop)
|
|
863
887
|
formatted_value = self._default_formatter(value)
|
|
864
888
|
|
|
865
|
-
formatter = self._detail_formatters.get(
|
|
889
|
+
formatter = self._detail_formatters.get(prop)
|
|
866
890
|
if formatter:
|
|
867
|
-
formatted_value = formatter(obj,
|
|
891
|
+
formatted_value = formatter(obj, prop)
|
|
868
892
|
return value, formatted_value
|
|
869
893
|
|
|
870
|
-
def get_model_attr(
|
|
871
|
-
self, attr: Union[str, InstrumentedAttribute]
|
|
872
|
-
) -> MODEL_ATTR_TYPE:
|
|
873
|
-
assert isinstance(attr, (str, InstrumentedAttribute))
|
|
874
|
-
attrs = inspect(self.model).attrs
|
|
875
|
-
|
|
876
|
-
if isinstance(attr, str):
|
|
877
|
-
key = attr
|
|
878
|
-
else:
|
|
879
|
-
key = attr.prop.key
|
|
880
|
-
|
|
881
|
-
if key in attrs:
|
|
882
|
-
return attrs[key]
|
|
883
|
-
|
|
884
|
-
raise InvalidColumnError(
|
|
885
|
-
f"Model '{self.model.__name__}' has no attribute '{key}'."
|
|
886
|
-
)
|
|
887
|
-
|
|
888
894
|
def _build_column_list(
|
|
889
895
|
self,
|
|
890
|
-
|
|
896
|
+
defaults: List[MODEL_PROPERTY],
|
|
891
897
|
include: Optional[Sequence[Union[str, InstrumentedAttribute]]] = None,
|
|
892
898
|
exclude: Optional[Sequence[Union[str, InstrumentedAttribute]]] = None,
|
|
893
|
-
) -> List[Tuple[str,
|
|
899
|
+
) -> List[Tuple[str, MODEL_PROPERTY]]:
|
|
894
900
|
"""This function generalizes constructing a list of columns
|
|
895
901
|
for any sequence of inclusions or exclusions.
|
|
896
902
|
"""
|
|
897
903
|
if include:
|
|
898
|
-
|
|
904
|
+
props = [map_attr_to_prop(prop, self) for prop in include]
|
|
899
905
|
elif exclude:
|
|
900
|
-
|
|
901
|
-
|
|
906
|
+
exclude_props = {map_attr_to_prop(prop, self) for prop in exclude}
|
|
907
|
+
props = [prop for prop in self._props if prop not in exclude_props]
|
|
902
908
|
else:
|
|
903
|
-
|
|
909
|
+
props = defaults
|
|
904
910
|
|
|
905
|
-
return [(self._column_labels.get(
|
|
911
|
+
return [(self._column_labels.get(prop, prop.key), prop) for prop in props]
|
|
906
912
|
|
|
907
|
-
def get_list_columns(self) -> List[Tuple[str,
|
|
908
|
-
"""Get list of
|
|
913
|
+
def get_list_columns(self) -> List[Tuple[str, MODEL_PROPERTY]]:
|
|
914
|
+
"""Get list of properties to display in List page."""
|
|
909
915
|
|
|
910
916
|
column_list = getattr(self, "column_list", None)
|
|
911
917
|
column_exclude_list = getattr(self, "column_exclude_list", None)
|
|
@@ -913,11 +919,11 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
913
919
|
return self._build_column_list(
|
|
914
920
|
include=column_list,
|
|
915
921
|
exclude=column_exclude_list,
|
|
916
|
-
|
|
922
|
+
defaults=[self._props[self.pk_column.key]],
|
|
917
923
|
)
|
|
918
924
|
|
|
919
|
-
def get_details_columns(self) -> List[Tuple[str,
|
|
920
|
-
"""Get list of
|
|
925
|
+
def get_details_columns(self) -> List[Tuple[str, MODEL_PROPERTY]]:
|
|
926
|
+
"""Get list of properties to display in Detail page."""
|
|
921
927
|
|
|
922
928
|
column_details_list = getattr(self, "column_details_list", None)
|
|
923
929
|
column_details_exclude_list = getattr(self, "column_details_exclude_list", None)
|
|
@@ -925,31 +931,23 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
925
931
|
return self._build_column_list(
|
|
926
932
|
include=column_details_list,
|
|
927
933
|
exclude=column_details_exclude_list,
|
|
928
|
-
|
|
934
|
+
defaults=self._props,
|
|
929
935
|
)
|
|
930
936
|
|
|
931
|
-
def get_form_columns(self) -> List[Tuple[str,
|
|
932
|
-
"""Get list of
|
|
937
|
+
def get_form_columns(self) -> List[Tuple[str, MODEL_PROPERTY]]:
|
|
938
|
+
"""Get list of properties to display in the form."""
|
|
933
939
|
|
|
934
940
|
form_columns = getattr(self, "form_columns", None)
|
|
935
941
|
form_excluded_columns = getattr(self, "form_excluded_columns", None)
|
|
936
942
|
|
|
937
|
-
columns = list(self._mapper.columns)
|
|
938
|
-
default = []
|
|
939
|
-
for attr in self._attrs:
|
|
940
|
-
if attr in self._relations:
|
|
941
|
-
default.append(attr)
|
|
942
|
-
if attr in columns:
|
|
943
|
-
default.append(attr)
|
|
944
|
-
|
|
945
943
|
return self._build_column_list(
|
|
946
944
|
include=form_columns,
|
|
947
945
|
exclude=form_excluded_columns,
|
|
948
|
-
|
|
946
|
+
defaults=self._props,
|
|
949
947
|
)
|
|
950
948
|
|
|
951
|
-
def get_export_columns(self) -> List[Tuple[str,
|
|
952
|
-
"""Get list of
|
|
949
|
+
def get_export_columns(self) -> List[Tuple[str, MODEL_PROPERTY]]:
|
|
950
|
+
"""Get list of properties to export."""
|
|
953
951
|
|
|
954
952
|
columns = getattr(self, "column_export_list", None)
|
|
955
953
|
excluded_columns = getattr(self, "column_export_exclude_list", None)
|
|
@@ -957,7 +955,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
957
955
|
return self._build_column_list(
|
|
958
956
|
include=columns,
|
|
959
957
|
exclude=excluded_columns,
|
|
960
|
-
|
|
958
|
+
defaults=[item[1] for item in self._list_props],
|
|
961
959
|
)
|
|
962
960
|
|
|
963
961
|
async def on_model_change(self, data: dict, model: Any, is_created: bool) -> None:
|
|
@@ -968,15 +966,16 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
968
966
|
async def after_model_change(
|
|
969
967
|
self, data: dict, model: Any, is_created: bool
|
|
970
968
|
) -> None:
|
|
971
|
-
"""Perform some actions after a model was created
|
|
969
|
+
"""Perform some actions after a model was created
|
|
970
|
+
or updated and committed to the database.
|
|
972
971
|
By default does nothing.
|
|
973
972
|
"""
|
|
974
973
|
|
|
975
974
|
def get_column_labels(
|
|
976
975
|
self,
|
|
977
|
-
) -> Dict[
|
|
976
|
+
) -> Dict[MODEL_PROPERTY, str]:
|
|
978
977
|
return {
|
|
979
|
-
|
|
978
|
+
map_attr_to_prop(column_label, self): value
|
|
980
979
|
for column_label, value in self.column_labels.items()
|
|
981
980
|
}
|
|
982
981
|
|
|
@@ -990,12 +989,12 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
990
989
|
return await Query(self).update(pk, data)
|
|
991
990
|
|
|
992
991
|
async def on_model_delete(self, model: Any) -> None:
|
|
993
|
-
"""Perform some actions before a model is
|
|
992
|
+
"""Perform some actions before a model is deleted.
|
|
994
993
|
By default does nothing.
|
|
995
994
|
"""
|
|
996
995
|
|
|
997
996
|
async def after_model_delete(self, model: Any) -> None:
|
|
998
|
-
"""Perform some actions
|
|
997
|
+
"""Perform some actions after a model is deleted.
|
|
999
998
|
By default do nothing.
|
|
1000
999
|
"""
|
|
1001
1000
|
|
|
@@ -1005,7 +1004,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1005
1004
|
return await get_model_form(
|
|
1006
1005
|
model=self.model,
|
|
1007
1006
|
engine=self.engine,
|
|
1008
|
-
only=[i[1].key or i[1].name for i in self.
|
|
1007
|
+
only=[i[1].key or i[1].name for i in self._form_props],
|
|
1009
1008
|
column_labels={k.key: v for k, v in self._column_labels.items()},
|
|
1010
1009
|
form_args=self.form_args,
|
|
1011
1010
|
form_widget_args=self.form_widget_args,
|
|
@@ -1013,6 +1012,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1013
1012
|
form_overrides=self.form_overrides,
|
|
1014
1013
|
form_ajax_refs=self._form_ajax_refs,
|
|
1015
1014
|
form_include_pk=self.form_include_pk,
|
|
1015
|
+
form_converter=self.form_converter,
|
|
1016
1016
|
)
|
|
1017
1017
|
|
|
1018
1018
|
def search_placeholder(self) -> str:
|
|
@@ -1029,7 +1029,7 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1029
1029
|
"""
|
|
1030
1030
|
|
|
1031
1031
|
search_fields = [
|
|
1032
|
-
|
|
1032
|
+
map_attr_to_prop(attr, self) for attr in self.column_searchable_list
|
|
1033
1033
|
]
|
|
1034
1034
|
field_names = [
|
|
1035
1035
|
self._column_labels.get(field, field.key) for field in search_fields
|
|
@@ -1037,7 +1037,8 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1037
1037
|
return ", ".join(field_names)
|
|
1038
1038
|
|
|
1039
1039
|
def search_query(self, stmt: Select, term: str) -> Select:
|
|
1040
|
-
"""Specify the search query given the SQLAlchemy statement
|
|
1040
|
+
"""Specify the search query given the SQLAlchemy statement
|
|
1041
|
+
and term to search for.
|
|
1041
1042
|
It can be used for doing more complex queries like JSON objects. For example:
|
|
1042
1043
|
|
|
1043
1044
|
```py
|
|
@@ -1045,14 +1046,14 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1045
1046
|
```
|
|
1046
1047
|
"""
|
|
1047
1048
|
expressions = [
|
|
1048
|
-
cast(
|
|
1049
|
+
cast(prop, String).ilike(f"%{term}%") for prop in self._search_fields
|
|
1049
1050
|
]
|
|
1050
1051
|
return stmt.filter(or_(*expressions))
|
|
1051
1052
|
|
|
1052
1053
|
def get_export_name(self, export_type: str) -> str:
|
|
1053
1054
|
"""The file name when exporting."""
|
|
1054
|
-
|
|
1055
|
-
return
|
|
1055
|
+
|
|
1056
|
+
return f"{self.name}_{time.strftime('%Y-%m-%d_%H-%M-%S')}.{export_type}"
|
|
1056
1057
|
|
|
1057
1058
|
def export_data(
|
|
1058
1059
|
self,
|
|
@@ -1070,11 +1071,11 @@ class ModelView(BaseView, metaclass=ModelViewMeta):
|
|
|
1070
1071
|
) -> StreamingResponse:
|
|
1071
1072
|
def generate(writer: Writer) -> Generator[Any, None, None]:
|
|
1072
1073
|
# Append the column titles at the beginning
|
|
1073
|
-
titles = [c[0] for c in self.
|
|
1074
|
+
titles = [c[0] for c in self._export_props]
|
|
1074
1075
|
yield writer.writerow(titles)
|
|
1075
1076
|
|
|
1076
1077
|
for row in data:
|
|
1077
|
-
vals = [str(self.
|
|
1078
|
+
vals = [str(self.get_prop_value(row, c[1])) for c in self._export_props]
|
|
1078
1079
|
yield writer.writerow(vals)
|
|
1079
1080
|
|
|
1080
1081
|
# `get_export_name` can be subclassed.
|
sqladmin/statics/js/main.js
CHANGED
|
@@ -133,3 +133,17 @@ $("#action-delete").click(function () {
|
|
|
133
133
|
}
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
// Select2 Tags
|
|
138
|
+
$(':input[data-role="select2-tags"]').each(function () {
|
|
139
|
+
$(this).select2({
|
|
140
|
+
tags: true,
|
|
141
|
+
multiple: true,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
existing_data = $(this).data("json") || [];
|
|
145
|
+
for (var i = 0; i < existing_data.length; i++) {
|
|
146
|
+
var option = new Option(existing_data[i], existing_data[i], true, true);
|
|
147
|
+
$(this).append(option).trigger('change');
|
|
148
|
+
}
|
|
149
|
+
});
|
sqladmin/templates/create.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<h3 class="card-title">New {{ model_view.name }}</h3>
|
|
7
7
|
</div>
|
|
8
8
|
<div class="card-body border-bottom py-3">
|
|
9
|
-
<form action="{{ request.url }}" method="POST">
|
|
9
|
+
<form action="{{ request.url }}" method="POST" enctype="multipart/form-data">
|
|
10
10
|
<fieldset class="form-fieldset">
|
|
11
11
|
{% for field in form %}
|
|
12
12
|
<div class="mb-3 form-group row">
|