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

153 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +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 

8from collections.abc import Awaitable, Callable 

9 

10import inspect 

11from typing import Any 

12 

13from lexigram.admin.actions.base import RowAction 

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

15from lexigram.admin.actions.exceptions import ActionError 

16from lexigram.admin.actions.types import ( 

17 ActionColor, 

18 ActionContext, 

19 ConfirmationConfig, 

20) 

21from lexigram.ui import Zones 

22from lexigram.result import Err, Ok, Result 

23 

24 

25class EditAction(RowAction): 

26 """Edit a single record.""" 

27 

28 def __init__( 

29 self, 

30 name: str = "edit", 

31 label: str | None = None, 

32 ) -> None: 

33 super().__init__( 

34 name=name, 

35 label=label or "Edit", 

36 icon="pencil", 

37 color=ActionColor.PRIMARY, 

38 ) 

39 

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

41 record_id = self._get_record_id(record) 

42 if not record_id: 

43 return None 

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

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

46 

47 def _get_htmx_attrs( 

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

49 ) -> dict[str, str]: 

50 return { 

51 "hx-get": url, 

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

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

54 } 

55 

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

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

58 

59 

60class ViewAction(RowAction): 

61 """View a single record.""" 

62 

63 def __init__( 

64 self, 

65 name: str = "view", 

66 label: str | None = None, 

67 ) -> None: 

68 super().__init__( 

69 name=name, 

70 label=label or "View", 

71 icon="eye", 

72 color=ActionColor.GRAY, 

73 ) 

74 

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

76 record_id = self._get_record_id(record) 

77 if not record_id: 

78 return None 

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

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

81 

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

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

84 

85 

86class DeleteAction(RowAction): 

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

88 

89 def __init__( 

90 self, 

91 name: str = "delete", 

92 label: str | None = None, 

93 confirm_title: str = "Delete Record", 

94 confirm_message: str | None = None, 

95 ) -> None: 

96 super().__init__( 

97 name=name, 

98 label=label or "Delete", 

99 icon="trash", 

100 color=ActionColor.DANGER, 

101 ) 

102 self._confirm_title = confirm_title 

103 self._confirm_message = confirm_message or ( 

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

105 ) 

106 

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

108 record_id = self._get_record_id(record) 

109 if not record_id: 

110 return None 

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

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

113 

114 def _get_htmx_attrs( 

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

116 ) -> dict[str, str]: 

117 return { 

118 "hx-get": url, 

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

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

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

122 } 

123 

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

125 return ConfirmationConfig( 

126 title=self._confirm_title, 

127 message=self._confirm_message, 

128 style=ActionColor.DANGER, 

129 ) 

130 

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

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

133 

134 

135class PermissionsAction(RowAction): 

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

137 

138 def __init__( 

139 self, 

140 name: str = "permissions", 

141 label: str | None = None, 

142 ) -> None: 

143 super().__init__( 

144 name=name, 

145 label=label or "Edit Permissions", 

146 icon="shield", 

147 color=ActionColor.GRAY, 

148 ) 

149 

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

151 record_id = self._get_record_id(record) 

152 if not record_id: 

153 return None 

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

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

156 

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

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

159 

160 

161class ImpersonateAction(RowAction): 

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

163 

