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"):
%def>
diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako
index a5ba1f4..8a8ae48 100644
--- a/src/wuttaweb/templates/form.mako
+++ b/src/wuttaweb/templates/form.mako
@@ -49,6 +49,10 @@
<%def name="make_vue_components()">
${parent.make_vue_components()}
+ ${self.make_vue_components_form()}
+%def>
+
+<%def name="make_vue_components_form()">
% if form is not Undefined:
${form.render_vue_finalize()}
% endif
diff --git a/src/wuttaweb/templates/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()}
%def>
diff --git a/src/wuttaweb/templates/views/master/configure.mako b/src/wuttaweb/templates/views/master/configure.mako
new file mode 100644
index 0000000..b354613
--- /dev/null
+++ b/src/wuttaweb/templates/views/master/configure.mako
@@ -0,0 +1,31 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+ Basics
+
+
+
+
+
+
+
+
+
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/wuttaweb/templates/views/master/create.mako b/src/wuttaweb/templates/views/master/create.mako
new file mode 100644
index 0000000..4de295b
--- /dev/null
+++ b/src/wuttaweb/templates/views/master/create.mako
@@ -0,0 +1,846 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/create.mako" />
+
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+
+%def>
+
+## nb. no need for standard form here
+<%def name="render_vue_template_form()">%def>
+<%def name="make_vue_components_form()">%def>
+
+<%def name="page_content()">
+
+
+
+
+
+ Choose Model
+
+
+ You can choose a particular model, or just enter a name if the
+ view needs to work with something outside the app database.
+
+
+
+
+
+
+ Choose model from app database
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Provide just a model name
+
+
+
+
+
+
+
+
+
+
+
+
+ This name will be used to suggest defaults for other class attributes.
+
+
+
+ It is best to use a "singular Python variable name" style;
+ for instance these are real examples:
+
+
+
+ - app_table
+ - email_setting
+ - master_view
+
+
+
+
+
+
+
+ Model looks good
+
+
+ Skip
+
+
+
+
+
+
+
+
+
+ Enter Details
+
+
+
+
+ {{ modelName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ List
+ Create
+ View
+ Edit
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ Details look good
+
+
+ Skip
+
+
+
+
+
+
+
+ Write View
+
+
+ This will create a new Python module with your view class definition.
+
+
+
+
+
+
+
+ {{ className }}
+
+
+
+ {{ modelClass || modelName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ viewModuleDir }}
+ /
+
+
+
+
+
+ Overwrite file if it exists
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ {{ writingViewFile ? "Working, please wait..." : "Write view class to file" }}
+
+
+ Skip
+
+
+
+
+
+
+ Confirm Route
+
+
+
+
+ Code was generated to file:
+
+
+
+
+ Review and modify code to your liking, then include the new
+ view/module in your view config.
+
+
+
+ Typical view config might be at:
+
+
+
+
+ The view config should contain something like:
+
+
+
def includeme(config):
+
+ # ..various things..
+
+ config.include("{{ viewModulePath }}")
+
+
+ Once you've done all that, the web app must be
+ restarted. This may happen automatically depending on your
+ setup. Test the route status below.
+
+
+
+
+
+
+
+ At this point your new view/route should be present in the app. Test below.
+
+
+
+
+
+
+
+
+
+
+
+
+ check not yet attempted
+
+
+ checking route...
+
+
+ {{ routeChecked }} found in app routes
+
+
+ {{ routeChecked }} not found in app routes
+
+
+
+
+
+
+
+
+
+
+
+ Check for Route
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ Route looks good
+
+
+ Skip
+
+
+
+
+
+
+ Add to Menu
+
+
+ You probably want to add a menu entry for the view, but it's optional.
+
+
+
+ Edit the menu file:
+
+
+
+
+ Add this entry wherever you like:
+
+
+ {
+ "title": "{{ modelTitlePlural }}",
+ "route": "{{ routePrefix }}",
+ "perm": "{{ permissionPrefix }}.list",
+}
+
+
+ Occasionally an entry like this might also be useful:
+
+
+ {
+ "title": "New {{ modelTitle }}",
+ "route": "{{ routePrefix }}.create",
+ "perm": "{{ permissionPrefix }}.create",
+}
+
+
+
+ Back
+
+
+ Menu looks good
+
+
+ Skip
+
+
+
+
+
+
+ Grant Access
+
+
+ You can grant access to each CRUD route, for any role(s) you like.
+
+
+
+
+
+
List {{ modelTitlePlural }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
Create {{ modelTitle }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
View {{ modelTitle }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
Edit {{ modelTitle }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
Delete {{ modelTitle }}
+
+
+ {{ role.name }}
+
+
+
+
+
+
+
+ Back
+
+
+ {{ applyingPermissions ? "Working, please wait..." : "Apply these permissions" }}
+
+
+ Skip
+
+
+
+
+
+
+ Commit Code
+
+
+ Hope you're having a great day.
+
+
+
+ Don't forget to commit code changes to your source repo.
+
+
+
+
+ Back
+
+
+
+
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+
+%def>
diff --git a/src/wuttaweb/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()}
+%def>
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()