Coverage for src / lexigram / admin / controllers / resource.py: 0%

304 statements  

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

1"""Resource controller for CRUD operations. 

2 

3Provides a base controller class for handling resource CRUD with 

4HTMX support, automatic pagination, filtering, and bulk actions. 

5""" 

6 

7from __future__ import annotations 

8 

9from abc import ABC 

10from dataclasses import dataclass 

11from datetime import UTC, datetime 

12from typing import Any, Generic, TypeVar 

13 

14from starlette.requests import Request 

15from starlette.responses import HTMLResponse, RedirectResponse, Response 

16 

17from lexigram.admin.data.data_source import IDataSource as DataSourceProtocol 

18from lexigram.admin.data.data_source import QueryResult 

19from lexigram.admin.data.query import QuerySpec 

20from lexigram.admin.exceptions import AdminValidationError, NotFoundError 

21from lexigram.admin.state.context import AdminContext, AdminContextManager 

22from lexigram.admin.state.url import URLState 

23from lexigram.admin.ui.organisms.admin_slide_over import ( 

24 render_bulk_delete_confirm, 

25 render_delete_confirm, 

26) 

27from lexigram.di.decorators import inject 

28from lexigram.logging import get_logger 

29from lexigram.ui import el, render_to_string 

30 

31logger = get_logger(__name__) 

32 

33T = TypeVar("T") 

34 

35 

36@dataclass 

37class ResourceMeta: 

38 """Metadata for a resource.""" 

39 

40 name: str 

41 label: str 

42 label_plural: str 

43 icon: str | None = None 

44 

45 # URLs 

46 prefix: str = "" 

47 

48 # Default settings 

49 per_page: int = 20 

50 searchable_fields: list[str] | None = None 

51 default_sort: str = "id" 

52 default_sort_order: str = "desc" 

53 

54 # Features 

55 enable_create: bool = True 

56 enable_edit: bool = True 

57 enable_delete: bool = True 

58 enable_clone: bool = True 

59 enable_bulk_actions: bool = True 

60 enable_export: bool = True 

61 

62 @classmethod 

63 def from_dict(cls, data: dict[str, Any]) -> ResourceMeta: 

64 """Create from dictionary.""" 

65 return cls( 

66 name=data.get("name", ""), 

67 label=data.get("label", ""), 

68 label_plural=data.get("label_plural", ""), 

69 icon=data.get("icon"), 

70 prefix=data.get("prefix", ""), 

71 per_page=data.get("per_page", 20), 

72 searchable_fields=data.get("searchable_fields"), 

73 default_sort=data.get("default_sort", "id"), 

74 default_sort_order=data.get("default_sort_order", "desc"), 

75 enable_create=data.get("enable_create", True), 

76 enable_edit=data.get("enable_edit", True), 

77 enable_delete=data.get("enable_delete", True), 

78 enable_clone=data.get("enable_clone", True), 

79 enable_bulk_actions=data.get("enable_bulk_actions", True), 

80 enable_export=data.get("enable_export", True), 

81 ) 

82 

83 

84@inject 

85class ResourceController(ABC, Generic[T]): 

86 """Base controller for resource CRUD operations. 

87 

88 Provides standard CRUD endpoints with HTMX support: 

89 - GET /{resource} - List with pagination/filtering 

90 - GET /{resource}/{id} - View single resource 

91 - GET /{resource}/create - Create form 

92 - POST /{resource} - Create resource 

93 - GET /{resource}/{id}/edit - Edit form 

94 - PUT /{resource}/{id} - Update resource 

95 - DELETE /{resource}/{id} - Delete resource 

96 - POST /{resource}/bulk - Bulk actions 

97 

98 Subclasses should implement: 

99 - get_data_source() - Return DataSourceProtocol for this resource 

100 - get_columns() - Return columns for list view 

101 - render_list() - Render list view HTML 

102 - render_detail() - Render detail view HTML 

103 - render_form() - Render create/edit form HTML 

104 """ 

105 

106 meta: ResourceMeta 

107 

