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.
- cli/__init__.py +1 -0
- cli/pbi_ontology_cli.py +286 -0
- powerbi_ontology/__init__.py +38 -0
- powerbi_ontology/analyzer.py +420 -0
- powerbi_ontology/chat.py +303 -0
- powerbi_ontology/cli.py +530 -0
- powerbi_ontology/contract_builder.py +269 -0
- powerbi_ontology/dax_parser.py +305 -0
- powerbi_ontology/export/__init__.py +17 -0
- powerbi_ontology/export/contract_to_owl.py +408 -0
- powerbi_ontology/export/fabric_iq.py +243 -0
- powerbi_ontology/export/fabric_iq_to_owl.py +463 -0
- powerbi_ontology/export/json_schema.py +110 -0
- powerbi_ontology/export/ontoguard.py +177 -0
- powerbi_ontology/export/owl.py +522 -0
- powerbi_ontology/extractor.py +368 -0
- powerbi_ontology/mcp_config.py +237 -0
- powerbi_ontology/mcp_models.py +166 -0
- powerbi_ontology/mcp_server.py +1106 -0
- powerbi_ontology/ontology_diff.py +776 -0
- powerbi_ontology/ontology_generator.py +406 -0
- powerbi_ontology/review.py +556 -0
- powerbi_ontology/schema_mapper.py +369 -0
- powerbi_ontology/semantic_debt.py +584 -0
- powerbi_ontology/utils/__init__.py +13 -0
- powerbi_ontology/utils/pbix_reader.py +558 -0
- powerbi_ontology/utils/visualizer.py +332 -0
- powerbi_ontology_extractor-0.1.0.dist-info/METADATA +507 -0
- powerbi_ontology_extractor-0.1.0.dist-info/RECORD +33 -0
- powerbi_ontology_extractor-0.1.0.dist-info/WHEEL +5 -0
- powerbi_ontology_extractor-0.1.0.dist-info/entry_points.txt +4 -0
- powerbi_ontology_extractor-0.1.0.dist-info/licenses/LICENSE +21 -0
- powerbi_ontology_extractor-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -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)
|