brilliance-admin 0.43.7__py3-none-any.whl → 0.44.12__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.
Files changed (37) hide show
  1. brilliance_admin/api/routers.py +2 -2
  2. brilliance_admin/api/views/autocomplete.py +1 -1
  3. brilliance_admin/api/views/{graphs.py → dashboard.py} +5 -5
  4. brilliance_admin/api/views/table.py +7 -6
  5. brilliance_admin/exceptions.py +1 -0
  6. brilliance_admin/integrations/sqlalchemy/auth.py +1 -2
  7. brilliance_admin/integrations/sqlalchemy/autocomplete.py +6 -1
  8. brilliance_admin/integrations/sqlalchemy/fields.py +22 -9
  9. brilliance_admin/integrations/sqlalchemy/table/create.py +4 -1
  10. brilliance_admin/integrations/sqlalchemy/table/delete.py +1 -1
  11. brilliance_admin/integrations/sqlalchemy/table/list.py +42 -12
  12. brilliance_admin/integrations/sqlalchemy/table/retrieve.py +33 -14
  13. brilliance_admin/integrations/sqlalchemy/table/update.py +6 -1
  14. brilliance_admin/locales/en.yml +12 -5
  15. brilliance_admin/locales/ru.yml +12 -6
  16. brilliance_admin/schema/__init__.py +3 -3
  17. brilliance_admin/schema/admin_schema.py +31 -23
  18. brilliance_admin/schema/category.py +87 -12
  19. brilliance_admin/schema/dashboard/__init__.py +1 -0
  20. brilliance_admin/schema/dashboard/category_dashboard.py +87 -0
  21. brilliance_admin/schema/table/category_table.py +13 -8
  22. brilliance_admin/schema/table/fields/base.py +65 -11
  23. brilliance_admin/schema/table/fields_schema.py +7 -1
  24. brilliance_admin/static/{index-BnnESruI.js → index-8ahvKI6W.js} +184 -184
  25. brilliance_admin/static/{index-vlBToOhT.css → index-B8JOx1Ps.css} +1 -1
  26. brilliance_admin/templates/index.html +2 -2
  27. brilliance_admin/translations.py +3 -0
  28. brilliance_admin/utils.py +38 -0
  29. {brilliance_admin-0.43.7.dist-info → brilliance_admin-0.44.12.dist-info}/METADATA +56 -108
  30. {brilliance_admin-0.43.7.dist-info → brilliance_admin-0.44.12.dist-info}/RECORD +33 -34
  31. {brilliance_admin-0.43.7.dist-info → brilliance_admin-0.44.12.dist-info}/WHEEL +1 -1
  32. brilliance_admin-0.44.12.dist-info/licenses/LICENSE +21 -0
  33. brilliance_admin/schema/graphs/__init__.py +0 -1
  34. brilliance_admin/schema/graphs/category_graphs.py +0 -51
  35. brilliance_admin/schema/group.py +0 -67
  36. brilliance_admin-0.43.7.dist-info/licenses/LICENSE +0 -17
  37. {brilliance_admin-0.43.7.dist-info → brilliance_admin-0.44.12.dist-info}/top_level.txt +0 -0
@@ -7,11 +7,12 @@ from urllib.parse import urljoin
7
7
  from fastapi import FastAPI, Request
8
8
  from fastapi.middleware.cors import CORSMiddleware
9
9
  from fastapi.staticfiles import StaticFiles
10
+ from pydantic import Field
10
11
  from pydantic.dataclasses import dataclass
11
12
 
12
13
  from brilliance_admin.auth import UserABC
13
14
  from brilliance_admin.docs import build_redoc_docs, build_scalar_docs
14
- from brilliance_admin.schema.group import Group, GroupSchemaData
15
+ from brilliance_admin.schema.category import BaseCategory, CategorySchemaData
15
16
  from brilliance_admin.translations import LanguageContext, LanguageManager
16
17
  from brilliance_admin.utils import DataclassBase, SupportsStr
17
18
 