108 # When True, DELETE calls soft-delete (sets deleted_at) instead of hard-delete. 

109 # Use RepositoryDataSource(soft_delete_enabled=True) to filter them in queries. 

110 soft_delete_enabled: bool = False 

111 

112 def __init__( 

113 self, 

114 data_source: DataSourceProtocol[T] | None = None, 

115 meta: ResourceMeta | None = None, 

116 ): 

117 self._data_source = data_source 

118 if meta: 

119 self.meta = meta 

120 # Optional audit logger — set via set_audit_logger() or DI 

121 self._audit_logger: Any = None 

122 # Optional revision service — set via set_revision_service() or DI 

123 self._revision_service: Any = None 

124 

125 def set_audit_logger(self, audit_logger: Any) -> None: 

126 """Attach an audit logger for CRUD event tracking. 

127 

128 Args: 

129 audit_logger: Any object implementing ``async log(AuditEntry) -> None``. 

130 """ 

131 self._audit_logger = audit_logger 

132 

133 def set_revision_service(self, revision_service: Any) -> None: 

134 """Attach a :class:`~lexigram.admin.services.revisions.RevisionService`. 

135 

136 When set, a snapshot is recorded after every successful create or 

137 update. The service also exposes ``diff`` and ``revert_data`` for the 

138 revision history UI. 

139 

140 Args: 

141 revision_service: ``RevisionService`` instance. 

142 """ 

143 self._revision_service = revision_service 

144 

145 async def _record_revision( 

146 self, 

147 request: Request, 

148 resource_id: str, 

149 data: dict[str, Any], 

150 comment: str = "", 

151 ) -> None: 

152 """Silently create a revision snapshot if a service is attached. 

153 

154 Args: 

155 request: Current HTTP request (actor identity extracted from state). 

156 resource_id: Record identifier. 

157 data: Full field snapshot to persist. 

158 comment: Optional human-readable description. 

159 """ 

160 if self._revision_service is None: 

161 return 

162 try: 

163 user = getattr(request.state, "user", None) 

164 actor_id = str(getattr(user, "id", getattr(user, "user_id", "system"))) 

165 resource_name = getattr(self.meta, "name", "resource") 

166 await self._revision_service.record( 

167 resource_name, 

168 resource_id, 

169 data, 

170 actor_id=actor_id, 

171 comment=comment, 

172 ) 

173 except Exception: # noqa: BLE001 

174 logger.warning( 

175 "Revision recording failed for resource %s/%s", 

176 getattr(self.meta, "name", ""), 

177 resource_id, 

178 ) 

179 

180 async def _emit_audit( 

181 self, 

182 request: Request, 

183 action: str, 

184 item_id: str = "", 

185 old_values: dict[str, Any] | None = None, 

186 new_values: dict[str, Any] | None = None, 

187 outcome: str = "success", 

188 ) -> None: 

189 """Emit an audit log entry if a logger is attached. 

190 

191 Args: 

192 request: Current HTTP request (used for actor identity and metadata). 

193 action: Machine-readable action (e.g. ``"user.create"``). 

194 item_id: Identifier of the affected record. 

195 old_values: Field snapshot before the change. 

196 new_values: Field snapshot after the change. 

197 outcome: ``"success"`` or ``"failure"``. 

198 """ 

199 if self._audit_logger is None: 

200 return 

201 try: 

202 from lexigram.contracts.audit import AuditEntry 

203 

204 user = getattr(request.state, "user", None) 

205 actor_id = str(getattr(user, "id", getattr(user, "user_id", "anonymous"))) 

206 resource_name = getattr(self.meta, "name", "unknown") 

207 entry = AuditEntry( 

208 action=action, 

209 actor_id=actor_id, 

210 resource_type=resource_name, 

211 resource_id=str(item_id), 

212 outcome=outcome, 

213 old_values=old_values, 

214 new_values=new_values, 

215 metadata={ 

216 "ip": request.client.host if request.client else "", 

217 "user_agent": request.headers.get("user-agent", ""), 

218 "path": str(request.url), 

219 }, 

220 ) 

