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.
@@ -12,6 +12,17 @@ Models:
12
12
  - NotificationChannel: Notification channel configuration
13
13
  - NotificationRule: Notification trigger rules
14
14
  - NotificationLog: Notification delivery log
15
+
16
+ Phase 5 Models:
17
+ - GlossaryCategory: Business term categories
18
+ - GlossaryTerm: Business glossary terms
19
+ - TermRelationship: Relationships between terms
20
+ - TermHistory: Term change history
21
+ - CatalogAsset: Data catalog assets
22
+ - AssetColumn: Asset column metadata
23
+ - AssetTag: Asset tags
24
+ - Comment: Comments on resources
25
+ - Activity: Activity log
15
26
  """
16
27
 
17
28
  from __future__ import annotations
@@ -19,11 +30,14 @@ from __future__ import annotations
19
30
  from datetime import datetime
20
31
  from typing import Any
21
32
 
33
+ from enum import Enum
34
+
22
35
  from sqlalchemy import (
23
36
  JSON,
24
37
  Boolean,
25
38
  DateTime,
26
39
  Enum as SQLEnum,
40
+ Float,
27
41
  ForeignKey,
28
42
  Index,
29
43
  Integer,
@@ -35,6 +49,66 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
35
49
  from .base import Base, TimestampMixin, UUIDMixin
36
50
 
37
51
 
52
+ # =============================================================================
53
+ # Phase 5: Enums
54
+ # =============================================================================
55
+
56
+
57
+ class TermStatus(str, Enum):
58
+ """Status of a glossary term."""
59
+
60
+ DRAFT = "draft"
61
+ APPROVED = "approved"
62
+ DEPRECATED = "deprecated"
63
+
64
+
65
+ class RelationshipType(str, Enum):
66
+ """Type of relationship between terms."""
67
+
68
+ SYNONYM = "synonym"
69
+ RELATED = "related"
70
+ PARENT = "parent"
71
+ CHILD = "child"
72
+
73
+
74
+ class AssetType(str, Enum):
75
+ """Type of catalog asset."""
76
+
77
+ TABLE = "table"
78
+ FILE = "file"
79
+ API = "api"
80
+
81
+
82
+ class SensitivityLevel(str, Enum):
83
+ """Sensitivity level for data columns."""
84
+
85
+ PUBLIC = "public"
86
+ INTERNAL = "internal"
87
+ CONFIDENTIAL = "confidential"
88
+ RESTRICTED = "restricted"
89
+
90
+
91
+ class ResourceType(str, Enum):
92
+ """Type of resource for comments and activities."""
93
+
94
+ TERM = "term"
95
+ CATEGORY = "category"
96
+ ASSET = "asset"
97
+ COLUMN = "column"
98
+
99
+
100
+ class ActivityAction(str, Enum):
101
+ """Type of activity action."""
102
+
103
+ CREATED = "created"
104
+ UPDATED = "updated"
105
+ DELETED = "deleted"
106
+ COMMENTED = "commented"
107
+ STATUS_CHANGED = "status_changed"
108
+ MAPPED = "mapped"
109
+ UNMAPPED = "unmapped"
110
+
111
+
38
112
  class Source(Base, UUIDMixin, TimestampMixin):
39
113
  """Data source model.
40
114
 
@@ -98,6 +172,12 @@ class Source(Base, UUIDMixin, TimestampMixin):
98
172
  cascade="all, delete-orphan",
99
173
  lazy="selectin",
100
174
  )
175
+ # Phase 5: Catalog assets linked to this source
176
+ assets: Mapped[list[CatalogAsset]] = relationship(
177
+ "CatalogAsset",
178
+ back_populates="source",
179
+ lazy="selectin",
180
+ )
101
181
 
102
182
  @property
103
183
  def source_path(self) -> str | None:
@@ -730,3 +810,616 @@ class NotificationLog(Base, UUIDMixin):
730
810
  self.status = "failed"
731
811
  self.error_message = error
732
812
  self.sent_at = datetime.utcnow()
