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.
Files changed (50) hide show
  1. dotorm/__init__.py +87 -0
  2. dotorm/access.py +151 -0
  3. dotorm/builder/__init__.py +0 -0
  4. dotorm/builder/builder.py +72 -0
  5. dotorm/builder/helpers.py +63 -0
  6. dotorm/builder/mixins/__init__.py +11 -0
  7. dotorm/builder/mixins/crud.py +246 -0
  8. dotorm/builder/mixins/m2m.py +110 -0
  9. dotorm/builder/mixins/relations.py +96 -0
  10. dotorm/builder/protocol.py +63 -0
  11. dotorm/builder/request_builder.py +144 -0
  12. dotorm/components/__init__.py +18 -0
  13. dotorm/components/dialect.py +99 -0
  14. dotorm/components/filter_parser.py +195 -0
  15. dotorm/databases/__init__.py +13 -0
  16. dotorm/databases/abstract/__init__.py +25 -0
  17. dotorm/databases/abstract/dialect.py +134 -0
  18. dotorm/databases/abstract/pool.py +10 -0
  19. dotorm/databases/abstract/session.py +67 -0
  20. dotorm/databases/abstract/types.py +36 -0
  21. dotorm/databases/clickhouse/__init__.py +8 -0
  22. dotorm/databases/clickhouse/pool.py +60 -0
  23. dotorm/databases/clickhouse/session.py +100 -0
  24. dotorm/databases/mysql/__init__.py +13 -0
  25. dotorm/databases/mysql/pool.py +69 -0
  26. dotorm/databases/mysql/session.py +128 -0
  27. dotorm/databases/mysql/transaction.py +39 -0
  28. dotorm/databases/postgres/__init__.py +23 -0
  29. dotorm/databases/postgres/pool.py +133 -0
  30. dotorm/databases/postgres/session.py +174 -0
  31. dotorm/databases/postgres/transaction.py +82 -0
  32. dotorm/decorators.py +379 -0
  33. dotorm/exceptions.py +9 -0
  34. dotorm/fields.py +604 -0
  35. dotorm/integrations/__init__.py +0 -0
  36. dotorm/integrations/pydantic.py +275 -0
  37. dotorm/model.py +802 -0
  38. dotorm/orm/__init__.py +15 -0
  39. dotorm/orm/mixins/__init__.py +13 -0
  40. dotorm/orm/mixins/access.py +67 -0
  41. dotorm/orm/mixins/ddl.py +250 -0
  42. dotorm/orm/mixins/many2many.py +175 -0
  43. dotorm/orm/mixins/primary.py +218 -0
  44. dotorm/orm/mixins/relations.py +513 -0
  45. dotorm/orm/protocol.py +147 -0
  46. dotorm/orm/utils.py +39 -0
  47. dotorm-2.0.8.dist-info/METADATA +1240 -0
  48. dotorm-2.0.8.dist-info/RECORD +50 -0
  49. dotorm-2.0.8.dist-info/WHEEL +4 -0
  50. 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