164 Target-role restriction (denying impersonation of another 

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

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

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

168 don't expose RBAC role membership. See 

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

170 D5 for the full reasoning. 

171 """ 

172 

173 def __init__( 

174 self, 

175 name: str = "impersonate", 

176 label: str | None = None, 

177 ) -> None: 

178 super().__init__( 

179 name=name, 

180 label=label or "Impersonate", 

181 icon="user-check", 

182 color=ActionColor.GRAY, 

183 ) 

184 

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

186 if user is None: 

187 return True 

188 record_id = self._get_record_id(record) 

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

190 return record_id != actor_id 

191 

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

193 record_id = self._get_record_id(record) 

194 if not record_id: 

195 return None 

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

197 

198 def _get_htmx_attrs( 

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

200 ) -> dict[str, str]: 

201 name = ( 

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

203 ) 

204 return { 

205 "hx-post": url, 

206 "hx-target": "body", 

207 "hx-swap": "none", 

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

209 } 

210 

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

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

213 

214 

215class CloneAction(RowAction): 

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

217 

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

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

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

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

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

223 so a fresh identifier is assigned. 

224 """ 

225 

226 def __init__( 

227 self, 

228 name: str = "clone", 

229 label: str | None = None, 

230 data_source: Any | None = None, 

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

232 mutate_record_data: Callable[ 

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

234 ] 

235 | None = None, 

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

237 | None = None, 

238 ) -> None: 

239 super().__init__( 

240 name=name, 

241 label=label or "Clone", 

242 icon="copy", 

243 color=ActionColor.SECONDARY, 

244 ) 

245 self._data_source = data_source 

246 self._exclude_attributes = exclude_attributes or [] 

247 self._mutate_record_data = mutate_record_data 

248 self._before_replica_saved = before_replica_saved 

249 

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

251 record_id = self._get_record_id(record) 

252 if not record_id: 

253 return None 

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

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

256 

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

258 record_id = _extract_id(record) 

259 if record_id is None: 

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

261 data_source = _resolve_data_source(ctx, self._data_source) 

262 if data_source is None: 

263 return Err( 

264 ActionError( 

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

266 ) 

267 ) 

268 original = await data_source.find_one(record_id) 

269 if original is None: 

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

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

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

273 data = dict(vars(original)) 

274 data.pop("id", None) 

275 for attribute in self._exclude_attributes: 

276 data.pop(attribute, None) 

277 if self._mutate_record_data is not None: 

278 mutated = self._mutate_record_data(data) 

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

280 if self._before_replica_saved is not None: 

281 hook = self._before_replica_saved(data) 

282 if inspect.isawaitable(hook): 

283 await hook 

284 created = await data_source.create(data) 

285 if created is None: 

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

287 return Ok( 

288 { 

289 "message": "Cloned record", 

290 "record": created, 

291 "cloned_id": _extract_id(created), 

292 } 

293 ) 

294 

295 

296class RestoreAction(RowAction): 

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

298 

299 def __init__( 

300 self, 

301 name: str = "restore", 

302 label: str | None = None, 

303 ) -> None: 

304 super().__init__( 

305 name=name, 

306 label=label or "Restore", 

307 icon="rotate-ccw", 

308 color=ActionColor.SUCCESS, 

309 ) 

310 

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

312 record_id = self._get_record_id(record) 

313 if not record_id: 

314 return None 

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

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

317 

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

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

320 

321 

322class PurgeAction(RowAction): 

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

324 

325 def __init__( 

326 self, 

327 name: str = "purge", 

328 label: str | None = None, 

329 confirm_title: str = "Permanently Delete Record", 

330 confirm_message: str | None = None, 

331 ) -> None: 

332 super().__init__( 

333 name=name, 

334 label=label or "Purge", 

335 icon="trash-2", 

336 color=ActionColor.DANGER, 

337 ) 

338 self._confirm_title = confirm_title 

339 self._confirm_message = confirm_message or ( 

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

341 "This action cannot be undone." 

342 ) 

343 

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

345 record_id = self._get_record_id(record) 

346 if not record_id: 

347 return None 

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

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

350 

351 def _get_htmx_attrs( 

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

353 ) -> dict[str, str]: 

354 attrs: dict[str, str] = { 

355 "hx-delete": url, 

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

357 "hx-swap": "innerHTML", 

358 } 

359 confirmation = self.confirm() 

360 if confirmation and confirmation.message: 

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

362 return attrs 

363 

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

365 return ConfirmationConfig( 

366 title=self._confirm_title, 

367 message=self._confirm_message, 

368 style=ActionColor.DANGER, 

369 ) 

370 

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

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