221 await self._audit_logger.log(entry) 

222 except Exception: # noqa: BLE001 

223 logger.warning( 

224 "Audit logging failed for action %s on %s/%s", 

225 action, 

226 getattr(self.meta, "name", ""), 

227 item_id, 

228 ) 

229 

230 def get_data_source(self) -> DataSourceProtocol[T]: 

231 """Get the data source for this resource.""" 

232 if self._data_source is None: 

233 raise NotImplementedError("Subclass must implement get_data_source()") 

234 return self._data_source 

235 

236 # ========== List ========== 

237 

238 async def list_view(self, request: Request) -> Response: 

239 """List resources with pagination and filtering.""" 

240 async with AdminContextManager(request) as ctx: 

241 # Parse URL state 

242 url_state = URLState.from_request(request) 

243 

244 # Build query 

245 query = self._build_query(url_state) 

246 

247 # Fetch data 

248 data_source = self.get_data_source() 

249 result = await data_source.find_many(query) 

250 

251 # Check if HTMX request 

252 if ctx.is_htmx: 

253 # Return just the table/content 

254 return HTMLResponse(self.render_list_partial(ctx, result, url_state)) 

255 

256 # Return full page 

257 return HTMLResponse(self.render_list(ctx, result, url_state)) 

258 

259 def _build_query(self, state: URLState) -> QuerySpec: 

260 """Build QuerySpec from URL state.""" 

261 qs = QuerySpec() 

262 

263 # Pagination — cursor takes priority over page number 

264 if state.cursor: 

265 qs = qs.with_cursor(state.cursor).with_per_page( 

266 state.per_page or self.meta.per_page 

267 ) 

268 else: 

269 qs = qs.with_page(state.page).with_per_page( 

270 state.per_page or self.meta.per_page 

271 ) 

272 

273 # Sorting 

274 if state.sort: 

275 qs = qs.with_order_by(state.sort, state.order) 

276 else: 

277 qs = qs.with_order_by(self.meta.default_sort, self.meta.default_sort_order) 

278 

279 # Search 

280 if state.search: 

281 qs = qs.with_search(state.search, self.meta.searchable_fields or []) 

282 

283 # Filters 

284 for field, value in state.filters.items(): 

285 if isinstance(value, list): 

286 qs = qs.with_where_in(field, value) 

287 else: 

288 qs = qs.with_where_eq(field, value) 

289 

290 return qs 

291 

292 # ========== Detail ========== 

293 

294 async def detail(self, request: Request) -> Response: 

295 """View single resource.""" 

296 async with AdminContextManager(request) as ctx: 

297 item_id = request.path_params.get("id") 

298 

299 data_source = self.get_data_source() 

300 item = await data_source.find_one(item_id) 

301 

302 if item is None: 

303 from lexigram.admin.lib.template import render_error_page 

304 

305 html = render_error_page( 

306 status_code=404, 

307 title="Not Found", 

308 message=f"{self.meta.label} not found", 

309 ) 

310 return HTMLResponse(html, status_code=404) 

311 

312 if ctx.is_htmx: 

313 return HTMLResponse(self.render_detail_partial(ctx, item)) 

314 

315 return HTMLResponse(self.render_detail(ctx, item)) 

316 

317 # ========== Create ========== 

318 

319 async def create_form(self, request: Request) -> Response: 

320 """Show create form.""" 

321 async with AdminContextManager(request) as ctx: 

322 if ctx.is_htmx: 

323 return HTMLResponse(self.render_form_partial(ctx, None)) 

324 return HTMLResponse(self.render_form(ctx, None)) 

325 

326 async def create(self, request: Request) -> Response: 

327 """Create new resource.""" 

328 async with AdminContextManager(request) as ctx: 

329 form_data = request.scope.get("admin_form_data") 

330 if form_data is None: 

331 form_data = await request.form() 

332 data = dict(form_data) 

333 

334 # Validate 

335 try: 

336 validated = self.validate_create(data) 

337 except AdminValidationError as e: 

338 # Return form with errors 

