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

124 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-04 08:56 -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 "master_views.list", 

219 "master_views.create", 

220 "master_views.configure", 

221 "app_tables.view", 

222 "people.list", 

223 "people.create", 

224 "people.view", 

225 "people.edit", 

226 "people.delete", 

227 "people.versions", 

228 "roles.list", 

229 "roles.create", 

230 "roles.view", 

231 "roles.edit", 

232 "roles.edit_builtin", 

233 "roles.delete", 

234 "roles.versions", 

235 "settings.list", 

236 "settings.create", 

237 "settings.view", 

238 "settings.edit", 

239 "settings.delete", 

240 "settings.delete_bulk", 

241 "upgrades.list", 

242 "upgrades.create", 

243 "upgrades.view", 

244 "upgrades.edit", 

245 "upgrades.delete", 

246 "upgrades.execute", 

247 "upgrades.download", 

248 "upgrades.configure", 

249 "users.list", 

250 "users.create", 

251 "users.view", 

252 "users.edit", 

253 "users.delete", 

254 "users.versions", 

255 ] 

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

257 admin2.notes = ( 

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

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

260 ) 

261 session.add(admin2) 

262 user.roles.append(admin2) 

263 for perm in site_admin_perms: 

264 auth.grant_permission(admin2, perm) 

265 

266 # maybe make person 

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

268 first = data["first_name"] 

269 last = data["last_name"] 

270 person = model.Person( 

271 first_name=first, 

272 last_name=last, 

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

274 ) 

275 session.add(person) 

276 user.person = person 

277 

278 self.setup_enhance_admin_user(user) 

279 

280 # send user to /login 

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

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

283 

284 return { 

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

286 "form": form, 

287 } 

288 

289 def setup_enhance_admin_user(self, user): 

290 """ 

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

292 

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

294 

295 :param user: New admin 

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

297 which was just created as part of initial setup. 

298 """ 

299 

300 def change_theme(self): 

301 """ 

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

303 the referring page. 

304 """ 

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

306 if theme: 

307 try: 

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

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

310 error = self.app.render_error(error) 

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

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

313 return self.redirect(referrer) 

314 

315 @classmethod 

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

317 """ """ 

318 cls._defaults(config) 

319 

320 @classmethod 

321 def _defaults(cls, config): 

322 

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

324 

325 # home page 

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

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

328 

329 # forbidden 

330 config.add_forbidden_view( 

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

332 ) 

333 

334 # notfound 

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

336 config.add_notfound_view( 

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

338 ) 

339 

340 # feedback 

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

342 config.add_view( 

343 cls, 

344 attr="feedback", 

345 route_name="feedback", 

346 permission="common.feedback", 

347 renderer="json", 

348 ) 

349 config.add_wutta_permission( 

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

351 ) 

352 

353 # setup 

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

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

356 

357 # change theme 

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

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

360 config.add_wutta_permission( 

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

362 ) 

363 

364 

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

366 base = globals() 

367 

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

369 "CommonView", base["CommonView"] 

370 ) 

371 CommonView.defaults(config) 

372 

373 

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

375 defaults(config)