better-notion 2.1.9__py3-none-any.whl → 2.3.1__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.
- better_notion/plugins/official/agents.py +68 -0
- better_notion/plugins/official/agents_cli.py +347 -0
- better_notion/plugins/official/agents_sdk/agent.py +503 -0
- better_notion/plugins/official/agents_sdk/history.py +658 -0
- better_notion/plugins/official/agents_sdk/managers.py +419 -1
- better_notion/plugins/official/agents_sdk/search.py +421 -0
- {better_notion-2.1.9.dist-info → better_notion-2.3.1.dist-info}/METADATA +1 -1
- {better_notion-2.1.9.dist-info → better_notion-2.3.1.dist-info}/RECORD +11 -8
- {better_notion-2.1.9.dist-info → better_notion-2.3.1.dist-info}/WHEEL +0 -0
- {better_notion-2.1.9.dist-info → better_notion-2.3.1.dist-info}/entry_points.txt +0 -0
- {better_notion-2.1.9.dist-info → better_notion-2.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""Change history tracking for the agents SDK.
|
|
2
|
+
|
|
3
|
+
This module provides audit trail functionality to track who changed what,
|
|
4
|
+
when, and why, with revision history, diff capabilities, and revert functionality.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from better_notion._sdk.client import NotionClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PropertyChange:
|
|
21
|
+
"""Represents a single property change.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
property_name: Name of the property that changed
|
|
25
|
+
from_value: Previous value (None if property was added)
|
|
26
|
+
to_value: New value (None if property was removed)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
property_name: str
|
|
30
|
+
from_value: Any
|
|
31
|
+
to_value: Any
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
"""Convert property change to dictionary.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dictionary with property, from, and to values
|
|
38
|
+
"""
|
|
39
|
+
return {
|
|
40
|
+
"property": self.property_name,
|
|
41
|
+
"from": self.from_value,
|
|
42
|
+
"to": self.to_value,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Revision:
|
|
48
|
+
"""Represents a single revision of an entity.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
revision_id: Sequential revision number (starting at 1)
|
|
52
|
+
timestamp: When the change was made
|
|
53
|
+
author: Who made the change
|
|
54
|
+
changes: List of property changes in this revision
|
|
55
|
+
reason: Optional explanation for the change
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
revision_id: int
|
|
59
|
+
timestamp: datetime
|
|
60
|
+
author: str
|
|
61
|
+
changes: list[PropertyChange]
|
|
62
|
+
reason: str | None
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
"""Convert revision to dictionary.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dictionary with all revision data
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
"revision_id": self.revision_id,
|
|
72
|
+
"timestamp": self.timestamp.isoformat(),
|
|
73
|
+
"author": self.author,
|
|
74
|
+
"changes": [c.to_dict() for c in self.changes],
|
|
75
|
+
"reason": self.reason,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HistoryStorage:
|
|
80
|
+
"""Storage backend for history data.
|
|
81
|
+
|
|
82
|
+
Supports both local file storage (JSONL) and optional Notion database storage.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, client: NotionClient) -> None:
|
|
86
|
+
"""Initialize the HistoryStorage.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
client: Notion API client
|
|
90
|
+
"""
|
|
91
|
+
self._client = client
|
|
92
|
+
self._history_root = Path(".notion_history")
|
|
93
|
+
|
|
94
|
+
async def save_revision(
|
|
95
|
+
self,
|
|
96
|
+
entity_id: str,
|
|
97
|
+
entity_type: str,
|
|
98
|
+
revision: Revision,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Save a revision to storage.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
entity_id: Entity ID
|
|
104
|
+
entity_type: Entity type (e.g., "task", "idea")
|
|
105
|
+
revision: Revision to save
|
|
106
|
+
"""
|
|
107
|
+
# Try Notion storage first if configured
|
|
108
|
+
database_id = self._client.workspace_config.get("Change_History")
|
|
109
|
+
if database_id:
|
|
110
|
+
try:
|
|
111
|
+
await self._save_to_notion(database_id, entity_id, entity_type, revision)
|
|
112
|
+
return
|
|
113
|
+
except Exception:
|
|
114
|
+
# Fall back to local storage on error
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Default to local storage
|
|
118
|
+
await self._save_to_local(entity_id, entity_type, revision)
|
|
119
|
+
|
|
120
|
+
async def _save_to_notion(
|
|
121
|
+
self,
|
|
122
|
+
database_id: str,
|
|
123
|
+
entity_id: str,
|
|
124
|
+
entity_type: str,
|
|
125
|
+
revision: Revision,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Save revision to Notion database.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
database_id: Change History database ID
|
|
131
|
+
entity_id: Entity ID
|
|
132
|
+
entity_type: Entity type
|
|
133
|
+
revision: Revision to save
|
|
134
|
+
"""
|
|
135
|
+
from better_notion._sdk.models.page import Page
|
|
136
|
+
|
|
137
|
+
# Create a page in the Change History database
|
|
138
|
+
changes_text = "\n".join([
|
|
139
|
+
f"{c.property_name}: {c.from_value} → {c.to_value}"
|
|
140
|
+
for c in revision.changes
|
|
141
|
+
])
|
|
142
|
+
|
|
143
|
+
properties = {
|
|
144
|
+
"Entity ID": [{"type": "text", "text": {"content": entity_id}}],
|
|
145
|
+
"Entity Type": {"select": {"name": entity_type}},
|
|
146
|
+
"Revision": {"number": revision.revision_id},
|
|
147
|
+
"Author": [{"type": "text", "text": {"content": revision.author}}],
|
|
148
|
+
"Timestamp": {"date": {"start": revision.timestamp.isoformat()}},
|
|
149
|
+
"Changes": [
|
|
150
|
+
{
|
|
151
|
+
"type": "text",
|
|
152
|
+
"text": {
|
|
153
|
+
"content": changes_text,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if revision.reason:
|
|
160
|
+
properties["Reason"] = [
|
|
161
|
+
{
|
|
162
|
+
"type": "text",
|
|
163
|
+
"text": {
|
|
164
|
+
"content": revision.reason,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
await Page.create(
|
|
170
|
+
client=self._client,
|
|
171
|
+
database_id=database_id,
|
|
172
|
+
title=f"{entity_type}:{entity_id} - r{revision.revision_id}",
|
|
173
|
+
properties=properties,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def _save_to_local(
|
|
177
|
+
self,
|
|
178
|
+
entity_id: str,
|
|
179
|
+
entity_type: str,
|
|
180
|
+
revision: Revision,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Save revision to local file storage.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
entity_id: Entity ID
|
|
186
|
+
entity_type: Entity type
|
|
187
|
+
revision: Revision to save
|
|
188
|
+
"""
|
|
189
|
+
# Create history directory
|
|
190
|
+
history_dir = self._history_root / entity_type
|
|
191
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
|
|
193
|
+
# Save to JSONL file
|
|
194
|
+
history_file = history_dir / f"{entity_id}.jsonl"
|
|
195
|
+
|
|
196
|
+
# Append revision
|
|
197
|
+
with open(history_file, "a", encoding="utf-8") as f:
|
|
198
|
+
f.write(json.dumps(revision.to_dict()) + "\n")
|
|
199
|
+
|
|
200
|
+
async def get_revisions(self, entity_id: str, entity_type: str | None = None) -> list[Revision]:
|
|
201
|
+
"""Get all revisions for an entity.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
entity_id: Entity ID
|
|
205
|
+
entity_type: Optional entity type to narrow search
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of revisions sorted by revision_id
|
|
209
|
+
"""
|
|
210
|
+
# Try local storage first
|
|
211
|
+
revisions = await self._get_from_local(entity_id, entity_type)
|
|
212
|
+
|
|
213
|
+
if not revisions:
|
|
214
|
+
# Try Notion storage (not implemented in v1)
|
|
215
|
+
revisions = await self._get_from_notion(entity_id)
|
|
216
|
+
|
|
217
|
+
return revisions
|
|
218
|
+
|
|
219
|
+
async def _get_from_local(
|
|
220
|
+
self,
|
|
221
|
+
entity_id: str,
|
|
222
|
+
entity_type: str | None = None,
|
|
223
|
+
) -> list[Revision]:
|
|
224
|
+
"""Get revisions from local storage.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
entity_id: Entity ID
|
|
228
|
+
entity_type: Optional entity type to narrow search
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
List of revisions
|
|
232
|
+
"""
|
|
233
|
+
if not self._history_root.exists():
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
# If entity_type is specified, look in that directory
|
|
237
|
+
if entity_type:
|
|
238
|
+
history_file = self._history_root / entity_type / f"{entity_id}.jsonl"
|
|
239
|
+
if history_file.exists():
|
|
240
|
+
return self._load_revisions_from_file(history_file)
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
# Otherwise, search all entity type directories
|
|
244
|
+
for entity_dir in self._history_root.iterdir():
|
|
245
|
+
if entity_dir.is_dir():
|
|
246
|
+
history_file = entity_dir / f"{entity_id}.jsonl"
|
|
247
|
+
if history_file.exists():
|
|
248
|
+
return self._load_revisions_from_file(history_file)
|
|
249
|
+
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
def _load_revisions_from_file(self, history_file: Path) -> list[Revision]:
|
|
253
|
+
"""Load revisions from a JSONL file.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
history_file: Path to the JSONL file
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of revisions
|
|
260
|
+
"""
|
|
261
|
+
revisions = []
|
|
262
|
+
with open(history_file, encoding="utf-8") as f:
|
|
263
|
+
for line in f:
|
|
264
|
+
data = json.loads(line)
|
|
265
|
+
revisions.append(
|
|
266
|
+
Revision(
|
|
267
|
+
revision_id=data["revision_id"],
|
|
268
|
+
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
269
|
+
author=data["author"],
|
|
270
|
+
changes=[
|
|
271
|
+
PropertyChange(
|
|
272
|
+
property_name=c["property"],
|
|
273
|
+
from_value=c.get("from"),
|
|
274
|
+
to_value=c.get("to"),
|
|
275
|
+
)
|
|
276
|
+
for c in data["changes"]
|
|
277
|
+
],
|
|
278
|
+
reason=data.get("reason"),
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Sort by revision_id
|
|
283
|
+
revisions.sort(key=lambda r: r.revision_id)
|
|
284
|
+
return revisions
|
|
285
|
+
|
|
286
|
+
async def _get_from_notion(self, entity_id: str) -> list[Revision]:
|
|
287
|
+
"""Get revisions from Notion storage.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
entity_id: Entity ID
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of revisions (empty in v1 - Notion storage not implemented)
|
|
294
|
+
"""
|
|
295
|
+
# Not implemented in v1
|
|
296
|
+
# Would query Change_History database and filter by Entity ID
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
async def get_audit_log(
|
|
300
|
+
self,
|
|
301
|
+
project_id: str | None = None,
|
|
302
|
+
days: int = 7,
|
|
303
|
+
entity_type: str | None = None,
|
|
304
|
+
) -> list[dict[str, Any]]:
|
|
305
|
+
"""Get audit log for changes.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
project_id: Optional project ID to filter by
|
|
309
|
+
days: Number of days to look back
|
|
310
|
+
entity_type: Optional entity type filter
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
List of change records with metadata
|
|
314
|
+
"""
|
|
315
|
+
# In v1, scan local storage for recent changes
|
|
316
|
+
cutoff_time = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
317
|
+
cutoff_time = cutoff_time.replace(day=cutoff_time.day - days)
|
|
318
|
+
|
|
319
|
+
changes = []
|
|
320
|
+
|
|
321
|
+
if not self._history_root.exists():
|
|
322
|
+
return changes
|
|
323
|
+
|
|
324
|
+
# Scan entity type directories
|
|
325
|
+
for entity_dir in self._history_root.iterdir():
|
|
326
|
+
if not entity_dir.is_dir():
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
# Filter by entity type if specified
|
|
330
|
+
if entity_type and entity_dir.name != entity_type:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
for history_file in entity_dir.glob("*.jsonl"):
|
|
334
|
+
file_changes = self._load_recent_changes_from_file(history_file, cutoff_time)
|
|
335
|
+
changes.extend(file_changes)
|
|
336
|
+
|
|
337
|
+
# Sort by timestamp (newest first)
|
|
338
|
+
changes.sort(key=lambda c: c["timestamp"], reverse=True)
|
|
339
|
+
|
|
340
|
+
return changes
|
|
341
|
+
|
|
342
|
+
def _load_recent_changes_from_file(
|
|
343
|
+
self,
|
|
344
|
+
history_file: Path,
|
|
345
|
+
cutoff_time: datetime,
|
|
346
|
+
) -> list[dict[str, Any]]:
|
|
347
|
+
"""Load recent changes from a JSONL file.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
history_file: Path to the JSONL file
|
|
351
|
+
cutoff_time: Only include changes after this time
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
List of change records
|
|
355
|
+
"""
|
|
356
|
+
changes = []
|
|
357
|
+
entity_id = history_file.stem # Filename is entity ID
|
|
358
|
+
entity_type = history_file.parent.name
|
|
359
|
+
|
|
360
|
+
with open(history_file, encoding="utf-8") as f:
|
|
361
|
+
for line in f:
|
|
362
|
+
data = json.loads(line)
|
|
363
|
+
timestamp = datetime.fromisoformat(data["timestamp"])
|
|
364
|
+
|
|
365
|
+
if timestamp >= cutoff_time:
|
|
366
|
+
changes.append(
|
|
367
|
+
{
|
|
368
|
+
"timestamp": timestamp,
|
|
369
|
+
"author": data["author"],
|
|
370
|
+
"entity_type": entity_type,
|
|
371
|
+
"entity_id": entity_id,
|
|
372
|
+
"revision_id": data["revision_id"],
|
|
373
|
+
"changes": data["changes"],
|
|
374
|
+
"reason": data.get("reason"),
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return changes
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class HistoryTracker:
|
|
382
|
+
"""Track and manage change history for entities.
|
|
383
|
+
|
|
384
|
+
Provides methods to record changes, retrieve history, compare revisions,
|
|
385
|
+
and generate audit logs.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
def __init__(self, client: NotionClient) -> None:
|
|
389
|
+
"""Initialize the HistoryTracker.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
client: Notion API client
|
|
393
|
+
"""
|
|
394
|
+
self._client = client
|
|
395
|
+
self._storage = HistoryStorage(client)
|
|
396
|
+
|
|
397
|
+
async def record_creation(
|
|
398
|
+
self,
|
|
399
|
+
entity_id: str,
|
|
400
|
+
entity_type: str,
|
|
401
|
+
author: str,
|
|
402
|
+
properties: dict[str, Any],
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Record entity creation.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
entity_id: Entity ID
|
|
408
|
+
entity_type: Entity type (e.g., "task", "idea")
|
|
409
|
+
author: Who created the entity
|
|
410
|
+
properties: Initial property values
|
|
411
|
+
"""
|
|
412
|
+
changes = [
|
|
413
|
+
PropertyChange(
|
|
414
|
+
property_name=prop,
|
|
415
|
+
from_value=None,
|
|
416
|
+
to_value=self._format_value(value),
|
|
417
|
+
)
|
|
418
|
+
for prop, value in properties.items()
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
revision = Revision(
|
|
422
|
+
revision_id=1,
|
|
423
|
+
timestamp=datetime.now(timezone.utc),
|
|
424
|
+
author=author,
|
|
425
|
+
changes=changes,
|
|
426
|
+
reason="Initial creation",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
await self._storage.save_revision(entity_id, entity_type, revision)
|
|
430
|
+
|
|
431
|
+
async def record_update(
|
|
432
|
+
self,
|
|
433
|
+
entity_id: str,
|
|
434
|
+
entity_type: str,
|
|
435
|
+
author: str,
|
|
436
|
+
old_properties: dict[str, Any],
|
|
437
|
+
new_properties: dict[str, Any],
|
|
438
|
+
reason: str | None = None,
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Record entity update.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
entity_id: Entity ID
|
|
444
|
+
entity_type: Entity type
|
|
445
|
+
author: Who made the change
|
|
446
|
+
old_properties: Property values before change
|
|
447
|
+
new_properties: Property values after change
|
|
448
|
+
reason: Optional explanation for the change
|
|
449
|
+
"""
|
|
450
|
+
changes = []
|
|
451
|
+
|
|
452
|
+
# Find changed properties
|
|
453
|
+
all_keys = set(old_properties.keys()) | set(new_properties.keys())
|
|
454
|
+
|
|
455
|
+
for key in all_keys:
|
|
456
|
+
old_val = old_properties.get(key)
|
|
457
|
+
new_val = new_properties.get(key)
|
|
458
|
+
|
|
459
|
+
if old_val != new_val:
|
|
460
|
+
changes.append(
|
|
461
|
+
PropertyChange(
|
|
462
|
+
property_name=key,
|
|
463
|
+
from_value=self._format_value(old_val),
|
|
464
|
+
to_value=self._format_value(new_val),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if not changes:
|
|
469
|
+
return # No changes to record
|
|
470
|
+
|
|
471
|
+
# Get next revision number
|
|
472
|
+
current_revisions = await self._storage.get_revisions(entity_id, entity_type)
|
|
473
|
+
next_revision_id = len(current_revisions) + 1
|
|
474
|
+
|
|
475
|
+
revision = Revision(
|
|
476
|
+
revision_id=next_revision_id,
|
|
477
|
+
timestamp=datetime.now(timezone.utc),
|
|
478
|
+
author=author,
|
|
479
|
+
changes=changes,
|
|
480
|
+
reason=reason,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
await self._storage.save_revision(entity_id, entity_type, revision)
|
|
484
|
+
|
|
485
|
+
async def get_history(self, entity_id: str, entity_type: str) -> list[Revision]:
|
|
486
|
+
"""Get full history for an entity.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
entity_id: Entity ID
|
|
490
|
+
entity_type: Entity type
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
List of revisions sorted by revision_id
|
|
494
|
+
"""
|
|
495
|
+
return await self._storage.get_revisions(entity_id, entity_type)
|
|
496
|
+
|
|
497
|
+
async def get_revision(
|
|
498
|
+
self,
|
|
499
|
+
entity_id: str,
|
|
500
|
+
entity_type: str,
|
|
501
|
+
revision_id: int,
|
|
502
|
+
) -> dict[str, Any] | None:
|
|
503
|
+
"""Get entity state at a specific revision.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
entity_id: Entity ID
|
|
507
|
+
entity_type: Entity type
|
|
508
|
+
revision_id: Revision number to retrieve
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Dictionary of property values at that revision, or None if not found
|
|
512
|
+
"""
|
|
513
|
+
revisions = await self._storage.get_revisions(entity_id, entity_type)
|
|
514
|
+
|
|
515
|
+
if not revisions or revision_id < 1 or revision_id > len(revisions):
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
# Reconstruct state by applying changes up to revision
|
|
519
|
+
state: dict[str, Any] = {}
|
|
520
|
+
for revision in revisions[:revision_id]:
|
|
521
|
+
for change in revision.changes:
|
|
522
|
+
state[change.property_name] = change.to_value
|
|
523
|
+
|
|
524
|
+
return state
|
|
525
|
+
|
|
526
|
+
async def compare_revisions(
|
|
527
|
+
self,
|
|
528
|
+
entity_id: str,
|
|
529
|
+
entity_type: str,
|
|
530
|
+
from_revision: int,
|
|
531
|
+
to_revision: int,
|
|
532
|
+
) -> dict[str, Any]:
|
|
533
|
+
"""Compare two revisions.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
entity_id: Entity ID
|
|
537
|
+
entity_type: Entity type
|
|
538
|
+
from_revision: Starting revision number
|
|
539
|
+
to_revision: Ending revision number
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
Dictionary with comparison results and changes
|
|
543
|
+
"""
|
|
544
|
+
revisions = await self._storage.get_revisions(entity_id, entity_type)
|
|
545
|
+
|
|
546
|
+
if not revisions:
|
|
547
|
+
raise ValueError(f"No revisions found for {entity_type}:{entity_id}")
|
|
548
|
+
|
|
549
|
+
if from_revision < 1 or from_revision > len(revisions):
|
|
550
|
+
raise ValueError(f"Invalid from_revision: {from_revision}")
|
|
551
|
+
|
|
552
|
+
if to_revision < 1 or to_revision > len(revisions):
|
|
553
|
+
raise ValueError(f"Invalid to_revision: {to_revision}")
|
|
554
|
+
|
|
555
|
+
# Get changes between revisions
|
|
556
|
+
changes = []
|
|
557
|
+
for revision in revisions[from_revision:to_revision]:
|
|
558
|
+
changes.extend(revision.changes)
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
"entity_id": entity_id,
|
|
562
|
+
"entity_type": entity_type,
|
|
563
|
+
"comparing": {
|
|
564
|
+
"from_revision": from_revision,
|
|
565
|
+
"to_revision": to_revision,
|
|
566
|
+
},
|
|
567
|
+
"changes": [c.to_dict() for c in changes],
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async def get_audit_log(
|
|
571
|
+
self,
|
|
572
|
+
project_id: str | None = None,
|
|
573
|
+
days: int = 7,
|
|
574
|
+
entity_type: str | None = None,
|
|
575
|
+
) -> dict[str, Any]:
|
|
576
|
+
"""Get audit log for changes.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
project_id: Optional project ID to filter by (not implemented in v1)
|
|
580
|
+
days: Number of days to look back
|
|
581
|
+
entity_type: Optional entity type filter
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
Dictionary with changes and summary statistics
|
|
585
|
+
"""
|
|
586
|
+
changes = await self._storage.get_audit_log(project_id, days, entity_type)
|
|
587
|
+
|
|
588
|
+
# Build summary statistics
|
|
589
|
+
by_author: dict[str, int] = {}
|
|
590
|
+
by_action: dict[str, int] = {"created": 0, "updated": 0}
|
|
591
|
+
by_type: dict[str, int] = {}
|
|
592
|
+
|
|
593
|
+
for change in changes:
|
|
594
|
+
# Count by author
|
|
595
|
+
author = change["author"]
|
|
596
|
+
by_author[author] = by_author.get(author, 0) + 1
|
|
597
|
+
|
|
598
|
+
# Count by action (creation has revision_id == 1)
|
|
599
|
+
if change["revision_id"] == 1:
|
|
600
|
+
by_action["created"] += 1
|
|
601
|
+
else:
|
|
602
|
+
by_action["updated"] += 1
|
|
603
|
+
|
|
604
|
+
# Count by type
|
|
605
|
+
etype = change["entity_type"]
|
|
606
|
+
by_type[etype] = by_type.get(etype, 0) + 1
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
"changes": changes,
|
|
610
|
+
"summary": {
|
|
611
|
+
"total_changes": len(changes),
|
|
612
|
+
"by_author": by_author,
|
|
613
|
+
"by_action": by_action,
|
|
614
|
+
"by_type": by_type,
|
|
615
|
+
},
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
def _format_value(self, value: Any) -> Any:
|
|
619
|
+
"""Format a value for storage.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
value: Value to format
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
Formatted value suitable for JSON serialization
|
|
626
|
+
"""
|
|
627
|
+
if value is None:
|
|
628
|
+
return None
|
|
629
|
+
elif isinstance(value, dict):
|
|
630
|
+
# Handle complex types like select, relation, etc.
|
|
631
|
+
if "name" in value:
|
|
632
|
+
return value["name"]
|
|
633
|
+
elif "title" in value:
|
|
634
|
+
# Extract text content from title
|
|
635
|
+
title_list = value["title"]
|
|
636
|
+
if title_list and len(title_list) > 0:
|
|
637
|
+
return title_list[0].get("text", {}).get("content", "")
|
|
638
|
+
return ""
|
|
639
|
+
elif "rich_text" in value:
|
|
640
|
+
# Extract text content from rich_text
|
|
641
|
+
text_list = value["rich_text"]
|
|
642
|
+
if text_list and len(text_list) > 0:
|
|
643
|
+
return text_list[0].get("text", {}).get("content", "")
|
|
644
|
+
return ""
|
|
645
|
+
elif "date" in value:
|
|
646
|
+
date_obj = value["date"]
|
|
647
|
+
if date_obj:
|
|
648
|
+
return date_obj.get("start", "")
|
|
649
|
+
return ""
|
|
650
|
+
else:
|
|
651
|
+
return str(value)
|
|
652
|
+
elif isinstance(value, list):
|
|
653
|
+
# Handle multi-select or relations
|
|
654
|
+
if len(value) > 0 and isinstance(value[0], dict):
|
|
655
|
+
return [v.get("name", str(v)) for v in value]
|
|
656
|
+
return value
|
|
657
|
+
else:
|
|
658
|
+
return value
|