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

107 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-29 20:33 -0500

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 

36log = logging.getLogger(__name__) 

37 

38 

39class CommonView(View): 

40 """ 

41 Common views shared by all apps. 

42 """ 

43 

44 def home(self, session=None): 

45 """ 

46 Home page view. 

47 

48 Template: ``/home.mako`` 

49 

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

51 root URL for the web app. 

52 """ 

53 # pylint: disable=duplicate-code 

54 model = self.app.model 

55 session = session or Session() 

56 

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

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

59 if not user: 

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

61 # pylint: enable=duplicate-code 

62 

63 # maybe auto-redirect anons to login 

64 if not self.request.user: 

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

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

67 

68 return { 

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

70 } 

71 

72 def forbidden_view(self): 

73 """ 

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

75 

76 Note that this page template is rendered only if the user is 

77 already logged in. If they are not yet, they're instead 

78 redirected to the login page. 

79 

80 Template: ``/forbidden.mako`` 

81 """ 

82 if not self.request.user: 

83 self.request.session["url_after_login"] = self.request.url 

84 self.request.session.flash("You must login to see that page.", "error") 

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

86 

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

88 

89 def notfound_view(self): 

90 """ 

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

92 

93 Template: ``/notfound.mako`` 

94 """ 

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

96 

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

98 """ """ 

99 model = self.app.model 

100 session = Session() 

101 

102 # validate form 

103 schema = self.feedback_make_schema() 

104 form = self.make_form(schema=schema) 

105 if not form.validate(): 

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

107 dform = form.get_deform() 

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

109 

110 # build email template context 

111 context = dict(form.validated) 

112 if context["user_uuid"]: 

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

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

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

116 ) 

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

118 

119 # send email 

120 try: 

121 self.feedback_send(context) 

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

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

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

125 

126 return {"ok": True} 

127 

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

129 """ """ 

130 schema = colander.Schema() 

131 

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

133 

134 schema.add( 

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

136 ) 

137 

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

139 

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

141 

142 return schema 

143 

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

145 """ """ 

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

147 

148 def setup(self): 

149 """ 

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

151 

152 Template: ``/setup.mako`` 

153 

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

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

156 home page. 

157 

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

159 be used to create the first admin user. 

160 

161 When form is submitted, this calls :meth:`do_setup()` to 

162 handle the actual setup. When finished, user will be 

163 redirected to the login page. 

164 

165 .. note:: 

166 

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

168 login pages will automatically redirect to this one. 

169 """ 

170 model = self.app.model 

171 session = Session() 

172 

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

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

175 if user: 

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

177 

178 form = self.make_form( 

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

180 show_button_cancel=False, 

181 show_button_reset=True, 

182 ) 

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

184 form.set_required("first_name", False) 

185 form.set_required("last_name", False) 

186 

187 if form.validate(): 

188 

189 # do the setup 

190 self.do_setup(session, form.validated) 

191 

192 # send user to /login 

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

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

195 

196 return { 

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

198 "form": form, 

199 } 

200 

201 def do_setup(self, session, data): 

202 """ 

203 Perform the actual setup operations. This is called from 

204 :meth:`setup()`. 

205 

206 Default logic will invoke two methods on the :term:`install 

207 handler`: 

208 

209 * :meth:`~wuttjamaican:wuttjamaican.install.InstallHandler.establish_builtin_roles()` 

210 * :meth:`~wuttjamaican:wuttjamaican.install.InstallHandler.establish_site_admin_user()` 

211 

212 :param session: Current :term:`db session`. 

213 

214 :param data: Form data submitted from the /setup page. 

215 """ 

216 install = self.app.get_install_handler() 

217 

218 install.establish_builtin_roles(session) 

219 

220 install.establish_site_admin_user( 

221 session, 

222 data["username"], 

223 data["password"], 

224 first_name=data["first_name"], 

225 last_name=data["last_name"], 

226 ) 

227 

228 def change_theme(self): 

229 """ 

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

231 the referring page. 

232 """ 

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

234 if theme: 

235 try: 

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

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

238 error = self.app.render_error(error) 

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

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

241 return self.redirect(referrer) 

242 

243 @classmethod 

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

245 """ """ 

246 cls._defaults(config) 

247 

248 @classmethod 

249 def _defaults(cls, config): 

250 

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

252 

253 # home page 

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

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

256 

257 # forbidden 

258 config.add_forbidden_view( 

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

260 ) 

261 

262 # notfound 

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

264 config.add_notfound_view( 

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

266 ) 

267 

268 # feedback 

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

270 config.add_view( 

271 cls, 

272 attr="feedback", 

273 route_name="feedback", 

274 permission="common.feedback", 

275 renderer="json", 

276 ) 

277 config.add_wutta_permission( 

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

279 ) 

280 

281 # setup 

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

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

284 

285 # change theme 

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

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

288 config.add_wutta_permission( 

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

290 ) 

291 

292 

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

294 base = globals() 

295 

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

297 "CommonView", base["CommonView"] 

298 ) 

299 CommonView.defaults(config) 

300 

301 

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

303 defaults(config)