339 if ctx.is_htmx: 

340 return HTMLResponse( 

341 self.render_form_partial(ctx, None, data, e.details or {}), 

342 status_code=422, 

343 ) 

344 return HTMLResponse( 

345 self.render_form(ctx, None, data, e.details or {}), 

346 status_code=422, 

347 ) 

348 

349 # Create 

350 data_source = self.get_data_source() 

351 created = await data_source.create(validated) 

352 created_id = str(getattr(created, "id", "") if created else "") 

353 await self._emit_audit( 

354 request, 

355 f"{getattr(self.meta, 'name', 'resource')}.create", 

356 item_id=created_id, 

357 new_values=validated, 

358 ) 

359 await self._record_revision( 

360 request, created_id, validated, comment="create" 

361 ) 

362 

363 # Redirect or return success 

364 ctx.add_flash("Created successfully", "success") 

365 if ctx.is_htmx: 

366 response = Response(status_code=200) 

367 response.headers["HX-Redirect"] = f"{self.meta.prefix}/{self.meta.name}" 

368 return response 

369 

370 return RedirectResponse( 

371 url=f"{self.meta.prefix}/{self.meta.name}", 

372 status_code=302, 

373 ) 

374 

375 def validate_create(self, data: dict[str, Any]) -> dict[str, Any]: 

376 """Validate data for create. Override to customize.""" 

377 return data 

378 

379 # ========== Update ========== 

380 

381 async def edit_form(self, request: Request) -> Response: 

382 """Show edit form.""" 

383 async with AdminContextManager(request) as ctx: 

384 item_id = request.path_params.get("id") 

385 

386 data_source = self.get_data_source() 

387 item = await data_source.find_one(item_id) 

388 

389 if item is None: 

390 raise NotFoundError(message=f"{self.meta.label} not found") 

391 

392 if ctx.is_htmx: 

393 return HTMLResponse(self.render_form_partial(ctx, item)) 

394 return HTMLResponse(self.render_form(ctx, item)) 

395 

396 async def update(self, request: Request) -> Response: 

397 """Update existing resource.""" 

398 async with AdminContextManager(request) as ctx: 

399 item_id = request.path_params.get("id") 

400 form_data = request.scope.get("admin_form_data") 

401 if form_data is None: 

402 form_data = await request.form() 

403 data = dict(form_data) 

404 

405 # Validate 

406 try: 

407 validated = self.validate_update(item_id, data) 

408 except AdminValidationError as e: 

409 # Get current item for form 

410 item = await self.get_data_source().find_one(item_id) 

411 if ctx.is_htmx: 

412 return HTMLResponse( 

413 self.render_form_partial(ctx, item, data, e.details or {}), 

414 status_code=422, 

415 ) 

416 return HTMLResponse( 

417 self.render_form(ctx, item, data, e.details or {}), 

418 status_code=422, 

419 ) 

420 

421 # Update 

422 data_source = self.get_data_source() 

423 item = await data_source.update(item_id, validated) 

424 await self._emit_audit( 

425 request, 

426 f"{getattr(self.meta, 'name', 'resource')}.update", 

427 item_id=str(item_id), 

428 new_values=validated, 

429 ) 

430 await self._record_revision( 

431 request, str(item_id), validated, comment="update" 

432 ) 

433 

434 ctx.add_flash("Updated successfully", "success") 

435 if ctx.is_htmx: 

436 response = Response(status_code=200) 

437 response.headers["HX-Redirect"] = ( 

438 f"{self.meta.prefix}/{self.meta.name}/{item_id}" 

439 ) 

440 return response 

441 

442 return RedirectResponse( 

443 url=f"{self.meta.prefix}/{self.meta.name}/{item_id}", 

444 status_code=302, 

445 ) 

446 

447 def validate_update(self, item_id: Any, data: dict[str, Any]) -> dict[str, Any]: 

448 """Validate data for update. Override to customize.""" 

449 return data 

450 

451 # ========== Delete ========== 

452 

453 async def delete_confirm(self, request: Request) -> Response: 

