truthound-dashboard 1.1.0__py3-none-any.whl → 1.2.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.
- truthound_dashboard/api/catalog.py +343 -0
- truthound_dashboard/api/collaboration.py +148 -0
- truthound_dashboard/api/glossary.py +329 -0
- truthound_dashboard/api/router.py +29 -0
- truthound_dashboard/core/__init__.py +12 -0
- truthound_dashboard/core/phase5/__init__.py +17 -0
- truthound_dashboard/core/phase5/activity.py +144 -0
- truthound_dashboard/core/phase5/catalog.py +868 -0
- truthound_dashboard/core/phase5/collaboration.py +305 -0
- truthound_dashboard/core/phase5/glossary.py +828 -0
- truthound_dashboard/db/__init__.py +37 -0
- truthound_dashboard/db/models.py +693 -0
- truthound_dashboard/schemas/__init__.py +114 -0
- truthound_dashboard/schemas/catalog.py +352 -0
- truthound_dashboard/schemas/collaboration.py +169 -0
- truthound_dashboard/schemas/glossary.py +349 -0
- {truthound_dashboard-1.1.0.dist-info → truthound_dashboard-1.2.0.dist-info}/METADATA +21 -1
- {truthound_dashboard-1.1.0.dist-info → truthound_dashboard-1.2.0.dist-info}/RECORD +21 -10
- {truthound_dashboard-1.1.0.dist-info → truthound_dashboard-1.2.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.1.0.dist-info → truthound_dashboard-1.2.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.1.0.dist-info → truthound_dashboard-1.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"""Catalog service for Phase 5.
|
|
2
|
+
|
|
3
|
+
Provides business logic for managing data catalog assets,
|
|
4
|
+
columns, and tags.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import or_, select
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
|
|
15
|
+
from truthound_dashboard.db import (
|
|
16
|
+
ActivityAction,
|
|
17
|
+
AssetColumn,
|
|
18
|
+
AssetTag,
|
|
19
|
+
AssetType,
|
|
20
|
+
BaseRepository,
|
|
21
|
+
CatalogAsset,
|
|
22
|
+
GlossaryTerm,
|
|
23
|
+
ResourceType,
|
|
24
|
+
Source,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from .activity import ActivityLogger
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Repositories
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AssetRepository(BaseRepository[CatalogAsset]):
|
|
36
|
+
"""Repository for CatalogAsset model operations."""
|
|
37
|
+
|
|
38
|
+
model = CatalogAsset
|
|
39
|
+
|
|
40
|
+
async def search(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
query: str | None = None,
|
|
44
|
+
asset_type: str | None = None,
|
|
45
|
+
source_id: str | None = None,
|
|
46
|
+
offset: int = 0,
|
|
47
|
+
limit: int = 100,
|
|
48
|
+
) -> Sequence[CatalogAsset]:
|
|
49
|
+
"""Search assets with filters.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
query: Search query (name or description).
|
|
53
|
+
asset_type: Filter by asset type.
|
|
54
|
+
source_id: Filter by data source.
|
|
55
|
+
offset: Number to skip.
|
|
56
|
+
limit: Maximum to return.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Sequence of matching assets.
|
|
60
|
+
"""
|
|
61
|
+
filters = []
|
|
62
|
+
|
|
63
|
+
if query:
|
|
64
|
+
search_pattern = f"%{query}%"
|
|
65
|
+
filters.append(
|
|
66
|
+
or_(
|
|
67
|
+
CatalogAsset.name.ilike(search_pattern),
|
|
68
|
+
CatalogAsset.description.ilike(search_pattern),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if asset_type:
|
|
73
|
+
filters.append(CatalogAsset.asset_type == asset_type)
|
|
74
|
+
|
|
75
|
+
if source_id:
|
|
76
|
+
filters.append(CatalogAsset.source_id == source_id)
|
|
77
|
+
|
|
78
|
+
return await self.list(
|
|
79
|
+
offset=offset,
|
|
80
|
+
limit=limit,
|
|
81
|
+
filters=filters if filters else None,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def count_filtered(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
query: str | None = None,
|
|
88
|
+
asset_type: str | None = None,
|
|
89
|
+
source_id: str | None = None,
|
|
90
|
+
) -> int:
|
|
91
|
+
"""Count assets matching filters.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
query: Search query.
|
|
95
|
+
asset_type: Filter by asset type.
|
|
96
|
+
source_id: Filter by data source.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Total count.
|
|
100
|
+
"""
|
|
101
|
+
filters = []
|
|
102
|
+
|
|
103
|
+
if query:
|
|
104
|
+
search_pattern = f"%{query}%"
|
|
105
|
+
filters.append(
|
|
106
|
+
or_(
|
|
107
|
+
CatalogAsset.name.ilike(search_pattern),
|
|
108
|
+
CatalogAsset.description.ilike(search_pattern),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if asset_type:
|
|
113
|
+
filters.append(CatalogAsset.asset_type == asset_type)
|
|
114
|
+
|
|
115
|
+
if source_id:
|
|
116
|
+
filters.append(CatalogAsset.source_id == source_id)
|
|
117
|
+
|
|
118
|
+
return await self.count(filters if filters else None)
|
|
119
|
+
|
|
120
|
+
async def get_by_source(
|
|
121
|
+
self,
|
|
122
|
+
source_id: str,
|
|
123
|
+
*,
|
|
124
|
+
limit: int = 100,
|
|
125
|
+
) -> Sequence[CatalogAsset]:
|
|
126
|
+
"""Get assets for a data source.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
source_id: Data source ID.
|
|
130
|
+
limit: Maximum to return.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Sequence of assets.
|
|
134
|
+
"""
|
|
135
|
+
return await self.list(
|
|
136
|
+
limit=limit,
|
|
137
|
+
filters=[CatalogAsset.source_id == source_id],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ColumnRepository(BaseRepository[AssetColumn]):
|
|
142
|
+
"""Repository for AssetColumn model operations."""
|
|
143
|
+
|
|
144
|
+
model = AssetColumn
|
|
145
|
+
|
|
146
|
+
async def get_for_asset(
|
|
147
|
+
self,
|
|
148
|
+
asset_id: str,
|
|
149
|
+
*,
|
|
150
|
+
limit: int = 500,
|
|
151
|
+
) -> Sequence[AssetColumn]:
|
|
152
|
+
"""Get columns for an asset.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
asset_id: Asset ID.
|
|
156
|
+
limit: Maximum to return.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Sequence of columns.
|
|
160
|
+
"""
|
|
161
|
+
return await self.list(
|
|
162
|
+
limit=limit,
|
|
163
|
+
filters=[AssetColumn.asset_id == asset_id],
|
|
164
|
+
order_by=AssetColumn.name,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def get_by_term(
|
|
168
|
+
self,
|
|
169
|
+
term_id: str,
|
|
170
|
+
*,
|
|
171
|
+
limit: int = 100,
|
|
172
|
+
) -> Sequence[AssetColumn]:
|
|
173
|
+
"""Get columns mapped to a term.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
term_id: Term ID.
|
|
177
|
+
limit: Maximum to return.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Sequence of columns.
|
|
181
|
+
"""
|
|
182
|
+
return await self.list(
|
|
183
|
+
limit=limit,
|
|
184
|
+
filters=[AssetColumn.term_id == term_id],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TagRepository(BaseRepository[AssetTag]):
|
|
189
|
+
"""Repository for AssetTag model operations."""
|
|
190
|
+
|
|
191
|
+
model = AssetTag
|
|
192
|
+
|
|
193
|
+
async def get_for_asset(
|
|
194
|
+
self,
|
|
195
|
+
asset_id: str,
|
|
196
|
+
) -> list[AssetTag]:
|
|
197
|
+
"""Get tags for an asset.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
asset_id: Asset ID.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
List of tags.
|
|
204
|
+
"""
|
|
205
|
+
result = await self.session.execute(
|
|
206
|
+
select(AssetTag)
|
|
207
|
+
.where(AssetTag.asset_id == asset_id)
|
|
208
|
+
.order_by(AssetTag.tag_name)
|
|
209
|
+
)
|
|
210
|
+
return list(result.scalars().all())
|
|
211
|
+
|
|
212
|
+
async def get_existing(
|
|
213
|
+
self,
|
|
214
|
+
asset_id: str,
|
|
215
|
+
tag_name: str,
|
|
216
|
+
) -> AssetTag | None:
|
|
217
|
+
"""Get existing tag by name.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
asset_id: Asset ID.
|
|
221
|
+
tag_name: Tag name.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Existing tag or None.
|
|
225
|
+
"""
|
|
226
|
+
result = await self.session.execute(
|
|
227
|
+
select(AssetTag).where(
|
|
228
|
+
AssetTag.asset_id == asset_id,
|
|
229
|
+
AssetTag.tag_name == tag_name,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return result.scalar_one_or_none()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# =============================================================================
|
|
236
|
+
# Service
|
|
237
|
+
# =============================================================================
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class CatalogService:
|
|
241
|
+
"""Service for managing data catalog.
|
|
242
|
+
|
|
243
|
+
Handles asset, column, and tag CRUD operations
|
|
244
|
+
with activity logging.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
248
|
+
"""Initialize service.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
session: Database session.
|
|
252
|
+
"""
|
|
253
|
+
self.session = session
|
|
254
|
+
self.asset_repo = AssetRepository(session)
|
|
255
|
+
self.column_repo = ColumnRepository(session)
|
|
256
|
+
self.tag_repo = TagRepository(session)
|
|
257
|
+
self.activity_logger = ActivityLogger(session)
|
|
258
|
+
|
|
259
|
+
# =========================================================================
|
|
260
|
+
# Asset Operations
|
|
261
|
+
# =========================================================================
|
|
262
|
+
|
|
263
|
+
async def list_assets(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
query: str | None = None,
|
|
267
|
+
asset_type: str | None = None,
|
|
268
|
+
source_id: str | None = None,
|
|
269
|
+
offset: int = 0,
|
|
270
|
+
limit: int = 100,
|
|
271
|
+
) -> tuple[Sequence[CatalogAsset], int]:
|
|
272
|
+
"""List assets with filters.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
query: Search query.
|
|
276
|
+
asset_type: Filter by type.
|
|
277
|
+
source_id: Filter by data source.
|
|
278
|
+
offset: Number to skip.
|
|
279
|
+
limit: Maximum to return.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Tuple of (assets, total_count).
|
|
283
|
+
"""
|
|
284
|
+
assets = await self.asset_repo.search(
|
|
285
|
+
query=query,
|
|
286
|
+
asset_type=asset_type,
|
|
287
|
+
source_id=source_id,
|
|
288
|
+
offset=offset,
|
|
289
|
+
limit=limit,
|
|
290
|
+
)
|
|
291
|
+
total = await self.asset_repo.count_filtered(
|
|
292
|
+
query=query,
|
|
293
|
+
asset_type=asset_type,
|
|
294
|
+
source_id=source_id,
|
|
295
|
+
)
|
|
296
|
+
return assets, total
|
|
297
|
+
|
|
298
|
+
async def get_asset(self, asset_id: str) -> CatalogAsset | None:
|
|
299
|
+
"""Get asset by ID.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
asset_id: Asset ID.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Asset or None.
|
|
306
|
+
"""
|
|
307
|
+
return await self.asset_repo.get_by_id(asset_id)
|
|
308
|
+
|
|
309
|
+
async def create_asset(
|
|
310
|
+
self,
|
|
311
|
+
*,
|
|
312
|
+
name: str,
|
|
313
|
+
asset_type: str = AssetType.TABLE.value,
|
|
314
|
+
source_id: str | None = None,
|
|
315
|
+
description: str | None = None,
|
|
316
|
+
owner_id: str | None = None,
|
|
317
|
+
columns: list[dict[str, Any]] | None = None,
|
|
318
|
+
tags: list[dict[str, Any]] | None = None,
|
|
319
|
+
actor_id: str | None = None,
|
|
320
|
+
) -> CatalogAsset:
|
|
321
|
+
"""Create a new asset.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
name: Asset name.
|
|
325
|
+
asset_type: Type of asset.
|
|
326
|
+
source_id: Optional data source ID.
|
|
327
|
+
description: Optional description.
|
|
328
|
+
owner_id: Owner identifier.
|
|
329
|
+
columns: Initial columns to create.
|
|
330
|
+
tags: Initial tags to add.
|
|
331
|
+
actor_id: User creating the asset.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Created asset.
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
ValueError: If source not found.
|
|
338
|
+
"""
|
|
339
|
+
# Validate source if provided
|
|
340
|
+
if source_id:
|
|
341
|
+
result = await self.session.execute(
|
|
342
|
+
select(Source).where(Source.id == source_id)
|
|
343
|
+
)
|
|
344
|
+
source = result.scalar_one_or_none()
|
|
345
|
+
if not source:
|
|
346
|
+
raise ValueError(f"Data source '{source_id}' not found")
|
|
347
|
+
|
|
348
|
+
asset = await self.asset_repo.create(
|
|
349
|
+
name=name,
|
|
350
|
+
asset_type=asset_type,
|
|
351
|
+
source_id=source_id,
|
|
352
|
+
description=description,
|
|
353
|
+
owner_id=owner_id,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Create initial columns
|
|
357
|
+
if columns:
|
|
358
|
+
for col_data in columns:
|
|
359
|
+
await self.column_repo.create(
|
|
360
|
+
asset_id=asset.id,
|
|
361
|
+
**col_data,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Create initial tags
|
|
365
|
+
if tags:
|
|
366
|
+
for tag_data in tags:
|
|
367
|
+
await self.tag_repo.create(
|
|
368
|
+
asset_id=asset.id,
|
|
369
|
+
**tag_data,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
await self.session.flush()
|
|
373
|
+
await self.session.refresh(asset)
|
|
374
|
+
|
|
375
|
+
await self.activity_logger.log(
|
|
376
|
+
ResourceType.ASSET,
|
|
377
|
+
asset.id,
|
|
378
|
+
ActivityAction.CREATED,
|
|
379
|
+
actor_id=actor_id,
|
|
380
|
+
description=f"Created asset: {asset.name}",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return asset
|
|
384
|
+
|
|
385
|
+
async def update_asset(
|
|
386
|
+
self,
|
|
387
|
+
asset_id: str,
|
|
388
|
+
*,
|
|
389
|
+
name: str | None = None,
|
|
390
|
+
asset_type: str | None = None,
|
|
391
|
+
source_id: str | None = None,
|
|
392
|
+
description: str | None = None,
|
|
393
|
+
owner_id: str | None = None,
|
|
394
|
+
quality_score: float | None = None,
|
|
395
|
+
actor_id: str | None = None,
|
|
396
|
+
) -> CatalogAsset | None:
|
|
397
|
+
"""Update an asset.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
asset_id: Asset ID.
|
|
401
|
+
name: New name.
|
|
402
|
+
asset_type: New type.
|
|
403
|
+
source_id: New source ID.
|
|
404
|
+
description: New description.
|
|
405
|
+
owner_id: New owner.
|
|
406
|
+
quality_score: New quality score.
|
|
407
|
+
actor_id: User updating the asset.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Updated asset or None.
|
|
411
|
+
|
|
412
|
+
Raises:
|
|
413
|
+
ValueError: If source not found.
|
|
414
|
+
"""
|
|
415
|
+
asset = await self.asset_repo.get_by_id(asset_id)
|
|
416
|
+
if not asset:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
changes = {}
|
|
420
|
+
|
|
421
|
+
if name is not None and name != asset.name:
|
|
422
|
+
changes["name"] = {"old": asset.name, "new": name}
|
|
423
|
+
asset.name = name
|
|
424
|
+
|
|
425
|
+
if asset_type is not None and asset_type != asset.asset_type:
|
|
426
|
+
changes["asset_type"] = {"old": asset.asset_type, "new": asset_type}
|
|
427
|
+
asset.asset_type = asset_type
|
|
428
|
+
|
|
429
|
+
if source_id is not None and source_id != asset.source_id:
|
|
430
|
+
if source_id:
|
|
431
|
+
result = await self.session.execute(
|
|
432
|
+
select(Source).where(Source.id == source_id)
|
|
433
|
+
)
|
|
434
|
+
source = result.scalar_one_or_none()
|
|
435
|
+
if not source:
|
|
436
|
+
raise ValueError(f"Data source '{source_id}' not found")
|
|
437
|
+
changes["source_id"] = {"old": asset.source_id, "new": source_id}
|
|
438
|
+
asset.source_id = source_id
|
|
439
|
+
|
|
440
|
+
if description is not None and description != asset.description:
|
|
441
|
+
changes["description"] = {"old": asset.description, "new": description}
|
|
442
|
+
asset.description = description
|
|
443
|
+
|
|
444
|
+
if owner_id is not None and owner_id != asset.owner_id:
|
|
445
|
+
changes["owner_id"] = {"old": asset.owner_id, "new": owner_id}
|
|
446
|
+
asset.owner_id = owner_id
|
|
447
|
+
|
|
448
|
+
if quality_score is not None and quality_score != asset.quality_score:
|
|
449
|
+
changes["quality_score"] = {"old": asset.quality_score, "new": quality_score}
|
|
450
|
+
asset.update_quality_score(quality_score)
|
|
451
|
+
|
|
452
|
+
if changes:
|
|
453
|
+
await self.session.flush()
|
|
454
|
+
await self.session.refresh(asset)
|
|
455
|
+
await self.activity_logger.log(
|
|
456
|
+
ResourceType.ASSET,
|
|
457
|
+
asset.id,
|
|
458
|
+
ActivityAction.UPDATED,
|
|
459
|
+
actor_id=actor_id,
|
|
460
|
+
description=f"Updated asset: {asset.name}",
|
|
461
|
+
metadata={"changes": changes},
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return asset
|
|
465
|
+
|
|
466
|
+
async def delete_asset(
|
|
467
|
+
self,
|
|
468
|
+
asset_id: str,
|
|
469
|
+
*,
|
|
470
|
+
actor_id: str | None = None,
|
|
471
|
+
) -> bool:
|
|
472
|
+
"""Delete an asset.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
asset_id: Asset ID.
|
|
476
|
+
actor_id: User deleting the asset.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
True if deleted.
|
|
480
|
+
"""
|
|
481
|
+
asset = await self.asset_repo.get_by_id(asset_id)
|
|
482
|
+
if not asset:
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
asset_name = asset.name
|
|
486
|
+
deleted = await self.asset_repo.delete(asset_id)
|
|
487
|
+
|
|
488
|
+
if deleted:
|
|
489
|
+
await self.activity_logger.log(
|
|
490
|
+
ResourceType.ASSET,
|
|
491
|
+
asset_id,
|
|
492
|
+
ActivityAction.DELETED,
|
|
493
|
+
actor_id=actor_id,
|
|
494
|
+
description=f"Deleted asset: {asset_name}",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
return deleted
|
|
498
|
+
|
|
499
|
+
# =========================================================================
|
|
500
|
+
# Column Operations
|
|
501
|
+
# =========================================================================
|
|
502
|
+
|
|
503
|
+
async def get_columns(
|
|
504
|
+
self,
|
|
505
|
+
asset_id: str,
|
|
506
|
+
) -> Sequence[AssetColumn]:
|
|
507
|
+
"""Get columns for an asset.
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
asset_id: Asset ID.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
Sequence of columns.
|
|
514
|
+
"""
|
|
515
|
+
return await self.column_repo.get_for_asset(asset_id)
|
|
516
|
+
|
|
517
|
+
async def get_column(self, column_id: str) -> AssetColumn | None:
|
|
518
|
+
"""Get column by ID.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
column_id: Column ID.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Column or None.
|
|
525
|
+
"""
|
|
526
|
+
return await self.column_repo.get_by_id(column_id)
|
|
527
|
+
|
|
528
|
+
async def create_column(
|
|
529
|
+
self,
|
|
530
|
+
asset_id: str,
|
|
531
|
+
*,
|
|
532
|
+
name: str,
|
|
533
|
+
data_type: str | None = None,
|
|
534
|
+
description: str | None = None,
|
|
535
|
+
is_nullable: bool = True,
|
|
536
|
+
is_primary_key: bool = False,
|
|
537
|
+
sensitivity_level: str | None = None,
|
|
538
|
+
actor_id: str | None = None,
|
|
539
|
+
) -> AssetColumn:
|
|
540
|
+
"""Create a new column.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
asset_id: Asset ID.
|
|
544
|
+
name: Column name.
|
|
545
|
+
data_type: Data type.
|
|
546
|
+
description: Description.
|
|
547
|
+
is_nullable: Whether nullable.
|
|
548
|
+
is_primary_key: Whether PK.
|
|
549
|
+
sensitivity_level: Sensitivity level.
|
|
550
|
+
actor_id: User creating the column.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Created column.
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
ValueError: If asset not found.
|
|
557
|
+
"""
|
|
558
|
+
asset = await self.asset_repo.get_by_id(asset_id)
|
|
559
|
+
if not asset:
|
|
560
|
+
raise ValueError(f"Asset '{asset_id}' not found")
|
|
561
|
+
|
|
562
|
+
column = await self.column_repo.create(
|
|
563
|
+
asset_id=asset_id,
|
|
564
|
+
name=name,
|
|
565
|
+
data_type=data_type,
|
|
566
|
+
description=description,
|
|
567
|
+
is_nullable=is_nullable,
|
|
568
|
+
is_primary_key=is_primary_key,
|
|
569
|
+
sensitivity_level=sensitivity_level,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
await self.activity_logger.log(
|
|
573
|
+
ResourceType.COLUMN,
|
|
574
|
+
column.id,
|
|
575
|
+
ActivityAction.CREATED,
|
|
576
|
+
actor_id=actor_id,
|
|
577
|
+
description=f"Created column: {asset.name}.{column.name}",
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
return column
|
|
581
|
+
|
|
582
|
+
async def update_column(
|
|
583
|
+
self,
|
|
584
|
+
column_id: str,
|
|
585
|
+
*,
|
|
586
|
+
name: str | None = None,
|
|
587
|
+
data_type: str | None = None,
|
|
588
|
+
description: str | None = None,
|
|
589
|
+
is_nullable: bool | None = None,
|
|
590
|
+
is_primary_key: bool | None = None,
|
|
591
|
+
sensitivity_level: str | None = None,
|
|
592
|
+
actor_id: str | None = None,
|
|
593
|
+
) -> AssetColumn | None:
|
|
594
|
+
"""Update a column.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
column_id: Column ID.
|
|
598
|
+
name: New name.
|
|
599
|
+
data_type: New data type.
|
|
600
|
+
description: New description.
|
|
601
|
+
is_nullable: New nullable setting.
|
|
602
|
+
is_primary_key: New PK setting.
|
|
603
|
+
sensitivity_level: New sensitivity level.
|
|
604
|
+
actor_id: User updating the column.
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
Updated column or None.
|
|
608
|
+
"""
|
|
609
|
+
column = await self.column_repo.get_by_id(column_id)
|
|
610
|
+
if not column:
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
changes = {}
|
|
614
|
+
|
|
615
|
+
if name is not None and name != column.name:
|
|
616
|
+
changes["name"] = {"old": column.name, "new": name}
|
|
617
|
+
column.name = name
|
|
618
|
+
|
|
619
|
+
if data_type is not None and data_type != column.data_type:
|
|
620
|
+
changes["data_type"] = {"old": column.data_type, "new": data_type}
|
|
621
|
+
column.data_type = data_type
|
|
622
|
+
|
|
623
|
+
if description is not None and description != column.description:
|
|
624
|
+
changes["description"] = {"old": column.description, "new": description}
|
|
625
|
+
column.description = description
|
|
626
|
+
|
|
627
|
+
if is_nullable is not None and is_nullable != column.is_nullable:
|
|
628
|
+
changes["is_nullable"] = {"old": column.is_nullable, "new": is_nullable}
|
|
629
|
+
column.is_nullable = is_nullable
|
|
630
|
+
|
|
631
|
+
if is_primary_key is not None and is_primary_key != column.is_primary_key:
|
|
632
|
+
changes["is_primary_key"] = {"old": column.is_primary_key, "new": is_primary_key}
|
|
633
|
+
column.is_primary_key = is_primary_key
|
|
634
|
+
|
|
635
|
+
if sensitivity_level is not None and sensitivity_level != column.sensitivity_level:
|
|
636
|
+
changes["sensitivity_level"] = {"old": column.sensitivity_level, "new": sensitivity_level}
|
|
637
|
+
column.sensitivity_level = sensitivity_level
|
|
638
|
+
|
|
639
|
+
if changes:
|
|
640
|
+
await self.session.flush()
|
|
641
|
+
await self.session.refresh(column)
|
|
642
|
+
await self.activity_logger.log(
|
|
643
|
+
ResourceType.COLUMN,
|
|
644
|
+
column.id,
|
|
645
|
+
ActivityAction.UPDATED,
|
|
646
|
+
actor_id=actor_id,
|
|
647
|
+
description=f"Updated column: {column.name}",
|
|
648
|
+
metadata={"changes": changes},
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
return column
|
|
652
|
+
|
|
653
|
+
async def delete_column(
|
|
654
|
+
self,
|
|
655
|
+
column_id: str,
|
|
656
|
+
*,
|
|
657
|
+
actor_id: str | None = None,
|
|
658
|
+
) -> bool:
|
|
659
|
+
"""Delete a column.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
column_id: Column ID.
|
|
663
|
+
actor_id: User deleting the column.
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
True if deleted.
|
|
667
|
+
"""
|
|
668
|
+
column = await self.column_repo.get_by_id(column_id)
|
|
669
|
+
if not column:
|
|
670
|
+
return False
|
|
671
|
+
|
|
672
|
+
column_name = column.name
|
|
673
|
+
deleted = await self.column_repo.delete(column_id)
|
|
674
|
+
|
|
675
|
+
if deleted:
|
|
676
|
+
await self.activity_logger.log(
|
|
677
|
+
ResourceType.COLUMN,
|
|
678
|
+
column_id,
|
|
679
|
+
ActivityAction.DELETED,
|
|
680
|
+
actor_id=actor_id,
|
|
681
|
+
description=f"Deleted column: {column_name}",
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
return deleted
|
|
685
|
+
|
|
686
|
+
async def map_column_to_term(
|
|
687
|
+
self,
|
|
688
|
+
column_id: str,
|
|
689
|
+
term_id: str,
|
|
690
|
+
*,
|
|
691
|
+
actor_id: str | None = None,
|
|
692
|
+
) -> AssetColumn | None:
|
|
693
|
+
"""Map a column to a glossary term.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
column_id: Column ID.
|
|
697
|
+
term_id: Term ID.
|
|
698
|
+
actor_id: User creating the mapping.
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Updated column or None.
|
|
702
|
+
|
|
703
|
+
Raises:
|
|
704
|
+
ValueError: If term not found.
|
|
705
|
+
"""
|
|
706
|
+
column = await self.column_repo.get_by_id(column_id)
|
|
707
|
+
if not column:
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
# Validate term exists
|
|
711
|
+
result = await self.session.execute(
|
|
712
|
+
select(GlossaryTerm).where(GlossaryTerm.id == term_id)
|
|
713
|
+
)
|
|
714
|
+
term = result.scalar_one_or_none()
|
|
715
|
+
if not term:
|
|
716
|
+
raise ValueError(f"Term '{term_id}' not found")
|
|
717
|
+
|
|
718
|
+
column.map_to_term(term_id)
|
|
719
|
+
await self.session.flush()
|
|
720
|
+
await self.session.refresh(column)
|
|
721
|
+
|
|
722
|
+
await self.activity_logger.log(
|
|
723
|
+
ResourceType.COLUMN,
|
|
724
|
+
column.id,
|
|
725
|
+
ActivityAction.MAPPED,
|
|
726
|
+
actor_id=actor_id,
|
|
727
|
+
description=f"Mapped {column.name} to term: {term.name}",
|
|
728
|
+
metadata={"term_id": term_id, "term_name": term.name},
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
return column
|
|
732
|
+
|
|
733
|
+
async def unmap_column_from_term(
|
|
734
|
+
self,
|
|
735
|
+
column_id: str,
|
|
736
|
+
*,
|
|
737
|
+
actor_id: str | None = None,
|
|
738
|
+
) -> AssetColumn | None:
|
|
739
|
+
"""Remove term mapping from a column.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
column_id: Column ID.
|
|
743
|
+
actor_id: User removing the mapping.
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
Updated column or None.
|
|
747
|
+
"""
|
|
748
|
+
column = await self.column_repo.get_by_id(column_id)
|
|
749
|
+
if not column:
|
|
750
|
+
return None
|
|
751
|
+
|
|
752
|
+
if column.term_id is None:
|
|
753
|
+
return column
|
|
754
|
+
|
|
755
|
+
column.unmap_term()
|
|
756
|
+
await self.session.flush()
|
|
757
|
+
await self.session.refresh(column)
|
|
758
|
+
|
|
759
|
+
await self.activity_logger.log(
|
|
760
|
+
ResourceType.COLUMN,
|
|
761
|
+
column.id,
|
|
762
|
+
ActivityAction.UNMAPPED,
|
|
763
|
+
actor_id=actor_id,
|
|
764
|
+
description=f"Removed term mapping from: {column.name}",
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
return column
|
|
768
|
+
|
|
769
|
+
# =========================================================================
|
|
770
|
+
# Tag Operations
|
|
771
|
+
# =========================================================================
|
|
772
|
+
|
|
773
|
+
async def get_tags(
|
|
774
|
+
self,
|
|
775
|
+
asset_id: str,
|
|
776
|
+
) -> list[AssetTag]:
|
|
777
|
+
"""Get tags for an asset.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
asset_id: Asset ID.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
List of tags.
|
|
784
|
+
"""
|
|
785
|
+
return await self.tag_repo.get_for_asset(asset_id)
|
|
786
|
+
|
|
787
|
+
async def add_tag(
|
|
788
|
+
self,
|
|
789
|
+
asset_id: str,
|
|
790
|
+
*,
|
|
791
|
+
tag_name: str,
|
|
792
|
+
tag_value: str | None = None,
|
|
793
|
+
actor_id: str | None = None,
|
|
794
|
+
) -> AssetTag:
|
|
795
|
+
"""Add a tag to an asset.
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
asset_id: Asset ID.
|
|
799
|
+
tag_name: Tag name.
|
|
800
|
+
tag_value: Optional tag value.
|
|
801
|
+
actor_id: User adding the tag.
|
|
802
|
+
|
|
803
|
+
Returns:
|
|
804
|
+
Created tag.
|
|
805
|
+
|
|
806
|
+
Raises:
|
|
807
|
+
ValueError: If asset not found or tag exists.
|
|
808
|
+
"""
|
|
809
|
+
asset = await self.asset_repo.get_by_id(asset_id)
|
|
810
|
+
if not asset:
|
|
811
|
+
raise ValueError(f"Asset '{asset_id}' not found")
|
|
812
|
+
|
|
813
|
+
# Check for existing tag
|
|
814
|
+
existing = await self.tag_repo.get_existing(asset_id, tag_name)
|
|
815
|
+
if existing:
|
|
816
|
+
raise ValueError(f"Tag '{tag_name}' already exists on this asset")
|
|
817
|
+
|
|
818
|
+
tag = await self.tag_repo.create(
|
|
819
|
+
asset_id=asset_id,
|
|
820
|
+
tag_name=tag_name.strip().lower(),
|
|
821
|
+
tag_value=tag_value,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
await self.activity_logger.log(
|
|
825
|
+
ResourceType.ASSET,
|
|
826
|
+
asset_id,
|
|
827
|
+
"tag_added",
|
|
828
|
+
actor_id=actor_id,
|
|
829
|
+
description=f"Added tag: {tag_name}",
|
|
830
|
+
metadata={"tag_name": tag_name, "tag_value": tag_value},
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
return tag
|
|
834
|
+
|
|
835
|
+
async def remove_tag(
|
|
836
|
+
self,
|
|
837
|
+
tag_id: str,
|
|
838
|
+
*,
|
|
839
|
+
actor_id: str | None = None,
|
|
840
|
+
) -> bool:
|
|
841
|
+
"""Remove a tag.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
tag_id: Tag ID.
|
|
845
|
+
actor_id: User removing the tag.
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
True if removed.
|
|
849
|
+
"""
|
|
850
|
+
tag = await self.tag_repo.get_by_id(tag_id)
|
|
851
|
+
if not tag:
|
|
852
|
+
return False
|
|
853
|
+
|
|
854
|
+
asset_id = tag.asset_id
|
|
855
|
+
tag_name = tag.tag_name
|
|
856
|
+
deleted = await self.tag_repo.delete(tag_id)
|
|
857
|
+
|
|
858
|
+
if deleted:
|
|
859
|
+
await self.activity_logger.log(
|
|
860
|
+
ResourceType.ASSET,
|
|
861
|
+
asset_id,
|
|
862
|
+
"tag_removed",
|
|
863
|
+
actor_id=actor_id,
|
|
864
|
+
description=f"Removed tag: {tag_name}",
|
|
865
|
+
metadata={"tag_name": tag_name},
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
return deleted
|