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,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