454 """Render a delete confirmation slide-over panel. 

455 

456 Called via HTMX GET from the Delete row action. Returns an 

457 AdminSlideOver fragment that lets the user confirm or cancel 

458 the deletion without using the native browser confirm dialog. 

459 """ 

460 item_id = request.path_params.get("id") 

461 label = self.meta.label 

462 

463 # Attempt to fetch a human-readable label for the record 

464 record_label = f"{label} #{item_id}" 

465 try: 

466 data_source = self.get_data_source() 

467 item = await data_source.find_one(item_id) 

468 if item: 

469 for field in ("name", "title", "email", "username", "label"): 

470 val = getattr(item, field, None) 

471 if val: 

472 record_label = str(val) 

473 break 

474 except Exception: # noqa: BLE001 

475 pass 

476 

477 delete_url = f"{self.meta.prefix}/{self.meta.name}/{item_id}" 

478 html = render_delete_confirm( 

479 record_label=record_label, 

480 delete_url=delete_url, 

481 ) 

482 return HTMLResponse(html) 

483 

484 async def bulk_delete_confirm(self, request: Request) -> Response: 

485 """Render a bulk delete confirmation slide-over panel. 

486 

487 Called via HTMX GET from a BulkAction button. Reads the selected 

488 record IDs from the query string (passed via ``hx-include`` of the 

489 checked checkboxes) and renders a slide-over confirmation panel. 

490 """ 

491 ids = request.query_params.getlist("ids") 

492 record_count = len(ids) 

493 

494 bulk_url = f"{self.meta.prefix}/{self.meta.name}/bulk" 

495 html = render_bulk_delete_confirm( 

496 record_count=record_count, 

497 bulk_url=bulk_url, 

498 ) 

499 return HTMLResponse(html) 

500 

501 async def delete(self, request: Request) -> Response: 

502 """Delete resource (soft or hard depending on soft_delete_enabled).""" 

503 async with AdminContextManager(request) as ctx: 

504 item_id = request.path_params.get("id") 

505 data_source = self.get_data_source() 

506 

507 if self.soft_delete_enabled: 

508 # Soft delete — stamp deleted_at instead of removing the row 

509 updated = await data_source.update( 

510 item_id, {"deleted_at": datetime.now(UTC).isoformat()} 

511 ) 

512 if updated is None: 

513 raise NotFoundError(message=f"{self.meta.label} not found") 

514 await self._emit_audit( 

515 request, 

516 f"{getattr(self.meta, 'name', 'resource')}.soft_delete", 

517 item_id=str(item_id), 

518 ) 

519 else: 

520 success = await data_source.delete(item_id) 

521 if not success: 

522 raise NotFoundError(message=f"{self.meta.label} not found") 

523 await self._emit_audit( 

524 request, 

525 f"{getattr(self.meta, 'name', 'resource')}.delete", 

526 item_id=str(item_id), 

527 ) 

528 

529 ctx.add_flash("Deleted successfully", "success") 

530 if ctx.is_htmx: 

531 response = Response(status_code=200) 

532 response.headers["HX-Redirect"] = f"{self.meta.prefix}/{self.meta.name}" 

533 return response 

534 

535 return RedirectResponse( 

536 url=f"{self.meta.prefix}/{self.meta.name}", 

537 status_code=302, 

538 ) 

539 

540 async def restore(self, request: Request) -> Response: 

541 """Restore a soft-deleted resource (clears deleted_at). 

542 

543 Only available when soft_delete_enabled is True. 

544 """ 

545 async with AdminContextManager(request) as ctx: 

546 if not self.soft_delete_enabled: 

547 return HTMLResponse( 

548 "Soft delete is not enabled for this resource", status_code=400 

549 ) 

550 

551 item_id = request.path_params.get("id") 

552 data_source = self.get_data_source() 

553 

554 updated = await data_source.update(item_id, {"deleted_at": None}) 

555 if updated is None: 

556 raise NotFoundError(message=f"{self.meta.label} not found") 

