Coverage for src / lexigram / admin / resources / detail_renderer.py: 0%

70 statements  

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

1from __future__ import annotations 

2 

3"""Detail view rendering for admin resources.""" 

4 

5from typing import Any 

6 

7from starlette.responses import HTMLResponse 

8 

9from lexigram.admin.config import AdminConfig 

10from lexigram.admin.engine.renderer import AdminRenderer 

11from lexigram.admin.exceptions import DataError 

12from lexigram.admin.observability.admin_metrics import AdminMetrics, OperationTimer 

13from lexigram.admin.state.context import wants_fragment 

14from lexigram.di.decorators import inject 

15from lexigram.logging import get_logger 

16from lexigram.ui import el, render_to_string 

17 

18logger = get_logger(__name__) 

19 

20 

21@inject 

22class DetailRenderer: 

23 """Handles rendering of detail views for admin resources.""" 

24 

25 def __init__( 

26 self, 

27 config: AdminConfig, 

28 resource_name: str, 

29 renderer: AdminRenderer, 

30 metrics: AdminMetrics | None = None, 

31 ): 

32 self._config = config 

33 self.resource_name = resource_name 

34 self._renderer = renderer 

35 self._metrics = metrics or AdminMetrics(None) 

36 

37 async def render_detail( 

38 self, 

39 request, 

40 resource, 

41 item_id: str, 

42 user=None, 

43 ) -> HTMLResponse: 

44 """Render detail view.""" 

45 timer = OperationTimer() 

46 

47 label = self.resource_name.replace("_", " ").title() 

48 

49 item_html = await self._get_item_html(resource, item_id, label) 

50 

51 content = f""" 

52 <div class="resource-header" style="margin-bottom: 1.5rem;"> 

53 <a href="{self._config.prefix}/{self.resource_name}" style="color: #6366f1;">&larr; Back to {label}</a> 

54 <h1>{label} #{item_id}</h1> 

55 </div> 

56 <div class="resource-content">{item_html}</div> 

57 <div class="resource-actions" style="margin-top: 1rem;"> 

58 <a href="{self._config.prefix}/{self.resource_name}/{item_id}/edit" class="btn btn-secondary">Edit</a> 

59 </div> 

60 """ 

61 

62 is_htmx = wants_fragment(request) 

63 if is_htmx: 

64 self._metrics.record_operation( 

65 "detail", 

66 resource=self.resource_name, 

67 status="success", 

68 duration_seconds=timer.elapsed(), 

69 ) 

70 return HTMLResponse(content) 

71 

72 self._metrics.record_operation( 

73 "detail", 

74 resource=self.resource_name, 

75 status="success", 

76 duration_seconds=timer.elapsed(), 

77 ) 

78 

79 return self._renderer.render_page( 

80 content, 

81 request=request, 

82 title=f"{label} #{item_id}", 

83 breadcrumbs=[ 

84 {"label": "Dashboard", "url": self._config.prefix}, 

85 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"}, 

86 { 

87 "label": f"#{item_id}", 

88 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}", 

89 }, 

90 ], 

91 ) 

92 

93 async def render_inline_edit( 

94 self, 

95 request, 

96 resource, 

97 item_id: str, 

98 user=None, 

99 ) -> HTMLResponse: 

100 """Render detail view with inline editable fields. 

101 

102 Each field is rendered as an Alpine.js-driven inline editor. In 

103 *display* mode the current value is shown with a pencil icon that 

104 appears on hover. Clicking the icon switches to *edit* mode which 

105 shows an ``<input>`` alongside Save and Cancel buttons. Saving 

106 issues an HTMX ``PATCH`` to 

107 ``/{resource_name}/{item_id}/inline?field=<name>`` carrying the new 

108 value. 

109 

110 Args: 

111 request: Incoming HTTP request. 

112 resource: Admin resource instance. 

113 item_id: Primary-key value of the record to display. 

114 user: Authenticated user (optional, for future RBAC use). 

115 

116 Returns: 

117 ``HTMLResponse`` with the inline-edit fragment or full page. 

118 """ 

119 label = self.resource_name.replace("_", " ").title() 

120 item_dict: dict = {} 

121 

122 if resource and hasattr(resource, "service") and resource.service: 

123 try: 

124 if hasattr(resource.service, "get"): 

125 item = await resource.service.get(item_id) 

126 if item: 

127 item_dict = ( 

128 item.model_dump() 

129 if hasattr(item, "model_dump") 

130 else dict(item) 

131 ) 

132 except DataError as exc: 

133 logger.debug( 

134 "inline_edit fetch failed resource=%s id=%s: %s", 

135 self.resource_name, 

136 item_id, 

137 exc, 

138 ) 

139 raise DataError( 

140 message=f"Failed to retrieve {self.resource_name} item {item_id}", 

141 original_error=exc, 

142 ) from None 

143 

144 patch_base = f"{self._config.prefix}/{self.resource_name}/{item_id}/inline" 

145 

146 field_rows: list[Any] = [] 

147 for field_name, field_value in item_dict.items(): 

148 alpine_key = f"editing_{field_name}" 