@@ -23,8 +24,8 @@ DEFAULT_LANGUAGES = {
23
24
 
24
25
  @dataclass
25
26
  class AdminSchemaData(DataclassBase):
26
- groups: Dict[str, GroupSchemaData]
27
27
  profile: UserABC | Any
28
+ categories: Dict[str, CategorySchemaData] = Field(default_factory=dict)
28
29
 
29
30
  def __post_init__(self):
30
31
  if not isinstance(self.profile, UserABC):
@@ -39,6 +40,7 @@ class AdminSettingsData(DataclassBase):
39
40
  login_greetings_message: SupportsStr | None
40
41
  navbar_density: str
41
42
  languages: Dict[str, str] | None
43
+ main_page: str | None = None
42
44
 
43
45
 
44
46
  @dataclass
@@ -50,9 +52,11 @@ class AdminIndexContextData(DataclassBase):
50
52
 
51
53
  @dataclass
52
54
  class AdminSchema:
53
- groups: List[Group]
55
+ categories: List[BaseCategory]
54
56
  auth: Any
55
57
 
58
+ main_page: str | None = None
59
+
56
60
  title: SupportsStr | None = 'Admin'
57
61
  description: SupportsStr | None = None
58
62
  login_greetings_message: SupportsStr | None = None
@@ -62,15 +66,17 @@ class AdminSchema:
62
66
 
63
67
  navbar_density: str = 'default'
64
68
 
65
- backend_prefix = None
66
- static_prefix = None
69
+ backend_prefix: str | None = None
70
+ static_prefix: str | None = None
67
71
 
68
72
  language_manager: LanguageManager | None = None
69
73
 
74
+ debug: bool = False
75
+
70
76
  def __post_init__(self):
71
- for group in self.groups:
72
- if not issubclass(group.__class__, Group):
73
- raise TypeError(f'Group "{group}" is not instance of Group subclass')
77
+ for category in self.categories:
78
+ if not issubclass(category.__class__, BaseCategory):
79
+ raise TypeError(f'Root category "{category}" is not instance of BaseCategory subclass')
74
80
 
75
81
  if not self.language_manager:
76
82
  self.language_manager = LanguageManager(DEFAULT_LANGUAGES)
@@ -81,24 +87,25 @@ class AdminSchema:
81
87
  def generate_schema(self, user: UserABC, language_slug: str | None) -> AdminSchemaData:
82
88
  language_context: LanguageContext = self.get_language_context(language_slug)
83
89
 
84
- groups = {}
90
+ result = AdminSchemaData(profile=user)
85
91
 
86
- for group in self.groups:
87
- if not group.slug:
88
- msg = f'Category group {type(group).__name__}.slug is empty'
92
+ for category in self.categories:
93
+ if not category.slug:
94
+ msg = f'Category {type(category).__name__}.slug is empty'
89
95
  raise AttributeError(msg)
90
96
 
91
- groups[group.slug] = group.generate_schema(user, language_context)
97
+ try:
98
+ result.categories[category.slug] = category.generate_schema(user, language_context).to_dict(keep_none=False)
99
+ except Exception as e:
100
+ msg = f'Root category "{category.slug}" generate_schema error: {e}'
101
+ raise Exception(msg) from e
92
102
 
93
- return AdminSchemaData(
94
- groups=groups,
95
- profile=user,
96
- )
103
+ return result
97
104
 
98
- def get_group(self, group_slug: str) -> Optional[Group]:
99
- for group in self.groups:
100
- if group.slug == group_slug:
101
- return group
105
+ def get_group(self, group_slug: str) -> Optional[BaseCategory]:
106
+ for category in self.categories:
107
+ if category.slug == group_slug:
108
+ return category
102
109
 
103
110
  return None
104
111
 
@@ -114,14 +121,13 @@ class AdminSchema:
114
121
 
115
122
  return AdminSettingsData(
116
123
  title=self.title,
124
+ main_page=self.main_page,
117
125
  description=self.description,
118
126
  login_greetings_message=self.login_greetings_message,
119
127
  navbar_density=self.navbar_density,
120
128
  languages=languages,
121
129
  )
122
130
 
123
- # pylint: disable=too-many-arguments
124
- # pylint: disable=too-many-positional-arguments
125
131
  def generate_app(
126
132
  self,
127
133
  debug=False,
@@ -131,6 +137,8 @@ class AdminSchema:
131
137
  include_docs=False,
132
138
  include_redoc=False,
133
139
  ) -> FastAPI:
140
+ self.debug = debug
141
+
134
142
  # pylint: disable=unused-variable
135
143
  language_context = self.get_language_context(language_slug=None)
136
144
 
@@ -4,10 +4,13 @@ from typing import Any, ClassVar, Dict, List
4
4
  from pydantic import Field
5
5
  from pydantic.dataclasses import dataclass
6
6
  from pydantic_core import core_schema
7
+ from structlog import get_logger
7
8
 
8
9
  from brilliance_admin.auth import UserABC
9
10
  from brilliance_admin.translations import LanguageContext
10
- from brilliance_admin.utils import DataclassBase, SupportsStr, humanize_field_name
11
+ from brilliance_admin.utils import DataclassBase, KwargsInitMixin, SupportsStr, humanize_field_name
12
+
13
+ logger = get_logger()
11
14
 
12
15
 
13
16
  # pylint: disable=too-many-instance-attributes
@@ -92,7 +95,7 @@ class TableInfoSchemaData(DataclassBase):
92
95
 
93
96
 
94
97
  @dataclass
95
- class GraphInfoSchemaData(DataclassBase):
98
+ class DashboardInfoSchemaData(DataclassBase):
96
99
  search_enabled: bool
97
100
  search_help: str | None
98
101
 
@@ -106,37 +109,56 @@ class CategorySchemaData(DataclassBase):
106
109
  icon: str | None
107
110
  type: str
108
111
 
112
+ categories: dict = Field(default_factory=dict)
113
+
109
114
  table_info: TableInfoSchemaData | None = None
110
- graph_info: GraphInfoSchemaData | None = None
115
+ dashboard_info: DashboardInfoSchemaData | None = None
116
+
117
+ link: str | None = None
111
118
 
112
119
  def __repr__(self):
113
120
  return f'<CategorySchemaData type={self.type} "{self.title}">'
114
121
 
115
122
 
116
- class Category(abc.ABC):
117
- slug: ClassVar[str]
118
- title: ClassVar[SupportsStr | None] = None
119
- description: ClassVar[SupportsStr | None] = None
123
+ class BaseCategory(KwargsInitMixin, abc.ABC):
124
+ slug: str
125
+ title: SupportsStr | None = None
126
+ description: SupportsStr | None = None
120
127
 
121
128
  # https://pictogrammers.com/library/mdi/
122
- icon: ClassVar[str | None] = None
129
+ icon: str | None = None
123
130
 
124
131
  _type_slug: ClassVar[str]
125
132
 
126
133
  def generate_schema(self, user: UserABC, language_context: LanguageContext) -> CategorySchemaData:
127
- return CategorySchemaData(
134
+ type_slug = getattr(type(self), '_type_slug', None)
135
+ if not type_slug:
136
+ msg = f'{type(self).__name__}._type_slug must be set!'
137
+ raise AttributeError(msg)
138
+
139
+ result = CategorySchemaData(
128
140
  title=language_context.get_text(self.title) or humanize_field_name(self.slug),
129
141
  description=language_context.get_text(self.description),
130
142
  icon=self.icon,
131
- type=self._type_slug,
143
+ type=type_slug,
132
144
  )
145
+ return result
146
+
147
+ def __init_subclass__(cls, **kwargs):
148
+ super().__init_subclass__(**kwargs)
149
+
150
+ if cls is BaseCategory:
151
+ return
152
+
153
+ if not issubclass(cls, BaseCategory):
154
+ raise TypeError(f'{cls.__name__} must inherit from Category')
133
155
 
134
156
  @classmethod
135
157
  def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema:
136
- def validate(v: Any) -> "Category":
158
+ def validate(v: Any) -> "BaseCategory":
137
159
  if isinstance(v, cls):
138
160
  return v
139
- raise TypeError(f"Expected {cls.__name__} instance")
161
+ raise TypeError(f"Expected {cls.__name__} instance, recieved: {type(v)} {v}")
140
162
 
141
163
  return core_schema.no_info_plain_validator_function(
142
164
  validate,
@@ -146,3 +168,56 @@ class Category(abc.ABC):
146
168
  return_schema=core_schema.str_schema(),
147
169
  ),
148
170
  )
171
+
172
+
173
+ class CategoryLink(BaseCategory):
174
+ _type_slug: str = 'link'
175
+
176
+ link: str
177
+
178
+ def generate_schema(self, user: UserABC, language_context: LanguageContext) -> CategorySchemaData:
179
+ result = super().generate_schema(user, language_context)
180
+ result.link = self.link
181
+ return result
182
+
183
+
184
+ class CategoryGroup(BaseCategory):
185
+ _type_slug: str = 'group'
186
+
187
+ subcategories: list = Field(default_factory=list)
188
+
189
+ def __init__(self, *args, **kwargs):
190
+ super().__init__(*args, **kwargs)
191
+
192
+ for category in self.subcategories:
193
+ if not isinstance(category, BaseCategory):
194
+ raise TypeError(f'Category "{category}" is not instance of BaseCategory subclass')
195
+
196
+ def generate_schema(self, user: UserABC, language_context: LanguageContext) -> CategorySchemaData:
197
+ result = super().generate_schema(user, language_context)
198
+
199
+ for category in self.subcategories:
200
+
201
+ if not category.slug:
202
+ msg = f'Category {type(category).__name__}.slug is empty'
203
+ raise AttributeError(msg)
204
+
205
+ if category.slug in result.categories:
206
+ exists = result.categories[category.slug]
207
+ msg = f'Category {type(category).__name__}.slug "{self.slug}" already registered by "{exists.title}"'
208
+ raise KeyError(msg)
209
+
210
+ try:
211
+ result.categories[category.slug] = category.generate_schema(user, language_context)
212
+ except Exception as e:
213
+ msg = f'Category "{category.slug}" {type(category)} generate_schema error: {e}'
214
+ raise Exception(msg) from e
215
+
216
+ return result
217
+
218
+ def get_category(self, category_slug: str):
219
+ for category in self.subcategories:
220
+ if category.slug == category_slug:
221
+ return category
222
+
223
+ return None
@@ -0,0 +1 @@
1
+ from .category_dashboard import CategoryDashboard
@@ -0,0 +1,87 @@
1
+ from typing import Any, Dict, List
2
+
3
+ from pydantic import BaseModel, Field, field_serializer
4
+
5
+ from brilliance_admin.schema.category import BaseCategory, DashboardInfoSchemaData
6
+ from brilliance_admin.schema.table.fields_schema import FieldsSchema
7
+ from brilliance_admin.translations import LanguageContext
8
+ from brilliance_admin.utils import SupportsStr
9
+
10
+
11
+ class DashboardData(BaseModel):
12
+ search: str | None = None
13
+ filters: Dict[str, Any] = Field(default_factory=dict)
14
+
15
+
16
+ class ChartData(BaseModel):
17
+ data: dict
18
+ options: dict
19
+ width: int | None = None
20
+ height: int = 50
21
+ type: str = 'line'
22
+
23
+ component_type: str = 'chart'
24
+
25
+
26
+ class Subcard(BaseModel):
27
+ title: SupportsStr
28
+ value: SupportsStr
29
+ color: str | None = None
30
+
31
+
32
+ class PeriodGraph(BaseModel):
33
+ title: SupportsStr
34
+ value: SupportsStr
35
+ change: int | float | None = None
36
+ subcards: List[Subcard] = Field(default_factory=list)
37
+ values: List[List[int | float]] = Field(default_factory=list)
38
+ vertical: List[SupportsStr] = Field(default_factory=list)
39
+ horizontal: List[SupportsStr] = Field(default_factory=list)
40
+ component_type: str = 'period_graph'
41
+
42
+ @field_serializer('horizontal', 'vertical')
43
+ def serialize_str_list(self, val: list) -> list:
44
+ return [str(v) for v in val]
45
+
46
+
47
+ class SmallGraph(BaseModel):
48
+ title: SupportsStr
49
+ value: SupportsStr
50
+ change: int | float | None = None
51
+ points: Dict[SupportsStr, float | int] = Field(default_factory=list)
52
+ component_type: str = 'small_graph'
53
+
54
+
55
+ class DashboardContainer(BaseModel):
56
+ cols: int | None = None
57
+ md: int | None = None
58
+ lg: int | None = None
59
+ sm: int | None = None
60
+
61
+ component_type: str = 'container'
62
+ components: List[Any] = Field(default_factory=list)
63
+
64
+
65
+ class CategoryDashboard(BaseCategory):
66
+ _type_slug: str = 'dashboard'
67
+
68
+ search_enabled: bool = False
69
+ search_help: SupportsStr | None = None
70
+
71
+ table_filters: FieldsSchema | None = None
72
+
73
+ def generate_schema(self, user, language_context: LanguageContext) -> DashboardInfoSchemaData:
74
+ schema = super().generate_schema(user, language_context)
75
+ dashboard_info = DashboardInfoSchemaData(
76
+ search_enabled=self.search_enabled,
77
+ search_help=language_context.get_text(self.search_help),
78
+ )
79
+
80
+ if self.table_filters:
81
+ dashboard_info.table_filters = self.table_filters.generate_schema(user, language_context)
82
+
83
+ schema.dashboard_info = dashboard_info
84
+ return schema
85
+
86
+ async def get_data(self, data: DashboardData, user) -> DashboardContainer:
87
+ raise NotImplementedError('get_data is not implemented')
@@ -8,8 +8,8 @@ from pydantic import Field
8
8
 
9
9
  from brilliance_admin.auth import UserABC
10
10
  from brilliance_admin.exceptions import AdminAPIException, APIError
11
- from brilliance_admin.schema import Category
12
- from brilliance_admin.schema.category import TableInfoSchemaData
11
+ from brilliance_admin.schema.admin_schema import AdminSchema
12
+ from brilliance_admin.schema.category import BaseCategory, TableInfoSchemaData
13
13
  from brilliance_admin.schema.table.admin_action import ActionData, ActionResult
14
14
  from brilliance_admin.schema.table.fields_schema import FieldsSchema
15
15
  from brilliance_admin.schema.table.table_models import AutocompleteData, AutocompleteResult, ListData, TableListResult
@@ -17,7 +17,7 @@ from brilliance_admin.translations import LanguageContext
17
17
  from brilliance_admin.utils import DeserializeAction, SupportsStr
18
18
 
19
19
 
20
- class CategoryTable(Category):
20
+ class CategoryTable(BaseCategory):
21
21
  _type_slug: str = 'table'
22
22
 
23
23
  search_enabled: bool = False
@@ -32,6 +32,8 @@ class CategoryTable(Category):
32
32
  pk_name: str | None = None
33
33
 
34
34
  def __init__(self, *args, table_schema=None, table_filters=None, **kwargs):
35
+ super().__init__(*args, **kwargs)
36
+
35
37
  if table_schema:
36
38
  self.table_schema = table_schema
37
39
 
@@ -128,6 +130,7 @@ class CategoryTable(Category):
128
130
  action_data: ActionData,
129
131
  language_context: LanguageContext,
130
132
  user: UserABC,
133
+ admin_schema: AdminSchema,
131
134
  ) -> ActionResult:
