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/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 typing import Any, Callable, Generator, List, TypeVar, Union
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
- from sqladmin._types import MODEL_ATTR_TYPE
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 get_relationships(model: Any) -> List[MODEL_ATTR_TYPE]:
123
- return list(inspect(model).relationships)
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(attr: MODEL_ATTR_TYPE) -> bool:
146
- return isinstance(attr, RelationshipProperty)
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, MODEL_ATTR_TYPE
38
+ from sqladmin._types import ENGINE_TYPE, MODEL_PROPERTY
38
39
  from sqladmin.ajax import create_ajax_loader
39
- from sqladmin.exceptions import InvalidColumnError, InvalidModelError
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
- get_relationships,
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: “Save”, “Save and continue editing”, and “Save and add another”.
403
- If save_as is True, “Save and add another” will be replaced by a “Save as new” button
404
- that creates a new object (with a new ID) rather than updating the existing object.
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 is to the edit view for that 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._relations = get_relationships(self.model)
647
- self._attrs = get_attributes(self.model)
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._list_attrs = self.get_list_columns()
677
+ self._list_props = self.get_list_columns()
655
678
  self._list_columns = [
656
- (name, attr)
657
- for (name, attr) in self._list_attrs
658
- if isinstance(attr, ColumnProperty)
679
+ (name, prop)
680
+ for (name, prop) in self._list_props
681
+ if isinstance(prop, ColumnProperty)
659
682
  ]
660
-
661
- self._details_attrs = self.get_details_columns()
662
- self._details_columns = [
663
- (name, attr)
664
- for (name, attr) in self._details_attrs
665
- if isinstance(attr, ColumnProperty)
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
- self.get_model_attr(attr): formatter
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
- self.get_model_attr(attr): formatter
702
+ map_attr_to_prop(attr, self): formatter
677
703
  for (attr, formatter) in column_formatters_detail.items()
678
704
  }
679
705
 
680
- self._form_attrs = self.get_form_columns()
706
+ self._form_props = self.get_form_columns()
681
707
 
682
- self._export_attrs = self.get_export_columns()
708
+ self._export_props = self.get_export_columns()
683
709
 
684
710
  self._search_fields = [
685
- getattr(self.model, self.get_model_attr(attr).key)
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
- getattr(self.model, self.get_model_attr(attr).key)
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, get_primary_key(obj).name)
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, get_primary_key(obj).name)
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, get_primary_key(obj).name)
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 _url_for_details_with_attr(
738
- self, request: Request, obj: Any, attr: RelationshipProperty
739
- ) -> str:
740
- target = getattr(obj, attr.key)
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, attr.mapper.primary_key[0].name)
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._relations:
787
- stmt = stmt.options(joinedload(relation.key))
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._relations:
819
- stmt = stmt.options(joinedload(relation.key))
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._relations:
829
- stmt = stmt.options(joinedload(relation.key))
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 get_attr_value(
837
- self, obj: type, attr: Union[Column, ColumnProperty, RelationshipProperty]
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(attr, Column):
842
- result = getattr(obj, attr.name)
843
-
844
- if isinstance(attr, (ColumnProperty, RelationshipProperty)):
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, attr: MODEL_ATTR_TYPE) -> Tuple[Any, Any]:
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.get_attr_value(obj, attr)
876
+ value = self.get_prop_value(obj, prop)
853
877
  formatted_value = self._default_formatter(value)
854
878
 
855
- formatter = self._list_formatters.get(attr)
879
+ formatter = self._list_formatters.get(prop)
856
880
  if formatter:
857
- formatted_value = formatter(obj, attr)
881
+ formatted_value = formatter(obj, prop)
858
882
  return value, formatted_value
859
883
 
860
- def get_detail_value(self, obj: type, attr: MODEL_ATTR_TYPE) -> Tuple[Any, Any]:
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.get_attr_value(obj, attr)
886
+ value = self.get_prop_value(obj, prop)
863
887
  formatted_value = self._default_formatter(value)
864
888
 
865
- formatter = self._detail_formatters.get(attr)
889
+ formatter = self._detail_formatters.get(prop)
866
890
  if formatter:
867
- formatted_value = formatter(obj, attr)
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
- default: List[MODEL_ATTR_TYPE],
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, MODEL_ATTR_TYPE]]:
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
- attrs = [self.get_model_attr(attr) for attr in include]
904
+ props = [map_attr_to_prop(prop, self) for prop in include]
899
905
  elif exclude:
900
- exclude_columns = [self.get_model_attr(attr) for attr in exclude]
901
- attrs = list(set(self._attrs) - set(exclude_columns))
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
- attrs = default
909
+ props = defaults
904
910
 
905
- return [(self._column_labels.get(attr, attr.key), attr) for attr in attrs]
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, MODEL_ATTR_TYPE]]:
908
- """Get list of columns to display in List page."""
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
- default=[getattr(self.model, self.pk_column.name).prop],
922
+ defaults=[self._props[self.pk_column.key]],
917
923
  )
918
924
 
919
- def get_details_columns(self) -> List[Tuple[str, MODEL_ATTR_TYPE]]:
920
- """Get list of columns to display in Detail page."""
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
- default=self._attrs,
934
+ defaults=self._props,
929
935
  )
930
936
 
931
- def get_form_columns(self) -> List[Tuple[str, MODEL_ATTR_TYPE]]:
932
- """Get list of columns to display in the form."""
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
- default=self._attrs,
946
+ defaults=self._props,
949
947
  )
950
948
 
951
- def get_export_columns(self) -> List[Tuple[str, MODEL_ATTR_TYPE]]:
952
- """Get list of columns to export."""
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
- default=[item[1] for item in self._list_attrs],
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 or updated and committed to the database.
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[MODEL_ATTR_TYPE, str]:
976
+ ) -> Dict[MODEL_PROPERTY, str]:
978
977
  return {
979
- self.get_model_attr(column_label): value
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 created or updated.
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 before a model is deleted.
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._form_attrs],
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
- self.get_model_attr(attr) for attr in self.column_searchable_list
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 and term to search for.
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(attr, String).ilike(f"%{term}%") for attr in self._search_fields
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
- filename = f"{self.name}_{time.strftime('%Y-%m-%d_%H-%M-%S')}.{export_type}"
1055
- return filename
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._export_attrs]
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.get_attr_value(row, c[1])) for c in self._export_attrs]
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.
@@ -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
+ });
@@ -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">