brilliance-admin 0.42.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.
Files changed (73) hide show
  1. admin_panel/__init__.py +4 -0
  2. admin_panel/api/__init__.py +0 -0
  3. admin_panel/api/routers.py +18 -0
  4. admin_panel/api/utils.py +28 -0
  5. admin_panel/api/views/__init__.py +0 -0
  6. admin_panel/api/views/auth.py +29 -0
  7. admin_panel/api/views/autocomplete.py +33 -0
  8. admin_panel/api/views/graphs.py +30 -0
  9. admin_panel/api/views/index.py +38 -0
  10. admin_panel/api/views/schema.py +29 -0
  11. admin_panel/api/views/settings.py +29 -0
  12. admin_panel/api/views/table.py +136 -0
  13. admin_panel/auth.py +32 -0
  14. admin_panel/docs.py +37 -0
  15. admin_panel/exceptions.py +38 -0
  16. admin_panel/integrations/__init__.py +0 -0
  17. admin_panel/integrations/sqlalchemy/__init__.py +6 -0
  18. admin_panel/integrations/sqlalchemy/auth.py +144 -0
  19. admin_panel/integrations/sqlalchemy/autocomplete.py +38 -0
  20. admin_panel/integrations/sqlalchemy/fields.py +254 -0
  21. admin_panel/integrations/sqlalchemy/fields_schema.py +316 -0
  22. admin_panel/integrations/sqlalchemy/table/__init__.py +19 -0
  23. admin_panel/integrations/sqlalchemy/table/base.py +141 -0
  24. admin_panel/integrations/sqlalchemy/table/create.py +73 -0
  25. admin_panel/integrations/sqlalchemy/table/delete.py +18 -0
  26. admin_panel/integrations/sqlalchemy/table/list.py +178 -0
  27. admin_panel/integrations/sqlalchemy/table/retrieve.py +61 -0
  28. admin_panel/integrations/sqlalchemy/table/update.py +95 -0
  29. admin_panel/schema/__init__.py +7 -0
  30. admin_panel/schema/admin_schema.py +191 -0
  31. admin_panel/schema/category.py +149 -0
  32. admin_panel/schema/graphs/__init__.py +1 -0
  33. admin_panel/schema/graphs/category_graphs.py +50 -0
  34. admin_panel/schema/group.py +67 -0
  35. admin_panel/schema/table/__init__.py +8 -0
  36. admin_panel/schema/table/admin_action.py +76 -0
  37. admin_panel/schema/table/category_table.py +175 -0
  38. admin_panel/schema/table/fields/__init__.py +5 -0
  39. admin_panel/schema/table/fields/base.py +249 -0
  40. admin_panel/schema/table/fields/function_field.py +65 -0
  41. admin_panel/schema/table/fields_schema.py +216 -0
  42. admin_panel/schema/table/table_models.py +53 -0
  43. admin_panel/static/favicon.jpg +0 -0
  44. admin_panel/static/index-BeniOHDv.js +525 -0
  45. admin_panel/static/index-vlBToOhT.css +8 -0
  46. admin_panel/static/materialdesignicons-webfont-CYDMK1kx.woff2 +0 -0
  47. admin_panel/static/materialdesignicons-webfont-CgCzGbLl.woff +0 -0
  48. admin_panel/static/materialdesignicons-webfont-D3kAzl71.ttf +0 -0
  49. admin_panel/static/materialdesignicons-webfont-DttUABo4.eot +0 -0
  50. admin_panel/static/tinymce/dark-first/content.min.css +250 -0
  51. admin_panel/static/tinymce/dark-first/skin.min.css +2820 -0
  52. admin_panel/static/tinymce/dark-slim/content.min.css +249 -0
  53. admin_panel/static/tinymce/dark-slim/skin.min.css +2821 -0
  54. admin_panel/static/tinymce/img/example.png +0 -0
  55. admin_panel/static/tinymce/img/tinymce.woff2 +0 -0
  56. admin_panel/static/tinymce/lightgray/content.min.css +1 -0
  57. admin_panel/static/tinymce/lightgray/fonts/tinymce.woff +0 -0
  58. admin_panel/static/tinymce/lightgray/skin.min.css +1 -0
  59. admin_panel/static/tinymce/plugins/accordion/css/accordion.css +17 -0
  60. admin_panel/static/tinymce/plugins/accordion/plugin.js +48 -0
  61. admin_panel/static/tinymce/plugins/codesample/css/prism.css +1 -0
  62. admin_panel/static/tinymce/plugins/customLink/css/link.css +3 -0
  63. admin_panel/static/tinymce/plugins/customLink/plugin.js +147 -0
  64. admin_panel/static/tinymce/tinymce.min.js +2 -0
  65. admin_panel/static/vanilla-picker-B6E6ObS_.js +8 -0
  66. admin_panel/templates/index.html +25 -0
  67. admin_panel/translations.py +145 -0
  68. admin_panel/utils.py +50 -0
  69. brilliance_admin-0.42.0.dist-info/METADATA +155 -0
  70. brilliance_admin-0.42.0.dist-info/RECORD +73 -0
  71. brilliance_admin-0.42.0.dist-info/WHEEL +5 -0
  72. brilliance_admin-0.42.0.dist-info/licenses/LICENSE +17 -0
  73. brilliance_admin-0.42.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,38 @@