132
135
  action_fn = self._get_action_fn(action)
133
136
  if action_fn is None:
@@ -154,7 +157,7 @@ class CategoryTable(Category):
154
157
 
155
158
  return result
156
159
 
157
- async def autocomplete(self, data: AutocompleteData, user: UserABC) -> AutocompleteResult:
160
+ async def autocomplete(self, data: AutocompleteData, user: UserABC, schema: AdminSchema) -> AutocompleteResult:
158
161
  """
159
162
  Retrieves list of found options to select.
160
163
  """
@@ -162,14 +165,16 @@ class CategoryTable(Category):
162
165
 
163
166
  # pylint: disable=too-many-arguments
164
167
  @abc.abstractmethod
165
- async def get_list(self, list_data: ListData, user: UserABC, language_context: LanguageContext) -> TableListResult:
168
+ async def get_list(
169
+ self, list_data: ListData, user: UserABC, language_context: LanguageContext, admin_schema: AdminSchema
170
+ ) -> TableListResult:
166
171
  raise NotImplementedError()
167
172
 
168
- # async def retrieve(self, pk: Any, user: UserABC) -> RetrieveResult:
173
+ # async def retrieve(self, pk: Any, user: UserABC, language_context: LanguageContext, admin_schema: AdminSchema) -> RetrieveResult:
169
174
  # raise NotImplementedError()
