Coverage for src/lexigram/admin/services/revisions.py: 100%

92 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Revision history service for lexigram-admin. 

2 

3Stores full field snapshots on each save and provides field-by-field diff 

4comparison between any two revisions. Supports revert to any prior revision. 

5 

6The service uses a structural ``_RevisionStore`` protocol so it works with 

7an in-memory store (default, zero dependencies) or a DB-backed store 

8injected via the DI container. 

9""" 

10 

11from __future__ import annotations 

12 

13from dataclasses import dataclass, field 

14from datetime import UTC, datetime 

15from typing import Any, Protocol 

16 

17# --------------------------------------------------------------------------- 

18# Data models 

19# --------------------------------------------------------------------------- 

20 

21 

22@dataclass 

23class Revision: 

24 """A single point-in-time snapshot of a resource record. 

25 

26 Attributes: 

27 revision_id: Unique identifier for this revision. 

28 resource_type: Name of the resource type (e.g. ``"user"``). 

29 resource_id: Identifier of the affected record. 

30 data: Full field snapshot at this point in time. 

31 actor_id: Who created this revision. 

32 created_at: UTC timestamp. 

33 comment: Optional human-readable description of the change. 

34 """ 

35 

36 revision_id: str 

37 resource_type: str 

38 resource_id: str 

39 data: dict[str, Any] 

40 actor_id: str 

41 created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) 

42 comment: str = "" 

43 

44 

45@dataclass 

46class FieldDiff: 

47 """The change for a single field between two revisions. 

48 

49 Attributes: 

50 field_name: Name of the field that changed. 

51 old_value: Value in the earlier revision (``None`` if field was absent). 

52 new_value: Value in the later revision (``None`` if field was removed). 

53 changed: Whether old and new values differ. 

54 """ 

55 

56 field_name: str 

57 old_value: Any 

58 new_value: Any 

59 changed: bool 

60 

61 

62@dataclass 

63class RevisionDiff: 

64 """Diff result between two revisions. 

65 

66 Attributes: 

67 from_revision: Earlier revision identifier. 

68 to_revision: Later revision identifier. 

69 fields: Per-field diff entries (only fields where ``changed=True`` by default). 

70 all_fields: When ``True`` all fields were included, not just changed ones. 

71 """ 

72 

73 from_revision: str 

74 to_revision: str 

75 fields: list[FieldDiff] 

76 all_fields: bool = False 

77 

78 

79# --------------------------------------------------------------------------- 

80# Storage protocol 

81# --------------------------------------------------------------------------- 

82 

83 

84class _RevisionStore(Protocol): 

85 """Minimal storage protocol for revision persistence.""" 

86 

87 async def save(self, revision: Revision) -> None: 

88 """Persist a new revision.""" 

89 ... 

90 

91 async def list_for_record( 

92 self, 

93 resource_type: str, 

94 resource_id: str, 

95 *, 

96 limit: int = 50, 

97 ) -> list[Revision]: 

98 """Return revisions for a record, newest-first.""" 

99 ... 

100 

101 async def get(self, revision_id: str) -> Revision | None: 

102 """Return a single revision by ID.""" 

103 ... 

104 

105 async def delete_for_record(self, resource_type: str, resource_id: str) -> int: 

106 """Delete all revisions for a record. Returns count deleted.""" 

107 ... 

108 

109 

110# --------------------------------------------------------------------------- 

111# In-memory store (default — zero infra deps) 

112# --------------------------------------------------------------------------- 

113 

114 

115class InMemoryRevisionStore: 

116 """Thread-safe in-process revision store. 

117 

118 Suitable for development and testing. Data is lost on restart. 

119 For production wire a DB-backed store via the DI container. 

120 """ 

121 

122 def __init__(self, max_per_record: int = 100) -> None: 

123 self._store: dict[str, list[Revision]] = {} # key: "type:id" 

124 self._by_id: dict[str, Revision] = {} 

125 self.max_per_record = max_per_record 

126 

127 def _key(self, resource_type: str, resource_id: str) -> str: 

128 return f"{resource_type}:{resource_id}" 

129 

130 async def save(self, revision: Revision) -> None: 

131 """Persist revision, trimming oldest if over max_per_record.""" 

132 k = self._key(revision.resource_type, revision.resource_id) 

133 bucket = self._store.setdefault(k, []) 

134 bucket.insert(0, revision) 

135 self._by_id[revision.revision_id] = revision 

136 if len(bucket) > self.max_per_record: 

137 dropped = bucket.pop() 

138 self._by_id.pop(dropped.revision_id, None) 

139 

140 async def list_for_record( 

141 self, 

142 resource_type: str, 

143 resource_id: str, 

144 *, 

145 limit: int = 50, 

146 ) -> list[Revision]: 

147 """Return up to *limit* revisions, newest-first.""" 

148 k = self._key(resource_type, resource_id) 

149 return self._store.get(k, [])[:limit] 

150 

151 async def get(self, revision_id: str) -> Revision | None: 

152 return self._by_id.get(revision_id) 

153 

154 async def delete_for_record(self, resource_type: str, resource_id: str) -> int: 

155 k = self._key(resource_type, resource_id) 

156 revisions = self._store.pop(k, []) 

157 for rev in revisions: 

158 self._by_id.pop(rev.revision_id, None) 

159 return len(revisions) 

160 

161 

162# --------------------------------------------------------------------------- 

163# RevisionService 

164# --------------------------------------------------------------------------- 

165 

166 

167class RevisionService: 

168 """Manages resource revision snapshots with diff and revert support. 

169 

