Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / common.py: 100%

124 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-17 14:42 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2026 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Common Views 

25""" 

26 

27import logging 

28 

29import colander 

30 

31from wuttaweb.views import View 

32from wuttaweb.forms import widgets 

33from wuttaweb.db import Session 

34from wuttaweb.util import set_app_theme 

35 

36 

37log = logging.getLogger(__name__) 

38 

39 

40class CommonView(View): 

41 """ 

42 Common views shared by all apps. 

43 """ 

44 

45 def home(self, session=None): 

46 """ 

47 Home page view. 

48 

49 Template: ``/home.mako`` 

50 

51 This is normally the view shown when a user navigates to the 

52 root URL for the web app. 

53 """ 

54 # pylint: disable=duplicate-code 

55 model = self.app.model 

56 session = session or Session() 

57 

58 # nb. redirect to /setup if no users exist 

59 user = session.query(model.User).first() 

60 if not user: 

61 return self.redirect(self.request.route_url("setup")) 

62 # pylint: enable=duplicate-code 

63 

64 # maybe auto-redirect anons to login 

65 if not self.request.user: 

66 if self.config.get_bool("wuttaweb.home_redirect_to_login"): 

67 return self.redirect(self.request.route_url("login")) 

68 

69 return { 

70 "index_title": self.app.get_title(), 

71 } 

72 

73 def forbidden_view(self): 

74 """ 

75 This view is shown when a request triggers a 403 Forbidden error. 

76 

77 Template: ``/forbidden.mako`` 

78 """ 

79 return {"index_title": self.app.get_title()} 

80 

81 def notfound_view(self): 

82 """ 

83 This view is shown when a request triggers a 404 Not Found error. 

84 

85 Template: ``/notfound.mako`` 

86 """ 

87 return {"index_title": self.app.get_title()} 

88 

89 def feedback(self): # pylint: disable=empty-docstring 

90 """ """ 

91 model = self.app.model 

92 session = Session() 

93 

94 # validate form 

95 schema = self.feedback_make_schema() 

96 form = self.make_form(schema=schema) 

97 if not form.validate(): 

98 # TODO: native Form class should better expose error(s) 

99 dform = form.get_deform() 

100 return {"error": str(dform.error)} 

101 

102 # build email template context 

103 context = dict(form.validated) 

104 if context["user_uuid"]: 

105 context["user"] = session.get(model.User, context["user_uuid"]) 

106 context["user_url"] = self.request.route_url( 

107 "users.view", uuid=context["user_uuid"] 

108 ) 

109 context["client_ip"] = self.request.client_addr 

110 

111 # send email 

112 try: 

113 self.feedback_send(context) 

114 except Exception as error: # pylint: disable=broad-exception-caught 

115 log.warning("failed to send feedback email", exc_info=True) 

116 return {"error": str(error) or error.__class__.__name__} 

117 

118 return {"ok": True} 

119 

120 def feedback_make_schema(self): # pylint: disable=empty-docstring 

121 """ """ 

122 schema = colander.Schema() 

123 

124 schema.add(colander.SchemaNode(colander.String(), name="referrer")) 

125 

126 schema.add( 

127 colander.SchemaNode(colander.String(), name="user_uuid", missing=None) 

128 ) 

129 

130 schema.add(colander.SchemaNode(colander.String(), name="user_name")) 

131 

132 schema.add(colander.SchemaNode(colander.String(), name="message")) 

133 

134 return schema 

135 

136 def feedback_send(self, context): # pylint: disable=empty-docstring 

137 """ """ 

138 self.app.send_email("feedback", context) 

139 

140 def setup(self, session=None): # pylint: disable=too-many-locals 

141 """ 

142 View for first-time app setup, to create admin user. 

143 

144 Template: ``/setup.mako`` 

145 

146 This page is only meant for one-time use. As such, if the app 

147 DB contains any users, this page will always redirect to the 

148 home page. 

149 

150 However if no users exist yet, this will show a form which may 

151 be used to create the first admin user. When finished, user 

152 will be redirected to the login page. 

153 

154 .. note:: 

155 

156 As long as there are no users in the DB, both the home and 

157 login pages will automatically redirect to this one. 

