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.
Files changed (98) hide show
  1. fastapi_rtk/__init__.py +39 -35
  2. fastapi_rtk/_version.py +1 -0
  3. fastapi_rtk/api/model_rest_api.py +476 -221
  4. fastapi_rtk/auth/auth.py +0 -9
  5. fastapi_rtk/backends/generic/__init__.py +6 -0
  6. fastapi_rtk/backends/generic/column.py +21 -12
  7. fastapi_rtk/backends/generic/db.py +42 -7
  8. fastapi_rtk/backends/generic/filters.py +21 -16
  9. fastapi_rtk/backends/generic/interface.py +14 -8
  10. fastapi_rtk/backends/generic/model.py +19 -11
  11. fastapi_rtk/backends/sqla/__init__.py +1 -0
  12. fastapi_rtk/backends/sqla/db.py +77 -17
  13. fastapi_rtk/backends/sqla/extensions/audit/audit.py +401 -189
  14. fastapi_rtk/backends/sqla/extensions/geoalchemy2/filters.py +15 -12
  15. fastapi_rtk/backends/sqla/filters.py +50 -21
  16. fastapi_rtk/backends/sqla/interface.py +96 -34
  17. fastapi_rtk/backends/sqla/model.py +56 -39
  18. fastapi_rtk/bases/__init__.py +20 -0
  19. fastapi_rtk/bases/db.py +94 -7
  20. fastapi_rtk/bases/file_manager.py +47 -3
  21. fastapi_rtk/bases/filter.py +22 -0
  22. fastapi_rtk/bases/interface.py +49 -5
  23. fastapi_rtk/bases/model.py +3 -0
  24. fastapi_rtk/bases/session.py +2 -0
  25. fastapi_rtk/cli/cli.py +62 -9
  26. fastapi_rtk/cli/commands/__init__.py +23 -0
  27. fastapi_rtk/cli/{db.py → commands/db/__init__.py} +107 -50
  28. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/env.py +2 -3
  29. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/env.py +10 -9
  30. fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/script.py.mako +3 -1
  31. fastapi_rtk/cli/{export.py → commands/export.py} +12 -10
  32. fastapi_rtk/cli/{security.py → commands/security.py} +73 -7
  33. fastapi_rtk/cli/commands/translate.py +299 -0
  34. fastapi_rtk/cli/decorators.py +9 -4
  35. fastapi_rtk/cli/utils.py +46 -0
  36. fastapi_rtk/config.py +41 -1
  37. fastapi_rtk/const.py +29 -1
  38. fastapi_rtk/db.py +76 -40
  39. fastapi_rtk/decorators.py +1 -1
  40. fastapi_rtk/dependencies.py +134 -62
  41. fastapi_rtk/exceptions.py +51 -1
  42. fastapi_rtk/fastapi_react_toolkit.py +186 -171
  43. fastapi_rtk/file_managers/file_manager.py +8 -6
  44. fastapi_rtk/file_managers/s3_file_manager.py +69 -33
  45. fastapi_rtk/globals.py +22 -12
  46. fastapi_rtk/lang/__init__.py +3 -0
  47. fastapi_rtk/lang/babel/__init__.py +4 -0
  48. fastapi_rtk/lang/babel/cli.py +40 -0
  49. fastapi_rtk/lang/babel/config.py +17 -0
  50. fastapi_rtk/lang/babel.cfg +1 -0
  51. fastapi_rtk/lang/lazy_text.py +120 -0
  52. fastapi_rtk/lang/messages.pot +238 -0
  53. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
  54. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +248 -0
  55. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
  56. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +244 -0
  57. fastapi_rtk/manager.py +355 -37
  58. fastapi_rtk/mixins.py +12 -0
  59. fastapi_rtk/routers.py +208 -72
  60. fastapi_rtk/schemas.py +142 -39
  61. fastapi_rtk/security/sqla/apis.py +39 -13
  62. fastapi_rtk/security/sqla/models.py +8 -23
  63. fastapi_rtk/security/sqla/security_manager.py +369 -11
  64. fastapi_rtk/setting.py +446 -88
  65. fastapi_rtk/types.py +94 -27
  66. fastapi_rtk/utils/__init__.py +8 -0
  67. fastapi_rtk/utils/async_task_runner.py +286 -61
  68. fastapi_rtk/utils/csv_json_converter.py +243 -40
  69. fastapi_rtk/utils/hooks.py +34 -0
  70. fastapi_rtk/utils/merge_schema.py +3 -3
  71. fastapi_rtk/utils/multiple_async_contexts.py +21 -0
  72. fastapi_rtk/utils/pydantic.py +46 -1
  73. fastapi_rtk/utils/run_utils.py +31 -1
  74. fastapi_rtk/utils/self_dependencies.py +1 -1
  75. fastapi_rtk/utils/use_default_when_none.py +1 -1
  76. fastapi_rtk/version.py +6 -1
  77. fastapi_rtk-1.0.13.dist-info/METADATA +28 -0
  78. fastapi_rtk-1.0.13.dist-info/RECORD +133 -0
  79. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/WHEEL +1 -2
  80. fastapi_rtk/backends/gremlinpython/__init__.py +0 -108
  81. fastapi_rtk/backends/gremlinpython/column.py +0 -208
  82. fastapi_rtk/backends/gremlinpython/db.py +0 -228
  83. fastapi_rtk/backends/gremlinpython/exceptions.py +0 -34
  84. fastapi_rtk/backends/gremlinpython/filters.py +0 -461
  85. fastapi_rtk/backends/gremlinpython/interface.py +0 -734
  86. fastapi_rtk/backends/gremlinpython/model.py +0 -364
  87. fastapi_rtk/backends/gremlinpython/session.py +0 -23
  88. fastapi_rtk/cli/commands.py +0 -295
  89. fastapi_rtk-0.2.27.dist-info/METADATA +0 -23
  90. fastapi_rtk-0.2.27.dist-info/RECORD +0 -126
  91. fastapi_rtk-0.2.27.dist-info/top_level.txt +0 -1
  92. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/README +0 -0
  93. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/alembic.ini.mako +0 -0
  94. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/script.py.mako +0 -0
  95. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/README +0 -0
  96. /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/alembic.ini.mako +0 -0
  97. {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/entry_points.txt +0 -0
  98. {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 SQLAConnection, SQLASession
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, Sequence(BASE_AUDIT_SEQUENCE), primary_key=True
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
- "table": mapped_column(sqlalchemy.String(256), nullable=False),
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("ix_audit_table_id", "table", "table_id", unique=False),
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
- "table": self.table,
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 not args:
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
- table=audit["model"].__tablename__,
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, conn: SQLAConnection = None):
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
- if not conn:
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[SQLAModel],
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[SQLAModel]): The audit entries to insert.
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(entries, session, raise_exception)
500
- session.add_all(entries)
501
- for entry in entries:
502
- logger.info(f"[{entry.operation}] {entry.table} {entry.table_id}")
503
- logger.debug(f"Entry data: \n{prettify_dict(entry.data)}")
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._get_audit_entries_sync(session)
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
- audit_models = [cls.create_entry(entry) for entry in audit_entries]
546
- existing_session = None
547
- if any(
548
- entry["model"].__bind_key__ == cls.model.__bind_key__
549
- for entry in audit_entries
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
- existing_session = session
552
- cls._session_callbacks_after_flush_postexec[session].append(
553
- lambda audit_models=audit_models,
554
- existing_session=existing_session: cls.insert_entries_sync(
555
- audit_models, session=existing_session, commit=not existing_session
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
- async def _get_audit_entries(
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
- Get audit entries from the session.
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 audit entries from.
758
+ session (sqlalchemy.orm.Session): The SQLAlchemy session to get models and operations from.
624
759
 
625
760
  Returns:
626
- list[AuditEntry]: A list of audit entries.
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
- audit_entry_deque = collections.deque[tuple[SQLAInterface, AuditOperation]]()
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
- audit_entry_deque.append((model, AuditOperation.INSERT))
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
- audit_entry_deque.append((model, AuditOperation.UPDATE))
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
- audit_entry_deque.append((model, AuditOperation.DELETE))
658
- return await cls._process_audit_entries(audit_entry_deque)
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
- async def _process_audit_entries(
814
+ def _process_model_operations(
676
815
  cls,
677
- audit_entry_deque: collections.deque[tuple[SQLAModel, AuditOperation]],
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 audit entries from the given queue.
828
+ Process model operations from the given deque.
683
829
 
684
830
  Args:
685
- audit_entry_deque (collections.deque[tuple[SQLAModel, AuditOperation]]): The audit entries to process.
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 audit entries.
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
- sessions = sessions if sessions is not None else {}
697
- while audit_entry_deque:
698
- dat = audit_entry_deque.popleft()
699
- model, operation = dat
700
- if model.__bind_key__ not in sessions:
701
- async with db.session(model.__bind_key__) as session:
702
- sessions[model.__bind_key__] = session
703
- audit_entry_deque.appendleft(dat) # Re-add to process later
704
- await cls._process_audit_entries(
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
- del sessions[model.__bind_key__]
848
+ )
708
849
  else:
709
- session = sessions[model.__bind_key__]
710
- if operation == AuditOperation.DELETE:
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.DELETE,
867
+ operation=AuditOperation.INSERT,
715
868
  pk=str(model.id_),
869
+ data=data,
870
+ updates=updates,
716
871
  )
717
872
  )
718
- else:
719
- excluded_properties = set[str]()
720
- for excluded_model in cls.excluded_properties:
721
- if model.__class__ == excluded_model or issubclass(
722
- model.__class__, excluded_model
723
- ):
724
- excluded_properties.update(
725
- cls.excluded_properties[excluded_model]
726
- )
727
- state = sqlalchemy.inspect(model)
728
- model_keys = cls.included_properties[model.__class__] or [
729
- x for x in state.attrs.keys() if x not in excluded_properties
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
- elif operation == AuditOperation.UPDATE:
755
- updated_columns = [
756
- x
757
- for x in model_keys
758
- if state.attrs[x].history.has_changes()
759
- ]
760
- old_model = await datamodel.get_one(
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 not old_model:
768
- logger.warning(
769
- f"[{AuditOperation.UPDATE}] Skipping audit entry due to missing old model data from the database: {model.__class__.__name__} {model.id_}"
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
- old_data = change_schema.model_validate(old_model).model_dump(
785
- mode="json"
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
- updates = {
788
- k: (old_data[k], data[k]) for k in old_data if k in data
789
- }
790
- if not updates:
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
- result.append(
796
- AuditEntry(
797
- model=model,
798
- operation=AuditOperation.UPDATE,
799
- pk=str(model.id_),
800
- data=data,
801
- updates=updates,
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
+ ]