truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. truthound_dashboard/api/alerts.py +75 -86
  2. truthound_dashboard/api/anomaly.py +7 -13
  3. truthound_dashboard/api/cross_alerts.py +38 -52
  4. truthound_dashboard/api/drift.py +49 -59
  5. truthound_dashboard/api/drift_monitor.py +234 -79
  6. truthound_dashboard/api/enterprise_sampling.py +498 -0
  7. truthound_dashboard/api/history.py +57 -5
  8. truthound_dashboard/api/lineage.py +3 -48
  9. truthound_dashboard/api/maintenance.py +104 -49
  10. truthound_dashboard/api/mask.py +1 -2
  11. truthound_dashboard/api/middleware.py +2 -1
  12. truthound_dashboard/api/model_monitoring.py +435 -311
  13. truthound_dashboard/api/notifications.py +227 -191
  14. truthound_dashboard/api/notifications_advanced.py +21 -20
  15. truthound_dashboard/api/observability.py +586 -0
  16. truthound_dashboard/api/plugins.py +2 -433
  17. truthound_dashboard/api/profile.py +199 -37
  18. truthound_dashboard/api/quality_reporter.py +701 -0
  19. truthound_dashboard/api/reports.py +7 -16
  20. truthound_dashboard/api/router.py +66 -0
  21. truthound_dashboard/api/rule_suggestions.py +5 -5
  22. truthound_dashboard/api/scan.py +17 -19
  23. truthound_dashboard/api/schedules.py +85 -50
  24. truthound_dashboard/api/schema_evolution.py +6 -6
  25. truthound_dashboard/api/schema_watcher.py +667 -0
  26. truthound_dashboard/api/sources.py +98 -27
  27. truthound_dashboard/api/tiering.py +1323 -0
  28. truthound_dashboard/api/triggers.py +14 -11
  29. truthound_dashboard/api/validations.py +12 -11
  30. truthound_dashboard/api/versioning.py +1 -6
  31. truthound_dashboard/core/__init__.py +129 -3
  32. truthound_dashboard/core/actions/__init__.py +62 -0
  33. truthound_dashboard/core/actions/custom.py +426 -0
  34. truthound_dashboard/core/actions/notifications.py +910 -0
  35. truthound_dashboard/core/actions/storage.py +472 -0
  36. truthound_dashboard/core/actions/webhook.py +281 -0
  37. truthound_dashboard/core/anomaly.py +262 -67
  38. truthound_dashboard/core/anomaly_explainer.py +4 -3
  39. truthound_dashboard/core/backends/__init__.py +67 -0
  40. truthound_dashboard/core/backends/base.py +299 -0
  41. truthound_dashboard/core/backends/errors.py +191 -0
  42. truthound_dashboard/core/backends/factory.py +423 -0
  43. truthound_dashboard/core/backends/mock_backend.py +451 -0
  44. truthound_dashboard/core/backends/truthound_backend.py +718 -0
  45. truthound_dashboard/core/checkpoint/__init__.py +87 -0
  46. truthound_dashboard/core/checkpoint/adapters.py +814 -0
  47. truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
  48. truthound_dashboard/core/checkpoint/runner.py +270 -0
  49. truthound_dashboard/core/connections.py +437 -10
  50. truthound_dashboard/core/converters/__init__.py +14 -0
  51. truthound_dashboard/core/converters/truthound.py +620 -0
  52. truthound_dashboard/core/cross_alerts.py +540 -320
  53. truthound_dashboard/core/datasource_factory.py +1672 -0
  54. truthound_dashboard/core/drift_monitor.py +216 -20
  55. truthound_dashboard/core/enterprise_sampling.py +1291 -0
  56. truthound_dashboard/core/interfaces/__init__.py +225 -0
  57. truthound_dashboard/core/interfaces/actions.py +652 -0
  58. truthound_dashboard/core/interfaces/base.py +247 -0
  59. truthound_dashboard/core/interfaces/checkpoint.py +676 -0
  60. truthound_dashboard/core/interfaces/protocols.py +664 -0
  61. truthound_dashboard/core/interfaces/reporters.py +650 -0
  62. truthound_dashboard/core/interfaces/routing.py +646 -0
  63. truthound_dashboard/core/interfaces/triggers.py +619 -0
  64. truthound_dashboard/core/lineage.py +407 -71
  65. truthound_dashboard/core/model_monitoring.py +431 -3
  66. truthound_dashboard/core/notifications/base.py +4 -0
  67. truthound_dashboard/core/notifications/channels.py +501 -1203
  68. truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
  69. truthound_dashboard/core/notifications/deduplication/service.py +131 -348
  70. truthound_dashboard/core/notifications/dispatcher.py +202 -11
  71. truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
  72. truthound_dashboard/core/notifications/escalation/engine.py +168 -358
  73. truthound_dashboard/core/notifications/routing/__init__.py +88 -128
  74. truthound_dashboard/core/notifications/routing/engine.py +90 -317
  75. truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
  76. truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
  77. truthound_dashboard/core/notifications/throttling/builder.py +117 -255
  78. truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
  79. truthound_dashboard/core/phase5/collaboration.py +1 -1
  80. truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
  81. truthound_dashboard/core/quality_reporter.py +1359 -0
  82. truthound_dashboard/core/report_history.py +0 -6
  83. truthound_dashboard/core/reporters/__init__.py +175 -14
  84. truthound_dashboard/core/reporters/adapters.py +943 -0
  85. truthound_dashboard/core/reporters/base.py +0 -3
  86. truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
  87. truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
  88. truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
  89. truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
  90. truthound_dashboard/core/reporters/compat.py +266 -0
  91. truthound_dashboard/core/reporters/csv_reporter.py +2 -35
  92. truthound_dashboard/core/reporters/factory.py +526 -0
  93. truthound_dashboard/core/reporters/interfaces.py +745 -0
  94. truthound_dashboard/core/reporters/registry.py +1 -10
  95. truthound_dashboard/core/scheduler.py +165 -0
  96. truthound_dashboard/core/schema_evolution.py +3 -3
  97. truthound_dashboard/core/schema_watcher.py +1528 -0
  98. truthound_dashboard/core/services.py +595 -76
  99. truthound_dashboard/core/store_manager.py +810 -0
  100. truthound_dashboard/core/streaming_anomaly.py +169 -4
  101. truthound_dashboard/core/tiering.py +1309 -0
  102. truthound_dashboard/core/triggers/evaluators.py +178 -8
  103. truthound_dashboard/core/truthound_adapter.py +2620 -197
  104. truthound_dashboard/core/unified_alerts.py +23 -20
  105. truthound_dashboard/db/__init__.py +8 -0
  106. truthound_dashboard/db/database.py +8 -2
  107. truthound_dashboard/db/models.py +944 -25
  108. truthound_dashboard/db/repository.py +2 -0
  109. truthound_dashboard/main.py +11 -0
  110. truthound_dashboard/schemas/__init__.py +177 -16
  111. truthound_dashboard/schemas/base.py +44 -23
  112. truthound_dashboard/schemas/collaboration.py +19 -6
  113. truthound_dashboard/schemas/cross_alerts.py +19 -3
  114. truthound_dashboard/schemas/drift.py +61 -55
  115. truthound_dashboard/schemas/drift_monitor.py +67 -23
  116. truthound_dashboard/schemas/enterprise_sampling.py +653 -0
  117. truthound_dashboard/schemas/lineage.py +0 -33
  118. truthound_dashboard/schemas/mask.py +10 -8
  119. truthound_dashboard/schemas/model_monitoring.py +89 -10
  120. truthound_dashboard/schemas/notifications_advanced.py +13 -0
  121. truthound_dashboard/schemas/observability.py +453 -0
  122. truthound_dashboard/schemas/plugins.py +0 -280
  123. truthound_dashboard/schemas/profile.py +154 -247
  124. truthound_dashboard/schemas/quality_reporter.py +403 -0
  125. truthound_dashboard/schemas/reports.py +2 -2
  126. truthound_dashboard/schemas/rule_suggestion.py +8 -1
  127. truthound_dashboard/schemas/scan.py +4 -24
  128. truthound_dashboard/schemas/schedule.py +11 -3
  129. truthound_dashboard/schemas/schema_watcher.py +727 -0
  130. truthound_dashboard/schemas/source.py +17 -2
  131. truthound_dashboard/schemas/tiering.py +822 -0
  132. truthound_dashboard/schemas/triggers.py +16 -0
  133. truthound_dashboard/schemas/unified_alerts.py +7 -0
  134. truthound_dashboard/schemas/validation.py +0 -13
  135. truthound_dashboard/schemas/validators/base.py +41 -21
  136. truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
  137. truthound_dashboard/schemas/validators/localization_validators.py +273 -0
  138. truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
  139. truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
  140. truthound_dashboard/schemas/validators/referential_validators.py +312 -0
  141. truthound_dashboard/schemas/validators/registry.py +93 -8
  142. truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
  143. truthound_dashboard/schemas/versioning.py +1 -6
  144. truthound_dashboard/static/index.html +2 -2
  145. truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
  147. truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
  148. truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
  149. truthound_dashboard/core/plugins/hooks/manager.py +0 -403
  150. truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
  151. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
  152. truthound_dashboard/core/reporters/junit_reporter.py +0 -233
  153. truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
  154. truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
  155. truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
  156. truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
  157. truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
  158. truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
  159. truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
  160. truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
  161. truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
  162. truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
  163. truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
  164. truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
  165. truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
  166. truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
  167. truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
  168. truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
  169. truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
  170. truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
  171. truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
  172. truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
  173. truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
  174. truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
  175. truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
  176. truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
  177. truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
  178. truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
  179. truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
  180. truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
  181. truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
  182. truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
  183. truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
  184. truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
  185. truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
  186. truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
  187. truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
  188. truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
  189. truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
  190. truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
  191. truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
  192. truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
  193. truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
  194. truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
  195. truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
  196. truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
  197. truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
  198. truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
  199. truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
  200. truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
  201. truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
  202. truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
  203. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -213,15 +213,54 @@ class Source(Base, UUIDMixin, TimestampMixin):
