powerbi-ontology-extractor 0.1.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,556 @@
1
+ """
2
+ Collaborative Ontology Review System.
3
+
4
+ Provides workflow for team review and approval of ontologies:
5
+ - Comments on entities, properties, relationships, rules
6
+ - Approval workflow: draft → review → approved → published
7
+ - Review history and audit trail
8
+ - Export/import review data
9
+
10
+ Use case: Team reviews ontology before deploying to production.
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional
20
+ from uuid import uuid4
21
+
22
+ from powerbi_ontology.ontology_generator import Ontology
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class ReviewStatus(Enum):
28
+ """Status of ontology review."""
29
+ DRAFT = "draft"
30
+ IN_REVIEW = "in_review"
31
+ CHANGES_REQUESTED = "changes_requested"
32
+ APPROVED = "approved"
33
+ PUBLISHED = "published"
34
+ REJECTED = "rejected"
35
+
36
+
37
+ class CommentType(Enum):
38
+ """Type of review comment."""
39
+ COMMENT = "comment"
40
+ SUGGESTION = "suggestion"
41
+ ISSUE = "issue"
42
+ APPROVAL = "approval"
43
+ REJECTION = "rejection"
44
+
45
+
46
+ class TargetType(Enum):
47
+ """Type of element being commented on."""
48
+ ONTOLOGY = "ontology"
49
+ ENTITY = "entity"
50
+ PROPERTY = "property"
51
+ RELATIONSHIP = "relationship"
52
+ RULE = "rule"
53
+
54
+
55
+ @dataclass
56
+ class ReviewComment:
57
+ """A comment on an ontology element."""
58
+ id: str
59
+ author: str
60
+ content: str
61
+ comment_type: CommentType
62
+ target_type: TargetType
63
+ target_path: str # e.g., "Customer.Email" or "rule:HighValue"
64
+ created_at: datetime
65
+ resolved: bool = False
66
+ resolved_by: Optional[str] = None
67
+ resolved_at: Optional[datetime] = None
68
+ replies: List["ReviewComment"] = field(default_factory=list)
69
+
70
+ def to_dict(self) -> dict:
71
+ """Convert to dictionary."""
72
+ return {
73
+ "id": self.id,
74
+ "author": self.author,
75
+ "content": self.content,
76
+ "comment_type": self.comment_type.value,
77
+ "target_type": self.target_type.value,
78
+ "target_path": self.target_path,
79
+ "created_at": self.created_at.isoformat(),
80
+ "resolved": self.resolved,
81
+ "resolved_by": self.resolved_by,
82
+ "resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
83
+ "replies": [r.to_dict() for r in self.replies],
84
+ }
85
+
86
+ @classmethod
87
+ def from_dict(cls, data: dict) -> "ReviewComment":
88
+ """Create from dictionary."""
89
+ return cls(
90
+ id=data["id"],
91
+ author=data["author"],
92
+ content=data["content"],
93
+ comment_type=CommentType(data["comment_type"]),
94
+ target_type=TargetType(data["target_type"]),
95
+ target_path=data["target_path"],
96
+ created_at=datetime.fromisoformat(data["created_at"]),
97
+ resolved=data.get("resolved", False),
98
+ resolved_by=data.get("resolved_by"),
99
+ resolved_at=datetime.fromisoformat(data["resolved_at"]) if data.get("resolved_at") else None,
100
+ replies=[cls.from_dict(r) for r in data.get("replies", [])],
101
+ )
102
+
103
+
104
+ @dataclass
105
+ class ReviewAction:
106
+ """An action in the review workflow."""
107
+ id: str
108
+ actor: str
109
+ action: str # "submit", "approve", "reject", "request_changes", "publish"
110
+ from_status: ReviewStatus
111
+ to_status: ReviewStatus
112
+ timestamp: datetime
113
+ comment: str = ""
114
+
115
+ def to_dict(self) -> dict:
116
+ """Convert to dictionary."""
117
+ return {
118
+ "id": self.id,
119
+ "actor": self.actor,
120
+ "action": self.action,
121
+ "from_status": self.from_status.value,
122
+ "to_status": self.to_status.value,
123
+ "timestamp": self.timestamp.isoformat(),
124
+ "comment": self.comment,
125
+ }
126
+
127
+ @classmethod
128
+ def from_dict(cls, data: dict) -> "ReviewAction":
129
+ """Create from dictionary."""
130
+ return cls(
131
+ id=data["id"],
132
+ actor=data["actor"],
133
+ action=data["action"],
134
+ from_status=ReviewStatus(data["from_status"]),
135
+ to_status=ReviewStatus(data["to_status"]),
136
+ timestamp=datetime.fromisoformat(data["timestamp"]),
137
+ comment=data.get("comment", ""),
138
+ )
139
+
140
+
141
+ @dataclass
142
+ class OntologyReview:
143
+ """
144
+ Review state for an ontology.
145
+
146
+ Tracks comments, approval status, and review history.
147
+ """
148
+ ontology_name: str
149
+ ontology_version: str
150
+ status: ReviewStatus = ReviewStatus.DRAFT
151
+ comments: List[ReviewComment] = field(default_factory=list)
152
+ history: List[ReviewAction] = field(default_factory=list)
153
+ reviewers: List[str] = field(default_factory=list)
154
+ approvers: List[str] = field(default_factory=list)
155
+ created_at: datetime = field(default_factory=datetime.now)
156
+ updated_at: datetime = field(default_factory=datetime.now)
157
+ metadata: Dict[str, Any] = field(default_factory=dict)
158
+
159
+ def add_comment(
160
+ self,
161
+ author: str,
162
+ content: str,
163
+ target_type: TargetType,
164
+ target_path: str,
165
+ comment_type: CommentType = CommentType.COMMENT,
166
+ ) -> ReviewComment:
167
+ """Add a comment to the review."""
168
+ comment = ReviewComment(
169
+ id=str(uuid4())[:8],
170
+ author=author,
171
+ content=content,
172
+ comment_type=comment_type,
173
+ target_type=target_type,
174
+ target_path=target_path,
175
+ created_at=datetime.now(),
176
+ )
177
+ self.comments.append(comment)
178
+ self.updated_at = datetime.now()
179
+ logger.info(f"Comment added by {author} on {target_path}")
180
+ return comment
181
+
182
+ def reply_to_comment(
183
+ self,
184
+ comment_id: str,
185
+ author: str,
186
+ content: str,
187
+ ) -> Optional[ReviewComment]:
188
+ """Reply to an existing comment."""
189
+ parent = self._find_comment(comment_id)
190
+ if not parent:
191
+ logger.warning(f"Comment {comment_id} not found")
192
+ return None
193
+
194
+ reply = ReviewComment(
195
+ id=str(uuid4())[:8],
196
+ author=author,
197
+ content=content,
198
+ comment_type=CommentType.COMMENT,
199
+ target_type=parent.target_type,
200
+ target_path=parent.target_path,
201
+ created_at=datetime.now(),
202
+ )
203
+ parent.replies.append(reply)
204
+ self.updated_at = datetime.now()
205
+ return reply
206
+
207
+ def resolve_comment(self, comment_id: str, resolved_by: str) -> bool:
208
+ """Mark a comment as resolved."""
209
+ comment = self._find_comment(comment_id)
210
+ if not comment:
211
+ return False
212
+
213
+ comment.resolved = True
214
+ comment.resolved_by = resolved_by
215
+ comment.resolved_at = datetime.now()
216
+ self.updated_at = datetime.now()
217
+ logger.info(f"Comment {comment_id} resolved by {resolved_by}")
218
+ return True
219
+
220
+ def _find_comment(self, comment_id: str) -> Optional[ReviewComment]:
221
+ """Find a comment by ID."""
222
+ for comment in self.comments:
223
+ if comment.id == comment_id:
224
+ return comment
225
+ for reply in comment.replies:
226
+ if reply.id == comment_id:
227
+ return reply
228
+ return None
229
+
230
+ def get_comments_for(self, target_path: str) -> List[ReviewComment]:
231
+ """Get all comments for a specific element."""
232
+ return [c for c in self.comments if c.target_path == target_path]
233
+
234
+ def get_unresolved_comments(self) -> List[ReviewComment]:
235
+ """Get all unresolved comments."""
236
+ return [c for c in self.comments if not c.resolved]
237
+
238
+ def get_issues(self) -> List[ReviewComment]:
239
+ """Get all issue-type comments."""
240
+ return [c for c in self.comments if c.comment_type == CommentType.ISSUE]
241
+
242
+ def to_dict(self) -> dict:
243
+ """Convert to dictionary."""
244
+ return {
245
+ "ontology_name": self.ontology_name,
246
+ "ontology_version": self.ontology_version,
247
+ "status": self.status.value,
248
+ "comments": [c.to_dict() for c in self.comments],
249
+ "history": [h.to_dict() for h in self.history],
250
+ "reviewers": self.reviewers,
251
+ "approvers": self.approvers,
252
+ "created_at": self.created_at.isoformat(),
253
+ "updated_at": self.updated_at.isoformat(),
254
+ "metadata": self.metadata,
255
+ }
256
+
257
+ @classmethod
258
+ def from_dict(cls, data: dict) -> "OntologyReview":
259
+ """Create from dictionary."""
260
+ review = cls(
261
+ ontology_name=data["ontology_name"],
262
+ ontology_version=data["ontology_version"],
263
+ status=ReviewStatus(data["status"]),
264
+ comments=[ReviewComment.from_dict(c) for c in data.get("comments", [])],
265
+ history=[ReviewAction.from_dict(h) for h in data.get("history", [])],
266
+ reviewers=data.get("reviewers", []),
267
+ approvers=data.get("approvers", []),
268
+ created_at=datetime.fromisoformat(data["created_at"]),
269
+ updated_at=datetime.fromisoformat(data["updated_at"]),
270
+ metadata=data.get("metadata", {}),
271
+ )
272
+ return review
273
+
274
+ def save(self, path: str):
275
+ """Save review to file."""
276
+ Path(path).write_text(json.dumps(self.to_dict(), indent=2))
277
+ logger.info(f"Review saved to {path}")
278
+
279
+ @classmethod
280
+ def load(cls, path: str) -> "OntologyReview":
281
+ """Load review from file."""
282
+ data = json.loads(Path(path).read_text())
283
+ return cls.from_dict(data)
284
+
285
+
286
+ class ReviewWorkflow:
287
+ """
288
+ Manages the review workflow for ontologies.
289
+
290
+ State transitions:
291
+ draft → in_review → approved → published
292
+ ↘ changes_requested → in_review
293
+ ↘ rejected
294
+ """
295
+
296
+ # Valid state transitions
297
+ TRANSITIONS = {
298
+ ReviewStatus.DRAFT: [ReviewStatus.IN_REVIEW],
299
+ ReviewStatus.IN_REVIEW: [
300
+ ReviewStatus.APPROVED,
301
+ ReviewStatus.CHANGES_REQUESTED,
302
+ ReviewStatus.REJECTED,
303
+ ],
304
+ ReviewStatus.CHANGES_REQUESTED: [ReviewStatus.IN_REVIEW],
305
+ ReviewStatus.APPROVED: [ReviewStatus.PUBLISHED, ReviewStatus.IN_REVIEW],
306
+ ReviewStatus.REJECTED: [ReviewStatus.DRAFT],
307
+ ReviewStatus.PUBLISHED: [], # Terminal state
308
+ }
309
+
310
+ def __init__(self, review: OntologyReview):
311
+ """Initialize workflow with review."""
312
+ self.review = review
313
+
314
+ def can_transition(self, to_status: ReviewStatus) -> bool:
315
+ """Check if transition is valid."""
316
+ valid_transitions = self.TRANSITIONS.get(self.review.status, [])
317
+ return to_status in valid_transitions
318
+
319
+ def submit_for_review(self, actor: str, reviewers: List[str], comment: str = "") -> bool:
320
+ """Submit ontology for review."""
321
+ if not self.can_transition(ReviewStatus.IN_REVIEW):
322
+ logger.warning(f"Cannot submit from {self.review.status}")
323
+ return False
324
+
325
+ self._record_action(actor, "submit", ReviewStatus.IN_REVIEW, comment)
326
+ self.review.reviewers = reviewers
327
+ self.review.status = ReviewStatus.IN_REVIEW
328
+ logger.info(f"Ontology submitted for review by {actor}")
329
+ return True
330
+
331
+ def approve(self, actor: str, comment: str = "") -> bool:
332
+ """Approve the ontology."""
333
+ if not self.can_transition(ReviewStatus.APPROVED):
334
+ logger.warning(f"Cannot approve from {self.review.status}")
335
+ return False
336
+
337
+ if actor not in self.review.reviewers:
338
+ logger.warning(f"{actor} is not a reviewer")
339
+ return False
340
+
341
+ self._record_action(actor, "approve", ReviewStatus.APPROVED, comment)
342
+ self.review.approvers.append(actor)
343
+ self.review.status = ReviewStatus.APPROVED
344
+
345
+ # Add approval comment
346
+ self.review.add_comment(
347
+ author=actor,
348
+ content=comment or "Approved",
349
+ target_type=TargetType.ONTOLOGY,
350
+ target_path="",
351
+ comment_type=CommentType.APPROVAL,
352
+ )
353
+
354
+ logger.info(f"Ontology approved by {actor}")
355
+ return True
356
+
357
+ def request_changes(self, actor: str, comment: str) -> bool:
358
+ """Request changes to the ontology."""
359
+ if not self.can_transition(ReviewStatus.CHANGES_REQUESTED):
360
+ logger.warning(f"Cannot request changes from {self.review.status}")
361
+ return False
362
+
363
+ if actor not in self.review.reviewers:
364
+ logger.warning(f"{actor} is not a reviewer")
365
+ return False
366
+
367
+ self._record_action(actor, "request_changes", ReviewStatus.CHANGES_REQUESTED, comment)
368
+ self.review.status = ReviewStatus.CHANGES_REQUESTED
369
+
370
+ # Add comment with requested changes
371
+ self.review.add_comment(
372
+ author=actor,
373
+ content=comment,
374
+ target_type=TargetType.ONTOLOGY,
375
+ target_path="",
376
+ comment_type=CommentType.ISSUE,
377
+ )
378
+
379
+ logger.info(f"Changes requested by {actor}")
380
+ return True
381
+
382
+ def reject(self, actor: str, comment: str) -> bool:
383
+ """Reject the ontology."""
384
+ if not self.can_transition(ReviewStatus.REJECTED):
385
+ logger.warning(f"Cannot reject from {self.review.status}")
386
+ return False
387
+
388
+ if actor not in self.review.reviewers:
389
+ logger.warning(f"{actor} is not a reviewer")
390
+ return False
391
+
392
+ self._record_action(actor, "reject", ReviewStatus.REJECTED, comment)
393
+ self.review.status = ReviewStatus.REJECTED
394
+
395
+ # Add rejection comment
396
+ self.review.add_comment(
397
+ author=actor,
398
+ content=comment,
399
+ target_type=TargetType.ONTOLOGY,
400
+ target_path="",
401
+ comment_type=CommentType.REJECTION,
402
+ )
403
+
404
+ logger.info(f"Ontology rejected by {actor}")
405
+ return True
406
+
407
+ def resubmit(self, actor: str, comment: str = "") -> bool:
408
+ """Resubmit after changes requested."""
409
+ if not self.can_transition(ReviewStatus.IN_REVIEW):
410
+ logger.warning(f"Cannot resubmit from {self.review.status}")
411
+ return False
412
+
413
+ self._record_action(actor, "resubmit", ReviewStatus.IN_REVIEW, comment)
414
+ self.review.status = ReviewStatus.IN_REVIEW
415
+ logger.info(f"Ontology resubmitted by {actor}")
416
+ return True
417
+
418
+ def publish(self, actor: str, comment: str = "") -> bool:
419
+ """Publish the approved ontology."""
420
+ if not self.can_transition(ReviewStatus.PUBLISHED):
421
+ logger.warning(f"Cannot publish from {self.review.status}")
422
+ return False
423
+
424
+ self._record_action(actor, "publish", ReviewStatus.PUBLISHED, comment)
425
+ self.review.status = ReviewStatus.PUBLISHED
426
+ logger.info(f"Ontology published by {actor}")
427
+ return True
428
+
429
+ def reset_to_draft(self, actor: str, comment: str = "") -> bool:
430
+ """Reset rejected ontology to draft."""
431
+ if self.review.status != ReviewStatus.REJECTED:
432
+ logger.warning("Can only reset from rejected state")
433
+ return False
434
+
435
+ self._record_action(actor, "reset", ReviewStatus.DRAFT, comment)
436
+ self.review.status = ReviewStatus.DRAFT
437
+ self.review.reviewers = []
438
+ self.review.approvers = []
439
+ logger.info(f"Ontology reset to draft by {actor}")
440
+ return True
441
+
442
+ def _record_action(self, actor: str, action: str, to_status: ReviewStatus, comment: str):
443
+ """Record an action in the history."""
444
+ review_action = ReviewAction(
445
+ id=str(uuid4())[:8],
446
+ actor=actor,
447
+ action=action,
448
+ from_status=self.review.status,
449
+ to_status=to_status,
450
+ timestamp=datetime.now(),
451
+ comment=comment,
452
+ )
453
+ self.review.history.append(review_action)
454
+ self.review.updated_at = datetime.now()
455
+
456
+
457
+ class ReviewReport:
458
+ """Generate reports from review data."""
459
+
460
+ def __init__(self, review: OntologyReview):
461
+ """Initialize with review."""
462
+ self.review = review
463
+
464
+ def to_markdown(self) -> str:
465
+ """Generate markdown report."""
466
+ lines = [
467
+ f"# Review Report: {self.review.ontology_name}",
468
+ "",
469
+ f"**Version:** {self.review.ontology_version}",
470
+ f"**Status:** {self.review.status.value.upper()}",
471
+ f"**Created:** {self.review.created_at.strftime('%Y-%m-%d %H:%M')}",
472
+ f"**Updated:** {self.review.updated_at.strftime('%Y-%m-%d %H:%M')}",
473
+ "",
474
+ ]
475
+
476
+ # Reviewers
477
+ if self.review.reviewers:
478
+ lines.append("## Reviewers")
479
+ lines.append("")
480
+ for reviewer in self.review.reviewers:
481
+ status = "✅" if reviewer in self.review.approvers else "⏳"
482
+ lines.append(f"- {status} {reviewer}")
483
+ lines.append("")
484
+
485
+ # Summary
486
+ unresolved = self.review.get_unresolved_comments()
487
+ issues = self.review.get_issues()
488
+
489
+ lines.append("## Summary")
490
+ lines.append("")
491
+ lines.append(f"- Total comments: {len(self.review.comments)}")
492
+ lines.append(f"- Unresolved: {len(unresolved)}")
493
+ lines.append(f"- Issues: {len([i for i in issues if not i.resolved])}")
494
+ lines.append("")
495
+
496
+ # Comments by target
497
+ if self.review.comments:
498
+ lines.append("## Comments")
499
+ lines.append("")
500
+
501
+ # Group by target
502
+ by_target: Dict[str, List[ReviewComment]] = {}
503
+ for c in self.review.comments:
504
+ key = c.target_path or "(ontology)"
505
+ if key not in by_target:
506
+ by_target[key] = []
507
+ by_target[key].append(c)
508
+
509
+ for target, comments in by_target.items():
510
+ lines.append(f"### {target}")
511
+ lines.append("")
512
+ for c in comments:
513
+ status = "✅" if c.resolved else "⬜"
514
+ icon = self._comment_icon(c.comment_type)
515
+ lines.append(f"{status} {icon} **{c.author}**: {c.content}")
516
+ if c.replies:
517
+ for r in c.replies:
518
+ lines.append(f" - **{r.author}**: {r.content}")
519
+ lines.append("")
520
+
521
+ # History
522
+ if self.review.history:
523
+ lines.append("## History")
524
+ lines.append("")
525
+ lines.append("| Time | Actor | Action | Status |")
526
+ lines.append("|------|-------|--------|--------|")
527
+ for h in self.review.history:
528
+ time = h.timestamp.strftime("%Y-%m-%d %H:%M")
529
+ lines.append(f"| {time} | {h.actor} | {h.action} | {h.to_status.value} |")
530
+ lines.append("")
531
+
532
+ return "\n".join(lines)
533
+
534
+ def _comment_icon(self, comment_type: CommentType) -> str:
535
+ """Get icon for comment type."""
536
+ icons = {
537
+ CommentType.COMMENT: "💬",
538
+ CommentType.SUGGESTION: "💡",
539
+ CommentType.ISSUE: "⚠️",
540
+ CommentType.APPROVAL: "✅",
541
+ CommentType.REJECTION: "❌",
542
+ }
543
+ return icons.get(comment_type, "💬")
544
+
545
+
546
+ def create_review(ontology: Ontology) -> OntologyReview:
547
+ """Create a new review for an ontology."""
548
+ return OntologyReview(
549
+ ontology_name=ontology.name,
550
+ ontology_version=ontology.version,
551
+ )
552
+
553
+
554
+ def load_review(path: str) -> OntologyReview:
555
+ """Load review from file."""
556
+ return OntologyReview.load(path)