Coverage for src/lexigram/admin/actions/standard/row.py: 95%

152 statements  

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

1"""Ready-to-use row actions (edit, view, delete, clone, restore, purge). 

2 

3Part of the ``lexigram.admin.actions.standard`` package. 

4""" 

5 

6from __future__ import annotations 

7 

8import inspect 

9from typing import Any 

10 

11from lexigram.admin.actions.base import RowAction 

12from lexigram.admin.actions.standard.utils import _extract_id, _resolve_data_source 

13from lexigram.admin.actions.exceptions import ActionError 

14from lexigram.admin.actions.types import ( 

15 ActionColor, 

16 ActionContext, 

17 ConfirmationConfig, 

18) 

19from lexigram.ui import Zones 

20from lexigram.result import Err, Ok, Result 

21 

22 

23class EditAction(RowAction): 

24 """Edit a single record.""" 

25 

26 def __init__( 

27 self, 

28 name: str = "edit", 

29 label: str | None = None, 

30 ) -> None: 

31 super().__init__( 

32 name=name, 

33 label=label or "Edit", 

34 icon="pencil", 

35 color=ActionColor.PRIMARY, 

36 ) 

37 

38 def _get_url(self, record: Any, ctx: ActionContext) -> str | None: 

39 record_id = self._get_record_id(record) 

40 if not record_id: 

41 return None 

42 prefix = ctx.resource_prefix or f"/{ctx.resource_name}" 

43 return f"{prefix}/{record_id}/edit" 

44 

45 def _get_htmx_attrs( 

46 self, url: str, record: Any, ctx: ActionContext 

47 ) -> dict[str, str]: 

48 return { 

49 "hx-get": url, 

50 "hx-target": Zones.SLIDE_OVER.selector, 

51 "hx-swap": Zones.SLIDE_OVER.swap_mode.value, 

52 } 

53 

54 async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]: 

55 return Ok({"message": f"Edited {record}"}) 

56 

57 

58class ViewAction(RowAction): 

59 """View a single record.""" 

60 

61 def __init__( 

62 self, 

63 name: str = "view", 

64 label: str | None = None, 

65 ) -> None: 

66 super().__init__( 

67 name=name, 

68 label=label or "View", 

69 icon="eye", 

70 color=ActionColor.GRAY, 

71 ) 

72 

73 def _get_url(self, record: Any, ctx: ActionContext) -> str | None: 

74 record_id = self._get_record_id(record) 

75 if not record_id: 

76 return None 

77 prefix = ctx.resource_prefix or f"/{ctx.resource_name}" 

78 return f"{prefix}/{record_id}" 

79 

80 async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]: 

81 return Ok({"message": f"Viewed {record}"}) 

82 

83 

84class DeleteAction(RowAction): 

85 """Delete a single record with confirmation.""" 

86 

87 def __init__( 

88 self, 

89 name: str = "delete", 

90 label: str | None = None, 

91 confirm_title: str = "Delete Record", 

92 confirm_message: str | None = None, 

93 ) -> None: 

94 super().__init__( 

95 name=name, 

96 label=label or "Delete", 

97 icon="trash", 

98 color=ActionColor.DANGER, 

99 ) 

100 self._confirm_title = confirm_title 

101 self._confirm_message = confirm_message or ( 

102 "Are you sure you want to delete this record? This action cannot be undone." 

103 ) 

104 

105 def _get_url(self, record: Any, ctx: ActionContext) -> str | None: 

106 record_id = self._get_record_id(record) 

107 if not record_id: 

108 return None 

109 prefix = ctx.resource_prefix or f"/{ctx.resource_name}" 

110 return f"{prefix}/{record_id}/delete-confirm" 

111 

112 def _get_htmx_attrs( 

113 self, url: str, record: Any, ctx: ActionContext 

114 ) -> dict[str, str]: 

115 return { 

116 "hx-get": url, 

117 "hx-target": Zones.SLIDE_OVER.selector, 

118 "hx-swap": Zones.SLIDE_OVER.swap_mode.value, 

119 "hx-push-url": "false", 

120 } 

121 

122 def confirm(self) -> ConfirmationConfig | None: 

123 return ConfirmationConfig( 

124 title=self._confirm_title, 

125 message=self._confirm_message, 

126 style=ActionColor.DANGER, 

127 ) 

128 

