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,828 @@
|
|
|
1
|
+
"""Glossary service for Phase 5.
|
|
2
|
+
|
|
3
|
+
Provides business logic for managing glossary terms, categories,
|
|
4
|
+
and relationships with automatic history tracking.
|
|
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
|
+
BaseRepository,
|
|
18
|
+
GlossaryCategory,
|
|
19
|
+
GlossaryTerm,
|
|
20
|
+
ResourceType,
|
|
21
|
+
TermHistory,
|
|
22
|
+
TermRelationship,
|
|
23
|
+
TermStatus,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .activity import ActivityLogger
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# Repositories
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CategoryRepository(BaseRepository[GlossaryCategory]):
|
|
35
|
+
"""Repository for GlossaryCategory model operations."""
|
|
36
|
+
|
|
37
|
+
model = GlossaryCategory
|
|
38
|
+
|
|
39
|
+
async def get_by_name(self, name: str) -> GlossaryCategory | None:
|
|
40
|
+
"""Get category by name.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
name: Category name.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Category or None.
|
|
47
|
+
"""
|
|
48
|
+
result = await self.session.execute(
|
|
49
|
+
select(GlossaryCategory).where(GlossaryCategory.name == name)
|
|
50
|
+
)
|
|
51
|
+
return result.scalar_one_or_none()
|
|
52
|
+
|
|
53
|
+
async def get_root_categories(self, *, limit: int = 100) -> Sequence[GlossaryCategory]:
|
|
54
|
+
"""Get root categories (no parent).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
limit: Maximum to return.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Sequence of root categories.
|
|
61
|
+
"""
|
|
62
|
+
return await self.list(
|
|
63
|
+
limit=limit,
|
|
64
|
+
filters=[GlossaryCategory.parent_id.is_(None)],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TermRepository(BaseRepository[GlossaryTerm]):
|
|
69
|
+
"""Repository for GlossaryTerm model operations."""
|
|
70
|
+
|
|
71
|
+
model = GlossaryTerm
|
|
72
|
+
|
|
73
|
+
async def get_by_name(self, name: str) -> GlossaryTerm | None:
|
|
74
|
+
"""Get term by name.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
name: Term name.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Term or None.
|
|
81
|
+
"""
|
|
82
|
+
result = await self.session.execute(
|
|
83
|
+
select(GlossaryTerm).where(GlossaryTerm.name == name)
|
|
84
|
+
)
|
|
85
|
+
return result.scalar_one_or_none()
|
|
86
|
+
|
|
87
|
+
async def search(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
query: str | None = None,
|
|
91
|
+
category_id: str | None = None,
|
|
92
|
+
status: str | None = None,
|
|
93
|
+
offset: int = 0,
|
|
94
|
+
limit: int = 100,
|
|
95
|
+
) -> Sequence[GlossaryTerm]:
|
|
96
|
+
"""Search terms with filters.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
query: Search query (name or definition).
|
|
100
|
+
category_id: Filter by category.
|
|
101
|
+
status: Filter by status.
|
|
102
|
+
offset: Number to skip.
|
|
103
|
+
limit: Maximum to return.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Sequence of matching terms.
|
|
107
|
+
"""
|
|
108
|
+
filters = []
|
|
109
|
+
|
|
110
|
+
if query:
|
|
111
|
+
search_pattern = f"%{query}%"
|
|
112
|
+
filters.append(
|
|
113
|
+
or_(
|
|
114
|
+
GlossaryTerm.name.ilike(search_pattern),
|
|
115
|
+
GlossaryTerm.definition.ilike(search_pattern),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if category_id:
|
|
120
|
+
filters.append(GlossaryTerm.category_id == category_id)
|
|
121
|
+
|
|
122
|
+
if status:
|
|
123
|
+
filters.append(GlossaryTerm.status == status)
|
|
124
|
+
|
|
125
|
+
return await self.list(
|
|
126
|
+
offset=offset,
|
|
127
|
+
limit=limit,
|
|
128
|
+
filters=filters if filters else None,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def count_filtered(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
query: str | None = None,
|
|
135
|
+
category_id: str | None = None,
|
|
136
|
+
status: str | None = None,
|
|
137
|
+
) -> int:
|
|
138
|
+
"""Count terms matching filters.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
query: Search query.
|
|
142
|
+
category_id: Filter by category.
|
|
143
|
+
status: Filter by status.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Total count.
|
|
147
|
+
"""
|
|
148
|
+
filters = []
|
|
149
|
+
|
|
150
|
+
if query:
|
|
151
|
+
search_pattern = f"%{query}%"
|
|
152
|
+
filters.append(
|
|
153
|
+
or_(
|
|
154
|
+
GlossaryTerm.name.ilike(search_pattern),
|
|
155
|
+
GlossaryTerm.definition.ilike(search_pattern),
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if category_id:
|
|
160
|
+
filters.append(GlossaryTerm.category_id == category_id)
|
|
161
|
+
|
|
162
|
+
if status:
|
|
163
|
+
filters.append(GlossaryTerm.status == status)
|
|
164
|
+
|
|
165
|
+
return await self.count(filters if filters else None)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RelationshipRepository(BaseRepository[TermRelationship]):
|
|
169
|
+
"""Repository for TermRelationship model operations."""
|
|
170
|
+
|
|
171
|
+
model = TermRelationship
|
|
172
|
+
|
|
173
|
+
async def get_for_term(self, term_id: str) -> list[TermRelationship]:
|
|
174
|
+
"""Get all relationships for a term.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
term_id: Term ID.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List of relationships.
|
|
181
|
+
"""
|
|
182
|
+
result = await self.session.execute(
|
|
183
|
+
select(TermRelationship).where(
|
|
184
|
+
or_(
|
|
185
|
+
TermRelationship.source_term_id == term_id,
|
|
186
|
+
TermRelationship.target_term_id == term_id,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
return list(result.scalars().all())
|
|
191
|
+
|
|
192
|
+
async def get_existing(
|
|
193
|
+
self,
|
|
194
|
+
source_term_id: str,
|
|
195
|
+
target_term_id: str,
|
|
196
|
+
relationship_type: str,
|
|
197
|
+
) -> TermRelationship | None:
|
|
198
|
+
"""Check if relationship already exists.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
source_term_id: Source term ID.
|
|
202
|
+
target_term_id: Target term ID.
|
|
203
|
+
relationship_type: Type of relationship.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Existing relationship or None.
|
|
207
|
+
"""
|
|
208
|
+
result = await self.session.execute(
|
|
209
|
+
select(TermRelationship).where(
|
|
210
|
+
TermRelationship.source_term_id == source_term_id,
|
|
211
|
+
TermRelationship.target_term_id == target_term_id,
|
|
212
|
+
TermRelationship.relationship_type == relationship_type,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
return result.scalar_one_or_none()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class HistoryRepository(BaseRepository[TermHistory]):
|
|
219
|
+
"""Repository for TermHistory model operations."""
|
|
220
|
+
|
|
221
|
+
model = TermHistory
|
|
222
|
+
|
|
223
|
+
async def get_for_term(
|
|
224
|
+
self,
|
|
225
|
+
term_id: str,
|
|
226
|
+
*,
|
|
227
|
+
limit: int = 50,
|
|
228
|
+
) -> Sequence[TermHistory]:
|
|
229
|
+
"""Get history for a term.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
term_id: Term ID.
|
|
233
|
+
limit: Maximum to return.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Sequence of history entries.
|
|
237
|
+
"""
|
|
238
|
+
return await self.list(
|
|
239
|
+
limit=limit,
|
|
240
|
+
filters=[TermHistory.term_id == term_id],
|
|
241
|
+
order_by=TermHistory.changed_at.desc(),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# =============================================================================
|
|
246
|
+
# Service
|
|
247
|
+
# =============================================================================
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class GlossaryService:
|
|
251
|
+
"""Service for managing business glossary.
|
|
252
|
+
|
|
253
|
+
Handles term and category CRUD operations with automatic
|
|
254
|
+
history tracking and activity logging.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
258
|
+
"""Initialize service.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
session: Database session.
|
|
262
|
+
"""
|
|
263
|
+
self.session = session
|
|
264
|
+
self.category_repo = CategoryRepository(session)
|
|
265
|
+
self.term_repo = TermRepository(session)
|
|
266
|
+
self.relationship_repo = RelationshipRepository(session)
|
|
267
|
+
self.history_repo = HistoryRepository(session)
|
|
268
|
+
self.activity_logger = ActivityLogger(session)
|
|
269
|
+
|
|
270
|
+
# =========================================================================
|
|
271
|
+
# Category Operations
|
|
272
|
+
# =========================================================================
|
|
273
|
+
|
|
274
|
+
async def list_categories(
|
|
275
|
+
self,
|
|
276
|
+
*,
|
|
277
|
+
offset: int = 0,
|
|
278
|
+
limit: int = 100,
|
|
279
|
+
) -> tuple[Sequence[GlossaryCategory], int]:
|
|
280
|
+
"""List all categories.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
offset: Number to skip.
|
|
284
|
+
limit: Maximum to return.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Tuple of (categories, total_count).
|
|
288
|
+
"""
|
|
289
|
+
categories = await self.category_repo.list(offset=offset, limit=limit)
|
|
290
|
+
total = await self.category_repo.count()
|
|
291
|
+
return categories, total
|
|
292
|
+
|
|
293
|
+
async def get_category(self, category_id: str) -> GlossaryCategory | None:
|
|
294
|
+
"""Get category by ID.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
category_id: Category ID.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Category or None.
|
|
301
|
+
"""
|
|
302
|
+
return await self.category_repo.get_by_id(category_id)
|
|
303
|
+
|
|
304
|
+
async def create_category(
|
|
305
|
+
self,
|
|
306
|
+
*,
|
|
307
|
+
name: str,
|
|
308
|
+
description: str | None = None,
|
|
309
|
+
parent_id: str | None = None,
|
|
310
|
+
actor_id: str | None = None,
|
|
311
|
+
) -> GlossaryCategory:
|
|
312
|
+
"""Create a new category.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
name: Category name.
|
|
316
|
+
description: Optional description.
|
|
317
|
+
parent_id: Optional parent category ID.
|
|
318
|
+
actor_id: User creating the category.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Created category.
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
ValueError: If name already exists or parent not found.
|
|
325
|
+
"""
|
|
326
|
+
# Check for duplicate name
|
|
327
|
+
existing = await self.category_repo.get_by_name(name)
|
|
328
|
+
if existing:
|
|
329
|
+
raise ValueError(f"Category with name '{name}' already exists")
|
|
330
|
+
|
|
331
|
+
# Validate parent if provided
|
|
332
|
+
if parent_id:
|
|
333
|
+
parent = await self.category_repo.get_by_id(parent_id)
|
|
334
|
+
if not parent:
|
|
335
|
+
raise ValueError(f"Parent category '{parent_id}' not found")
|
|
336
|
+
|
|
337
|
+
category = await self.category_repo.create(
|
|
338
|
+
name=name,
|
|
339
|
+
description=description,
|
|
340
|
+
parent_id=parent_id,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
await self.activity_logger.log(
|
|
344
|
+
ResourceType.CATEGORY,
|
|
345
|
+
category.id,
|
|
346
|
+
ActivityAction.CREATED,
|
|
347
|
+
actor_id=actor_id,
|
|
348
|
+
description=f"Created category: {category.name}",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return category
|
|
352
|
+
|
|
353
|
+
async def update_category(
|
|
354
|
+
self,
|
|
355
|
+
category_id: str,
|
|
356
|
+
*,
|
|
357
|
+
name: str | None = None,
|
|
358
|
+
description: str | None = None,
|
|
359
|
+
parent_id: str | None = None,
|
|
360
|
+
actor_id: str | None = None,
|
|
361
|
+
) -> GlossaryCategory | None:
|
|
362
|
+
"""Update a category.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
category_id: Category ID.
|
|
366
|
+
name: New name.
|
|
367
|
+
description: New description.
|
|
368
|
+
parent_id: New parent ID.
|
|
369
|
+
actor_id: User updating the category.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Updated category or None.
|
|
373
|
+
|
|
374
|
+
Raises:
|
|
375
|
+
ValueError: If name already exists or parent not found.
|
|
376
|
+
"""
|
|
377
|
+
category = await self.category_repo.get_by_id(category_id)
|
|
378
|
+
if not category:
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
changes = {}
|
|
382
|
+
|
|
383
|
+
if name is not None and name != category.name:
|
|
384
|
+
existing = await self.category_repo.get_by_name(name)
|
|
385
|
+
if existing and existing.id != category_id:
|
|
386
|
+
raise ValueError(f"Category with name '{name}' already exists")
|
|
387
|
+
changes["name"] = {"old": category.name, "new": name}
|
|
388
|
+
category.name = name
|
|
389
|
+
|
|
390
|
+
if description is not None and description != category.description:
|
|
391
|
+
changes["description"] = {"old": category.description, "new": description}
|
|
392
|
+
category.description = description
|
|
393
|
+
|
|
394
|
+
if parent_id is not None and parent_id != category.parent_id:
|
|
395
|
+
if parent_id:
|
|
396
|
+
parent = await self.category_repo.get_by_id(parent_id)
|
|
397
|
+
if not parent:
|
|
398
|
+
raise ValueError(f"Parent category '{parent_id}' not found")
|
|
399
|
+
# Prevent circular reference
|
|
400
|
+
if parent_id == category_id:
|
|
401
|
+
raise ValueError("Category cannot be its own parent")
|
|
402
|
+
changes["parent_id"] = {"old": category.parent_id, "new": parent_id}
|
|
403
|
+
category.parent_id = parent_id
|
|
404
|
+
|
|
405
|
+
if changes:
|
|
406
|
+
await self.session.flush()
|
|
407
|
+
await self.session.refresh(category)
|
|
408
|
+
await self.activity_logger.log(
|
|
409
|
+
ResourceType.CATEGORY,
|
|
410
|
+
category.id,
|
|
411
|
+
ActivityAction.UPDATED,
|
|
412
|
+
actor_id=actor_id,
|
|
413
|
+
description=f"Updated category: {category.name}",
|
|
414
|
+
metadata={"changes": changes},
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return category
|
|
418
|
+
|
|
419
|
+
async def delete_category(
|
|
420
|
+
self,
|
|
421
|
+
category_id: str,
|
|
422
|
+
*,
|
|
423
|
+
actor_id: str | None = None,
|
|
424
|
+
) -> bool:
|
|
425
|
+
"""Delete a category.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
category_id: Category ID.
|
|
429
|
+
actor_id: User deleting the category.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
True if deleted.
|
|
433
|
+
"""
|
|
434
|
+
category = await self.category_repo.get_by_id(category_id)
|
|
435
|
+
if not category:
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
category_name = category.name
|
|
439
|
+
deleted = await self.category_repo.delete(category_id)
|
|
440
|
+
|
|
441
|
+
if deleted:
|
|
442
|
+
await self.activity_logger.log(
|
|
443
|
+
ResourceType.CATEGORY,
|
|
444
|
+
category_id,
|
|
445
|
+
ActivityAction.DELETED,
|
|
446
|
+
actor_id=actor_id,
|
|
447
|
+
description=f"Deleted category: {category_name}",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
return deleted
|
|
451
|
+
|
|
452
|
+
# =========================================================================
|
|
453
|
+
# Term Operations
|
|
454
|
+
# =========================================================================
|
|
455
|
+
|
|
456
|
+
async def list_terms(
|
|
457
|
+
self,
|
|
458
|
+
*,
|
|
459
|
+
query: str | None = None,
|
|
460
|
+
category_id: str | None = None,
|
|
461
|
+
status: str | None = None,
|
|
462
|
+
offset: int = 0,
|
|
463
|
+
limit: int = 100,
|
|
464
|
+
) -> tuple[Sequence[GlossaryTerm], int]:
|
|
465
|
+
"""List terms with filters.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
query: Search query.
|
|
469
|
+
category_id: Filter by category.
|
|
470
|
+
status: Filter by status.
|
|
471
|
+
offset: Number to skip.
|
|
472
|
+
limit: Maximum to return.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
Tuple of (terms, total_count).
|
|
476
|
+
"""
|
|
477
|
+
terms = await self.term_repo.search(
|
|
478
|
+
query=query,
|
|
479
|
+
category_id=category_id,
|
|
480
|
+
status=status,
|
|
481
|
+
offset=offset,
|
|
482
|
+
limit=limit,
|
|
483
|
+
)
|
|
484
|
+
total = await self.term_repo.count_filtered(
|
|
485
|
+
query=query,
|
|
486
|
+
category_id=category_id,
|
|
487
|
+
status=status,
|
|
488
|
+
)
|
|
489
|
+
return terms, total
|
|
490
|
+
|
|
491
|
+
async def get_term(self, term_id: str) -> GlossaryTerm | None:
|
|
492
|
+
"""Get term by ID.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
term_id: Term ID.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Term or None.
|
|
499
|
+
"""
|
|
500
|
+
return await self.term_repo.get_by_id(term_id)
|
|
501
|
+
|
|
502
|
+
async def create_term(
|
|
503
|
+
self,
|
|
504
|
+
*,
|
|
505
|
+
name: str,
|
|
506
|
+
definition: str,
|
|
507
|
+
category_id: str | None = None,
|
|
508
|
+
status: str = TermStatus.DRAFT.value,
|
|
509
|
+
owner_id: str | None = None,
|
|
510
|
+
actor_id: str | None = None,
|
|
511
|
+
) -> GlossaryTerm:
|
|
512
|
+
"""Create a new term.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
name: Term name.
|
|
516
|
+
definition: Term definition.
|
|
517
|
+
category_id: Optional category ID.
|
|
518
|
+
status: Term status.
|
|
519
|
+
owner_id: Owner identifier.
|
|
520
|
+
actor_id: User creating the term.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Created term.
|
|
524
|
+
|
|
525
|
+
Raises:
|
|
526
|
+
ValueError: If name already exists or category not found.
|
|
527
|
+
"""
|
|
528
|
+
# Check for duplicate name
|
|
529
|
+
existing = await self.term_repo.get_by_name(name)
|
|
530
|
+
if existing:
|
|
531
|
+
raise ValueError(f"Term with name '{name}' already exists")
|
|
532
|
+
|
|
533
|
+
# Validate category if provided
|
|
534
|
+
if category_id:
|
|
535
|
+
category = await self.category_repo.get_by_id(category_id)
|
|
536
|
+
if not category:
|
|
537
|
+
raise ValueError(f"Category '{category_id}' not found")
|
|
538
|
+
|
|
539
|
+
term = await self.term_repo.create(
|
|
540
|
+
name=name,
|
|
541
|
+
definition=definition,
|
|
542
|
+
category_id=category_id,
|
|
543
|
+
status=status,
|
|
544
|
+
owner_id=owner_id,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
await self.activity_logger.log(
|
|
548
|
+
ResourceType.TERM,
|
|
549
|
+
term.id,
|
|
550
|
+
ActivityAction.CREATED,
|
|
551
|
+
actor_id=actor_id,
|
|
552
|
+
description=f"Created term: {term.name}",
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
return term
|
|
556
|
+
|
|
557
|
+
async def update_term(
|
|
558
|
+
self,
|
|
559
|
+
term_id: str,
|
|
560
|
+
*,
|
|
561
|
+
name: str | None = None,
|
|
562
|
+
definition: str | None = None,
|
|
563
|
+
category_id: str | None = None,
|
|
564
|
+
status: str | None = None,
|
|
565
|
+
owner_id: str | None = None,
|
|
566
|
+
actor_id: str | None = None,
|
|
567
|
+
) -> GlossaryTerm | None:
|
|
568
|
+
"""Update a term with history tracking.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
term_id: Term ID.
|
|
572
|
+
name: New name.
|
|
573
|
+
definition: New definition.
|
|
574
|
+
category_id: New category ID.
|
|
575
|
+
status: New status.
|
|
576
|
+
owner_id: New owner.
|
|
577
|
+
actor_id: User updating the term.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
Updated term or None.
|
|
581
|
+
|
|
582
|
+
Raises:
|
|
583
|
+
ValueError: If name already exists or category not found.
|
|
584
|
+
"""
|
|
585
|
+
term = await self.term_repo.get_by_id(term_id)
|
|
586
|
+
if not term:
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
changes = {}
|
|
590
|
+
history_entries = []
|
|
591
|
+
|
|
592
|
+
if name is not None and name != term.name:
|
|
593
|
+
existing = await self.term_repo.get_by_name(name)
|
|
594
|
+
if existing and existing.id != term_id:
|
|
595
|
+
raise ValueError(f"Term with name '{name}' already exists")
|
|
596
|
+
history_entries.append(("name", term.name, name))
|
|
597
|
+
changes["name"] = {"old": term.name, "new": name}
|
|
598
|
+
term.name = name
|
|
599
|
+
|
|
600
|
+
if definition is not None and definition != term.definition:
|
|
601
|
+
history_entries.append(("definition", term.definition, definition))
|
|
602
|
+
changes["definition"] = {"old": term.definition, "new": definition}
|
|
603
|
+
term.definition = definition
|
|
604
|
+
|
|
605
|
+
if category_id is not None and category_id != term.category_id:
|
|
606
|
+
if category_id:
|
|
607
|
+
category = await self.category_repo.get_by_id(category_id)
|
|
608
|
+
if not category:
|
|
609
|
+
raise ValueError(f"Category '{category_id}' not found")
|
|
610
|
+
history_entries.append(("category_id", term.category_id, category_id))
|
|
611
|
+
changes["category_id"] = {"old": term.category_id, "new": category_id}
|
|
612
|
+
term.category_id = category_id
|
|
613
|
+
|
|
614
|
+
if status is not None and status != term.status:
|
|
615
|
+
old_status = term.status
|
|
616
|
+
history_entries.append(("status", term.status, status))
|
|
617
|
+
changes["status"] = {"old": term.status, "new": status}
|
|
618
|
+
term.status = status
|
|
619
|
+
|
|
620
|
+
await self.activity_logger.log(
|
|
621
|
+
ResourceType.TERM,
|
|
622
|
+
term.id,
|
|
623
|
+
ActivityAction.STATUS_CHANGED,
|
|
624
|
+
actor_id=actor_id,
|
|
625
|
+
description=f"Changed status: {old_status} → {status}",
|
|
626
|
+
metadata={"old_status": old_status, "new_status": status},
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
if owner_id is not None and owner_id != term.owner_id:
|
|
630
|
+
history_entries.append(("owner_id", term.owner_id, owner_id))
|
|
631
|
+
changes["owner_id"] = {"old": term.owner_id, "new": owner_id}
|
|
632
|
+
term.owner_id = owner_id
|
|
633
|
+
|
|
634
|
+
if history_entries:
|
|
635
|
+
# Record history
|
|
636
|
+
for field_name, old_value, new_value in history_entries:
|
|
637
|
+
await self.history_repo.create(
|
|
638
|
+
term_id=term_id,
|
|
639
|
+
field_name=field_name,
|
|
640
|
+
old_value=str(old_value) if old_value else None,
|
|
641
|
+
new_value=str(new_value) if new_value else None,
|
|
642
|
+
changed_by=actor_id,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
await self.session.flush()
|
|
646
|
+
await self.session.refresh(term)
|
|
647
|
+
|
|
648
|
+
# Log general update (if not just status change)
|
|
649
|
+
if not (len(changes) == 1 and "status" in changes):
|
|
650
|
+
await self.activity_logger.log(
|
|
651
|
+
ResourceType.TERM,
|
|
652
|
+
term.id,
|
|
653
|
+
ActivityAction.UPDATED,
|
|
654
|
+
actor_id=actor_id,
|
|
655
|
+
description=f"Updated term: {term.name}",
|
|
656
|
+
metadata={"changes": changes},
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
return term
|
|
660
|
+
|
|
661
|
+
async def delete_term(
|
|
662
|
+
self,
|
|
663
|
+
term_id: str,
|
|
664
|
+
*,
|
|
665
|
+
actor_id: str | None = None,
|
|
666
|
+
) -> bool:
|
|
667
|
+
"""Delete a term.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
term_id: Term ID.
|
|
671
|
+
actor_id: User deleting the term.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
True if deleted.
|
|
675
|
+
"""
|
|
676
|
+
term = await self.term_repo.get_by_id(term_id)
|
|
677
|
+
if not term:
|
|
678
|
+
return False
|
|
679
|
+
|
|
680
|
+
term_name = term.name
|
|
681
|
+
deleted = await self.term_repo.delete(term_id)
|
|
682
|
+
|
|
683
|
+
if deleted:
|
|
684
|
+
await self.activity_logger.log(
|
|
685
|
+
ResourceType.TERM,
|
|
686
|
+
term_id,
|
|
687
|
+
ActivityAction.DELETED,
|
|
688
|
+
actor_id=actor_id,
|
|
689
|
+
description=f"Deleted term: {term_name}",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
return deleted
|
|
693
|
+
|
|
694
|
+
async def get_term_history(
|
|
695
|
+
self,
|
|
696
|
+
term_id: str,
|
|
697
|
+
*,
|
|
698
|
+
limit: int = 50,
|
|
699
|
+
) -> Sequence[TermHistory]:
|
|
700
|
+
"""Get history for a term.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
term_id: Term ID.
|
|
704
|
+
limit: Maximum to return.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Sequence of history entries.
|
|
708
|
+
"""
|
|
709
|
+
return await self.history_repo.get_for_term(term_id, limit=limit)
|
|
710
|
+
|
|
711
|
+
# =========================================================================
|
|
712
|
+
# Relationship Operations
|
|
713
|
+
# =========================================================================
|
|
714
|
+
|
|
715
|
+
async def get_term_relationships(
|
|
716
|
+
self,
|
|
717
|
+
term_id: str,
|
|
718
|
+
) -> list[TermRelationship]:
|
|
719
|
+
"""Get all relationships for a term.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
term_id: Term ID.
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
List of relationships.
|
|
726
|
+
"""
|
|
727
|
+
return await self.relationship_repo.get_for_term(term_id)
|
|
728
|
+
|
|
729
|
+
async def create_relationship(
|
|
730
|
+
self,
|
|
731
|
+
*,
|
|
732
|
+
source_term_id: str,
|
|
733
|
+
target_term_id: str,
|
|
734
|
+
relationship_type: str,
|
|
735
|
+
actor_id: str | None = None,
|
|
736
|
+
) -> TermRelationship:
|
|
737
|
+
"""Create a relationship between terms.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
source_term_id: Source term ID.
|
|
741
|
+
target_term_id: Target term ID.
|
|
742
|
+
relationship_type: Type of relationship.
|
|
743
|
+
actor_id: User creating the relationship.
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
Created relationship.
|
|
747
|
+
|
|
748
|
+
Raises:
|
|
749
|
+
ValueError: If terms not found or relationship exists.
|
|
750
|
+
"""
|
|
751
|
+
# Validate source term
|
|
752
|
+
source_term = await self.term_repo.get_by_id(source_term_id)
|
|
753
|
+
if not source_term:
|
|
754
|
+
raise ValueError(f"Source term '{source_term_id}' not found")
|
|
755
|
+
|
|
756
|
+
# Validate target term
|
|
757
|
+
target_term = await self.term_repo.get_by_id(target_term_id)
|
|
758
|
+
if not target_term:
|
|
759
|
+
raise ValueError(f"Target term '{target_term_id}' not found")
|
|
760
|
+
|
|
761
|
+
# Check for self-reference
|
|
762
|
+
if source_term_id == target_term_id:
|
|
763
|
+
raise ValueError("Cannot create relationship with same term")
|
|
764
|
+
|
|
765
|
+
# Check for existing relationship
|
|
766
|
+
existing = await self.relationship_repo.get_existing(
|
|
767
|
+
source_term_id,
|
|
768
|
+
target_term_id,
|
|
769
|
+
relationship_type,
|
|
770
|
+
)
|
|
771
|
+
if existing:
|
|
772
|
+
raise ValueError("Relationship already exists")
|
|
773
|
+
|
|
774
|
+
relationship = await self.relationship_repo.create(
|
|
775
|
+
source_term_id=source_term_id,
|
|
776
|
+
target_term_id=target_term_id,
|
|
777
|
+
relationship_type=relationship_type,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# Log activity on source term
|
|
781
|
+
await self.activity_logger.log(
|
|
782
|
+
ResourceType.TERM,
|
|
783
|
+
source_term_id,
|
|
784
|
+
"relationship_created",
|
|
785
|
+
actor_id=actor_id,
|
|
786
|
+
description=f"Added {relationship_type} relationship: {source_term.name} → {target_term.name}",
|
|
787
|
+
metadata={
|
|
788
|
+
"relationship_id": relationship.id,
|
|
789
|
+
"relationship_type": relationship_type,
|
|
790
|
+
"target_term_id": target_term_id,
|
|
791
|
+
"target_term_name": target_term.name,
|
|
792
|
+
},
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
return relationship
|
|
796
|
+
|
|
797
|
+
async def delete_relationship(
|
|
798
|
+
self,
|
|
799
|
+
relationship_id: str,
|
|
800
|
+
*,
|
|
801
|
+
actor_id: str | None = None,
|
|
802
|
+
) -> bool:
|
|
803
|
+
"""Delete a relationship.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
relationship_id: Relationship ID.
|
|
807
|
+
actor_id: User deleting the relationship.
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
True if deleted.
|
|
811
|
+
"""
|
|
812
|
+
relationship = await self.relationship_repo.get_by_id(relationship_id)
|
|
813
|
+
if not relationship:
|
|
814
|
+
return False
|
|
815
|
+
|
|
816
|
+
source_term_id = relationship.source_term_id
|
|
817
|
+
deleted = await self.relationship_repo.delete(relationship_id)
|
|
818
|
+
|
|
819
|
+
if deleted:
|
|
820
|
+
await self.activity_logger.log(
|
|
821
|
+
ResourceType.TERM,
|
|
822
|
+
source_term_id,
|
|
823
|
+
"relationship_deleted",
|
|
824
|
+
actor_id=actor_id,
|
|
825
|
+
description="Removed term relationship",
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
return deleted
|