feat: add wizard for generating new master view code
This commit is contained in:
parent
92d4ce43b1
commit
a62827d2c3
8 changed files with 1690 additions and 4 deletions
116
src/wuttaweb/code-templates/new-master-view.mako
Normal file
116
src/wuttaweb/code-templates/new-master-view.mako
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
31
src/wuttaweb/templates/views/master/configure.mako
Normal file
31
src/wuttaweb/templates/views/master/configure.mako
Normal 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>
|
||||||
846
src/wuttaweb/templates/views/master/create.mako
Normal file
846
src/wuttaweb/templates/views/master/create.mako
Normal 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:
|
||||||
|
<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:
|
||||||
|
<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'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's optional.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
Edit the menu file:
|
||||||
|
<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're having a great day.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="block">
|
||||||
|
Don'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>
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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}):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue