3
0
Fork 0

Compare commits

...

5 commits

Author SHA1 Message Date
Lance Edgar 180acc509f bump: version 0.16.2 → 0.17.0 2024-12-15 00:48:07 -06:00
Lance Edgar 30671fcd78 fix: add handling for decimal values and lists, in make_json_safe() 2024-12-14 23:57:34 -06:00
Lance Edgar dd1fd8c0ce feat: add basic support for batch execution
no execution options yet, and no progress indicator

also basic delete support, invoking handler
2024-12-14 23:57:10 -06:00
Lance Edgar e3beb9953d feat: add basic support for rows grid for master, batch views 2024-12-14 10:48:11 -06:00
Lance Edgar 5006c97b4b feat: add basic master view class for batches
no support for displaying rows yet, just the main batch CRUD
2024-12-13 22:20:04 -06:00
18 changed files with 1549 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -201,6 +201,10 @@
width: 100%;
}
.tool-panels-wrapper {
padding: 1rem;
}
</style>
</%def>

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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