Coverage for src / lexigram / admin / lib / template.py: 15%

52 statements  

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

1"""Template utilities for lexigram-admin. 

2 

3Provides simple template rendering functions for standalone pages 

4like login, error, etc. Uses StandaloneLayout for consistent styling. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import Any 

10 

11from markupsafe import escape 

12 

13from lexigram.admin.ui.layouts import ( 

14 StandaloneLayout, 

15 StandaloneLayoutConfig, 

16 StandaloneLayoutContext, 

17) 

18 

19 

20def render_login_page( 

21 next_url: str = "/admin/", 

22 error: str = "", 

23 site_name: str = "Lexigram Admin", 

24 csrf_token: str = "", 

25) -> str: 

26 """Render a standalone login page. 

27 

28 Args: 

29 next_url: URL to redirect to after login. 

30 error: Error message to display. 

31 site_name: Site name for branding. 

32 csrf_token: CSRF token to embed as a hidden form field. 

33 

34 Returns: 

35 HTML string for login page. 

36 """ 

37 flash_messages: list[tuple[str, str]] = [] 

38 if error: 

39 flash_messages.append(("error", error)) 

40 

41 config = StandaloneLayoutConfig( 

42 app_name=site_name, 

43 show_footer=True, 

44 centered=True, 

45 ) 

46 context = StandaloneLayoutContext( 

47 page_title="Login", 

48 flash_messages=flash_messages, 

49 ) 

50 

51 content = f""" 

52 <div class="w-full max-w-md bg-card border border-border rounded-lg shadow-lg p-8"> 

53 <div class="text-center mb-6"> 

54 <h1 class="text-2xl font-bold text-foreground mb-2">Sign In</h1> 

55 <p class="text-sm text-muted-foreground">Please sign in to continue</p> 

56 </div> 

57 <form method="post" action="/admin/login"> 

58 <input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> 

59 <input type="hidden" name="next" value="{escape(next_url)}"> 

60 <div class="mb-4"> 

61 <label for="email" class="block text-sm font-medium text-foreground mb-2">Email</label> 

62 <input type="email" id="email" name="email" placeholder="your@email.com" required 

63 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"> 

64 </div> 

65 <div class="mb-4"> 

66 <label for="password" class="block text-sm font-medium text-foreground mb-2">Password</label> 

67 <input type="password" id="password" name="password" placeholder="Password" required 

68 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"> 

69 </div> 

70 <button type="submit" 

71 class="w-full py-2.5 rounded-md bg-primary text-primary-foreground font-medium cursor-pointer hover:bg-primary/90 transition-colors focus:outline-none focus:ring-2 focus:ring-ring">Sign In</button> 

72 </form> 

73 </div> 

74 """ 

75 

76 layout = StandaloneLayout(config=config, context=context) 

77 return layout.render(content) 

78 

79 

80def render_setup_page( 

81 error: str = "", 

82 site_name: str = "Lexigram Admin", 

83 locked: bool = False, 

84 csrf_token: str = "", 

85) -> str: 

86 """Render the first-run admin setup page. 

87 

88 Args: 

89 error: Optional error message to display. 

90 site_name: Site name for branding. 

91 locked: When True, shows a locked state (setup already complete). 

92 csrf_token: CSRF token to embed as a hidden form field (only used when not locked). 

93 

94 Returns: 

95 HTML string for the setup page. 

96 """ 

97 flash_messages: list[tuple[str, str]] = [] 

98 if error: 

99 category = "warning" if locked else "error" 

100 flash_messages.append((category, error)) 

101 

102 config = StandaloneLayoutConfig( 

103 app_name=site_name, 

104 show_footer=True, 

105 centered=True, 

106 ) 

107 context = StandaloneLayoutContext( 

108 page_title="Initial Setup" if not locked else "Setup Complete", 

109 flash_messages=flash_messages, 

110 ) 

111 

112 if locked: 

113 content = """ 

114 <div class="w-full max-w-md bg-card border border-border rounded-lg shadow-lg p-8 text-center"> 

115 <h1 class="text-2xl font-bold text-foreground mb-2">✅ Setup Complete</h1> 

116 <p class="text-sm text-muted-foreground mb-4">An administrator account already exists.<br> 

117 Please log in with your credentials.</p> 

118 <a href="/admin/login" 

119 class="block w-full py-2.5 rounded-md bg-primary text-primary-foreground font-medium text-center hover:bg-primary/90 transition-colors">Go to Login</a> 

120 </div> 

121 """ 

122 else: 

123 content = f""" 

124 <div class="w-full max-w-md bg-card border border-border rounded-lg shadow-lg p-8"> 

125 <div class="text-center mb-6"> 

126 <h1 class="text-2xl font-bold text-foreground mb-2">Welcome to {escape(site_name)}</h1> 

127 <p class="text-sm text-muted-foreground">Create your administrator account to get started.</p> 

128 </div> 

129 <form method="post" action="/admin/setup"> 

130 <input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> 

131 <div class="mb-4"> 

132 <label for="name" class="block text-sm font-medium text-foreground mb-2">Full Name</label> 

133 <input type="text" id="name" name="name" placeholder="Your Name" required 

134 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"> 

135 </div> 

136 <div class="mb-4"> 

137 <label for="email" class="block text-sm font-medium text-foreground mb-2">Email Address</label> 

138 <input type="email" id="email" name="email" 

139 placeholder="admin@example.com" required 

140 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"> 

141 </div> 

142 <div class="mb-4"> 

143 <label for="password" class="block text-sm font-medium text-foreground mb-2">Password</label> 

144 <input type="password" id="password" name="password" 

145 placeholder="At least 8 characters" required minlength="8" 