813
+
814
+
815
+ # =============================================================================
816
+ # Phase 5: Business Glossary Models
817
+ # =============================================================================
818
+
819
+
820
+ class GlossaryCategory(Base, UUIDMixin, TimestampMixin):
821
+ """Business glossary category model.
822
+
823
+ Provides hierarchical categorization for business terms.
824
+ Categories can be nested (parent-child relationships).
825
+
826
+ Attributes:
827
+ id: Unique identifier (UUID).
828
+ name: Category name (unique).
829
+ description: Optional category description.
830
+ parent_id: Optional parent category ID for hierarchy.
831
+ """
832
+
833
+ __tablename__ = "glossary_categories"
834
+
835
+ name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
836
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
837
+ parent_id: Mapped[str | None] = mapped_column(
838
+ String(36),
839
+ ForeignKey("glossary_categories.id", ondelete="SET NULL"),
840
+ nullable=True,
841
+ index=True,
842
+ )
843
+
844
+ # Relationships
845
+ parent: Mapped[GlossaryCategory | None] = relationship(
846
+ "GlossaryCategory",
847
+ remote_side="GlossaryCategory.id",
848
+ back_populates="children",
849
+ )
850
+ children: Mapped[list[GlossaryCategory]] = relationship(
851
+ "GlossaryCategory",
852
+ back_populates="parent",
853
+ lazy="selectin",
854
+ )
855
+ terms: Mapped[list[GlossaryTerm]] = relationship(
856
+ "GlossaryTerm",
857
+ back_populates="category",
858
+ lazy="selectin",
859
+ )
860
+
861
+ @property
862
+ def term_count(self) -> int:
863
+ """Get number of terms in this category."""
864
+ return len(self.terms)
865
+
866
+ @property
867
+ def full_path(self) -> str:
868
+ """Get full category path (e.g., 'Parent > Child')."""
869
+ if self.parent:
870
+ return f"{self.parent.full_path} > {self.name}"
871
+ return self.name
872
+
873
+
874
+ class GlossaryTerm(Base, UUIDMixin, TimestampMixin):
875
+ """Business glossary term model.
876
+
877
+ Represents a business term with its definition, status, and relationships.
878
+
879
+ Attributes:
880
+ id: Unique identifier (UUID).
881
+ name: Term name (unique).
882
+ definition: Term definition (required).
883
+ category_id: Optional category ID.
884
+ status: Term status (draft, approved, deprecated).
885
+ owner_id: Optional owner identifier.
886
+ """
887
+
888
+ __tablename__ = "glossary_terms"
889
+
890
+ # Composite index for efficient search queries
891
+ __table_args__ = (
892
+ Index("idx_glossary_terms_name_status", "name", "status"),
893
+ Index("idx_glossary_terms_category", "category_id"),
894
+ )
895
+
896
+ name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
897
+ definition: Mapped[str] = mapped_column(Text, nullable=False)
898
+ category_id: Mapped[str | None] = mapped_column(
899
+ String(36),
900
+ ForeignKey("glossary_categories.id", ondelete="SET NULL"),
901
+ nullable=True,
902
+ index=True,
903
+ )
904
+ status: Mapped[str] = mapped_column(
905
+ String(20),
906
+ nullable=False,
907
+ default=TermStatus.DRAFT.value,
908
+ index=True,
909
+ )
910
+ owner_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
911
+
912
+ # Relationships
913
+ category: Mapped[GlossaryCategory | None] = relationship(
914
+ "GlossaryCategory",
915
+ back_populates="terms",
916
+ )
917
+ history: Mapped[list[TermHistory]] = relationship(
918
+ "TermHistory",
919
+ back_populates="term",
920
+ cascade="all, delete-orphan",
921
+ lazy="selectin",
922
+ order_by="desc(TermHistory.changed_at)",
923
+ )
924
+ # Relationships where this term is the source
925
+ outgoing_relationships: Mapped[list[TermRelationship]] = relationship(
926
+ "TermRelationship",
927
+ foreign_keys="TermRelationship.source_term_id",
928
+ back_populates="source_term",
929
+ cascade="all, delete-orphan",
930
+ lazy="selectin",
931
+ )
932
+ # Relationships where this term is the target
933
+ incoming_relationships: Mapped[list[TermRelationship]] = relationship(
934
+ "TermRelationship",
935
+ foreign_keys="TermRelationship.target_term_id",
936
+ back_populates="target_term",
937
+ cascade="all, delete-orphan",
938
+ lazy="selectin",
939
+ )
940
+ # Columns mapped to this term
941
+ mapped_columns: Mapped[list[AssetColumn]] = relationship(
942
+ "AssetColumn",
943
+ back_populates="term",
944
+ lazy="selectin",
945
+ )
946
+
947
+ @property
948
+ def synonyms(self) -> list[GlossaryTerm]:
949
+ """Get all synonym terms."""
950
+ result = []
951
+ for rel in self.outgoing_relationships:
952
+ if rel.relationship_type == RelationshipType.SYNONYM.value:
953
+ result.append(rel.target_term)
954
+ for rel in self.incoming_relationships:
955
+ if rel.relationship_type == RelationshipType.SYNONYM.value:
956
+ result.append(rel.source_term)
957
+ return result
958
+
959
+ @property
960
+ def related_terms(self) -> list[GlossaryTerm]:
961
+ """Get all related terms (non-synonym relationships)."""
962
+ result = []
963
+ for rel in self.outgoing_relationships:
964
+ if rel.relationship_type == RelationshipType.RELATED.value:
965
+ result.append(rel.target_term)
966
+ for rel in self.incoming_relationships:
967
+ if rel.relationship_type == RelationshipType.RELATED.value:
968
+ result.append(rel.source_term)
969
+ return result
970
+
971
+ @property
972
+ def is_approved(self) -> bool:
973
+ """Check if term is approved."""
974
+ return self.status == TermStatus.APPROVED.value
975
+
976
+ @property
977
+ def is_deprecated(self) -> bool:
978
+ """Check if term is deprecated."""
979
+ return self.status == TermStatus.DEPRECATED.value
980
+
981
+ def approve(self) -> None:
982
+ """Approve this term."""
983
+ self.status = TermStatus.APPROVED.value
984
+
985
+ def deprecate(self) -> None:
986
+ """Mark this term as deprecated."""
987
+ self.status = TermStatus.DEPRECATED.value
988
+
989
+
990
+ class TermRelationship(Base, UUIDMixin):
991
+ """Relationship between glossary terms.
992
+
993
+ Represents directional relationships between terms such as
994
+ synonyms, related terms, or parent-child relationships.
995
+
996
+ Attributes:
997
+ id: Unique identifier (UUID).
998
+ source_term_id: Source term ID.
999
+ target_term_id: Target term ID.
1000
+ relationship_type: Type of relationship.
1001
+ created_at: When the relationship was created.
1002
+ """
1003
+
1004
+ __tablename__ = "term_relationships"
1005
+
1006
+ # Unique constraint to prevent duplicate relationships
1007
+ __table_args__ = (
1008
+ Index(
1009
+ "idx_term_relationships_unique",
1010
+ "source_term_id",
1011
+ "target_term_id",
1012
+ "relationship_type",
1013
+ unique=True,
1014
+ ),
1015
+ )
1016
+
1017
+ source_term_id: Mapped[str] = mapped_column(
1018
+ String(36),
1019
+ ForeignKey("glossary_terms.id", ondelete="CASCADE"),
1020
+ nullable=False,
1021
+ index=True,
1022
+ )
1023
+ target_term_id: Mapped[str] = mapped_column(
1024
+ String(36),
1025
+ ForeignKey("glossary_terms.id", ondelete="CASCADE"),
1026
+ nullable=False,
1027
+ index=True,
1028
+ )
1029
+ relationship_type: Mapped[str] = mapped_column(
1030
+ String(20),
1031
+ nullable=False,
1032
+ index=True,
1033
+ )
1034
+ created_at: Mapped[datetime] = mapped_column(
1035
+ DateTime,
1036
+ default=datetime.utcnow,
1037
+ nullable=False,
1038
+ )
1039
+
1040
+ # Relationships
1041
+ source_term: Mapped[GlossaryTerm] = relationship(
1042
+ "GlossaryTerm",
1043
+ foreign_keys=[source_term_id],
1044
+ back_populates="outgoing_relationships",
1045
+ )
1046
+ target_term: Mapped[GlossaryTerm] = relationship(
1047
+ "GlossaryTerm",
1048
+ foreign_keys=[target_term_id],
1049
+ back_populates="incoming_relationships",
1050
+ )
1051
+
1052
+
1053
+ class TermHistory(Base, UUIDMixin):
1054
+ """History of changes to glossary terms.
1055
+
1056
+ Tracks all modifications to term fields for auditing.
1057
+
1058
+ Attributes:
1059
+ id: Unique identifier (UUID).
1060
+ term_id: Reference to the term.
1061
+ field_name: Name of the changed field.
1062
+ old_value: Previous value (as string).
1063
+ new_value: New value (as string).
1064
+ changed_by: User who made the change.
1065
+ changed_at: When the change occurred.
1066
+ """
1067
+
1068
+ __tablename__ = "term_history"
1069
+
1070
+ # Index for efficient history queries
1071
+ __table_args__ = (
1072
+ Index("idx_term_history_term_changed", "term_id", "changed_at"),
1073
+ )
1074
+
1075
+ term_id: Mapped[str] = mapped_column(
1076
+ String(36),
1077
+ ForeignKey("glossary_terms.id", ondelete="CASCADE"),
1078
+ nullable=False,
1079
+ index=True,
1080
+ )
1081
+ field_name: Mapped[str] = mapped_column(String(100), nullable=False)
1082
+ old_value: Mapped[str | None] = mapped_column(Text, nullable=True)
1083
+ new_value: Mapped[str | None] = mapped_column(Text, nullable=True)
1084
+ changed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
1085
+ changed_at: Mapped[datetime] = mapped_column(
1086
+ DateTime,
1087
+ default=datetime.utcnow,
1088
+ nullable=False,
1089
+ )
1090
+
1091
+ # Relationships
1092
+ term: Mapped[GlossaryTerm] = relationship(
1093
+ "GlossaryTerm",
1094
+ back_populates="history",
1095
+ )
1096
+
1097
+
1098
+ # =============================================================================
1099
+ # Phase 5: Data Catalog Models
1100
+ # =============================================================================
1101
+
1102
+
1103
+ class CatalogAsset(Base, UUIDMixin, TimestampMixin):
1104
+ """Data catalog asset model.
1105
+
1106
+ Represents a data asset (table, file, API) in the catalog.
1107
+
1108
+ Attributes:
1109
+ id: Unique identifier (UUID).
1110
+ name: Asset name.
1111
+ asset_type: Type of asset (table, file, api).
1112
+ source_id: Optional reference to data source.
1113
+ description: Optional asset description.
1114
+ owner_id: Optional owner identifier.
1115
+ quality_score: Computed quality score (0-100).
1116
+ """
1117
+
1118
+ __tablename__ = "catalog_assets"
1119
+
1120
+ # Indexes for efficient queries
1121
+ __table_args__ = (
1122
+ Index("idx_catalog_assets_type", "asset_type"),
1123
+ Index("idx_catalog_assets_source", "source_id"),
1124
+ Index("idx_catalog_assets_name_type", "name", "asset_type"),
1125
+ )
1126
+
1127
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
1128
+ asset_type: Mapped[str] = mapped_column(
1129
+ String(20),
1130
+ nullable=False,
1131
+ default=AssetType.TABLE.value,
1132
+ )
1133
+ source_id: Mapped[str | None] = mapped_column(
1134
+ String(36),
1135
+ ForeignKey("sources.id", ondelete="SET NULL"),
1136
+ nullable=True,
1137
+ index=True,
1138
+ )
1139
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
1140
+ owner_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
1141
+ quality_score: Mapped[float | None] = mapped_column(Float, nullable=True)
1142
+
1143
+ # Relationships
1144
+ source: Mapped[Source | None] = relationship(
1145
+ "Source",
1146
+ back_populates="assets",
1147
+ )
1148
+ columns: Mapped[list[AssetColumn]] = relationship(
1149
+ "AssetColumn",
1150
+ back_populates="asset",
1151
+ cascade="all, delete-orphan",
1152
+ lazy="selectin",
1153
+ order_by="AssetColumn.name",
1154
+ )
1155
+ tags: Mapped[list[AssetTag]] = relationship(
1156
+ "AssetTag",
1157
+ back_populates="asset",
1158
+ cascade="all, delete-orphan",
1159
+ lazy="selectin",
1160
+ )
1161
+
1162
+ @property
1163
+ def column_count(self) -> int:
1164
+ """Get number of columns."""
1165
+ return len(self.columns)
1166
+
1167
+ @property
1168
+ def tag_names(self) -> list[str]:
1169
+ """Get list of tag names."""
1170
+ return [tag.tag_name for tag in self.tags]
1171
+
1172
+ @property
1173
+ def quality_level(self) -> str:
1174
+ """Get quality level based on score."""
1175
+ if self.quality_score is None:
1176
+ return "unknown"
1177
+ if self.quality_score >= 90:
1178
+ return "excellent"
1179
+ if self.quality_score >= 70:
1180
+ return "good"
1181
+ if self.quality_score >= 50:
1182
+ return "fair"
1183
+ return "poor"
1184
+
1185
+ def update_quality_score(self, score: float) -> None:
1186
+ """Update the quality score."""
1187
+ self.quality_score = min(100.0, max(0.0, score))
1188
+
1189
+
1190
+ class AssetColumn(Base, UUIDMixin, TimestampMixin):
1191
+ """Asset column metadata model.
1192
+
1193
+ Represents a column within a data asset with optional term mapping.
1194
+
1195
+ Attributes:
1196
+ id: Unique identifier (UUID).
1197
+ asset_id: Reference to parent asset.
1198
+ name: Column name.
1199
+ data_type: Column data type.
1200
+ description: Optional column description.
1201
+ is_nullable: Whether column allows null values.
1202
+ is_primary_key: Whether column is a primary key.
1203
+ term_id: Optional mapped glossary term ID.
1204
+ sensitivity_level: Data sensitivity classification.
1205
+ """
1206
+
1207
+ __tablename__ = "asset_columns"
1208
+
1209
+ # Indexes for efficient queries
1210
+ __table_args__ = (
1211
+ Index("idx_asset_columns_asset", "asset_id"),
1212
+ Index("idx_asset_columns_term", "term_id"),
1213
+ Index("idx_asset_columns_sensitivity", "sensitivity_level"),
1214
+ )
1215
+
1216
+ asset_id: Mapped[str] = mapped_column(
1217
+ String(36),
1218
+ ForeignKey("catalog_assets.id", ondelete="CASCADE"),
1219
+ nullable=False,
1220
+ index=True,
1221
+ )
1222
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
1223
+ data_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
1224
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
1225
+ is_nullable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
1226
+ is_primary_key: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
1227
+ term_id: Mapped[str | None] = mapped_column(
1228
+ String(36),
1229
+ ForeignKey("glossary_terms.id", ondelete="SET NULL"),
1230
+ nullable=True,
1231
+ index=True,
1232
+ )
1233
+ sensitivity_level: Mapped[str | None] = mapped_column(
1234
+ String(20),
1235
+ nullable=True,
1236
+ default=SensitivityLevel.PUBLIC.value,
1237
+ )
1238
+
1239
+ # Relationships
1240
+ asset: Mapped[CatalogAsset] = relationship(
1241
+ "CatalogAsset",
1242
+ back_populates="columns",
1243
+ )
1244
+ term: Mapped[GlossaryTerm | None] = relationship(
1245
+ "GlossaryTerm",
1246
+ back_populates="mapped_columns",
1247
+ )
1248
+
1249
+ @property
1250
+ def is_sensitive(self) -> bool:
1251
+ """Check if column contains sensitive data."""
1252
+ if self.sensitivity_level is None:
1253
+ return False
1254
+ return self.sensitivity_level in (
1255
+ SensitivityLevel.CONFIDENTIAL.value,
1256
+ SensitivityLevel.RESTRICTED.value,
1257
+ )
1258
+
1259
+ @property
1260
+ def has_term_mapping(self) -> bool:
1261
+ """Check if column is mapped to a term."""
1262
+ return self.term_id is not None
1263
+
1264
+ def map_to_term(self, term_id: str) -> None:
1265
+ """Map this column to a glossary term."""
1266
+ self.term_id = term_id
1267
+
1268
+ def unmap_term(self) -> None:
1269
+ """Remove term mapping from this column."""
1270
+ self.term_id = None
1271
+
1272
+
1273
+ class AssetTag(Base, UUIDMixin):
1274
+ """Tag for catalog assets.
1275
+
1276
+ Provides flexible tagging for assets with optional values.
1277
+
1278
+ Attributes:
1279
+ id: Unique identifier (UUID).
1280
+ asset_id: Reference to parent asset.
1281
+ tag_name: Tag name/key.
1282
+ tag_value: Optional tag value.
1283
+ created_at: When the tag was created.
1284
+ """
1285
+
1286
+ __tablename__ = "asset_tags"
1287
+
1288
+ # Unique constraint to prevent duplicate tags
1289
+ __table_args__ = (
1290
+ Index("idx_asset_tags_asset", "asset_id"),
1291
+ Index("idx_asset_tags_name", "tag_name"),
1292
+ Index(
1293
+ "idx_asset_tags_unique",
1294
+ "asset_id",
1295
+ "tag_name",
1296
+ unique=True,
1297
+ ),
1298
+ )
1299
+
1300
+ asset_id: Mapped[str] = mapped_column(
1301
+ String(36),
1302
+ ForeignKey("catalog_assets.id", ondelete="CASCADE"),
1303
+ nullable=False,
1304
+ index=True,
1305
+ )
1306
+ tag_name: Mapped[str] = mapped_column(String(100), nullable=False)
1307
+ tag_value: Mapped[str | None] = mapped_column(String(255), nullable=True)
1308
+ created_at: Mapped[datetime] = mapped_column(
1309
+ DateTime,
1310
+ default=datetime.utcnow,
1311
+ nullable=False,
1312
+ )
1313
+
1314
+ # Relationships
1315
+ asset: Mapped[CatalogAsset] = relationship(
1316
+ "CatalogAsset",
1317
+ back_populates="tags",
1318
+ )
1319
+
1320
+
1321
+ # =============================================================================
1322
+ # Phase 5: Collaboration Models
1323
+ # =============================================================================
1324
+
1325
+
1326
+ class Comment(Base, UUIDMixin, TimestampMixin):
1327
+ """Comment on resources (terms, assets, columns).
1328
+
1329
+ Supports threaded comments with replies.
1330
+
1331
+ Attributes:
1332
+ id: Unique identifier (UUID).
1333
+ resource_type: Type of resource being commented on.
1334
+ resource_id: ID of the resource.
1335
+ content: Comment content.
1336
+ author_id: Optional author identifier.
1337
+ parent_id: Optional parent comment ID for replies.
1338
+ """
1339
+
1340
+ __tablename__ = "comments"
1341
+
1342
+ # Indexes for efficient queries
1343
+ __table_args__ = (
1344
+ Index("idx_comments_resource", "resource_type", "resource_id"),
1345
+ Index("idx_comments_parent", "parent_id"),
1346
+ )
1347
+
1348
+ resource_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
1349
+ resource_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
1350
+ content: Mapped[str] = mapped_column(Text, nullable=False)
1351
+ author_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
1352
+ parent_id: Mapped[str | None] = mapped_column(
1353
+ String(36),
1354
+ ForeignKey("comments.id", ondelete="CASCADE"),
1355
+ nullable=True,
1356
+ index=True,
1357
+ )
1358
+
1359
+ # Relationships
1360
+ parent: Mapped[Comment | None] = relationship(
1361
+ "Comment",
1362
+ remote_side="Comment.id",
1363
+ back_populates="replies",
1364
+ )
1365
+ replies: Mapped[list[Comment]] = relationship(
1366
+ "Comment",
1367
+ back_populates="parent",
1368
+ cascade="all, delete-orphan",
1369
+ lazy="selectin",
1370
+ order_by="Comment.created_at",
1371
+ )
1372
+
1373
+ @property
1374
+ def is_reply(self) -> bool:
1375
+ """Check if this is a reply to another comment."""
1376
+ return self.parent_id is not None
1377
+
1378
+ @property
1379
+ def reply_count(self) -> int:
1380
+ """Get number of direct replies."""
1381
+ return len(self.replies)
1382
+
1383
+
1384
+ class Activity(Base, UUIDMixin):
1385
+ """Activity log for tracking changes.
1386
+
1387
+ Records all significant actions on resources for audit trail.
1388
+
1389
+ Attributes:
1390
+ id: Unique identifier (UUID).
1391
+ resource_type: Type of resource.
1392
+ resource_id: ID of the resource.
1393
+ action: Type of action performed.
1394
+ actor_id: User who performed the action.
1395
+ description: Human-readable description.
1396
+ metadata: Additional action metadata as JSON.
1397
+ created_at: When the activity occurred.
1398
+ """
1399
+
1400
+ __tablename__ = "activities"
1401
+
1402
+ # Indexes for efficient queries
1403
+ __table_args__ = (
1404
+ Index("idx_activities_resource", "resource_type", "resource_id"),
1405
+ Index("idx_activities_action", "action"),
1406
+ Index("idx_activities_created", "created_at"),
1407
+ )
1408
+
1409
+ resource_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
1410
+ resource_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
1411
+ action: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
1412
+ actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
1413
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
1414
+ metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
1415
+ created_at: Mapped[datetime] = mapped_column(
1416
+ DateTime,
1417
+ default=datetime.utcnow,
1418
+ nullable=False,
1419
+ index=True,
1420
+ )
1421
+
1422
+ @property
1423
+ def resource_key(self) -> str:
1424
+ """Get unique resource key."""
1425
+ return f"{self.resource_type}:{self.resource_id}"