158 """ 

159 model = self.app.model 

160 session = session or Session() 

161 

162 # nb. this view only available until first user is created 

163 user = session.query(model.User).first() 

164 if user: 

165 return self.redirect(self.request.route_url("home")) 

166 

167 form = self.make_form( 

168 fields=["username", "password", "first_name", "last_name"], 

169 show_button_cancel=False, 

170 show_button_reset=True, 

171 ) 

172 form.set_widget("password", widgets.CheckedPasswordWidget()) 

173 form.set_required("first_name", False) 

174 form.set_required("last_name", False) 

175 

176 if form.validate(): 

177 auth = self.app.get_auth_handler() 

178 data = form.validated 

179 

180 # make user 

181 user = auth.make_user(session=session, username=data["username"]) 

182 auth.set_user_password(user, data["password"]) 

183 

184 # assign admin role 

185 admin = auth.get_role_administrator(session) 

186 user.roles.append(admin) 

187 admin.notes = ( 

188 'users in this role may "become root".\n\n' 

189 "it's recommended not to grant other perms to this role." 

190 ) 

191 

192 # initialize built-in roles 

193 authed = auth.get_role_authenticated(session) 

194 authed.notes = ( 

195 "this role represents any user who *is* logged in.\n\n" 

196 "you may grant any perms you like to it." 

197 ) 

198 anon = auth.get_role_anonymous(session) 

199 anon.notes = ( 

200 "this role represents any user who is *not* logged in.\n\n" 

201 "you may grant any perms you like to it." 

202 ) 

203 

204 # also make "Site Admin" role 

205 site_admin_perms = [ 

206 "alembic.migrations.list", 

207 "alembic.migrations.create", 

208 "alembic.migrations.view", 

209 "alembic.migrations.delete", 

210 "alembic.migrations.configure", 

211 "alembic.dashboard", 

212 "alembic.migrate", 

213 "app_tables.list", 

214 "app_tables.create", 

215 "app_tables.view", 

216 "appinfo.list", 

217 "appinfo.configure", 

218 "email_settings.list", 

219 "email_settings.edit", 

220 "email_settings.view", 

221 "master_views.list", 

222 "master_views.create", 

223 "master_views.configure", 

224 "app_tables.view", 

225 "people.list", 

226 "people.create", 

227 "people.view", 

228 "people.edit", 

229 "people.delete", 

230 "people.versions", 

231 "roles.list", 

232 "roles.create", 

233 "roles.view", 

234 "roles.edit", 

235 "roles.edit_builtin", 

236 "roles.delete", 

237 "roles.versions", 

238 "settings.list", 

239 "settings.create", 

240 "settings.view", 

241 "settings.edit", 

242 "settings.delete", 

243 "settings.delete_bulk", 

244 "upgrades.list", 

245 "upgrades.create", 

246 "upgrades.view", 

247 "upgrades.edit", 

248 "upgrades.delete", 

249 "upgrades.execute", 

250 "upgrades.download", 

251 "upgrades.configure", 

252 "users.list", 

253 "users.create", 

254 "users.view", 

255 "users.edit", 

256 "users.delete", 

257 "users.versions", 

258 ] 

259 admin2 = model.Role(name="Site Admin") 

260 admin2.notes = ( 

261 'this is the "daily driver" admin role.\n\n' 

262 "you may grant any perms you like to it." 

263 ) 

264 session.add(admin2) 

265 user.roles.append(admin2) 

266 for perm in site_admin_perms: 

267 auth.grant_permission(admin2, perm) 

268 

269 # maybe make person 

270 if data["first_name"] or data["last_name"]: 

271 first = data["first_name"] 

272 last = data["last_name"] 

273 person = model.Person( 

274 first_name=first, 

275 last_name=last, 

276 full_name=(f"{first} {last}").strip(), 

277 ) 

278 session.add(person) 

279 user.person = person 

280 

281 self.setup_enhance_admin_user(user) 

282 

283 # send user to /login 

284 self.request.session.flash("Account created! Please login below.") 

285 return self.redirect(self.request.route_url("login")) 

286 

287 return { 

288 "index_title": self.app.get_title(), 

289 "form": form, 

290 } 

291 

292 def setup_enhance_admin_user(self, user): 

293 """ 

294 Further "enhance" the initial admin user when it is first created. 

295 

296 This does nothing by default; subclass can override if needed. 

297 

298 :param user: New admin 

299 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` 

300 which was just created as part of initial setup. 

301 """ 

302 

303 def change_theme(self): 

304 """ 

305 This view will set the global app theme, then redirect back to 

306 the referring page. 

307 """ 

308 theme = self.request.params.get("theme") 

309 if theme: 

310 try: 

311 set_app_theme(self.request, theme, session=Session()) 

312 except Exception as error: # pylint: disable=broad-exception-caught 

313 error = self.app.render_error(error) 

314 self.request.session.flash(f"Failed to set theme: {error}", "error") 

315 referrer = self.request.params.get("referrer") or self.request.get_referrer() 

316 return self.redirect(referrer) 

317 

318 @classmethod 

319 def defaults(cls, config): # pylint: disable=empty-docstring 

320 """ """ 

321 cls._defaults(config) 

322 

323 @classmethod 

324 def _defaults(cls, config): 

325 

326 config.add_wutta_permission_group("common", "(General)", overwrite=False) 

327 

328 # home page 

329 config.add_route("home", "/") 

330 config.add_view(cls, attr="home", route_name="home", renderer="/home.mako") 

331 

332 # forbidden 

333 config.add_forbidden_view( 

334 cls, attr="forbidden_view", renderer="/forbidden.mako" 

335 ) 

336 

337 # notfound 

338 # nb. also, auto-correct URLs which require trailing slash 

339 config.add_notfound_view( 

340 cls, attr="notfound_view", append_slash=True, renderer="/notfound.mako" 

341 ) 

342 

343 # feedback 

344 config.add_route("feedback", "/feedback", request_method="POST") 

345 config.add_view( 

346 cls, 

347 attr="feedback", 

348 route_name="feedback", 

349 permission="common.feedback", 

350 renderer="json", 

351 ) 

352 config.add_wutta_permission( 

353 "common", "common.feedback", "Send a feedback message" 

354 ) 

355 

356 # setup 

357 config.add_route("setup", "/setup") 

358 config.add_view(cls, attr="setup", route_name="setup", renderer="/setup.mako") 

359 

360 # change theme 

361 config.add_route("change_theme", "/change-theme", request_method="POST") 

362 config.add_view(cls, attr="change_theme", route_name="change_theme") 

363 config.add_wutta_permission( 

364 "common", "common.change_theme", "Change the global app theme" 

365 ) 

366 

367 

368def defaults(config, **kwargs): # pylint: disable=missing-function-docstring 

369 base = globals() 

370 

371 CommonView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

372 "CommonView", base["CommonView"] 

373 ) 

374 CommonView.defaults(config) 

375 

376 

377def includeme(config): # pylint: disable=missing-function-docstring 

378 defaults(config)