149 row = el( 

150 "tr", 

151 el( 

152 "td", 

153 el("strong", field_name), 

154 class_="py-2 pr-4 align-top text-sm font-medium text-muted-foreground w-1/4", 

155 ), 

156 el( 

157 "td", 

158 # Display mode 

159 el( 

160 "div", 

161 el( 

162 "span", 

163 str(field_value), 

164 class_="inline-edit-value", 

165 ), 

166 el( 

167 "button", 

168 "✎", 

169 type="button", 

170 title=f"Edit {field_name}", 

171 class_=( 

172 "inline-edit-pencil ml-2 text-primary-500 opacity-0 " 

173 "group-hover:opacity-100 transition-opacity text-xs" 

174 ), 

175 **{"@click": f"{alpine_key} = true"}, 

176 ), 

177 class_="group flex items-center", 

178 **{"x-show": f"!{alpine_key}"}, 

179 ), 

180 # Edit mode 

181 el( 

182 "div", 

183 el( 

184 "input", 

185 type="text", 

186 name=field_name, 

187 value=str(field_value), 

188 class_=( 

189 "inline-edit-input border border-border " 

190 "rounded px-2 py-1 text-sm w-full " 

191 "bg-muted text-foreground " 

192 "focus:outline-none focus:ring-2 focus:ring-primary-500" 

193 ), 

194 **{ 

195 ":name": f"'{field_name}'", 

196 "x-ref": f"input_{field_name}", 

197 }, 

198 ), 

199 el( 

200 "button", 

201 "Save", 

202 type="button", 

203 class_=( 

204 "ml-2 px-2 py-1 text-xs font-medium text-white " 

205 "bg-primary-600 hover:bg-primary-700 rounded " 

206 "focus:outline-none focus:ring-2 focus:ring-primary-500" 

207 ), 

208 **{ 

209 "hx-patch": patch_base, 

210 "hx-include": f"[name='{field_name}']", 

211 "hx-target": "closest tr", 

212 "hx-swap": "outerHTML", 

213 "@click": f"{alpine_key} = false", 

214 }, 

215 ), 

216 el( 

217 "button", 

218 "Cancel", 

219 type="button", 

220 class_=( 

221 "ml-1 px-2 py-1 text-xs font-medium text-muted-foreground " 

222 "dark:text-muted-foreground hover:text-foreground rounded border " 

223 "border-border" 

224 ), 

225 **{"@click": f"{alpine_key} = false"}, 

226 ), 

227 class_="flex items-center gap-1", 

228 **{"x-show": alpine_key}, 

229 ), 

230 class_="py-2 text-sm text-foreground", 

231 ), 

232 **{ 

233 "x-data": f"{{ {alpine_key}: false }}", 

234 "class": "border-t border-border", 

235 }, 

236 ) 

237 field_rows.append(row) 

238 

239 table_html = render_to_string( 

240 el("table", *field_rows, class_="detail-inline-edit-table w-full") 

241 ) 

242 

243 content = f""" 

244 <div class="resource-header" style="margin-bottom: 1.5rem;"> 

245 <a href="{self._config.prefix}/{self.resource_name}" style="color: #6366f1;">&larr; Back to {label}</a> 

246 <h1>{label} #{item_id} <span class="text-muted-foreground text-xs">(inline edit)</span></h1> 

247 </div> 

248 <div class="resource-content">{table_html}</div> 

249 """ 

250 

251 is_htmx = wants_fragment(request) 

252 if is_htmx: 

253 return HTMLResponse(content) 

254 

255 return self._renderer.render_page( 

256 content, 

257 request=request, 

258 title=f"{label} #{item_id} — Inline Edit", 

259 breadcrumbs=[ 

260 {"label": "Dashboard", "url": self._config.prefix}, 

261 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"}, 

262 { 

263 "label": f"#{item_id}", 

264 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}", 

265 }, 

266 { 

267 "label": "Inline Edit", 

268 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}/inline-edit", 

269 }, 

270 ], 

271 ) 

272 

273 async def _get_item_html(self, resource, item_id: str, label: str) -> str: 

274 """Get HTML representation of the item.""" 

275 if resource and hasattr(resource, "service") and resource.service: 

276 try: 

277 if hasattr(resource.service, "get"): 

278 item = await resource.service.get(item_id) 

279 if item: 

280 item_dict = ( 

281 item.model_dump() 

282 if hasattr(item, "model_dump") 

283 else dict(item) 

284 ) 

285 rows = [ 

286 el("tr", el("td", el("strong", k)), el("td", str(v))) 

287 for k, v in item_dict.items() 

288 ] 

289 return render_to_string( 

290 el("table", *rows, class_="detail-table") 

291 ) 

292 return render_to_string(el("p", "Item not found")) 

293 except DataError as e: 

294 logger.debug( 

295 "Failed to get item %s/%s: %s", 

296 self.resource_name, 

297 item_id, 

298 e, 

299 ) 

300 raise DataError( 

301 message=f"Failed to retrieve {self.resource_name} item {item_id}", 

302 original_error=e, 

303 ) from None 

304 

305 return render_to_string(el("p", f"Item #{item_id}")) 

306 

307 

308__all__ = ["DetailRenderer"]