diff --git a/CHANGELOG.md b/CHANGELOG.md index 57295c5..5196306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.27.0 (2025-12-31) + +### Feat + +- add wizard for generating new master view code +- add basic MasterView to show all registered master views +- add MasterView registry/discovery mechanism + +### Fix + +- show db backend (dialect name) on App Info page +- prevent whitespace wrap for tool panel header +- render datetimes with tooltip showing time delta from now +- fallback to default continuum plugin logic, when no request +- flush session when creating new object via MasterView +- fix page title for Alembic Dashboard + ## v0.26.0 (2025-12-28) ### Feat diff --git a/docs/api/wuttaweb.views.views.rst b/docs/api/wuttaweb.views.views.rst new file mode 100644 index 0000000..ad26770 --- /dev/null +++ b/docs/api/wuttaweb.views.views.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.views`` +======================== + +.. automodule:: wuttaweb.views.views + :members: diff --git a/docs/index.rst b/docs/index.rst index f910cc9..c7bed0b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,6 +74,7 @@ the narrative docs are pretty scant. That will eventually change. api/wuttaweb.views.tables api/wuttaweb.views.upgrades api/wuttaweb.views.users + api/wuttaweb.views.views Indices and tables diff --git a/pyproject.toml b/pyproject.toml index 5e85363..ec934fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.26.0" +version = "0.27.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -45,7 +45,7 @@ dependencies = [ "SQLAlchemy-Utils", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.28.0", + "WuttJamaican[db]>=0.28.1", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 75d00f2..d6749b7 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -164,6 +164,11 @@ def make_pyramid_config(settings): ) pyramid_config.add_directive("add_wutta_permission", "wuttaweb.auth.add_permission") + # add some more config magic + pyramid_config.add_directive( + "add_wutta_master_view", "wuttaweb.conf.add_master_view" + ) + return pyramid_config 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/conf.py b/src/wuttaweb/conf.py index 6d33fa6..61c038d 100644 --- a/src/wuttaweb/conf.py +++ b/src/wuttaweb/conf.py @@ -31,7 +31,8 @@ class WuttaWebConfigExtension(WuttaConfigExtension): """ Config extension for WuttaWeb. - This sets the default plugin for SQLAlchemy-Continuum. Which is + This sets the default plugin used for SQLAlchemy-Continuum, to + :class:`~wuttaweb.db.continuum.WuttaWebContinuumPlugin`. Which is only relevant if Wutta-Continuum is installed and enabled. For more info see :doc:`wutta-continuum:index`. """ @@ -44,3 +45,45 @@ class WuttaWebConfigExtension(WuttaConfigExtension): "wutta_continuum.wutta_plugin_spec", "wuttaweb.db.continuum:WuttaWebContinuumPlugin", ) + + +def add_master_view(config, master): + """ + Pyramid directive to add the given ``MasterView`` subclass to the + app's registry. + + This allows the app to dynamically present certain options for + admin features etc. + + This is normally called automatically for all master views, within + the :meth:`~wuttaweb.views.master.MasterView.defaults()` method. + + Should you need to call this yourself, do not call it directly but + instead make a similar call via the Pyramid config object:: + + pyramid_config.add_wutta_master_view(PoserWidgetView) + + :param config: Reference to the Pyramid config object. + + :param master: Reference to a + :class:`~wuttaweb.views.master.MasterView` subclass. + + This function is involved in app startup; once that phase is + complete you can inspect the master views like so:: + + master_views = request.registry.settings["wuttaweb_master_views"] + + # find master views for given model class + user_views = master_views.get(model.User, []) + + # some master views are registered by model name instead (if no class) + email_views = master_views.get("email_setting", []) + """ + key = master.get_model_class() or master.get_model_name() + + def action(): + master_views = config.get_settings().get("wuttaweb_master_views", {}) + master_views.setdefault(key, []).append(master) + config.add_settings({"wuttaweb_master_views": master_views}) + + config.action(None, action) diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 72c4d79..e7ba16d 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -274,7 +274,7 @@ class WuttaDateTimeWidget(DateTimeInputWidget): if not cstruct: return "" dt = datetime.datetime.fromisoformat(cstruct) - return self.app.render_datetime(dt) + return self.app.render_datetime(dt, html=True) return super().serialize(field, cstruct, **kw) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 76149f6..63829a4 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -2041,7 +2041,7 @@ class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-meth grid.set_renderer('foo', 'datetime') """ dt = getattr(obj, key) - return self.app.render_datetime(dt) + return self.app.render_datetime(dt, html=True) def render_enum(self, obj, key, value, enum=None): """ diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index 137d8b1..54bcb10 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -19,6 +19,9 @@ ${app.get_node_title()} + + ${config.appdb_engine.dialect.name} + ${app.get_timezone_name()} @@ -34,6 +37,14 @@
+ % if request.has_perm("master_views.list"): + + % endif + % if request.has_perm("app_tables.list"): 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/tables/app/index.mako b/src/wuttaweb/templates/tables/app/index.mako index cda3623..e9a6c0a 100644 --- a/src/wuttaweb/templates/tables/app/index.mako +++ b/src/wuttaweb/templates/tables/app/index.mako @@ -20,6 +20,14 @@ once /> % endif + % if request.has_perm("master_views.list"): + + % endif +
${parent.page_content()} 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/templates/views/master/index.mako b/src/wuttaweb/templates/views/master/index.mako new file mode 100644 index 0000000..7d4cd4d --- /dev/null +++ b/src/wuttaweb/templates/views/master/index.mako @@ -0,0 +1,17 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="page_content()"> +
+ + % if request.has_perm("app_tables.list"): + + % endif + +
+ ${parent.page_content()} + diff --git a/src/wuttaweb/testing.py b/src/wuttaweb/testing.py index b9986aa..2d56066 100644 --- a/src/wuttaweb/testing.py +++ b/src/wuttaweb/testing.py @@ -75,6 +75,9 @@ class WebTestCase(DataTestCase): self.pyramid_config.add_directive( "add_wutta_permission", "wuttaweb.auth.add_permission" ) + self.pyramid_config.add_directive( + "add_wutta_master_view", "wuttaweb.conf.add_master_view" + ) self.pyramid_config.add_subscriber( "wuttaweb.subscribers.before_render", "pyramid.events.BeforeRender" ) diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index 3bc1bf5..9a839b2 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -40,6 +40,7 @@ That will in turn include the following modules: * :mod:`wuttaweb.views.upgrades` * :mod:`wuttaweb.views.tables` * :mod:`wuttaweb.views.alembic` +* :mod:`wuttaweb.views.views` You can also selectively override some modules while keeping most defaults. @@ -77,6 +78,7 @@ def defaults(config, **kwargs): # pylint: disable=missing-function-docstring config.include(mod("wuttaweb.views.upgrades")) config.include(mod("wuttaweb.views.tables")) config.include(mod("wuttaweb.views.alembic")) + config.include(mod("wuttaweb.views.views")) def includeme(config): # pylint: disable=missing-function-docstring diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index f1920ed..2875f7c 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -25,7 +25,6 @@ Base Logic for Master Views """ # pylint: disable=too-many-lines -import datetime import logging import os import threading @@ -1280,7 +1279,6 @@ class MasterView(View): # pylint: disable=too-many-public-methods # issued_at g.set_label("issued_at", "Changed") - g.set_renderer("issued_at", self.render_issued_at) g.set_link("issued_at") g.set_sort_defaults("issued_at", "desc") @@ -1390,7 +1388,7 @@ class MasterView(View): # pylint: disable=too-many-public-methods "instance_title": instance_title, "instance_url": self.get_action_url("versions", instance), "transaction": txn, - "changed": self.render_issued_at(txn, None, None), + "changed": self.app.render_datetime(txn.issued_at, html=True), "version_diffs": version_diffs, "show_prev_next": True, "prev_url": prev_url, @@ -1421,14 +1419,6 @@ class MasterView(View): # pylint: disable=too-many-public-methods .all() ) - def render_issued_at( # pylint: disable=missing-function-docstring,unused-argument - self, txn, key, value - ): - dt = txn.issued_at - dt = dt.replace(tzinfo=datetime.timezone.utc) - dt = dt.astimezone(None) - return self.app.render_datetime(dt) - ############################## # autocomplete methods ############################## @@ -2025,22 +2015,17 @@ class MasterView(View): # pylint: disable=too-many-public-methods fmt = f"${{:0,.{scale}f}}" return fmt.format(value) - def grid_render_datetime(self, record, key, value, fmt=None): - """ - Custom grid value renderer for - :class:`~python:datetime.datetime` fields. + def grid_render_datetime( # pylint: disable=empty-docstring + self, record, key, value, fmt=None + ): + """ """ + warnings.warn( + "MasterView.grid_render_datetime() is deprecated; " + "please use app.render_datetime() directly instead", + DeprecationWarning, + stacklevel=2, + ) - :param fmt: Optional format string to use instead of the - default: ``'%Y-%m-%d %I:%M:%S %p'`` - - To use this feature for your grid:: - - grid.set_renderer('my_datetime_field', self.grid_render_datetime) - - # you can also override format - grid.set_renderer('my_datetime_field', self.grid_render_datetime, - fmt='%Y-%m-%d %H:%M:%S') - """ # nb. get new value since the one provided will just be a # (json-safe) *string* if the original type was datetime value = record[key] @@ -3855,6 +3840,9 @@ class MasterView(View): # pylint: disable=too-many-public-methods model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() + # add to master view registry + config.add_wutta_master_view(cls) + # permission group config.add_wutta_permission_group( permission_prefix, model_title_plural, overwrite=False diff --git a/src/wuttaweb/views/reports.py b/src/wuttaweb/views/reports.py index 90ff204..1f91012 100644 --- a/src/wuttaweb/views/reports.py +++ b/src/wuttaweb/views/reports.py @@ -48,6 +48,7 @@ class ReportView(MasterView): # pylint: disable=abstract-method * ``/reports/XXX`` """ + model_name = "report" model_title = "Report" model_key = "report_key" filterable = False diff --git a/src/wuttaweb/views/tables.py b/src/wuttaweb/views/tables.py index 611a97a..e08a57c 100644 --- a/src/wuttaweb/views/tables.py +++ b/src/wuttaweb/views/tables.py @@ -25,6 +25,7 @@ Table Views """ import os +import sys from alembic import command as alembic_command from sqlalchemy_utils import get_mapper @@ -44,11 +45,12 @@ class AppTableView(MasterView): # pylint: disable=abstract-method """ Master view showing all tables in the :term:`app database`. - Default route prefix is ``tables``. + Default route prefix is ``app_tables``. Notable URLs provided by this class: - * ``/tables/`` + * ``/tables/app/`` + * ``/tables/app/XXX`` """ # pylint: disable=duplicate-code @@ -68,6 +70,8 @@ class AppTableView(MasterView): # pylint: disable=abstract-method labels = { "name": "Table Name", + "module_name": "Module", + "module_file": "File", } grid_columns = [ @@ -81,7 +85,11 @@ class AppTableView(MasterView): # pylint: disable=abstract-method form_fields = [ "name", "schema", + "model_name", + "description", # "row_count", + "module_name", + "module_file", ] has_rows = True @@ -101,6 +109,31 @@ class AppTableView(MasterView): # pylint: disable=abstract-method "description", ] + def normalize_table(self, table): # pylint: disable=missing-function-docstring + record = { + "name": table.name, + "schema": table.schema or "", + # "row_count": 42, + } + + try: + cls = get_mapper(table).class_ + except ValueError: + pass + else: + record.update( + { + "model_class": cls, + "model_name": cls.__name__, + "model_name_dotted": f"{cls.__module__}.{cls.__name__}", + "description": (cls.__doc__ or "").strip(), + "module_name": cls.__module__, + "module_file": sys.modules[cls.__module__].__file__, + } + ) + + return record + def get_grid_data( # pylint: disable=empty-docstring self, columns=None, session=None ): @@ -109,13 +142,7 @@ class AppTableView(MasterView): # pylint: disable=abstract-method data = [] for table in model.Base.metadata.tables.values(): - data.append( - { - "name": table.name, - "schema": table.schema or "", - # "row_count": 42, - } - ) + data.append(self.normalize_table(table)) return data @@ -143,12 +170,11 @@ class AppTableView(MasterView): # pylint: disable=abstract-method name = self.request.matchdict["name"] table = model.Base.metadata.tables[name] - data = { - "name": table.name, - "schema": table.schema or "", - # "row_count": 42, - "table": table, - } + + # nb. sometimes need the real table reference later when + # dealing with an instance view + data = self.normalize_table(table) + data["table"] = table self.__dict__["_cached_instance"] = data @@ -158,6 +184,57 @@ class AppTableView(MasterView): # pylint: disable=abstract-method """ """ return instance["name"] + def configure_form(self, form): # pylint: disable=empty-docstring + """ """ + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + def get_xref_buttons(self, obj): + """ + 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. 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()` + """ + table = obj + buttons = [] + + # nb. we do not omit any buttons due to lack of permission + # here. all buttons are shown for anyone seeing this page. + # this is for sake of clarity so admin users are aware of what + # is *possible* within the app etc. + master_views = self.request.registry.settings.get("wuttaweb_master_views", {}) + model_views = master_views.get(table["model_class"], []) + for view in model_views: + buttons.append( + self.make_button( + view.get_model_title_plural(), + primary=True, + url=self.request.route_url(view.get_route_prefix()), + icon_left="eye", + ) + ) + + # 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 """ """ table = obj @@ -366,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() @@ -392,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/upgrades.py b/src/wuttaweb/views/upgrades.py index a4868b2..34b2ed8 100644 --- a/src/wuttaweb/views/upgrades.py +++ b/src/wuttaweb/views/upgrades.py @@ -81,9 +81,6 @@ class UpgradeView(MasterView): # pylint: disable=abstract-method # description g.set_link("description") - # created - g.set_renderer("created", self.grid_render_datetime) - # created_by g.set_link("created_by") Creator = orm.aliased(model.User) # pylint: disable=invalid-name @@ -96,9 +93,6 @@ class UpgradeView(MasterView): # pylint: disable=abstract-method # status g.set_renderer("status", self.grid_render_enum, enum=enum.UpgradeStatus) - # executed - g.set_renderer("executed", self.grid_render_datetime) - # executed_by g.set_link("executed_by") Executor = orm.aliased(model.User) # pylint: disable=invalid-name diff --git a/src/wuttaweb/views/views.py b/src/wuttaweb/views/views.py new file mode 100644 index 0000000..40e2d85 --- /dev/null +++ b/src/wuttaweb/views/views.py @@ -0,0 +1,469 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# wuttaweb -- Web App for Wutta Framework +# Copyright © 2024-2025 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +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 + """ + Master view which shows a list of all master views found in the + app registry. + + Route prefix is ``master_views``; notable URLs provided by this + class include: + + * ``/views/master/`` + """ + + model_name = "master_view" + model_title = "Master View" + model_title_plural = "Master Views" + url_prefix = "/views/master" + + filterable = False + sortable = True + sort_on_backend = False + paginated = True + paginate_on_backend = False + + creatable = True + viewable = False # nb. it has a pseudo-view action instead + editable = False + deletable = False + configurable = True + + labels = { + "model_title_plural": "Title", + "url_prefix": "URL Prefix", + } + + grid_columns = [ + "model_title_plural", + "model_name", + "route_prefix", + "url_prefix", + ] + + sort_defaults = "model_title_plural" + + def get_grid_data( # pylint: disable=empty-docstring + self, columns=None, session=None + ): + """ """ + data = [] + + # nb. we do not omit any views due to lack of permission here. + # all views are shown for anyone seeing this page. this is + # for sake of clarity so admin users are aware of what is + # *possible* within the app etc. + master_views = self.request.registry.settings.get("wuttaweb_master_views", {}) + for model_views in master_views.values(): + for view in model_views: + data.append( + { + "model_title_plural": view.get_model_title_plural(), + "model_name": view.get_model_name(), + "route_prefix": view.get_route_prefix(), + "url_prefix": view.get_url_prefix(), + } + ) + + return data + + def configure_grid(self, grid): # pylint: disable=empty-docstring + """ """ + g = grid + super().configure_grid(g) + + # nb. show more views by default + g.pagesize = 50 + + # nb. add "pseudo" View action + def viewurl(view, i): # pylint: disable=unused-argument + return self.request.route_url(view["route_prefix"]) + + g.add_action("view", icon="eye", url=viewurl) + + # model_title_plural + g.set_link("model_title_plural") + g.set_searchable("model_title_plural") + + # model_name + g.set_searchable("model_name") + + # route_prefix + g.set_searchable("route_prefix") + + # url_prefix + 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() + + MasterViewView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name + "MasterViewView", base["MasterViewView"] + ) + MasterViewView.defaults(config) + + +def includeme(config): # pylint: disable=missing-function-docstring + defaults(config) diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index 03799bd..818c38a 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -207,7 +207,8 @@ class TestWuttaDateTimeWidget(WebTestCase): # input data (from schema type) is always "local, zone-aware, isoformat" dt = datetime.datetime(2024, 12, 12, 13, 49, tzinfo=tzlocal) result = widget.serialize(field, dt.isoformat()) - self.assertEqual(result, "2024-12-12 13:49-0500") + self.assertTrue(result.startswith('") + 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() @@ -203,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 new file mode 100644 index 0000000..177a8b1 --- /dev/null +++ b/tests/views/test_views.py @@ -0,0 +1,370 @@ +# -*- 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 + + +class TestMasterViewView(WebTestCase): + + def make_view(self): + return mod.MasterViewView(self.request) + + def test_includeme(self): + self.pyramid_config.include("wuttaweb.views.views") + + def test_get_grid_data(self): + view = self.make_view() + + # empty by default, since nothing registered in test setup + data = view.get_grid_data() + self.assertIsInstance(data, list) + self.assertEqual(len(data), 0) + + # so let's register one and try again + self.pyramid_config.add_wutta_master_view(UserView) + data = view.get_grid_data() + self.assertGreater(len(data), 0) + master = data[0] + self.assertIsInstance(master, dict) + self.assertEqual(master["model_title_plural"], "Users") + self.assertEqual(master["model_name"], "User") + self.assertEqual(master["url_prefix"], "/users") + + def test_configure_grid(self): + self.pyramid_config.add_route("users", "/users/") + self.pyramid_config.add_wutta_master_view(UserView) + view = self.make_view() + + # sanity / coverage check + grid = view.make_grid( + columns=["model_title_plural", "url_prefix"], data=view.get_grid_data() + ) + view.configure_grid(grid) + + # 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()