170
175
 
171
- # async def create(self, data: dict, user: UserABC) -> CreateResult:
176
+ # async def create(self, data: dict, user: UserABC, language_context: LanguageContext, admin_schema: AdminSchema) -> CreateResult:
172
177
  # raise NotImplementedError()
173
178
 
174
- # async def update(self, pk: Any, data: dict, user: UserABC) -> UpdateResult:
179
+ # async def update(self, pk: Any, data: dict, user: UserABC, language_context: LanguageContext, admin_schema: AdminSchema) -> UpdateResult:
175
180
  # raise NotImplementedError()
@@ -8,6 +8,7 @@ from pydantic.dataclasses import dataclass
8
8
  from brilliance_admin.exceptions import FieldError
9
9
  from brilliance_admin.schema.category import FieldSchemaData
10
10
  from brilliance_admin.translations import LanguageContext
11
+ from brilliance_admin.translations import TranslateText as _
11
12
  from brilliance_admin.utils import DeserializeAction, SupportsStr, humanize_field_name
12
13
 
13
14
 
@@ -80,7 +81,7 @@ class IntegerField(TableField):
80
81
  async def deserialize(self, value, action: DeserializeAction, extra: dict, *args, **kwargs) -> Any:
81
82
  value = await super().deserialize(value, action, extra, *args, **kwargs)