557 await self._emit_audit( 

558 request, 

559 f"{getattr(self.meta, 'name', 'resource')}.restore", 

560 item_id=str(item_id), 

561 ) 

562 

563 ctx.add_flash("Restored successfully", "success") 

564 if ctx.is_htmx: 

565 response = Response(status_code=200) 

566 response.headers["HX-Trigger"] = ( 

567 '{"refresh-list":true,"show-toast":{"message":"Restored successfully","type":"success"}}' 

568 ) 

569 return response 

570 

571 return RedirectResponse( 

572 url=f"{self.meta.prefix}/{self.meta.name}", 

573 status_code=302, 

574 ) 

575 

576 # ========== Bulk Actions ========== 

577 

578 async def bulk_action(self, request: Request) -> Response: 

579 """Handle bulk actions.""" 

580 async with AdminContextManager(request) as ctx: 

581 form_data = request.scope.get("admin_form_data") 

582 if form_data is None: 

583 form_data = await request.form() 

584 action = form_data.get("action") 

585 ids = form_data.getlist("ids") 

586 

587 if not action or not ids: 

588 return HTMLResponse("Missing action or ids", status_code=400) 

589 

590 result = await self.execute_bulk_action(action, ids) # type: ignore[arg-type] 

591 

592 ctx.add_flash(result, "success") 

593 if ctx.is_htmx: 

594 response = HTMLResponse(render_to_string(el("p", str(result)))) 

595 response.headers["HX-Trigger"] = ( 

596 '{"refresh-list":true,"show-toast":{"message":"' 

597 + result.replace('"', '\\"') 

598 + '","type":"success"}}' 

599 ) 

600 return response 

601 

602 return RedirectResponse( 

603 url=f"{self.meta.prefix}/{self.meta.name}", 

604 status_code=302, 

605 ) 

606 

607 async def execute_bulk_action(self, action: str, ids: list[str]) -> str: 

608 """Execute bulk action. Override to add custom actions. 

609 

610 When the record count meets or exceeds the configured 

611 ``bulk_threshold`` (from ``TasksIntegrationConfig``), the action is 

612 dispatched through the tasks integration instead of running inline. 

613 """ 

614 if self._should_dispatch_via_tasks(len(ids)): 

615 return await self._dispatch_via_tasks(action, ids) 

616 

617 data_source = self.get_data_source() 

618 

619 if action == "delete": 

620 count = await data_source.bulk_delete(ids) 

621 return f"Deleted {count} items" 

622 

623 return f"Unknown action: {action}" 

624 

625 def _should_dispatch_via_tasks(self, count: int) -> bool: 

626 """Check if the bulk count exceeds the tasks threshold.""" 

627 from lexigram.admin.integrations import get as get_integration 

628 

629 tasks = get_integration("TasksIntegration") 

630 if not tasks: 

631 return False 

632 if not tasks._enabled: 

633 return False 

634 return count >= tasks.threshold 

635 

636 async def _dispatch_via_tasks(self, action: str, ids: list[str]) -> str: 

637 """Dispatch a bulk action through the tasks integration.""" 

638 from lexigram.admin.integrations import get as get_integration 

639 

640 tasks = get_integration("TasksIntegration") 

641 if not tasks: 

642 return "Task system unavailable" 

643 

644 result = await tasks.dispatch( 

645 runner=action, 

646 action_name=action, 

647 record_ids=ids, 

648 ctx_summary=f"Bulk {action} of {len(ids)} records", 

649 ) 

650 return f"Scheduled bulk {action} for {len(ids)} records (task: {result.get('status', 'unknown')})" 

651 

652 # ========== Rendering (Override these) ========== 

653 

654 def render_list( 

655 self, 

656 ctx: AdminContext, 

657 result: QueryResult[T], 

658 state: URLState, 

659 ) -> str: 

660 """Render full list page. Override in subclass.""" 

661 return f""" 

662<!DOCTYPE html> 

663<html> 

664<head> 

665 <title>{self.meta.label_plural}</title> 

666 <script src="https://unpkg.com/htmx.org@1.9.10"></script> 

667</head> 

668<body> 

669 <h1>{self.meta.label_plural}</h1> 

670 {self.render_list_partial(ctx, result, state)} 

671</body> 

672</html> 

673""" 