213
213
  assets: Mapped[list[CatalogAsset]] = relationship(
214
214
  "CatalogAsset",
215
215
  back_populates="source",
216
+ cascade="all, delete-orphan",
216
217
  lazy="selectin",
217
218
  )
218
219
  # Generated reports for this source
219
220
  generated_reports: Mapped[list["GeneratedReport"]] = relationship(
220
221
  "GeneratedReport",
221
222
  back_populates="source",
223
+ cascade="all, delete-orphan",
222
224
  lazy="selectin",
223
225
  order_by="desc(GeneratedReport.created_at)",
224
226
  )
227
+ # Anomaly detections for this source
228
+ anomaly_detections: Mapped[list["AnomalyDetection"]] = relationship(
229
+ "AnomalyDetection",
230
+ back_populates="source",
231
+ cascade="all, delete-orphan",
232
+ lazy="selectin",
233
+ )
234
+ # Drift comparisons where this source is the baseline
235
+ baseline_comparisons: Mapped[list["DriftComparison"]] = relationship(
236
+ "DriftComparison",
237
+ foreign_keys="[DriftComparison.baseline_source_id]",
238
+ back_populates="baseline_source",
239
+ cascade="all, delete-orphan",
240
+ lazy="selectin",
241
+ )
242
+ # Drift comparisons where this source is the current
243
+ current_comparisons: Mapped[list["DriftComparison"]] = relationship(
244
+ "DriftComparison",
245
+ foreign_keys="[DriftComparison.current_source_id]",
246
+ back_populates="current_source",
247
+ cascade="all, delete-orphan",
248
+ lazy="selectin",
249
+ )
250
+ # Data masks for this source
251
+ data_masks: Mapped[list["DataMask"]] = relationship(
252
+ "DataMask",
253
+ back_populates="source",
254
+ cascade="all, delete-orphan",
255
+ lazy="selectin",
256
+ )
257
+ # PII scans for this source
258
+ pii_scans: Mapped[list["PIIScan"]] = relationship(
259
+ "PIIScan",
260
+ back_populates="source",
261
+ cascade="all, delete-orphan",
262
+ lazy="selectin",
263
+ )
225
264
 
226
265
  @property
227
266
  def source_path(self) -> str | None:
@@ -321,7 +360,8 @@ class Rule(Base, UUIDMixin, TimestampMixin):
321
360
  @property
322
361
  def column_count(self) -> int:
323
362
  """Get number of columns with rules defined."""
324
- return len(self.column_rules)
363
+ rules = self.column_rules
364
+ return len(rules) if rules else 0
325
365
 
326
366
  def deactivate(self) -> None:
327
367
  """Mark this rule as inactive."""
@@ -722,12 +762,12 @@ class DriftComparison(Base, UUIDMixin, TimestampMixin):
722
762
  baseline_source: Mapped[Source] = relationship(
723
763
  "Source",
724
764
  foreign_keys=[baseline_source_id],
725
- backref="baseline_comparisons",
765
+ back_populates="baseline_comparisons",
726
766
  )
727
767
  current_source: Mapped[Source] = relationship(
728
768
  "Source",
729
769
  foreign_keys=[current_source_id],
730
- backref="current_comparisons",
770
+ back_populates="current_comparisons",
731
771
  )
732
772
 
733
773
  @property
@@ -825,7 +865,7 @@ class DataMask(Base, UUIDMixin):
825
865
  # Relationships
826
866
  source: Mapped[Source] = relationship(
827
867
  "Source",
828
- backref="data_masks",
868
+ back_populates="data_masks",
829
869
  )
830
870
 
831
871
  @property
@@ -939,7 +979,7 @@ class PIIScan(Base, UUIDMixin):
939
979
  # Relationships
940
980
  source: Mapped[Source] = relationship(
941
981
  "Source",
942
- backref="pii_scans",
982
+ back_populates="pii_scans",
943
983
  )
944
984
 
945
985
  @property
@@ -1240,6 +1280,7 @@ class GlossaryCategory(Base, UUIDMixin, TimestampMixin):
1240
1280
  "GlossaryCategory",
1241
1281
  remote_side="GlossaryCategory.id",
1242
1282
  back_populates="children",
1283
+ lazy="selectin",
1243
1284
  )
