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
« 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"""
27import logging
29import colander
31from wuttaweb.views import View
32from wuttaweb.forms import widgets
33from wuttaweb.db import Session
34from wuttaweb.util import set_app_theme
37log = logging.getLogger(__name__)
40class CommonView(View):
41 """
42 Common views shared by all apps.
43 """
45 def home(self, session=None):
46 """
47 Home page view.
49 Template: ``/home.mako``
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()
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
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"))
69 return {
70 "index_title": self.app.get_title(),
71 }
73 def forbidden_view(self):
74 """
75 This view is shown when a request triggers a 403 Forbidden error.
77 Template: ``/forbidden.mako``
78 """
79 return {"index_title": self.app.get_title()}
81 def notfound_view(self):
82 """
83 This view is shown when a request triggers a 404 Not Found error.
85 Template: ``/notfound.mako``
86 """
87 return {"index_title": self.app.get_title()}
89 def feedback(self): # pylint: disable=empty-docstring
90 """ """
91 model = self.app.model
92 session = Session()
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)}
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
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__}
118 return {"ok": True}
120 def feedback_make_schema(self): # pylint: disable=empty-docstring
121 """ """
122 schema = colander.Schema()
124 schema.add(colander.SchemaNode(colander.String(), name="referrer"))
126 schema.add(
127 colander.SchemaNode(colander.String(), name="user_uuid", missing=None)
128 )
130 schema.add(colander.SchemaNode(colander.String(), name="user_name"))
132 schema.add(colander.SchemaNode(colander.String(), name="message"))
134 return schema
136 def feedback_send(self, context): # pylint: disable=empty-docstring
137 """ """
138 self.app.send_email("feedback", context)
140 def setup(self, session=None): # pylint: disable=too-many-locals
141 """
142 View for first-time app setup, to create admin user.
144 Template: ``/setup.mako``
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.
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.
154 .. note::
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()
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"))
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)
176 if form.validate():
177 auth = self.app.get_auth_handler()
178 data = form.validated
180 # make user
181 user = auth.make_user(session=session, username=data["username"])
182 auth.set_user_password(user, data["password"])
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 )
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 )
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)
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
278 self.setup_enhance_admin_user(user)
280 # send user to /login
281 self.request.session.flash("Account created! Please login below.")
282 return self.redirect(self.request.route_url("login"))
284 return {
285 "index_title": self.app.get_title(),
286 "form": form,
287 }
289 def setup_enhance_admin_user(self, user):
290 """
291 Further "enhance" the initial admin user when it is first created.
293 This does nothing by default; subclass can override if needed.
295 :param user: New admin
296 :class:`~wuttjamaican:wuttjamaican.db.model.auth.User`
297 which was just created as part of initial setup.
298 """
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)
315 @classmethod
316 def defaults(cls, config): # pylint: disable=empty-docstring
317 """ """
318 cls._defaults(config)
320 @classmethod
321 def _defaults(cls, config):
323 config.add_wutta_permission_group("common", "(General)", overwrite=False)
325 # home page
326 config.add_route("home", "/")
327 config.add_view(cls, attr="home", route_name="home", renderer="/home.mako")
329 # forbidden
330 config.add_forbidden_view(
331 cls, attr="forbidden_view", renderer="/forbidden.mako"
332 )
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 )
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 )
353 # setup
354 config.add_route("setup", "/setup")
355 config.add_view(cls, attr="setup", route_name="setup", renderer="/setup.mako")
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 )
365def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
366 base = globals()
368 CommonView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
369 "CommonView", base["CommonView"]
370 )
371 CommonView.defaults(config)
374def includeme(config): # pylint: disable=missing-function-docstring
375 defaults(config)