129 async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]: 

130 return Ok({"message": f"Deleted {record}", "deleted": True}) 

131 

132 

133class PermissionsAction(RowAction): 

134 """Edit a user's direct permissions (users resource only).""" 

135 

136 def __init__( 

137 self, 

138 name: str = "permissions", 

139 label: str | None = None, 

140 ) -> None: 

141 super().__init__( 

142 name=name, 

143 label=label or "Edit Permissions", 

144 icon="shield", 

145 color=ActionColor.GRAY, 

146 ) 

147 

148 def _get_url(self, record: Any, ctx: ActionContext) -> str | None: 

149 record_id = self._get_record_id(record) 

150 if not record_id: 

151 return None 

152 prefix = ctx.resource_prefix or f"/{ctx.resource_name}" 

153 return f"{prefix}/{record_id}/permissions" 

154 

155 async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]: 

156 return Ok({"message": f"Editing permissions for {record}"}) 

157 

158 

159class ImpersonateAction(RowAction): 

160 """Impersonate a user's session (users resource only). 

161 

162 Target-role restriction (denying impersonation of another 

163 super-admin) is enforced server-side only — this action has no 

164 access to DI/config at render time (``Action`` is a frozen, 

165 import-time-constructed dataclass), and the row's rendered fields 

166 don't expose RBAC role membership. See 

167 ``docs/superpowers/specs/2026-08-19-admin-impersonation-usability-design.md`` 

168 D5 for the full reasoning. 

169 """ 

170 

171 def __init__( 

172 self, 

173 name: str = "impersonate", 

174 label: str | None = None, 

175 ) -> None: 

176 super().__init__( 

177 name=name, 

178 label=label or "Impersonate", 

179 icon="user-check", 

180 color=ActionColor.GRAY, 

181 ) 

182 

183 def visible_for(self, record: Any, user: Any | None = None) -> bool: 

184 if user is None: 

185 return True 

186 record_id = self._get_record_id(record) 

187 actor_id = str(getattr(user, "id", "")) 

188 return record_id != actor_id 

189 

190 def _get_url(self, record: Any, ctx: ActionContext) -> str | None: 

191 record_id = self._get_record_id(record) 

192 if not record_id: 

193 return None 

194 return f"/admin/impersonate/{record_id}" 

195 

196 def _get_htmx_attrs( 

197 self, url: str, record: Any, ctx: ActionContext 

198 ) -> dict[str, str]: 

199 name = ( 

200 record.get("name", "this user") if isinstance(record, dict) else "this user" 

201 ) 

202 return { 

203 "hx-post": url, 

204 "hx-target": "body", 

205 "hx-swap": "none", 

206 "hx-confirm": f"Impersonate {name}?", 

207 } 

208 

209 async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]: 

210 return Ok({"message": f"Impersonating {record}"}) 

211 

212 

213class CloneAction(RowAction): 

214 """Clone a single record through the data source. 

215 

216 Mirrors Filament's ``Replicate`` action: ``exclude_attributes`` drops 

217 fields from the copy (``excludeAttributes``), ``mutate_record_data`` 

218 transforms the record data before creation (``mutateRecordDataUsing``), 

219 and ``before_replica_saved`` runs just before the new record is 

220 persisted (``beforeReplicaSaved``). The ``id`` field is always stripped 

221 so a fresh identifier is assigned. 

222 """ 

223 

224 def __init__( 

225 self, 

226 name: str = "clone", 

227 label: str | None = None, 

228 data_source: Any | None = None, 

229 exclude_attributes: list[str] | None = None, 

230 mutate_record_data: Callable[ 

231 [dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]] 

232 ] 

233 | None = None, 

234 before_replica_saved: Callable[[dict[str, Any]], None | Awaitable[None]] 

235 | None = None, 

236 ) -> None: 

237 super().__init__( 

238 name=name, 

239 label=label or "Clone", 

240 icon="copy", 

241 color=ActionColor.SECONDARY, 

242 ) 

243 self._data_source = data_source 

244 self._exclude_attributes = exclude_attributes or [] 

245 self._mutate_record_data = mutate_record_data 

246 self._before_replica_saved = before_replica_saved 

247 

248 def _get_url(self, record: Any, ctx: ActionContext) -> str | None: 

249 record_id = self._get_record_id(record) 

250 if not record_id: 

251 return None 