1244
1285
  children: Mapped[list[GlossaryCategory]] = relationship(
1245
1286
  "GlossaryCategory",
@@ -1307,6 +1348,7 @@ class GlossaryTerm(Base, UUIDMixin, TimestampMixin):
1307
1348
  category: Mapped[GlossaryCategory | None] = relationship(
1308
1349
  "GlossaryCategory",
1309
1350
  back_populates="terms",
1351
+ lazy="selectin",
1310
1352
  )
1311
1353
  history: Mapped[list[TermHistory]] = relationship(
1312
1354
  "TermHistory",
@@ -1538,6 +1580,7 @@ class CatalogAsset(Base, UUIDMixin, TimestampMixin):
1538
1580
  source: Mapped[Source | None] = relationship(
1539
1581
  "Source",
1540
1582
  back_populates="assets",
1583
+ lazy="selectin",
1541
1584
  )
1542
1585
  columns: Mapped[list[AssetColumn]] = relationship(
1543
1586
  "AssetColumn",
@@ -2548,6 +2591,7 @@ class LineageNode(Base, UUIDMixin, TimestampMixin):
2548
2591
  __table_args__ = (
2549
2592
  Index("idx_lineage_nodes_type", "node_type"),
2550
2593
  Index("idx_lineage_nodes_source", "source_id"),
2594
+ Index("idx_lineage_nodes_name_type", "name", "node_type", unique=True),
2551
2595
  )
2552
2596
 
2553
2597
  name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
@@ -2873,7 +2917,7 @@ class AnomalyDetection(Base, UUIDMixin):
2873
2917
  # Relationships
2874
2918
  source: Mapped[Source] = relationship(
2875
2919
  "Source",
2876
- backref="anomaly_detections",
2920
+ back_populates="anomaly_detections",
2877
2921
  )
2878
2922
 
2879
2923
  @property
@@ -3696,12 +3740,17 @@ class DriftMonitor(Base, UUIDMixin, TimestampMixin):
3696
3740
  current_source_id: Reference to current Source.
3697
3741
  status: Monitor status.
3698
3742
  method: Drift detection method.
3699
- threshold_critical: Critical drift threshold.
3700
- threshold_high: High drift threshold.
3701
- columns: Columns to monitor (None = all).
3702
- schedule_cron: Optional cron expression for scheduling.
3743
+ threshold: Drift threshold for detection.
3744
+ alert_threshold_critical: Critical drift threshold for alerts.
3745
+ alert_threshold_high: High drift threshold for alerts.
3746
+ columns_json: Columns to monitor (None = all).
3747
+ cron_expression: Optional cron expression for scheduling.
3748
+ alert_on_drift: Whether to create alerts on drift.
3749
+ notification_channel_ids_json: IDs of notification channels.
3703
3750
  last_run_at: Last run timestamp.
3704
- next_run_at: Next scheduled run.
3751
+ total_runs: Total number of runs.
3752
+ drift_detected_count: Number of runs with drift detected.
3753
+ consecutive_drift_count: Consecutive runs with drift.
3705
3754
  config: Additional configuration.
3706
3755
  """
3707
3756
 
@@ -3733,12 +3782,17 @@ class DriftMonitor(Base, UUIDMixin, TimestampMixin):
3733
3782
  index=True,
3734
3783
  )
3735
3784
  method: Mapped[str] = mapped_column(String(30), nullable=False, default="auto")
3736
- threshold_critical: Mapped[float] = mapped_column(Float, default=0.3, nullable=False)
3737
- threshold_high: Mapped[float] = mapped_column(Float, default=0.1, nullable=False)
3738
- columns: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
3739
- schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
3785
+ threshold: Mapped[float] = mapped_column(Float, default=0.05, nullable=False)
3786
+ alert_threshold_critical: Mapped[float] = mapped_column(Float, default=0.3, nullable=False)
3787
+ alert_threshold_high: Mapped[float] = mapped_column(Float, default=0.2, nullable=False)
3788
+ columns_json: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
3789
+ cron_expression: Mapped[str | None] = mapped_column(String(100), nullable=True)
3790
+ alert_on_drift: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
3791
+ notification_channel_ids_json: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
3740
3792
  last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
3741
- next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
3793
+ total_runs: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
3794
+ drift_detected_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
3795
+ consecutive_drift_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
3742
3796
  config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
3743
3797
 
3744
3798
  # Relationships
@@ -3776,6 +3830,13 @@ class DriftMonitor(Base, UUIDMixin, TimestampMixin):
3776
3830
  """Get the most recent run."""
3777
3831
  return self.runs[0] if self.runs else None
3778
3832
 
3833
+ @property
3834
+ def last_drift_detected(self) -> bool | None:
3835
+ """Check if drift was detected in the most recent run."""
3836
+ if self.latest_run is None:
3837
+ return None
3838
+ return self.latest_run.has_drift
3839
+
3779
3840
  def pause(self) -> None:
3780
3841
  """Pause this monitor."""
3781
3842
  self.status = DriftMonitorStatus.PAUSED.value
@@ -3784,10 +3845,10 @@ class DriftMonitor(Base, UUIDMixin, TimestampMixin):
3784
3845
  """Resume this monitor."""
3785
3846
  self.status = DriftMonitorStatus.ACTIVE.value
3786
3847
 
3787
- def mark_run(self, next_run: datetime | None = None) -> None:
3788
- """Mark as run and update next run time."""
3848
+ def mark_run(self) -> None:
3849
+ """Mark as run and update counters."""
3789
3850
  self.last_run_at = datetime.utcnow()
3790
- self.next_run_at = next_run
3851
+ self.total_runs += 1
3791
3852
 
3792
3853
 
3793
3854
  class DriftMonitorRun(Base, UUIDMixin):
@@ -3949,12 +4010,16 @@ class DriftAlert(Base, UUIDMixin):
3949
4010
  drift_score: Mapped[float] = mapped_column(Float, nullable=False)
3950
4011
  affected_columns: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
3951
4012
  message: Mapped[str] = mapped_column(Text, nullable=False)
4013
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
3952
4014
  acknowledged_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
3953
4015
  acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
3954
4016
  resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
3955
4017
  created_at: Mapped[datetime] = mapped_column(
3956
4018
  DateTime, default=datetime.utcnow, nullable=False
3957
4019
  )
4020
+ updated_at: Mapped[datetime | None] = mapped_column(
4021
+ DateTime, onupdate=datetime.utcnow, nullable=True
4022
+ )
3958
4023
 
3959
4024
  # Relationships
3960
4025
  monitor: Mapped[DriftMonitor] = relationship(
@@ -4327,12 +4392,8 @@ class ReportFormatType(str, Enum):
4327
4392
  """Report output format types."""
4328
4393
 
4329
4394
  HTML = "html"
4330
- PDF = "pdf"
4331
4395
  CSV = "csv"
4332
4396
  JSON = "json"
4333
- MARKDOWN = "markdown"
4334
- JUNIT = "junit"
4335
- EXCEL = "excel"
4336
4397
  CUSTOM = "custom"
4337
4398
 
4338
4399
 
@@ -4359,8 +4420,8 @@ class GeneratedReport(Base, UUIDMixin, TimestampMixin):
4359
4420
  reporter_id: Optional reference to custom reporter used.
4360
4421
  name: Human-readable report name.
4361
4422
  description: Optional description.
4362
- format: Report output format (html, pdf, csv, etc.).
4363
- theme: Theme used for HTML/PDF reports.
4423
+ format: Report output format (html, csv, etc.).
4424
+ theme: Theme used for HTML reports.
4364
4425
  locale: Language locale used.
4365
4426
  status: Generation status.
4366
4427
  file_path: Path to stored report file (if persisted).
@@ -5044,3 +5105,861 @@ class PluginHook(Base, UUIDMixin, TimestampMixin):
5044
5105
  self.total_execution_ms += execution_ms
5045
5106
  if error:
5046
5107
  self.last_error = error
5108
+
5109
+
5110
+ # =============================================================================
5111
+ # Storage Tiering Models (truthound 1.2.10+)
5112
+ # =============================================================================
5113
+
5114
+
5115
+ class TierType(str, Enum):
5116
+ """Storage tier types."""
5117
+
5118
+ HOT = "hot"
5119
+ WARM = "warm"
5120
+ COLD = "cold"
5121
+ ARCHIVE = "archive"
5122
+
5123
+
5124
+ class MigrationDirection(str, Enum):
5125
+ """Migration direction for tier policies."""
5126
+
5127
+ DEMOTE = "demote"
5128
+ PROMOTE = "promote"
5129
+
5130
+
5131
+ class TierPolicyType(str, Enum):
5132
+ """Types of tier policies."""
5133
+
5134
+ AGE_BASED = "age_based"
5135
+ ACCESS_BASED = "access_based"
5136
+ SIZE_BASED = "size_based"
5137
+ SCHEDULED = "scheduled"
5138
+ COMPOSITE = "composite"
5139
+ CUSTOM = "custom"
5140
+
5141
+
5142
+ class StorageTierModel(Base, UUIDMixin, TimestampMixin):
5143
+ """Storage tier definition model.
5144
+
5145
+ Represents a storage tier with its backend configuration.
5146
+
5147
+ Attributes:
5148
+ id: Unique identifier (UUID).
5149
+ name: Unique tier name (hot, warm, cold, archive, or custom).
5150
+ tier_type: Type classification (HOT, WARM, COLD, ARCHIVE).
5151
+ store_type: Backend store type (filesystem, s3, gcs, etc.).
5152
+ store_config: JSON configuration for the store backend.
5153
+ priority: Read order priority (lower = higher priority).
5154
+ cost_per_gb: Cost per GB for cost analysis.
5155
+ retrieval_time_ms: Expected retrieval latency in milliseconds.
5156
+ tier_metadata: Additional tier metadata.
5157
+ is_active: Whether the tier is active.
5158
+ """
5159
+
5160
+ __tablename__ = "storage_tiers"
5161
+
5162
+ __table_args__ = (
5163
+ Index("idx_storage_tiers_name", "name", unique=True),
5164
+ Index("idx_storage_tiers_type", "tier_type"),
5165
+ Index("idx_storage_tiers_priority", "priority"),
5166
+ )
5167
+
5168
+ name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
5169
+ tier_type: Mapped[str] = mapped_column(
5170
+ String(20),
5171
+ nullable=False,
5172
+ default=TierType.HOT.value,
5173
+ )
5174
+ store_type: Mapped[str] = mapped_column(String(50), nullable=False)
5175
+ store_config: Mapped[dict[str, Any]] = mapped_column(
5176
+ JSON, nullable=False, default=dict
5177
+ )
5178
+ priority: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
5179
+ cost_per_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
5180
+ retrieval_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
5181
+ tier_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
5182
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
5183
+
5184
+ # Relationships
5185
+ policies_from: Mapped[list["TierPolicyModel"]] = relationship(
5186
+ "TierPolicyModel",
5187
+ foreign_keys="TierPolicyModel.from_tier_id",
5188
+ back_populates="from_tier",
5189
+ lazy="selectin",
5190
+ )
5191
+ policies_to: Mapped[list["TierPolicyModel"]] = relationship(
5192
+ "TierPolicyModel",
5193
+ foreign_keys="TierPolicyModel.to_tier_id",
5194
+ back_populates="to_tier",
5195
+ lazy="selectin",
5196
+ )
5197
+
5198
+
5199
+ class TierPolicyModel(Base, UUIDMixin, TimestampMixin):
5200
+ """Tier migration policy model.
5201
+
5202
+ Stores tier migration policy configuration including composite policies.
5203
+ Supports AgeBasedTierPolicy, AccessBasedTierPolicy, SizeBasedTierPolicy,
5204
+ ScheduledTierPolicy, CompositeTierPolicy, and CustomTierPolicy.
5205
+
5206
+ Attributes:
5207
+ id: Unique identifier (UUID).
5208
+ name: Policy name.
5209
+ description: Policy description.
5210
+ policy_type: Type of policy (age_based, access_based, etc.).
5211
+ from_tier_id: Source tier ID.
5212
+ to_tier_id: Destination tier ID.
5213
+ direction: Migration direction (demote/promote).
5214
+ config: JSON configuration specific to policy type.
5215
+ is_active: Whether policy is active.
5216
+ priority: Execution priority (lower = runs first).
5217
+ parent_id: Parent composite policy ID (for nested policies).
5218
+ """
5219
+
5220
+ __tablename__ = "tier_policies"
5221
+
5222
+ __table_args__ = (
5223
+ Index("idx_tier_policies_name", "name"),
5224
+ Index("idx_tier_policies_type", "policy_type"),
5225
+ Index("idx_tier_policies_from_tier", "from_tier_id"),
5226
+ Index("idx_tier_policies_to_tier", "to_tier_id"),
5227
+ Index("idx_tier_policies_parent", "parent_id"),
5228
+ Index("idx_tier_policies_active", "is_active"),
5229
+ )
5230
+
5231
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
5232
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
5233
+ policy_type: Mapped[str] = mapped_column(
5234
+ String(30),
5235
+ nullable=False,
5236
+ default=TierPolicyType.AGE_BASED.value,
5237
+ )
5238
+ from_tier_id: Mapped[str] = mapped_column(
5239
+ String(36),
5240
+ ForeignKey("storage_tiers.id", ondelete="CASCADE"),
5241
+ nullable=False,
5242
+ )
5243
+ to_tier_id: Mapped[str] = mapped_column(
5244
+ String(36),
5245
+ ForeignKey("storage_tiers.id", ondelete="CASCADE"),
5246
+ nullable=False,
5247
+ )
5248
+ direction: Mapped[str] = mapped_column(
5249
+ String(20),
5250
+ nullable=False,
5251
+ default=MigrationDirection.DEMOTE.value,
5252
+ )
5253
+ config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
5254
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
5255
+ priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
5256
+ parent_id: Mapped[str | None] = mapped_column(
5257
+ String(36),
5258
+ ForeignKey("tier_policies.id", ondelete="CASCADE"),
5259
+ nullable=True,
5260
+ )
5261
+
5262
+ # Relationships
5263
+ from_tier: Mapped["StorageTierModel"] = relationship(
5264
+ "StorageTierModel",
5265
+ foreign_keys=[from_tier_id],
5266
+ back_populates="policies_from",
5267
+ lazy="selectin",
5268
+ )
5269
+ to_tier: Mapped["StorageTierModel"] = relationship(
5270
+ "StorageTierModel",
5271
+ foreign_keys=[to_tier_id],
5272
+ back_populates="policies_to",
5273
+ lazy="selectin",
5274
+ )
5275
+ parent: Mapped["TierPolicyModel | None"] = relationship(
5276
+ "TierPolicyModel",
5277
+ remote_side="TierPolicyModel.id",
5278
+ back_populates="children",
5279
+ foreign_keys=[parent_id],
5280
+ )
5281
+ children: Mapped[list["TierPolicyModel"]] = relationship(
5282
+ "TierPolicyModel",
5283
+ back_populates="parent",
5284
+ cascade="all, delete-orphan",
5285
+ lazy="selectin",
5286
+ )
5287
+
5288
+ @property
5289
+ def is_composite(self) -> bool:
5290
+ """Check if this is a composite policy."""
5291
+ return self.policy_type == TierPolicyType.COMPOSITE.value
5292
+
5293
+ @property
5294
+ def child_count(self) -> int:
5295
+ """Get number of child policies."""
5296
+ return len(self.children) if self.children else 0
5297
+
5298
+
5299
+ class TieringConfigModel(Base, UUIDMixin, TimestampMixin):
5300
+ """Tiering configuration model.
5301
+
5302
+ Stores the main tiering configuration including default tier,
5303
+ promotion settings, and batch processing options.
5304
+
5305
+ Attributes:
5306
+ id: Unique identifier (UUID).
5307
+ name: Configuration name.
5308
+ default_tier_id: Default tier for new items.
5309
+ enable_promotion: Whether to auto-promote on frequent access.
5310
+ promotion_threshold: Access count to trigger promotion.
5311
+ check_interval_hours: Hours between auto-checks.
5312
+ batch_size: Items per migration batch.
5313
+ enable_parallel_migration: Whether to enable parallel migration.
5314
+ max_parallel_migrations: Maximum concurrent migrations.
5315
+ is_active: Whether configuration is active.
5316
+ """
5317
+
5318
+ __tablename__ = "tiering_configs"
5319
+
5320
+ __table_args__ = (
5321
+ Index("idx_tiering_configs_name", "name", unique=True),
5322
+ Index("idx_tiering_configs_active", "is_active"),
5323
+ )
5324
+
5325
+ name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
5326
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
5327
+ default_tier_id: Mapped[str | None] = mapped_column(
5328
+ String(36),
5329
+ ForeignKey("storage_tiers.id", ondelete="SET NULL"),
5330
+ nullable=True,
5331
+ )
5332
+ enable_promotion: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
5333
+ promotion_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
5334
+ check_interval_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=24)
5335
+ batch_size: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
5336
+ enable_parallel_migration: Mapped[bool] = mapped_column(
5337
+ Boolean, nullable=False, default=False
5338
+ )
5339
+ max_parallel_migrations: Mapped[int] = mapped_column(
5340
+ Integer, nullable=False, default=4
5341
+ )
5342
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
5343
+
5344
+ # Relationships
5345
+ default_tier: Mapped["StorageTierModel | None"] = relationship(
5346
+ "StorageTierModel",
5347
+ foreign_keys=[default_tier_id],
5348
+ lazy="selectin",
5349
+ )
5350
+
5351
+
5352
+ class TierMigrationHistoryModel(Base, UUIDMixin):
5353
+ """Tier migration history model.
5354
+
5355
+ Tracks migration operations for auditing and analysis.
5356
+
5357
+ Attributes:
5358
+ id: Unique identifier (UUID).
5359
+ policy_id: Reference to the policy that triggered migration.
5360
+ item_id: ID of the migrated item.
5361
+ from_tier_id: Source tier ID.
5362
+ to_tier_id: Destination tier ID.
5363
+ size_bytes: Size of migrated item.
5364
+ started_at: When migration started.
5365
+ completed_at: When migration completed.
5366
+ status: Migration status (pending, in_progress, completed, failed).
5367
+ error_message: Error message if failed.
5368
+ """
5369
+
5370
+ __tablename__ = "tier_migration_history"
5371
+
5372
+ __table_args__ = (
5373
+ Index("idx_tier_migration_policy", "policy_id"),
5374
+ Index("idx_tier_migration_item", "item_id"),
5375
+ Index("idx_tier_migration_status", "status"),
5376
+ Index("idx_tier_migration_started", "started_at"),
5377
+ )
5378
+
5379
+ policy_id: Mapped[str | None] = mapped_column(
5380
+ String(36),
5381
+ ForeignKey("tier_policies.id", ondelete="SET NULL"),
5382
+ nullable=True,
5383
+ )
5384
+ item_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
5385
+ from_tier_id: Mapped[str] = mapped_column(String(36), nullable=False)
5386
+ to_tier_id: Mapped[str] = mapped_column(String(36), nullable=False)
5387
+ size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
5388
+ started_at: Mapped[datetime] = mapped_column(
5389
+ DateTime, nullable=False, default=datetime.utcnow
5390
+ )
5391
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
5392
+ status: Mapped[str] = mapped_column(
5393
+ String(20), nullable=False, default="pending"
5394
+ )
5395
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
5396
+
5397
+ @property
5398
+ def duration_ms(self) -> float | None:
5399
+ """Calculate migration duration in milliseconds."""
5400
+ if self.started_at and self.completed_at:
5401
+ return (self.completed_at - self.started_at).total_seconds() * 1000
5402
+ return None
5403
+
5404
+
5405
+ # =============================================================================
5406
+ # Schema Watcher Models (truthound 1.2.10+)
5407
+ # =============================================================================
5408
+
5409
+
5410
+ class SchemaWatcherStatus(str, Enum):
5411
+ """Status of a schema watcher."""
5412
+
5413
+ ACTIVE = "active"
5414
+ PAUSED = "paused"
5415
+ STOPPED = "stopped"
5416
+ ERROR = "error"
5417
+
5418
+
5419
+ class SchemaWatcherAlertStatus(str, Enum):
5420
+ """Status of a schema watcher alert."""
5421
+
5422
+ OPEN = "open"
5423
+ ACKNOWLEDGED = "acknowledged"
5424
+ RESOLVED = "resolved"
5425
+ SUPPRESSED = "suppressed"
5426
+
5427
+
5428
+ class SchemaWatcherAlertSeverity(str, Enum):
5429
+ """Severity of schema watcher alert."""
5430
+
5431
+ CRITICAL = "critical"
5432
+ HIGH = "high"
5433
+ MEDIUM = "medium"
5434
+ LOW = "low"
5435
+ INFO = "info"
5436
+
5437
+
5438
+ class SchemaWatcherRunStatus(str, Enum):
5439
+ """Status of a schema watcher run."""
5440
+
5441
+ PENDING = "pending"
5442
+ RUNNING = "running"
5443
+ COMPLETED = "completed"
5444
+ FAILED = "failed"
5445
+
5446
+
5447
+ class SchemaWatcherModel(Base, UUIDMixin, TimestampMixin):
5448
+ """Schema Watcher for continuous schema monitoring.
5449
+
5450
+ Implements truthound's SchemaWatcher functionality for continuous
5451
+ monitoring of schema changes with configurable polling intervals,
5452
+ alert thresholds, and notification integration.
5453
+
5454
+ Attributes:
5455
+ id: Unique identifier (UUID).
5456
+ name: Human-readable name for the watcher.
5457
+ source_id: Reference to the Source being watched.
5458
+ status: Current watcher status (active, paused, stopped, error).
5459
+ poll_interval_seconds: How often to check for schema changes.
5460
+ only_breaking: Only alert on breaking changes.
5461
+ enable_rename_detection: Enable column rename detection.
5462
+ rename_similarity_threshold: Threshold for rename detection (0.0-1.0).
5463
+ version_strategy: Version numbering strategy (semantic, incremental, timestamp, git).
5464
+ notify_on_change: Send notifications when changes detected.
5465
+ track_history: Track changes in schema history.
5466
+ last_check_at: When the watcher last checked for changes.
5467
+ last_change_at: When the last change was detected.
5468
+ next_check_at: When the next check is scheduled.
5469
+ check_count: Total number of checks performed.
5470
+ change_count: Total number of changes detected.
5471
+ error_count: Number of consecutive errors.
5472
+ last_error: Last error message if any.
5473
+ config: Additional configuration as JSON.
5474
+ """
5475
+
5476
+ __tablename__ = "schema_watchers"
5477
+
5478
+ __table_args__ = (
5479
+ Index("idx_schema_watcher_source", "source_id"),
5480
+ Index("idx_schema_watcher_status", "status"),
5481
+ Index("idx_schema_watcher_next_check", "next_check_at"),
5482
+ )
5483
+
5484
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
5485
+ source_id: Mapped[str] = mapped_column(
5486
+ String(36),
5487
+ ForeignKey("sources.id", ondelete="CASCADE"),
5488
+ nullable=False,
5489
+ index=True,
5490
+ )
5491
+ status: Mapped[str] = mapped_column(
5492
+ String(20),
5493
+ nullable=False,
5494
+ default=SchemaWatcherStatus.ACTIVE.value,
5495
+ )
5496
+ poll_interval_seconds: Mapped[int] = mapped_column(
5497
+ Integer,
5498
+ nullable=False,
5499
+ default=60, # Default 60 seconds
5500
+ )
5501
+ only_breaking: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
5502
+ enable_rename_detection: Mapped[bool] = mapped_column(
5503
+ Boolean, default=True, nullable=False
5504
+ )
5505
+ rename_similarity_threshold: Mapped[float] = mapped_column(
5506
+ Float, default=0.8, nullable=False
5507
+ )
5508
+ version_strategy: Mapped[str] = mapped_column(
5509
+ String(20),
5510
+ default="semantic",
5511
+ nullable=False,
5512
+ )
5513
+ notify_on_change: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
5514
+ track_history: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
5515
+
5516
+ # Monitoring state
5517
+ last_check_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
5518
+ last_change_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
5519
+ next_check_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
5520
+ check_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
5521
+ change_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
5522
+ error_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
5523
+ last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
5524
+
5525
+ # Additional configuration
5526
+ watcher_config: Mapped[dict[str, Any] | None] = mapped_column(
5527
+ "config", JSON, nullable=True
5528
+ )
5529
+
5530
+ # Relationships
5531
+ source: Mapped[Source] = relationship("Source", lazy="selectin")
5532
+ alerts: Mapped[list[SchemaWatcherAlertModel]] = relationship(
5533
+ "SchemaWatcherAlertModel",
5534
+ back_populates="watcher",
5535
+ cascade="all, delete-orphan",
5536
+ lazy="selectin",
5537
+ order_by="desc(SchemaWatcherAlertModel.created_at)",
5538
+ )
5539
+ runs: Mapped[list[SchemaWatcherRunModel]] = relationship(
5540
+ "SchemaWatcherRunModel",
5541
+ back_populates="watcher",
5542
+ cascade="all, delete-orphan",
5543
+ lazy="selectin",
5544
+ order_by="desc(SchemaWatcherRunModel.started_at)",
5545
+ )
5546
+
5547
+ @property
5548
+ def is_active(self) -> bool:
5549
+ """Check if watcher is actively running."""
5550
+ return self.status == SchemaWatcherStatus.ACTIVE.value
5551
+
5552
+ @property
5553
+ def is_healthy(self) -> bool:
5554
+ """Check if watcher is healthy (no errors)."""
5555
+ return self.error_count == 0 and self.status != SchemaWatcherStatus.ERROR.value
5556
+
5557
+ @property
5558
+ def detection_rate(self) -> float:
5559
+ """Calculate change detection rate."""
5560
+ if self.check_count == 0:
5561
+ return 0.0
5562
+ return self.change_count / self.check_count
5563
+
5564
+
5565
+ class SchemaWatcherAlertModel(Base, UUIDMixin, TimestampMixin):
5566
+ """Schema Watcher Alert for tracking schema change notifications.
5567
+
5568
+ Records alerts generated by the SchemaWatcher when schema changes
5569
+ are detected, with support for acknowledgment and resolution.
5570
+
5571
+ Attributes:
5572
+ id: Unique identifier (UUID).
5573
+ watcher_id: Reference to the SchemaWatcher.
5574
+ source_id: Reference to the Source.
5575
+ from_version_id: Previous schema version ID.
5576
+ to_version_id: New schema version ID.
5577
+ title: Alert title.
5578
+ severity: Alert severity level.
5579
+ status: Alert status (open, acknowledged, resolved, suppressed).
5580
+ total_changes: Total number of changes in this alert.
5581
+ breaking_changes: Number of breaking changes.
5582
+ changes_summary: JSON summary of changes.
5583
+ impact_scope: Scope of impact (local, downstream, system).
5584
+ affected_consumers: List of affected downstream consumers.
5585
+ recommendations: List of recommended actions.
5586
+ acknowledged_at: When alert was acknowledged.
5587
+ acknowledged_by: Who acknowledged the alert.
5588
+ resolved_at: When alert was resolved.
5589
+ resolved_by: Who resolved the alert.
5590
+ resolution_notes: Notes about resolution.
5591
+ """
5592
+
5593
+ __tablename__ = "schema_watcher_alerts"
5594
+
5595
+ __table_args__ = (
5596
+ Index("idx_watcher_alert_watcher", "watcher_id"),
5597
+ Index("idx_watcher_alert_source", "source_id"),
5598
+ Index("idx_watcher_alert_status", "status"),
5599
+ Index("idx_watcher_alert_severity", "severity"),
5600
+ Index("idx_watcher_alert_created", "created_at"),
5601
+ )
5602
+
5603
+ watcher_id: Mapped[str] = mapped_column(
5604
+ String(36),
5605
+ ForeignKey("schema_watchers.id", ondelete="CASCADE"),
5606
+ nullable=False,
5607
+ index=True,
5608
+ )
5609
+ source_id: Mapped[str] = mapped_column(
5610
+ String(36),
5611
+ ForeignKey("sources.id", ondelete="CASCADE"),
5612
+ nullable=False,
5613
+ index=True,
5614
+ )
5615
+ from_version_id: Mapped[str | None] = mapped_column(
5616
+ String(36),
5617
+ ForeignKey("schema_versions.id", ondelete="SET NULL"),
5618
+ nullable=True,
5619
+ )
5620
+ to_version_id: Mapped[str] = mapped_column(
5621
+ String(36),
5622
+ ForeignKey("schema_versions.id", ondelete="CASCADE"),
5623
+ nullable=False,
5624
+ )
5625
+ title: Mapped[str] = mapped_column(String(500), nullable=False)
5626
+ severity: Mapped[str] = mapped_column(
5627
+ String(20),
5628
+ nullable=False,
5629
+ default=SchemaWatcherAlertSeverity.MEDIUM.value,
5630
+ )
5631
+ status: Mapped[str] = mapped_column(
5632
+ String(20),
5633
+ nullable=False,
5634
+ default=SchemaWatcherAlertStatus.OPEN.value,
5635
+ )
5636
+ total_changes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
5637
+ breaking_changes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
5638
+ changes_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
5639
+ impact_scope: Mapped[str | None] = mapped_column(String(20), nullable=True)
5640
+ affected_consumers: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
5641
+ recommendations: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
5642
+
5643
+ # Acknowledgment tracking
5644
+ acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
5645
+ acknowledged_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
5646
+
5647
+ # Resolution tracking
5648
+ resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
5649
+ resolved_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
5650
+ resolution_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
5651
+
5652
+ # Relationships
5653
+ watcher: Mapped[SchemaWatcherModel] = relationship(
5654
+ "SchemaWatcherModel",
5655
+ back_populates="alerts",
5656
+ lazy="selectin",
5657
+ )
5658
+ source: Mapped[Source] = relationship("Source", lazy="selectin")
5659
+ from_version: Mapped[SchemaVersion | None] = relationship(
5660
+ "SchemaVersion",
5661
+ foreign_keys=[from_version_id],
5662
+ lazy="selectin",
5663
+ )
5664
+ to_version: Mapped[SchemaVersion] = relationship(
5665
+ "SchemaVersion",
5666
+ foreign_keys=[to_version_id],
5667
+ lazy="selectin",
5668
+ )
5669
+
5670
+ @property
5671
+ def is_open(self) -> bool:
5672
+ """Check if alert is still open."""
5673
+ return self.status == SchemaWatcherAlertStatus.OPEN.value
5674
+
5675
+ @property
5676
+ def is_resolved(self) -> bool:
5677
+ """Check if alert has been resolved."""
5678
+ return self.status == SchemaWatcherAlertStatus.RESOLVED.value
5679
+
5680
+ @property
5681
+ def has_breaking_changes(self) -> bool:
5682
+ """Check if alert contains breaking changes."""
5683
+ return self.breaking_changes > 0
5684
+
5685
+ @property
5686
+ def time_to_acknowledge(self) -> float | None:
5687
+ """Calculate time to acknowledge in seconds."""
5688
+ if self.created_at and self.acknowledged_at:
5689
+ return (self.acknowledged_at - self.created_at).total_seconds()
5690
+ return None
5691
+
5692
+ @property
5693
+ def time_to_resolve(self) -> float | None:
5694
+ """Calculate time to resolve in seconds."""
5695
+ if self.created_at and self.resolved_at:
5696
+ return (self.resolved_at - self.created_at).total_seconds()
5697
+ return None
5698
+
5699
+
5700
+ class SchemaWatcherRunModel(Base, UUIDMixin):
5701
+ """Schema Watcher Run history for tracking check executions.
5702
+
5703
+ Records each execution of the schema watcher check operation
5704
+ for auditing and performance analysis.
5705
+
5706
+ Attributes:
5707
+ id: Unique identifier (UUID).
5708
+ watcher_id: Reference to the SchemaWatcher.
5709
+ source_id: Reference to the Source.
5710
+ started_at: When the check started.
5711
+ completed_at: When the check completed.
5712
+ status: Run status (pending, running, completed, failed).
5713
+ changes_detected: Number of changes detected.
5714
+ breaking_detected: Number of breaking changes detected.
5715
+ version_created_id: ID of new schema version if created.
5716
+ alert_created_id: ID of alert if created.
5717
+ duration_ms: Duration of the check in milliseconds.
5718
+ error_message: Error message if failed.
5719
+ run_metadata: Additional metadata as JSON.
5720
+ """
5721
+
5722
+ __tablename__ = "schema_watcher_runs"
5723
+
5724
+ __table_args__ = (
5725
+ Index("idx_watcher_run_watcher", "watcher_id"),
5726
+ Index("idx_watcher_run_source", "source_id"),
5727
+ Index("idx_watcher_run_status", "status"),
5728
+ Index("idx_watcher_run_started", "started_at"),
5729
+ )
5730
+
5731
+ watcher_id: Mapped[str] = mapped_column(
5732
+ String(36),
5733
+ ForeignKey("schema_watchers.id", ondelete="CASCADE"),
5734
+ nullable=False,
5735
+ index=True,
5736
+ )
5737
+ source_id: Mapped[str] = mapped_column(
5738
+ String(36),
5739
+ ForeignKey("sources.id", ondelete="CASCADE"),
5740
+ nullable=False,
5741
+ index=True,
5742
+ )
5743
+ started_at: Mapped[datetime] = mapped_column(
5744
+ DateTime, nullable=False, default=datetime.utcnow
5745
+ )
5746
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
5747
+ status: Mapped[str] = mapped_column(
5748
+ String(20), nullable=False, default="pending"
5749
+ )
5750
+ changes_detected: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
5751
+ breaking_detected: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
5752
+ version_created_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
5753
+ alert_created_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
5754
+ duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
5755
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
5756
+ run_metadata: Mapped[dict[str, Any] | None] = mapped_column(
5757
+ "metadata", JSON, nullable=True
5758
+ )
5759
+
5760
+ # Relationships
5761
+ watcher: Mapped[SchemaWatcherModel] = relationship(
5762
+ "SchemaWatcherModel",
5763
+ back_populates="runs",
5764
+ lazy="selectin",
5765
+ )
5766
+ source: Mapped[Source] = relationship("Source", lazy="selectin")
5767
+
5768
+ @property
5769
+ def is_successful(self) -> bool:
5770
+ """Check if run completed successfully."""
5771
+ return self.status == "completed" and not self.error_message
5772
+
5773
+ @property
5774
+ def has_changes(self) -> bool:
5775
+ """Check if changes were detected in this run."""
5776
+ return self.changes_detected > 0
5777
+
5778
+
5779
+ # =============================================================================
5780
+ # Cross-Alert Models (Persistent Storage)
5781
+ # =============================================================================
5782
+
5783
+
5784
+ class CrossAlertConfig(Base, UUIDMixin, TimestampMixin):
5785
+ """Cross-alert configuration model.
5786
+
5787
+ Stores configuration for cross-feature alert correlation between
5788
+ anomaly detection and drift monitoring. Replaces in-memory storage.
5789
+
5790
+ Attributes:
5791
+ id: Unique identifier (UUID).
5792
+ source_id: Optional source ID for source-specific config, None for global.
5793
+ enabled: Whether cross-alert is enabled.
5794
+ trigger_drift_on_anomaly: Auto-trigger drift check when anomaly spikes.
5795
+ trigger_anomaly_on_drift: Auto-trigger anomaly check when drift detected.
5796
+ thresholds: JSON dict with threshold values.
5797
+ notify_on_correlation: Send notification when correlation found.
5798
+ notification_channel_ids: Channel IDs for notifications.
5799
+ cooldown_seconds: Cooldown period between auto-triggers.
5800
+ last_anomaly_trigger_at: Last time anomaly triggered drift check.
5801
+ last_drift_trigger_at: Last time drift triggered anomaly check.
5802
+ """
5803
+
5804
+ __tablename__ = "cross_alert_configs"
5805
+
5806
+ __table_args__ = (
5807
+ Index("idx_cross_alert_config_source", "source_id"),
5808
+ Index("idx_cross_alert_config_enabled", "enabled"),
5809
+ )
5810
+
5811
+ source_id: Mapped[str | None] = mapped_column(
5812
+ String(36),
5813
+ ForeignKey("sources.id", ondelete="CASCADE"),
5814
+ nullable=True,
5815
+ unique=True,
5816
+ index=True,
5817
+ )
5818
+ enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
5819
+ trigger_drift_on_anomaly: Mapped[bool] = mapped_column(
5820
+ Boolean, default=True, nullable=False
5821
+ )
5822
+ trigger_anomaly_on_drift: Mapped[bool] = mapped_column(
5823
+ Boolean, default=True, nullable=False
5824
+ )
5825
+ thresholds: Mapped[dict[str, Any]] = mapped_column(
5826
+ JSON,
5827
+ nullable=False,
5828
+ default=lambda: {
5829
+ "anomaly_rate_threshold": 0.1,
5830
+ "anomaly_count_threshold": 10,
5831
+ "drift_percentage_threshold": 10.0,
5832
+ "drift_columns_threshold": 2,
5833
+ },
5834
+ )
5835
+ notify_on_correlation: Mapped[bool] = mapped_column(
5836
+ Boolean, default=True, nullable=False
5837
+ )
5838
+ notification_channel_ids: Mapped[list[str] | None] = mapped_column(
5839
+ JSON, nullable=True
5840
+ )
5841
+ cooldown_seconds: Mapped[int] = mapped_column(Integer, default=300, nullable=False)
5842
+ last_anomaly_trigger_at: Mapped[datetime | None] = mapped_column(
5843
+ DateTime, nullable=True
5844
+ )
5845
+ last_drift_trigger_at: Mapped[datetime | None] = mapped_column(
5846
+ DateTime, nullable=True
5847
+ )
5848
+
5849
+ # Relationships
5850
+ source: Mapped[Source | None] = relationship("Source", lazy="selectin")
5851
+
5852
+
5853
+ class CrossAlertCorrelation(Base, UUIDMixin):
5854
+ """Cross-alert correlation record model.
5855
+
5856
+ Stores detected correlations between anomaly and drift alerts.
5857
+ Replaces in-memory _correlations list.
5858
+
5859
+ Attributes:
5860
+ id: Unique identifier (UUID).
5861
+ source_id: Reference to the data source.
5862
+ correlation_strength: Strength level (strong, moderate, weak).
5863
+ confidence_score: Confidence score between 0 and 1.
5864
+ time_delta_seconds: Time difference between alerts.
5865
+ anomaly_alert_id: ID of the anomaly detection record.
5866
+ drift_alert_id: ID of the drift alert record.
5867
+ anomaly_data: JSON with anomaly alert details.
5868
+ drift_data: JSON with drift alert details.
5869
+ common_columns: List of columns affected by both.
5870
+ suggested_action: Suggested action for this correlation.
5871
+ notes: Optional notes.
5872
+ created_at: When the correlation was detected.
5873
+ """
5874
+
5875
+ __tablename__ = "cross_alert_correlations"
5876
+
5877
+ __table_args__ = (
5878
+ Index("idx_cross_correlation_source", "source_id"),
5879
+ Index("idx_cross_correlation_strength", "correlation_strength"),
5880
+ Index("idx_cross_correlation_created", "created_at"),
5881
+ )
5882
+
5883
+ source_id: Mapped[str] = mapped_column(
5884
+ String(36),
5885
+ ForeignKey("sources.id", ondelete="CASCADE"),
5886
+ nullable=False,
5887
+ index=True,
5888
+ )
5889
+ correlation_strength: Mapped[str] = mapped_column(
5890
+ String(20), nullable=False
5891
+ ) # strong, moderate, weak
5892
+ confidence_score: Mapped[float] = mapped_column(Float, nullable=False)
5893
+ time_delta_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
5894
+ anomaly_alert_id: Mapped[str] = mapped_column(String(36), nullable=False)
5895
+ drift_alert_id: Mapped[str] = mapped_column(String(36), nullable=False)
5896
+ anomaly_data: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
5897
+ drift_data: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
5898
+ common_columns: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
5899
+ suggested_action: Mapped[str | None] = mapped_column(Text, nullable=True)
5900
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
5901
+ created_at: Mapped[datetime] = mapped_column(
5902
+ DateTime, default=datetime.utcnow, nullable=False
5903
+ )
5904
+
5905
+ # Relationships
5906
+ source: Mapped[Source] = relationship("Source", lazy="selectin")
5907
+
5908
+
5909
+ class CrossAlertTriggerEvent(Base, UUIDMixin):
5910
+ """Cross-alert auto-trigger event record model.
5911
+
5912
+ Stores records of auto-triggered checks between anomaly and drift.
5913
+ Replaces in-memory _auto_trigger_events list.
5914
+
5915
+ Attributes:
5916
+ id: Unique identifier (UUID).
5917
+ source_id: Reference to the data source.
5918
+ trigger_type: Type of trigger (anomaly_to_drift, drift_to_anomaly).
5919
+ trigger_alert_id: ID of the alert that triggered this.
5920
+ trigger_alert_type: Type of triggering alert (anomaly, drift).
5921
+ result_id: ID of the resulting check (drift comparison or anomaly detection).
5922
+ correlation_found: Whether a correlation was found.
5923
+ correlation_id: ID of the correlation if found.
5924
+ status: Status (pending, running, completed, failed, skipped).
5925
+ error_message: Error message if failed.
5926
+ skipped_reason: Reason if skipped.
5927
+ created_at: When the event was created.
5928
+ """
5929
+
5930
+ __tablename__ = "cross_alert_trigger_events"
5931
+
5932
+ __table_args__ = (
5933
+ Index("idx_cross_trigger_source", "source_id"),
5934
+ Index("idx_cross_trigger_type", "trigger_type"),
5935
+ Index("idx_cross_trigger_status", "status"),
5936
+ Index("idx_cross_trigger_created", "created_at"),
5937
+ )
5938
+
5939
+ source_id: Mapped[str] = mapped_column(
5940
+ String(36),
5941
+ ForeignKey("sources.id", ondelete="CASCADE"),
5942
+ nullable=False,
5943
+ index=True,
5944
+ )
5945
+ trigger_type: Mapped[str] = mapped_column(
5946
+ String(30), nullable=False
5947
+ ) # anomaly_to_drift, drift_to_anomaly
5948
+ trigger_alert_id: Mapped[str] = mapped_column(String(36), nullable=False)
5949
+ trigger_alert_type: Mapped[str] = mapped_column(
5950
+ String(20), nullable=False
5951
+ ) # anomaly, drift
5952
+ result_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
5953
+ correlation_found: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
5954
+ correlation_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
5955
+ status: Mapped[str] = mapped_column(
5956
+ String(20), nullable=False, default="pending"
5957
+ ) # pending, running, completed, failed, skipped
5958
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
5959
+ skipped_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
5960
+ created_at: Mapped[datetime] = mapped_column(
5961
+ DateTime, default=datetime.utcnow, nullable=False
5962
+ )
5963
+
5964
+ # Relationships
5965
+ source: Mapped[Source] = relationship("Source", lazy="selectin")