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

121 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Standard action implementations for lexigram-admin. 

2 

3Provides ready-to-use RowAction, BulkAction, and HeaderAction subclasses 

4that wrap common data-source operations such as edit, view, delete, create, 

5and bulk delete. Each action comes with sensible defaults for name, label, 

6icon, and color. 

7""" 

8 

9from __future__ import annotations 

10 

11from typing import Any 

12 

13from lexigram.admin.actions.base import BulkAction, HeaderAction, RowAction 

14from lexigram.admin.actions.types import ActionColor, ActionContext, ConfirmationConfig 

15from lexigram.result import Ok, Result 

16from lexigram.ui import Zones 

17 

18 

19class EditAction(RowAction): 

20 """Edit a single record.""" 

21 

22 def __init__( 

23 self, 

24 name: str = "edit", 

25 label: str | None = None, 

26 ) -> None: 

27 super().__init__( 

28 name=name, 

29 label=label or "Edit", 

30 icon="pencil", 

31 color=ActionColor.PRIMARY, 

32 ) 

33 

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

35 record_id = self._get_record_id(record) 

36 if not record_id: 

37 return None 

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

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

40 

41 def _get_htmx_attrs( 

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

43 ) -> dict[str, str]: 

44 return { 

45 "hx-get": url, 

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

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

48 } 

49 

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

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

52 

53 

54class ViewAction(RowAction): 

55 """View a single record.""" 

56 

57 def __init__( 

58 self, 

59 name: str = "view", 

60 label: str | None = None, 

61 ) -> None: 

62 super().__init__( 

63 name=name, 

64 label=label or "View", 

65 icon="eye", 

66 color=ActionColor.GRAY, 

67 ) 

68 

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

70 record_id = self._get_record_id(record) 

71 if not record_id: 

72 return None 

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

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

75 

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

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

78 

79 

80class DeleteAction(RowAction): 

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

82 

83 def __init__( 

84 self, 

85 name: str = "delete", 

86 label: str | None = None, 

87 confirm_title: str = "Delete Record", 

88 confirm_message: str | None = None, 

89 ) -> None: 

90 super().__init__( 

91 name=name, 

92 label=label or "Delete", 

93 icon="trash", 

94 color=ActionColor.DANGER, 

95 ) 

96 self._confirm_title = confirm_title 

97 self._confirm_message = confirm_message or ( 

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

99 ) 

100 

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

102 record_id = self._get_record_id(record) 

103 if not record_id: 

104 return None 

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

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

107 

108 def _get_htmx_attrs( 

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

110 ) -> dict[str, str]: 

111 return { 

112 "hx-get": url, 

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

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

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

116 } 

117 

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

119 return ConfirmationConfig( 

120 title=self._confirm_title, 

121 message=self._confirm_message, 

122 style=ActionColor.DANGER, 

123 ) 

124 

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

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

127 

128 

129class CreateAction(HeaderAction): 

130 """Create a new record.""" 

131 

132 def __init__( 

133 self, 

134 name: str = "create", 

135 label: str | None = None, 

136 ) -> None: 

137 super().__init__( 

138 name=name, 

139 label=label or "Create", 

140 icon="plus", 

141 color=ActionColor.PRIMARY, 

142 ) 

143 

144 def _get_htmx_attrs( 

145 self, url: str, record: None, ctx: ActionContext 

146 ) -> dict[str, str]: 

147 return { 

148 "hx-get": url, 

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

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

151 } 

152 

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

154 return Ok({"message": "Created new record"}) 

155 

156 

157class DeleteBulkAction(BulkAction): 

158 """Delete multiple selected records.""" 

159 

160 def __init__( 

161 self, 

162 name: str = "delete", 

163 label: str | None = None, 

164 ) -> None: 

165 super().__init__( 

166 name=name, 

167 label=label or "Delete Selected", 

168 icon="trash", 

169 color=ActionColor.DANGER, 

170 ) 

171 

172 def _get_htmx_attrs( 

173 self, url: str, records: list[Any], ctx: ActionContext 

174 ) -> dict[str, str]: 

175 # Open bulk delete confirmation slide-over instead of native hx-confirm 

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

177 confirm_url = f"{prefix}/bulk-delete-confirm" 

178 return { 

179 "hx-get": confirm_url, 

180 "hx-target": "#slide-over-container", 

181 "hx-swap": "innerHTML", 

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

183 "hx-include": "#lexigram-table [name='ids']:checked", 

184 } 

185 

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

187 return ConfirmationConfig( 

188 title="Delete Selected Records", 

189 message="Are you sure you want to delete the selected records? " 

190 "This action cannot be undone.", 

191 style=ActionColor.DANGER, 

192 ) 

193 

194 async def execute(self, records: list[Any], ctx: ActionContext) -> Result[Any, Any]: 

195 count = len(records) 

196 return Ok({"message": f"Deleted {count} record(s)", "deleted_count": count}) 

197 

198 

199class CloneAction(RowAction): 

200 """Clone a single record.""" 

201 

202 def __init__( 

203 self, 

204 name: str = "clone", 

205 label: str | None = None, 

206 ) -> None: 

207 super().__init__( 

208 name=name, 

209 label=label or "Clone", 

210 icon="copy", 

211 color=ActionColor.SECONDARY, 

212 ) 

213 

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

215 record_id = self._get_record_id(record) 

216 if not record_id: 

217 return None 

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

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

220 

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

222 return Ok({"message": f"Cloned {record}"}) 

223 

224 

225class RestoreAction(RowAction): 

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

227 

228 def __init__( 

229 self, 

230 name: str = "restore", 

231 label: str | None = None, 

232 ) -> None: 

233 super().__init__( 

234 name=name, 

235 label=label or "Restore", 

236 icon="rotate-ccw", 

237 color=ActionColor.SUCCESS, 

238 ) 

239 

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

241 record_id = self._get_record_id(record) 

242 if not record_id: 

243 return None 

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

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

246 

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

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

249 

250 

251class PurgeAction(RowAction): 

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

253 

254 def __init__( 

255 self, 

256 name: str = "purge", 

257 label: str | None = None, 

258 confirm_title: str = "Permanently Delete Record", 

259 confirm_message: str | None = None, 

260 ) -> None: 

261 super().__init__( 

262 name=name, 

263 label=label or "Purge", 

264 icon="trash-2", 

265 color=ActionColor.DANGER, 

266 ) 

267 self._confirm_title = confirm_title 

268 self._confirm_message = confirm_message or ( 

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

270 "This action cannot be undone." 

271 ) 

272 

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

274 record_id = self._get_record_id(record) 

275 if not record_id: 

276 return None 

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

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

279 

280 def _get_htmx_attrs( 

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

282 ) -> dict[str, str]: 

283 attrs: dict[str, str] = { 

284 "hx-delete": url, 

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

286 "hx-swap": "innerHTML", 

287 } 

288 confirmation = self.confirm() 

289 if confirmation and confirmation.message: 

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

291 return attrs 

292 

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

294 return ConfirmationConfig( 

295 title=self._confirm_title, 

296 message=self._confirm_message, 

297 style=ActionColor.DANGER, 

298 ) 

299 

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

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

302 

303 

304class ExportAction(RowAction): 

305 """Export a single record.""" 

306 

307 def __init__( 

308 self, 

309 name: str = "export", 

310 label: str | None = None, 

311 ) -> None: 

312 super().__init__( 

313 name=name, 

314 label=label or "Export", 

315 icon="download", 

316 color=ActionColor.GRAY, 

317 ) 

318 

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

320 return Ok({"message": f"Exported {record}"}) 

321 

322 

323class ExportBulkAction(BulkAction): 

324 """Export multiple selected records.""" 

325 

326 def __init__( 

327 self, 

328 name: str = "export", 

329 label: str | None = None, 

330 ) -> None: 

331 super().__init__( 

332 name=name, 

333 label=label or "Export Selected", 

334 icon="download", 

335 color=ActionColor.GRAY, 

336 ) 

337 

338 async def execute(self, records: list[Any], ctx: ActionContext) -> Result[Any, Any]: 

339 count = len(records) 

340 return Ok({"message": f"Exported {count} record(s)", "exported_count": count}) 

341 

342 

343__all__ = [ 

344 "CloneAction", 

345 "CreateAction", 

346 "DeleteAction", 

347 "DeleteBulkAction", 

348 "EditAction", 

349 "ExportAction", 

350 "ExportBulkAction", 

351 "PurgeAction", 

352 "RestoreAction", 

353 "ViewAction", 

354]