better-notion 2.2.0__py3-none-any.whl → 2.3.2__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,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