3
0
Fork 0

Compare commits

...

7 commits

Author SHA1 Message Date
c6417b7b8b bump: version 0.26.0 → 0.27.0 2025-12-31 19:18:21 -06:00
a62827d2c3 feat: add wizard for generating new master view code 2025-12-31 18:52:37 -06:00
92d4ce43b1 feat: add basic MasterView to show all registered master views
also serves as anchor for "make new master view" feature, coming soon
2025-12-29 20:06:00 -06:00
21775257a9 fix: show db backend (dialect name) on App Info page 2025-12-29 19:47:11 -06:00
6a8bea462f fix: prevent whitespace wrap for tool panel header 2025-12-29 19:47:11 -06:00
484e1f810a feat: add MasterView registry/discovery mechanism
and show related MasterView link buttons when viewing App Table

also show some more info about model class when viewing table
2025-12-29 19:47:08 -06:00
0619f070c7 fix: render datetimes with tooltip showing time delta from now 2025-12-29 14:20:20 -06:00
28 changed files with 2176 additions and 55 deletions

View file

@ -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

View file

@ -0,0 +1,6 @@
``wuttaweb.views.views``
========================
.. automodule:: wuttaweb.views.views
:members:

View file

@ -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

View file

@ -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",
]

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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):
"""

View file

@ -19,6 +19,9 @@
<b-field horizontal label="Node Title">
<span>${app.get_node_title()}</span>
</b-field>
<b-field horizontal label="DB Backend">
<span>${config.appdb_engine.dialect.name}</span>
</b-field>
<b-field horizontal label="Time Zone">
<span>${app.get_timezone_name()}</span>
</b-field>
@ -34,6 +37,14 @@
<div class="buttons">
% if request.has_perm("master_views.list"):
<wutta-button type="is-primary"
tag="a" href="${url('master_views')}"
icon-left="eye"
label="Master Views"
once />
% endif
% if request.has_perm("app_tables.list"):
<wutta-button type="is-primary"
tag="a" href="${url('app_tables')}"

View file

@ -212,6 +212,10 @@
padding: 1rem;
}
.tool-panels-wrapper .panel-heading {
white-space: nowrap;
}
</style>
</%def>

View file

@ -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

View file

@ -20,6 +20,14 @@
once />
% endif
% if request.has_perm("master_views.list"):
<wutta-button type="is-primary"
tag="a" href="${url('master_views')}"
icon-left="eye"
label="Master Views"
once />
% endif
</div>
${parent.page_content()}
</%def>

View file

@ -0,0 +1,31 @@
## -*- coding: utf-8; -*-
<%inherit file="/configure.mako" />
<%def name="form_content()">
<h3 class="block is-size-3">Basics</h3>
<div class="block" style="padding-left: 2rem; width: 50%;">
<b-field label="Default location for new Master Views">
<b-select name="wuttaweb.master_views.default_module_dir"
v-model="simpleSettings['wuttaweb.master_views.default_module_dir']"
@input="settingsNeedSaved = true">
<option :value="null">(none)</option>
<option v-for="modpath in viewModuleLocations"
:value="modpath">
{{ modpath }}
</option>
</b-select>
</b-field>
</div>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
ThisPageData.viewModuleLocations = ${json.dumps(view_module_locations)|n}
</script>
</%def>

View file

@ -0,0 +1,846 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/create.mako" />
<%def name="extra_styles()">
${parent.extra_styles()}
<style>
## indent prev/next step buttons at page bottom
.buttons.steps-control {
margin: 3rem;
}
## nb. this fixes some field labels within panels. i guess
## the fields are not wide enough due to flexbox?
.label {
white-space: nowrap;
}
</style>
</%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()">
<b-steps v-model="activeStep"
@input="activeStepChanged"
:animated="false"
rounded
:has-navigation="false"
vertical
icon-pack="fas">
<b-step-item step="1"
value="choose-model"
label="Choose Model"
clickable>
<h3 class="is-size-3 block">Choose Model</h3>
<p class="block">
You can choose a particular model, or just enter a name if the
view needs to work with something outside the app database.
</p>
<div style="margin-left: 2rem; width: 70%;">
<div class="field">
<b-radio v-model="modelOption"
native-value="model_class">
Choose model from app database
</b-radio>
</div>
<div v-show="modelOption == 'model_class'"
style="padding: 1rem 0;">
<b-field label="Model" horizontal>
<b-select v-model="modelClass">
<option v-for="name in modelClasses"
:value="name">
{{ name }}
</option>
</b-select>
</b-field>
</div>
<div class="field">
<b-radio v-model="modelOption"
native-value="model_name">
Provide just a model name
</b-radio>
</div>
<div v-show="modelOption == 'model_name'"
style="padding: 1rem 0;">
<b-field label="Model Name" horizontal>
<b-input v-model="modelName" />
</b-field>
<div style="margin: 2rem;">
<p class="block">
This name will be used to suggest defaults for other class attributes.
</p>
<p class="block">
It is best to use a "singular Python variable name" style;
for instance these are real examples:
</p>
<ul class="block is-family-code">
<li>app_table</li>
<li>email_setting</li>
<li>master_view</li>
</ul>
</div>
</div>
</div>
<div class="buttons steps-control">
<b-button type="is-primary"
icon-pack="fas"
icon-left="check"
@click="modelLooksGood()"
:disabled="modelLooksBad">
Model looks good
</b-button>
<b-button icon-pack="fas"
icon-left="arrow-right"
@click="showStep('enter-details')">
Skip
</b-button>
</div>
</b-step-item>
<b-step-item step="2"
value="enter-details"
label="Enter Details"
clickable>
<b-loading v-model="fetchingSuggestions" />
<h3 class="is-size-3 block">Enter Details</h3>
<div class="block" style="width: 70%;">
<b-field :label="modelLabel" horizontal>
<span>{{ modelName }}</span>
</b-field>
<b-field label="Model Title" horizontal>
<b-input v-model="modelTitle"
@input="dirty = true" />
</b-field>
<b-field label="Model Title Plural" horizontal>
<b-input v-model="modelTitlePlural"
@input="dirty = true" />
</b-field>
<b-field label="View Class Name" horizontal>
<b-input v-model="className"
@input="dirty = true" />
</b-field>
<b-field label="Route Prefix" horizontal>
<b-input v-model="routePrefix"
@input="dirty = true" />
</b-field>
<b-field label="Permission Prefix" horizontal>
<b-input v-model="permissionPrefix"
@input="dirty = true" />
</b-field>
<b-field label="URL Prefix" horizontal>
<b-input v-model="urlPrefix"
@input="dirty = true" />
</b-field>
<b-field label="Template Prefix" horizontal>
<b-input v-model="templatePrefix"
@input="dirty = true" />
</b-field>
<b-field label="CRUD Routes" horizontal>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<b-checkbox v-model="listable">List</b-checkbox>
<b-checkbox v-model="creatable">Create</b-checkbox>
<b-checkbox v-model="viewable">View</b-checkbox>
<b-checkbox v-model="editable">Edit</b-checkbox>
<b-checkbox v-model="deletable">Delete</b-checkbox>
</div>
</b-field>
<b-field v-if="listable"
label="Grid Columns"
horizontal>
<b-input type="textarea" v-model="gridColumns" />
</b-field>
<b-field v-if="creatable || viewable || editable || deletable"
label="Form Fields"
horizontal>
<b-input type="textarea" v-model="formFields" />
</b-field>
</div>
<div class="buttons steps-control">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="showStep('choose-model')">
Back
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="check"
@click="showStep('write-view')">
Details look good
</b-button>
<b-button icon-pack="fas"
icon-left="arrow-right"
@click="showStep('write-view')">
Skip
</b-button>
</div>
</b-step-item>
<b-step-item step="3"
value="write-view"
label="Write View"
clickable>
<h3 class="is-size-3 block">Write View</h3>
<p class="block">
This will create a new Python module with your view class definition.
</p>
<div style="margin-left: 2rem;">
<b-field grouped>
<b-field label="View Class Name">
{{ className }}
</b-field>
<b-field label="Model Name">
{{ modelClass || modelName }}
</b-field>
</b-field>
<b-field label="View Location">
<b-select v-model="viewModuleDir">
<option :value="null">(other)</option>
<option v-for="path in viewModuleDirs"
:value="path">
{{ path }}
</option>
</b-select>
</b-field>
<b-field label="Target File">
<div>
<b-field>
<b-input v-if="!viewModuleDir"
v-model="classFilePath" />
<div v-if="viewModuleDir"
style="display: flex; gap: 0.5rem; align-items: center;">
<span>{{ viewModuleDir }}</span>
<span>/</span>
<b-input style="display: inline-block;" v-model="classFileName" />
</div>
</b-field>
<b-field>
<b-checkbox v-model="classFileOverwrite">
Overwrite file if it exists
</b-checkbox>
</b-field>
</div>
</b-field>
</div>
<div class="buttons steps-control">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="showStep('enter-details')">
Back
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="save"
@click="writeViewFile()"
:disabled="writingViewFile">
{{ writingViewFile ? "Working, please wait..." : "Write view class to file" }}
</b-button>
<b-button icon-pack="fas"
icon-left="arrow-right"
@click="showStep('confirm-route')">
Skip
</b-button>
</div>
</b-step-item>
<b-step-item step="4"
value="confirm-route"
label="Confirm Route"
clickable>
<h3 class="is-size-3 block">Confirm Route</h3>
<div v-if="wroteViewFile"
class="block">
<p class="block">
Code was generated to file: &nbsp; &nbsp;
<wutta-copyable-text :text="wroteViewFile"
class="is-family-code" />
</p>
<p class="block">
Review and modify code to your liking, then include the new
view/module in your view config.
</p>
<p class="block">
Typical view config might be at: &nbsp; &nbsp;
<wutta-copyable-text :text="viewConfigPath"
class="is-family-code" />
</p>
<p class="block">
The view config should contain something like:
</p>
<pre class="block is-family-code" style="padding-left: 3rem;">def includeme(config):
# ..various things..
config.include("{{ viewModulePath }}")</pre>
<p class="block">
Once you&apos;ve done all that, the web app must be
restarted. This may happen automatically depending on your
setup. Test the route status below.
</p>
</div>
<div v-if="!wroteViewFile"
class="block">
<p class="block">
At this point your new view/route should be present in the app. Test below.
</p>
</div>
<div class="card block">
<header class="card-header">
<p class="card-header-title">
Route Status
</p>
</header>
<div class="card-content">
<div class="content">
<div class="level">
<div class="level-left">
<div class="level-item">
<span v-if="!routeChecking && !routeChecked && !routeCheckProblem">
check not yet attempted
</span>
<span v-if="routeChecking" class="is-italic">
checking route...
</span>
<span v-if="!routeChecking && routeChecked && !routeCheckProblem"
class="has-text-success has-text-weight-bold">
{{ routeChecked }} found in app routes
</span>
<span v-if="!routeChecking && routeCheckProblem"
class="has-text-danger has-text-weight-bold">
{{ routeChecked }} not found in app routes
</span>
</div>
</div>
<div class="level-right">
<div class="level-item">
<b-field horizontal label="Route">
<b-input v-model="routeCheckRoute" />
</b-field>
</div>
<div class="level-item">
<b-button type="is-primary"
icon-pack="fas"
icon-left="redo"
@click="routeCheck()">
Check for Route
</b-button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="buttons steps-control">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="showStep('write-view')">
Back
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="check"
@click="showStep('add-to-menu')"
:disabled="routeChecking || !routeChecked || routeCheckProblem">
Route looks good
</b-button>
<b-button icon-pack="fas"
icon-left="arrow-right"
@click="showStep('add-to-menu')">
Skip
</b-button>
</div>
</b-step-item>
<b-step-item step="5"
value="add-to-menu"
label="Add to Menu"
clickable>
<h3 class="is-size-3 block">Add to Menu</h3>
<p class="block">
You probably want to add a menu entry for the view, but it&apos;s optional.
</p>
<p class="block">
Edit the menu file: &nbsp; &nbsp;
<wutta-copyable-text :text="menuFilePath"
class="is-family-code" />
</p>
<p class="block">
Add this entry wherever you like:
</p>
<pre class="block is-family-code" style="padding-left: 3rem;">{
"title": "{{ modelTitlePlural }}",
"route": "{{ routePrefix }}",
"perm": "{{ permissionPrefix }}.list",
}</pre>
<p class="block">
Occasionally an entry like this might also be useful:
</p>
<pre class="block is-family-code" style="padding-left: 3rem;">{
"title": "New {{ modelTitle }}",
"route": "{{ routePrefix }}.create",
"perm": "{{ permissionPrefix }}.create",
}</pre>
<div class="buttons steps-control">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="showStep('confirm-route')">
Back
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="check"
@click="showStep('grant-access')">
Menu looks good
</b-button>
<b-button icon-pack="fas"
icon-left="arrow-right"
@click="showStep('grant-access')">
Skip
</b-button>
</div>
</b-step-item>
<b-step-item step="6"
value="grant-access"
label="Grant Access"
clickable>
<h3 class="is-size-3 block">Grant Access</h3>
<p class="block">
You can grant access to each CRUD route, for any role(s) you like.
</p>
<div style="margin-left: 3rem;">
<div v-if="listable" class="block">
<h4 class="is-size-4 block">List {{ modelTitlePlural }}</h4>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<b-checkbox v-for="role in roles"
:key="role.uuid"
v-model="listingRoles[role.uuid]">
{{ role.name }}
</b-checkbox>
</div>
</div>
<div v-if="creatable" class="block">
<h4 class="is-size-4 block">Create {{ modelTitle }}</h4>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<b-checkbox v-for="role in roles"
:key="role.uuid"
v-model="creatingRoles[role.uuid]">
{{ role.name }}
</b-checkbox>
</div>
</div>
<div v-if="viewable" class="block">
<h4 class="is-size-4 block">View {{ modelTitle }}</h4>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<b-checkbox v-for="role in roles"
:key="role.uuid"
v-model="viewingRoles[role.uuid]">
{{ role.name }}
</b-checkbox>
</div>
</div>
<div v-if="editable" class="block">
<h4 class="is-size-4 block">Edit {{ modelTitle }}</h4>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<b-checkbox v-for="role in roles"
:key="role.uuid"
v-model="editingRoles[role.uuid]">
{{ role.name }}
</b-checkbox>
</div>
</div>
<div v-if="deletable" class="block">
<h4 class="is-size-4 block">Delete {{ modelTitle }}</h4>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<b-checkbox v-for="role in roles"
:key="role.uuid"
v-model="deletingRoles[role.uuid]">
{{ role.name }}
</b-checkbox>
</div>
</div>
</div>
<div class="buttons steps-control">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="showStep('add-to-menu')">
Back
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="check"
@click="applyPermissions()"
:disabled="applyingPermissions">
{{ applyingPermissions ? "Working, please wait..." : "Apply these permissions" }}
</b-button>
<b-button icon-pack="fas"
icon-left="arrow-right"
@click="showStep('commit-code')">
Skip
</b-button>
</div>
</b-step-item>
<b-step-item step="7"
value="commit-code"
label="Commit Code"
clickable>
<h3 class="is-size-3 block">Commit Code</h3>
<p class="block">
Hope you&apos;re having a great day.
</p>
<p class="block">
Don&apos;t forget to commit code changes to your source repo.
</p>
<div class="buttons steps-control">
<b-button icon-pack="fas"
icon-left="arrow-left"
@click="showStep('grant-access')">
Back
</b-button>
<wutta-button type="is-primary"
tag="a" :href="viewURL"
icon-left="arrow-right"
:label="`Show my new view: ${'$'}{viewPath}`"
once
:disabled="!viewURL" />
</div>
</b-step-item>
</b-steps>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
// nb. for warning user they may lose changes if leaving page
ThisPageData.dirty = false
ThisPageData.wizardActionURL = "${url(f'{route_prefix}.wizard_action')}"
ThisPageData.activeStep = location.hash ? location.hash.substring(1) : "choose-model"
ThisPage.methods.activeStepChanged = function(value) {
location.hash = value
}
ThisPage.methods.showStep = function(step) {
this.activeStep = step
location.hash = step
}
ThisPageData.modelOption = "model_class"
ThisPageData.modelClasses = ${json.dumps(app_models)|n}
ThisPageData.modelClass = null
ThisPageData.modelName = "poser_widget"
ThisPage.mounted = function() {
const params = new URLSearchParams(location.search)
if (params.has('modelClass')) {
this.modelOption = "model_class"
this.modelClass = params.get('modelClass')
}
}
ThisPage.computed.modelLabel = function() {
if (this.modelOption == "model_class") {
return "Model Class"
}
if (this.modelOption == "model_name") {
return "Model Name"
}
}
ThisPage.computed.modelLooksBad = function() {
if (this.modelOption == "model_class") {
return !this.modelClass
}
if (this.modelOption == "model_name") {
return !(this.modelName || "").trim()
}
}
ThisPage.methods.modelLooksGood = function() {
if (this.modelOption == "model_class") {
// nb. from now on model name == class name
this.modelName = this.modelClass
}
this.fetchingSuggestions = true
const params = {
action: "suggest_details",
model_option: this.modelOption,
model_name: this.modelName,
}
this.wuttaPOST(this.wizardActionURL, params, response => {
this.modelTitle = response.data.model_title
this.modelTitlePlural = response.data.model_title_plural
this.className = response.data.class_name
this.routePrefix = response.data.route_prefix
this.permissionPrefix = response.data.permission_prefix
this.urlPrefix = response.data.url_prefix
this.templatePrefix = response.data.template_prefix
this.listable = true
this.creatable = true
this.viewable = true
this.editable = true
this.deletable = true
this.gridColumns = response.data.grid_columns
this.formFields = response.data.form_fields
this.classFileName = response.data.class_file_name
this.classFilePath = this.classFilePath.replace(/\/[^\/]+$/, "/" + response.data.class_file_name)
this.fetchingSuggestions = false
}, response => {
this.fetchingSuggestions = false
})
this.showStep("enter-details")
}
ThisPageData.fetchingSuggestions = false
ThisPageData.modelTitle = "Poser Widget"
ThisPageData.modelTitlePlural = "Poser Widgets"
ThisPageData.className = "PoserWidgetView"
ThisPageData.routePrefix = "poser_widgets"
ThisPageData.permissionPrefix = "poser_widgets"
ThisPageData.urlPrefix = "/poser-widgets"
ThisPageData.templatePrefix = "/poser-widgets"
ThisPageData.listable = true
ThisPageData.creatable = true
ThisPageData.viewable = true
ThisPageData.editable = true
ThisPageData.deletable = true
ThisPageData.gridColumns = null
ThisPageData.formFields = null
ThisPageData.viewModuleDirs = ${json.dumps(view_module_dirs)|n}
ThisPageData.viewModuleDir = ${json.dumps(view_module_dir)|n}
ThisPageData.classFileName = "poser_widgets.py"
ThisPageData.classFilePath = "??/poser_widgets.py"
ThisPageData.classFileOverwrite = false
ThisPageData.writingViewFile = false
ThisPageData.wroteViewFile = null
ThisPageData.viewConfigPath = null
ThisPageData.viewModulePath = null
ThisPage.methods.writeViewFile = function() {
this.writingViewFile = true
this.routeCheckRoute = this.routePrefix
this.routeChecked = null
this.routeCheckProblem = false
const params = {
action: "write_view_file",
view_location: this.viewModuleDir,
view_file_name: this.classFileName,
view_file_path: this.classFilePath,
overwrite: this.classFileOverwrite,
class_name: this.className,
model_option: this.modelOption,
model_name: this.modelName,
model_title: this.modelTitle,
model_title_plural: this.modelTitlePlural,
route_prefix: this.routePrefix,
permission_prefix: this.permissionPrefix,
url_prefix: this.urlPrefix,
template_prefix: this.templatePrefix,
listable: this.listable,
creatable: this.creatable,
viewable: this.viewable,
editable: this.editable,
deletable: this.deletable,
grid_columns: (this.gridColumns || "").split("\n").filter((col) => col.trim().length > 0),
form_fields: (this.formFields || "").split("\n").filter((fld) => fld.trim().length > 0),
}
this.wuttaPOST(this.wizardActionURL, params, response => {
this.wroteViewFile = response.data.view_file_path
this.viewConfigPath = response.data.view_config_path
this.viewModulePath = response.data.view_module_path
this.writingViewFile = false
this.showStep("confirm-route")
}, response => {
this.writingViewFile = false
})
}
ThisPageData.routeCheckRoute = "poser_widgets"
ThisPageData.routeChecked = null
ThisPageData.routeChecking = false
ThisPageData.routeCheckProblem = false
ThisPage.methods.routeCheck = function() {
this.routeChecking = true
const params = {
action: "check_route",
route: this.routeCheckRoute,
}
this.wuttaPOST(this.wizardActionURL, params, response => {
// nb. we slow the response down just a bit so the user
// can "see" that a *new* import was in fact attempted.
setTimeout(() => {
this.routeChecking = false
this.routeChecked = this.routeCheckRoute
if (response.data.problem) {
this.routeCheckProblem = true
} else {
this.routeCheckProblem = false
this.viewURL = response.data.url
this.viewPath = response.data.path
}
}, 200)
})
}
ThisPageData.menuFilePath = ${json.dumps(menu_path)|n}
ThisPageData.viewURL = null
ThisPageData.viewPath = null
ThisPageData.roles = ${json.dumps(roles)|n}
ThisPageData.listingRoles = ${json.dumps(listing_roles)|n}
ThisPageData.creatingRoles = ${json.dumps(creating_roles)|n}
ThisPageData.viewingRoles = ${json.dumps(viewing_roles)|n}
ThisPageData.editingRoles = ${json.dumps(editing_roles)|n}
ThisPageData.deletingRoles = ${json.dumps(deleting_roles)|n}
ThisPageData.applyingPermissions = false
ThisPage.methods.applyPermissions = function() {
this.applyingPermissions = true
const params = {
action: "apply_permissions",
permission_prefix: this.permissionPrefix,
}
if (this.listable) {
params.listing_roles = this.listingRoles
}
if (this.creatable) {
params.creating_roles = this.creatingRoles
}
if (this.viewable) {
params.viewing_roles = this.viewingRoles
}
if (this.editable) {
params.editing_roles = this.editingRoles
}
if (this.deletable) {
params.deleting_roles = this.deletingRoles
}
this.wuttaPOST(this.wizardActionURL, params, response => {
this.applyingPermissions = false
this.showStep("commit-code")
}, response => {
this.applyingPermissions = false
})
}
// cf. https://stackoverflow.com/a/56551646
ThisPage.methods.beforeWindowUnload = function(e) {
// warn user if navigating away would lose changes
if (this.dirty) {
e.preventDefault()
e.returnValue = ''
}
}
ThisPage.created = function() {
window.addEventListener("beforeunload", this.beforeWindowUnload)
}
</script>
</%def>

View file

@ -0,0 +1,17 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/index.mako" />
<%def name="page_content()">
<div class="buttons">
% if request.has_perm("app_tables.list"):
<wutta-button type="is-primary"
tag="a" href="${url('app_tables')}"
icon-left="table"
label="App Tables"
once />
% endif
</div>
${parent.page_content()}
</%def>

View file

@ -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"
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

469
src/wuttaweb/views/views.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -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('<span title="'))
self.assertIn("2024-12-12 13:49-0500", result)
class TestWuttaMoneyInputWidget(WebTestCase):

View file

@ -1681,8 +1681,8 @@ class TestGrid(WebTestCase):
dt = datetime.datetime(2024, 12, 12, 13, 44)
obj = MagicMock(dt=dt)
result = grid.render_datetime(obj, "dt", str(dt))
self.assertEqual(result, "2024-12-12 05:44-0800")
self.assertNotEqual(result, str(dt))
self.assertTrue(result.startswith('<span title="'))
self.assertIn("2024-12-12 05:44-0800", result)
def test_render_vue_tag(self):
grid = self.make_grid(columns=["foo", "bar"])

61
tests/test_conf.py Normal file
View file

@ -0,0 +1,61 @@
# -*- coding: utf-8; -*-
from wuttjamaican.db.model import User
from wuttjamaican.testing import ConfigTestCase
from wuttaweb import conf as mod
from wuttaweb.testing import WebTestCase
from wuttaweb.views import MasterView
class TestWuttaWebConfigExtension(ConfigTestCase):
def test_basic(self):
# continuum plugin not set yet (b/c config was not extended)
self.assertIsNone(self.config.get("wutta_continuum.wutta_plugin_spec"))
# so let's extend it
extension = mod.WuttaWebConfigExtension()
extension.configure(self.config)
self.assertEqual(
self.config.get("wutta_continuum.wutta_plugin_spec"),
"wuttaweb.db.continuum:WuttaWebContinuumPlugin",
)
class MasterWithClass(MasterView):
model_class = User
class MasterWithName(MasterView):
model_class = "Widget"
class TestAddMasterView(WebTestCase):
def test_master_with_class(self):
model = self.app.model
# nb. due to minimal test bootstrapping, no master views are
# registered by default at this point
self.assertNotIn("wuttaweb_master_views", self.request.registry.settings)
self.pyramid_config.add_wutta_master_view(MasterWithClass)
self.assertIn("wuttaweb_master_views", self.request.registry.settings)
master_views = self.request.registry.settings["wuttaweb_master_views"]
self.assertIn(model.User, master_views)
self.assertEqual(master_views[model.User], [MasterWithClass])
def test_master_with_name(self):
model = self.app.model
# nb. due to minimal test bootstrapping, no master views are
# registered by default at this point
self.assertNotIn("wuttaweb_master_views", self.request.registry.settings)
self.pyramid_config.add_wutta_master_view(MasterWithName)
self.assertIn("wuttaweb_master_views", self.request.registry.settings)
master_views = self.request.registry.settings["wuttaweb_master_views"]
self.assertIn("Widget", master_views)
self.assertEqual(master_views["Widget"], [MasterWithName])

View file

@ -9,6 +9,7 @@ from wuttjamaican.db.conf import check_alembic_current, make_alembic_config
from wuttaweb.testing import WebTestCase
from wuttaweb.views import tables as mod
from wuttaweb.views.users import UserView
class TestAppTableView(WebTestCase):
@ -75,6 +76,47 @@ version_locations = wuttjamaican.db:alembic/versions
table = {"name": "poser_foo"}
self.assertEqual(view.get_instance_title(table), "poser_foo")
def test_configure_form(self):
view = self.make_view()
table = {"name": "user", "description": "Represents a user"}
# no description widget by default
form = view.make_form(model_instance=table, fields=["name", "description"])
self.assertNotIn("description", form.widgets)
# but it gets added when configuring
view.configure_form(form)
self.assertIn("description", form.widgets)
def test_get_xref_buttons(self):
self.pyramid_config.add_route("users", "/users/")
self.pyramid_config.add_route("master_views.create", "/views/master/new")
model = self.app.model
view = self.make_view()
# nb. must add this first
self.pyramid_config.add_wutta_master_view(UserView)
# should be just one xref button by default
table = {"name": "user", "model_class": model.User}
buttons = view.get_xref_buttons(table)
self.assertEqual(len(buttons), 1)
button = buttons[0]
self.assertIn("Users", button)
self.assertIn("http://example.com/users/", button)
# unless we have perm to make new master view
with patch.object(self.request, "is_root", new=True):
table = {"name": "user", "model_class": model.User}
buttons = view.get_xref_buttons(table)
self.assertEqual(len(buttons), 3)
first, second, third = buttons
self.assertIn("Users", first)
self.assertIn("http://example.com/users/", first)
self.assertEqual(second, "<br />")
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}):

370
tests/views/test_views.py Normal file
View file

@ -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()