Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / views.py: 100%
229 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-31 19:25 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-31 19:25 -0600
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
39log = logging.getLogger(__name__)
42class MasterViewView(MasterView): # pylint: disable=abstract-method
43 """
44 Master view which shows a list of all master views found in the
45 app registry.
47 Route prefix is ``master_views``; notable URLs provided by this
48 class include:
50 * ``/views/master/``
51 """
53 model_name = "master_view"
54 model_title = "Master View"
55 model_title_plural = "Master Views"
56 url_prefix = "/views/master"
58 filterable = False
59 sortable = True
60 sort_on_backend = False
61 paginated = True
62 paginate_on_backend = False
64 creatable = True
65 viewable = False # nb. it has a pseudo-view action instead
66 editable = False
67 deletable = False
68 configurable = True
70 labels = {
71 "model_title_plural": "Title",
72 "url_prefix": "URL Prefix",
73 }
75 grid_columns = [
76 "model_title_plural",
77 "model_name",
78 "route_prefix",
79 "url_prefix",
80 ]
82 sort_defaults = "model_title_plural"
84 def get_grid_data( # pylint: disable=empty-docstring
85 self, columns=None, session=None
86 ):
87 """ """
88 data = []
90 # nb. we do not omit any views due to lack of permission here.
91 # all views are shown for anyone seeing this page. this is
92 # for sake of clarity so admin users are aware of what is
93 # *possible* within the app etc.
94 master_views = self.request.registry.settings.get("wuttaweb_master_views", {})
95 for model_views in master_views.values():
96 for view in model_views:
97 data.append(
98 {
99 "model_title_plural": view.get_model_title_plural(),
100 "model_name": view.get_model_name(),
101 "route_prefix": view.get_route_prefix(),
102 "url_prefix": view.get_url_prefix(),
103 }
104 )
106 return data
108 def configure_grid(self, grid): # pylint: disable=empty-docstring
109 """ """
110 g = grid
111 super().configure_grid(g)
113 # nb. show more views by default
114 g.pagesize = 50
116 # nb. add "pseudo" View action
117 def viewurl(view, i): # pylint: disable=unused-argument
118 return self.request.route_url(view["route_prefix"])
120 g.add_action("view", icon="eye", url=viewurl)
122 # model_title_plural
123 g.set_link("model_title_plural")
124 g.set_searchable("model_title_plural")
126 # model_name
127 g.set_searchable("model_name")
129 # route_prefix
130 g.set_searchable("route_prefix")
132 # url_prefix
133 g.set_link("url_prefix")
134 g.set_searchable("url_prefix")
136 def get_template_context(self, context): # pylint: disable=empty-docstring
137 """ """
138 if self.creating:
139 model = self.app.model
140 session = self.Session()
142 # app models
143 app_models = []
144 for name in dir(model):
145 obj = getattr(model, name)
146 if (
147 isinstance(obj, type)
148 and issubclass(obj, model.Base)
149 and obj is not model.Base
150 ):
151 app_models.append(name)
152 context["app_models"] = sorted(app_models)
154 # view module location
155 view_locations = self.get_view_module_options()
156 modpath = self.config.get("wuttaweb.master_views.default_module_dir")
157 if modpath not in view_locations:
158 modpath = None
159 if not modpath and len(view_locations) == 1:
160 modpath = view_locations[0]
161 context["view_module_dirs"] = view_locations
162 context["view_module_dir"] = modpath
164 # menu handler path
165 web = self.app.get_web_handler()
166 menu = web.get_menu_handler()
167 context["menu_path"] = sys.modules[menu.__class__.__module__].__file__
169 # roles for access
170 roles = self.get_roles_for_access(session)
171 context["roles"] = [
172 {"uuid": role.uuid.hex, "name": role.name} for role in roles
173 ]
174 context["listing_roles"] = {role.uuid.hex: False for role in roles}
175 context["creating_roles"] = {role.uuid.hex: False for role in roles}
176 context["viewing_roles"] = {role.uuid.hex: False for role in roles}
177 context["editing_roles"] = {role.uuid.hex: False for role in roles}
178 context["deleting_roles"] = {role.uuid.hex: False for role in roles}
180 return context
182 def get_roles_for_access( # pylint: disable=missing-function-docstring
183 self, session
184 ):
185 model = self.app.model
186 auth = self.app.get_auth_handler()
187 admin = auth.get_role_administrator(session)
188 return (
189 session.query(model.Role)
190 .filter(model.Role.uuid != admin.uuid)
191 .order_by(model.Role.name)
192 .all()
193 )
195 def get_view_module_options(self): # pylint: disable=missing-function-docstring
196 modules = set()
197 master_views = self.request.registry.settings.get("wuttaweb_master_views", {})
198 for model_views in master_views.values():
199 for view in model_views:
200 parent = ".".join(view.__module__.split(".")[:-1])
201 modules.add(parent)
202 return sorted(modules)
204 def wizard_action(self): # pylint: disable=too-many-return-statements
205 """
206 AJAX view to handle various actions for the "new master view" wizard.
207 """
208 data = self.request.json_body
209 action = data.get("action", "").strip()
210 try:
211 # nb. cannot use match/case statement until python 3.10, but this
212 # project technically still supports python 3.8
213 if action == "suggest_details":
214 return self.suggest_details(data)
215 if action == "write_view_file":
216 return self.write_view_file(data)
217 if action == "check_route":
218 return self.check_route(data)
219 if action == "apply_permissions":
220 return self.apply_permissions(data)
221 if action == "":
222 return {"error": "Must specify the action to perform."}
223 return {"error": f"Unknown action requested: {action}"}
225 except Exception as err: # pylint: disable=broad-exception-caught
226 log.exception("new master view wizard action failed: %s", action)
227 return {"error": f"Unexpected error occurred: {err}"}
229 def suggest_details( # pylint: disable=missing-function-docstring,too-many-locals
230 self, data
231 ):
232 model = self.app.model
233 model_name = data["model_name"]
235 def make_normal(match):
236 return "_" + match.group(1).lower()
238 # normal is like: poser_widget
239 normal = re.sub(r"([A-Z])", make_normal, model_name)
240 normal = normal.lstrip("_")
242 def make_title(match):
243 return " " + match.group(1).upper()
245 # title is like: Poser Widget
246 title = re.sub(r"(?:^|_)([a-z])", make_title, normal)
247 title = title.lstrip(" ")
249 model_title = title
250 model_title_plural = title + "s"
252 def make_camel(match):
253 return match.group(1).upper()
255 # camel is like: PoserWidget
256 camel = re.sub(r"(?:^|_)([a-z])", make_camel, normal)
258 # fields are unknown without model class
259 grid_columns = []
260 form_fields = []
262 if data["model_option"] == "model_class":
263 model_class = getattr(model, model_name)
265 # get model title from model class, if possible
266 if hasattr(model_class, "__wutta_hint__"):
267 model_title = model_class.__wutta_hint__.get("model_title", model_title)
268 model_title_plural = model_class.__wutta_hint__.get(
269 "model_title_plural", model_title + "s"
270 )
272 # get columns/fields from model class
273 grid_columns = get_model_fields(self.config, model_class)
274 form_fields = grid_columns
276 # plural is like: poser_widgets
277 plural = re.sub(r"(?:^| )([A-Z])", make_normal, model_title_plural)
278 plural = plural.lstrip("_")
280 route_prefix = plural
281 url_prefix = "/" + (plural).replace("_", "-")
283 return {
284 "class_file_name": plural + ".py",
285 "class_name": camel + "View",
286 "model_name": model_name,
287 "model_title": model_title,
288 "model_title_plural": model_title_plural,
289 "route_prefix": route_prefix,
290 "permission_prefix": route_prefix,
291 "url_prefix": url_prefix,
292 "template_prefix": url_prefix,
293 "grid_columns": "\n".join(grid_columns),
294 "form_fields": "\n".join(form_fields),
295 }
297 def write_view_file(self, data): # pylint: disable=missing-function-docstring
298 model = self.app.model
300 # sort out the destination file path
301 modpath = data["view_location"]
302 if modpath:
303 mod = importlib.import_module(modpath)
304 file_path = os.path.join(
305 os.path.dirname(mod.__file__), data["view_file_name"]
306 )
307 else:
308 file_path = data["view_file_path"]
310 # confirm file is writable
311 if os.path.exists(file_path):
312 if data["overwrite"]:
313 os.remove(file_path)
314 else:
315 return {"error": "File already exists"}
317 # guess its dotted module path
318 modname, ext = os.path.splitext( # pylint: disable=unused-variable
319 os.path.basename(file_path)
320 )
321 if modpath:
322 modpath = f"{modpath}.{modname}"
323 else:
324 modpath = f"poser.web.views.{modname}"
326 # inject module for class if needed
327 if data["model_option"] == "model_class":
328 model_class = getattr(model, data["model_name"])
329 data["model_module"] = model_class.__module__
331 # TODO: make templates dir configurable?
332 view_templates = TemplateLookup(
333 directories=[self.app.resource_path("wuttaweb:code-templates")]
334 )
336 # render template to file
337 template = view_templates.get_template("/new-master-view.mako")
338 content = template.render(**data)
339 with open(file_path, "wt", encoding="utf_8") as f:
340 f.write(content)
342 return {
343 "view_file_path": file_path,
344 "view_module_path": modpath,
345 "view_config_path": os.path.join(os.path.dirname(file_path), "__init__.py"),
346 }
348 def check_route(self, data): # pylint: disable=missing-function-docstring
349 try:
350 url = self.request.route_url(data["route"])
351 path = self.request.route_path(data["route"])
352 except Exception as err: # pylint: disable=broad-exception-caught
353 return {"problem": self.app.render_error(err)}
355 return {"url": url, "path": path}
357 def apply_permissions( # pylint: disable=missing-function-docstring,too-many-branches
358 self, data
359 ):
360 session = self.Session()
361 auth = self.app.get_auth_handler()
362 roles = self.get_roles_for_access(session)
363 permission_prefix = data["permission_prefix"]
365 if "listing_roles" in data:
366 listing = data["listing_roles"]
367 for role in roles:
368 if listing.get(role.uuid.hex):
369 auth.grant_permission(role, f"{permission_prefix}.list")
370 else:
371 auth.revoke_permission(role, f"{permission_prefix}.list")
373 if "creating_roles" in data:
374 creating = data["creating_roles"]
375 for role in roles:
376 if creating.get(role.uuid.hex):
377 auth.grant_permission(role, f"{permission_prefix}.create")
378 else:
379 auth.revoke_permission(role, f"{permission_prefix}.create")
381 if "viewing_roles" in data:
382 viewing = data["viewing_roles"]
383 for role in roles:
384 if viewing.get(role.uuid.hex):
385 auth.grant_permission(role, f"{permission_prefix}.view")
386 else:
387 auth.revoke_permission(role, f"{permission_prefix}.view")
389 if "editing_roles" in data:
390 editing = data["editing_roles"]
391 for role in roles:
392 if editing.get(role.uuid.hex):
393 auth.grant_permission(role, f"{permission_prefix}.edit")
394 else:
395 auth.revoke_permission(role, f"{permission_prefix}.edit")
397 if "deleting_roles" in data:
398 deleting = data["deleting_roles"]
399 for role in roles:
400 if deleting.get(role.uuid.hex):
401 auth.grant_permission(role, f"{permission_prefix}.delete")
402 else:
403 auth.revoke_permission(role, f"{permission_prefix}.delete")
405 return {}
407 def configure_get_simple_settings(self): # pylint: disable=empty-docstring
408 """ """
409 return [
410 {"name": "wuttaweb.master_views.default_module_dir"},
411 ]
413 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
414 self, **kwargs
415 ):
416 """ """
417 context = super().configure_get_context(**kwargs)
419 context["view_module_locations"] = self.get_view_module_options()
421 return context
423 @classmethod
424 def defaults(cls, config): # pylint: disable=empty-docstring
425 """ """
426 cls._masterview_defaults(config)
427 cls._defaults(config)
429 # pylint: disable=duplicate-code
430 @classmethod
431 def _masterview_defaults(cls, config):
432 route_prefix = cls.get_route_prefix()
433 permission_prefix = cls.get_permission_prefix()
434 model_title_plural = cls.get_model_title_plural()
435 url_prefix = cls.get_url_prefix()
437 # fix permission group
438 config.add_wutta_permission_group(
439 permission_prefix, model_title_plural, overwrite=False
440 )
442 # wizard actions
443 config.add_route(
444 f"{route_prefix}.wizard_action",
445 f"{url_prefix}/new/wizard-action",
446 request_method="POST",
447 )
448 config.add_view(
449 cls,
450 attr="wizard_action",
451 route_name=f"{route_prefix}.wizard_action",
452 renderer="json",
453 permission=f"{permission_prefix}.create",
454 )
456 # pylint: enable=duplicate-code
459def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
460 base = globals()
462 MasterViewView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name
463 "MasterViewView", base["MasterViewView"]
464 )
465 MasterViewView.defaults(config)
468def includeme(config): # pylint: disable=missing-function-docstring
469 defaults(config)