diff --git a/CHANGELOG.md b/CHANGELOG.md index 5196306..57295c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,23 +5,6 @@ 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 deleted file mode 100644 index ad26770..0000000 --- a/docs/api/wuttaweb.views.views.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttaweb.views.views`` -======================== - -.. automodule:: wuttaweb.views.views - :members: diff --git a/docs/index.rst b/docs/index.rst index c7bed0b..f910cc9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,7 +74,6 @@ 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 ec934fe..5e85363 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.27.0" +version = "0.26.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.1", + "WuttJamaican[db]>=0.28.0", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index d6749b7..75d00f2 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -164,11 +164,6 @@ 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 deleted file mode 100644 index 01fb4b7..0000000 --- a/src/wuttaweb/code-templates/new-master-view.mako +++ /dev/null @@ -1,116 +0,0 @@ -## -*- 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 61c038d..6d33fa6 100644 --- a/src/wuttaweb/conf.py +++ b/src/wuttaweb/conf.py @@ -31,8 +31,7 @@ class WuttaWebConfigExtension(WuttaConfigExtension): """ Config extension for WuttaWeb. - This sets the default plugin used for SQLAlchemy-Continuum, to - :class:`~wuttaweb.db.continuum.WuttaWebContinuumPlugin`. Which is + This sets the default plugin for SQLAlchemy-Continuum. Which is only relevant if Wutta-Continuum is installed and enabled. For more info see :doc:`wutta-continuum:index`. """ @@ -45,45 +44,3 @@ 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 e7ba16d..72c4d79 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, html=True) + return self.app.render_datetime(dt) return super().serialize(field, cstruct, **kw) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 63829a4..76149f6 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, html=True) + return self.app.render_datetime(dt) 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 54bcb10..137d8b1 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -19,9 +19,6 @@ ${app.get_node_title()} - - ${config.appdb_engine.dialect.name} - ${app.get_timezone_name()} @@ -37,14 +34,6 @@
- % 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 8a8ae48..a5ba1f4 100644 --- a/src/wuttaweb/templates/form.mako +++ b/src/wuttaweb/templates/form.mako @@ -49,10 +49,6 @@ <%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 e9a6c0a..cda3623 100644 --- a/src/wuttaweb/templates/tables/app/index.mako +++ b/src/wuttaweb/templates/tables/app/index.mako @@ -20,14 +20,6 @@ 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 deleted file mode 100644 index b354613..0000000 --- a/src/wuttaweb/templates/views/master/configure.mako +++ /dev/null @@ -1,31 +0,0 @@ -## -*- 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 deleted file mode 100644 index 4de295b..0000000 --- a/src/wuttaweb/templates/views/master/create.mako +++ /dev/null @@ -1,846 +0,0 @@ -## -*- 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 deleted file mode 100644 index 7d4cd4d..0000000 --- a/src/wuttaweb/templates/views/master/index.mako +++ /dev/null @@ -1,17 +0,0 @@ -## -*- 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 2d56066..b9986aa 100644 --- a/src/wuttaweb/testing.py +++ b/src/wuttaweb/testing.py @@ -75,9 +75,6 @@ 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 9a839b2..3bc1bf5 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -40,7 +40,6 @@ 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. @@ -78,7 +77,6 @@ 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 2875f7c..f1920ed 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -25,6 +25,7 @@ Base Logic for Master Views """ # pylint: disable=too-many-lines +import datetime import logging import os import threading @@ -1279,6 +1280,7 @@ 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") @@ -1388,7 +1390,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.app.render_datetime(txn.issued_at, html=True), + "changed": self.render_issued_at(txn, None, None), "version_diffs": version_diffs, "show_prev_next": True, "prev_url": prev_url, @@ -1419,6 +1421,14 @@ 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 ############################## @@ -2015,17 +2025,22 @@ class MasterView(View): # pylint: disable=too-many-public-methods fmt = f"${{:0,.{scale}f}}" return fmt.format(value) - 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, - ) + def grid_render_datetime(self, record, key, value, fmt=None): + """ + Custom grid value renderer for + :class:`~python:datetime.datetime` fields. + :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] @@ -3840,9 +3855,6 @@ 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 1f91012..90ff204 100644 --- a/src/wuttaweb/views/reports.py +++ b/src/wuttaweb/views/reports.py @@ -48,7 +48,6 @@ 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 e08a57c..611a97a 100644 --- a/src/wuttaweb/views/tables.py +++ b/src/wuttaweb/views/tables.py @@ -25,7 +25,6 @@ Table Views """ import os -import sys from alembic import command as alembic_command from sqlalchemy_utils import get_mapper @@ -45,12 +44,11 @@ class AppTableView(MasterView): # pylint: disable=abstract-method """ Master view showing all tables in the :term:`app database`. - Default route prefix is ``app_tables``. + Default route prefix is ``tables``. Notable URLs provided by this class: - * ``/tables/app/`` - * ``/tables/app/XXX`` + * ``/tables/`` """ # pylint: disable=duplicate-code @@ -70,8 +68,6 @@ class AppTableView(MasterView): # pylint: disable=abstract-method labels = { "name": "Table Name", - "module_name": "Module", - "module_file": "File", } grid_columns = [ @@ -85,11 +81,7 @@ class AppTableView(MasterView): # pylint: disable=abstract-method form_fields = [ "name", "schema", - "model_name", - "description", # "row_count", - "module_name", - "module_file", ] has_rows = True @@ -109,31 +101,6 @@ 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 ): @@ -142,7 +109,13 @@ class AppTableView(MasterView): # pylint: disable=abstract-method data = [] for table in model.Base.metadata.tables.values(): - data.append(self.normalize_table(table)) + data.append( + { + "name": table.name, + "schema": table.schema or "", + # "row_count": 42, + } + ) return data @@ -170,11 +143,12 @@ class AppTableView(MasterView): # pylint: disable=abstract-method name = self.request.matchdict["name"] table = model.Base.metadata.tables[name] - - # nb. sometimes need the real table reference later when - # dealing with an instance view - data = self.normalize_table(table) - data["table"] = table + data = { + "name": table.name, + "schema": table.schema or "", + # "row_count": 42, + "table": table, + } self.__dict__["_cached_instance"] = data @@ -184,57 +158,6 @@ 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 @@ -443,7 +366,6 @@ 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() @@ -470,8 +392,6 @@ 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 34b2ed8..a4868b2 100644 --- a/src/wuttaweb/views/upgrades.py +++ b/src/wuttaweb/views/upgrades.py @@ -81,6 +81,9 @@ 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 @@ -93,6 +96,9 @@ 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 deleted file mode 100644 index 40e2d85..0000000 --- a/src/wuttaweb/views/views.py +++ /dev/null @@ -1,469 +0,0 @@ -# -*- 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 818c38a..03799bd 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -207,8 +207,7 @@ 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.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() @@ -245,7 +203,6 @@ 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 deleted file mode 100644 index 177a8b1..0000000 --- a/tests/views/test_views.py +++ /dev/null @@ -1,370 +0,0 @@ -# -*- 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()