1
+ from admin_panel.auth import UserABC
2
+ from admin_panel.schema.table.table_models import AutocompleteData, AutocompleteResult
3
+ from admin_panel.translations import LanguageManager
4
+
5
+
6
+ class SQLAlchemyAdminAutocompleteMixin:
7
+ async def autocomplete(
8
+ self, data: AutocompleteData, user: UserABC, language_manager: LanguageManager,
9
+ ) -> AutocompleteResult:
10
+ form_schema = None
11
+
12
+ if data.action_name is not None:
13
+ action_fn = self._get_action_fn(data.action_name)
14
+ if not action_fn:
15
+ raise Exception(f'Action "{data.action_name}" is not found')
16
+
17
+ if not action_fn.form_schema:
18
+ raise Exception(f'Action "{data.action_name}" form_schema is None')
19
+
20
+ form_schema = action_fn.form_schema
21
+
22
+ elif data.is_filter:
23
+ if not self.table_filters:
24
+ raise Exception(f'Action "{data.action_name}" table_filters is None')
25
+
26
+ form_schema = self.table_filters
27
+
28
+ else:
29
+ form_schema = self.table_schema
30
+
31
+ field = form_schema.get_field(data.field_slug)
32
+ if not field:
33
+ raise Exception(f'Field "{data.field_slug}" is not found')
34
+
35
+ async with self.db_async_session() as session:
36
+ results = await field.autocomplete(self.model, data, user, extra={'db_session': session})
37
+
38
+ return AutocompleteResult(results=results)
@@ -0,0 +1,254 @@
1
+ from typing import Any, List
2
+
3
+ from pydantic.dataclasses import dataclass
4
+
5
+ from admin_panel.auth import UserABC
6
+ from admin_panel.exceptions import AdminAPIException, APIError, FieldError
7
+ from admin_panel.schema.category import FieldSchemaData
8
+ from admin_panel.schema.table.fields.base import TableField
9
+ from admin_panel.schema.table.table_models import Record
10
+ from admin_panel.translations import LanguageManager
11
+ from admin_panel.translations import TranslateText as _
12
+ from admin_panel.utils import DeserializeAction
13
+
14
+
15
+ def get_pk(obj):
16
+ pk_cols = obj.__mapper__.primary_key
17
+ if len(pk_cols) != 1:
18
+ raise NotImplementedError('Composite primary key is not supported')
19
+ return getattr(obj, pk_cols[0].key)
20
+
21
+
22
+ @dataclass
23
+ class SQLAlchemyRelatedField(TableField):
24
+ _type: str = 'related'
25
+
26
+ # Тип связи.
27
+ # Откуда берётся:
28
+ # - из SQLAlchemy relationship.uselist
29
+ # rel.uselist == True -> список связанных объектов
30
+ # rel.uselist == False -> одиночная связь
31
+ #
32
+ # Зачем нужен:
33
+ # - чтобы понимать, ожидать list или один объект
34
+ # - влияет на логику update_related и serialize
35
+ many: bool = False
36
+
37
+ # Имя relationship-атрибута на модели.
38
+ # Откуда берётся:
39
+ # - из mapper.relationships: rel.key
40
+ # - либо через поиск relationship по FK колонке (col.local_columns)
41
+ #
42
+ # Зачем нужен:
43
+ # - для доступа к связи через ORM
44
+ # getattr(record, rel_name)
45
+ # - для записи и чтения связанных объектов
46
+ rel_name: str | None = None
47
+
48
+ # Класс связанной SQLAlchemy-модели.
49
+ # Откуда берётся:
50
+ # - из relationship: rel.mapper.class_
51
+ #
52
+ # Зачем нужен:
53
+ # - для загрузки связанных записей из БД
54
+ # session.get(target_model, pk)
55
+ # select(target_model).where(target_model.id.in_(...))
56
+ target_model: Any | None = None
57
+
58
+ # Работает только если many=True
59
+ dual_list: bool = False
60
+
61
+ def generate_schema(self, user: UserABC, field_slug, language_manager: LanguageManager) -> FieldSchemaData:
62
+ schema = super().generate_schema(user, field_slug, language_manager)
63
+ schema.many = self.many
64
+ schema.rel_name = self.rel_name
65
+ schema.dual_list = self.dual_list
66
+ return schema
67
+
68
+ def _get_target_model(self, model, field_slug):
69
+ # pylint: disable=import-outside-toplevel
70
+ from sqlalchemy import inspect
71
+
72
+ mapper = inspect(model).mapper
73
+ attr = mapper.attrs.get(field_slug)
74
+ if attr is None:
75
+ msg = f'Field "{field_slug}" is not found on model "{model}"'
76
+ raise AttributeError(msg)
77
+
78
+ # RelationshipProperty
79
+ if hasattr(attr, 'mapper'):
80
+ return attr.mapper.class_
81
+
82
+ # ColumnProperty (FK column). Try to resolve from foreign key target table.
83
+ col = getattr(model, field_slug).property.columns[0]
84
+ if not col.foreign_keys:
85
+ msg = f'Field "{field_slug}" is not a relationship and not a FK column'
86
+ raise AttributeError(msg)
87
+
88
+ fk = next(iter(col.foreign_keys))
89
+ target_table = fk.column.table
90
+
91
+ # Find a mapped class that uses this table in the same registry
92
+ for m in mapper.registry.mappers:
93
+ if getattr(m, 'local_table', None) is target_table:
94
+ return m.class_
95
+
96
+ msg = f'Cannot resolve target model for FK "{field_slug}"'
97
+ raise AttributeError(msg)
98
+
99
+ async def autocomplete(self, model, data, user, *, extra: dict | None = None) -> List[Record]:
100
+ # pylint: disable=import-outside-toplevel
101
+ from sqlalchemy import select
102
+ from sqlalchemy.sql import expression
103
+
104
+ if extra is None or extra.get('db_session') is None:
105
+ msg = f'SQLAlchemyRelatedField.autocomplete {type(self).__name__} requires extra["db_session"] (AsyncSession)'
106
+ raise AttributeError(msg)
107
+
108
+ session = extra['db_session']
109
+
110
+ results = []
111
+
112
+ target_model = self._get_target_model(model, data.field_slug)
113
+ limit = min(150, data.limit)
114
+ stmt = select(target_model).limit(limit)
115
+
116
+ if data.search_string:
117
+ if hasattr(target_model, 'id'):
118
+ stmt = stmt.where(getattr(target_model, 'id') == data.search_string)
119
+
120
+ # Add already selected choices
121
+ existed_choices = []
122
+ if data.existed_choices:
123
+ existed_choices = [i['key'] for i in data.existed_choices if 'key' in i]
124
+
125
+ if existed_choices and hasattr(target_model, 'id'):
126
+ stmt = stmt.where(getattr(target_model, 'id').in_(existed_choices) | expression.true())
127
+
128
+ records = (await session.execute(stmt)).scalars().all()
129
+ for record in records:
130
+ results.append(Record(key=getattr(record, 'id'), title=str(record)))
131
+
132
+ return results
133
+
134
+ async def serialize(self, value, extra: dict, *args, **kwargs) -> Any:
135
+ """
136
+ Сериализация related-поля.
137
+
138
+ Входные данные:
139
+ - value всегда scalar (None или int)
140
+ - ORM-объект доступен через extra["record"]
141
+ """
142
+ record = extra.get('record')
143
+ if record is None:
144
+ raise FieldError(f'Missing record in serialize context in value: {value}')
145
+
146
+ if self.many:
147
+ related = getattr(record, self.rel_name, None)
148
+ if related is None:
149
+ raise FieldError(f'Related field "{self.rel_name}" is missing on record {record} (many=True)')
150
+
151
+ return [{'key': get_pk(obj), 'title': str(obj)} for obj in related]
152
+
153
+ related = getattr(record, self.rel_name, None)
154
+ if related is None:
155
+ raise FieldError(f'Related field "{self.rel_name}" is missing on record (many=False)')
156
+
157
+ return {'key': get_pk(related), 'title': str(related)}
158
+
159
+ async def deserialize(self, value, action: DeserializeAction, extra: dict, *args, **kwargs) -> Any:
160
+ value = await super().deserialize(value, action, extra, *args, **kwargs)
161
+ if not value:
162
+ return None
163
+
164
+ if isinstance(value, list):
165
+ result = []
166
+ for i in value:
167
+ i = i.get('key')
168
+ if not isinstance(i, (int, str)):
169
+ raise FieldError(f'Value "{i}" is not supported for related field')
170
+ result.append(i)
171
+ return result
172
+
173
+ result = None
174
+ if isinstance(value, dict) and 'key' in value:
175
+ result = value['key']
176
+
177
+ if isinstance(value, (int, str)):
178
+ result = value
179
+
180
+ if not isinstance(result, (int, str)):
181
+ raise FieldError(f'Value "{result}" is not supported for related field')
182
+
183
+ return result
184
+
185
+ async def update_related(self, record, field_slug, value, session):
186
+ """
187
+ Обновление SQLAlchemy relationship.
188
+
189
+ Предположения:
190
+ - self.rel_name всегда имя relationship
191
+ - self.target_model задан
192
+ - self.many отражает тип связи
193
+ """
194
+
195
+ # pylint: disable=import-outside-toplevel
196
+
197
+ if value is None:
198
+ return
199
+
200
+ # При CREATE объект должен быть в session до работы с relationship
201
+ if record not in session:
202
+ session.add(record)
203
+
204
+ rel_attr = self.rel_name
205
+
206
+ if self.many:
207
+ assert isinstance(value, list)
208
+
209
+ if not value:
210
+ setattr(record, rel_attr, [])
211
+ return
212
+
213
+ result = []
214
+ for i in value:
215
+ obj = await session.get(self.target_model, i)
216
+ if obj is None:
217
+ msg = _('related_not_found') % {
218
+ 'model': self.target_model.__name__,
219
+ 'pk': i,
220
+ 'field_slug': field_slug,
221
+ }
222
+ raise AdminAPIException(
223
+ APIError(message=msg, code='related_not_found'),
224
+ status_code=400,
225
+ )
226
+ result.append(obj)
227
+
228
+ # getattr(record, rel_attr).clear()
229
+ getattr(record, rel_attr).extend(list(result))
230
+ return
231
+
232
+ obj = await session.get(self.target_model, value)
233
+ setattr(record, rel_attr, obj)
234
+
235
+ async def apply_filter(self, stmt, value, model, column):
236
+ # pylint: disable=import-outside-toplevel
237
+ from sqlalchemy import inspect
238
+
239
+ if value is None:
240
+ return stmt
241
+
242
+ rel = getattr(model, self.rel_name)
243
+ pk_col = inspect(self.target_model).primary_key[0]
244
+
245
+ # many=False: FK (many-to-one)
246
+ if not self.many:
247
+ if not isinstance(value, int):
248
+ raise FieldError(f'Expected int for filter {self.rel_name}')
249
+ return stmt.where(rel.has(pk_col == value))
250
+
251
+ # many=True: one-to-many / many-to-many
252
+ if not isinstance(value, list):
253
+ raise FieldError(f'Expected list[int] for filter {self.rel_name}')
254
+ return stmt.where(rel.any(pk_col.in_(value)))
@@ -0,0 +1,316 @@
1
+ import datetime
2
+ from typing import Any
3
+
4
+ from admin_panel import schema
5
+ from admin_panel.exceptions import AdminAPIException, APIError
6
+ from admin_panel.integrations.sqlalchemy.fields import SQLAlchemyRelatedField
7
+ from admin_panel.schema.table.fields.base import DateTimeField
8
+ from admin_panel.translations import TranslateText as _
9
+ from admin_panel.utils import DeserializeAction, humanize_field_name
10
+
11
+ FIELD_FILTERS_NOT_FOUND = '{class_name} filter "{field_slug}" not found inside table_filters fields: {available_filters}'
12
+
13
+
14
+ class SQLAlchemyFieldsSchema(schema.FieldsSchema):
15
+ model: Any
16
+
17
+ def __init__(self, *args, model=None, **kwargs):
18
+ if model:
19
+ self.model = model
20
+
21
+ super().__init__(*args, **kwargs)
22
+
23
+ def generate_fields(self, kwargs) -> dict:
24
+ generated_fields = super().generate_fields(kwargs)
25
+
26
+ # pylint: disable=import-outside-toplevel
27
+ from sqlalchemy import inspect
28
+ from sqlalchemy.dialects.postgresql import ARRAY
29
+ from sqlalchemy.ext.mutable import Mutable
30
+ from sqlalchemy.sql import sqltypes
31
+ from sqlalchemy.sql.schema import Column
32
+
33
+ mapper = inspect(self.model).mapper
34
+
35
+ for attr in mapper.column_attrs:
36
+ col: Column = attr.columns[0]
37
+ field_slug = attr.key
38
+
39
+ if field_slug in generated_fields:
40
+ continue
41
+
42
+ field_data = {}
43
+ info = col.info or {}
44
+ field_data["label"] = info.get('label', humanize_field_name(field_slug))
45
+ field_data["help_text"] = info.get('help_text')
46
+
47
+ field_data["read_only"] = col.primary_key
48
+
49
+ # Whether the field is required on input (best-effort heuristic)
50
+ field_data["required"] = (
51
+ not col.nullable
52
+ and col.default is None
53
+ and col.server_default is None
54
+ and not col.primary_key
55
+ )
56
+
57
+ if "choices" in info:
58
+ field_data["choices"] = [(c[0], c[1]) for c in info["choices"]]
59
+
60
+ col_type = col.type
61
+ try:
62
+ py_t = col_type.python_type
63
+ except Exception:
64
+ py_t = None
65
+
66
+ impl = getattr(attr, 'impl', None)
67
+ is_mutable = isinstance(impl, Mutable)
68
+
69
+ # Foreign key column
70
+ if col.foreign_keys:
71
+ continue
72
+
73
+ elif isinstance(col_type, (sqltypes.BigInteger, sqltypes.Integer)) or py_t is int:
74
+ field_class = schema.IntegerField
75
+
76
+ elif isinstance(col_type, sqltypes.Numeric):
77
+ field_class = schema.IntegerField
78
+ field_data["inputmode"] = "decimal"
79
+ field_data["precision"] = col_type.precision
80
+ field_data["scale"] = col_type.scale
81
+
82
+ elif isinstance(col_type, sqltypes.String) or py_t is str:
83
+ field_class = schema.StringField
84
+ # Max length is usually stored as String(length=...)
85
+ if getattr(col_type, "length", None):
86
+ field_data["max_length"] = col_type.length
87
+
88
+ elif isinstance(col_type, sqltypes.DateTime) or py_t is datetime:
89
+ field_class = schema.DateTimeField
90
+
91
+ elif isinstance(col_type, sqltypes.Boolean) or py_t is bool:
92
+ field_class = schema.BooleanField
93
+
94
+ elif isinstance(col_type, sqltypes.JSON):
95
+ field_class = schema.JSONField
96
+
97
+ elif isinstance(col_type, ARRAY):
98
+ field_class = schema.ArrayField
99
+ field_data["array_type"] = type(col_type.item_type).__name__.lower()
100
+ field_data["read_only"] = not is_mutable
101
+
102
+ elif isinstance(col_type, sqltypes.NullType):
103
+ continue
104
+
105
+ elif not self.fields:
106
+ msg = f'SQLAlchemy autogenerate ORM field {self.model.__name__}.{field_slug} is not supported for type: {col_type}'
107
+ raise AttributeError(msg)
108
+
109
+ schema_field = field_class(**field_data)
110
+
111
+ if col.primary_key:
112
+ generated_fields = {field_slug: schema_field, **generated_fields}
113
+ else:
114
+ generated_fields[field_slug] = schema_field
115
+
116
+ for field_slug, field in self.generate_related_fields():
117
+ generated_fields[field_slug] = field
118
+
119
+ return generated_fields
120
+
121
+ def generate_related_fields(self):
122
+ # pylint: disable=import-outside-toplevel
123
+ from sqlalchemy import inspect
124
+
125
+ mapper = inspect(self.model).mapper
126
+
127
+ # relationship-поля
128
+ for rel in mapper.relationships:
129
+ field_slug = rel.key
130
+
131
+ field_data = {}
132
+
133
+ info = rel.info or {}
134
+ field_data["label"] = info.get('label', humanize_field_name(field_slug))
135
+ field_data["help_text"] = info.get('help_text')
136
+
137
+ field_data["read_only"] = rel.viewonly
138
+ field_data["required"] = (
139
+ not rel.uselist
140
+ and all(not col.nullable for col in rel.local_columns)
141
+ )
142
+
143
+ field_data["rel_name"] = rel.key
144
+ field_data["many"] = rel.uselist
145
+ field_data["dual_list"] = rel.uselist
146
+ field_data["target_model"] = rel.mapper.class_
147
+
148
+ yield field_slug, SQLAlchemyRelatedField(**field_data)
149
+
150
+ # FK-поля
151
+ for attr in mapper.column_attrs:
152
+ col = attr.columns[0]
153
+
154
+ if not col.foreign_keys:
155
+ continue
156
+
157
+ rel_obj = None
158
+ for rel in mapper.relationships:
159
+ if col in rel.local_columns:
160
+ rel_obj = rel
161
+ break
162
+
163
+ if not rel_obj:
164
+ continue
165
+
166
+ field_slug = attr.key
167
+
168
+ field_data = {}
169
+
170
+ info = col.info or {}
171
+ field_data["label"] = info.get('label', humanize_field_name(rel_obj.key))
172
+ field_data["help_text"] = info.get('help_text')
173
+
174
+ field_data["read_only"] = False
175
+ field_data["required"] = (
176
+ not col.nullable
177
+ and col.default is None
178
+ and col.server_default is None
179
+ and not col.primary_key
180
+ )
181
+
182
+ field_data["rel_name"] = rel_obj.key
183
+ field_data["many"] = rel_obj.uselist
184
+ field_data["target_model"] = rel_obj.mapper.class_
185
+
186
+ yield field_slug, SQLAlchemyRelatedField(**field_data)
187
+
188
+ async def apply_filters(self, stmt, filters: dict):
189
+ # pylint: disable=import-outside-toplevel
190
+ from sqlalchemy import String, cast
191
+
192
+ for field_slug in filters.keys():
193
+ field = self.get_field(field_slug)
194
+
195
+ if not field:
196
+ available_filters = list(self.get_fields().keys())
197
+ msg = FIELD_FILTERS_NOT_FOUND.format(
198
+ class_name=type(self).__name__,
199
+ field_slug=field_slug,
200
+ available_filters=available_filters,
201
+ )
202
+ raise AttributeError(msg)
203
+
204
+ deserialized_filters = await self.deserialize(
205
+ filters,
206
+ DeserializeAction.FILTERS,
207
+ extra={'model': self.model},
208
+ )
209
+
210
+ for field_slug, value in deserialized_filters.items():
211
+ field = self.get_field(field_slug)
212
+ column = getattr(self.model, field_slug, None)
213
+
214
+ apply_filter = getattr(field, 'apply_filter', None)
215
+ if apply_filter and callable(apply_filter):
216
+ stmt = await apply_filter(stmt, value, self.model, column)
217
+
218
+ elif issubclass(type(field), DateTimeField) and field.range:
219
+ stmt = stmt.where(column >= value['from'])
220
+ stmt = stmt.where(column <= value['to'])
221
+
222
+ elif isinstance(value, list):
223
+ stmt = stmt.where(column.in_(value))
224
+
225
+ elif isinstance(value, str):
226
+ stmt = stmt.where(
227
+ cast(column, String).like(value)
228
+ )
229
+
230
+ else:
231
+ stmt = stmt.where(column == value)
232
+
233
+ return stmt
234
+
235
+ async def serialize(self, record, extra: dict, *args, **kwargs) -> dict:
236
+ # pylint: disable=import-outside-toplevel
237
+ from sqlalchemy import inspect
238
+
239
+ # Convert model values to dict
240
+ record_data = {
241
+ attr.key: getattr(record, attr.key, None)
242
+ for attr in inspect(record).mapper.column_attrs
243
+ if self.get_field(attr.key)
244
+ }
245
+ return await super().serialize(record_data, extra, *args, **kwargs)
246
+
247
+ def validate_incoming_data(self, data):
248
+ '''
249
+ Validate that all fields keys has their schema
250
+
251
+ for create, update
252
+ '''
253
+ for field_slug in data.keys():
254
+ field = self.get_field(field_slug)
255
+ if not field:
256
+ available = list(self.get_fields().keys())
257
+ msg = _('field_not_found_in_schema') % {'field_slug': field_slug, 'available': available}
258
+ raise AdminAPIException(
259
+ APIError(message=msg, code='field_not_found_in_schema '),
260
+ status_code=400,
261
+ )
262
+
263
+ async def create(self, user, data, session):
264
+ self.validate_incoming_data(data)
265
+
266
+ record = self.model()
267
+
268
+ deserialized_data = await self.deserialize(
269
+ data,
270
+ DeserializeAction.CREATE,
271
+ extra={'model': self.model},
272
+ )
273
+
274
+ # сначала простые поля
275
+ for field_slug, value in deserialized_data.items():
276
+ field = self.get_field(field_slug)
277
+
278
+ if isinstance(field, SQLAlchemyRelatedField):
279
+ continue
280
+
281
+ setattr(record, field_slug, value)
282
+
283
+ session.add(record)
284
+
285
+ # затем related под no_autoflush
286
+ for field_slug, value in deserialized_data.items():
287
+ field = self.get_field(field_slug)
288
+
289
+ if isinstance(field, SQLAlchemyRelatedField):
290
+ with session.no_autoflush:
291
+ await field.update_related(record, field_slug, value, session)
292
+
293
+ await session.commit()
294
+ await session.refresh(record)
295
+ return record
296
+
297
+ async def update(self, record, user, data, session):
298
+ self.validate_incoming_data(data)
299
+
300
+ deserialized_data = await self.deserialize(
301
+ data,
302
+ DeserializeAction.UPDATE,
303
+ extra={'model': self.model},
304
+ )
305
+
306
+ for field_slug, value in deserialized_data.items():
307
+ field = self.get_field(field_slug)
308
+
309
+ if isinstance(field, SQLAlchemyRelatedField):
310
+ await field.update_related(record, field_slug, value, session)
311
+ continue
312
+
313
+ setattr(record, field_slug, value)
314
+
315
+ await session.commit()
316
+ return record
@@ -0,0 +1,19 @@
1
+ # pylint: disable=wildcard-import, unused-wildcard-import, unused-import, too-many-ancestors
2
+ # flake8: noqa: F405
3
+ from .base import SQLAlchemyAdminBase
4
+ from .create import SQLAlchemyAdminCreate
5
+ from .delete import SQLAlchemyDeleteAction
6
+ from .list import SQLAlchemyAdminListMixin
7
+ from .retrieve import SQLAlchemyAdminRetrieveMixin
8
+ from .update import SQLAlchemyAdminUpdate
9
+
10
+
11
+ class SQLAlchemyAdmin(
12
+ SQLAlchemyAdminUpdate,
13
+ SQLAlchemyAdminCreate,
14
+ SQLAlchemyDeleteAction,
15
+ SQLAlchemyAdminListMixin,
16
+ SQLAlchemyAdminRetrieveMixin,
17
+ SQLAlchemyAdminBase,
18
+ ):
19
+ pass