diff --git a/src/wuttaweb/code-templates/new-master-view.mako b/src/wuttaweb/code-templates/new-master-view.mako
new file mode 100644
index 0000000..01fb4b7
--- /dev/null
+++ b/src/wuttaweb/code-templates/new-master-view.mako
@@ -0,0 +1,116 @@
+## -*- coding: utf-8; mode: python; -*-
+# -*- coding: utf-8; -*-
+"""
+Master view for ${model_title_plural}
+"""
+
+% if model_option == "model_class":
+from ${model_module} import ${model_name}
+% endif
+
+from wuttaweb.views import MasterView
+
+
+class ${class_name}(MasterView):
+ """
+ Master view for ${model_title_plural}
+ """
+ % if model_option == "model_class":
+ model_class = ${model_name}
+ % else:
+ model_name = "${model_name}"
+ % endif
+ model_title = "${model_title}"
+ model_title_plural = "${model_title_plural}"
+
+ route_prefix = "${route_prefix}"
+ % if permission_prefix != route_prefix:
+ permission_prefix = "${permission_prefix}"
+ % endif
+ url_prefix = "${url_prefix}"
+ % if template_prefix != url_prefix:
+ template_prefix = "${template_prefix}"
+ % endif
+
+ % if not listable:
+ listable = False
+ % endif
+ creatable = ${creatable}
+ % if not viewable:
+ viewable = ${viewable}
+ % endif
+ editable = ${editable}
+ deletable = ${deletable}
+
+ % if listable and model_option == "model_name":
+ filterable = False
+ sort_on_backend = False
+ paginate_on_backend = False
+ % endif
+
+ % if grid_columns:
+ grid_columns = [
+ % for field in grid_columns:
+ "${field}",
+ % endfor
+ ]
+ % elif model_option == "model_name":
+ # TODO: must specify grid columns before the list view will work:
+ # grid_columns = [
+ # "foo",
+ # "bar",
+ # ]
+ % endif
+
+ % if form_fields:
+ form_fields = [
+ % for field in form_fields:
+ "${field}",
+ % endfor
+ ]
+ % elif model_option == "model_name":
+ # TODO: must specify form fields before create/view/edit/delete will work:
+ # form_fields = [
+ # "foo",
+ # "bar",
+ # ]
+ % endif
+
+ % if listable and model_option == "model_name":
+ def get_grid_data(self, columns=None, session=None):
+ data = []
+
+ # TODO: you should return whatever data is needed for the grid.
+ # it is expected to be a list of dicts, with keys corresponding
+ # to grid columns.
+ #
+ # data = [
+ # {"foo": 1, "bar": "abc"},
+ # {"foo": 2, "bar": "def"},
+ # ]
+
+ return data
+ % endif
+
+ % if listable:
+ def configure_grid(self, grid):
+ g = grid
+ super().configure_grid(g)
+
+ # TODO: tweak grid however you need here
+ #
+ # g.set_label("foo", "FOO")
+ # g.set_link("foo")
+ # g.set_renderer("foo", self.render_special_field)
+ % endif
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ ${class_name} = kwargs.get('${class_name}', base['${class_name}'])
+ ${class_name}.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako
index a5ba1f4..8a8ae48 100644
--- a/src/wuttaweb/templates/form.mako
+++ b/src/wuttaweb/templates/form.mako
@@ -49,6 +49,10 @@
<%def name="make_vue_components()">
${parent.make_vue_components()}
+ ${self.make_vue_components_form()}
+%def>
+
+<%def name="make_vue_components_form()">
% if form is not Undefined:
${form.render_vue_finalize()}
% endif
diff --git a/src/wuttaweb/templates/views/master/configure.mako b/src/wuttaweb/templates/views/master/configure.mako
new file mode 100644
index 0000000..b354613
--- /dev/null
+++ b/src/wuttaweb/templates/views/master/configure.mako
@@ -0,0 +1,31 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+
Basics
+
+
+
+
+
+
+
+
+
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/wuttaweb/templates/views/master/create.mako b/src/wuttaweb/templates/views/master/create.mako
new file mode 100644
index 0000000..4de295b
--- /dev/null
+++ b/src/wuttaweb/templates/views/master/create.mako
@@ -0,0 +1,846 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/create.mako" />
+
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+
+%def>
+
+## nb. no need for standard form here
+<%def name="render_vue_template_form()">%def>
+<%def name="make_vue_components_form()">%def>
+
+<%def name="page_content()">
+
+
+
+
+
+ Choose Model
+
+
+ You can choose a particular model, or just enter a name if the
+ view needs to work with something outside the app database.
+
+
+
+
+
+
+ Choose model from app database
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Provide just a model name
+
+
+
+
+
+
+
+
+
+
+
+
+ This name will be used to suggest defaults for other class attributes.
+
+
+
+ It is best to use a "singular Python variable name" style;
+ for instance these are real examples:
+
+
+
+ - app_table
+ - email_setting
+ - master_view
+
+
+
+
+
+
+
+ Model looks good
+
+
+ Skip
+
+
+
+
+
+
+
+
+
+ Enter Details
+
+
+
+
+ {{ modelName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ List
+ Create
+ View
+ Edit
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ Details look good
+
+
+ Skip
+
+
+
+
+
+
+
+ Write View
+
+
+ This will create a new Python module with your view class definition.
+
+
+
+
+
+
+
+ {{ className }}
+
+
+
+ {{ modelClass || modelName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ viewModuleDir }}
+ /
+
+
+
+
+
+ Overwrite file if it exists
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ {{ writingViewFile ? "Working, please wait..." : "Write view class to file" }}
+
+
+ Skip
+
+
+
+
+
+
+ Confirm Route
+
+
+
+
+ Code was generated to file:
+
+
+
+
+ Review and modify code to your liking, then include the new
+ view/module in your view config.
+
+
+
+ Typical view config might be at:
+
+
+
+
+ The view config should contain something like:
+
+
+
def includeme(config):
+
+ # ..various things..
+
+ config.include("{{ viewModulePath }}")
+
+
+ Once you've done all that, the web app must be
+ restarted. This may happen automatically depending on your
+ setup. Test the route status below.
+
+
+
+
+
+
+
+ At this point your new view/route should be present in the app. Test below.
+
+
+
+
+
+
+
+
+
+
+
+
+ check not yet attempted
+
+
+ checking route...
+
+
+ {{ routeChecked }} found in app routes
+
+
+ {{ routeChecked }} not found in app routes
+
+
+
+
+
+
+
+
+
+
+
+ Check for Route
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ Route looks good
+
+
+ Skip
+
+
+
+
+
+
+ Add to Menu
+
+
+ You probably want to add a menu entry for the view, but it's optional.
+
+
+
+ Edit the menu file:
+
+
+
+
+ Add this entry wherever you like:
+
+
+ {
+ "title": "{{ modelTitlePlural }}",
+ "route": "{{ routePrefix }}",
+ "perm": "{{ permissionPrefix }}.list",
+}
+
+
+ Occasionally an entry like this might also be useful:
+
+
+ {
+ "title": "New {{ modelTitle }}",
+ "route": "{{ routePrefix }}.create",
+ "perm": "{{ permissionPrefix }}.create",
+}
+
+
+
+ Back
+
+
+ Menu looks good
+
+
+ Skip
+
+
+
+
+
+
+ Grant Access
+
+
+ You can grant access to each CRUD route, for any role(s) you like.
+
+
+
+
+
+
List {{ modelTitlePlural }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
Create {{ modelTitle }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
View {{ modelTitle }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
Edit {{ modelTitle }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
Delete {{ modelTitle }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
+
+ Back
+
+
+ {{ applyingPermissions ? "Working, please wait..." : "Apply these permissions" }}
+
+
+ Skip
+
+
+
+
+
+
+ Commit Code
+
+
+ Hope you're having a great day.
+
+
+
+ Don't forget to commit code changes to your source repo.
+
+
+
+
+ Back
+
+
+
+
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/wuttaweb/views/tables.py b/src/wuttaweb/views/tables.py
index 273079b..e08a57c 100644
--- a/src/wuttaweb/views/tables.py
+++ b/src/wuttaweb/views/tables.py
@@ -196,7 +196,8 @@ class AppTableView(MasterView): # pylint: disable=abstract-method
"""
By default this returns a list of buttons for each
:class:`~wuttaweb.views.master.MasterView` subclass registered
- in the app for the current table model.
+ in the app for the current table model. Also a button to make
+ a new Master View class, if permissions allow.
See also parent method docs,
:meth:`~wuttaweb.views.master.MasterView.get_xref_buttons()`
@@ -220,6 +221,18 @@ class AppTableView(MasterView): # pylint: disable=abstract-method
)
)
+ # only add "new master view" button if user has perm
+ if self.request.has_perm("master_views.create"):
+ # nb. separate slightly from others
+ buttons.append(HTML.tag("br"))
+ buttons.append(
+ self.make_button(
+ "New Master View",
+ url=self.request.route_url("master_views.create"),
+ icon_left="plus",
+ )
+ )
+
return buttons
def get_row_grid_data(self, obj): # pylint: disable=empty-docstring
@@ -430,6 +443,7 @@ class AppTableView(MasterView): # pylint: disable=abstract-method
cls._apptable_defaults(config)
cls._defaults(config)
+ # pylint: disable=duplicate-code
@classmethod
def _apptable_defaults(cls, config):
route_prefix = cls.get_route_prefix()
@@ -456,6 +470,8 @@ class AppTableView(MasterView): # pylint: disable=abstract-method
permission=f"{permission_prefix}.create",
)
+ # pylint: enable=duplicate-code
+
def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
base = globals()
diff --git a/src/wuttaweb/views/views.py b/src/wuttaweb/views/views.py
index 7b4f7ae..40e2d85 100644
--- a/src/wuttaweb/views/views.py
+++ b/src/wuttaweb/views/views.py
@@ -24,7 +24,19 @@
Views of Views
"""
+import importlib
+import logging
+import os
+import re
+import sys
+
+from mako.lookup import TemplateLookup
+
from wuttaweb.views import MasterView
+from wuttaweb.util import get_model_fields
+
+
+log = logging.getLogger(__name__)
class MasterViewView(MasterView): # pylint: disable=abstract-method
@@ -49,10 +61,11 @@ class MasterViewView(MasterView): # pylint: disable=abstract-method
paginated = True
paginate_on_backend = False
- creatable = False
+ creatable = True
viewable = False # nb. it has a pseudo-view action instead
editable = False
deletable = False
+ configurable = True
labels = {
"model_title_plural": "Title",
@@ -120,6 +133,328 @@ class MasterViewView(MasterView): # pylint: disable=abstract-method
g.set_link("url_prefix")
g.set_searchable("url_prefix")
+ def get_template_context(self, context): # pylint: disable=empty-docstring
+ """ """
+ if self.creating:
+ model = self.app.model
+ session = self.Session()
+
+ # app models
+ app_models = []
+ for name in dir(model):
+ obj = getattr(model, name)
+ if (
+ isinstance(obj, type)
+ and issubclass(obj, model.Base)
+ and obj is not model.Base
+ ):
+ app_models.append(name)
+ context["app_models"] = sorted(app_models)
+
+ # view module location
+ view_locations = self.get_view_module_options()
+ modpath = self.config.get("wuttaweb.master_views.default_module_dir")
+ if modpath not in view_locations:
+ modpath = None
+ if not modpath and len(view_locations) == 1:
+ modpath = view_locations[0]
+ context["view_module_dirs"] = view_locations
+ context["view_module_dir"] = modpath
+
+ # menu handler path
+ web = self.app.get_web_handler()
+ menu = web.get_menu_handler()
+ context["menu_path"] = sys.modules[menu.__class__.__module__].__file__
+
+ # roles for access
+ roles = self.get_roles_for_access(session)
+ context["roles"] = [
+ {"uuid": role.uuid.hex, "name": role.name} for role in roles
+ ]
+ context["listing_roles"] = {role.uuid.hex: False for role in roles}
+ context["creating_roles"] = {role.uuid.hex: False for role in roles}
+ context["viewing_roles"] = {role.uuid.hex: False for role in roles}
+ context["editing_roles"] = {role.uuid.hex: False for role in roles}
+ context["deleting_roles"] = {role.uuid.hex: False for role in roles}
+
+ return context
+
+ def get_roles_for_access( # pylint: disable=missing-function-docstring
+ self, session
+ ):
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ admin = auth.get_role_administrator(session)
+ return (
+ session.query(model.Role)
+ .filter(model.Role.uuid != admin.uuid)
+ .order_by(model.Role.name)
+ .all()
+ )
+
+ def get_view_module_options(self): # pylint: disable=missing-function-docstring
+ modules = set()
+ master_views = self.request.registry.settings.get("wuttaweb_master_views", {})
+ for model_views in master_views.values():
+ for view in model_views:
+ parent = ".".join(view.__module__.split(".")[:-1])
+ modules.add(parent)
+ return sorted(modules)
+
+ def wizard_action(self): # pylint: disable=too-many-return-statements
+ """
+ AJAX view to handle various actions for the "new master view" wizard.
+ """
+ data = self.request.json_body
+ action = data.get("action", "").strip()
+ try:
+ # nb. cannot use match/case statement until python 3.10, but this
+ # project technically still supports python 3.8
+ if action == "suggest_details":
+ return self.suggest_details(data)
+ if action == "write_view_file":
+ return self.write_view_file(data)
+ if action == "check_route":
+ return self.check_route(data)
+ if action == "apply_permissions":
+ return self.apply_permissions(data)
+ if action == "":
+ return {"error": "Must specify the action to perform."}
+ return {"error": f"Unknown action requested: {action}"}
+
+ except Exception as err: # pylint: disable=broad-exception-caught
+ log.exception("new master view wizard action failed: %s", action)
+ return {"error": f"Unexpected error occurred: {err}"}
+
+ def suggest_details( # pylint: disable=missing-function-docstring,too-many-locals
+ self, data
+ ):
+ model = self.app.model
+ model_name = data["model_name"]
+
+ def make_normal(match):
+ return "_" + match.group(1).lower()
+
+ # normal is like: poser_widget
+ normal = re.sub(r"([A-Z])", make_normal, model_name)
+ normal = normal.lstrip("_")
+
+ def make_title(match):
+ return " " + match.group(1).upper()
+
+ # title is like: Poser Widget
+ title = re.sub(r"(?:^|_)([a-z])", make_title, normal)
+ title = title.lstrip(" ")
+
+ model_title = title
+ model_title_plural = title + "s"
+
+ def make_camel(match):
+ return match.group(1).upper()
+
+ # camel is like: PoserWidget
+ camel = re.sub(r"(?:^|_)([a-z])", make_camel, normal)
+
+ # fields are unknown without model class
+ grid_columns = []
+ form_fields = []
+
+ if data["model_option"] == "model_class":
+ model_class = getattr(model, model_name)
+
+ # get model title from model class, if possible
+ if hasattr(model_class, "__wutta_hint__"):
+ model_title = model_class.__wutta_hint__.get("model_title", model_title)
+ model_title_plural = model_class.__wutta_hint__.get(
+ "model_title_plural", model_title + "s"
+ )
+
+ # get columns/fields from model class
+ grid_columns = get_model_fields(self.config, model_class)
+ form_fields = grid_columns
+
+ # plural is like: poser_widgets
+ plural = re.sub(r"(?:^| )([A-Z])", make_normal, model_title_plural)
+ plural = plural.lstrip("_")
+
+ route_prefix = plural
+ url_prefix = "/" + (plural).replace("_", "-")
+
+ return {
+ "class_file_name": plural + ".py",
+ "class_name": camel + "View",
+ "model_name": model_name,
+ "model_title": model_title,
+ "model_title_plural": model_title_plural,
+ "route_prefix": route_prefix,
+ "permission_prefix": route_prefix,
+ "url_prefix": url_prefix,
+ "template_prefix": url_prefix,
+ "grid_columns": "\n".join(grid_columns),
+ "form_fields": "\n".join(form_fields),
+ }
+
+ def write_view_file(self, data): # pylint: disable=missing-function-docstring
+ model = self.app.model
+
+ # sort out the destination file path
+ modpath = data["view_location"]
+ if modpath:
+ mod = importlib.import_module(modpath)
+ file_path = os.path.join(
+ os.path.dirname(mod.__file__), data["view_file_name"]
+ )
+ else:
+ file_path = data["view_file_path"]
+
+ # confirm file is writable
+ if os.path.exists(file_path):
+ if data["overwrite"]:
+ os.remove(file_path)
+ else:
+ return {"error": "File already exists"}
+
+ # guess its dotted module path
+ modname, ext = os.path.splitext( # pylint: disable=unused-variable
+ os.path.basename(file_path)
+ )
+ if modpath:
+ modpath = f"{modpath}.{modname}"
+ else:
+ modpath = f"poser.web.views.{modname}"
+
+ # inject module for class if needed
+ if data["model_option"] == "model_class":
+ model_class = getattr(model, data["model_name"])
+ data["model_module"] = model_class.__module__
+
+ # TODO: make templates dir configurable?
+ view_templates = TemplateLookup(
+ directories=[self.app.resource_path("wuttaweb:code-templates")]
+ )
+
+ # render template to file
+ template = view_templates.get_template("/new-master-view.mako")
+ content = template.render(**data)
+ with open(file_path, "wt", encoding="utf_8") as f:
+ f.write(content)
+
+ return {
+ "view_file_path": file_path,
+ "view_module_path": modpath,
+ "view_config_path": os.path.join(os.path.dirname(file_path), "__init__.py"),
+ }
+
+ def check_route(self, data): # pylint: disable=missing-function-docstring
+ try:
+ url = self.request.route_url(data["route"])
+ path = self.request.route_path(data["route"])
+ except Exception as err: # pylint: disable=broad-exception-caught
+ return {"problem": self.app.render_error(err)}
+
+ return {"url": url, "path": path}
+
+ def apply_permissions( # pylint: disable=missing-function-docstring,too-many-branches
+ self, data
+ ):
+ session = self.Session()
+ auth = self.app.get_auth_handler()
+ roles = self.get_roles_for_access(session)
+ permission_prefix = data["permission_prefix"]
+
+ if "listing_roles" in data:
+ listing = data["listing_roles"]
+ for role in roles:
+ if listing.get(role.uuid.hex):
+ auth.grant_permission(role, f"{permission_prefix}.list")
+ else:
+ auth.revoke_permission(role, f"{permission_prefix}.list")
+
+ if "creating_roles" in data:
+ creating = data["creating_roles"]
+ for role in roles:
+ if creating.get(role.uuid.hex):
+ auth.grant_permission(role, f"{permission_prefix}.create")
+ else:
+ auth.revoke_permission(role, f"{permission_prefix}.create")
+
+ if "viewing_roles" in data:
+ viewing = data["viewing_roles"]
+ for role in roles:
+ if viewing.get(role.uuid.hex):
+ auth.grant_permission(role, f"{permission_prefix}.view")
+ else:
+ auth.revoke_permission(role, f"{permission_prefix}.view")
+
+ if "editing_roles" in data:
+ editing = data["editing_roles"]
+ for role in roles:
+ if editing.get(role.uuid.hex):
+ auth.grant_permission(role, f"{permission_prefix}.edit")
+ else:
+ auth.revoke_permission(role, f"{permission_prefix}.edit")
+
+ if "deleting_roles" in data:
+ deleting = data["deleting_roles"]
+ for role in roles:
+ if deleting.get(role.uuid.hex):
+ auth.grant_permission(role, f"{permission_prefix}.delete")
+ else:
+ auth.revoke_permission(role, f"{permission_prefix}.delete")
+
+ return {}
+
+ def configure_get_simple_settings(self): # pylint: disable=empty-docstring
+ """ """
+ return [
+ {"name": "wuttaweb.master_views.default_module_dir"},
+ ]
+
+ def configure_get_context( # pylint: disable=empty-docstring,arguments-differ
+ self, **kwargs
+ ):
+ """ """
+ context = super().configure_get_context(**kwargs)
+
+ context["view_module_locations"] = self.get_view_module_options()
+
+ return context
+
+ @classmethod
+ def defaults(cls, config): # pylint: disable=empty-docstring
+ """ """
+ cls._masterview_defaults(config)
+ cls._defaults(config)
+
+ # pylint: disable=duplicate-code
+ @classmethod
+ def _masterview_defaults(cls, config):
+ route_prefix = cls.get_route_prefix()
+ permission_prefix = cls.get_permission_prefix()
+ model_title_plural = cls.get_model_title_plural()
+ url_prefix = cls.get_url_prefix()
+
+ # fix permission group
+ config.add_wutta_permission_group(
+ permission_prefix, model_title_plural, overwrite=False
+ )
+
+ # wizard actions
+ config.add_route(
+ f"{route_prefix}.wizard_action",
+ f"{url_prefix}/new/wizard-action",
+ request_method="POST",
+ )
+ config.add_view(
+ cls,
+ attr="wizard_action",
+ route_name=f"{route_prefix}.wizard_action",
+ renderer="json",
+ permission=f"{permission_prefix}.create",
+ )
+
+ # pylint: enable=duplicate-code
+
def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
base = globals()
diff --git a/tests/views/test_tables.py b/tests/views/test_tables.py
index 2c3507c..b970e63 100644
--- a/tests/views/test_tables.py
+++ b/tests/views/test_tables.py
@@ -90,20 +90,33 @@ version_locations = wuttjamaican.db:alembic/versions
def test_get_xref_buttons(self):
self.pyramid_config.add_route("users", "/users/")
+ self.pyramid_config.add_route("master_views.create", "/views/master/new")
model = self.app.model
view = self.make_view()
# nb. must add this first
self.pyramid_config.add_wutta_master_view(UserView)
- # now xref button should work
- table = {"name": "person", "model_class": model.User}
+ # should be just one xref button by default
+ table = {"name": "user", "model_class": model.User}
buttons = view.get_xref_buttons(table)
self.assertEqual(len(buttons), 1)
button = buttons[0]
self.assertIn("Users", button)
self.assertIn("http://example.com/users/", button)
+ # unless we have perm to make new master view
+ with patch.object(self.request, "is_root", new=True):
+ table = {"name": "user", "model_class": model.User}
+ buttons = view.get_xref_buttons(table)
+ self.assertEqual(len(buttons), 3)
+ first, second, third = buttons
+ self.assertIn("Users", first)
+ self.assertIn("http://example.com/users/", first)
+ self.assertEqual(second, "
")
+ self.assertIn("New Master View", third)
+ self.assertIn("http://example.com/views/master/new", third)
+
def test_get_row_grid_data(self):
model = self.app.model
view = self.make_view()
@@ -232,6 +245,7 @@ version_locations = wuttjamaican.db:alembic/versions
result = view.wizard_action()
self.assertIn("error", result)
self.assertEqual(result["error"], "File already exists")
+ self.assertEqual(os.path.getsize(module_path), 0)
# but it can overwrite if requested
with patch.dict(sample, {"overwrite": True}):
diff --git a/tests/views/test_views.py b/tests/views/test_views.py
index 30d52b5..177a8b1 100644
--- a/tests/views/test_views.py
+++ b/tests/views/test_views.py
@@ -1,5 +1,9 @@
# -*- coding: utf-8; -*-
+import os
+import sys
+from unittest.mock import patch
+
from wuttaweb.testing import WebTestCase
from wuttaweb.views import views as mod
from wuttaweb.views.users import UserView
@@ -44,3 +48,323 @@ class TestMasterViewView(WebTestCase):
# nb. must invoke this to exercise the url logic
grid.get_vue_context()
+
+ def test_get_template_context(self):
+ view = self.make_view()
+ with patch.object(view, "Session", return_value=self.session):
+
+ # normal view gets no extra context
+ context = view.get_template_context({})
+ self.assertIsInstance(context, dict)
+ self.assertNotIn("app_models", context)
+ self.assertNotIn("view_module_dirs", context)
+ self.assertNotIn("view_module_dir", context)
+ self.assertNotIn("menu_path", context)
+ self.assertNotIn("roles", context)
+ self.assertNotIn("listing_roles", context)
+ self.assertNotIn("creating_roles", context)
+ self.assertNotIn("viewing_roles", context)
+ self.assertNotIn("editing_roles", context)
+ self.assertNotIn("deleting_roles", context)
+
+ # but 'create' view gets extra context
+ with patch.object(view, "creating", new=True):
+ context = view.get_template_context({})
+ self.assertIsInstance(context, dict)
+ self.assertIn("app_models", context)
+ self.assertIn("view_module_dirs", context)
+ self.assertIn("view_module_dir", context)
+ self.assertIn("menu_path", context)
+ self.assertIn("roles", context)
+ self.assertIn("listing_roles", context)
+ self.assertIn("creating_roles", context)
+ self.assertIn("viewing_roles", context)
+ self.assertIn("editing_roles", context)
+ self.assertIn("deleting_roles", context)
+
+ # try that again but this time make sure there is only
+ # one possibility for view module path, which is auto
+ # selected by default
+ with patch.object(
+ view, "get_view_module_options", return_value=["wuttaweb.views"]
+ ):
+ context = view.get_template_context({})
+ self.assertEqual(context["view_module_dir"], "wuttaweb.views")
+
+ def test_get_view_module_options(self):
+ view = self.make_view()
+
+ # register one master view, which should be reflected in options
+ self.pyramid_config.add_wutta_master_view(UserView)
+ options = view.get_view_module_options()
+ self.assertEqual(len(options), 1)
+ self.assertEqual(options[0], "wuttaweb.views")
+
+ def test_suggest_details(self):
+ view = self.make_view()
+
+ # first test uses model_class
+ sample = {
+ "action": "suggest_details",
+ "model_option": "model_class",
+ "model_name": "Person",
+ }
+ with patch.object(self.request, "json_body", new=sample, create=True):
+ result = view.wizard_action()
+ self.assertEqual(result["class_file_name"], "people.py")
+ self.assertEqual(result["class_name"], "PersonView")
+ self.assertEqual(result["model_name"], "Person")
+ self.assertEqual(result["model_title"], "Person")
+ self.assertEqual(result["model_title_plural"], "People")
+ self.assertEqual(result["route_prefix"], "people")
+ self.assertEqual(result["permission_prefix"], "people")
+ self.assertEqual(result["url_prefix"], "/people")
+ self.assertEqual(result["template_prefix"], "/people")
+ self.assertIn("grid_columns", result)
+ self.assertIsInstance(result["grid_columns"], str)
+ self.assertIn("form_fields", result)
+ self.assertIsInstance(result["form_fields"], str)
+
+ # second test uses model_name
+ sample = {
+ "action": "suggest_details",
+ "model_option": "model_name",
+ "model_name": "acme_brick",
+ }
+ with patch.object(self.request, "json_body", new=sample, create=True):
+ result = view.wizard_action()
+ self.assertEqual(result["class_file_name"], "acme_bricks.py")
+ self.assertEqual(result["class_name"], "AcmeBrickView")
+ self.assertEqual(result["model_name"], "acme_brick")
+ self.assertEqual(result["model_title"], "Acme Brick")
+ self.assertEqual(result["model_title_plural"], "Acme Bricks")
+ self.assertEqual(result["route_prefix"], "acme_bricks")
+ self.assertEqual(result["permission_prefix"], "acme_bricks")
+ self.assertEqual(result["url_prefix"], "/acme-bricks")
+ self.assertEqual(result["template_prefix"], "/acme-bricks")
+ self.assertEqual(result["grid_columns"], "")
+ self.assertEqual(result["form_fields"], "")
+
+ def test_write_view_file(self):
+ view = self.make_view()
+ view_file_path = self.write_file("silly_things.py", "")
+ wutta_file_path = os.path.join(
+ os.path.dirname(sys.modules["wuttaweb.views"].__file__),
+ "silly_things.py",
+ )
+ self.assertEqual(os.path.getsize(view_file_path), 0)
+
+ # first test w/ Upgrade model_class and target file path
+ sample = {
+ "action": "write_view_file",
+ "view_location": None,
+ "view_file_path": view_file_path,
+ "overwrite": False,
+ "class_name": "UpgradeView",
+ "model_option": "model_class",
+ "model_name": "Upgrade",
+ "model_title": "Upgrade",
+ "model_title_plural": "Upgrades",
+ "route_prefix": "upgrades",
+ "permission_prefix": "upgrades",
+ "url_prefix": "/upgrades",
+ "template_prefix": "/upgrades",
+ "listable": True,
+ "creatable": True,
+ "viewable": True,
+ "editable": True,
+ "deletable": True,
+ "grid_columns": ["description", "created_by"],
+ "form_fields": ["description", "created_by"],
+ }
+ with patch.object(self.request, "json_body", new=sample, create=True):
+
+ # does not overwrite by default
+ result = view.wizard_action()
+ self.assertIn("error", result)
+ self.assertEqual(result["error"], "File already exists")
+ self.assertEqual(os.path.getsize(view_file_path), 0)
+
+ # but can overwrite if requested
+ with patch.dict(sample, {"overwrite": True}):
+ result = view.wizard_action()
+ self.assertNotIn("error", result)
+ self.assertGreater(os.path.getsize(view_file_path), 1000)
+ self.assertEqual(result["view_file_path"], view_file_path)
+ self.assertEqual(
+ result["view_module_path"], "poser.web.views.silly_things"
+ )
+
+ # reset file
+ with open(view_file_path, "wb") as f:
+ pass
+ self.assertEqual(os.path.getsize(view_file_path), 0)
+
+ # second test w/ silly_thing model_name and target module path
+ sample = {
+ "action": "write_view_file",
+ "view_location": "wuttaweb.views",
+ "view_file_name": "silly_things.py",
+ "overwrite": False,
+ "class_name": "SillyThingView",
+ "model_option": "model_name",
+ "model_name": "silly_thing",
+ "model_title": "Silly Thing",
+ "model_title_plural": "Silly Things",
+ "route_prefix": "silly_things",
+ "permission_prefix": "silly_things",
+ "url_prefix": "/silly-things",
+ "template_prefix": "/silly-things",
+ "listable": True,
+ "creatable": True,
+ "viewable": True,
+ "editable": True,
+ "deletable": True,
+ "grid_columns": ["id", "name", "description"],
+ "form_fields": ["id", "name", "description"],
+ }
+ with patch.object(self.request, "json_body", new=sample, create=True):
+
+ # file does not yet exist, so will be written
+ result = view.wizard_action()
+ self.assertNotIn("error", result)
+ self.assertEqual(result["view_file_path"], wutta_file_path)
+ self.assertGreater(os.path.getsize(wutta_file_path), 1000)
+ self.assertEqual(os.path.getsize(view_file_path), 0)
+ self.assertEqual(result["view_module_path"], "wuttaweb.views.silly_things")
+
+ # once file exists, will not overwrite by default
+ result = view.wizard_action()
+ self.assertIn("error", result)
+ self.assertEqual(result["error"], "File already exists")
+ self.assertEqual(os.path.getsize(view_file_path), 0)
+
+ # reset file
+ with open(wutta_file_path, "wb") as f:
+ pass
+ self.assertEqual(os.path.getsize(wutta_file_path), 0)
+
+ # can still overrwite explicitly
+ with patch.dict(sample, {"overwrite": True}):
+ result = view.wizard_action()
+ self.assertNotIn("error", result)
+ self.assertEqual(result["view_file_path"], wutta_file_path)
+ self.assertGreater(os.path.getsize(wutta_file_path), 1000)
+ self.assertEqual(os.path.getsize(view_file_path), 0)
+ self.assertEqual(
+ result["view_module_path"], "wuttaweb.views.silly_things"
+ )
+
+ # nb. must be sure to deleta that file!
+ os.remove(wutta_file_path)
+
+ def test_check_route(self):
+ self.pyramid_config.add_route("people", "/people/")
+ view = self.make_view()
+ sample = {
+ "action": "check_route",
+ "route": "people",
+ }
+
+ with patch.object(self.request, "json_body", new=sample, create=True):
+
+ # should get url and path
+ result = view.wizard_action()
+ self.assertEqual(result["url"], "http://example.com/people/")
+ self.assertEqual(result["path"], "/people/")
+ self.assertNotIn("problem", result)
+
+ # unless we check a bad route
+ with patch.dict(sample, {"route": "invalid_nothing_burger"}):
+ result = view.wizard_action()
+ self.assertIn("problem", result)
+ self.assertNotIn("url", result)
+ self.assertNotIn("path", result)
+
+ def test_apply_permissions(self):
+ model = self.app.model
+ auth = self.app.get_auth_handler()
+ admin = auth.get_role_administrator(self.session)
+ known = auth.get_role_authenticated(self.session)
+
+ manager = model.Role(name="Manager")
+ self.session.add(manager)
+
+ worker = model.Role(name="worker")
+ self.session.add(worker)
+
+ fred = model.User(username="fred")
+ fred.roles.append(manager)
+ fred.roles.append(worker)
+ self.session.add(fred)
+
+ self.session.commit()
+
+ self.assertFalse(auth.has_permission(self.session, fred, "people.list"))
+ self.assertFalse(auth.has_permission(self.session, fred, "people.create"))
+ self.assertFalse(auth.has_permission(self.session, fred, "people.view"))
+ self.assertFalse(auth.has_permission(self.session, fred, "people.edit"))
+ self.assertFalse(auth.has_permission(self.session, fred, "people.delete"))
+
+ view = self.make_view()
+ with patch.object(view, "Session", return_value=self.session):
+
+ sample = {
+ "action": "apply_permissions",
+ "permission_prefix": "people",
+ "listing_roles": {known.uuid.hex: True},
+ "creating_roles": {worker.uuid.hex: True},
+ "viewing_roles": {known.uuid.hex: True},
+ "editing_roles": {manager.uuid.hex: True},
+ "deleting_roles": {manager.uuid.hex: True},
+ }
+ with patch.object(self.request, "json_body", new=sample, create=True):
+
+ # nb. empty result is normal
+ result = view.wizard_action()
+ self.assertEqual(result, {})
+
+ self.assertTrue(auth.has_permission(self.session, fred, "people.list"))
+ self.assertTrue(
+ auth.has_permission(self.session, fred, "people.create")
+ )
+ self.assertTrue(auth.has_permission(self.session, fred, "people.view"))
+ self.assertTrue(auth.has_permission(self.session, fred, "people.edit"))
+ self.assertTrue(
+ auth.has_permission(self.session, fred, "people.delete")
+ )
+
+ def test_wizard_action(self):
+ view = self.make_view()
+
+ # missing action
+ with patch.object(self.request, "json_body", create=True, new={}):
+ result = view.wizard_action()
+ self.assertIn("error", result)
+ self.assertEqual(result["error"], "Must specify the action to perform.")
+
+ # unknown action
+ with patch.object(
+ self.request, "json_body", create=True, new={"action": "nothing"}
+ ):
+ result = view.wizard_action()
+ self.assertIn("error", result)
+ self.assertEqual(result["error"], "Unknown action requested: nothing")
+
+ # error invoking action
+ with patch.object(
+ self.request, "json_body", create=True, new={"action": "check_route"}
+ ):
+ with patch.object(view, "check_route", side_effect=RuntimeError("whoa")):
+ result = view.wizard_action()
+ self.assertIn("error", result)
+ self.assertEqual(result["error"], "Unexpected error occurred: whoa")
+
+ def test_configure(self):
+ self.pyramid_config.add_route("home", "/")
+ self.pyramid_config.add_route("login", "/auth/login")
+ self.pyramid_config.add_route("master_views", "/views/master")
+ view = self.make_view()
+
+ # sanity/coverage
+ view.configure()