dotorm 2.0.8__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.
- dotorm/__init__.py +87 -0
- dotorm/access.py +151 -0
- dotorm/builder/__init__.py +0 -0
- dotorm/builder/builder.py +72 -0
- dotorm/builder/helpers.py +63 -0
- dotorm/builder/mixins/__init__.py +11 -0
- dotorm/builder/mixins/crud.py +246 -0
- dotorm/builder/mixins/m2m.py +110 -0
- dotorm/builder/mixins/relations.py +96 -0
- dotorm/builder/protocol.py +63 -0
- dotorm/builder/request_builder.py +144 -0
- dotorm/components/__init__.py +18 -0
- dotorm/components/dialect.py +99 -0
- dotorm/components/filter_parser.py +195 -0
- dotorm/databases/__init__.py +13 -0
- dotorm/databases/abstract/__init__.py +25 -0
- dotorm/databases/abstract/dialect.py +134 -0
- dotorm/databases/abstract/pool.py +10 -0
- dotorm/databases/abstract/session.py +67 -0
- dotorm/databases/abstract/types.py +36 -0
- dotorm/databases/clickhouse/__init__.py +8 -0
- dotorm/databases/clickhouse/pool.py +60 -0
- dotorm/databases/clickhouse/session.py +100 -0
- dotorm/databases/mysql/__init__.py +13 -0
- dotorm/databases/mysql/pool.py +69 -0
- dotorm/databases/mysql/session.py +128 -0
- dotorm/databases/mysql/transaction.py +39 -0
- dotorm/databases/postgres/__init__.py +23 -0
- dotorm/databases/postgres/pool.py +133 -0
- dotorm/databases/postgres/session.py +174 -0
- dotorm/databases/postgres/transaction.py +82 -0
- dotorm/decorators.py +379 -0
- dotorm/exceptions.py +9 -0
- dotorm/fields.py +604 -0
- dotorm/integrations/__init__.py +0 -0
- dotorm/integrations/pydantic.py +275 -0
- dotorm/model.py +802 -0
- dotorm/orm/__init__.py +15 -0
- dotorm/orm/mixins/__init__.py +13 -0
- dotorm/orm/mixins/access.py +67 -0
- dotorm/orm/mixins/ddl.py +250 -0
- dotorm/orm/mixins/many2many.py +175 -0
- dotorm/orm/mixins/primary.py +218 -0
- dotorm/orm/mixins/relations.py +513 -0
- dotorm/orm/protocol.py +147 -0
- dotorm/orm/utils.py +39 -0
- dotorm-2.0.8.dist-info/METADATA +1240 -0
- dotorm-2.0.8.dist-info/RECORD +50 -0
- dotorm-2.0.8.dist-info/WHEEL +4 -0
- dotorm-2.0.8.dist-info/licenses/LICENSE +21 -0
dotorm/model.py
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"""DotModel - main ORM model class."""
|
|
2
|
+
|
|
3
|
+
from abc import ABCMeta
|
|
4
|
+
import asyncio
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
import json
|
|
7
|
+
from types import UnionType
|
|
8
|
+
from typing import (
|
|
9
|
+
TYPE_CHECKING,
|
|
10
|
+
Annotated,
|
|
11
|
+
Any,
|
|
12
|
+
Awaitable,
|
|
13
|
+
Callable,
|
|
14
|
+
ClassVar,
|
|
15
|
+
Type,
|
|
16
|
+
Union,
|
|
17
|
+
dataclass_transform,
|
|
18
|
+
get_origin,
|
|
19
|
+
get_args,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
from .components.dialect import POSTGRES, Dialect
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .builder.builder import Builder
|
|
27
|
+
import aiomysql
|
|
28
|
+
import asyncpg
|
|
29
|
+
|
|
30
|
+
# import asynch
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# from .databases.mysql.session import (
|
|
34
|
+
# NoTransactionSession as MysqlNoTransactionSession,
|
|
35
|
+
# )
|
|
36
|
+
from .databases.postgres.session import (
|
|
37
|
+
NoTransactionSession as PostgresNoTransactionSession,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# from .databases.clickhouse.session import (
|
|
41
|
+
# NoTransactionSession as ClickhouseNoTransactionSession,
|
|
42
|
+
# )
|
|
43
|
+
|
|
44
|
+
from .fields import (
|
|
45
|
+
AttachmentOne2many,
|
|
46
|
+
Field,
|
|
47
|
+
JSONField,
|
|
48
|
+
Many2many,
|
|
49
|
+
Many2one,
|
|
50
|
+
One2many,
|
|
51
|
+
One2one,
|
|
52
|
+
AttachmentMany2one,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class JsonMode(IntEnum):
|
|
57
|
+
FORM = 1
|
|
58
|
+
LIST = 2
|
|
59
|
+
CREATE = 3
|
|
60
|
+
UPDATE = 4
|
|
61
|
+
NESTED_LIST = 5 # Для вложенных записей внутри FORM
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass_transform(kw_only_default=True, field_specifiers=(Field,))
|
|
65
|
+
class ModelMetaclass(ABCMeta): ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Import mixins here to avoid circular imports
|
|
69
|
+
from .orm.mixins.ddl import DDLMixin
|
|
70
|
+
from .orm.mixins.primary import OrmPrimaryMixin
|
|
71
|
+
from .orm.mixins.many2many import OrmMany2manyMixin
|
|
72
|
+
from .orm.mixins.relations import OrmRelationsMixin
|
|
73
|
+
from .orm.mixins.access import AccessMixin
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DotModel(
|
|
77
|
+
DDLMixin,
|
|
78
|
+
OrmRelationsMixin,
|
|
79
|
+
OrmMany2manyMixin,
|
|
80
|
+
OrmPrimaryMixin,
|
|
81
|
+
AccessMixin,
|
|
82
|
+
metaclass=ModelMetaclass,
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Main ORM model class.
|
|
86
|
+
|
|
87
|
+
Combines all functionality through mixins:
|
|
88
|
+
- OrmPrimaryMixin: Basic CRUD operations (create, get, update, delete)
|
|
89
|
+
- OrmMany2manyMixin: Many-to-many relation operations
|
|
90
|
+
- OrmRelationsMixin: Search and relation loading
|
|
91
|
+
- DDLMixin: Table creation (DDL operations)
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
from dotorm import DotModel, Integer, Char, Many2one
|
|
95
|
+
from dotorm.components import POSTGRES
|
|
96
|
+
|
|
97
|
+
class User(DotModel):
|
|
98
|
+
__table__ = "users"
|
|
99
|
+
_dialect = POSTGRES
|
|
100
|
+
|
|
101
|
+
id: int = Integer(primary_key=True)
|
|
102
|
+
name: str = Char(max_length=100)
|
|
103
|
+
role_id: int = Many2one(lambda: Role)
|
|
104
|
+
|
|
105
|
+
# CRUD operations
|
|
106
|
+
user = await User.get(1)
|
|
107
|
+
users = await User.search(fields=["id", "name"], limit=10)
|
|
108
|
+
new_id = await User.create(User(name="John"))
|
|
109
|
+
await user.update(User(name="Jane"))
|
|
110
|
+
await user.delete()
|
|
111
|
+
|
|
112
|
+
# DDL operations
|
|
113
|
+
await User.__create_table__()
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
# class variables (it is intended to be shared by all instances)
|
|
117
|
+
# name of table in database
|
|
118
|
+
__table__: ClassVar[str]
|
|
119
|
+
# create table in db
|
|
120
|
+
__auto_create__: ClassVar[bool] = True
|
|
121
|
+
# path name for route ednpoints CRUD
|
|
122
|
+
__route__: ClassVar[str]
|
|
123
|
+
# create CRUD endpoints automaticaly or not
|
|
124
|
+
__auto_crud__: ClassVar[bool] = False
|
|
125
|
+
# name database
|
|
126
|
+
__database__: ClassVar[str]
|
|
127
|
+
# pool of connections to database
|
|
128
|
+
# use for default usage in orm (without explicit set)
|
|
129
|
+
_pool: ClassVar["asyncpg.Pool | None"]
|
|
130
|
+
# class that implement no transaction execute
|
|
131
|
+
# single connection -> execute -> release connection to pool
|
|
132
|
+
# use for default usage in orm (without explicit set)
|
|
133
|
+
_no_transaction: Type[PostgresNoTransactionSession] = (
|
|
134
|
+
PostgresNoTransactionSession
|
|
135
|
+
)
|
|
136
|
+
# base validation schema for routers endpoints
|
|
137
|
+
# __schema__: ClassVar[Type]
|
|
138
|
+
__schema__: ClassVar[Annotated]
|
|
139
|
+
# variables for override auto created - update and create schemas
|
|
140
|
+
__schema_create__: ClassVar[Type]
|
|
141
|
+
__schema_read_output__: ClassVar[Type]
|
|
142
|
+
__schema_read_search_output__: ClassVar[Type]
|
|
143
|
+
__schema_read_search_input__: ClassVar[Type]
|
|
144
|
+
__schema_update__: ClassVar[Type]
|
|
145
|
+
__response_model_exclude__: ClassVar[set[str] | None] = None
|
|
146
|
+
# its auto
|
|
147
|
+
# __schema_output_search__: ClassVar[Type]
|
|
148
|
+
|
|
149
|
+
# id required field in any model
|
|
150
|
+
id: ClassVar[int]
|
|
151
|
+
|
|
152
|
+
_dialect: ClassVar[Dialect] = POSTGRES
|
|
153
|
+
_builder: ClassVar["Builder"]
|
|
154
|
+
|
|
155
|
+
def __init_subclass__(cls, **kwargs):
|
|
156
|
+
"""
|
|
157
|
+
1.Срабатывает один раз при определении подкласса,а не при каждом создании экземпляра
|
|
158
|
+
2.Позволяет устанавливать значения на уровне класса, а не объекта
|
|
159
|
+
3.__init_subclass__ — это правильный и "официальный"
|
|
160
|
+
способ кастомизировать поведение наследования
|
|
161
|
+
"""
|
|
162
|
+
# не забудем super на случай MRO
|
|
163
|
+
super().__init_subclass__(**kwargs)
|
|
164
|
+
# Здесь мы проверяем не hasattr, а __dict__,
|
|
165
|
+
# чтобы не словить унаследованный __route__
|
|
166
|
+
if "__table__" in cls.__dict__ and "__route__" not in cls.__dict__:
|
|
167
|
+
# установить имя роута такой же как имя модели по умолчанию
|
|
168
|
+
cls.__route__ = "/" + cls.__table__
|
|
169
|
+
|
|
170
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
171
|
+
# задаем переменные переданные с помощью kwargs
|
|
172
|
+
# instance variables (it is intended to be used by one instance)
|
|
173
|
+
for name, value in kwargs.items():
|
|
174
|
+
setattr(self, name, value)
|
|
175
|
+
|
|
176
|
+
# Десериализация JSON полей (если пришла строка из БД)
|
|
177
|
+
for name, field in self.get_fields().items():
|
|
178
|
+
if isinstance(field, JSONField):
|
|
179
|
+
value = getattr(self, name, None)
|
|
180
|
+
# Если значение - строка, десериализуем в dict/list
|
|
181
|
+
if isinstance(value, str):
|
|
182
|
+
try:
|
|
183
|
+
setattr(self, name, json.loads(value))
|
|
184
|
+
except (json.JSONDecodeError, TypeError):
|
|
185
|
+
pass # Оставляем как есть если не валидный JSON
|
|
186
|
+
|
|
187
|
+
# если поле вычисляемое и не хранящееся в БД то вычислить его
|
|
188
|
+
for name, field in self.get_fields().items():
|
|
189
|
+
if field.compute and not field.store:
|
|
190
|
+
setattr(self, name, field.compute(self))
|
|
191
|
+
|
|
192
|
+
# # Если есть функция вычисления (имя метода)
|
|
193
|
+
# if isinstance(field.compute, str):
|
|
194
|
+
# # Проверяем, существует ли метод
|
|
195
|
+
# method_name = field.compute
|
|
196
|
+
# if hasattr(self, method_name):
|
|
197
|
+
# # Вычисляем значение сразу
|
|
198
|
+
# method = getattr(self, method_name)
|
|
199
|
+
# if hasattr(method, "compute_deps"):
|
|
200
|
+
# # Для методов с зависимостями - не вычисляем сразу
|
|
201
|
+
# # а оставляем как не вычисленное
|
|
202
|
+
# pass
|
|
203
|
+
# else:
|
|
204
|
+
# # Вычисляем значение
|
|
205
|
+
# setattr(self, name, method())
|
|
206
|
+
# else:
|
|
207
|
+
# raise AttributeError(
|
|
208
|
+
# f"Method '{method_name}' not found for field '{name}'"
|
|
209
|
+
# )
|
|
210
|
+
|
|
211
|
+
# def __setattr__(self, name: str, value: Any) -> None:
|
|
212
|
+
# """Переопределяем для отслеживания изменений"""
|
|
213
|
+
# # Если это поле модели и оно изменилось
|
|
214
|
+
# if name in self.get_fields():
|
|
215
|
+
# field = self.get_fields()[name]
|
|
216
|
+
# if field.store: # Только если поле хранится в БД
|
|
217
|
+
# # Инвалидируем зависимые поля
|
|
218
|
+
# self._invalidate_dependent_fields(name)
|
|
219
|
+
# super().__setattr__(name, value)
|
|
220
|
+
|
|
221
|
+
# def _invalidate_dependent_fields(self, changed_field: str):
|
|
222
|
+
# """Инвалидировать все зависимые поля"""
|
|
223
|
+
# # Проходим по всем полям модели
|
|
224
|
+
# for field_name, field in self.get_fields().items():
|
|
225
|
+
# # Если это вычисляемое поле с зависимостями
|
|
226
|
+
# if field.compute and not field.store:
|
|
227
|
+
# method_name = field.compute
|
|
228
|
+
# if isinstance(method_name, str) and hasattr(self, method_name):
|
|
229
|
+
# method = getattr(self, method_name)
|
|
230
|
+
# # Проверяем, есть ли зависимости
|
|
231
|
+
# if hasattr(method, "compute_deps"):
|
|
232
|
+
# deps = method.compute_deps
|
|
233
|
+
# if changed_field in deps:
|
|
234
|
+
# # Устанавливаем флаг, что поле нужно пересчитать
|
|
235
|
+
# # Вместо удаления атрибута - устанавливаем флаг
|
|
236
|
+
# setattr(self, f"_{field_name}_computed", False)
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def _get_db_session(cls, session=None):
|
|
240
|
+
"""
|
|
241
|
+
Получить сессию БД.
|
|
242
|
+
|
|
243
|
+
Приоритет:
|
|
244
|
+
1. Явно переданная session
|
|
245
|
+
2. Сессия из контекста транзакции (contextvars)
|
|
246
|
+
3. NoTransaction сессия (автокоммит)
|
|
247
|
+
"""
|
|
248
|
+
if session is not None:
|
|
249
|
+
return session
|
|
250
|
+
|
|
251
|
+
# Проверяем контекст транзакции
|
|
252
|
+
from .databases.postgres.transaction import get_current_session
|
|
253
|
+
|
|
254
|
+
ctx_session = get_current_session()
|
|
255
|
+
if ctx_session is not None:
|
|
256
|
+
return ctx_session
|
|
257
|
+
|
|
258
|
+
# Fallback на NoTransaction
|
|
259
|
+
return cls._no_transaction(cls._pool)
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def prepare_form_ids(cls, rows: list[dict]):
|
|
263
|
+
"""Deserialize from list of dicts to list of objects."""
|
|
264
|
+
records = [cls.prepare_form_id([r]) for r in rows]
|
|
265
|
+
return records
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def prepare_form_id(cls, r: list):
|
|
269
|
+
"""Deserialize from dict to object."""
|
|
270
|
+
if not r:
|
|
271
|
+
return None
|
|
272
|
+
if len(r) > 1:
|
|
273
|
+
raise Exception("More than 1 record in form")
|
|
274
|
+
record = cls(**r[0])
|
|
275
|
+
return record
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def prepare_list_ids(cls, rows: list[dict]):
|
|
279
|
+
"""Десериализация из списка соварей в список объектов.
|
|
280
|
+
Используется при получении данных из БД"""
|
|
281
|
+
return [cls(**r) for r in rows]
|
|
282
|
+
|
|
283
|
+
@classmethod
|
|
284
|
+
def prepare_list_id(cls, r: list):
|
|
285
|
+
"""Десериализация из словаря в объект.
|
|
286
|
+
Используется при получении данных из БД.
|
|
287
|
+
Заменяет m2o с объекта Model на {id:Model}
|
|
288
|
+
Заменяет m2m и o2m с списка Model на list[{id:Model}]
|
|
289
|
+
"""
|
|
290
|
+
if len(r) != 1:
|
|
291
|
+
raise
|
|
292
|
+
record = cls(**r[0])
|
|
293
|
+
return record
|
|
294
|
+
|
|
295
|
+
@classmethod
|
|
296
|
+
def get_fields(cls) -> dict[str, Field]:
|
|
297
|
+
"""Возвращает только собственные поля класса (без унаследованных).
|
|
298
|
+
|
|
299
|
+
Для получения всех полей включая унаследованные используйте get_all_fields().
|
|
300
|
+
"""
|
|
301
|
+
return {
|
|
302
|
+
attr_name: attr
|
|
303
|
+
for attr_name, attr in cls.__dict__.items()
|
|
304
|
+
if isinstance(attr, Field)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def get_all_fields(cls) -> dict[str, Field]:
|
|
309
|
+
"""
|
|
310
|
+
Возвращает все поля модели, включая унаследованные из миксинов и родительских классов.
|
|
311
|
+
|
|
312
|
+
Использует MRO (Method Resolution Order) для сбора полей из всей цепочки наследования.
|
|
313
|
+
Поля из дочерних классов переопределяют поля из родительских.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
dict[str, Field]: Словарь {имя_поля: объект_Field}
|
|
317
|
+
|
|
318
|
+
Example:
|
|
319
|
+
class AuditMixin:
|
|
320
|
+
created_at = Datetime()
|
|
321
|
+
|
|
322
|
+
class Lead(AuditMixin, DotModel):
|
|
323
|
+
name = Char()
|
|
324
|
+
|
|
325
|
+
Lead.get_all_fields() # {'created_at': Datetime, 'name': Char}
|
|
326
|
+
Lead.get_fields() # {'name': Char} - только собственные
|
|
327
|
+
"""
|
|
328
|
+
fields = {}
|
|
329
|
+
for klass in reversed(cls.__mro__):
|
|
330
|
+
if klass is object:
|
|
331
|
+
continue
|
|
332
|
+
for attr_name, attr in klass.__dict__.items():
|
|
333
|
+
if isinstance(attr, Field):
|
|
334
|
+
fields[attr_name] = attr
|
|
335
|
+
return fields
|
|
336
|
+
|
|
337
|
+
@classmethod
|
|
338
|
+
def get_compute_fields(cls):
|
|
339
|
+
"""Только те поля, которые имеют связи. Ассоциации."""
|
|
340
|
+
return [
|
|
341
|
+
(name, field)
|
|
342
|
+
for name, field in cls.get_fields().items()
|
|
343
|
+
if field.compute
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
@classmethod
|
|
347
|
+
def get_relation_fields(cls):
|
|
348
|
+
"""Только те поля, которые имеют связи. Ассоциации."""
|
|
349
|
+
return [
|
|
350
|
+
(name, field)
|
|
351
|
+
for name, field in cls.get_fields().items()
|
|
352
|
+
if field.relation
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def get_relation_fields_m2m(cls):
|
|
357
|
+
"""Только те поля, которые имеют связи многие ко многим."""
|
|
358
|
+
return {
|
|
359
|
+
name: field
|
|
360
|
+
for name, field in cls.get_fields().items()
|
|
361
|
+
if isinstance(field, Many2many)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
@classmethod
|
|
365
|
+
def get_relation_fields_m2m_o2m(cls):
|
|
366
|
+
"""Только те поля, которые имеют связи многие ко многим или один ко многим."""
|
|
367
|
+
return [
|
|
368
|
+
(name, field)
|
|
369
|
+
for name, field in cls.get_fields().items()
|
|
370
|
+
if isinstance(
|
|
371
|
+
field, (Many2many, One2many, AttachmentOne2many, One2one)
|
|
372
|
+
)
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
def get_relation_fields_attachment(cls):
|
|
377
|
+
"""Только те поля, которые имеют связи m2o для вложений."""
|
|
378
|
+
return [
|
|
379
|
+
(name, field)
|
|
380
|
+
for name, field in cls.get_fields().items()
|
|
381
|
+
if isinstance(field, (AttachmentMany2one, AttachmentOne2many))
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
@classmethod
|
|
385
|
+
def get_store_fields(cls) -> list[str]:
|
|
386
|
+
"""Возвращает только те поля, которые хранятся в БД.
|
|
387
|
+
Поля, у которых store = False, не хранятся в бд.
|
|
388
|
+
По умолчанию все поля store = True, кроме One2many и Many2many
|
|
389
|
+
"""
|
|
390
|
+
return [
|
|
391
|
+
name for name, field in cls.get_fields().items() if field.store
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
@classmethod
|
|
395
|
+
def get_store_fields_omit_m2o(cls) -> list[str]:
|
|
396
|
+
"""Возвращает только те поля, которые хранятся в БД.
|
|
397
|
+
Поля, у которых store = False, не хранятся в бд.
|
|
398
|
+
По умолчанию все поля store = True, кроме One2many и Many2many.
|
|
399
|
+
Исключает m2o поля.
|
|
400
|
+
Используется при чтении связанного поля, для остановки вложенности.
|
|
401
|
+
"""
|
|
402
|
+
return [
|
|
403
|
+
name
|
|
404
|
+
for name, field in cls.get_fields().items()
|
|
405
|
+
if field.store and not isinstance(field, Many2one)
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
@classmethod
|
|
409
|
+
def get_store_fields_dict(cls) -> dict[str, Field]:
|
|
410
|
+
"""Возвращает только те поля, которые хранятся в БД.
|
|
411
|
+
Результат в виде dict"""
|
|
412
|
+
return {
|
|
413
|
+
name: field
|
|
414
|
+
for name, field in cls.get_fields().items()
|
|
415
|
+
if field.store
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
@classmethod
|
|
419
|
+
async def get_default_values(
|
|
420
|
+
cls, fields_client_nested: dict[str, list[str]]
|
|
421
|
+
) -> dict[str, Field]:
|
|
422
|
+
"""
|
|
423
|
+
fields_client_nested - словарь вложенных полей для полей m2m и o2m
|
|
424
|
+
|
|
425
|
+
Возвращает поля с установленным значением по умолчанию.
|
|
426
|
+
Используется при создании записи(сущности) на фронтенде. Например
|
|
427
|
+
мы создаем пользователя поле active у которого по умолчанию True.
|
|
428
|
+
"""
|
|
429
|
+
default_values = {}
|
|
430
|
+
for name, field in cls.get_fields().items():
|
|
431
|
+
# Для One2many и Many2many всегда возвращаем структуру x2m_default
|
|
432
|
+
if isinstance(field, (One2many, Many2many)):
|
|
433
|
+
fields_nested = fields_client_nested.get(name)
|
|
434
|
+
if fields_nested:
|
|
435
|
+
fields_info = field.relation_table.get_fields_info_list(
|
|
436
|
+
fields_nested
|
|
437
|
+
)
|
|
438
|
+
x2m_default = {
|
|
439
|
+
"data": [],
|
|
440
|
+
"fields": fields_info,
|
|
441
|
+
"total": 0,
|
|
442
|
+
}
|
|
443
|
+
default_values.update({name: x2m_default})
|
|
444
|
+
|
|
445
|
+
elif field.default is not None:
|
|
446
|
+
if callable(field.default):
|
|
447
|
+
# если корутина то сделать авейт
|
|
448
|
+
if asyncio.iscoroutinefunction(field.default):
|
|
449
|
+
res = await field.default()
|
|
450
|
+
default_values.update({name: res})
|
|
451
|
+
# иначе просто вызов
|
|
452
|
+
else:
|
|
453
|
+
default_values.update({name: field.default()})
|
|
454
|
+
else:
|
|
455
|
+
default_values.update({name: field.default})
|
|
456
|
+
|
|
457
|
+
return default_values
|
|
458
|
+
|
|
459
|
+
@classmethod
|
|
460
|
+
def get_none_update_fields_set(cls) -> set[str]:
|
|
461
|
+
"""Возвращает только те поля, которые не используются при обновлении.
|
|
462
|
+
1. Являются primary key (обычно id). (нельзя обновить ид)
|
|
463
|
+
2. Поля, у которых store = False, не хранятся в бд.
|
|
464
|
+
По умолчанию все поля store = True, кроме One2many и Many2many.
|
|
465
|
+
(нельзя обновить в БД то чего там нет)
|
|
466
|
+
3. Все relation поля, кроме many2one (так как это просто число, ид)
|
|
467
|
+
(нельзя обновить в БД то чего там нет, one2many)
|
|
468
|
+
"""
|
|
469
|
+
return {
|
|
470
|
+
name
|
|
471
|
+
for name, field in cls.get_fields().items()
|
|
472
|
+
if not field.store
|
|
473
|
+
or field.primary_key
|
|
474
|
+
or (field.relation and not isinstance(field, Many2one))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
@classmethod
|
|
478
|
+
def _is_field_required(cls, field_name: str, field: Field) -> bool:
|
|
479
|
+
"""
|
|
480
|
+
Определяет, является ли поле обязательным для API схемы.
|
|
481
|
+
|
|
482
|
+
Приоритет:
|
|
483
|
+
1. schema_required (если задан) — явное переопределение для схемы
|
|
484
|
+
2. Primary key — всегда необязателен (автогенерация)
|
|
485
|
+
3. required (если задан) — общая обязательность
|
|
486
|
+
4. Аннотация типа — автоопределение
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
field_name: Имя поля
|
|
490
|
+
field: Объект Field
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
True если поле обязательно в схеме, False иначе
|
|
494
|
+
"""
|
|
495
|
+
# 1. schema_required имеет высший приоритет для схемы
|
|
496
|
+
if field.schema_required is not None:
|
|
497
|
+
return field.schema_required
|
|
498
|
+
|
|
499
|
+
# 2. Primary key не требует ввода от пользователя
|
|
500
|
+
if field.primary_key:
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
# 3. Проверяем явно заданный атрибут required
|
|
504
|
+
if field.required is not None:
|
|
505
|
+
return field.required
|
|
506
|
+
|
|
507
|
+
# 4. Проверяем аннотацию типа
|
|
508
|
+
annotations = getattr(cls, "__annotations__", {})
|
|
509
|
+
if field_name not in annotations:
|
|
510
|
+
# Если аннотации нет, смотрим на null из поля
|
|
511
|
+
return not field.null
|
|
512
|
+
|
|
513
|
+
py_type = annotations[field_name]
|
|
514
|
+
|
|
515
|
+
# Строковая аннотация: "Model | None"
|
|
516
|
+
if isinstance(py_type, str):
|
|
517
|
+
if "None" in py_type:
|
|
518
|
+
return False
|
|
519
|
+
# Проверяем на list в строке
|
|
520
|
+
if py_type.startswith("list[") or py_type.startswith("List["):
|
|
521
|
+
return False
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
origin = get_origin(py_type)
|
|
525
|
+
|
|
526
|
+
# Списки не обязательны (One2many, Many2many)
|
|
527
|
+
if origin is list:
|
|
528
|
+
return False
|
|
529
|
+
|
|
530
|
+
# Union тип: проверяем наличие None
|
|
531
|
+
if origin is UnionType or origin is Union:
|
|
532
|
+
args = get_args(py_type)
|
|
533
|
+
if type(None) in args:
|
|
534
|
+
return False
|
|
535
|
+
return True
|
|
536
|
+
|
|
537
|
+
# Простой тип без None - обязателен
|
|
538
|
+
return True
|
|
539
|
+
|
|
540
|
+
@classmethod
|
|
541
|
+
def get_fields_info_list(cls, fields_list: list[str]):
|
|
542
|
+
"""Get field info for list view."""
|
|
543
|
+
fields_info = []
|
|
544
|
+
for name, field in cls.get_fields().items():
|
|
545
|
+
if name in fields_list:
|
|
546
|
+
required = cls._is_field_required(name, field)
|
|
547
|
+
if field.relation:
|
|
548
|
+
fields_info.append(
|
|
549
|
+
{
|
|
550
|
+
"name": name,
|
|
551
|
+
"type": field.__class__.__name__,
|
|
552
|
+
"relation": (
|
|
553
|
+
field.relation_table.__table__
|
|
554
|
+
if field.relation_table
|
|
555
|
+
else ""
|
|
556
|
+
),
|
|
557
|
+
"required": required,
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
else:
|
|
561
|
+
fields_info.append(
|
|
562
|
+
{
|
|
563
|
+
"name": name,
|
|
564
|
+
"type": field.__class__.__name__,
|
|
565
|
+
"options": field.options or [],
|
|
566
|
+
"required": required,
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
return fields_info
|
|
570
|
+
|
|
571
|
+
@classmethod
|
|
572
|
+
def get_fields_info_form(cls, fields_list: list[str]):
|
|
573
|
+
"""Get field info for form view."""
|
|
574
|
+
fields_info = []
|
|
575
|
+
for name, field in cls.get_fields().items():
|
|
576
|
+
if name in fields_list:
|
|
577
|
+
required = cls._is_field_required(name, field)
|
|
578
|
+
if field.relation:
|
|
579
|
+
fields_info.append(
|
|
580
|
+
{
|
|
581
|
+
"name": name,
|
|
582
|
+
"type": field.__class__.__name__,
|
|
583
|
+
"relatedModel": (
|
|
584
|
+
field.relation_table.__table__
|
|
585
|
+
if field.relation_table
|
|
586
|
+
else ""
|
|
587
|
+
),
|
|
588
|
+
"relatedField": (field.relation_table_field or ""),
|
|
589
|
+
"required": required,
|
|
590
|
+
}
|
|
591
|
+
)
|
|
592
|
+
else:
|
|
593
|
+
fields_info.append(
|
|
594
|
+
{
|
|
595
|
+
"name": name,
|
|
596
|
+
"type": field.__class__.__name__,
|
|
597
|
+
"options": field.options or [],
|
|
598
|
+
"required": required,
|
|
599
|
+
}
|
|
600
|
+
)
|
|
601
|
+
return fields_info
|
|
602
|
+
|
|
603
|
+
def get_json(
|
|
604
|
+
self, exclude_unset=False, only_store=None, mode=JsonMode.LIST
|
|
605
|
+
):
|
|
606
|
+
"""Возвращает все поля модели.
|
|
607
|
+
Для экземпляра класса. В экземпляре поля (класс Field)
|
|
608
|
+
преобразуются в реальные данные например Integer -> int"""
|
|
609
|
+
fields_json = {}
|
|
610
|
+
# fields - это поля описанные в модели (классе)
|
|
611
|
+
if only_store:
|
|
612
|
+
fields = self.get_store_fields_dict().items()
|
|
613
|
+
else:
|
|
614
|
+
fields = self.get_fields().items()
|
|
615
|
+
|
|
616
|
+
for field_name, field_class in fields:
|
|
617
|
+
# field - это поле из экземпляра.
|
|
618
|
+
# 1. оно может содержать данные, если задано.
|
|
619
|
+
# 2. оно может содержать класс Field, если не задано.
|
|
620
|
+
field = getattr(self, field_name)
|
|
621
|
+
|
|
622
|
+
# НЕ ЗАДАНО
|
|
623
|
+
# если поле экземпляра класса, осталось классом Field
|
|
624
|
+
# это значит что оно не было считано из БД
|
|
625
|
+
if isinstance(field, Field):
|
|
626
|
+
# если установлен флаг исключить не заданные,
|
|
627
|
+
# то ничего не делать
|
|
628
|
+
if not exclude_unset:
|
|
629
|
+
# иначе взять значение по умолчанию или None
|
|
630
|
+
if field.default is not None:
|
|
631
|
+
# если default - callable (лямбда или функция), вызываем её
|
|
632
|
+
if callable(field.default):
|
|
633
|
+
fields_json[field_name] = field.default()
|
|
634
|
+
else:
|
|
635
|
+
fields_json[field_name] = field.default
|
|
636
|
+
else:
|
|
637
|
+
fields_json[field_name] = None
|
|
638
|
+
|
|
639
|
+
# ЗАДАНО как many2one
|
|
640
|
+
# если поле является моделью то это many2one
|
|
641
|
+
elif isinstance(field, DotModel):
|
|
642
|
+
if mode == JsonMode.LIST:
|
|
643
|
+
# обрубаем, исключаем все релейшен поля
|
|
644
|
+
fields_json[field_name] = {
|
|
645
|
+
"id": field.id,
|
|
646
|
+
"name": getattr(field, "name", str(field.id)),
|
|
647
|
+
}
|
|
648
|
+
elif mode == JsonMode.FORM:
|
|
649
|
+
fields_json[field_name] = field.json()
|
|
650
|
+
elif mode == JsonMode.CREATE or mode == JsonMode.UPDATE:
|
|
651
|
+
fields_json[field_name] = field.id
|
|
652
|
+
|
|
653
|
+
# ЗАДАНО как many2many или one2many
|
|
654
|
+
elif isinstance(
|
|
655
|
+
field_class, (Many2many, One2many, AttachmentOne2many)
|
|
656
|
+
):
|
|
657
|
+
if mode == JsonMode.LIST:
|
|
658
|
+
# При search: field это list
|
|
659
|
+
fields_json[field_name] = [
|
|
660
|
+
{
|
|
661
|
+
"id": rec.id,
|
|
662
|
+
"name": rec.name or str(rec.id),
|
|
663
|
+
}
|
|
664
|
+
for rec in field
|
|
665
|
+
]
|
|
666
|
+
elif mode == JsonMode.NESTED_LIST:
|
|
667
|
+
# Вложенная сериализация из FORM: field это dict с data
|
|
668
|
+
fields_json[field_name] = [
|
|
669
|
+
{
|
|
670
|
+
"id": rec.id,
|
|
671
|
+
"name": rec.name or str(rec.id),
|
|
672
|
+
}
|
|
673
|
+
for rec in field["data"]
|
|
674
|
+
]
|
|
675
|
+
elif mode == JsonMode.FORM:
|
|
676
|
+
# При FORM (get) field это dict с data/fields/total
|
|
677
|
+
fields_json[field_name] = {
|
|
678
|
+
"data": [
|
|
679
|
+
rec.json(mode=JsonMode.NESTED_LIST)
|
|
680
|
+
for rec in field["data"]
|
|
681
|
+
],
|
|
682
|
+
"fields": field["fields"],
|
|
683
|
+
"total": field["total"],
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
# Сериализуем JSONField в строку при записи в БД
|
|
687
|
+
elif (
|
|
688
|
+
only_store
|
|
689
|
+
and isinstance(field_class, JSONField)
|
|
690
|
+
and isinstance(field, (dict, list))
|
|
691
|
+
):
|
|
692
|
+
fields_json[field_name] = json.dumps(field, ensure_ascii=False)
|
|
693
|
+
# ЗАДАНО как значение (число строка время...)
|
|
694
|
+
# иначе поле считается прочитанным из БД и просто пробрасывается
|
|
695
|
+
else:
|
|
696
|
+
fields_json[field_name] = field
|
|
697
|
+
return fields_json
|
|
698
|
+
|
|
699
|
+
def json(
|
|
700
|
+
self,
|
|
701
|
+
include={},
|
|
702
|
+
exclude={},
|
|
703
|
+
exclude_none=False,
|
|
704
|
+
exclude_unset=False,
|
|
705
|
+
only_store=None,
|
|
706
|
+
mode=JsonMode.LIST,
|
|
707
|
+
):
|
|
708
|
+
"""Сериализация экземпляра модели в dict python.
|
|
709
|
+
|
|
710
|
+
Keyword Arguments:
|
|
711
|
+
include -- только эти поля
|
|
712
|
+
exclude -- исключить поля
|
|
713
|
+
exclude_none -- исключить поля со значением None
|
|
714
|
+
only_store -- только те поля, которые хранятьсь в БД
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
python dict
|
|
718
|
+
"""
|
|
719
|
+
record = self.get_json(exclude_unset, only_store, mode)
|
|
720
|
+
if include:
|
|
721
|
+
record = {k: v for k, v in record.items() if k in include}
|
|
722
|
+
if exclude:
|
|
723
|
+
record = {k: v for k, v in record.items() if k not in exclude}
|
|
724
|
+
if exclude_none:
|
|
725
|
+
record = {k: v for k, v in record.items() if v is not None}
|
|
726
|
+
return record
|
|
727
|
+
|
|
728
|
+
@classmethod
|
|
729
|
+
def get_onchange_fields(cls) -> list[str]:
|
|
730
|
+
"""
|
|
731
|
+
Получить список полей у которых есть onchange обработчики.
|
|
732
|
+
|
|
733
|
+
Используется фронтендом для определения за какими полями следить.
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
Список имён полей с onchange обработчиками
|
|
737
|
+
"""
|
|
738
|
+
fields_with_onchange = set()
|
|
739
|
+
|
|
740
|
+
for attr_name in dir(cls):
|
|
741
|
+
# Пропускаем dunder методы
|
|
742
|
+
if attr_name.startswith("__"):
|
|
743
|
+
continue
|
|
744
|
+
attr = getattr(cls, attr_name, None)
|
|
745
|
+
if attr and callable(attr) and hasattr(attr, "_is_onchange"):
|
|
746
|
+
onchange_fields = getattr(attr, "_onchange_fields", ())
|
|
747
|
+
fields_with_onchange.update(onchange_fields)
|
|
748
|
+
|
|
749
|
+
return list(fields_with_onchange)
|
|
750
|
+
|
|
751
|
+
@classmethod
|
|
752
|
+
def _get_onchange_handlers(cls, field_name: str) -> list[str]:
|
|
753
|
+
"""
|
|
754
|
+
Получить список методов-обработчиков для указанного поля.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
field_name: Имя поля
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
Список имён методов-обработчиков
|
|
761
|
+
"""
|
|
762
|
+
handlers = []
|
|
763
|
+
|
|
764
|
+
for attr_name in dir(cls):
|
|
765
|
+
# Пропускаем dunder методы
|
|
766
|
+
if attr_name.startswith("__"):
|
|
767
|
+
continue
|
|
768
|
+
attr = getattr(cls, attr_name, None)
|
|
769
|
+
if attr and callable(attr) and hasattr(attr, "_is_onchange"):
|
|
770
|
+
onchange_fields = getattr(attr, "_onchange_fields", ())
|
|
771
|
+
if field_name in onchange_fields:
|
|
772
|
+
handlers.append(attr_name)
|
|
773
|
+
|
|
774
|
+
return handlers
|
|
775
|
+
|
|
776
|
+
async def execute_onchange(self, field_name: str) -> dict:
|
|
777
|
+
"""
|
|
778
|
+
Выполнить все onchange обработчики для указанного поля.
|
|
779
|
+
|
|
780
|
+
Перед вызовом self должен быть заполнен текущими значениями формы.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
field_name: Имя изменённого поля
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
Объединённый dict со значениями для обновления формы
|
|
787
|
+
"""
|
|
788
|
+
result = {}
|
|
789
|
+
handlers = self._get_onchange_handlers(field_name)
|
|
790
|
+
|
|
791
|
+
for handler_name in handlers:
|
|
792
|
+
handler: Awaitable | None = getattr(self, handler_name, None)
|
|
793
|
+
if handler and callable(handler):
|
|
794
|
+
handler_result = await handler()
|
|
795
|
+
if handler_result:
|
|
796
|
+
result.update(handler_result)
|
|
797
|
+
|
|
798
|
+
return result
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# Backward compatibility alias
|
|
802
|
+
Model = DotModel
|