Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/views/views.py: 100%
249 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2025 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"""
24Views of Views
25"""
27import importlib
28import logging
29import os
30import re
31import sys
33from mako.lookup import TemplateLookup
35from wuttaweb.views import MasterView
36from wuttaweb.util import get_model_fields
38log = logging.getLogger(__name__)
41class MasterViewView(MasterView): # pylint: disable=abstract-method
42 """
43 Master view which shows a list of all master views found in the
44 app registry.
46 Route prefix is ``master_views``; notable URLs provided by this
47 class include:
49 * ``/views/master/``
50 """
52 model_name = "master_view"
53 model_title = "Master View"
54 model_title_plural = "Master Views"
55 url_prefix = "/views/master"
57 filterable = False
58 sortable = True
59 sort_on_backend = False
60 paginated = True
61 paginate_on_backend = False
63 creatable = True
64 viewable = False # nb. it has a pseudo-view action instead
65 editable = False
66 deletable = False
67 configurable = True
69 labels = {
70 "model_title_plural": "Title",
71 "url_prefix": "URL Prefix",
72 }
74 grid_columns = [
75 "model_title_plural",
76 "model_name",
77 "route_prefix",
78 "url_prefix",
79 ]
81 sort_defaults = "model_title_plural"
83 def get_grid_data( # pylint: disable=empty-docstring
84 self, columns=None, session=None
85 ):
86 """ """
87 data = []
89 # nb. we do not omit any views due to lack of permission here.
90 # all views are shown for anyone seeing this page. this is
91 # for sake of clarity so admin users are aware of what is
92 # *possible* within the app etc.
93 master_views = self.request.registry.settings.get("wuttaweb_master_views", {})
94 for model_views in master_views.values():
95 for view in model_views:
96 data.append(
97 {
98 "model_title_plural": view.get_model_title_plural(),
99 "model_name": view.get_model_name(),
100 "route_prefix": view.get_route_prefix(),
101 "url_prefix": view.get_url_prefix(),
102 }
103 )
105 return data
107 def configure_grid(self, grid): # pylint: disable=empty-docstring
108 """ """
109 g = grid
110 super().configure_grid(g)
112 # nb. show more views by default
113 g.pagesize = 50
115 # nb. add "pseudo" View action
116 def viewurl(view, i): # pylint: disable=unused-argument
117 return self.request.route_url(view["route_prefix"])
119 g.add_action("view", icon="eye", url=viewurl)
121 # model_title_plural
122 g.set_link("model_title_plural")
123 g.set_searchable("model_title_plural")
125 # model_name
126 g.set_searchable("model_name")
128 # route_prefix
129 g.set_searchable("route_prefix")
131 # url_prefix
132 g.set_link("url_prefix")
133 g.set_searchable("url_prefix")
135 def get_template_context(self, context): # pylint: disable=empty-docstring
136 """ """
137 context = super().get_template_context(context)
139 if self.creating:
140 model = self.app.model
141 session = self.Session()
143 # app models
144 app_models = []
145 for name in dir(model):
146 obj = getattr(model, name)
147 if (
148 isinstance(obj, type)
149 and issubclass(obj, model.Base)
150 and obj is not model.Base
151 ):
152 app_models.append(name)
153 context["app_models"] = sorted(app_models)
155 # view module location
156 view_locations = self.get_view_module_options()
157 modpath = self.config.get("wuttaweb.master_views.default_module_dir")
158 if modpath not in view_locations:
159 modpath = None
160 if not modpath:
161 pkgname = self.get_pkgname()
162 pkgpath = f"{pkgname}.web.views"
163 if pkgpath in view_locations:
164 modpath = pkgpath
165 context["view_module_dirs"] = view_locations
166 context["view_module_dir"] = modpath
168 # menu handler path
169 web = self.app.get_web_handler()
170 menu = web.get_menu_handler()
171 context["menu_path"] = sys.modules[menu.__class__.__module__].__file__
173 # roles for access
174 roles = self.get_roles_for_access(session)
175 context["roles"] = [
176 {"uuid": role.uuid.hex, "name": role.name} for role in roles
177 ]
178 roles = {role.uuid.hex: False for role in roles}
179 context.update(
180 {
181 "listing_roles": roles,
182 "creating_roles": roles,
183 "viewing_roles": roles,
184 "editing_roles": roles,
185 "deleting_roles": roles,
186 "history_roles": roles,
187 }
188 )
190 return context
192 def get_pkgname(self): # pylint: disable=missing-function-docstring
193 model = self.app.model
194 return model.__name__.split(".")[0]
196 def get_roles_for_access( # pylint: disable=missing-function-docstring
197 self, session
198 ):
199 model = self.app.model
200 auth = self.app.get_auth_handler()
201 admin = auth.get_role_administrator(session)
202 return (
203 session.query(model.Role)
204 .filter(model.Role.uuid != admin.uuid)
205 .order_by(model.Role.name)
206 .all()
207 )
209 def get_view_module_options(self): # pylint: disable=missing-function-docstring
210 modules = set()
211 master_views = self.request.registry.settings.get("wuttaweb_master_views", {})
212 for model_views in master_views.values():
213 for view in model_views:
214 parent = ".".join(view.__module__.split(".")[:-1])
215 modules.add(parent)
216 return sorted(modules)
218 def wizard_action(self): # pylint: disable=too-many-return-statements
219 """
220 AJAX view to handle various actions for the "new master view" wizard.
221 """
222 data = self.request.json_body
223 action = data.get("action", "").strip()
224 try:
225 # nb. cannot use match/case statement until python 3.10, but this
226 # project technically still supports python 3.8
227 if action == "suggest_details":
228 return self.suggest_details(data)
229 if action == "write_view_file":
230 return self.write_view_file(data)
231 if action == "check_route":
232 return self.check_route(data)
233 if action == "apply_permissions":
234 return self.apply_permissions(data)
235 if action == "":
236 return {"error": "Must specify the action to perform."}
237 return {"error": f"Unknown action requested: {action}"}
239 except Exception as err: # pylint: disable=broad-exception-caught
240 log.exception("new master view wizard action failed: %s", action)
241 return {"error": f"Unexpected error occurred: {err}"}
243 def suggest_details( # pylint: disable=missing-function-docstring,too-many-locals
244 self, data
245 ):
246 model = self.app.model
247 model_name = data["model_name"]
249 def make_normal(match):
250 return "_" + match.group(1).lower()
252 # normal is like: poser_widget
253 normal = re.sub(r"([A-Z])", make_normal, model_name)
254 normal = normal.lstrip("_")
256 def make_title(match):
257 return " " + match.group(1).upper()
259 # title is like: Poser Widget
260 title = re.sub(r"(?:^|_)([a-z])", make_title, normal)
261 title = title.lstrip(" ")
263 model_title = title
264 model_title_plural = title + "s"
266 def make_camel(match):
267 return match.group(1).upper()
269 # camel is like: PoserWidget
270 camel = re.sub(r"(?:^|_)([a-z])", make_camel, normal)
272 # fields are unknown without model class
273 grid_columns = []
274 form_fields = []
276 # versioning feature requires "configured" model class
277 versioning = False
279 if data["model_option"] == "model_class":
280 model_class = getattr(model, model_name)
282 # get model title from model class, if possible
283 if hasattr(model_class, "__wutta_hint__"):
284 model_title = model_class.__wutta_hint__.get("model_title", model_title)
285 model_title_plural = model_class.__wutta_hint__.get(
286 "model_title_plural", model_title + "s"
287 )
289 # get columns/fields from model class
290 grid_columns = get_model_fields(self.config, model_class)
291 if "uuid" in grid_columns:
292 grid_columns.remove("uuid")
293 form_fields = grid_columns
295 # check versioning support
296 try:
297 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel
298 except ImportError: # pragma: no cover
299 pass
300 else:
301 try:
302 continuum.version_class(model_class)
303 except continuum.exc.ClassNotVersioned:
304 pass
305 else:
306 versioning = True
308 # plural is like: poser_widgets
309 plural = re.sub(r"(?:^| )([A-Z])", make_normal, model_title_plural)
310 plural = plural.lstrip("_")
312 route_prefix = plural
313 url_prefix = "/" + (plural).replace("_", "-")
315 return {
316 "class_file_name": plural + ".py",
317 "class_name": camel + "View",
318 "model_name": model_name,
319 "model_title": model_title,
320 "model_title_plural": model_title_plural,
321 "route_prefix": route_prefix,
322 "permission_prefix": route_prefix,
323 "url_prefix": url_prefix,
324 "template_prefix": url_prefix,
325 "grid_columns": "\n".join(grid_columns),
326 "form_fields": "\n".join(form_fields),
327 "supports_versioning": versioning,
328 }
330 def write_view_file(self, data): # pylint: disable=missing-function-docstring
331 model = self.app.model
333 # sort out the destination file path
334 modpath = data["view_location"]
335 if modpath:
336 mod = importlib.import_module(modpath)
337 file_path = os.path.join(
338 os.path.dirname(mod.__file__), data["view_file_name"]
339 )
340 else:
341 file_path = data["view_file_path"]
343 # confirm file is writable
344 if os.path.exists(file_path):
345 if data["overwrite"]:
346 os.remove(file_path)
347 else:
348 return {"error": "File already exists"}
350 # guess its dotted module path
351 modname, ext = os.path.splitext( # pylint: disable=unused-variable
352 os.path.basename(file_path)
353 )
354 if modpath:
355 modpath = f"{modpath}.{modname}"
356 else:
357 modpath = f"poser.web.views.{modname}"
359 # inject module for class if needed
360 if data["model_option"] == "model_class":
361 model_class = getattr(model, data["model_name"])
362 data["model_module"] = model_class.__module__
364 # TODO: make templates dir configurable?
365 view_templates = TemplateLookup(
366 directories=[self.app.resource_path("wuttaweb:code-templates")]
367 )
369 # render template to file
370 template = view_templates.get_template("/new-master-view.mako")
371 content = template.render(**data)
372 with open(file_path, "wt", encoding="utf_8") as f:
373 f.write(content)
375 return {
376 "view_file_path": file_path,
377 "view_module_path": modpath,
378 "view_config_path": os.path.join(os.path.dirname(file_path), "__init__.py"),
379 }
381 def check_route(self, data): # pylint: disable=missing-function-docstring
382 try:
383 url = self.request.route_url(data["route"])
384 path = self.request.route_path(data["route"])
385 except Exception as err: # pylint: disable=broad-exception-caught
386 return {"problem": self.app.render_error(err)}
388 return {"url": url, "path": path}
390 def apply_permissions( # pylint: disable=missing-function-docstring,too-many-branches
391 self, data
392 ):
393 session = self.Session()
394 auth = self.app.get_auth_handler()
395 roles = self.get_roles_for_access(session)
396 permission_prefix = data["permission_prefix"]
398 if "listing_roles" in data:
399 listing = data["listing_roles"]
400 for role in roles:
401 if listing.get(role.uuid.hex):
402 auth.grant_permission(role, f"{permission_prefix}.list")
403 else:
404 auth.revoke_permission(role, f"{permission_prefix}.list")
406 if "creating_roles" in data:
407 creating = data["creating_roles"]
408 for role in roles:
409 if creating.get(role.uuid.hex):
410 auth.grant_permission(role, f"{permission_prefix}.create")
411 else:
412 auth.revoke_permission(role, f"{permission_prefix}.create")
414 if "viewing_roles" in data:
415 viewing = data["viewing_roles"]
416 for role in roles:
417 if viewing.get(role.uuid.hex):
418 auth.grant_permission(role, f"{permission_prefix}.view")
419 else:
420 auth.revoke_permission(role, f"{permission_prefix}.view")
422 if "history_roles" in data:
423 history = data["history_roles"]
424 for role in roles:
425 if history.get(role.uuid.hex):
426 auth.grant_permission(role, f"{permission_prefix}.versions")
427 else:
428 auth.revoke_permission(role, f"{permission_prefix}.versions")
430 if "editing_roles" in data:
431 editing = data["editing_roles"]
432 for role in roles:
433 if editing.get(role.uuid.hex):
434 auth.grant_permission(role, f"{permission_prefix}.edit")
435 else:
436 auth.revoke_permission(role, f"{permission_prefix}.edit")
438 if "deleting_roles" in data:
439 deleting = data["deleting_roles"]
440 for role in roles:
441 if deleting.get(role.uuid.hex):
442 auth.grant_permission(role, f"{permission_prefix}.delete")
443 else:
444 auth.revoke_permission(role, f"{permission_prefix}.delete")
446 return {}
448 def configure_get_simple_settings(self): # pylint: disable=empty-docstring
449 """ """
450 return [
451 {"name": "wuttaweb.master_views.default_module_dir"},
452 ]
454 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
455 self, **kwargs
456 ):
457 """ """
458 context = super().configure_get_context(**kwargs)
460 context["view_module_locations"] = self.get_view_module_options()
462 return context
464 @classmethod
465 def defaults(cls, config): # pylint: disable=empty-docstring
466 """ """
467 cls._masterview_defaults(config)
468 cls._defaults(config)
470 # pylint: disable=duplicate-code
471 @classmethod
472 def _masterview_defaults(cls, config):
473 route_prefix = cls.get_route_prefix()
474 permission_prefix = cls.get_permission_prefix()
475 model_title_plural = cls.get_model_title_plural()
476 url_prefix = cls.get_url_prefix()
478 # fix permission group
479 config.add_wutta_permission_group(
480 permission_prefix, model_title_plural, overwrite=False
481 )
483 # wizard actions
484 config.add_route(
485 f"{route_prefix}.wizard_action",
486 f"{url_prefix}/new/wizard-action",
487 request_method="POST",
488 )
489 config.add_view(
490 cls,
491 attr="wizard_action",
492 route_name=f"{route_prefix}.wizard_action",
493 renderer="json",
494 permission=f"{permission_prefix}.create",
495 )
497 # pylint: enable=duplicate-code
500def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
501 base = globals()
503 MasterViewView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
504 "MasterViewView", base["MasterViewView"]
505 )
506 MasterViewView.defaults(config)
509def includeme(config): # pylint: disable=missing-function-docstring
510 defaults(config)