fastapi-rtk 0.2.27__py3-none-any.whl → 1.0.13__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.
- fastapi_rtk/__init__.py +39 -35
- fastapi_rtk/_version.py +1 -0
- fastapi_rtk/api/model_rest_api.py +476 -221
- fastapi_rtk/auth/auth.py +0 -9
- fastapi_rtk/backends/generic/__init__.py +6 -0
- fastapi_rtk/backends/generic/column.py +21 -12
- fastapi_rtk/backends/generic/db.py +42 -7
- fastapi_rtk/backends/generic/filters.py +21 -16
- fastapi_rtk/backends/generic/interface.py +14 -8
- fastapi_rtk/backends/generic/model.py +19 -11
- fastapi_rtk/backends/sqla/__init__.py +1 -0
- fastapi_rtk/backends/sqla/db.py +77 -17
- fastapi_rtk/backends/sqla/extensions/audit/audit.py +401 -189
- fastapi_rtk/backends/sqla/extensions/geoalchemy2/filters.py +15 -12
- fastapi_rtk/backends/sqla/filters.py +50 -21
- fastapi_rtk/backends/sqla/interface.py +96 -34
- fastapi_rtk/backends/sqla/model.py +56 -39
- fastapi_rtk/bases/__init__.py +20 -0
- fastapi_rtk/bases/db.py +94 -7
- fastapi_rtk/bases/file_manager.py +47 -3
- fastapi_rtk/bases/filter.py +22 -0
- fastapi_rtk/bases/interface.py +49 -5
- fastapi_rtk/bases/model.py +3 -0
- fastapi_rtk/bases/session.py +2 -0
- fastapi_rtk/cli/cli.py +62 -9
- fastapi_rtk/cli/commands/__init__.py +23 -0
- fastapi_rtk/cli/{db.py → commands/db/__init__.py} +107 -50
- fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/env.py +2 -3
- fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/env.py +10 -9
- fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/script.py.mako +3 -1
- fastapi_rtk/cli/{export.py → commands/export.py} +12 -10
- fastapi_rtk/cli/{security.py → commands/security.py} +73 -7
- fastapi_rtk/cli/commands/translate.py +299 -0
- fastapi_rtk/cli/decorators.py +9 -4
- fastapi_rtk/cli/utils.py +46 -0
- fastapi_rtk/config.py +41 -1
- fastapi_rtk/const.py +29 -1
- fastapi_rtk/db.py +76 -40
- fastapi_rtk/decorators.py +1 -1
- fastapi_rtk/dependencies.py +134 -62
- fastapi_rtk/exceptions.py +51 -1
- fastapi_rtk/fastapi_react_toolkit.py +186 -171
- fastapi_rtk/file_managers/file_manager.py +8 -6
- fastapi_rtk/file_managers/s3_file_manager.py +69 -33
- fastapi_rtk/globals.py +22 -12
- fastapi_rtk/lang/__init__.py +3 -0
- fastapi_rtk/lang/babel/__init__.py +4 -0
- fastapi_rtk/lang/babel/cli.py +40 -0
- fastapi_rtk/lang/babel/config.py +17 -0
- fastapi_rtk/lang/babel.cfg +1 -0
- fastapi_rtk/lang/lazy_text.py +120 -0
- fastapi_rtk/lang/messages.pot +238 -0
- fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
- fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +248 -0
- fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
- fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +244 -0
- fastapi_rtk/manager.py +355 -37
- fastapi_rtk/mixins.py +12 -0
- fastapi_rtk/routers.py +208 -72
- fastapi_rtk/schemas.py +142 -39
- fastapi_rtk/security/sqla/apis.py +39 -13
- fastapi_rtk/security/sqla/models.py +8 -23
- fastapi_rtk/security/sqla/security_manager.py +369 -11
- fastapi_rtk/setting.py +446 -88
- fastapi_rtk/types.py +94 -27
- fastapi_rtk/utils/__init__.py +8 -0
- fastapi_rtk/utils/async_task_runner.py +286 -61
- fastapi_rtk/utils/csv_json_converter.py +243 -40
- fastapi_rtk/utils/hooks.py +34 -0
- fastapi_rtk/utils/merge_schema.py +3 -3
- fastapi_rtk/utils/multiple_async_contexts.py +21 -0
- fastapi_rtk/utils/pydantic.py +46 -1
- fastapi_rtk/utils/run_utils.py +31 -1
- fastapi_rtk/utils/self_dependencies.py +1 -1
- fastapi_rtk/utils/use_default_when_none.py +1 -1
- fastapi_rtk/version.py +6 -1
- fastapi_rtk-1.0.13.dist-info/METADATA +28 -0
- fastapi_rtk-1.0.13.dist-info/RECORD +133 -0
- {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/WHEEL +1 -2
- fastapi_rtk/backends/gremlinpython/__init__.py +0 -108
- fastapi_rtk/backends/gremlinpython/column.py +0 -208
- fastapi_rtk/backends/gremlinpython/db.py +0 -228
- fastapi_rtk/backends/gremlinpython/exceptions.py +0 -34
- fastapi_rtk/backends/gremlinpython/filters.py +0 -461
- fastapi_rtk/backends/gremlinpython/interface.py +0 -734
- fastapi_rtk/backends/gremlinpython/model.py +0 -364
- fastapi_rtk/backends/gremlinpython/session.py +0 -23
- fastapi_rtk/cli/commands.py +0 -295
- fastapi_rtk-0.2.27.dist-info/METADATA +0 -23
- fastapi_rtk-0.2.27.dist-info/RECORD +0 -126
- fastapi_rtk-0.2.27.dist-info/top_level.txt +0 -1
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/README +0 -0
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/alembic.ini.mako +0 -0
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/script.py.mako +0 -0
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/README +0 -0
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/alembic.ini.mako +0 -0
- {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/entry_points.txt +0 -0
- {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -6,25 +6,28 @@ import pydantic
|
|
|
6
6
|
import sqlalchemy.dialects.postgresql
|
|
7
7
|
import sqlalchemy.event
|
|
8
8
|
import sqlalchemy.orm
|
|
9
|
-
from sqlalchemy import MetaData, Sequence
|
|
10
9
|
|
|
11
10
|
from .....const import logger
|
|
12
11
|
from .....db import db
|
|
12
|
+
from .....exceptions import raise_exception
|
|
13
13
|
from .....globals import g
|
|
14
14
|
from .....security.sqla.models import User
|
|
15
15
|
from .....utils import (
|
|
16
|
+
AsyncTaskRunner,
|
|
16
17
|
class_factory_from_dict,
|
|
18
|
+
get_pydantic_model_field,
|
|
17
19
|
lazy,
|
|
18
20
|
prettify_dict,
|
|
19
21
|
run_coroutine_in_threadpool,
|
|
20
22
|
safe_call_sync,
|
|
21
23
|
smart_run,
|
|
22
24
|
smartdefaultdict,
|
|
25
|
+
use_default_when_none,
|
|
23
26
|
)
|
|
24
27
|
from ...column import mapped_column
|
|
25
28
|
from ...interface import SQLAInterface
|
|
26
29
|
from ...model import Model
|
|
27
|
-
from ...session import
|
|
30
|
+
from ...session import SQLASession
|
|
28
31
|
from .types import AuditEntry, AuditOperation, SQLAModel
|
|
29
32
|
|
|
30
33
|
__all__ = ["audit_model_factory", "Audit"]
|
|
@@ -57,14 +60,16 @@ def audit_model_factory(
|
|
|
57
60
|
attrs = {
|
|
58
61
|
"__tablename__": BASE_AUDIT_TABLE_NAME,
|
|
59
62
|
"id": mapped_column(
|
|
60
|
-
sqlalchemy.Integer,
|
|
63
|
+
sqlalchemy.Integer,
|
|
64
|
+
sqlalchemy.Sequence(BASE_AUDIT_SEQUENCE),
|
|
65
|
+
primary_key=True,
|
|
61
66
|
),
|
|
62
67
|
"created": mapped_column(
|
|
63
68
|
sqlalchemy.DateTime(timezone=True),
|
|
64
69
|
nullable=False,
|
|
65
70
|
default=lambda: datetime.datetime.now(datetime.timezone.utc),
|
|
66
71
|
),
|
|
67
|
-
"
|
|
72
|
+
"table_name": mapped_column(sqlalchemy.String(256), nullable=False),
|
|
68
73
|
"table_id": mapped_column(sqlalchemy.String(1024), nullable=False),
|
|
69
74
|
"operation": mapped_column(sqlalchemy.String(256), nullable=False),
|
|
70
75
|
"data": mapped_column(
|
|
@@ -84,13 +89,15 @@ def audit_model_factory(
|
|
|
84
89
|
),
|
|
85
90
|
"created_by": sqlalchemy.orm.relationship(User),
|
|
86
91
|
"__table_args__": (
|
|
87
|
-
sqlalchemy.Index(
|
|
92
|
+
sqlalchemy.Index(
|
|
93
|
+
"ix_audit_table_id", "table_name", "table_id", unique=False
|
|
94
|
+
),
|
|
88
95
|
),
|
|
89
96
|
"__repr__": lambda self: prettify_dict(
|
|
90
97
|
{
|
|
91
98
|
"id": self.id,
|
|
92
99
|
"created": self.created,
|
|
93
|
-
"
|
|
100
|
+
"table_name": self.table_name,
|
|
94
101
|
"table_id": self.table_id,
|
|
95
102
|
"operation": self.operation,
|
|
96
103
|
"data": self.data,
|
|
@@ -173,6 +180,16 @@ class Audit:
|
|
|
173
180
|
"""
|
|
174
181
|
Dictionary mapping models to properties that should be excluded from auditing.
|
|
175
182
|
"""
|
|
183
|
+
mapping_insert_updates_when_no_changes = collections.defaultdict[
|
|
184
|
+
typing.Type[SQLAModel], bool | None
|
|
185
|
+
](lambda: None)
|
|
186
|
+
"""
|
|
187
|
+
Dictionary mapping models to a flag indicating whether to insert audit entries for updates even when there are no changes if the model is dirty.
|
|
188
|
+
"""
|
|
189
|
+
mapping_insert_updates_when_no_changes_default = False
|
|
190
|
+
"""
|
|
191
|
+
Default value for `mapping_insert_updates_when_no_changes` if not set for a specific model.
|
|
192
|
+
"""
|
|
176
193
|
callbacks = list[
|
|
177
194
|
typing.Callable[
|
|
178
195
|
[AuditEntry], None | typing.Coroutine[typing.Any, typing.Any, None]
|
|
@@ -181,6 +198,14 @@ class Audit:
|
|
|
181
198
|
"""
|
|
182
199
|
List of callbacks to be called after adding the audit to the database.
|
|
183
200
|
"""
|
|
201
|
+
scheduler = lazy(
|
|
202
|
+
lambda cls: raise_exception(
|
|
203
|
+
f"{cls.__name__}.scheduler is not configured. Please call `{cls.__name__}.run_in_background()` to configure it."
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
"""
|
|
207
|
+
The scheduler for running audit inserts and callbacks in the background.
|
|
208
|
+
"""
|
|
184
209
|
|
|
185
210
|
_session_callbacks_after_flush_postexec = collections.defaultdict[
|
|
186
211
|
sqlalchemy.orm.Session,
|
|
@@ -200,6 +225,14 @@ class Audit:
|
|
|
200
225
|
"""
|
|
201
226
|
Dictionary mapping SQLAlchemy sessions to their commit callbacks.
|
|
202
227
|
"""
|
|
228
|
+
_session_is_commited = collections.defaultdict[SQLASession, bool](bool)
|
|
229
|
+
"""
|
|
230
|
+
Dictionary mapping SQLAlchemy sessions to a flag indicating if they have been committed.
|
|
231
|
+
"""
|
|
232
|
+
_run_in_background = False
|
|
233
|
+
"""
|
|
234
|
+
Whether to insert audit entries and run callbacks in the background.
|
|
235
|
+
"""
|
|
203
236
|
_configured = False
|
|
204
237
|
"""
|
|
205
238
|
Flag to indicate if the audit class has been configured.
|
|
@@ -237,6 +270,7 @@ class Audit:
|
|
|
237
270
|
*,
|
|
238
271
|
include: list[str] | str | None = None,
|
|
239
272
|
exclude: list[str] | str | None = None,
|
|
273
|
+
insert_updates_when_no_changes: bool | None = None,
|
|
240
274
|
):
|
|
241
275
|
"""
|
|
242
276
|
Decorator to register a model and its subclasses for auditing.
|
|
@@ -245,6 +279,7 @@ class Audit:
|
|
|
245
279
|
model_cls (typing.Type[SQLAModel]): The SQLAlchemy model class to audit.
|
|
246
280
|
include (list[str] | str | None, optional): List of property names to include in the audit. Defaults to None.
|
|
247
281
|
exclude (list[str] | str | None, optional): List of property names to exclude from the audit. Defaults to None.
|
|
282
|
+
insert_updates_when_no_changes (bool | None, optional): Whether to insert audit entries for updates even when there are no changes if the model is dirty. Defaults to None.
|
|
248
283
|
|
|
249
284
|
Returns:
|
|
250
285
|
typing.Type[SQLAModel]: The audited model class.
|
|
@@ -266,20 +301,25 @@ class Audit:
|
|
|
266
301
|
updated = mapped_column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now())
|
|
267
302
|
```
|
|
268
303
|
"""
|
|
269
|
-
if include or exclude:
|
|
304
|
+
if include or exclude or insert_updates_when_no_changes is not None:
|
|
270
305
|
|
|
271
306
|
def decorator(model_cls: typing.Type[SQLAModel]) -> typing.Type[SQLAModel]:
|
|
272
307
|
model_cls = cls.audit_model(model_cls)
|
|
273
308
|
params = [
|
|
274
|
-
(include, cls.include_properties),
|
|
275
|
-
(exclude, cls.exclude_properties),
|
|
309
|
+
(include, {}, cls.include_properties),
|
|
310
|
+
(exclude, {}, cls.exclude_properties),
|
|
311
|
+
(
|
|
312
|
+
insert_updates_when_no_changes,
|
|
313
|
+
{"decorator": True},
|
|
314
|
+
cls.insert_updates_when_no_changes,
|
|
315
|
+
),
|
|
276
316
|
]
|
|
277
|
-
for args, func in params:
|
|
278
|
-
if
|
|
317
|
+
for args, kwargs, func in params:
|
|
318
|
+
if args is None:
|
|
279
319
|
continue
|
|
280
320
|
if not isinstance(args, tuple):
|
|
281
321
|
args = (args,)
|
|
282
|
-
model_cls = func(*args)(model_cls)
|
|
322
|
+
model_cls = func(*args, **kwargs)(model_cls)
|
|
283
323
|
return model_cls
|
|
284
324
|
|
|
285
325
|
return decorator
|
|
@@ -386,6 +426,28 @@ class Audit:
|
|
|
386
426
|
|
|
387
427
|
return decorator
|
|
388
428
|
|
|
429
|
+
@classmethod
|
|
430
|
+
def insert_updates_when_no_changes(cls, value=True, *, decorator=False):
|
|
431
|
+
"""
|
|
432
|
+
Set whether to insert audit entries for updates even when there are no changes if the model is dirty.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
value (bool, optional): Whether to insert audit entries for updates even when there are no changes if the model is dirty. Defaults to True.
|
|
436
|
+
decorator (bool, optional): Whether to use this method as a decorator. If True, the method returns a decorator function that can be applied to a model class. Defaults to False.
|
|
437
|
+
"""
|
|
438
|
+
if decorator:
|
|
439
|
+
|
|
440
|
+
def decorator(model_cls: typing.Type[SQLAModel]):
|
|
441
|
+
cls.mapping_insert_updates_when_no_changes[model_cls] = value
|
|
442
|
+
logger.info(
|
|
443
|
+
f"Set insert_updates_when_no_changes to {value} for model {model_cls.__name__}."
|
|
444
|
+
)
|
|
445
|
+
return model_cls
|
|
446
|
+
|
|
447
|
+
return decorator
|
|
448
|
+
cls.mapping_insert_updates_when_no_changes_default = value
|
|
449
|
+
logger.info(f"Set insert_updates_when_no_changes to {value}.")
|
|
450
|
+
|
|
389
451
|
@classmethod
|
|
390
452
|
def callback(
|
|
391
453
|
cls,
|
|
@@ -406,6 +468,28 @@ class Audit:
|
|
|
406
468
|
logger.info(f"Callback {func.__name__} registered.")
|
|
407
469
|
return func
|
|
408
470
|
|
|
471
|
+
@classmethod
|
|
472
|
+
def run_in_background(cls, value: bool = True):
|
|
473
|
+
"""
|
|
474
|
+
Set whether to insert audit entries and run callbacks in the background.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
value (bool, optional): Whether to insert audit entries and run callbacks in the background. Defaults to True.
|
|
478
|
+
"""
|
|
479
|
+
if value:
|
|
480
|
+
try:
|
|
481
|
+
import apscheduler.schedulers.asyncio
|
|
482
|
+
|
|
483
|
+
cls.scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler()
|
|
484
|
+
cls.scheduler.start()
|
|
485
|
+
except ImportError as e:
|
|
486
|
+
raise ImportError(
|
|
487
|
+
"apscheduler is required to run audit in background. Please install it with `pip install apscheduler` or add it to your `requirements.txt`."
|
|
488
|
+
) from e
|
|
489
|
+
|
|
490
|
+
cls._run_in_background = value
|
|
491
|
+
logger.info(f"Set run_in_background to {value}.")
|
|
492
|
+
|
|
409
493
|
"""
|
|
410
494
|
--------------------------------------------------------------------------------------------------------
|
|
411
495
|
MODEL METHODS - implemented
|
|
@@ -435,7 +519,7 @@ class Audit:
|
|
|
435
519
|
AuditTable: An instance of the audit table model with the provided audit data.
|
|
436
520
|
"""
|
|
437
521
|
return cls.model(
|
|
438
|
-
|
|
522
|
+
table_name=audit["model"].__tablename__,
|
|
439
523
|
table_id=str(audit["pk"]),
|
|
440
524
|
operation=audit["operation"],
|
|
441
525
|
data=audit.get("data"),
|
|
@@ -444,20 +528,11 @@ class Audit:
|
|
|
444
528
|
)
|
|
445
529
|
|
|
446
530
|
@classmethod
|
|
447
|
-
async def create_table(cls
|
|
531
|
+
async def create_table(cls):
|
|
448
532
|
"""
|
|
449
533
|
Create the audit table in the database.
|
|
450
|
-
|
|
451
|
-
Args:
|
|
452
|
-
conn (SQLAConnection, optional): The database connection to use. Defaults to None.
|
|
453
534
|
"""
|
|
454
|
-
|
|
455
|
-
async with db.connect(cls.model.__bind_key__) as conn:
|
|
456
|
-
return await cls.create_table(conn)
|
|
457
|
-
|
|
458
|
-
audit_metadata = MetaData()
|
|
459
|
-
cls.model.__table__.to_metadata(audit_metadata)
|
|
460
|
-
return await db._create_all(conn, audit_metadata)
|
|
535
|
+
return await db.create_all(cls.model.__bind_key__, tables=[cls.model.__table__])
|
|
461
536
|
|
|
462
537
|
@classmethod
|
|
463
538
|
def create_table_sync(cls, *args, **kwargs):
|
|
@@ -476,7 +551,9 @@ class Audit:
|
|
|
476
551
|
@classmethod
|
|
477
552
|
async def insert_entries(
|
|
478
553
|
cls,
|
|
479
|
-
entries: list[
|
|
554
|
+
entries: list[AuditEntry],
|
|
555
|
+
model_entries: list[SQLAModel] | None = None,
|
|
556
|
+
*,
|
|
480
557
|
session: SQLASession | None = None,
|
|
481
558
|
commit=True,
|
|
482
559
|
raise_exception=False,
|
|
@@ -485,7 +562,8 @@ class Audit:
|
|
|
485
562
|
Insert multiple audit entries into the database.
|
|
486
563
|
|
|
487
564
|
Args:
|
|
488
|
-
entries (list[
|
|
565
|
+
entries (list[AuditEntry]): The audit entries to insert.
|
|
566
|
+
model_entries (list[SQLAModel] | None, optional): Pre-created audit model instances to insert. If not provided, will create from `entries`. Defaults to None.
|
|
489
567
|
session (SQLASession | None, optional): The database session to use. Defaults to None.
|
|
490
568
|
commit (bool, optional): Whether to commit the session after inserting. Defaults to True.
|
|
491
569
|
raise_exception (bool, optional): Whether to raise an exception if the insert fails. Defaults to False.
|
|
@@ -494,35 +572,61 @@ class Audit:
|
|
|
494
572
|
e: The exception raised during the insert.
|
|
495
573
|
"""
|
|
496
574
|
try:
|
|
575
|
+
is_commited = cls._session_is_commited[session]
|
|
576
|
+
if is_commited:
|
|
577
|
+
logger.warning(
|
|
578
|
+
"Session has already been committed, creating a new session to insert audit entries."
|
|
579
|
+
)
|
|
580
|
+
commit = is_commited
|
|
581
|
+
del cls._session_is_commited[session]
|
|
582
|
+
session = None
|
|
497
583
|
if not session:
|
|
498
584
|
async with db.session(cls.model.__bind_key__) as session:
|
|
499
|
-
await cls.insert_entries(
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
585
|
+
await cls.insert_entries(
|
|
586
|
+
entries,
|
|
587
|
+
model_entries,
|
|
588
|
+
session=session,
|
|
589
|
+
commit=commit,
|
|
590
|
+
raise_exception=raise_exception,
|
|
591
|
+
)
|
|
592
|
+
return
|
|
593
|
+
model_entries = model_entries or [
|
|
594
|
+
cls.create_entry(entry) for entry in entries
|
|
595
|
+
]
|
|
596
|
+
session.add_all(model_entries)
|
|
597
|
+
for entry in model_entries:
|
|
598
|
+
operation = use_default_when_none(
|
|
599
|
+
getattr(entry, "operation", None), "UNKNOWN OPERATION"
|
|
600
|
+
)
|
|
601
|
+
table_name = use_default_when_none(
|
|
602
|
+
getattr(entry, "table_name", None), "UNKNOWN TABLE"
|
|
603
|
+
)
|
|
604
|
+
table_id = use_default_when_none(
|
|
605
|
+
getattr(entry, "table_id", None), "UNKNOWN ID"
|
|
606
|
+
)
|
|
607
|
+
entry_data = use_default_when_none(
|
|
608
|
+
getattr(entry, "data", None), {"detail": "No data"}
|
|
609
|
+
)
|
|
610
|
+
logger.info(f"[{operation}] {table_name} {table_id}")
|
|
611
|
+
logger.debug(f"Entry data: \n{prettify_dict(entry_data)}")
|
|
504
612
|
if commit:
|
|
505
613
|
await smart_run(session.commit)
|
|
614
|
+
# Run callbacks immediately after commit if not part of a larger transaction
|
|
615
|
+
await cls._run_callbacks(entries)
|
|
616
|
+
else:
|
|
617
|
+
|
|
618
|
+
def callback_for_commit(entries=entries):
|
|
619
|
+
return cls._run_async_function_or_delegate_to_runner(
|
|
620
|
+
cls._run_callbacks, entries=entries
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
cls._session_callbacks_after_commit[session].append(callback_for_commit)
|
|
506
624
|
except Exception as e:
|
|
507
625
|
logger.error(f"Error inserting audit entries: {e}")
|
|
508
626
|
if not raise_exception:
|
|
509
627
|
return
|
|
510
628
|
raise e
|
|
511
629
|
|
|
512
|
-
@classmethod
|
|
513
|
-
def insert_entries_sync(cls, *args, **kwargs):
|
|
514
|
-
"""
|
|
515
|
-
Synchronous version of `insert_entries`.
|
|
516
|
-
|
|
517
|
-
Args:
|
|
518
|
-
*args: Positional arguments to pass to `insert_entries`.
|
|
519
|
-
**kwargs: Keyword arguments to pass to `insert_entries`.
|
|
520
|
-
|
|
521
|
-
Returns:
|
|
522
|
-
T: The result of the synchronous insert operation.
|
|
523
|
-
"""
|
|
524
|
-
return run_coroutine_in_threadpool(cls.insert_entries(*args, **kwargs))
|
|
525
|
-
|
|
526
630
|
"""
|
|
527
631
|
--------------------------------------------------------------------------------------------------------
|
|
528
632
|
EVENT LISTENER METHODS - implemented
|
|
@@ -537,31 +641,48 @@ class Audit:
|
|
|
537
641
|
Args:
|
|
538
642
|
session (sqlalchemy.orm.Session): The SQLAlchemy session that was flushed.
|
|
539
643
|
"""
|
|
540
|
-
audit_entries = cls.
|
|
644
|
+
audit_entries = cls._get_audit_entries(session)
|
|
541
645
|
if not audit_entries:
|
|
542
646
|
logger.debug("No models to be audited in `after_flush`.")
|
|
543
647
|
return
|
|
544
648
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
649
|
+
model_entries = [cls.create_entry(entry) for entry in audit_entries]
|
|
650
|
+
if cls._run_in_background:
|
|
651
|
+
cls._session_callbacks_after_commit[session].append(
|
|
652
|
+
lambda audit_entries=audit_entries,
|
|
653
|
+
model_entries=model_entries: cls.scheduler.add_job(
|
|
654
|
+
cls.insert_entries,
|
|
655
|
+
args=[audit_entries, model_entries],
|
|
656
|
+
name=f"{cls.__name__}.insert_entries",
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
existing_session = (
|
|
662
|
+
session
|
|
663
|
+
if any(
|
|
664
|
+
entry["model"].__bind_key__ == cls.model.__bind_key__
|
|
665
|
+
for entry in audit_entries
|
|
666
|
+
)
|
|
667
|
+
else None
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
def callback_for_flush_postexec(
|
|
671
|
+
audit_entries=audit_entries,
|
|
672
|
+
model_entries=model_entries,
|
|
673
|
+
existing_session=existing_session,
|
|
550
674
|
):
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
675
|
+
return cls._run_async_function_or_delegate_to_runner(
|
|
676
|
+
cls.insert_entries,
|
|
677
|
+
audit_entries,
|
|
678
|
+
model_entries,
|
|
679
|
+
session=existing_session,
|
|
680
|
+
commit=not existing_session,
|
|
556
681
|
)
|
|
682
|
+
|
|
683
|
+
cls._session_callbacks_after_flush_postexec[session].append(
|
|
684
|
+
callback_for_flush_postexec
|
|
557
685
|
)
|
|
558
|
-
for entry in audit_entries:
|
|
559
|
-
for callback in cls.callbacks:
|
|
560
|
-
cls._session_callbacks_after_commit[session].append(
|
|
561
|
-
lambda entry=entry, callback=callback: safe_call_sync(
|
|
562
|
-
callback(entry)
|
|
563
|
-
)
|
|
564
|
-
)
|
|
565
686
|
|
|
566
687
|
@classmethod
|
|
567
688
|
def _after_flush_postexec(cls, session: sqlalchemy.orm.Session, *_):
|
|
@@ -586,6 +707,7 @@ class Audit:
|
|
|
586
707
|
Args:
|
|
587
708
|
session (sqlalchemy.orm.Session): The SQLAlchemy session that was committed.
|
|
588
709
|
"""
|
|
710
|
+
cls._session_is_commited[session] = True
|
|
589
711
|
callbacks = cls._session_callbacks_after_commit.pop(session, [])
|
|
590
712
|
if not callbacks:
|
|
591
713
|
logger.debug("No callbacks to run in `after_commit`.")
|
|
@@ -612,20 +734,40 @@ class Audit:
|
|
|
612
734
|
"""
|
|
613
735
|
|
|
614
736
|
@classmethod
|
|
615
|
-
|
|
737
|
+
def _get_audit_entries(cls, session: sqlalchemy.orm.Session):
|
|
738
|
+
"""
|
|
739
|
+
Get audit entries from the given session.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
session (sqlalchemy.orm.Session): The SQLAlchemy session to get audit entries from.
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
list[AuditEntry]: A list of audit entries.
|
|
746
|
+
"""
|
|
747
|
+
return cls._process_model_operations(cls._get_model_operations(session))
|
|
748
|
+
|
|
749
|
+
@classmethod
|
|
750
|
+
def _get_model_operations(
|
|
616
751
|
cls,
|
|
617
752
|
session: sqlalchemy.orm.Session,
|
|
618
753
|
):
|
|
619
754
|
"""
|
|
620
|
-
|
|
755
|
+
Gets a deque of models and their corresponding audit operations from the given session.
|
|
621
756
|
|
|
622
757
|
Args:
|
|
623
|
-
session (sqlalchemy.orm.Session): The SQLAlchemy session to get
|
|
758
|
+
session (sqlalchemy.orm.Session): The SQLAlchemy session to get models and operations from.
|
|
624
759
|
|
|
625
760
|
Returns:
|
|
626
|
-
list[
|
|
761
|
+
collections.deque[tuple[SQLAModel, AuditOperation, list[str], dict[str, sqlalchemy.orm.attributes.History]]]: A deque of tuples containing the model, operation, properties to audit, and updated columns with their history.
|
|
627
762
|
"""
|
|
628
|
-
|
|
763
|
+
model_operation_deque = collections.deque[
|
|
764
|
+
tuple[
|
|
765
|
+
SQLAModel,
|
|
766
|
+
AuditOperation,
|
|
767
|
+
list[str],
|
|
768
|
+
dict[str, sqlalchemy.orm.attributes.History],
|
|
769
|
+
]
|
|
770
|
+
]()
|
|
629
771
|
for model in session.new:
|
|
630
772
|
if not cls._should_audit(model):
|
|
631
773
|
logger.debug(
|
|
@@ -634,7 +776,9 @@ class Audit:
|
|
|
634
776
|
continue
|
|
635
777
|
if not hasattr(model, "__tablename__"):
|
|
636
778
|
continue
|
|
637
|
-
|
|
779
|
+
model_operation_deque.append(
|
|
780
|
+
(model, AuditOperation.INSERT, cls._get_audit_properties(model), {})
|
|
781
|
+
)
|
|
638
782
|
for model in session.dirty:
|
|
639
783
|
if not cls._should_audit(model):
|
|
640
784
|
logger.debug(
|
|
@@ -645,7 +789,16 @@ class Audit:
|
|
|
645
789
|
continue
|
|
646
790
|
if not session.is_modified(model):
|
|
647
791
|
continue
|
|
648
|
-
|
|
792
|
+
state = sqlalchemy.inspect(model)
|
|
793
|
+
properties = cls._get_audit_properties(model)
|
|
794
|
+
updates = {
|
|
795
|
+
x.key: x.history
|
|
796
|
+
for x in state.attrs
|
|
797
|
+
if x.history.has_changes() and x.key in properties
|
|
798
|
+
}
|
|
799
|
+
model_operation_deque.append(
|
|
800
|
+
(model, AuditOperation.UPDATE, properties, updates)
|
|
801
|
+
)
|
|
649
802
|
for model in session.deleted:
|
|
650
803
|
if not cls._should_audit(model):
|
|
651
804
|
logger.debug(
|
|
@@ -654,153 +807,136 @@ class Audit:
|
|
|
654
807
|
continue
|
|
655
808
|
if not hasattr(model, "__tablename__"):
|
|
656
809
|
continue
|
|
657
|
-
|
|
658
|
-
return
|
|
659
|
-
|
|
660
|
-
@classmethod
|
|
661
|
-
def _get_audit_entries_sync(cls, *args, **kwargs):
|
|
662
|
-
"""
|
|
663
|
-
Synchronous version of `_get_audit_entries`.
|
|
664
|
-
|
|
665
|
-
Args:
|
|
666
|
-
*args: Positional arguments to pass to `_get_audit_entries`.
|
|
667
|
-
**kwargs: Keyword arguments to pass to `_get_audit_entries`.
|
|
668
|
-
|
|
669
|
-
Returns:
|
|
670
|
-
list[AuditEntry]: A list of audit entries.
|
|
671
|
-
"""
|
|
672
|
-
return run_coroutine_in_threadpool(cls._get_audit_entries(*args, **kwargs))
|
|
810
|
+
model_operation_deque.append((model, AuditOperation.DELETE, [], {}))
|
|
811
|
+
return model_operation_deque
|
|
673
812
|
|
|
674
813
|
@classmethod
|
|
675
|
-
|
|
814
|
+
def _process_model_operations(
|
|
676
815
|
cls,
|
|
677
|
-
|
|
816
|
+
model_operation_deque: collections.deque[
|
|
817
|
+
tuple[
|
|
818
|
+
SQLAModel,
|
|
819
|
+
AuditOperation,
|
|
820
|
+
list[str],
|
|
821
|
+
dict[str, sqlalchemy.orm.attributes.History],
|
|
822
|
+
]
|
|
823
|
+
],
|
|
824
|
+
*,
|
|
678
825
|
result: list[AuditEntry] | None = None,
|
|
679
|
-
sessions: dict[str, SQLASession] | None = None,
|
|
680
826
|
):
|
|
681
827
|
"""
|
|
682
|
-
Process
|
|
828
|
+
Process model operations from the given deque.
|
|
683
829
|
|
|
684
830
|
Args:
|
|
685
|
-
|
|
831
|
+
model_operation_deque (collections.deque[tuple[SQLAModel, AuditOperation, list[str], dict[str, sqlalchemy.orm.attributes.History]]]): The model operations to process.
|
|
686
832
|
result (list[AuditEntry] | None, optional): The list to store the processed audit entries. Defaults to None. Used for recursion.
|
|
687
|
-
sessions (dict[str, SQLASession] | None, optional): The sessions to use for processing. Defaults to None. Used for recursion.
|
|
688
|
-
|
|
689
|
-
Raises:
|
|
690
|
-
ValueError: If the data is invalid.
|
|
691
833
|
|
|
692
834
|
Returns:
|
|
693
|
-
list[AuditEntry]: A list of processed
|
|
835
|
+
list[AuditEntry]: A list of audit entries processed from the model operations.
|
|
694
836
|
"""
|
|
695
837
|
result = result if result is not None else []
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
audit_entry_deque, result, sessions
|
|
838
|
+
while model_operation_deque:
|
|
839
|
+
dat = model_operation_deque.popleft()
|
|
840
|
+
model, operation, model_keys, update_history = dat
|
|
841
|
+
if operation == AuditOperation.DELETE:
|
|
842
|
+
result.append(
|
|
843
|
+
AuditEntry(
|
|
844
|
+
model=model,
|
|
845
|
+
operation=AuditOperation.DELETE,
|
|
846
|
+
pk=str(model.id_),
|
|
706
847
|
)
|
|
707
|
-
|
|
848
|
+
)
|
|
708
849
|
else:
|
|
709
|
-
|
|
710
|
-
|
|
850
|
+
datamodel = cls.interfaces[model.__class__]
|
|
851
|
+
schema_key = f"{model.__class__.__name__}-{'-'.join(model_keys)}"
|
|
852
|
+
schema = cls.schemas.get(schema_key)
|
|
853
|
+
if not schema:
|
|
854
|
+
cls.schemas[schema_key] = schema = datamodel.generate_schema(
|
|
855
|
+
model_keys,
|
|
856
|
+
with_id=False,
|
|
857
|
+
with_name=False,
|
|
858
|
+
optional=True,
|
|
859
|
+
related_kwargs={"with_property": False},
|
|
860
|
+
)
|
|
861
|
+
data = schema.model_validate(model).model_dump(mode="json")
|
|
862
|
+
if operation == AuditOperation.INSERT:
|
|
863
|
+
updates = {k: (None, v) for k, v in data.items()}
|
|
711
864
|
result.append(
|
|
712
865
|
AuditEntry(
|
|
713
866
|
model=model,
|
|
714
|
-
operation=AuditOperation.
|
|
867
|
+
operation=AuditOperation.INSERT,
|
|
715
868
|
pk=str(model.id_),
|
|
869
|
+
data=data,
|
|
870
|
+
updates=updates,
|
|
716
871
|
)
|
|
717
872
|
)
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
datamodel = cls.interfaces[model.__class__]
|
|
732
|
-
schema_key = f"{model.__class__.__name__}-{'-'.join(model_keys)}"
|
|
733
|
-
schema = cls.schemas.get(schema_key)
|
|
734
|
-
if not schema:
|
|
735
|
-
cls.schemas[schema_key] = schema = datamodel.generate_schema(
|
|
736
|
-
model_keys,
|
|
737
|
-
with_id=False,
|
|
738
|
-
with_name=False,
|
|
739
|
-
optional=True,
|
|
740
|
-
related_kwargs={"with_property": False},
|
|
741
|
-
)
|
|
742
|
-
data = schema.model_validate(model).model_dump(mode="json")
|
|
743
|
-
if operation == AuditOperation.INSERT:
|
|
744
|
-
updates = {k: (None, v) for k, v in data.items()}
|
|
745
|
-
result.append(
|
|
746
|
-
AuditEntry(
|
|
747
|
-
model=model,
|
|
748
|
-
operation=AuditOperation.INSERT,
|
|
749
|
-
pk=str(model.id_),
|
|
750
|
-
data=data,
|
|
751
|
-
updates=updates,
|
|
873
|
+
elif operation == AuditOperation.UPDATE:
|
|
874
|
+
update_schema_key = (
|
|
875
|
+
f"{model.__class__.__name__}-{'-'.join(update_history.keys())}"
|
|
876
|
+
)
|
|
877
|
+
update_schema = cls.schemas.get(update_schema_key)
|
|
878
|
+
if not update_schema:
|
|
879
|
+
cls.schemas[update_schema_key] = update_schema = (
|
|
880
|
+
datamodel.generate_schema(
|
|
881
|
+
update_history.keys(),
|
|
882
|
+
with_id=False,
|
|
883
|
+
with_name=False,
|
|
884
|
+
optional=True,
|
|
885
|
+
related_kwargs={"with_property": False},
|
|
752
886
|
)
|
|
753
887
|
)
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
session,
|
|
762
|
-
{
|
|
763
|
-
"list_columns": updated_columns,
|
|
764
|
-
"where_id": model.id_,
|
|
765
|
-
},
|
|
888
|
+
old_data = {}
|
|
889
|
+
for col, hist in update_history.items():
|
|
890
|
+
old_value = hist.deleted if hist.deleted else None
|
|
891
|
+
old_value = (
|
|
892
|
+
old_value[0]
|
|
893
|
+
if old_value and len(old_value) == 1
|
|
894
|
+
else old_value
|
|
766
895
|
)
|
|
767
|
-
if
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
)
|
|
771
|
-
continue
|
|
772
|
-
change_schema_key = f"{old_model.__class__.__name__}-{'-'.join(updated_columns)}"
|
|
773
|
-
change_schema = cls.schemas.get(change_schema_key)
|
|
774
|
-
if not change_schema:
|
|
775
|
-
cls.schemas[change_schema_key] = change_schema = (
|
|
776
|
-
datamodel.generate_schema(
|
|
777
|
-
updated_columns,
|
|
778
|
-
with_id=False,
|
|
779
|
-
with_name=False,
|
|
780
|
-
optional=True,
|
|
781
|
-
related_kwargs={"with_property": False},
|
|
782
|
-
)
|
|
896
|
+
if datamodel.is_relation(col):
|
|
897
|
+
related_schema = get_pydantic_model_field(
|
|
898
|
+
update_schema, col
|
|
783
899
|
)
|
|
784
|
-
|
|
785
|
-
|
|
900
|
+
if datamodel.is_relation_one_to_one(
|
|
901
|
+
col
|
|
902
|
+
) or datamodel.is_relation_many_to_one(col):
|
|
903
|
+
if old_value is not None:
|
|
904
|
+
old_value = related_schema.model_validate(
|
|
905
|
+
old_value
|
|
906
|
+
).model_dump(mode="json")
|
|
907
|
+
else:
|
|
908
|
+
# For one-to-many and many-to-many relations, we need to calculate the old value
|
|
909
|
+
old_value = list(hist.unchanged) + list(hist.deleted)
|
|
910
|
+
old_value = [
|
|
911
|
+
related_schema.model_validate(x).model_dump(
|
|
912
|
+
mode="json"
|
|
913
|
+
)
|
|
914
|
+
for x in old_value
|
|
915
|
+
]
|
|
916
|
+
|
|
917
|
+
old_data[col] = old_value
|
|
918
|
+
old_data = update_schema.model_validate(
|
|
919
|
+
old_data, from_attributes=False
|
|
920
|
+
).model_dump(mode="json")
|
|
921
|
+
updates = {k: (old_data[k], data[k]) for k in old_data if k in data}
|
|
922
|
+
if not updates:
|
|
923
|
+
logger.debug(
|
|
924
|
+
f"[{AuditOperation.UPDATE}] No changes detected for model: {model.__class__.__name__} {model.id_}"
|
|
786
925
|
)
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
logger.debug(
|
|
792
|
-
f"[{AuditOperation.UPDATE}] No changes detected for model: {model.__class__.__name__} {model.id_}"
|
|
793
|
-
)
|
|
926
|
+
if not use_default_when_none(
|
|
927
|
+
cls.mapping_insert_updates_when_no_changes[model.__class__],
|
|
928
|
+
cls.mapping_insert_updates_when_no_changes_default,
|
|
929
|
+
):
|
|
794
930
|
continue
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
)
|
|
931
|
+
result.append(
|
|
932
|
+
AuditEntry(
|
|
933
|
+
model=model,
|
|
934
|
+
operation=AuditOperation.UPDATE,
|
|
935
|
+
pk=str(model.id_),
|
|
936
|
+
data=data,
|
|
937
|
+
updates=updates,
|
|
803
938
|
)
|
|
939
|
+
)
|
|
804
940
|
return result
|
|
805
941
|
|
|
806
942
|
@classmethod
|
|
@@ -818,3 +954,79 @@ class Audit:
|
|
|
818
954
|
model.__class__ in cls.models
|
|
819
955
|
or any(issubclass(model.__class__, m) for m in cls.models)
|
|
820
956
|
)
|
|
957
|
+
|
|
958
|
+
@classmethod
|
|
959
|
+
async def _run_callbacks(cls, entries: list[AuditEntry]):
|
|
960
|
+
"""
|
|
961
|
+
Run registered callbacks for the given audit entries in parallel.
|
|
962
|
+
|
|
963
|
+
Args:
|
|
964
|
+
entries (list[AuditEntry]): The audit entries to process.
|
|
965
|
+
"""
|
|
966
|
+
async with AsyncTaskRunner():
|
|
967
|
+
for entry in entries:
|
|
968
|
+
for callback in cls.callbacks:
|
|
969
|
+
AsyncTaskRunner.add_task(
|
|
970
|
+
lambda callback=callback, entry=entry: smart_run(
|
|
971
|
+
callback, entry
|
|
972
|
+
)
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
@classmethod
|
|
976
|
+
def _run_async_function_or_delegate_to_runner(
|
|
977
|
+
cls,
|
|
978
|
+
async_function: typing.Callable[
|
|
979
|
+
..., typing.Coroutine[typing.Any, typing.Any, None]
|
|
980
|
+
],
|
|
981
|
+
*args,
|
|
982
|
+
kwargs_in_task_runner: dict[str, typing.Any] | None = None,
|
|
983
|
+
**kwargs,
|
|
984
|
+
):
|
|
985
|
+
"""
|
|
986
|
+
Run an async function in the current threadpool or delegate it to the current AsyncTaskRunner if available.
|
|
987
|
+
|
|
988
|
+
Args:
|
|
989
|
+
async_function (typing.Callable[ ..., typing.Coroutine[typing.Any, typing.Any, None] ]): The async function to run.
|
|
990
|
+
*args: Positional arguments to pass to the async function.
|
|
991
|
+
kwargs_in_task_runner (dict[str, typing.Any] | None, optional): Additional keyword arguments to pass to function when run in AsyncTaskRunner. Defaults to None.
|
|
992
|
+
**kwargs: Keyword arguments to pass to the async function.
|
|
993
|
+
|
|
994
|
+
Returns:
|
|
995
|
+
typing.Any: The result of the async function or None if delegated.
|
|
996
|
+
"""
|
|
997
|
+
try:
|
|
998
|
+
return safe_call_sync(async_function(*args, **kwargs))
|
|
999
|
+
except Exception as e:
|
|
1000
|
+
try:
|
|
1001
|
+
task_runner = AsyncTaskRunner.get_current_runner()
|
|
1002
|
+
params = {**kwargs, **(kwargs_in_task_runner or {})}
|
|
1003
|
+
task_runner.add_task(lambda: async_function(*args, **params))
|
|
1004
|
+
logger.warning(
|
|
1005
|
+
f"Delegated async function {async_function.__name__} to AsyncTaskRunner due to error running in threadpool"
|
|
1006
|
+
)
|
|
1007
|
+
except RuntimeError:
|
|
1008
|
+
logger.error(
|
|
1009
|
+
f"Error running async function {async_function.__name__} in threadpool and no AsyncTaskRunner is available to delegate the task to: {e}"
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
@classmethod
|
|
1013
|
+
def _get_audit_properties(cls, model: SQLAModel):
|
|
1014
|
+
"""
|
|
1015
|
+
Get the list of properties to include in the audit for the given model.
|
|
1016
|
+
|
|
1017
|
+
Args:
|
|
1018
|
+
model (SQLAModel): The SQLAlchemy model instance to get included properties for.
|
|
1019
|
+
|
|
1020
|
+
Returns:
|
|
1021
|
+
list[str]: A list of included property names.
|
|
1022
|
+
"""
|
|
1023
|
+
excluded_properties = set[str]()
|
|
1024
|
+
for excluded_model in cls.excluded_properties:
|
|
1025
|
+
if model.__class__ == excluded_model or issubclass(
|
|
1026
|
+
model.__class__, excluded_model
|
|
1027
|
+
):
|
|
1028
|
+
excluded_properties.update(cls.excluded_properties[excluded_model])
|
|
1029
|
+
state = sqlalchemy.inspect(model)
|
|
1030
|
+
return cls.included_properties[model.__class__] or [
|
|
1031
|
+
x for x in state.attrs.keys() if x not in excluded_properties
|
|
1032
|
+
]
|