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 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 name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + 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()} + + + +## nb. no need for standard form here +<%def name="render_vue_template_form()"> +<%def name="make_vue_components_form()"> + +<%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. +

+ +
+ +
+
+

+ Route Status +

+
+
+
+
+
+
+ + 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 name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + 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()