82
83
  if value and not isinstance(value, int):
83
- raise FieldError(f'bad int type: {type(value)}')
84
+ raise FieldError(_('errors.bad_type_error') % {'type': type(value), 'expected': 'init'})
84
85
 
85
86
  return value
86
87
 
@@ -116,7 +117,7 @@ class StringField(TableField):
116
117
  async def deserialize(self, value, action: DeserializeAction, extra: dict, *args, **kwargs) -> Any:
117
118
  value = await super().deserialize(value, action, extra, *args, **kwargs)
118
119
  if value and not isinstance(value, str):
119
- raise FieldError(f'bad string type: {type(value)}')
120
+ raise FieldError(_('errors.bad_type_error') % {'type': type(value), 'expected': 'string'})
120
121
 
121
122
  return value
122
123
 
@@ -126,6 +127,18 @@ class BooleanField(TableField):
126
127
  _type = 'boolean'
127
128
 
128
129
 
130
+ def _parse_iso(value: str) -> datetime.datetime:
131
+ if value.endswith('Z'):
132
+ value = value.replace('Z', '+00:00')
133
+
134
+ dt = datetime.datetime.fromisoformat(value)
135
+
136
+ if dt.tzinfo is None:
137
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
138
+
139
+ return dt
140
+
141
+
129
142
  @dataclass
