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.
@@ -0,0 +1,329 @@
1
+ """Glossary API endpoints.
2
+
3
+ This module provides REST API endpoints for managing business glossary
4
+ terms, categories, and relationships.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Annotated
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
12
+
13
+ from truthound_dashboard.core.phase5 import GlossaryService
14
+ from truthound_dashboard.schemas import (
15
+ CategoryCreate,
16
+ CategoryListResponse,
17
+ CategoryResponse,
18
+ CategoryUpdate,
19
+ MessageResponse,
20
+ RelationshipCreate,
21
+ RelationshipListResponse,
22
+ RelationshipResponse,
23
+ TermCreate,
24
+ TermHistoryListResponse,
25
+ TermHistoryResponse,
26
+ TermListItem,
27
+ TermListResponse,
28
+ TermResponse,
29
+ TermUpdate,
30
+ )
31
+
32
+ from .deps import SessionDep
33
+
34
+ router = APIRouter()
35
+
36
+
37
+ # =============================================================================
38
+ # Dependencies
39
+ # =============================================================================
40
+
41
+
42
+ async def get_glossary_service(session: SessionDep) -> GlossaryService:
43
+ """Get glossary service dependency."""
44
+ return GlossaryService(session)
45
+
46
+
47
+ GlossaryServiceDep = Annotated[GlossaryService, Depends(get_glossary_service)]
48
+
49
+
50
+ # =============================================================================
51
+ # Term Endpoints
52
+ # =============================================================================
53
+
54
+
55
+ @router.get("/terms", response_model=TermListResponse)
56
+ async def list_terms(
57
+ service: GlossaryServiceDep,
58
+ search: Annotated[str | None, Query(description="Search query")] = None,
59
+ category_id: Annotated[str | None, Query(description="Filter by category")] = None,
60
+ status: Annotated[str | None, Query(description="Filter by status")] = None,
61
+ offset: Annotated[int, Query(ge=0)] = 0,
62
+ limit: Annotated[int, Query(ge=1, le=100)] = 100,
63
+ ) -> TermListResponse:
64
+ """List glossary terms with optional filters.
65
+
66
+ - **search**: Search in term name and definition
67
+ - **category_id**: Filter by category
68
+ - **status**: Filter by status (draft, approved, deprecated)
69
+ """
70
+ terms, total = await service.list_terms(
71
+ query=search,
72
+ category_id=category_id,
73
+ status=status,
74
+ offset=offset,
75
+ limit=limit,
76
+ )
77
+ return TermListResponse(
78
+ data=[TermListItem.from_model(t) for t in terms],
79
+ total=total,
80
+ offset=offset,
81
+ limit=limit,
82
+ )
83
+
84
+
85
+ @router.post("/terms", response_model=TermResponse, status_code=status.HTTP_201_CREATED)
86
+ async def create_term(
87
+ service: GlossaryServiceDep,
88
+ data: TermCreate,
89
+ ) -> TermResponse:
90
+ """Create a new glossary term."""
91
+ try:
92
+ term = await service.create_term(
93
+ name=data.name,
94
+ definition=data.definition,
95
+ category_id=data.category_id,
96
+ status=data.status.value,
97
+ owner_id=data.owner_id,
98
+ )
99
+ return TermResponse.from_model(term)
100
+ except ValueError as e:
101
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
102
+
103
+
104
+ @router.get("/terms/{term_id}", response_model=TermResponse)
105
+ async def get_term(
106
+ service: GlossaryServiceDep,
107
+ term_id: str,
108
+ ) -> TermResponse:
109
+ """Get a glossary term by ID."""
110
+ term = await service.get_term(term_id)
111
+ if not term:
112
+ raise HTTPException(
113
+ status_code=status.HTTP_404_NOT_FOUND,
114
+ detail=f"Term '{term_id}' not found",
115
+ )
116
+ return TermResponse.from_model(term)
117
+
118
+
119
+ @router.put("/terms/{term_id}", response_model=TermResponse)
120
+ async def update_term(
121
+ service: GlossaryServiceDep,
122
+ term_id: str,
123
+ data: TermUpdate,
124
+ ) -> TermResponse:
125
+ """Update a glossary term."""
126
+ try:
127
+ term = await service.update_term(
128
+ term_id,
129
+ name=data.name,
130
+ definition=data.definition,
131
+ category_id=data.category_id,
132
+ status=data.status.value if data.status else None,
133
+ owner_id=data.owner_id,
134
+ )
135
+ if not term:
136
+ raise HTTPException(
137
+ status_code=status.HTTP_404_NOT_FOUND,
138
+ detail=f"Term '{term_id}' not found",
139
+ )
140
+ return TermResponse.from_model(term)
141
+ except ValueError as e:
142
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
143
+
144
+
145
+ @router.delete("/terms/{term_id}", response_model=MessageResponse)
146
+ async def delete_term(
147
+ service: GlossaryServiceDep,
148
+ term_id: str,
149
+ ) -> MessageResponse:
150
+ """Delete a glossary term."""
151
+ deleted = await service.delete_term(term_id)
152
+ if not deleted:
153
+ raise HTTPException(
154
+ status_code=status.HTTP_404_NOT_FOUND,
155
+ detail=f"Term '{term_id}' not found",
156
+ )
157
+ return MessageResponse(message="Term deleted successfully")
158
+
159
+
160
+ @router.get("/terms/{term_id}/history", response_model=TermHistoryListResponse)
161
+ async def get_term_history(
162
+ service: GlossaryServiceDep,
163
+ term_id: str,
164
+ limit: Annotated[int, Query(ge=1, le=100)] = 50,
165
+ ) -> TermHistoryListResponse:
166
+ """Get change history for a term."""
167
+ # Verify term exists
168
+ term = await service.get_term(term_id)
169
+ if not term:
170
+ raise HTTPException(
171
+ status_code=status.HTTP_404_NOT_FOUND,
172
+ detail=f"Term '{term_id}' not found",
173
+ )
174
+
175
+ history = await service.get_term_history(term_id, limit=limit)
176
+ return TermHistoryListResponse(
177
+ data=[TermHistoryResponse.from_model(h) for h in history],
178
+ total=len(history),
179
+ )
180
+
181
+
182
+ # =============================================================================
183
+ # Category Endpoints
184
+ # =============================================================================
185
+
186
+
187
+ @router.get("/categories", response_model=CategoryListResponse)
188
+ async def list_categories(
189
+ service: GlossaryServiceDep,
190
+ offset: Annotated[int, Query(ge=0)] = 0,
191
+ limit: Annotated[int, Query(ge=1, le=100)] = 100,
192
+ ) -> CategoryListResponse:
193
+ """List all glossary categories."""
194
+ categories, total = await service.list_categories(offset=offset, limit=limit)
195
+ return CategoryListResponse(
196
+ data=[CategoryResponse.from_model(c) for c in categories],
197
+ total=total,
198
+ offset=offset,
199
+ limit=limit,
200
+ )
201
+
202
+
203
+ @router.post("/categories", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
204
+ async def create_category(
205
+ service: GlossaryServiceDep,
206
+ data: CategoryCreate,
207
+ ) -> CategoryResponse:
208
+ """Create a new glossary category."""
209
+ try:
210
+ category = await service.create_category(
211
+ name=data.name,
212
+ description=data.description,
213
+ parent_id=data.parent_id,
214
+ )
215
+ return CategoryResponse.from_model(category)
216
+ except ValueError as e:
217
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
218
+
219
+
220
+ @router.get("/categories/{category_id}", response_model=CategoryResponse)
221
+ async def get_category(
222
+ service: GlossaryServiceDep,
223
+ category_id: str,
224
+ ) -> CategoryResponse:
225
+ """Get a glossary category by ID."""
226
+ category = await service.get_category(category_id)
227
+ if not category:
228
+ raise HTTPException(
229
+ status_code=status.HTTP_404_NOT_FOUND,
230
+ detail=f"Category '{category_id}' not found",
231
+ )
232
+ return CategoryResponse.from_model(category)
233
+
234
+
235
+ @router.put("/categories/{category_id}", response_model=CategoryResponse)
236
+ async def update_category(
237
+ service: GlossaryServiceDep,
238
+ category_id: str,
239
+ data: CategoryUpdate,
240
+ ) -> CategoryResponse:
241
+ """Update a glossary category."""
242
+ try:
243
+ category = await service.update_category(
244
+ category_id,
245
+ name=data.name,
246
+ description=data.description,
247
+ parent_id=data.parent_id,
248
+ )
249
+ if not category:
250
+ raise HTTPException(
251
+ status_code=status.HTTP_404_NOT_FOUND,
252
+ detail=f"Category '{category_id}' not found",
253
+ )
254
+ return CategoryResponse.from_model(category)
255
+ except ValueError as e:
256
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
257
+
258
+
259
+ @router.delete("/categories/{category_id}", response_model=MessageResponse)
260
+ async def delete_category(
261
+ service: GlossaryServiceDep,
262
+ category_id: str,
263
+ ) -> MessageResponse:
264
+ """Delete a glossary category."""
265
+ deleted = await service.delete_category(category_id)
266
+ if not deleted:
267
+ raise HTTPException(
268
+ status_code=status.HTTP_404_NOT_FOUND,
269
+ detail=f"Category '{category_id}' not found",
270
+ )
271
+ return MessageResponse(message="Category deleted successfully")
272
+
273
+
274
+ # =============================================================================
275
+ # Relationship Endpoints
276
+ # =============================================================================
277
+
278
+
279
+ @router.get("/terms/{term_id}/relationships", response_model=RelationshipListResponse)
280
+ async def get_term_relationships(
281
+ service: GlossaryServiceDep,
282
+ term_id: str,
283
+ ) -> RelationshipListResponse:
284
+ """Get all relationships for a term."""
285
+ # Verify term exists
286
+ term = await service.get_term(term_id)
287
+ if not term:
288
+ raise HTTPException(
289
+ status_code=status.HTTP_404_NOT_FOUND,
290
+ detail=f"Term '{term_id}' not found",
291
+ )
292
+
293
+ relationships = await service.get_term_relationships(term_id)
294
+ return RelationshipListResponse(
295
+ data=[RelationshipResponse.from_model(r) for r in relationships],
296
+ total=len(relationships),
297
+ )
298
+
299
+
300
+ @router.post("/relationships", response_model=RelationshipResponse, status_code=status.HTTP_201_CREATED)
301
+ async def create_relationship(
302
+ service: GlossaryServiceDep,
303
+ data: RelationshipCreate,
304
+ ) -> RelationshipResponse:
305
+ """Create a relationship between terms."""
306
+ try:
307
+ relationship = await service.create_relationship(
308
+ source_term_id=data.source_term_id,
309
+ target_term_id=data.target_term_id,
310
+ relationship_type=data.relationship_type.value,
311
+ )
312
+ return RelationshipResponse.from_model(relationship)
313
+ except ValueError as e:
314
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
315
+
316
+
317
+ @router.delete("/relationships/{relationship_id}", response_model=MessageResponse)
318
+ async def delete_relationship(
319
+ service: GlossaryServiceDep,
320
+ relationship_id: str,
321
+ ) -> MessageResponse:
322
+ """Delete a term relationship."""
323
+ deleted = await service.delete_relationship(relationship_id)
324
+ if not deleted:
325
+ raise HTTPException(
326
+ status_code=status.HTTP_404_NOT_FOUND,
327
+ detail=f"Relationship '{relationship_id}' not found",
328
+ )
329
+ return MessageResponse(message="Relationship deleted successfully")
@@ -6,6 +6,7 @@ This module configures the main API router and includes all sub-routers.
6
6
  from fastapi import APIRouter
7
7
 
8
8
  from . import (
9
+ # Phase 1-4
9
10
  drift,
10
11
  health,
11
12
  history,
@@ -16,6 +17,10 @@ from . import (
16
17
  schemas,
17
18
  sources,
18
19
  validations,
20
+ # Phase 5
21
+ catalog,
22
+ collaboration,
23
+ glossary,
19
24
  )
20
25
 
21
26
  api_router = APIRouter()
@@ -81,3 +86,27 @@ api_router.include_router(
81
86
  notifications.router,
82
87
  tags=["notifications"],
83
88
  )
89
+
90
+ # =============================================================================
91
+ # Phase 5: Business Glossary & Data Catalog
92
+ # =============================================================================
93
+
94
+ # Glossary management endpoints
95
+ api_router.include_router(
96
+ glossary.router,
97
+ prefix="/glossary",
98
+ tags=["glossary"],
99
+ )
100
+
101
+ # Catalog management endpoints
102
+ api_router.include_router(
103
+ catalog.router,
104
+ prefix="/catalog",
105
+ tags=["catalog"],
106
+ )
107
+
108
+ # Collaboration endpoints (comments, activities)
109
+ api_router.include_router(
110
+ collaboration.router,
111
+ tags=["collaboration"],
112
+ )
@@ -142,6 +142,13 @@ from .truthound_adapter import (
142
142
  get_adapter,
143
143
  reset_adapter,
144
144
  )
145
+ # Phase 5 Services
146
+ from .phase5 import (
147
+ ActivityLogger,
148
+ CatalogService,
149
+ CollaborationService,
150
+ GlossaryService,
151
+ )
145
152
 
146
153
  __all__ = [
147
154
  # Base classes
@@ -261,4 +268,9 @@ __all__ = [
261
268
  "StratifiedSamplingStrategy",
262
269
  "get_sampler",
263
270
  "reset_sampler",
271
+ # Phase 5 Services
272
+ "GlossaryService",
273
+ "CatalogService",
274
+ "CollaborationService",
275
+ "ActivityLogger",
264
276
  ]
@@ -0,0 +1,17 @@
1
+ """Phase 5: Business Glossary & Data Catalog services.
2
+
3
+ This module provides services for managing business glossary terms,
4
+ data catalog assets, and collaboration features.
5
+ """
6
+
7
+ from .activity import ActivityLogger
8
+ from .catalog import CatalogService
9
+ from .glossary import GlossaryService
10
+ from .collaboration import CollaborationService
11
+
12
+ __all__ = [
13
+ "GlossaryService",
14
+ "CatalogService",
15
+ "CollaborationService",
16
+ "ActivityLogger",
17
+ ]
@@ -0,0 +1,144 @@
1
+ """Activity logging service for Phase 5.
2
+
3
+ Provides centralized activity logging for all Phase 5 operations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from sqlalchemy import select
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from truthound_dashboard.db import Activity, ActivityAction, BaseRepository, ResourceType
14
+
15
+
16
+ class ActivityRepository(BaseRepository[Activity]):
17
+ """Repository for Activity model operations."""
18
+
19
+ model = Activity
20
+
21
+ async def get_for_resource(
22
+ self,
23
+ resource_type: str,
24
+ resource_id: str,
25
+ *,
26
+ limit: int = 50,
27
+ ) -> list[Activity]:
28
+ """Get activities for a specific resource."""
29
+ result = await self.session.execute(
30
+ select(Activity)
31
+ .where(Activity.resource_type == resource_type)
32
+ .where(Activity.resource_id == resource_id)
33
+ .order_by(Activity.created_at.desc())
34
+ .limit(limit)
35
+ )
36
+ return list(result.scalars().all())
37
+
38
+ async def get_recent(
39
+ self,
40
+ *,
41
+ resource_type: str | None = None,
42
+ limit: int = 50,
43
+ ) -> list[Activity]:
44
+ """Get recent activities."""
45
+ query = select(Activity).order_by(Activity.created_at.desc()).limit(limit)
46
+
47
+ if resource_type:
48
+ query = query.where(Activity.resource_type == resource_type)
49
+
50
+ result = await self.session.execute(query)
51
+ return list(result.scalars().all())
52
+
53
+
54
+ class ActivityLogger:
55
+ """Service for logging activities.
56
+
57
+ Usage:
58
+ logger = ActivityLogger(session)
59
+ await logger.log(ResourceType.TERM, term_id, ActivityAction.CREATED,
60
+ description="Created term: Customer ID")
61
+ """
62
+
63
+ def __init__(self, session: AsyncSession) -> None:
64
+ self.session = session
65
+ self.repository = ActivityRepository(session)
66
+
67
+ async def log(
68
+ self,
69
+ resource_type: ResourceType | str,
70
+ resource_id: str,
71
+ action: ActivityAction | str,
72
+ *,
73
+ actor_id: str | None = None,
74
+ description: str | None = None,
75
+ metadata: dict[str, Any] | None = None,
76
+ ) -> Activity:
77
+ """Log an activity.
78
+
79
+ Args:
80
+ resource_type: Type of resource (term, asset, column, category).
81
+ resource_id: Resource ID.
82
+ action: Action performed (created, updated, deleted, etc).
83
+ actor_id: User who performed the action.
84
+ description: Human-readable description.
85
+ metadata: Additional metadata as JSON.
86
+
87
+ Returns:
88
+ Created activity record.
89
+ """
90
+ resource_type_value = (
91
+ resource_type.value
92
+ if isinstance(resource_type, ResourceType)
93
+ else resource_type
94
+ )
95
+ action_value = (
96
+ action.value if isinstance(action, ActivityAction) else action
97
+ )
98
+
99
+ return await self.repository.create(
100
+ resource_type=resource_type_value,
101
+ resource_id=resource_id,
102
+ action=action_value,
103
+ actor_id=actor_id,
104
+ description=description,
105
+ metadata=metadata,
106
+ )
107
+
108
+ async def get_for_resource(
109
+ self,
110
+ resource_type: ResourceType | str,
111
+ resource_id: str,
112
+ *,
113
+ limit: int = 50,
114
+ ) -> list[Activity]:
115
+ """Get activities for a specific resource."""
116
+ resource_type_value = (
117
+ resource_type.value
118
+ if isinstance(resource_type, ResourceType)
119
+ else resource_type
120
+ )
121
+ return await self.repository.get_for_resource(
122
+ resource_type_value,
123
+ resource_id,
124
+ limit=limit,
125
+ )
126
+
127
+ async def get_recent(
128
+ self,
129
+ *,
130
+ resource_type: ResourceType | str | None = None,
131
+ limit: int = 50,
132
+ ) -> list[Activity]:
133
+ """Get recent activities."""
134
+ resource_type_value = None
135
+ if resource_type:
136
+ resource_type_value = (
137
+ resource_type.value
138
+ if isinstance(resource_type, ResourceType)
139
+ else resource_type
140
+ )
141
+ return await self.repository.get_recent(
142
+ resource_type=resource_type_value,
143
+ limit=limit,
144
+ )