3
0
Fork 0

feat: add wizard for generating new master view code

This commit is contained in:
Lance Edgar 2025-12-31 18:52:37 -06:00
parent 92d4ce43b1
commit a62827d2c3
8 changed files with 1690 additions and 4 deletions

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

@ -49,6 +49,10 @@
<%def name="make_vue_components()"> <%def name="make_vue_components()">
${parent.make_vue_components()} ${parent.make_vue_components()}
${self.make_vue_components_form()}
</%def>
<%def name="make_vue_components_form()">
% if form is not Undefined: % if form is not Undefined:
${form.render_vue_finalize()} ${form.render_vue_finalize()}
% endif % endif

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

@ -196,7 +196,8 @@ class AppTableView(MasterView): # pylint: disable=abstract-method
""" """
By default this returns a list of buttons for each By default this returns a list of buttons for each
:class:`~wuttaweb.views.master.MasterView` subclass registered :class:`~wuttaweb.views.master.MasterView` subclass registered
in the app for the current table model. in the app for the current table model. Also a button to make
a new Master View class, if permissions allow.
See also parent method docs, See also parent method docs,
:meth:`~wuttaweb.views.master.MasterView.get_xref_buttons()` :meth:`~wuttaweb.views.master.MasterView.get_xref_buttons()`
@ -220,6 +221,18 @@ class AppTableView(MasterView): # pylint: disable=abstract-method
) )
) )
# only add "new master view" button if user has perm
if self.request.has_perm("master_views.create"):
# nb. separate slightly from others
buttons.append(HTML.tag("br"))
buttons.append(
self.make_button(
"New Master View",
url=self.request.route_url("master_views.create"),
icon_left="plus",
)
)
return buttons return buttons
def get_row_grid_data(self, obj): # pylint: disable=empty-docstring def get_row_grid_data(self, obj): # pylint: disable=empty-docstring
@ -430,6 +443,7 @@ class AppTableView(MasterView): # pylint: disable=abstract-method
cls._apptable_defaults(config) cls._apptable_defaults(config)
cls._defaults(config) cls._defaults(config)
# pylint: disable=duplicate-code
@classmethod @classmethod
def _apptable_defaults(cls, config): def _apptable_defaults(cls, config):
route_prefix = cls.get_route_prefix() route_prefix = cls.get_route_prefix()
@ -456,6 +470,8 @@ class AppTableView(MasterView): # pylint: disable=abstract-method
permission=f"{permission_prefix}.create", permission=f"{permission_prefix}.create",
) )
# pylint: enable=duplicate-code
def defaults(config, **kwargs): # pylint: disable=missing-function-docstring def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
base = globals() base = globals()

View file

@ -24,7 +24,19 @@
Views of Views 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.views import MasterView
from wuttaweb.util import get_model_fields
log = logging.getLogger(__name__)
class MasterViewView(MasterView): # pylint: disable=abstract-method class MasterViewView(MasterView): # pylint: disable=abstract-method
@ -49,10 +61,11 @@ class MasterViewView(MasterView): # pylint: disable=abstract-method
paginated = True paginated = True
paginate_on_backend = False paginate_on_backend = False
creatable = False creatable = True
viewable = False # nb. it has a pseudo-view action instead viewable = False # nb. it has a pseudo-view action instead
editable = False editable = False
deletable = False deletable = False
configurable = True
labels = { labels = {
"model_title_plural": "Title", "model_title_plural": "Title",
@ -120,6 +133,328 @@ class MasterViewView(MasterView): # pylint: disable=abstract-method
g.set_link("url_prefix") g.set_link("url_prefix")
g.set_searchable("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 def defaults(config, **kwargs): # pylint: disable=missing-function-docstring
base = globals() base = globals()

View file

@ -90,20 +90,33 @@ version_locations = wuttjamaican.db:alembic/versions
def test_get_xref_buttons(self): def test_get_xref_buttons(self):
self.pyramid_config.add_route("users", "/users/") self.pyramid_config.add_route("users", "/users/")
self.pyramid_config.add_route("master_views.create", "/views/master/new")
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
# nb. must add this first # nb. must add this first
self.pyramid_config.add_wutta_master_view(UserView) self.pyramid_config.add_wutta_master_view(UserView)
# now xref button should work # should be just one xref button by default
table = {"name": "person", "model_class": model.User} table = {"name": "user", "model_class": model.User}
buttons = view.get_xref_buttons(table) buttons = view.get_xref_buttons(table)
self.assertEqual(len(buttons), 1) self.assertEqual(len(buttons), 1)
button = buttons[0] button = buttons[0]
self.assertIn("Users", button) self.assertIn("Users", button)
self.assertIn("http://example.com/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): def test_get_row_grid_data(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
@ -232,6 +245,7 @@ version_locations = wuttjamaican.db:alembic/versions
result = view.wizard_action() result = view.wizard_action()
self.assertIn("error", result) self.assertIn("error", result)
self.assertEqual(result["error"], "File already exists") self.assertEqual(result["error"], "File already exists")
self.assertEqual(os.path.getsize(module_path), 0)
# but it can overwrite if requested # but it can overwrite if requested
with patch.dict(sample, {"overwrite": True}): with patch.dict(sample, {"overwrite": True}):

View file

@ -1,5 +1,9 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import os
import sys
from unittest.mock import patch
from wuttaweb.testing import WebTestCase from wuttaweb.testing import WebTestCase
from wuttaweb.views import views as mod from wuttaweb.views import views as mod
from wuttaweb.views.users import UserView from wuttaweb.views.users import UserView
@ -44,3 +48,323 @@ class TestMasterViewView(WebTestCase):
# nb. must invoke this to exercise the url logic # nb. must invoke this to exercise the url logic
grid.get_vue_context() 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()