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.
- admin_panel/__init__.py +4 -0
- admin_panel/api/__init__.py +0 -0
- admin_panel/api/routers.py +18 -0
- admin_panel/api/utils.py +28 -0
- admin_panel/api/views/__init__.py +0 -0
- admin_panel/api/views/auth.py +29 -0
- admin_panel/api/views/autocomplete.py +33 -0
- admin_panel/api/views/graphs.py +30 -0
- admin_panel/api/views/index.py +38 -0
- admin_panel/api/views/schema.py +29 -0
- admin_panel/api/views/settings.py +29 -0
- admin_panel/api/views/table.py +136 -0
- admin_panel/auth.py +32 -0
- admin_panel/docs.py +37 -0
- admin_panel/exceptions.py +38 -0
- admin_panel/integrations/__init__.py +0 -0
- admin_panel/integrations/sqlalchemy/__init__.py +6 -0
- admin_panel/integrations/sqlalchemy/auth.py +144 -0
- admin_panel/integrations/sqlalchemy/autocomplete.py +38 -0
- admin_panel/integrations/sqlalchemy/fields.py +254 -0
- admin_panel/integrations/sqlalchemy/fields_schema.py +316 -0
- admin_panel/integrations/sqlalchemy/table/__init__.py +19 -0
- admin_panel/integrations/sqlalchemy/table/base.py +141 -0
- admin_panel/integrations/sqlalchemy/table/create.py +73 -0
- admin_panel/integrations/sqlalchemy/table/delete.py +18 -0
- admin_panel/integrations/sqlalchemy/table/list.py +178 -0
- admin_panel/integrations/sqlalchemy/table/retrieve.py +61 -0
- admin_panel/integrations/sqlalchemy/table/update.py +95 -0
- admin_panel/schema/__init__.py +7 -0
- admin_panel/schema/admin_schema.py +191 -0
- admin_panel/schema/category.py +149 -0
- admin_panel/schema/graphs/__init__.py +1 -0
- admin_panel/schema/graphs/category_graphs.py +50 -0
- admin_panel/schema/group.py +67 -0
- admin_panel/schema/table/__init__.py +8 -0
- admin_panel/schema/table/admin_action.py +76 -0
- admin_panel/schema/table/category_table.py +175 -0
- admin_panel/schema/table/fields/__init__.py +5 -0
- admin_panel/schema/table/fields/base.py +249 -0
- admin_panel/schema/table/fields/function_field.py +65 -0
- admin_panel/schema/table/fields_schema.py +216 -0
- admin_panel/schema/table/table_models.py +53 -0
- admin_panel/static/favicon.jpg +0 -0
- admin_panel/static/index-BeniOHDv.js +525 -0
- admin_panel/static/index-vlBToOhT.css +8 -0
- admin_panel/static/materialdesignicons-webfont-CYDMK1kx.woff2 +0 -0
- admin_panel/static/materialdesignicons-webfont-CgCzGbLl.woff +0 -0
- admin_panel/static/materialdesignicons-webfont-D3kAzl71.ttf +0 -0
- admin_panel/static/materialdesignicons-webfont-DttUABo4.eot +0 -0
- admin_panel/static/tinymce/dark-first/content.min.css +250 -0
- admin_panel/static/tinymce/dark-first/skin.min.css +2820 -0
- admin_panel/static/tinymce/dark-slim/content.min.css +249 -0
- admin_panel/static/tinymce/dark-slim/skin.min.css +2821 -0
- admin_panel/static/tinymce/img/example.png +0 -0
- admin_panel/static/tinymce/img/tinymce.woff2 +0 -0
- admin_panel/static/tinymce/lightgray/content.min.css +1 -0
- admin_panel/static/tinymce/lightgray/fonts/tinymce.woff +0 -0
- admin_panel/static/tinymce/lightgray/skin.min.css +1 -0
- admin_panel/static/tinymce/plugins/accordion/css/accordion.css +17 -0
- admin_panel/static/tinymce/plugins/accordion/plugin.js +48 -0
- admin_panel/static/tinymce/plugins/codesample/css/prism.css +1 -0
- admin_panel/static/tinymce/plugins/customLink/css/link.css +3 -0
- admin_panel/static/tinymce/plugins/customLink/plugin.js +147 -0
- admin_panel/static/tinymce/tinymce.min.js +2 -0
- admin_panel/static/vanilla-picker-B6E6ObS_.js +8 -0
- admin_panel/templates/index.html +25 -0
- admin_panel/translations.py +145 -0
- admin_panel/utils.py +50 -0
- brilliance_admin-0.42.0.dist-info/METADATA +155 -0
- brilliance_admin-0.42.0.dist-info/RECORD +73 -0
- brilliance_admin-0.42.0.dist-info/WHEEL +5 -0
- brilliance_admin-0.42.0.dist-info/licenses/LICENSE +17 -0
- 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
|