truthound-dashboard 1.4.3__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.3.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.3.dist-info/METADATA +0 -505
  203. {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.3.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1323 @@
1
+ """Storage tiering API endpoints.
2
+
3
+ This module provides REST API endpoints for managing storage tiering:
4
+ - Storage tiers (hot, warm, cold, archive)
5
+ - Tier policies (age-based, access-based, size-based, scheduled, composite, custom)
6
+ - Tiering configurations
7
+ - Migration history
8
+
9
+ Based on truthound 1.2.10+ storage tiering capabilities.
10
+
11
+ Endpoints:
12
+ Storage Tiers:
13
+ GET /tiering/tiers - List storage tiers
14
+ POST /tiering/tiers - Create storage tier
15
+ GET /tiering/tiers/{id} - Get storage tier
16
+ PUT /tiering/tiers/{id} - Update storage tier
17
+ DELETE /tiering/tiers/{id} - Delete storage tier
18
+
19
+ Tier Policies:
20
+ GET /tiering/policies - List tier policies
21
+ POST /tiering/policies - Create tier policy
22
+ GET /tiering/policies/{id} - Get tier policy
23
+ PUT /tiering/policies/{id} - Update tier policy
24
+ DELETE /tiering/policies/{id} - Delete tier policy
25
+ GET /tiering/policies/{id}/tree - Get policy with children (for composite)
26
+ GET /tiering/policies/types - Get available policy types
27
+
28
+ Tiering Configurations:
29
+ GET /tiering/configs - List tiering configurations
30
+ POST /tiering/configs - Create tiering configuration
31
+ GET /tiering/configs/{id} - Get tiering configuration
32
+ PUT /tiering/configs/{id} - Update tiering configuration
33
+ DELETE /tiering/configs/{id} - Delete tiering configuration
34
+
35
+ Migration History:
36
+ GET /tiering/migrations - List migration history
37
+ GET /tiering/migrations/{id} - Get migration details
38
+
39
+ Statistics:
40
+ GET /tiering/stats - Get tiering statistics
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from typing import Any
46
+
47
+ from fastapi import APIRouter, Depends, HTTPException, Query
48
+ from sqlalchemy import func, select
49
+ from sqlalchemy.ext.asyncio import AsyncSession
50
+ from sqlalchemy.orm import selectinload
51
+
52
+ from ..db import get_db_session as get_session
53
+ from ..db.models import (
54
+ MigrationDirection,
55
+ StorageTierModel,
56
+ TierMigrationHistoryModel,
57
+ TierPolicyModel,
58
+ TierPolicyType,
59
+ TierType,
60
+ TieringConfigModel,
61
+ )
62
+ from ..schemas.base import MessageResponse
63
+ from ..schemas.tiering import (
64
+ AgeBasedPolicyConfig,
65
+ AccessBasedPolicyConfig,
66
+ CompositePolicyConfig,
67
+ CustomPolicyConfig,
68
+ MigrationHistoryListResponse,
69
+ MigrationHistoryResponse,
70
+ MigrationItemResponse,
71
+ MigrateItemRequest,
72
+ PolicyExecutionRequest,
73
+ PolicyExecutionResponse,
74
+ PolicyExecutionSummary,
75
+ PolicyTypeInfo,
76
+ PolicyTypesResponse,
77
+ ProcessPoliciesResponse,
78
+ ScheduledPolicyConfig,
79
+ SizeBasedPolicyConfig,
80
+ StorageTierCreate,
81
+ StorageTierListResponse,
82
+ StorageTierResponse,
83
+ StorageTierUpdate,
84
+ TierPolicyCreate,
85
+ TierPolicyListResponse,
86
+ TierPolicyResponse,
87
+ TierPolicyType as TierPolicyTypeEnum,
88
+ TierPolicyUpdate,
89
+ TierPolicyWithChildren,
90
+ TierStatistics,
91
+ TierType as TierTypeEnum,
92
+ TieringConfigCreate,
93
+ TieringConfigListResponse,
94
+ TieringConfigResponse,
95
+ TieringConfigUpdate,
96
+ TieringStatistics,
97
+ TieringStatusResponse,
98
+ )
99
+
100
+ router = APIRouter(prefix="/tiering", tags=["tiering"])
101
+
102
+
103
+ # =============================================================================
104
+ # Helper Functions
105
+ # =============================================================================
106
+
107
+
108
+ def _tier_to_response(tier: StorageTierModel) -> StorageTierResponse:
109
+ """Convert storage tier model to response schema."""
110
+ return StorageTierResponse(
111
+ id=tier.id,
112
+ name=tier.name,
113
+ tier_type=TierTypeEnum(tier.tier_type),
114
+ store_type=tier.store_type,
115
+ store_config=tier.store_config,
116
+ priority=tier.priority,
117
+ cost_per_gb=tier.cost_per_gb,
118
+ retrieval_time_ms=tier.retrieval_time_ms,
119
+ metadata=tier.tier_metadata,
120
+ is_active=tier.is_active,
121
+ created_at=tier.created_at,
122
+ updated_at=tier.updated_at,
123
+ )
124
+
125
+
126
+ def _policy_to_response(
127
+ policy: TierPolicyModel,
128
+ from_tier_name: str | None = None,
129
+ to_tier_name: str | None = None,
130
+ ) -> TierPolicyResponse:
131
+ """Convert tier policy model to response schema."""
132
+ return TierPolicyResponse(
133
+ id=policy.id,
134
+ name=policy.name,
135
+ description=policy.description,
136
+ policy_type=TierPolicyTypeEnum(policy.policy_type),
137
+ from_tier_id=policy.from_tier_id,
138
+ to_tier_id=policy.to_tier_id,
139
+ direction=policy.direction,
140
+ config=policy.config,
141
+ is_active=policy.is_active,
142
+ priority=policy.priority,
143
+ parent_id=policy.parent_id,
144
+ child_count=policy.child_count,
145
+ from_tier_name=from_tier_name or (policy.from_tier.name if policy.from_tier else None),
146
+ to_tier_name=to_tier_name or (policy.to_tier.name if policy.to_tier else None),
147
+ created_at=policy.created_at,
148
+ updated_at=policy.updated_at,
149
+ )
150
+
151
+
152
+ def _policy_to_tree(policy: TierPolicyModel) -> TierPolicyWithChildren:
153
+ """Convert tier policy model to tree response with children."""
154
+ return TierPolicyWithChildren(
155
+ id=policy.id,
156
+ name=policy.name,
157
+ description=policy.description,
158
+ policy_type=TierPolicyTypeEnum(policy.policy_type),
159
+ from_tier_id=policy.from_tier_id,
160
+ to_tier_id=policy.to_tier_id,
161
+ direction=policy.direction,
162
+ config=policy.config,
163
+ is_active=policy.is_active,
164
+ priority=policy.priority,
165
+ parent_id=policy.parent_id,
166
+ child_count=policy.child_count,
167
+ from_tier_name=policy.from_tier.name if policy.from_tier else None,
168
+ to_tier_name=policy.to_tier.name if policy.to_tier else None,
169
+ created_at=policy.created_at,
170
+ updated_at=policy.updated_at,
171
+ children=[_policy_to_tree(child) for child in (policy.children or [])],
172
+ )
173
+
174
+
175
+ def _config_to_response(config: TieringConfigModel) -> TieringConfigResponse:
176
+ """Convert tiering configuration model to response schema."""
177
+ return TieringConfigResponse(
178
+ id=config.id,
179
+ name=config.name,
180
+ description=config.description,
181
+ default_tier_id=config.default_tier_id,
182
+ enable_promotion=config.enable_promotion,
183
+ promotion_threshold=config.promotion_threshold,
184
+ check_interval_hours=config.check_interval_hours,
185
+ batch_size=config.batch_size,
186
+ enable_parallel_migration=config.enable_parallel_migration,
187
+ max_parallel_migrations=config.max_parallel_migrations,
188
+ is_active=config.is_active,
189
+ default_tier_name=config.default_tier.name if config.default_tier else None,
190
+ created_at=config.created_at,
191
+ updated_at=config.updated_at,
192
+ )
193
+
194
+
195
+ def _migration_to_response(
196
+ migration: TierMigrationHistoryModel,
197
+ from_tier_name: str | None = None,
198
+ to_tier_name: str | None = None,
199
+ policy_name: str | None = None,
200
+ ) -> MigrationHistoryResponse:
201
+ """Convert migration history model to response schema."""
202
+ return MigrationHistoryResponse(
203
+ id=migration.id,
204
+ policy_id=migration.policy_id,
205
+ item_id=migration.item_id,
206
+ from_tier_id=migration.from_tier_id,
207
+ to_tier_id=migration.to_tier_id,
208
+ size_bytes=migration.size_bytes,
209
+ status=migration.status,
210
+ error_message=migration.error_message,
211
+ started_at=migration.started_at,
212
+ completed_at=migration.completed_at,
213
+ duration_ms=migration.duration_ms,
214
+ from_tier_name=from_tier_name,
215
+ to_tier_name=to_tier_name,
216
+ policy_name=policy_name,
217
+ )
218
+
219
+
220
+ # =============================================================================
221
+ # Storage Tiers Endpoints
222
+ # =============================================================================
223
+
224
+
225
+ @router.get("/tiers", response_model=StorageTierListResponse)
226
+ async def list_storage_tiers(
227
+ offset: int = Query(default=0, ge=0),
228
+ limit: int = Query(default=50, ge=1, le=100),
229
+ active_only: bool = Query(default=False),
230
+ tier_type: str | None = Query(default=None),
231
+ session: AsyncSession = Depends(get_session),
232
+ ) -> StorageTierListResponse:
233
+ """List all storage tiers with optional filtering."""
234
+ query = select(StorageTierModel)
235
+
236
+ if active_only:
237
+ query = query.where(StorageTierModel.is_active == True) # noqa: E712
238
+
239
+ if tier_type:
240
+ query = query.where(StorageTierModel.tier_type == tier_type)
241
+
242
+ # Get total count
243
+ count_query = select(func.count()).select_from(query.subquery())
244
+ total = (await session.execute(count_query)).scalar() or 0
245
+
246
+ # Apply pagination and ordering
247
+ query = query.order_by(StorageTierModel.priority).offset(offset).limit(limit)
248
+ result = await session.execute(query)
249
+ tiers = result.scalars().all()
250
+
251
+ return StorageTierListResponse(
252
+ items=[_tier_to_response(tier) for tier in tiers],
253
+ total=total,
254
+ offset=offset,
255
+ limit=limit,
256
+ )
257
+
258
+
259
+ @router.post("/tiers", response_model=StorageTierResponse, status_code=201)
260
+ async def create_storage_tier(
261
+ request: StorageTierCreate,
262
+ session: AsyncSession = Depends(get_session),
263
+ ) -> StorageTierResponse:
264
+ """Create a new storage tier."""
265
+ # Check for duplicate name
266
+ existing = await session.execute(
267
+ select(StorageTierModel).where(StorageTierModel.name == request.name)
268
+ )
269
+ if existing.scalar_one_or_none():
270
+ raise HTTPException(
271
+ status_code=400,
272
+ detail=f"Storage tier with name '{request.name}' already exists",
273
+ )
274
+
275
+ tier = StorageTierModel(
276
+ name=request.name,
277
+ tier_type=request.tier_type.value,
278
+ store_type=request.store_type,
279
+ store_config=request.store_config,
280
+ priority=request.priority,
281
+ cost_per_gb=request.cost_per_gb,
282
+ retrieval_time_ms=request.retrieval_time_ms,
283
+ tier_metadata=request.metadata,
284
+ is_active=request.is_active,
285
+ )
286
+ session.add(tier)
287
+ await session.commit()
288
+ await session.refresh(tier)
289
+
290
+ return _tier_to_response(tier)
291
+
292
+
293
+ @router.get("/tiers/{tier_id}", response_model=StorageTierResponse)
294
+ async def get_storage_tier(
295
+ tier_id: str,
296
+ session: AsyncSession = Depends(get_session),
297
+ ) -> StorageTierResponse:
298
+ """Get a storage tier by ID."""
299
+ result = await session.execute(
300
+ select(StorageTierModel).where(StorageTierModel.id == tier_id)
301
+ )
302
+ tier = result.scalar_one_or_none()
303
+
304
+ if not tier:
305
+ raise HTTPException(status_code=404, detail="Storage tier not found")
306
+
307
+ return _tier_to_response(tier)
308
+
309
+
310
+ @router.put("/tiers/{tier_id}", response_model=StorageTierResponse)
311
+ async def update_storage_tier(
312
+ tier_id: str,
313
+ request: StorageTierUpdate,
314
+ session: AsyncSession = Depends(get_session),
315
+ ) -> StorageTierResponse:
316
+ """Update a storage tier."""
317
+ result = await session.execute(
318
+ select(StorageTierModel).where(StorageTierModel.id == tier_id)
319
+ )
320
+ tier = result.scalar_one_or_none()
321
+
322
+ if not tier:
323
+ raise HTTPException(status_code=404, detail="Storage tier not found")
324
+
325
+ # Check for duplicate name if updating
326
+ if request.name is not None and request.name != tier.name:
327
+ existing = await session.execute(
328
+ select(StorageTierModel).where(StorageTierModel.name == request.name)
329
+ )
330
+ if existing.scalar_one_or_none():
331
+ raise HTTPException(
332
+ status_code=400,
333
+ detail=f"Storage tier with name '{request.name}' already exists",
334
+ )
335
+
336
+ # Apply updates
337
+ if request.name is not None:
338
+ tier.name = request.name
339
+ if request.tier_type is not None:
340
+ tier.tier_type = request.tier_type.value
341
+ if request.store_type is not None:
342
+ tier.store_type = request.store_type
343
+ if request.store_config is not None:
344
+ tier.store_config = request.store_config
345
+ if request.priority is not None:
346
+ tier.priority = request.priority
347
+ if request.cost_per_gb is not None:
348
+ tier.cost_per_gb = request.cost_per_gb
349
+ if request.retrieval_time_ms is not None:
350
+ tier.retrieval_time_ms = request.retrieval_time_ms
351
+ if request.metadata is not None:
352
+ tier.tier_metadata = request.metadata
353
+ if request.is_active is not None:
354
+ tier.is_active = request.is_active
355
+
356
+ await session.commit()
357
+ await session.refresh(tier)
358
+
359
+ return _tier_to_response(tier)
360
+
361
+
362
+ @router.delete("/tiers/{tier_id}", response_model=MessageResponse)
363
+ async def delete_storage_tier(
364
+ tier_id: str,
365
+ session: AsyncSession = Depends(get_session),
366
+ ) -> MessageResponse:
367
+ """Delete a storage tier."""
368
+ result = await session.execute(
369
+ select(StorageTierModel).where(StorageTierModel.id == tier_id)
370
+ )
371
+ tier = result.scalar_one_or_none()
372
+
373
+ if not tier:
374
+ raise HTTPException(status_code=404, detail="Storage tier not found")
375
+
376
+ await session.delete(tier)
377
+ await session.commit()
378
+
379
+ return MessageResponse(message=f"Storage tier '{tier.name}' deleted successfully")
380
+
381
+
382
+ # =============================================================================
383
+ # Tier Policies Endpoints
384
+ # =============================================================================
385
+
386
+
387
+ @router.get("/policies", response_model=TierPolicyListResponse)
388
+ async def list_tier_policies(
389
+ offset: int = Query(default=0, ge=0),
390
+ limit: int = Query(default=50, ge=1, le=100),
391
+ active_only: bool = Query(default=False),
392
+ policy_type: str | None = Query(default=None),
393
+ from_tier_id: str | None = Query(default=None),
394
+ to_tier_id: str | None = Query(default=None),
395
+ parent_id: str | None = Query(default=None),
396
+ root_only: bool = Query(default=False, description="Only show root policies (no parent)"),
397
+ session: AsyncSession = Depends(get_session),
398
+ ) -> TierPolicyListResponse:
399
+ """List all tier policies with optional filtering."""
400
+ query = select(TierPolicyModel).options(
401
+ selectinload(TierPolicyModel.from_tier),
402
+ selectinload(TierPolicyModel.to_tier),
403
+ )
404
+
405
+ if active_only:
406
+ query = query.where(TierPolicyModel.is_active == True) # noqa: E712
407
+
408
+ if policy_type:
409
+ query = query.where(TierPolicyModel.policy_type == policy_type)
410
+
411
+ if from_tier_id:
412
+ query = query.where(TierPolicyModel.from_tier_id == from_tier_id)
413
+
414
+ if to_tier_id:
415
+ query = query.where(TierPolicyModel.to_tier_id == to_tier_id)
416
+
417
+ if parent_id:
418
+ query = query.where(TierPolicyModel.parent_id == parent_id)
419
+
420
+ if root_only:
421
+ query = query.where(TierPolicyModel.parent_id == None) # noqa: E711
422
+
423
+ # Get total count
424
+ count_query = select(func.count()).select_from(query.subquery())
425
+ total = (await session.execute(count_query)).scalar() or 0
426
+
427
+ # Apply pagination and ordering
428
+ query = query.order_by(TierPolicyModel.priority, TierPolicyModel.name).offset(offset).limit(limit)
429
+ result = await session.execute(query)
430
+ policies = result.scalars().all()
431
+
432
+ return TierPolicyListResponse(
433
+ items=[_policy_to_response(policy) for policy in policies],
434
+ total=total,
435
+ offset=offset,
436
+ limit=limit,
437
+ )
438
+
439
+
440
+ @router.post("/policies", response_model=TierPolicyResponse, status_code=201)
441
+ async def create_tier_policy(
442
+ request: TierPolicyCreate,
443
+ session: AsyncSession = Depends(get_session),
444
+ ) -> TierPolicyResponse:
445
+ """Create a new tier policy."""
446
+ # Validate from_tier exists
447
+ from_tier = await session.execute(
448
+ select(StorageTierModel).where(StorageTierModel.id == request.from_tier_id)
449
+ )
450
+ if not from_tier.scalar_one_or_none():
451
+ raise HTTPException(status_code=400, detail="Source tier not found")
452
+
453
+ # Validate to_tier exists
454
+ to_tier = await session.execute(
455
+ select(StorageTierModel).where(StorageTierModel.id == request.to_tier_id)
456
+ )
457
+ if not to_tier.scalar_one_or_none():
458
+ raise HTTPException(status_code=400, detail="Destination tier not found")
459
+
460
+ # Validate parent if specified
461
+ if request.parent_id:
462
+ parent = await session.execute(
463
+ select(TierPolicyModel).where(TierPolicyModel.id == request.parent_id)
464
+ )
465
+ parent_policy = parent.scalar_one_or_none()
466
+ if not parent_policy:
467
+ raise HTTPException(status_code=400, detail="Parent policy not found")
468
+ if parent_policy.policy_type != TierPolicyType.COMPOSITE.value:
469
+ raise HTTPException(
470
+ status_code=400,
471
+ detail="Parent policy must be a composite policy",
472
+ )
473
+
474
+ policy = TierPolicyModel(
475
+ name=request.name,
476
+ description=request.description,
477
+ policy_type=request.policy_type.value,
478
+ from_tier_id=request.from_tier_id,
479
+ to_tier_id=request.to_tier_id,
480
+ direction=request.direction.value,
481
+ config=request.config,
482
+ is_active=request.is_active,
483
+ priority=request.priority,
484
+ parent_id=request.parent_id,
485
+ )
486
+ session.add(policy)
487
+ await session.commit()
488
+ await session.refresh(policy, ["from_tier", "to_tier"])
489
+
490
+ return _policy_to_response(policy)
491
+
492
+
493
+ @router.get("/policies/types", response_model=PolicyTypesResponse)
494
+ async def get_policy_types() -> PolicyTypesResponse:
495
+ """Get available policy types with their configuration schemas."""
496
+ policy_types = [
497
+ PolicyTypeInfo(
498
+ type=TierPolicyTypeEnum.AGE_BASED,
499
+ name="Age-Based",
500
+ description="Migrate items based on age (days/hours since creation)",
501
+ config_schema=AgeBasedPolicyConfig.model_json_schema(),
502
+ ),
503
+ PolicyTypeInfo(
504
+ type=TierPolicyTypeEnum.ACCESS_BASED,
505
+ name="Access-Based",
506
+ description="Migrate based on access patterns (inactive days or access count)",
507
+ config_schema=AccessBasedPolicyConfig.model_json_schema(),
508
+ ),
509
+ PolicyTypeInfo(
510
+ type=TierPolicyTypeEnum.SIZE_BASED,
511
+ name="Size-Based",
512
+ description="Migrate based on item size or tier capacity",
513
+ config_schema=SizeBasedPolicyConfig.model_json_schema(),
514
+ ),
515
+ PolicyTypeInfo(
516
+ type=TierPolicyTypeEnum.SCHEDULED,
517
+ name="Scheduled",
518
+ description="Migrate on a schedule (specific days/times)",
519
+ config_schema=ScheduledPolicyConfig.model_json_schema(),
520
+ ),
521
+ PolicyTypeInfo(
522
+ type=TierPolicyTypeEnum.COMPOSITE,
523
+ name="Composite",
524
+ description="Combine multiple policies with AND/OR logic",
525
+ config_schema=CompositePolicyConfig.model_json_schema(),
526
+ ),
527
+ PolicyTypeInfo(
528
+ type=TierPolicyTypeEnum.CUSTOM,
529
+ name="Custom",
530
+ description="Define custom migration logic with a predicate expression",
531
+ config_schema=CustomPolicyConfig.model_json_schema(),
532
+ ),
533
+ ]
534
+ return PolicyTypesResponse(policy_types=policy_types)
535
+
536
+
537
+ @router.get("/policies/{policy_id}", response_model=TierPolicyResponse)
538
+ async def get_tier_policy(
539
+ policy_id: str,
540
+ session: AsyncSession = Depends(get_session),
541
+ ) -> TierPolicyResponse:
542
+ """Get a tier policy by ID."""
543
+ result = await session.execute(
544
+ select(TierPolicyModel)
545
+ .options(
546
+ selectinload(TierPolicyModel.from_tier),
547
+ selectinload(TierPolicyModel.to_tier),
548
+ )
549
+ .where(TierPolicyModel.id == policy_id)
550
+ )
551
+ policy = result.scalar_one_or_none()
552
+
553
+ if not policy:
554
+ raise HTTPException(status_code=404, detail="Tier policy not found")
555
+
556
+ return _policy_to_response(policy)
557
+
558
+
559
+ @router.get("/policies/{policy_id}/tree", response_model=TierPolicyWithChildren)
560
+ async def get_tier_policy_tree(
561
+ policy_id: str,
562
+ session: AsyncSession = Depends(get_session),
563
+ ) -> TierPolicyWithChildren:
564
+ """Get a tier policy with all nested children (for composite policies)."""
565
+ result = await session.execute(
566
+ select(TierPolicyModel)
567
+ .options(
568
+ selectinload(TierPolicyModel.from_tier),
569
+ selectinload(TierPolicyModel.to_tier),
570
+ selectinload(TierPolicyModel.children).selectinload(TierPolicyModel.from_tier),
571
+ selectinload(TierPolicyModel.children).selectinload(TierPolicyModel.to_tier),
572
+ selectinload(TierPolicyModel.children).selectinload(TierPolicyModel.children),
573
+ )
574
+ .where(TierPolicyModel.id == policy_id)
575
+ )
576
+ policy = result.scalar_one_or_none()
577
+
578
+ if not policy:
579
+ raise HTTPException(status_code=404, detail="Tier policy not found")
580
+
581
+ return _policy_to_tree(policy)
582
+
583
+
584
+ @router.put("/policies/{policy_id}", response_model=TierPolicyResponse)
585
+ async def update_tier_policy(
586
+ policy_id: str,
587
+ request: TierPolicyUpdate,
588
+ session: AsyncSession = Depends(get_session),
589
+ ) -> TierPolicyResponse:
590
+ """Update a tier policy."""
591
+ result = await session.execute(
592
+ select(TierPolicyModel)
593
+ .options(
594
+ selectinload(TierPolicyModel.from_tier),
595
+ selectinload(TierPolicyModel.to_tier),
596
+ )
597
+ .where(TierPolicyModel.id == policy_id)
598
+ )
599
+ policy = result.scalar_one_or_none()
600
+
601
+ if not policy:
602
+ raise HTTPException(status_code=404, detail="Tier policy not found")
603
+
604
+ # Validate from_tier if updating
605
+ if request.from_tier_id is not None:
606
+ from_tier = await session.execute(
607
+ select(StorageTierModel).where(StorageTierModel.id == request.from_tier_id)
608
+ )
609
+ if not from_tier.scalar_one_or_none():
610
+ raise HTTPException(status_code=400, detail="Source tier not found")
611
+
612
+ # Validate to_tier if updating
613
+ if request.to_tier_id is not None:
614
+ to_tier = await session.execute(
615
+ select(StorageTierModel).where(StorageTierModel.id == request.to_tier_id)
616
+ )
617
+ if not to_tier.scalar_one_or_none():
618
+ raise HTTPException(status_code=400, detail="Destination tier not found")
619
+
620
+ # Apply updates
621
+ if request.name is not None:
622
+ policy.name = request.name
623
+ if request.description is not None:
624
+ policy.description = request.description
625
+ if request.policy_type is not None:
626
+ policy.policy_type = request.policy_type.value
627
+ if request.from_tier_id is not None:
628
+ policy.from_tier_id = request.from_tier_id
629
+ if request.to_tier_id is not None:
630
+ policy.to_tier_id = request.to_tier_id
631
+ if request.direction is not None:
632
+ policy.direction = request.direction.value
633
+ if request.config is not None:
634
+ policy.config = request.config
635
+ if request.is_active is not None:
636
+ policy.is_active = request.is_active
637
+ if request.priority is not None:
638
+ policy.priority = request.priority
639
+ if request.parent_id is not None:
640
+ policy.parent_id = request.parent_id
641
+
642
+ await session.commit()
643
+ await session.refresh(policy, ["from_tier", "to_tier"])
644
+
645
+ return _policy_to_response(policy)
646
+
647
+
648
+ @router.delete("/policies/{policy_id}", response_model=MessageResponse)
649
+ async def delete_tier_policy(
650
+ policy_id: str,
651
+ session: AsyncSession = Depends(get_session),
652
+ ) -> MessageResponse:
653
+ """Delete a tier policy."""
654
+ result = await session.execute(
655
+ select(TierPolicyModel).where(TierPolicyModel.id == policy_id)
656
+ )
657
+ policy = result.scalar_one_or_none()
658
+
659
+ if not policy:
660
+ raise HTTPException(status_code=404, detail="Tier policy not found")
661
+
662
+ policy_name = policy.name
663
+ await session.delete(policy)
664
+ await session.commit()
665
+
666
+ return MessageResponse(message=f"Tier policy '{policy_name}' deleted successfully")
667
+
668
+
669
+ # =============================================================================
670
+ # Tiering Configurations Endpoints
671
+ # =============================================================================
672
+
673
+
674
+ @router.get("/configs", response_model=TieringConfigListResponse)
675
+ async def list_tiering_configs(
676
+ offset: int = Query(default=0, ge=0),
677
+ limit: int = Query(default=50, ge=1, le=100),
678
+ active_only: bool = Query(default=False),
679
+ session: AsyncSession = Depends(get_session),
680
+ ) -> TieringConfigListResponse:
681
+ """List all tiering configurations."""
682
+ query = select(TieringConfigModel).options(
683
+ selectinload(TieringConfigModel.default_tier)
684
+ )
685
+
686
+ if active_only:
687
+ query = query.where(TieringConfigModel.is_active == True) # noqa: E712
688
+
689
+ # Get total count
690
+ count_query = select(func.count()).select_from(query.subquery())
691
+ total = (await session.execute(count_query)).scalar() or 0
692
+
693
+ # Apply pagination and ordering
694
+ query = query.order_by(TieringConfigModel.name).offset(offset).limit(limit)
695
+ result = await session.execute(query)
696
+ configs = result.scalars().all()
697
+
698
+ return TieringConfigListResponse(
699
+ items=[_config_to_response(config) for config in configs],
700
+ total=total,
701
+ offset=offset,
702
+ limit=limit,
703
+ )
704
+
705
+
706
+ @router.post("/configs", response_model=TieringConfigResponse, status_code=201)
707
+ async def create_tiering_config(
708
+ request: TieringConfigCreate,
709
+ session: AsyncSession = Depends(get_session),
710
+ ) -> TieringConfigResponse:
711
+ """Create a new tiering configuration."""
712
+ # Check for duplicate name
713
+ existing = await session.execute(
714
+ select(TieringConfigModel).where(TieringConfigModel.name == request.name)
715
+ )
716
+ if existing.scalar_one_or_none():
717
+ raise HTTPException(
718
+ status_code=400,
719
+ detail=f"Tiering configuration with name '{request.name}' already exists",
720
+ )
721
+
722
+ # Validate default_tier if specified
723
+ if request.default_tier_id:
724
+ default_tier = await session.execute(
725
+ select(StorageTierModel).where(StorageTierModel.id == request.default_tier_id)
726
+ )
727
+ if not default_tier.scalar_one_or_none():
728
+ raise HTTPException(status_code=400, detail="Default tier not found")
729
+
730
+ config = TieringConfigModel(
731
+ name=request.name,
732
+ description=request.description,
733
+ default_tier_id=request.default_tier_id,
734
+ enable_promotion=request.enable_promotion,
735
+ promotion_threshold=request.promotion_threshold,
736
+ check_interval_hours=request.check_interval_hours,
737
+ batch_size=request.batch_size,
738
+ enable_parallel_migration=request.enable_parallel_migration,
739
+ max_parallel_migrations=request.max_parallel_migrations,
740
+ is_active=request.is_active,
741
+ )
742
+ session.add(config)
743
+ await session.commit()
744
+ await session.refresh(config, ["default_tier"])
745
+
746
+ return _config_to_response(config)
747
+
748
+
749
+ @router.get("/configs/{config_id}", response_model=TieringConfigResponse)
750
+ async def get_tiering_config(
751
+ config_id: str,
752
+ session: AsyncSession = Depends(get_session),
753
+ ) -> TieringConfigResponse:
754
+ """Get a tiering configuration by ID."""
755
+ result = await session.execute(
756
+ select(TieringConfigModel)
757
+ .options(selectinload(TieringConfigModel.default_tier))
758
+ .where(TieringConfigModel.id == config_id)
759
+ )
760
+ config = result.scalar_one_or_none()
761
+
762
+ if not config:
763
+ raise HTTPException(status_code=404, detail="Tiering configuration not found")
764
+
765
+ return _config_to_response(config)
766
+
767
+
768
+ @router.put("/configs/{config_id}", response_model=TieringConfigResponse)
769
+ async def update_tiering_config(
770
+ config_id: str,
771
+ request: TieringConfigUpdate,
772
+ session: AsyncSession = Depends(get_session),
773
+ ) -> TieringConfigResponse:
774
+ """Update a tiering configuration."""
775
+ result = await session.execute(
776
+ select(TieringConfigModel)
777
+ .options(selectinload(TieringConfigModel.default_tier))
778
+ .where(TieringConfigModel.id == config_id)
779
+ )
780
+ config = result.scalar_one_or_none()
781
+
782
+ if not config:
783
+ raise HTTPException(status_code=404, detail="Tiering configuration not found")
784
+
785
+ # Check for duplicate name if updating
786
+ if request.name is not None and request.name != config.name:
787
+ existing = await session.execute(
788
+ select(TieringConfigModel).where(TieringConfigModel.name == request.name)
789
+ )
790
+ if existing.scalar_one_or_none():
791
+ raise HTTPException(
792
+ status_code=400,
793
+ detail=f"Tiering configuration with name '{request.name}' already exists",
794
+ )
795
+
796
+ # Apply updates
797
+ if request.name is not None:
798
+ config.name = request.name
799
+ if request.description is not None:
800
+ config.description = request.description
801
+ if request.default_tier_id is not None:
802
+ config.default_tier_id = request.default_tier_id
803
+ if request.enable_promotion is not None:
804
+ config.enable_promotion = request.enable_promotion
805
+ if request.promotion_threshold is not None:
806
+ config.promotion_threshold = request.promotion_threshold
807
+ if request.check_interval_hours is not None:
808
+ config.check_interval_hours = request.check_interval_hours
809
+ if request.batch_size is not None:
810
+ config.batch_size = request.batch_size
811
+ if request.enable_parallel_migration is not None:
812
+ config.enable_parallel_migration = request.enable_parallel_migration
813
+ if request.max_parallel_migrations is not None:
814
+ config.max_parallel_migrations = request.max_parallel_migrations
815
+ if request.is_active is not None:
816
+ config.is_active = request.is_active
817
+
818
+ await session.commit()
819
+ await session.refresh(config, ["default_tier"])
820
+
821
+ return _config_to_response(config)
822
+
823
+
824
+ @router.delete("/configs/{config_id}", response_model=MessageResponse)
825
+ async def delete_tiering_config(
826
+ config_id: str,
827
+ session: AsyncSession = Depends(get_session),
828
+ ) -> MessageResponse:
829
+ """Delete a tiering configuration."""
830
+ result = await session.execute(
831
+ select(TieringConfigModel).where(TieringConfigModel.id == config_id)
832
+ )
833
+ config = result.scalar_one_or_none()
834
+
835
+ if not config:
836
+ raise HTTPException(status_code=404, detail="Tiering configuration not found")
837
+
838
+ config_name = config.name
839
+ await session.delete(config)
840
+ await session.commit()
841
+
842
+ return MessageResponse(message=f"Tiering configuration '{config_name}' deleted successfully")
843
+
844
+
845
+ # =============================================================================
846
+ # Migration History Endpoints
847
+ # =============================================================================
848
+
849
+
850
+ @router.get("/migrations", response_model=MigrationHistoryListResponse)
851
+ async def list_migration_history(
852
+ offset: int = Query(default=0, ge=0),
853
+ limit: int = Query(default=50, ge=1, le=100),
854
+ policy_id: str | None = Query(default=None),
855
+ status: str | None = Query(default=None),
856
+ from_tier_id: str | None = Query(default=None),
857
+ to_tier_id: str | None = Query(default=None),
858
+ session: AsyncSession = Depends(get_session),
859
+ ) -> MigrationHistoryListResponse:
860
+ """List migration history with optional filtering."""
861
+ query = select(TierMigrationHistoryModel)
862
+
863
+ if policy_id:
864
+ query = query.where(TierMigrationHistoryModel.policy_id == policy_id)
865
+
866
+ if status:
867
+ query = query.where(TierMigrationHistoryModel.status == status)
868
+
869
+ if from_tier_id:
870
+ query = query.where(TierMigrationHistoryModel.from_tier_id == from_tier_id)
871
+
872
+ if to_tier_id:
873
+ query = query.where(TierMigrationHistoryModel.to_tier_id == to_tier_id)
874
+
875
+ # Get total count
876
+ count_query = select(func.count()).select_from(query.subquery())
877
+ total = (await session.execute(count_query)).scalar() or 0
878
+
879
+ # Apply pagination and ordering
880
+ query = query.order_by(TierMigrationHistoryModel.started_at.desc()).offset(offset).limit(limit)
881
+ result = await session.execute(query)
882
+ migrations = result.scalars().all()
883
+
884
+ # Get tier names for display
885
+ tier_ids = set()
886
+ policy_ids = set()
887
+ for m in migrations:
888
+ tier_ids.add(m.from_tier_id)
889
+ tier_ids.add(m.to_tier_id)
890
+ if m.policy_id:
891
+ policy_ids.add(m.policy_id)
892
+
893
+ # Fetch tier names
894
+ tier_names: dict[str, str] = {}
895
+ if tier_ids:
896
+ tiers_result = await session.execute(
897
+ select(StorageTierModel).where(StorageTierModel.id.in_(tier_ids))
898
+ )
899
+ for tier in tiers_result.scalars():
900
+ tier_names[tier.id] = tier.name
901
+
902
+ # Fetch policy names
903
+ policy_names: dict[str, str] = {}
904
+ if policy_ids:
905
+ policies_result = await session.execute(
906
+ select(TierPolicyModel).where(TierPolicyModel.id.in_(policy_ids))
907
+ )
908
+ for policy in policies_result.scalars():
909
+ policy_names[policy.id] = policy.name
910
+
911
+ return MigrationHistoryListResponse(
912
+ items=[
913
+ _migration_to_response(
914
+ m,
915
+ from_tier_name=tier_names.get(m.from_tier_id),
916
+ to_tier_name=tier_names.get(m.to_tier_id),
917
+ policy_name=policy_names.get(m.policy_id) if m.policy_id else None,
918
+ )
919
+ for m in migrations
920
+ ],
921
+ total=total,
922
+ offset=offset,
923
+ limit=limit,
924
+ )
925
+
926
+
927
+ @router.get("/migrations/{migration_id}", response_model=MigrationHistoryResponse)
928
+ async def get_migration_history(
929
+ migration_id: str,
930
+ session: AsyncSession = Depends(get_session),
931
+ ) -> MigrationHistoryResponse:
932
+ """Get a migration history entry by ID."""
933
+ result = await session.execute(
934
+ select(TierMigrationHistoryModel).where(TierMigrationHistoryModel.id == migration_id)
935
+ )
936
+ migration = result.scalar_one_or_none()
937
+
938
+ if not migration:
939
+ raise HTTPException(status_code=404, detail="Migration history entry not found")
940
+
941
+ # Get tier names
942
+ tier_names: dict[str, str] = {}
943
+ tiers_result = await session.execute(
944
+ select(StorageTierModel).where(
945
+ StorageTierModel.id.in_([migration.from_tier_id, migration.to_tier_id])
946
+ )
947
+ )
948
+ for tier in tiers_result.scalars():
949
+ tier_names[tier.id] = tier.name
950
+
951
+ # Get policy name
952
+ policy_name = None
953
+ if migration.policy_id:
954
+ policy_result = await session.execute(
955
+ select(TierPolicyModel).where(TierPolicyModel.id == migration.policy_id)
956
+ )
957
+ policy = policy_result.scalar_one_or_none()
958
+ if policy:
959
+ policy_name = policy.name
960
+
961
+ return _migration_to_response(
962
+ migration,
963
+ from_tier_name=tier_names.get(migration.from_tier_id),
964
+ to_tier_name=tier_names.get(migration.to_tier_id),
965
+ policy_name=policy_name,
966
+ )
967
+
968
+
969
+ # =============================================================================
970
+ # Statistics Endpoints
971
+ # =============================================================================
972
+
973
+
974
+ @router.get("/stats", response_model=TieringStatistics)
975
+ async def get_tiering_statistics(
976
+ session: AsyncSession = Depends(get_session),
977
+ ) -> TieringStatistics:
978
+ """Get overall tiering statistics."""
979
+ # Count tiers
980
+ total_tiers = (
981
+ await session.execute(select(func.count()).select_from(StorageTierModel))
982
+ ).scalar() or 0
983
+ active_tiers = (
984
+ await session.execute(
985
+ select(func.count())
986
+ .select_from(StorageTierModel)
987
+ .where(StorageTierModel.is_active == True) # noqa: E712
988
+ )
989
+ ).scalar() or 0
990
+
991
+ # Count policies
992
+ total_policies = (
993
+ await session.execute(select(func.count()).select_from(TierPolicyModel))
994
+ ).scalar() or 0
995
+ active_policies = (
996
+ await session.execute(
997
+ select(func.count())
998
+ .select_from(TierPolicyModel)
999
+ .where(TierPolicyModel.is_active == True) # noqa: E712
1000
+ )
1001
+ ).scalar() or 0
1002
+ composite_policies = (
1003
+ await session.execute(
1004
+ select(func.count())
1005
+ .select_from(TierPolicyModel)
1006
+ .where(TierPolicyModel.policy_type == TierPolicyType.COMPOSITE.value)
1007
+ )
1008
+ ).scalar() or 0
1009
+
1010
+ # Count migrations
1011
+ total_migrations = (
1012
+ await session.execute(select(func.count()).select_from(TierMigrationHistoryModel))
1013
+ ).scalar() or 0
1014
+ successful_migrations = (
1015
+ await session.execute(
1016
+ select(func.count())
1017
+ .select_from(TierMigrationHistoryModel)
1018
+ .where(TierMigrationHistoryModel.status == "completed")
1019
+ )
1020
+ ).scalar() or 0
1021
+ failed_migrations = (
1022
+ await session.execute(
1023
+ select(func.count())
1024
+ .select_from(TierMigrationHistoryModel)
1025
+ .where(TierMigrationHistoryModel.status == "failed")
1026
+ )
1027
+ ).scalar() or 0
1028
+
1029
+ # Total bytes migrated
1030
+ total_bytes_migrated = (
1031
+ await session.execute(
1032
+ select(func.sum(TierMigrationHistoryModel.size_bytes))
1033
+ .where(TierMigrationHistoryModel.status == "completed")
1034
+ )
1035
+ ).scalar() or 0
1036
+
1037
+ # Per-tier statistics
1038
+ tier_stats: list[TierStatistics] = []
1039
+ tiers_result = await session.execute(select(StorageTierModel))
1040
+ for tier in tiers_result.scalars():
1041
+ # Count policies for this tier
1042
+ policy_count = (
1043
+ await session.execute(
1044
+ select(func.count())
1045
+ .select_from(TierPolicyModel)
1046
+ .where(TierPolicyModel.from_tier_id == tier.id)
1047
+ )
1048
+ ).scalar() or 0
1049
+
1050
+ tier_stats.append(
1051
+ TierStatistics(
1052
+ tier_id=tier.id,
1053
+ tier_name=tier.name,
1054
+ tier_type=TierTypeEnum(tier.tier_type),
1055
+ item_count=0, # Would need to track items per tier
1056
+ total_size_bytes=0,
1057
+ total_size_gb=0.0,
1058
+ estimated_cost=None,
1059
+ policy_count=policy_count,
1060
+ )
1061
+ )
1062
+
1063
+ return TieringStatistics(
1064
+ total_tiers=total_tiers,
1065
+ active_tiers=active_tiers,
1066
+ total_policies=total_policies,
1067
+ active_policies=active_policies,
1068
+ composite_policies=composite_policies,
1069
+ total_migrations=total_migrations,
1070
+ successful_migrations=successful_migrations,
1071
+ failed_migrations=failed_migrations,
1072
+ total_bytes_migrated=total_bytes_migrated,
1073
+ tier_stats=tier_stats,
1074
+ )
1075
+
1076
+
1077
+ # =============================================================================
1078
+ # Policy Execution Endpoints
1079
+ # =============================================================================
1080
+
1081
+
1082
+ @router.post("/policies/{policy_id}/execute", response_model=PolicyExecutionResponse)
1083
+ async def execute_policy(
1084
+ policy_id: str,
1085
+ request: PolicyExecutionRequest | None = None,
1086
+ session: AsyncSession = Depends(get_session),
1087
+ ) -> PolicyExecutionResponse:
1088
+ """Execute a tier policy to migrate eligible items.
1089
+
1090
+ This endpoint triggers the actual migration of items based on the policy
1091
+ rules. Use dry_run=True to preview what would be migrated without making
1092
+ actual changes.
1093
+
1094
+ Args:
1095
+ policy_id: Policy ID to execute.
1096
+ request: Execution options (dry_run, batch_size).
1097
+ session: Database session.
1098
+
1099
+ Returns:
1100
+ Execution result with migration details.
1101
+ """
1102
+ from ..core.tiering import TieringService
1103
+
1104
+ dry_run = request.dry_run if request else False
1105
+ batch_size = request.batch_size if request else 100
1106
+
1107
+ service = TieringService(session)
1108
+
1109
+ try:
1110
+ result = await service.execute_policy(
1111
+ policy_id=policy_id,
1112
+ dry_run=dry_run,
1113
+ batch_size=batch_size,
1114
+ )
1115
+
1116
+ return PolicyExecutionResponse(
1117
+ policy_id=policy_id,
1118
+ dry_run=result.dry_run,
1119
+ start_time=result.start_time,
1120
+ end_time=result.end_time,
1121
+ duration_seconds=result.duration_seconds,
1122
+ items_scanned=result.items_scanned,
1123
+ items_migrated=result.items_migrated,
1124
+ items_failed=result.items_failed,
1125
+ bytes_migrated=result.bytes_migrated,
1126
+ success_rate=result.success_rate,
1127
+ errors=result.errors,
1128
+ migrations=[
1129
+ MigrationItemResponse(
1130
+ item_id=m.item_id,
1131
+ from_tier=m.from_tier,
1132
+ to_tier=m.to_tier,
1133
+ success=m.success,
1134
+ size_bytes=m.size_bytes,
1135
+ error_message=m.error_message,
1136
+ duration_ms=m.duration_ms,
1137
+ )
1138
+ for m in result.migrations
1139
+ ],
1140
+ )
1141
+ except ValueError as e:
1142
+ raise HTTPException(status_code=404, detail=str(e))
1143
+ except Exception as e:
1144
+ raise HTTPException(status_code=500, detail=f"Policy execution failed: {str(e)}")
1145
+
1146
+
1147
+ @router.post("/items/{item_id}/migrate", response_model=MigrationItemResponse)
1148
+ async def migrate_item(
1149
+ item_id: str,
1150
+ request: MigrateItemRequest,
1151
+ session: AsyncSession = Depends(get_session),
1152
+ ) -> MigrationItemResponse:
1153
+ """Migrate a single item between tiers.
1154
+
1155
+ Args:
1156
+ item_id: Item ID to migrate.
1157
+ request: Migration request with source and destination tiers.
1158
+ session: Database session.
1159
+
1160
+ Returns:
1161
+ Migration result.
1162
+ """
1163
+ from ..core.tiering import TieringService
1164
+
1165
+ service = TieringService(session)
1166
+
1167
+ try:
1168
+ result = await service.migrate_item(
1169
+ item_id=item_id,
1170
+ from_tier_id=request.from_tier_id,
1171
+ to_tier_id=request.to_tier_id,
1172
+ )
1173
+
1174
+ return MigrationItemResponse(
1175
+ item_id=result.item_id,
1176
+ from_tier=result.from_tier,
1177
+ to_tier=result.to_tier,
1178
+ success=result.success,
1179
+ size_bytes=result.size_bytes,
1180
+ error_message=result.error_message,
1181
+ duration_ms=result.duration_ms,
1182
+ )
1183
+ except ValueError as e:
1184
+ raise HTTPException(status_code=404, detail=str(e))
1185
+ except Exception as e:
1186
+ raise HTTPException(status_code=500, detail=f"Migration failed: {str(e)}")
1187
+
1188
+
1189
+ @router.post("/items/{item_id}/access", response_model=MessageResponse)
1190
+ async def record_item_access(
1191
+ item_id: str,
1192
+ tier_id: str = Query(..., description="Current tier ID of the item"),
1193
+ session: AsyncSession = Depends(get_session),
1194
+ ) -> MessageResponse:
1195
+ """Record an access to an item for intelligent tiering.
1196
+
1197
+ Call this when an item is accessed to track access patterns for
1198
+ access-based tiering policies.
1199
+
1200
+ Args:
1201
+ item_id: Accessed item ID.
1202
+ tier_id: Current tier ID.
1203
+ session: Database session.
1204
+
1205
+ Returns:
1206
+ Success message.
1207
+ """
1208
+ from ..core.tiering import TieringService
1209
+
1210
+ service = TieringService(session)
1211
+ await service.record_access(item_id, tier_id)
1212
+
1213
+ return MessageResponse(message=f"Access recorded for item '{item_id}'")
1214
+
1215
+
1216
+ @router.post("/process", response_model=ProcessPoliciesResponse)
1217
+ async def process_all_policies(
1218
+ session: AsyncSession = Depends(get_session),
1219
+ ) -> ProcessPoliciesResponse:
1220
+ """Process all active tiering policies.
1221
+
1222
+ This endpoint triggers evaluation and execution of all active policies.
1223
+ Use this for manual triggering or testing. In production, this is typically
1224
+ called by the background scheduler.
1225
+
1226
+ Returns:
1227
+ Summary of all policy executions.
1228
+ """
1229
+ from ..core.tiering import TieringService
1230
+
1231
+ service = TieringService(session)
1232
+
1233
+ try:
1234
+ results = await service.process_due_policies()
1235
+
1236
+ total_scanned = sum(r.items_scanned for r in results)
1237
+ total_migrated = sum(r.items_migrated for r in results)
1238
+ total_failed = sum(r.items_failed for r in results)
1239
+ total_bytes = sum(r.bytes_migrated for r in results)
1240
+ all_errors = [e for r in results for e in r.errors]
1241
+
1242
+ return ProcessPoliciesResponse(
1243
+ policies_executed=len(results),
1244
+ total_items_scanned=total_scanned,
1245
+ total_items_migrated=total_migrated,
1246
+ total_items_failed=total_failed,
1247
+ total_bytes_migrated=total_bytes,
1248
+ errors=all_errors,
1249
+ policy_results=[
1250
+ PolicyExecutionSummary(
1251
+ items_scanned=r.items_scanned,
1252
+ items_migrated=r.items_migrated,
1253
+ items_failed=r.items_failed,
1254
+ bytes_migrated=r.bytes_migrated,
1255
+ duration_seconds=r.duration_seconds,
1256
+ success_rate=r.success_rate,
1257
+ errors=r.errors,
1258
+ )
1259
+ for r in results
1260
+ ],
1261
+ )
1262
+ except Exception as e:
1263
+ raise HTTPException(status_code=500, detail=f"Policy processing failed: {str(e)}")
1264
+
1265
+
1266
+ @router.get("/status", response_model=TieringStatusResponse)
1267
+ async def get_tiering_status(
1268
+ session: AsyncSession = Depends(get_session),
1269
+ ) -> TieringStatusResponse:
1270
+ """Get the current tiering system status.
1271
+
1272
+ Returns information about truthound integration, active configurations,
1273
+ and system health.
1274
+ """
1275
+ from ..core.tiering import get_tiering_adapter
1276
+
1277
+ adapter = get_tiering_adapter()
1278
+
1279
+ # Get active config
1280
+ config_result = await session.execute(
1281
+ select(TieringConfigModel).where(TieringConfigModel.is_active == True) # noqa: E712
1282
+ )
1283
+ active_config = config_result.scalar_one_or_none()
1284
+
1285
+ # Get counts
1286
+ active_tiers = (
1287
+ await session.execute(
1288
+ select(func.count())
1289
+ .select_from(StorageTierModel)
1290
+ .where(StorageTierModel.is_active == True) # noqa: E712
1291
+ )
1292
+ ).scalar() or 0
1293
+
1294
+ active_policies = (
1295
+ await session.execute(
1296
+ select(func.count())
1297
+ .select_from(TierPolicyModel)
1298
+ .where(TierPolicyModel.is_active == True) # noqa: E712
1299
+ )
1300
+ ).scalar() or 0
1301
+
1302
+ # Recent migrations
1303
+ recent_migrations = (
1304
+ await session.execute(
1305
+ select(func.count())
1306
+ .select_from(TierMigrationHistoryModel)
1307
+ .where(
1308
+ TierMigrationHistoryModel.started_at
1309
+ >= func.datetime("now", "-24 hours")
1310
+ )
1311
+ )
1312
+ ).scalar() or 0
1313
+
1314
+ return TieringStatusResponse(
1315
+ truthound_available=adapter.is_available,
1316
+ tiering_enabled=active_config is not None and active_config.is_active,
1317
+ active_config_id=active_config.id if active_config else None,
1318
+ active_config_name=active_config.name if active_config else None,
1319
+ check_interval_hours=active_config.check_interval_hours if active_config else None,
1320
+ active_tiers=active_tiers,
1321
+ active_policies=active_policies,
1322
+ migrations_last_24h=recent_migrations,
1323
+ )