674 

675 def render_list_partial( 

676 self, 

677 ctx: AdminContext, 

678 result: QueryResult[T], 

679 state: URLState, 

680 ) -> str: 

681 """Render list content (for HTMX). Override in subclass.""" 

682 row_els: list[Any] = [] 

683 for item in result.items: 

684 row_els.append(el("tr", el("td", str(item)))) 

685 

686 total_pages = getattr(result, "total_pages", "?") 

687 return f""" 

688<table> 

689 <thead><tr><th>Item</th></tr></thead> 

690 {render_to_string(el("tbody", *row_els))} 

691</table> 

692<div>Page {result.page} of {total_pages}</div> 

693""" 

694 

695 def render_detail(self, ctx: AdminContext, item: T) -> str: 

696 """Render full detail page. Override in subclass.""" 

697 return f""" 

698<!DOCTYPE html> 

699<html> 

700<head><title>{self.meta.label}</title></head> 

701<body> 

702 <h1>{self.meta.label}</h1> 

703 {self.render_detail_partial(ctx, item)} 

704</body> 

705</html> 

706""" 

707 

708 def render_detail_partial(self, ctx: AdminContext, item: T) -> str: 

709 """Render detail content. Override in subclass.""" 

710 return render_to_string(el("pre", str(item))) 

711 

712 def render_form( 

713 self, 

714 ctx: AdminContext, 

715 item: T | None, 

716 data: dict[str, Any] | None = None, 

717 errors: dict[str, list[str]] | None = None, 

718 ) -> str: 

719 """Render full form page. Override in subclass.""" 

720 title = f"Edit {self.meta.label}" if item else f"Create {self.meta.label}" 

721 return f""" 

722<!DOCTYPE html> 

723<html> 

724<head><title>{title}</title></head> 

725<body> 

726 <h1>{title}</h1> 

727 {self.render_form_partial(ctx, item, data, errors)} 

728</body> 

729</html> 

730""" 

731 

732 def render_form_partial( 

733 self, 

734 ctx: AdminContext, 

735 item: T | None, 

736 data: dict[str, Any] | None = None, 

737 errors: dict[str, list[str]] | None = None, 

738 ) -> str: 

739 """Render form content. Override in subclass.""" 

740 action = f"{self.meta.prefix}/{self.meta.name}" 

741 if item: 

742 id_val = getattr(item, "id", None) 

743 action = f"{action}/{id_val}" 

744 

745 return f""" 

746<form method="POST" action="{action}"> 

747 <p>Override render_form_partial() to customize</p> 

748 <button type="submit">Save</button> 

749</form> 

750""" 

751 

752 # ========== Route Registration ========== 

753 

754 def get_routes(self) -> list: 

755 """Get Starlette routes for this controller.""" 

756 from starlette.routing import Route 

757 

758 prefix = f"/{self.meta.name}" 

759 

760 return [ 

761 Route(prefix, self.list_view, methods=["GET"]), 

762 Route(f"{prefix}/create", self.create_form, methods=["GET"]), 

763 Route(prefix, self.create, methods=["POST"]), 

764 Route(f"{prefix}/bulk", self.bulk_action, methods=["POST"]), 

765 Route( 

766 f"{prefix}/bulk-delete-confirm", 

767 self.bulk_delete_confirm, 

768 methods=["GET"], 

769 ), 

770 Route(f"{prefix}/{{id}}", self.detail, methods=["GET"]), 

771 Route(f"{prefix}/{{id}}/edit", self.edit_form, methods=["GET"]), 

772 Route( 

773 f"{prefix}/{{id}}/delete-confirm", self.delete_confirm, methods=["GET"] 

774 ), 

775 Route(f"{prefix}/{{id}}", self.update, methods=["PUT", "POST"]), 

776 Route(f"{prefix}/{{id}}", self.delete, methods=["DELETE"]), 

777 ]