Compare commits
5 commits
d72a2a15ec
...
180acc509f
Author | SHA1 | Date | |
---|---|---|---|
|
180acc509f | ||
|
30671fcd78 | ||
|
dd1fd8c0ce | ||
|
e3beb9953d | ||
|
5006c97b4b |
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -5,6 +5,29 @@ All notable changes to wuttaweb will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## v0.17.0 (2024-12-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- add basic support for batch execution
|
||||
- add basic support for rows grid for master, batch views
|
||||
- add basic master view class for batches
|
||||
|
||||
### Fix
|
||||
|
||||
- add handling for decimal values and lists, in `make_json_safe()`
|
||||
- fix behavior when editing Roles for a User
|
||||
- add basic views for raw Permissions
|
||||
- improve support for date, datetime fields in grids, forms
|
||||
- add way to set field widgets using pseudo-type
|
||||
- add support for date, datetime form fields
|
||||
- make dropdown widgets as wide as other text fields in main form
|
||||
- add fallback instance title
|
||||
- display "global" errors at top of form, if present
|
||||
- add `make_form()` and `make_grid()` methods on web handler
|
||||
- correct "empty option" behavior for `ObjectRef` schema type
|
||||
- use fanstatic to serve built-in images by default
|
||||
|
||||
## v0.16.2 (2024-12-10)
|
||||
|
||||
### Fix
|
||||
|
|
6
docs/api/wuttaweb.views.batch.rst
Normal file
6
docs/api/wuttaweb.views.batch.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttaweb.views.batch``
|
||||
========================
|
||||
|
||||
.. automodule:: wuttaweb.views.batch
|
||||
:members:
|
|
@ -49,6 +49,7 @@ the narrative docs are pretty scant. That will eventually change.
|
|||
api/wuttaweb.views
|
||||
api/wuttaweb.views.auth
|
||||
api/wuttaweb.views.base
|
||||
api/wuttaweb.views.batch
|
||||
api/wuttaweb.views.common
|
||||
api/wuttaweb.views.essential
|
||||
api/wuttaweb.views.master
|
||||
|
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "WuttaWeb"
|
||||
version = "0.16.2"
|
||||
version = "0.17.0"
|
||||
description = "Web App for Wutta Framework"
|
||||
readme = "README.md"
|
||||
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
|
||||
|
@ -32,6 +32,7 @@ requires-python = ">= 3.8"
|
|||
dependencies = [
|
||||
"ColanderAlchemy",
|
||||
"humanize",
|
||||
"markdown",
|
||||
"paginate",
|
||||
"paginate_sqlalchemy",
|
||||
"pyramid>=2",
|
||||
|
@ -42,7 +43,7 @@ dependencies = [
|
|||
"pyramid_tm",
|
||||
"waitress",
|
||||
"WebHelpers2",
|
||||
"WuttJamaican[db]>=0.17.1",
|
||||
"WuttJamaican[db]>=0.18.0",
|
||||
"zope.sqlalchemy>=1.5",
|
||||
]
|
||||
|
||||
|
|
|
@ -104,7 +104,7 @@ class ObjectRefWidget(SelectWidget):
|
|||
# add url, only if rendering readonly
|
||||
readonly = kw.get('readonly', self.readonly)
|
||||
if readonly:
|
||||
if 'url' not in values and self.url and hasattr(field.schema, 'model_instance'):
|
||||
if 'url' not in values and self.url and getattr(field.schema, 'model_instance', None):
|
||||
values['url'] = self.url(field.schema.model_instance)
|
||||
|
||||
return values
|
||||
|
@ -421,3 +421,22 @@ class PermissionsWidget(WuttaCheckboxChoiceWidget):
|
|||
kw['values'] = values
|
||||
|
||||
return super().serialize(field, cstruct, **kw)
|
||||
|
||||
|
||||
class BatchIdWidget(Widget):
|
||||
"""
|
||||
Widget for use with the
|
||||
:attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.id`
|
||||
field of a :term:`batch` model.
|
||||
|
||||
This widget is "always" read-only and renders the Batch ID as
|
||||
zero-padded 8-char string
|
||||
"""
|
||||
|
||||
def serialize(self, field, cstruct, **kw):
|
||||
""" """
|
||||
if cstruct is colander.null:
|
||||
return colander.null
|
||||
|
||||
batch_id = int(cstruct)
|
||||
return f'{batch_id:08d}'
|
||||
|
|
|
@ -201,6 +201,10 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-panels-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
</%def>
|
||||
|
||||
|
|
124
src/wuttaweb/templates/batch/view.mako
Normal file
124
src/wuttaweb/templates/batch/view.mako
Normal file
|
@ -0,0 +1,124 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/master/view.mako" />
|
||||
|
||||
<%def name="extra_styles()">
|
||||
${parent.extra_styles()}
|
||||
<style>
|
||||
|
||||
## TODO: should we do something like this site-wide?
|
||||
## (so far this is the only place we use markdown)
|
||||
.markdown p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
</%def>
|
||||
|
||||
<%def name="tool_panels()">
|
||||
${parent.tool_panels()}
|
||||
${self.tool_panel_execution()}
|
||||
</%def>
|
||||
|
||||
<%def name="tool_panel_execution()">
|
||||
<wutta-tool-panel heading="Execution">
|
||||
% if batch.executed:
|
||||
<b-notification :closable="false">
|
||||
<p class="block">
|
||||
Batch was executed<br />
|
||||
${app.render_time_ago(batch.executed)}
|
||||
by ${batch.executed_by}.
|
||||
</p>
|
||||
</b-notification>
|
||||
% elif why_not_execute:
|
||||
<b-notification type="is-warning" :closable="false">
|
||||
<p class="block">
|
||||
Batch cannot be executed:
|
||||
</p>
|
||||
<p class="block">
|
||||
${why_not_execute}
|
||||
</p>
|
||||
</b-notification>
|
||||
% else:
|
||||
% if master.has_perm('execute'):
|
||||
<b-notification type="is-success" :closable="false">
|
||||
<p class="block">
|
||||
Batch can be executed.
|
||||
</p>
|
||||
<b-button type="is-primary"
|
||||
@click="executeInit()"
|
||||
icon-pack="fas"
|
||||
icon-left="arrow-circle-right">
|
||||
Execute Batch
|
||||
</b-button>
|
||||
|
||||
<b-modal has-modal-card
|
||||
:active.sync="executeShowDialog">
|
||||
<div class="modal-card">
|
||||
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Execute ${model_title}</p>
|
||||
</header>
|
||||
|
||||
## TODO: forcing black text b/c of b-notification
|
||||
## wrapping button, which has white text
|
||||
<section class="modal-card-body has-text-black">
|
||||
<p class="block has-text-weight-bold">
|
||||
What will happen when this batch is executed?
|
||||
</p>
|
||||
<div class="markdown">
|
||||
${execution_described|n}
|
||||
</div>
|
||||
${h.form(master.get_action_url('execute', batch), ref='executeForm')}
|
||||
${h.csrf_token(request)}
|
||||
${h.end_form()}
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot">
|
||||
<b-button @click="executeShowDialog = false">
|
||||
Cancel
|
||||
</b-button>
|
||||
<b-button type="is-primary"
|
||||
@click="executeSubmit()"
|
||||
icon-pack="fas"
|
||||
icon-left="arrow-circle-right"
|
||||
:disabled="executeSubmitting">
|
||||
{{ executeSubmitting ? "Working, please wait..." : "Execute Batch" }}
|
||||
</b-button>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</b-modal>
|
||||
</b-notification>
|
||||
|
||||
% else:
|
||||
<b-notification type="is-warning" :closable="false">
|
||||
<p class="block">
|
||||
Batch may be executed,<br />
|
||||
but you do not have permission.
|
||||
</p>
|
||||
</b-notification>
|
||||
% endif
|
||||
% endif
|
||||
</wutta-tool-panel>
|
||||
</%def>
|
||||
|
||||
<%def name="modify_vue_vars()">
|
||||
${parent.modify_vue_vars()}
|
||||
% if not batch.executed and not why_not_execute and master.has_perm('execute'):
|
||||
<script>
|
||||
|
||||
ThisPageData.executeShowDialog = false
|
||||
ThisPageData.executeSubmitting = false
|
||||
|
||||
ThisPage.methods.executeInit = function() {
|
||||
this.executeShowDialog = true
|
||||
}
|
||||
|
||||
ThisPage.methods.executeSubmit = function() {
|
||||
this.executeSubmitting = true
|
||||
this.$refs.executeForm.submit()
|
||||
}
|
||||
|
||||
</script>
|
||||
% endif
|
||||
</%def>
|
|
@ -1,6 +1,19 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/page.mako" />
|
||||
|
||||
<%def name="page_layout()">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
|
||||
## main form
|
||||
<div style="flex-grow: 1;">
|
||||
${self.page_content()}
|
||||
</div>
|
||||
|
||||
## tool panels
|
||||
${self.tool_panels_wrapper()}
|
||||
</div>
|
||||
</%def>
|
||||
|
||||
<%def name="page_content()">
|
||||
% if form is not Undefined:
|
||||
<div class="wutta-form-wrapper">
|
||||
|
@ -9,6 +22,14 @@
|
|||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="tool_panels_wrapper()">
|
||||
<div class="tool-panels-wrapper">
|
||||
${self.tool_panels()}
|
||||
</div>
|
||||
</%def>
|
||||
|
||||
<%def name="tool_panels()"></%def>
|
||||
|
||||
<%def name="render_vue_template_form()">
|
||||
% if form is not Undefined:
|
||||
${form.render_vue_template()}
|
||||
|
|
|
@ -5,5 +5,48 @@
|
|||
|
||||
<%def name="content_title()">${instance_title}</%def>
|
||||
|
||||
<%def name="page_layout()">
|
||||
|
||||
${parent.body()}
|
||||
% if master.has_rows:
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
|
||||
## main form
|
||||
<div style="flex-grow: 1;">
|
||||
${self.page_content()}
|
||||
</div>
|
||||
|
||||
## tool panels
|
||||
${self.tool_panels_wrapper()}
|
||||
|
||||
</div>
|
||||
|
||||
## rows grid
|
||||
<br />
|
||||
<h4 class="block is-size-4">${master.get_rows_title() or ''}</h4>
|
||||
${rows_grid.render_vue_tag()}
|
||||
</div>
|
||||
|
||||
% else:
|
||||
## no rows, just main form + tool panels
|
||||
${parent.page_layout()}
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="render_vue_templates()">
|
||||
${parent.render_vue_templates()}
|
||||
% if master.has_rows:
|
||||
${self.render_vue_template_rows_grid()}
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="render_vue_template_rows_grid()">
|
||||
${rows_grid.render_vue_template()}
|
||||
</%def>
|
||||
|
||||
<%def name="make_vue_components()">
|
||||
${parent.make_vue_components()}
|
||||
% if master.has_rows:
|
||||
${rows_grid.render_vue_finalize()}
|
||||
% endif
|
||||
</%def>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/base.mako" />
|
||||
|
||||
<%def name="page_layout()">
|
||||
${self.page_content()}
|
||||
</%def>
|
||||
|
||||
<%def name="page_content()"></%def>
|
||||
|
||||
<%def name="render_vue_templates()">
|
||||
|
@ -12,7 +16,7 @@
|
|||
<%def name="render_vue_template_this_page()">
|
||||
<script type="text/x-template" id="this-page-template">
|
||||
<div class="wutta-page-content-wrapper">
|
||||
${self.page_content()}
|
||||
${self.page_layout()}
|
||||
</div>
|
||||
</script>
|
||||
</%def>
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
${self.make_wutta_timepicker_component()}
|
||||
${self.make_wutta_filter_component()}
|
||||
${self.make_wutta_filter_value_component()}
|
||||
${self.make_wutta_tool_panel_component()}
|
||||
</%def>
|
||||
|
||||
<%def name="make_wutta_request_mixin()">
|
||||
|
@ -477,3 +478,28 @@
|
|||
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
<%def name="make_wutta_tool_panel_component()">
|
||||
<script type="text/x-template" id="wutta-tool-panel-template">
|
||||
<nav class="panel tool-panel">
|
||||
<p class="panel-heading">{{ heading }}</p>
|
||||
<div class="panel-block">
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</script>
|
||||
<script>
|
||||
|
||||
const WuttaToolPanel = {
|
||||
template: '#wutta-tool-panel-template',
|
||||
props: {
|
||||
heading: String,
|
||||
},
|
||||
}
|
||||
|
||||
Vue.component('wutta-tool-panel', WuttaToolPanel)
|
||||
|
||||
</script>
|
||||
</%def>
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
Web Utilities
|
||||
"""
|
||||
|
||||
import decimal
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
|
@ -525,17 +526,28 @@ def make_json_safe(value, key=None, warn=True):
|
|||
if value is colander.null:
|
||||
return None
|
||||
|
||||
# recursively convert dict
|
||||
if isinstance(value, dict):
|
||||
elif isinstance(value, dict):
|
||||
# recursively convert dict
|
||||
parent = dict(value)
|
||||
for key, value in parent.items():
|
||||
parent[key] = make_json_safe(value, key=key, warn=warn)
|
||||
value = parent
|
||||
|
||||
# convert UUID to str
|
||||
if isinstance(value, _uuid.UUID):
|
||||
elif isinstance(value, list):
|
||||
# recursively convert list
|
||||
parent = list(value)
|
||||
for i, value in enumerate(parent):
|
||||
parent[i] = make_json_safe(value, key=key, warn=warn)
|
||||
value = parent
|
||||
|
||||
elif isinstance(value, _uuid.UUID):
|
||||
# convert UUID to str
|
||||
value = value.hex
|
||||
|
||||
elif isinstance(value, decimal.Decimal):
|
||||
# convert decimal to float
|
||||
value = float(value)
|
||||
|
||||
# ensure JSON-compatibility, warn if problems
|
||||
try:
|
||||
json.dumps(value)
|
||||
|
|
404
src/wuttaweb/views/batch.py
Normal file
404
src/wuttaweb/views/batch.py
Normal file
|
@ -0,0 +1,404 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# wuttaweb -- Web App for Wutta Framework
|
||||
# Copyright © 2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Wutta Framework.
|
||||
#
|
||||
# Wutta Framework is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by the Free
|
||||
# Software Foundation, either version 3 of the License, or (at your option) any
|
||||
# later version.
|
||||
#
|
||||
# Wutta Framework is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
# more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Base logic for Batch Master views
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import markdown
|
||||
from sqlalchemy import orm
|
||||
|
||||
from wuttaweb.views import MasterView
|
||||
from wuttaweb.forms.schema import UserRef
|
||||
from wuttaweb.forms.widgets import BatchIdWidget
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BatchMasterView(MasterView):
|
||||
"""
|
||||
Base class for all "batch master" views.
|
||||
|
||||
.. attribute:: batch_handler
|
||||
|
||||
Reference to the :term:`batch handler` for use with the view.
|
||||
|
||||
This is set when the view is first created, using return value
|
||||
from :meth:`get_batch_handler()`.
|
||||
"""
|
||||
|
||||
labels = {
|
||||
'id': "Batch ID",
|
||||
'status_code': "Batch Status",
|
||||
}
|
||||
|
||||
sort_defaults = ('id', 'desc')
|
||||
|
||||
has_rows = True
|
||||
rows_title = "Batch Rows"
|
||||
rows_sort_defaults = 'sequence'
|
||||
|
||||
def __init__(self, request, context=None):
|
||||
super().__init__(request, context=context)
|
||||
self.batch_handler = self.get_batch_handler()
|
||||
|
||||
def get_batch_handler(self):
|
||||
"""
|
||||
Must return the :term:`batch handler` for use with this view.
|
||||
|
||||
There is no default logic; subclass must override.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_fallback_templates(self, template):
|
||||
"""
|
||||
We override the default logic here, to prefer "batch"
|
||||
templates over the "master" templates.
|
||||
|
||||
So for instance the "view batch" page will by default use the
|
||||
``/batch/view.mako`` template - which does inherit from
|
||||
``/master/view.mako`` but adds extra features specific to
|
||||
batches.
|
||||
"""
|
||||
templates = super().get_fallback_templates(template)
|
||||
templates.insert(0, f'/batch/{template}.mako')
|
||||
return templates
|
||||
|
||||
def render_to_response(self, template, context):
|
||||
"""
|
||||
We override the default logic here, to inject batch-related
|
||||
context for the
|
||||
:meth:`~wuttaweb.views.master.MasterView.view()` template
|
||||
specifically. These values are used in the template file,
|
||||
``/batch/view.mako``.
|
||||
|
||||
* ``batch`` - reference to the current :term:`batch`
|
||||
* ``batch_handler`` reference to :attr:`batch_handler`
|
||||
* ``why_not_execute`` - text of reason (if any) not to execute batch
|
||||
* ``execution_described`` - HTML (rendered from markdown) describing batch execution
|
||||
"""
|
||||
if template == 'view':
|
||||
batch = context['instance']
|
||||
context['batch'] = batch
|
||||
context['batch_handler'] = self.batch_handler
|
||||
context['why_not_execute'] = self.batch_handler.why_not_execute(batch)
|
||||
|
||||
description = (self.batch_handler.describe_execution(batch)
|
||||
or "Handler does not say! Your guess is as good as mine.")
|
||||
context['execution_described'] = markdown.markdown(
|
||||
description, extensions=['fenced_code', 'codehilite'])
|
||||
|
||||
return super().render_to_response(template, context)
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
model = self.app.model
|
||||
|
||||
# created_by
|
||||
CreatedBy = orm.aliased(model.User)
|
||||
g.set_joiner('created_by',
|
||||
lambda q: q.join(CreatedBy,
|
||||
CreatedBy.uuid == self.model_class.created_by_uuid))
|
||||
g.set_sorter('created_by', CreatedBy.username)
|
||||
# g.set_filter('created_by', CreatedBy.username, label="Created By Username")
|
||||
|
||||
# id
|
||||
g.set_renderer('id', self.render_batch_id)
|
||||
g.set_link('id')
|
||||
|
||||
# description
|
||||
g.set_link('description')
|
||||
|
||||
def render_batch_id(self, batch, key, value):
|
||||
""" """
|
||||
if value:
|
||||
batch_id = int(value)
|
||||
return f'{batch_id:08d}'
|
||||
|
||||
def get_instance_title(self, batch):
|
||||
""" """
|
||||
if batch.description:
|
||||
return f"{batch.id_str} {batch.description}"
|
||||
return batch.id_str
|
||||
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
batch = f.model_instance
|
||||
|
||||
# id
|
||||
if self.creating:
|
||||
f.remove('id')
|
||||
else:
|
||||
f.set_readonly('id')
|
||||
f.set_widget('id', BatchIdWidget())
|
||||
|
||||
# notes
|
||||
f.set_widget('notes', 'notes')
|
||||
|
||||
# rows
|
||||
f.remove('rows')
|
||||
if self.creating:
|
||||
f.remove('row_count')
|
||||
else:
|
||||
f.set_readonly('row_count')
|
||||
|
||||
# status
|
||||
f.remove('status_text')
|
||||
if self.creating:
|
||||
f.remove('status_code')
|
||||
else:
|
||||
f.set_readonly('status_code')
|
||||
|
||||
# created
|
||||
if self.creating:
|
||||
f.remove('created')
|
||||
else:
|
||||
f.set_readonly('created')
|
||||
|
||||
# created_by
|
||||
f.remove('created_by_uuid')
|
||||
if self.creating:
|
||||
f.remove('created_by')
|
||||
else:
|
||||
f.set_node('created_by', UserRef(self.request))
|
||||
f.set_readonly('created_by')
|
||||
|
||||
# executed
|
||||
if self.creating or not batch.executed:
|
||||
f.remove('executed')
|
||||
else:
|
||||
f.set_readonly('executed')
|
||||
|
||||
# executed_by
|
||||
f.remove('executed_by_uuid')
|
||||
if self.creating or not batch.executed:
|
||||
f.remove('executed_by')
|
||||
else:
|
||||
f.set_node('executed_by', UserRef(self.request))
|
||||
f.set_readonly('executed_by')
|
||||
|
||||
def objectify(self, form):
|
||||
"""
|
||||
We override the default logic here, to invoke
|
||||
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.make_batch()`
|
||||
on the batch handler - when creating. Parent/default logic is
|
||||
used when updating.
|
||||
"""
|
||||
if self.creating:
|
||||
|
||||
# first get the "normal" objectified batch. this will have
|
||||
# all attributes set correctly per the form data, but will
|
||||
# not yet belong to the db session. we ultimately discard it.
|
||||
schema = form.get_schema()
|
||||
batch = schema.objectify(form.validated, context=form.model_instance)
|
||||
|
||||
# then we collect attributes from the new batch
|
||||
kwargs = dict([(key, getattr(batch, key))
|
||||
for key in form.validated
|
||||
if hasattr(batch, key)])
|
||||
|
||||
# and set attribute for user creating the batch
|
||||
kwargs['created_by'] = self.request.user
|
||||
|
||||
# finally let batch handler make the "real" batch
|
||||
return self.batch_handler.make_batch(self.Session(), **kwargs)
|
||||
|
||||
# when not creating, normal logic is fine
|
||||
return super().objectify(form)
|
||||
|
||||
def redirect_after_create(self, batch):
|
||||
"""
|
||||
If the new batch requires initial population, we launch a
|
||||
thread for that and show the "progress" page.
|
||||
|
||||
Otherwise this will do the normal thing of redirecting to the
|
||||
"view" page for the new batch.
|
||||
"""
|
||||
# just view batch if should not populate
|
||||
if not self.batch_handler.should_populate(batch):
|
||||
return self.redirect(self.get_action_url('view', batch))
|
||||
|
||||
# setup thread to populate batch
|
||||
route_prefix = self.get_route_prefix()
|
||||
key = f'{route_prefix}.populate'
|
||||
progress = self.make_progress(key, success_url=self.get_action_url('view', batch))
|
||||
thread = threading.Thread(target=self.populate_thread,
|
||||
args=(batch.uuid,),
|
||||
kwargs=dict(progress=progress))
|
||||
|
||||
# start thread and show progress page
|
||||
thread.start()
|
||||
return self.render_progress(progress)
|
||||
|
||||
def delete_instance(self, batch):
|
||||
"""
|
||||
Delete the given batch instance.
|
||||
|
||||
This calls
|
||||
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_delete()`
|
||||
on the :attr:`batch_handler`.
|
||||
"""
|
||||
self.batch_handler.do_delete(batch, self.request.user)
|
||||
|
||||
##############################
|
||||
# populate methods
|
||||
##############################
|
||||
|
||||
def populate_thread(self, batch_uuid, progress=None):
|
||||
"""
|
||||
Thread target for populating new object with progress indicator.
|
||||
|
||||
When a new batch is created, and the batch handler says it
|
||||
should also be populated, then this thread is launched to do
|
||||
so outside of the main request/response cycle. Progress bar
|
||||
is then shown to the user until it completes.
|
||||
|
||||
This method mostly just calls
|
||||
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_populate()`
|
||||
on the :term:`batch handler`.
|
||||
"""
|
||||
# nb. must use our own session in separate thread
|
||||
session = self.app.make_session()
|
||||
|
||||
# nb. main web request which created the batch, must complete
|
||||
# before that session is committed. until that happens we
|
||||
# will not be able to see the new batch. hence this loop,
|
||||
# where we wait for the batch to appear.
|
||||
batch = None
|
||||
tries = 0
|
||||
while not batch:
|
||||
batch = session.get(self.model_class, batch_uuid)
|
||||
tries += 1
|
||||
if tries > 10:
|
||||
raise RuntimeError("can't find the batch")
|
||||
time.sleep(0.1)
|
||||
|
||||
try:
|
||||
# populate the batch
|
||||
self.batch_handler.do_populate(batch, progress=progress)
|
||||
session.flush()
|
||||
|
||||
except Exception as error:
|
||||
session.rollback()
|
||||
log.warning("failed to populate %s: %s",
|
||||
self.get_model_title(), batch,
|
||||
exc_info=True)
|
||||
if progress:
|
||||
progress.handle_error(error)
|
||||
|
||||
else:
|
||||
session.commit()
|
||||
if progress:
|
||||
progress.handle_success()
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
##############################
|
||||
# execute methods
|
||||
##############################
|
||||
|
||||
def execute(self):
|
||||
"""
|
||||
View to execute the current :term:`batch`.
|
||||
|
||||
Eventually this should show a progress indicator etc., but for
|
||||
now it simply calls
|
||||
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
|
||||
on the :attr:`batch_handler` and waits for it to complete,
|
||||
then redirects user back to the "view batch" page.
|
||||
"""
|
||||
self.executing = True
|
||||
batch = self.get_instance()
|
||||
|
||||
try:
|
||||
self.batch_handler.do_execute(batch, self.request.user)
|
||||
except Exception as error:
|
||||
log.warning("failed to execute batch: %s", batch, exc_info=True)
|
||||
self.request.session.flash(f"Execution failed!: {error}", 'error')
|
||||
|
||||
return self.redirect(self.get_action_url('view', batch))
|
||||
|
||||
##############################
|
||||
# row methods
|
||||
##############################
|
||||
|
||||
@classmethod
|
||||
def get_row_model_class(cls):
|
||||
""" """
|
||||
if hasattr(cls, 'row_model_class'):
|
||||
return cls.row_model_class
|
||||
|
||||
Batch = cls.get_model_class()
|
||||
return Batch.__row_class__
|
||||
|
||||
def get_row_grid_data(self, batch):
|
||||
"""
|
||||
Returns the base query for the batch
|
||||
:attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.rows`
|
||||
data.
|
||||
"""
|
||||
BatchRow = self.get_row_model_class()
|
||||
query = self.Session.query(BatchRow)\
|
||||
.filter(BatchRow.batch == batch)
|
||||
return query
|
||||
|
||||
def configure_row_grid(self, g):
|
||||
""" """
|
||||
super().configure_row_grid(g)
|
||||
|
||||
g.set_label('sequence', "Seq.", column_only=True)
|
||||
|
||||
##############################
|
||||
# configuration
|
||||
##############################
|
||||
|
||||
@classmethod
|
||||
def defaults(cls, config):
|
||||
""" """
|
||||
cls._defaults(config)
|
||||
cls._batch_defaults(config)
|
||||
|
||||
@classmethod
|
||||
def _batch_defaults(cls, config):
|
||||
route_prefix = cls.get_route_prefix()
|
||||
permission_prefix = cls.get_permission_prefix()
|
||||
model_title = cls.get_model_title()
|
||||
instance_url_prefix = cls.get_instance_url_prefix()
|
||||
|
||||
# execute
|
||||
config.add_route(f'{route_prefix}.execute',
|
||||
f'{instance_url_prefix}/execute',
|
||||
request_method='POST')
|
||||
config.add_view(cls, attr='execute',
|
||||
route_name=f'{route_prefix}.execute',
|
||||
permission=f'{permission_prefix}.execute')
|
||||
config.add_wutta_permission(permission_prefix,
|
||||
f'{permission_prefix}.execute',
|
||||
f"Execute {model_title}")
|
|
@ -73,12 +73,12 @@ class MasterView(View):
|
|||
|
||||
.. attribute:: model_class
|
||||
|
||||
Optional reference to a data model class. While not strictly
|
||||
required, most views will set this to a SQLAlchemy mapped
|
||||
class,
|
||||
Optional reference to a :term:`data model` class. While not
|
||||
strictly required, most views will set this to a SQLAlchemy
|
||||
mapped class,
|
||||
e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
|
||||
|
||||
Code should not access this directly but instead call
|
||||
The base logic should not access this directly but instead call
|
||||
:meth:`get_model_class()`.
|
||||
|
||||
.. attribute:: model_name
|
||||
|
@ -340,6 +340,38 @@ class MasterView(View):
|
|||
Boolean indicating whether the master view supports
|
||||
"configuring" - i.e. it should have a :meth:`configure()` view.
|
||||
Default value is ``False``.
|
||||
|
||||
**ROW FEATURES**
|
||||
|
||||
.. attribute:: has_rows
|
||||
|
||||
Whether the model has "rows" which should also be displayed
|
||||
when viewing model records.
|
||||
|
||||
This the "master switch" for all row features; if this is turned
|
||||
on then many other things kick in.
|
||||
|
||||
See also :attr:`row_model_class`.
|
||||
|
||||
.. attribute:: row_model_class
|
||||
|
||||
Reference to a :term:`data model` class for the rows.
|
||||
|
||||
The base logic should not access this directly but instead call
|
||||
:meth:`get_row_model_class()`.
|
||||
|
||||
.. attribute:: rows_title
|
||||
|
||||
Display title for the rows grid.
|
||||
|
||||
The base logic should not access this directly but instead call
|
||||
:meth:`get_rows_title()`.
|
||||
|
||||
.. attribute:: row_grid_columns
|
||||
|
||||
List of columns for the row grid.
|
||||
|
||||
This is optional; see also :meth:`get_row_grid_columns()`.
|
||||
"""
|
||||
|
||||
##############################
|
||||
|
@ -368,6 +400,16 @@ class MasterView(View):
|
|||
execute_progress_template = None
|
||||
configurable = False
|
||||
|
||||
# row features
|
||||
has_rows = False
|
||||
rows_filterable = True
|
||||
rows_filter_defaults = None
|
||||
rows_sortable = True
|
||||
rows_sort_on_backend = True
|
||||
rows_sort_defaults = None
|
||||
rows_paginated = True
|
||||
rows_paginate_on_backend = True
|
||||
|
||||
# current action
|
||||
listing = False
|
||||
creating = False
|
||||
|
@ -458,6 +500,7 @@ class MasterView(View):
|
|||
* :meth:`make_model_form()`
|
||||
* :meth:`configure_form()`
|
||||
* :meth:`create_save_form()`
|
||||
* :meth:`redirect_after_create()`
|
||||
"""
|
||||
self.creating = True
|
||||
form = self.make_model_form(cancel_url_fallback=self.get_index_url())
|
||||
|
@ -465,7 +508,7 @@ class MasterView(View):
|
|||
if form.validate():
|
||||
obj = self.create_save_form(form)
|
||||
self.Session.flush()
|
||||
return self.redirect(self.get_action_url('view', obj))
|
||||
return self.redirect_after_create(obj)
|
||||
|
||||
context = {
|
||||
'form': form,
|
||||
|
@ -491,6 +534,16 @@ class MasterView(View):
|
|||
self.persist(obj)
|
||||
return obj
|
||||
|
||||
def redirect_after_create(self, obj):
|
||||
"""
|
||||
Usually, this returns a redirect to which we send the user,
|
||||
after a new model record has been created. By default this
|
||||
sends them to the "view" page for the record.
|
||||
|
||||
It is called automatically by :meth:`create()`.
|
||||
"""
|
||||
return self.redirect(self.get_action_url('view', obj))
|
||||
|
||||
##############################
|
||||
# view methods
|
||||
##############################
|
||||
|
@ -514,15 +567,40 @@ class MasterView(View):
|
|||
|
||||
* :meth:`make_model_form()`
|
||||
* :meth:`configure_form()`
|
||||
* :meth:`make_row_model_grid()` - if :attr:`has_rows` is true
|
||||
"""
|
||||
self.viewing = True
|
||||
instance = self.get_instance()
|
||||
form = self.make_model_form(instance, readonly=True)
|
||||
|
||||
obj = self.get_instance()
|
||||
form = self.make_model_form(obj, readonly=True)
|
||||
context = {
|
||||
'instance': instance,
|
||||
'instance': obj,
|
||||
'form': form,
|
||||
}
|
||||
|
||||
if self.has_rows:
|
||||
|
||||
# always make the grid first. note that it already knows
|
||||
# to "reset" its params when that is requested.
|
||||
grid = self.make_row_model_grid(obj)
|
||||
|
||||
# but if user did request a "reset" then we want to
|
||||
# redirect so the query string gets cleared out
|
||||
if self.request.GET.get('reset-view'):
|
||||
|
||||
# nb. we want to preserve url hash if applicable
|
||||
kw = {'_query': None,
|
||||
'_anchor': self.request.GET.get('hash')}
|
||||
return self.redirect(self.request.current_route_url(**kw))
|
||||
|
||||
# so-called 'partial' requests get just the grid data
|
||||
if self.request.params.get('partial'):
|
||||
context = grid.get_vue_context()
|
||||
if grid.paginated and grid.paginate_on_backend:
|
||||
context['pager_stats'] = grid.get_vue_pager_stats()
|
||||
return self.json_response(context)
|
||||
|
||||
context['rows_grid'] = grid
|
||||
|
||||
return self.render_to_response('view', context)
|
||||
|
||||
##############################
|
||||
|
@ -1896,8 +1974,8 @@ class MasterView(View):
|
|||
|
||||
This is called by :meth:`make_model_grid()`.
|
||||
|
||||
There is no default logic here; subclass should override as
|
||||
needed. The ``grid`` param will already be "complete" and
|
||||
There is minimal default logic here; subclass should override
|
||||
as needed. The ``grid`` param will already be "complete" and
|
||||
ready to use as-is, but this method can further modify it
|
||||
based on request details etc.
|
||||
"""
|
||||
|
@ -2230,6 +2308,182 @@ class MasterView(View):
|
|||
session = session or self.Session()
|
||||
session.add(obj)
|
||||
|
||||
##############################
|
||||
# row methods
|
||||
##############################
|
||||
|
||||
def get_rows_title(self):
|
||||
"""
|
||||
Returns the display title for model **rows** grid, if
|
||||
applicable/desired. Only relevant if :attr:`has_rows` is
|
||||
true.
|
||||
|
||||
There is no default here, but subclass may override by
|
||||
assigning :attr:`rows_title`.
|
||||
"""
|
||||
if hasattr(self, 'rows_title'):
|
||||
return self.rows_title
|
||||
|
||||
def make_row_model_grid(self, obj, **kwargs):
|
||||
"""
|
||||
Create and return a grid for a record's **rows** data, for use
|
||||
in :meth:`view()`. Only applicable if :attr:`has_rows` is
|
||||
true.
|
||||
|
||||
:param obj: Current model instance for which rows data is
|
||||
being displayed.
|
||||
|
||||
:returns: :class:`~wuttaweb.grids.base.Grid` instance for the
|
||||
rows data.
|
||||
|
||||
See also related methods, which are called by this one:
|
||||
|
||||
* :meth:`get_row_grid_key()`
|
||||
* :meth:`get_row_grid_columns()`
|
||||
* :meth:`get_row_grid_data()`
|
||||
* :meth:`configure_row_grid()`
|
||||
"""
|
||||
if 'key' not in kwargs:
|
||||
kwargs['key'] = self.get_row_grid_key()
|
||||
|
||||
if 'model_class' not in kwargs:
|
||||
model_class = self.get_row_model_class()
|
||||
if model_class:
|
||||
kwargs['model_class'] = model_class
|
||||
|
||||
if 'columns' not in kwargs:
|
||||
kwargs['columns'] = self.get_row_grid_columns()
|
||||
|
||||
if 'data' not in kwargs:
|
||||
kwargs['data'] = self.get_row_grid_data(obj)
|
||||
|
||||
kwargs.setdefault('filterable', self.rows_filterable)
|
||||
kwargs.setdefault('filter_defaults', self.rows_filter_defaults)
|
||||
kwargs.setdefault('sortable', self.rows_sortable)
|
||||
kwargs.setdefault('sort_multiple', not self.request.use_oruga)
|
||||
kwargs.setdefault('sort_on_backend', self.rows_sort_on_backend)
|
||||
kwargs.setdefault('sort_defaults', self.rows_sort_defaults)
|
||||
kwargs.setdefault('paginated', self.rows_paginated)
|
||||
kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend)
|
||||
|
||||
grid = self.make_grid(**kwargs)
|
||||
self.configure_row_grid(grid)
|
||||
grid.load_settings()
|
||||
return grid
|
||||
|
||||
def get_row_grid_key(self):
|
||||
"""
|
||||
Returns the (presumably) unique key to be used for the
|
||||
**rows** grid in :meth:`view()`. Only relevant if
|
||||
:attr:`has_rows` is true.
|
||||
|
||||
This is called from :meth:`make_row_model_grid()`; in the
|
||||
resulting grid, this becomes
|
||||
:attr:`~wuttaweb.grids.base.Grid.key`.
|
||||
|
||||
Whereas you can define :attr:`grid_key` for the main grid, the
|
||||
row grid key is always generated dynamically. This
|
||||
incorporates the current record key (whose rows are in the
|
||||
grid) so that the rows grid for each record is unique.
|
||||
"""
|
||||
parts = [self.get_grid_key()]
|
||||
for key in self.get_model_key():
|
||||
parts.append(str(self.request.matchdict[key]))
|
||||
return '.'.join(parts)
|
||||
|
||||
def get_row_grid_columns(self):
|
||||
"""
|
||||
Returns the default list of column names for the **rows**
|
||||
grid, for use in :meth:`view()`. Only relevant if
|
||||
:attr:`has_rows` is true.
|
||||
|
||||
This is called by :meth:`make_row_model_grid()`; in the
|
||||
resulting grid, this becomes
|
||||
:attr:`~wuttaweb.grids.base.Grid.columns`.
|
||||
|
||||
This method may return ``None``, in which case the grid may
|
||||
(try to) generate its own default list.
|
||||
|
||||
Subclass may define :attr:`row_grid_columns` for simple cases,
|
||||
or can override this method if needed.
|
||||
|
||||
Also note that :meth:`configure_row_grid()` may be used to
|
||||
further modify the final column set, regardless of what this
|
||||
method returns. So a common pattern is to declare all
|
||||
"supported" columns by setting :attr:`row_grid_columns` but
|
||||
then optionally remove or replace some of those within
|
||||
:meth:`configure_row_grid()`.
|
||||
"""
|
||||
if hasattr(self, 'row_grid_columns'):
|
||||
return self.row_grid_columns
|
||||
|
||||
def get_row_grid_data(self, obj):
|
||||
"""
|
||||
Returns the data for the **rows** grid, for use in
|
||||
:meth:`view()`. Only relevant if :attr:`has_rows` is true.
|
||||
|
||||
This is called by :meth:`make_row_model_grid()`; in the
|
||||
resulting grid, this becomes
|
||||
:attr:`~wuttaweb.grids.base.Grid.data`.
|
||||
|
||||
Default logic not implemented; subclass must define this.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def configure_row_grid(self, grid):
|
||||
"""
|
||||
Configure the **rows** grid for use in :meth:`view()`. Only
|
||||
relevant if :attr:`has_rows` is true.
|
||||
|
||||
This is called by :meth:`make_row_model_grid()`.
|
||||
|
||||
There is minimal default logic here; subclass should override
|
||||
as needed. The ``grid`` param will already be "complete" and
|
||||
ready to use as-is, but this method can further modify it
|
||||
based on request details etc.
|
||||
"""
|
||||
grid.remove('uuid')
|
||||
self.set_row_labels(grid)
|
||||
|
||||
def set_row_labels(self, obj):
|
||||
"""
|
||||
Set label overrides on a **row** form or grid, based on what
|
||||
is defined by the view class and its parent class(es).
|
||||
|
||||
This is called automatically from
|
||||
:meth:`configure_row_grid()` and
|
||||
:meth:`configure_row_form()`.
|
||||
|
||||
This calls :meth:`collect_row_labels()` to find everything,
|
||||
then it assigns the labels using one of (based on ``obj``
|
||||
type):
|
||||
|
||||
* :func:`wuttaweb.forms.base.Form.set_label()`
|
||||
* :func:`wuttaweb.grids.base.Grid.set_label()`
|
||||
|
||||
:param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
|
||||
:class:`~wuttaweb.forms.base.Form` instance.
|
||||
"""
|
||||
labels = self.collect_row_labels()
|
||||
for key, label in labels.items():
|
||||
obj.set_label(key, label)
|
||||
|
||||
def collect_row_labels(self):
|
||||
"""
|
||||
Collect all **row** labels defined within the view class
|
||||
hierarchy.
|
||||
|
||||
This is called by :meth:`set_row_labels()`.
|
||||
|
||||
:returns: Dict of all labels found.
|
||||
"""
|
||||
labels = {}
|
||||
hierarchy = self.get_class_hierarchy()
|
||||
for cls in hierarchy:
|
||||
if hasattr(cls, 'row_labels'):
|
||||
labels.update(cls.row_labels)
|
||||
return labels
|
||||
|
||||
##############################
|
||||
# class methods
|
||||
##############################
|
||||
|
@ -2515,6 +2769,18 @@ class MasterView(View):
|
|||
|
||||
return cls.get_model_title_plural()
|
||||
|
||||
@classmethod
|
||||
def get_row_model_class(cls):
|
||||
"""
|
||||
Returns the **row** model class for the view, if defined.
|
||||
Only relevant if :attr:`has_rows` is true.
|
||||
|
||||
There is no default here, but a subclass may override by
|
||||
assigning :attr:`row_model_class`.
|
||||
"""
|
||||
if hasattr(cls, 'row_model_class'):
|
||||
return cls.row_model_class
|
||||
|
||||
##############################
|
||||
# configuration
|
||||
##############################
|
||||
|
|
|
@ -302,3 +302,23 @@ class TestPermissionsWidget(WebTestCase):
|
|||
# editable output always includes the perm
|
||||
html = widget.serialize(field, set())
|
||||
self.assertIn("Polish the widgets", html)
|
||||
|
||||
|
||||
class TestBatchIdWidget(WebTestCase):
|
||||
|
||||
def make_field(self, node, **kwargs):
|
||||
# TODO: not sure why default renderer is in use even though
|
||||
# pyramid_deform was included in setup? but this works..
|
||||
kwargs.setdefault('renderer', deform.Form.default_renderer)
|
||||
return deform.Field(node, **kwargs)
|
||||
|
||||
def test_serialize(self):
|
||||
node = colander.SchemaNode(colander.Integer())
|
||||
field = self.make_field(node)
|
||||
widget = mod.BatchIdWidget()
|
||||
|
||||
result = widget.serialize(field, colander.null)
|
||||
self.assertIs(result, colander.null)
|
||||
|
||||
result = widget.serialize(field, 42)
|
||||
self.assertEqual(result, '00000042')
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
import decimal
|
||||
import json
|
||||
import uuid as _uuid
|
||||
from unittest import TestCase
|
||||
|
@ -570,6 +571,12 @@ class TestMakeJsonSafe(TestCase):
|
|||
value = mod.make_json_safe(uuid)
|
||||
self.assertEqual(value, uuid.hex)
|
||||
|
||||
def test_decimal(self):
|
||||
value = decimal.Decimal('42.42')
|
||||
self.assertNotEqual(value, 42.42)
|
||||
result = mod.make_json_safe(value)
|
||||
self.assertEqual(result, 42.42)
|
||||
|
||||
def test_dict(self):
|
||||
model = self.app.model
|
||||
person = model.Person(full_name="Betty Boop")
|
||||
|
@ -585,3 +592,21 @@ class TestMakeJsonSafe(TestCase):
|
|||
'foo': 'bar',
|
||||
'person': "Betty Boop",
|
||||
})
|
||||
|
||||
def test_list(self):
|
||||
model = self.app.model
|
||||
person = model.Person(full_name="Betty Boop")
|
||||
|
||||
data = [
|
||||
'foo',
|
||||
'bar',
|
||||
person,
|
||||
]
|
||||
|
||||
self.assertRaises(TypeError, json.dumps, data)
|
||||
value = mod.make_json_safe(data)
|
||||
self.assertEqual(value, [
|
||||
'foo',
|
||||
'bar',
|
||||
"Betty Boop",
|
||||
])
|
||||
|
|
373
tests/views/test_batch.py
Normal file
373
tests/views/test_batch.py
Normal file
|
@ -0,0 +1,373 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from sqlalchemy import orm
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from wuttjamaican.db import model
|
||||
from wuttjamaican.batch import BatchHandler
|
||||
from wuttaweb.views import MasterView, batch as mod
|
||||
from wuttaweb.progress import SessionProgress
|
||||
from tests.util import WebTestCase
|
||||
|
||||
|
||||
class MockBatch(model.BatchMixin, model.Base):
|
||||
__tablename__ = 'testing_batch_mock'
|
||||
|
||||
class MockBatchRow(model.BatchRowMixin, model.Base):
|
||||
__tablename__ = 'testing_batch_mock_row'
|
||||
__batch_class__ = MockBatch
|
||||
|
||||
MockBatch.__row_class__ = MockBatchRow
|
||||
|
||||
class MockBatchHandler(BatchHandler):
|
||||
model_class = MockBatch
|
||||
|
||||
|
||||
class TestBatchMasterView(WebTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.setup_web()
|
||||
|
||||
# nb. create MockBatch, MockBatchRow
|
||||
model.Base.metadata.create_all(bind=self.session.bind)
|
||||
|
||||
def make_handler(self):
|
||||
return MockBatchHandler(self.config)
|
||||
|
||||
def make_view(self):
|
||||
return mod.BatchMasterView(self.request)
|
||||
|
||||
def test_get_batch_handler(self):
|
||||
self.assertRaises(NotImplementedError, mod.BatchMasterView, self.request)
|
||||
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=42):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
self.assertEqual(view.batch_handler, 42)
|
||||
|
||||
def test_get_fallback_templates(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = self.make_view()
|
||||
templates = view.get_fallback_templates('view')
|
||||
self.assertEqual(templates, [
|
||||
'/batch/view.mako',
|
||||
'/master/view.mako',
|
||||
])
|
||||
|
||||
def test_render_to_response(self):
|
||||
model = self.app.model
|
||||
handler = MockBatchHandler(self.config)
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.flush()
|
||||
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
with patch.object(MasterView, 'render_to_response') as render_to_response:
|
||||
view = self.make_view()
|
||||
response = view.render_to_response('view', {'instance': batch})
|
||||
self.assertTrue(render_to_response.called)
|
||||
context = render_to_response.call_args[0][1]
|
||||
self.assertIs(context['batch'], batch)
|
||||
self.assertIs(context['batch_handler'], handler)
|
||||
|
||||
def test_configure_grid(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
grid = view.make_model_grid()
|
||||
# nb. coverage only; tests nothing
|
||||
view.configure_grid(grid)
|
||||
|
||||
def test_render_batch_id(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
batch = MockBatch(id=42)
|
||||
|
||||
result = view.render_batch_id(batch, 'id', 42)
|
||||
self.assertEqual(result, '00000042')
|
||||
|
||||
result = view.render_batch_id(batch, 'id', None)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_get_instance_title(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
|
||||
batch = MockBatch(id=42)
|
||||
result = view.get_instance_title(batch)
|
||||
self.assertEqual(result, "00000042")
|
||||
|
||||
batch = MockBatch(id=43, description="runnin some numbers")
|
||||
result = view.get_instance_title(batch)
|
||||
self.assertEqual(result, "00000043 runnin some numbers")
|
||||
|
||||
def test_configure_form(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
|
||||
# creating
|
||||
with patch.object(view, 'creating', new=True):
|
||||
form = view.make_model_form(model_instance=None)
|
||||
view.configure_form(form)
|
||||
|
||||
batch = MockBatch(id=42)
|
||||
|
||||
# viewing
|
||||
with patch.object(view, 'viewing', new=True):
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
view.configure_form(form)
|
||||
|
||||
# editing
|
||||
with patch.object(view, 'editing', new=True):
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
view.configure_form(form)
|
||||
|
||||
# deleting
|
||||
with patch.object(view, 'deleting', new=True):
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
view.configure_form(form)
|
||||
|
||||
# viewing (executed)
|
||||
batch.executed = datetime.datetime.now()
|
||||
with patch.object(view, 'viewing', new=True):
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
view.configure_form(form)
|
||||
|
||||
def test_objectify(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
with patch.object(mod.BatchMasterView, 'Session', return_value=self.session):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
|
||||
# create batch
|
||||
with patch.object(view, 'creating', new=True):
|
||||
form = view.make_model_form(model_instance=None)
|
||||
form.validated = {}
|
||||
batch = view.objectify(form)
|
||||
self.assertIsInstance(batch.id, int)
|
||||
self.assertTrue(batch.id > 0)
|
||||
|
||||
# edit batch
|
||||
with patch.object(view, 'editing', new=True):
|
||||
with patch.object(view.batch_handler, 'make_batch') as make_batch:
|
||||
form = view.make_model_form(model_instance=batch)
|
||||
form.validated = {'description': 'foo'}
|
||||
self.assertIsNone(batch.description)
|
||||
batch = view.objectify(form)
|
||||
self.assertEqual(batch.description, 'foo')
|
||||
|
||||
def test_redirect_after_create(self):
|
||||
self.pyramid_config.add_route('mock_batches.view', '/batch/mock/{uuid}')
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
with patch.multiple(mod.BatchMasterView, create=True,
|
||||
model_class=MockBatch,
|
||||
route_prefix='mock_batches'):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
batch = MockBatch(id=42)
|
||||
|
||||
# typically redirect to view batch
|
||||
result = view.redirect_after_create(batch)
|
||||
self.assertIsInstance(result, HTTPFound)
|
||||
|
||||
# unless populating in which case thread is launched
|
||||
self.request.session.id = 'abcdefghijk'
|
||||
with patch.object(mod, 'threading') as threading:
|
||||
thread = MagicMock()
|
||||
threading.Thread.return_value = thread
|
||||
with patch.object(view.batch_handler, 'should_populate', return_value=True):
|
||||
with patch.object(view, 'render_progress') as render_progress:
|
||||
view.redirect_after_create(batch)
|
||||
self.assertTrue(threading.Thread.called)
|
||||
thread.start.assert_called_once_with()
|
||||
self.assertTrue(render_progress.called)
|
||||
|
||||
def test_delete_instance(self):
|
||||
model = self.app.model
|
||||
handler = self.make_handler()
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.flush()
|
||||
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = self.make_view()
|
||||
|
||||
self.assertEqual(self.session.query(MockBatch).count(), 1)
|
||||
view.delete_instance(batch)
|
||||
self.assertEqual(self.session.query(MockBatch).count(), 0)
|
||||
|
||||
def test_populate_thread(self):
|
||||
model = self.app.model
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
|
||||
view = mod.BatchMasterView(self.request)
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = MockBatch(id=42, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.commit()
|
||||
|
||||
# nb. use our session within thread method
|
||||
with patch.object(self.app, 'make_session', return_value=self.session):
|
||||
|
||||
# nb. prevent closing our session
|
||||
with patch.object(self.session, 'close') as close:
|
||||
|
||||
# without progress
|
||||
view.populate_thread(batch.uuid)
|
||||
close.assert_called_once_with()
|
||||
close.reset_mock()
|
||||
|
||||
# with progress
|
||||
self.request.session.id = 'abcdefghijk'
|
||||
view.populate_thread(batch.uuid,
|
||||
progress=SessionProgress(self.request,
|
||||
'populate_mock_batch'))
|
||||
close.assert_called_once_with()
|
||||
close.reset_mock()
|
||||
|
||||
# failure to populate, without progress
|
||||
with patch.object(view.batch_handler, 'do_populate', side_effect=RuntimeError):
|
||||
view.populate_thread(batch.uuid)
|
||||
close.assert_called_once_with()
|
||||
close.reset_mock()
|
||||
|
||||
# failure to populate, with progress
|
||||
with patch.object(view.batch_handler, 'do_populate', side_effect=RuntimeError):
|
||||
view.populate_thread(batch.uuid,
|
||||
progress=SessionProgress(self.request,
|
||||
'populate_mock_batch'))
|
||||
close.assert_called_once_with()
|
||||
close.reset_mock()
|
||||
|
||||
# failure for batch to appear
|
||||
self.session.delete(batch)
|
||||
self.session.commit()
|
||||
# nb. should give up waiting after 1 second
|
||||
self.assertRaises(RuntimeError, view.populate_thread, batch.uuid)
|
||||
|
||||
def test_execute(self):
|
||||
self.pyramid_config.add_route('mock_batches.view', '/batch/mock/{uuid}')
|
||||
model = self.app.model
|
||||
handler = MockBatchHandler(self.config)
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.commit()
|
||||
|
||||
with patch.multiple(mod.BatchMasterView, create=True,
|
||||
model_class=MockBatch,
|
||||
route_prefix='mock_batches',
|
||||
get_batch_handler=MagicMock(return_value=handler),
|
||||
get_instance=MagicMock(return_value=batch)):
|
||||
view = self.make_view()
|
||||
|
||||
# batch executes okay
|
||||
response = view.execute()
|
||||
self.assertEqual(response.status_code, 302) # redirect to "view batch"
|
||||
self.assertFalse(self.request.session.peek_flash('error'))
|
||||
|
||||
# but cannot be executed again
|
||||
response = view.execute()
|
||||
self.assertEqual(response.status_code, 302) # redirect to "view batch"
|
||||
# nb. flash has error this time
|
||||
self.assertTrue(self.request.session.peek_flash('error'))
|
||||
|
||||
def test_get_row_model_class(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
view = self.make_view()
|
||||
|
||||
self.assertRaises(AttributeError, view.get_row_model_class)
|
||||
|
||||
# row class determined from batch class
|
||||
with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True):
|
||||
cls = view.get_row_model_class()
|
||||
self.assertIs(cls, MockBatchRow)
|
||||
|
||||
self.assertRaises(AttributeError, view.get_row_model_class)
|
||||
|
||||
# view may specify row class
|
||||
with patch.object(mod.BatchMasterView, 'row_model_class', new=MockBatchRow, create=True):
|
||||
cls = view.get_row_model_class()
|
||||
self.assertIs(cls, MockBatchRow)
|
||||
|
||||
def test_get_row_grid_data(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
model = self.app.model
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
row = handler.make_row()
|
||||
handler.add_row(batch, row)
|
||||
self.session.flush()
|
||||
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
|
||||
view = self.make_view()
|
||||
self.assertRaises(AttributeError, view.get_row_grid_data, batch)
|
||||
|
||||
Session = MagicMock(return_value=self.session)
|
||||
Session.query.side_effect = lambda m: self.session.query(m)
|
||||
with patch.multiple(mod.BatchMasterView, create=True,
|
||||
Session=Session,
|
||||
model_class=MockBatch):
|
||||
|
||||
view = self.make_view()
|
||||
data = view.get_row_grid_data(batch)
|
||||
self.assertIsInstance(data, orm.Query)
|
||||
self.assertEqual(data.count(), 1)
|
||||
|
||||
def test_configure_row_grid(self):
|
||||
handler = MockBatchHandler(self.config)
|
||||
model = self.app.model
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
row = handler.make_row()
|
||||
handler.add_row(batch, row)
|
||||
self.session.flush()
|
||||
|
||||
with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
|
||||
|
||||
Session = MagicMock(return_value=self.session)
|
||||
Session.query.side_effect = lambda m: self.session.query(m)
|
||||
with patch.multiple(mod.BatchMasterView, create=True,
|
||||
Session=Session,
|
||||
model_class=MockBatch):
|
||||
|
||||
with patch.object(self.request, 'matchdict', new={'uuid': batch.uuid}):
|
||||
view = self.make_view()
|
||||
grid = view.make_row_model_grid(batch)
|
||||
self.assertIn('sequence', grid.labels)
|
||||
self.assertEqual(grid.labels['sequence'], "Seq.")
|
||||
|
||||
def test_defaults(self):
|
||||
# nb. coverage only
|
||||
with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True):
|
||||
mod.BatchMasterView.defaults(self.pyramid_config)
|
|
@ -334,6 +334,16 @@ class TestMasterView(WebTestCase):
|
|||
model_class=MyModel):
|
||||
self.assertEqual(mod.MasterView.get_config_title(), "Dinosaurs")
|
||||
|
||||
def test_get_row_model_class(self):
|
||||
model = self.app.model
|
||||
|
||||
# no default
|
||||
self.assertIsNone(mod.MasterView.get_row_model_class())
|
||||
|
||||
# class may specify
|
||||
with patch.object(mod.MasterView, 'row_model_class', create=True, new=model.User):
|
||||
self.assertIs(mod.MasterView.get_row_model_class(), model.User)
|
||||
|
||||
##############################
|
||||
# support methods
|
||||
##############################
|
||||
|
@ -1017,6 +1027,53 @@ class TestMasterView(WebTestCase):
|
|||
with patch.object(view, 'get_instance', return_value=setting):
|
||||
response = view.view()
|
||||
|
||||
def test_view_with_rows(self):
|
||||
self.pyramid_config.include('wuttaweb.views.common')
|
||||
self.pyramid_config.include('wuttaweb.views.auth')
|
||||
self.pyramid_config.add_route('people', '/people/')
|
||||
model = self.app.model
|
||||
person = model.Person(full_name="Whitney Houston")
|
||||
self.session.add(person)
|
||||
user = model.User(username='whitney', person=person)
|
||||
self.session.add(user)
|
||||
self.session.commit()
|
||||
|
||||
get_row_grid_data = MagicMock()
|
||||
with patch.multiple(mod.MasterView, create=True,
|
||||
Session=MagicMock(return_value=self.session),
|
||||
model_class=model.Person,
|
||||
route_prefix='people',
|
||||
has_rows=True,
|
||||
row_model_class=model.User,
|
||||
get_row_grid_data=get_row_grid_data):
|
||||
with patch.object(self.request, 'matchdict', new={'uuid': person.uuid}):
|
||||
view = self.make_view()
|
||||
|
||||
# just for coverage
|
||||
get_row_grid_data.return_value = []
|
||||
response = view.view()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content_type, 'text/html')
|
||||
|
||||
# now with data...
|
||||
get_row_grid_data.return_value = [user]
|
||||
response = view.view()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content_type, 'text/html')
|
||||
|
||||
# then once more as 'partial' - aka. data only
|
||||
with patch.dict(self.request.GET, {'partial': 1}):
|
||||
response = view.view()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content_type, 'application/json')
|
||||
|
||||
# redirects when view is reset
|
||||
with patch.dict(self.request.GET, {'reset-view': '1', 'hash': 'foo'}):
|
||||
# nb. mock current route
|
||||
with patch.object(self.request, 'current_route_url'):
|
||||
response = view.view()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_edit(self):
|
||||
self.pyramid_config.include('wuttaweb.views.common')
|
||||
self.pyramid_config.include('wuttaweb.views.auth')
|
||||
|
@ -1501,3 +1558,103 @@ class TestMasterView(WebTestCase):
|
|||
# should now have 0 settings
|
||||
count = self.session.query(model.Setting).count()
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
##############################
|
||||
# row methods
|
||||
##############################
|
||||
|
||||
def test_collect_row_labels(self):
|
||||
|
||||
# default labels
|
||||
view = self.make_view()
|
||||
labels = view.collect_row_labels()
|
||||
self.assertEqual(labels, {})
|
||||
|
||||
# labels come from all classes; subclass wins
|
||||
with patch.object(View, 'row_labels', create=True, new={'foo': "Foo", 'bar': "Bar"}):
|
||||
with patch.object(mod.MasterView, 'row_labels', create=True, new={'foo': "FOO FIGHTERS"}):
|
||||
view = self.make_view()
|
||||
labels = view.collect_row_labels()
|
||||
self.assertEqual(labels, {'foo': "FOO FIGHTERS", 'bar': "Bar"})
|
||||
|
||||
def test_set_row_labels(self):
|
||||
model = self.app.model
|
||||
person = model.Person(full_name="Fred Flintstone")
|
||||
self.session.add(person)
|
||||
|
||||
with patch.multiple(mod.MasterView, create=True,
|
||||
model_class=model.Person,
|
||||
has_rows=True,
|
||||
row_model_class=model.User):
|
||||
|
||||
# no labels by default
|
||||
view = self.make_view()
|
||||
grid = view.make_row_model_grid(person, key='person.users', data=[])
|
||||
view.set_row_labels(grid)
|
||||
self.assertEqual(grid.labels, {})
|
||||
|
||||
# labels come from all classes; subclass wins
|
||||
with patch.object(View, 'row_labels', create=True, new={'username': "USERNAME"}):
|
||||
with patch.object(mod.MasterView, 'row_labels', create=True, new={'username': "UserName"}):
|
||||
view = self.make_view()
|
||||
grid = view.make_row_model_grid(person, key='person.users', data=[])
|
||||
view.set_row_labels(grid)
|
||||
self.assertEqual(grid.labels, {'username': "UserName"})
|
||||
|
||||
def test_get_row_grid_data(self):
|
||||
model = self.app.model
|
||||
person = model.Person(full_name="Fred Flintstone")
|
||||
self.session.add(person)
|
||||
view = self.make_view()
|
||||
self.assertRaises(NotImplementedError, view.get_row_grid_data, person)
|
||||
|
||||
def test_get_row_grid_columns(self):
|
||||
|
||||
# no default
|
||||
view = self.make_view()
|
||||
self.assertIsNone(view.get_row_grid_columns())
|
||||
|
||||
# class may specify
|
||||
with patch.object(view, 'row_grid_columns', create=True, new=['foo', 'bar']):
|
||||
self.assertEqual(view.get_row_grid_columns(), ['foo', 'bar'])
|
||||
|
||||
def test_get_row_grid_key(self):
|
||||
view = self.make_view()
|
||||
with patch.multiple(mod.MasterView, create=True,
|
||||
model_key='id',
|
||||
grid_key='widgets'):
|
||||
|
||||
self.request.matchdict = {'id': 42}
|
||||
self.assertEqual(view.get_row_grid_key(), 'widgets.42')
|
||||
|
||||
def test_make_row_model_grid(self):
|
||||
model = self.app.model
|
||||
person = model.Person(full_name="Barney Rubble")
|
||||
self.session.add(person)
|
||||
self.session.commit()
|
||||
|
||||
self.request.matchdict = {'uuid': person.uuid}
|
||||
with patch.multiple(mod.MasterView, create=True,
|
||||
model_class=model.Person):
|
||||
view = self.make_view()
|
||||
|
||||
# specify data
|
||||
grid = view.make_row_model_grid(person, data=[])
|
||||
self.assertIsNone(grid.model_class)
|
||||
self.assertEqual(grid.data, [])
|
||||
|
||||
# fetch data
|
||||
with patch.object(view, 'get_row_grid_data', return_value=[]):
|
||||
grid = view.make_row_model_grid(person)
|
||||
self.assertIsNone(grid.model_class)
|
||||
self.assertEqual(grid.data, [])
|
||||
|
||||
def test_get_rows_title(self):
|
||||
view = self.make_view()
|
||||
|
||||
# no default
|
||||
self.assertIsNone(view.get_rows_title())
|
||||
|
||||
# class may specify
|
||||
with patch.object(view, 'rows_title', create=True, new="Mock Rows"):
|
||||
self.assertEqual(view.get_rows_title(), "Mock Rows")
|
||||
|
|
Loading…
Reference in a new issue