130
143
  class DateTimeField(TableField):
131
144
  _type = 'datetime'
@@ -151,28 +164,40 @@ class DateTimeField(TableField):
151
164
  return
152
165
 
153
166
  if value and not isinstance(value, (str, dict)):
154
- raise FieldError(f'bad datetime type: {type(value)}')
167
+ raise FieldError(_('errors.bad_type_error') % {'type': type(value), 'expected': 'datetime'})
155
168
 
156
169
  if isinstance(value, str):
157
- return datetime.datetime.strptime(value, self.format)
170
+ return _parse_iso(value)
158
171
 
159
172
  if isinstance(value, dict):
160
173
  if not value.get('from') or not value.get('to'):
161
- msg = f'{type(self).__name__} value must be dict with from,to values: {value}'
162
- raise FieldError(msg)
174
+ raise FieldError(
175
+ f'{type(self).__name__} value must be dict with from,to values: {value}'
176
+ )
163
177
 
164
178
  return {
165
- 'from': datetime.datetime.strptime(value.get('from'), self.format),
166
- 'to': datetime.datetime.strptime(value.get('to'), self.format),
179
+ 'from': _parse_iso(value['from']),
180
+ 'to': _parse_iso(value['to']),
167
181
  }
168
182
 
169
- raise NotImplementedError(f'Value "{value}" is not supporetd for datetime')
183
+ raise FieldError(_('errors.bad_type_error') % {'type': type(value), 'expected': 'datetime'})
170
184
 