146 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"> 

147 <p class="text-xs text-muted-foreground mt-1">Minimum 8 characters.</p> 

148 </div> 

149 <div class="mb-4"> 

150 <label for="confirm_password" class="block text-sm font-medium text-foreground mb-2">Confirm Password</label> 

151 <input type="password" id="confirm_password" name="confirm_password" 

152 placeholder="Repeat password" required minlength="8" 

153 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"> 

154 </div> 

155 <button type="submit" 

156 class="w-full py-2.5 rounded-md bg-primary text-primary-foreground font-medium cursor-pointer hover:bg-primary/90 transition-colors focus:outline-none focus:ring-2 focus:ring-ring">Create Account</button> 

157 </form> 

158 </div> 

159 """ 

160 

161 layout = StandaloneLayout(config=config, context=context) 

162 return layout.render(content) 

163 

164 

165def render_error_page( 

166 status_code: int = 500, 

167 title: str = "Error", 

168 message: str = "An error occurred", 

169 details: str = "", 

170 site_name: str = "Lexigram Admin", 

171 icon: str = "", 

172 action_text: str = "Return to Admin", 

173 action_url: str = "/admin/", 

174) -> str: 

175 """Render a standalone error page. 

176 

177 Args: 

178 status_code: HTTP status code 

179 title: Error title 

180 message: Error message 

181 details: Additional details (hidden in production) 

182 site_name: Site name for branding 

183 icon: Emoji icon to display 

184 action_text: Call-to-action button text 

185 action_url: Call-to-action button URL 

186 

187 Returns: 

188 HTML string for error page 

189 """ 

190 config = StandaloneLayoutConfig( 

191 app_name=site_name, 

192 show_footer=True, 

193 centered=True, 

194 ) 

195 context = StandaloneLayoutContext( 

196 page_title=title, 

197 ) 

198 

199 details_html = "" 

200 if details: 

201 details_html = f""" 

202 <details class="text-left mt-4"> 

203 <summary class="cursor-pointer font-medium text-sm text-muted-foreground">Technical Details</summary> 

204 <pre class="bg-muted text-foreground p-4 rounded-md overflow-x-auto mt-2">{escape(details)}</pre> 

205 </details> 

206 """ 

207 

208 icon_html = ( 

209 f'<div style="font-size: 3rem; margin-bottom: 0.5rem;">{icon}</div>' 

210 if icon 

211 else "" 

212 ) 

213 

214 content = f""" 

215 <div class="w-full max-w-2xl bg-card border border-border rounded-lg shadow-lg p-8 text-center"> 

216 {icon_html} 

217 <div class="text-6xl font-bold text-muted-foreground mb-2">{status_code}</div> 

218 <h1 class="text-2xl font-bold text-foreground mb-2">{escape(title)}</h1> 

219 <p class="text-sm text-muted-foreground">{escape(message)}</p> 

220 {details_html} 

221 <a href="{escape(action_url)}" 

222 class="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-medium mt-4 hover:bg-primary/90 transition-colors">{escape(action_text)}</a> 

223 </div> 

224 """ 

225 

226 layout = StandaloneLayout(config=config, context=context) 

227 return layout.render(content) 

228 

229 

230def render_template( 

231 template_name: str, 

232 context: dict[str, Any] | None = None, 

233 **kwargs: Any, 

234) -> str: 

235 """Render a named template. 

236 

237 This is a simple fallback template renderer. For full template support, 

238 use the Jinja2 integration in ui/templates_jinja.py. 

239 

240 Args: 

241 template_name: Template name (used as title) 

242 context: Template context 

243 **kwargs: Additional context values 

244 

245 Returns: 

246 HTML string 

247 """ 

248 ctx = context or {} 

249 ctx.update(kwargs) 

250 

251 title = ctx.get("title", template_name) 

252 content = ctx.get("content", "") 

253 

254 # Include debug info when error template is requested 

255 if template_name == "debug_error.html": 

256 exc_type = ctx.get("exc_type", "") 

257 exc_message = ctx.get("exc_message", "") 

258 traceback = ctx.get("traceback", "") 

259 traceback_plain = ctx.get("traceback_plain", "") 

260 debug_html = f""" 

261 <div class="error-details" style="margin-top: 1rem; padding: 1rem; background: #fef2f2; border: 1px solid #fecaca; border-radius: 4px;"> 

262 <h2 style="color: #b91c1c;">{escape(str(exc_type))}</h2> 

263 <pre style="white-space: pre-wrap; word-break: break-word; font-family: monospace; font-size: 0.85rem; margin-top: 0.5rem;">{escape(str(exc_message))}</pre> 

264 <details style="margin-top: 0.5rem;"> 

265 <summary style="cursor: pointer; font-weight: 500;">Traceback</summary> 

266 <pre style="white-space: pre-wrap; word-break: break-word; font-family: monospace; font-size: 0.75rem; max-height: 400px; overflow: auto; margin-top: 0.25rem; background: #1f2937; color: #e5e7eb; padding: 0.75rem; border-radius: 4px;">{escape(str(traceback_plain))}</pre> 

267 </details> 

268 </div> 

269 """ 

270 content = debug_html 

271 

272 config = StandaloneLayoutConfig( 

273 show_footer=False, 

274 show_logo=False, 

275 centered=False, 

276 ) 

277 layout_context = StandaloneLayoutContext( 

278 page_title=title, 

279 ) 

280 

281 page_content = f""" 

282 <main class="container" style="padding: 2rem;"> 

283 <h1>{escape(title)}</h1> 

284 {content} 

285 </main> 

286 """ 

287 

288 layout = StandaloneLayout(config=config, context=layout_context) 

289 return layout.render(page_content)