170 Args: 

171 store: Revision store implementation. Defaults to 

172 :class:`InMemoryRevisionStore`. 

173 max_revisions: Maximum revisions retained per record (forwarded to 

174 :class:`InMemoryRevisionStore` if no store is provided). 

175 """ 

176 

177 def __init__( 

178 self, 

179 store: _RevisionStore | None = None, 

180 max_revisions: int = 100, 

181 ) -> None: 

182 self._store: _RevisionStore = store or InMemoryRevisionStore( 

183 max_per_record=max_revisions 

184 ) 

185 self._counter: int = 0 

186 

187 # ------------------------------------------------------------------ 

188 # Internal helpers 

189 # ------------------------------------------------------------------ 

190 

191 def _new_id(self) -> str: 

192 self._counter += 1 

193 ts = datetime.now(UTC).strftime("%Y%m%d%H%M%S%f") 

194 return f"rev-{ts}-{self._counter}" 

195 

196 # ------------------------------------------------------------------ 

197 # Public API 

198 # ------------------------------------------------------------------ 

199 

200 async def record( 

201 self, 

202 resource_type: str, 

203 resource_id: str, 

204 data: dict[str, Any], 

205 *, 

206 actor_id: str = "system", 

207 comment: str = "", 

208 ) -> Revision: 

209 """Create and persist a new revision snapshot. 

210 

211 Call this *after* a successful save so the snapshot reflects the 

212 committed state. 

213 

214 Args: 

215 resource_type: Resource name (e.g. ``"user"``). 

216 resource_id: Record identifier. 

217 data: Full field snapshot. 

218 actor_id: Principal who triggered the change. 

219 comment: Optional human-readable note. 

220 

221 Returns: 

222 The newly created :class:`Revision`. 

223 """ 

224 revision = Revision( 

225 revision_id=self._new_id(), 

226 resource_type=resource_type, 

227 resource_id=str(resource_id), 

228 data=dict(data), 

229 actor_id=actor_id, 

230 comment=comment, 

231 ) 

232 await self._store.save(revision) 

233 return revision 

234 

235 async def list_revisions( 

236 self, 

237 resource_type: str, 

238 resource_id: str, 

239 *, 

240 limit: int = 50, 

241 ) -> list[Revision]: 

242 """Return revisions for a record, newest-first. 

243 

244 Args: 

245 resource_type: Resource name. 

246 resource_id: Record identifier. 

247 limit: Maximum number of revisions to return. 

248 

249 Returns: 

250 List of :class:`Revision` objects. 

251 """ 

252 return await self._store.list_for_record( 

253 resource_type, str(resource_id), limit=limit 

254 ) 

255 

256 async def get_revision(self, revision_id: str) -> Revision | None: 

257 """Fetch a single revision by ID. 

258 

259 Args: 

260 revision_id: The revision identifier. 

261 

262 Returns: 

263 :class:`Revision` or ``None`` if not found. 

264 """ 

265 return await self._store.get(revision_id) 

266 

267 async def diff( 

268 self, 

269 revision_id_a: str, 

270 revision_id_b: str, 

271 *, 

272 include_unchanged: bool = False, 

273 ) -> RevisionDiff | None: 

274 """Compute a field-by-field diff between two revisions. 

275 

276 Args: 

277 revision_id_a: Earlier (or baseline) revision. 

278 revision_id_b: Later revision. 

279 include_unchanged: When ``True`` all fields are included, not just 

280 those that changed. 

281 

282 Returns: 

283 :class:`RevisionDiff` or ``None`` if either revision is not found. 

284 """ 

285 rev_a = await self._store.get(revision_id_a) 

286 rev_b = await self._store.get(revision_id_b) 

287 if rev_a is None or rev_b is None: 

288 return None 

289 

290 all_keys = set(rev_a.data) | set(rev_b.data) 

291 diffs: list[FieldDiff] = [] 

292 for key in sorted(all_keys): 

293 old = rev_a.data.get(key) 

294 new = rev_b.data.get(key) 

295 changed = old != new 

296 if include_unchanged or changed: 

297 diffs.append( 

298 FieldDiff( 

299 field_name=key, old_value=old, new_value=new, changed=changed 

300 ) 

301 ) 

302 

303 return RevisionDiff( 

304 from_revision=revision_id_a, 

305 to_revision=revision_id_b, 

306 fields=diffs, 

307 all_fields=include_unchanged, 

308 ) 

309 

310 async def revert_data(self, revision_id: str) -> dict[str, Any] | None: 

311 """Return the data snapshot from a prior revision, ready to apply. 

312 

313 Does **not** persist anything — callers are responsible for passing 

314 the returned data to their update handler (and then calling 

315 :meth:`record` to snapshot the revert). 

316 

317 Args: 

318 revision_id: The revision to revert to. 

319 

320 Returns: 

321 Field snapshot dict or ``None`` if revision not found. 

322 """ 

323 revision = await self._store.get(revision_id) 

324 return dict(revision.data) if revision else None 

325 

326 async def purge(self, resource_type: str, resource_id: str) -> int: 

327 """Delete all revisions for a record. 

328 

329 Args: 

330 resource_type: Resource name. 

331 resource_id: Record identifier. 

332 

333 Returns: 

334 Number of revisions deleted. 

335 """ 

336 return await self._store.delete_for_record(resource_type, str(resource_id)) 

337 

338 

339__all__ = [ 

340 "FieldDiff", 

341 "InMemoryRevisionStore", 

342 "Revision", 

343 "RevisionDiff", 

344 "RevisionService", 

345]