252 prefix = ctx.resource_prefix or f"/{ctx.resource_name}" 

253 return f"{prefix}/{record_id}/clone" 

254 

255 async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]: 

256 record_id = _extract_id(record) 

257 if record_id is None: 

258 return Err(ActionError("Clone requires a record with an id.")) 

259 data_source = _resolve_data_source(ctx, self._data_source) 

260 if data_source is None: 

261 return Err( 

262 ActionError( 

263 "Clone requires a data source; inject one or set ctx.data_source." 

264 ) 

265 ) 

266 original = await data_source.find_one(record_id) 

267 if original is None: 

268 return Err(ActionError(f"Record {record_id} not found.")) 

269 data: dict[str, Any] = dict(original) if isinstance(original, dict) else {} 

270 if not data and hasattr(original, "__dict__"): 

271 data = dict(vars(original)) 

272 data.pop("id", None) 

273 for attribute in self._exclude_attributes: 

274 data.pop(attribute, None) 

275 if self._mutate_record_data is not None: 

276 mutated = self._mutate_record_data(data) 

277 data = await mutated if inspect.isawaitable(mutated) else mutated 

278 if self._before_replica_saved is not None: 

279 hook = self._before_replica_saved(data) 

280 if inspect.isawaitable(hook): 

281 await hook 

282 created = await data_source.create(data) 

283 if created is None: 

284 return Err(ActionError(f"Failed to clone record {record_id}.")) 

285 return Ok( 

286 { 

287 "message": "Cloned record", 

288 "record": created, 

289 "cloned_id": _extract_id(created), 

290 } 

291 ) 

292 

293 

294class RestoreAction(RowAction): 

295 """Restore a single soft-deleted record.""" 

296 

297 def __init__( 

298 self, 

299 name: str = "restore", 

300 label: str | None = None, 

301 ) -> None: 

302 super().__init__( 

303 name=name, 

304 label=label or "Restore", 

305 icon="rotate-ccw", 

306 color=ActionColor.SUCCESS, 

307 ) 

308 

309 def _get_url(self, record: Any, ctx: ActionContext) -> str | None: 

310 record_id = self._get_record_id(record) 

311 if not record_id: 

312 return None 

313 prefix = ctx.resource_prefix or f"/{ctx.resource_name}" 

314 return f"{prefix}/{record_id}/restore" 

315 

316 async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]: 

317 return Ok({"message": f"Restored {record}"}) 

318 

319 

320class PurgeAction(RowAction): 

321 """Permanently delete a soft-deleted record.""" 

322 

323 def __init__( 

324 self, 

325 name: str = "purge", 

326 label: str | None = None, 

327 confirm_title: str = "Permanently Delete Record", 

328 confirm_message: str | None = None, 

329 ) -> None: 

330 super().__init__( 

331 name=name, 

332 label=label or "Purge", 

333 icon="trash-2", 

334 color=ActionColor.DANGER, 

335 ) 

336 self._confirm_title = confirm_title 

337 self._confirm_message = confirm_message or ( 

338 "Are you sure you want to permanently delete this record? " 

339 "This action cannot be undone." 

340 ) 

341 

342 def _get_url(self, record: Any, ctx: ActionContext) -> str | None: 

343 record_id = self._get_record_id(record) 

344 if not record_id: 

345 return None 

346 prefix = ctx.resource_prefix or f"/{ctx.resource_name}" 

347 return f"{prefix}/{record_id}/purge" 

348 

349 def _get_htmx_attrs( 

350 self, url: str, record: Any, ctx: ActionContext 

351 ) -> dict[str, str]: 

352 attrs: dict[str, str] = { 

353 "hx-delete": url, 

354 "hx-target": "#table-data", 

355 "hx-swap": "innerHTML", 

356 } 

357 confirmation = self.confirm() 

358 if confirmation and confirmation.message: 

359 attrs["hx-confirm"] = confirmation.message 

360 return attrs 

361 

362 def confirm(self) -> ConfirmationConfig | None: 

363 return ConfirmationConfig( 

364 title=self._confirm_title, 

365 message=self._confirm_message, 

366 style=ActionColor.DANGER, 

367 ) 

368 

369 async def execute(self, record: Any, ctx: ActionContext) -> Result[Any, Any]: 

370 return Ok({"message": f"Purged {record}", "purged": True})