171
185
 
172
186
  @dataclass
173
187
  class JSONField(TableField):
174
188
  _type = 'json'
175
189
 
190
+ async def deserialize(self, value, action: DeserializeAction, extra: dict, *args, **kwargs) -> Any:
191
+ value = await super().deserialize(value, action, extra, *args, **kwargs)
192
+
193
+ if value is None:
194
+ return
195
+
196
+ if not isinstance(value, (dict, list)):
197
+ raise FieldError(_('errors.bad_type_error') % {'type': type(value), 'expected': 'JSON'})
198
+
199
+ return value
200
+
176
201
 
177
202
  @dataclass
178
203
  class ArrayField(TableField):
@@ -190,8 +215,11 @@ class ArrayField(TableField):
190
215
  async def deserialize(self, value, action: DeserializeAction, extra: dict, *args, **kwargs) -> Any:
191
216
  value = await super().deserialize(value, action, extra, *args, **kwargs)
192
217
 
193
- if value and not isinstance(value, list):
194
- raise FieldError(f'bad array type: {type(value)}')
218
+ if value is None:
219
+ return
220
+
221
+ if not isinstance(value, list):
222
+ raise FieldError(_('errors.bad_type_error') % {'type': type(value), 'expected': 'Array'})
195
223
 
196
224
  return value
197
225
 
@@ -276,3 +304,29 @@ class ChoiceField(TableField):
276
304
  'value': value,
277
305
  'title': choice.get('title') or value if choice else value.capitalize(),
278
306
  }
307
+
308
+ async def deserialize(self, value, action: DeserializeAction, extra: dict, *args, **kwargs) -> Any:
309
+ value = await super().deserialize(value, action, extra, *args, **kwargs)
310
+
311
+ if value is None:
312
+ return
313
+
314
+ if isinstance(value, dict):
315
+ if 'value' not in value:
316
+ raise FieldError(
317
+ f'{type(self).__name__} dict value must contain "value": {value}'
318
+ )
319
+ value = value['value']
320
+
321
+ if not isinstance(value, str):
322
+ raise FieldError(
323
+ f'{type(self).__name__} value must be str, got {type(value)}'
324
+ )
325
+
326
+ choice = self.find_choice(value)
327
+ if not choice:
328
+ raise FieldError(
329
+ f'Invalid choice value "{value}", allowed: {[c["value"] for c in self.choices or []]}'
330
+ )
331
+
332
+ return value
@@ -159,7 +159,13 @@ class FieldsSchema:
159
159
  result = {}
160
160
  for field_slug, field in self.get_fields().items():
161
161
  value = data.get(field_slug)
162
- result[field_slug] = await field.serialize(value, extra)
162
+
163
+ try:
164
+ result[field_slug] = await field.serialize(value, extra)
165
+ except FieldError as e:
166
+ e.field_slug = field_slug
167
+ raise e
168
+
163
169
  return result
164
170
 
165
171
  async def deserialize(self, data: dict, action: DeserializeAction, extra) -> dict: