diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63497e1..4500527 100644
--- a/CHANGELOG.md
+++ b/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
diff --git a/docs/api/wuttaweb.views.batch.rst b/docs/api/wuttaweb.views.batch.rst
new file mode 100644
index 0000000..8adc64b
--- /dev/null
+++ b/docs/api/wuttaweb.views.batch.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.batch``
+========================
+
+.. automodule:: wuttaweb.views.batch
+ :members:
diff --git a/docs/index.rst b/docs/index.rst
index 7ece535..ce74ae6 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index 0a0435c..5aed35f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
]
diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py
index 2c8a944..f90768a 100644
--- a/src/wuttaweb/forms/widgets.py
+++ b/src/wuttaweb/forms/widgets.py
@@ -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}'
diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index a33d3ca..df05cbb 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -201,6 +201,10 @@
width: 100%;
}
+ .tool-panels-wrapper {
+ padding: 1rem;
+ }
+
%def>
diff --git a/src/wuttaweb/templates/batch/view.mako b/src/wuttaweb/templates/batch/view.mako
new file mode 100644
index 0000000..569af5b
--- /dev/null
+++ b/src/wuttaweb/templates/batch/view.mako
@@ -0,0 +1,124 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+
+%def>
+
+<%def name="tool_panels()">
+ ${parent.tool_panels()}
+ ${self.tool_panel_execution()}
+%def>
+
+<%def name="tool_panel_execution()">
+
+ % if batch.executed:
+
+
+ Batch was executed
+ ${app.render_time_ago(batch.executed)}
+ by ${batch.executed_by}.
+
+
+ % elif why_not_execute:
+
+
+ Batch cannot be executed:
+
+
+ ${why_not_execute}
+
+
+ % else:
+ % if master.has_perm('execute'):
+
+
+ Batch can be executed.
+
+
+ Execute Batch
+
+
+
+
+
+
+ Execute ${model_title}
+
+
+ ## TODO: forcing black text b/c of b-notification
+ ## wrapping button, which has white text
+
+
+ What will happen when this batch is executed?
+
+
+ ${execution_described|n}
+
+ ${h.form(master.get_action_url('execute', batch), ref='executeForm')}
+ ${h.csrf_token(request)}
+ ${h.end_form()}
+
+
+
+
+
+
+
+
+ % else:
+
+
+ Batch may be executed,
+ but you do not have permission.
+
+
+ % endif
+ % endif
+
+%def>
+
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+ % if not batch.executed and not why_not_execute and master.has_perm('execute'):
+
+ % endif
+%def>
diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako
index de7209a..1a4fe2d 100644
--- a/src/wuttaweb/templates/form.mako
+++ b/src/wuttaweb/templates/form.mako
@@ -1,6 +1,19 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
+<%def name="page_layout()">
+
@@ -9,6 +22,14 @@
% endif
%def>
+<%def name="tool_panels_wrapper()">
+
+ ${self.tool_panels()}
+
+%def>
+
+<%def name="tool_panels()">%def>
+
<%def name="render_vue_template_form()">
% if form is not Undefined:
${form.render_vue_template()}
diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako
index b84ebc1..b4db013 100644
--- a/src/wuttaweb/templates/master/view.mako
+++ b/src/wuttaweb/templates/master/view.mako
@@ -5,5 +5,48 @@
<%def name="content_title()">${instance_title}%def>
+<%def name="page_layout()">
-${parent.body()}
+ % if master.has_rows:
+
+
+
+ ## main form
+
+ ${self.page_content()}
+
+
+ ## tool panels
+ ${self.tool_panels_wrapper()}
+
+
+
+ ## rows grid
+
+
${master.get_rows_title() or ''}
+ ${rows_grid.render_vue_tag()}
+
+
+ % 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>
diff --git a/src/wuttaweb/templates/page.mako b/src/wuttaweb/templates/page.mako
index 218e9f4..c23ce90 100644
--- a/src/wuttaweb/templates/page.mako
+++ b/src/wuttaweb/templates/page.mako
@@ -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()">
%def>
diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako
index 6030840..5664933 100644
--- a/src/wuttaweb/templates/wutta-components.mako
+++ b/src/wuttaweb/templates/wutta-components.mako
@@ -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 @@
%def>
+
+<%def name="make_wutta_tool_panel_component()">
+
+
+%def>
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index c0069d4..0697f03 100644
--- a/src/wuttaweb/util.py
+++ b/src/wuttaweb/util.py
@@ -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)
diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py
new file mode 100644
index 0000000..1645455
--- /dev/null
+++ b/src/wuttaweb/views/batch.py
@@ -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
.
+#
+################################################################################
+"""
+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}")
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 4ce9d2d..0030859 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -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
##############################
diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py
index a49bdf5..cd3c7c4 100644
--- a/tests/forms/test_widgets.py
+++ b/tests/forms/test_widgets.py
@@ -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')
diff --git a/tests/test_util.py b/tests/test_util.py
index 21de3a4..6946d65 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -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",
+ ])
diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py
new file mode 100644
index 0000000..a3a34fe
--- /dev/null
+++ b/tests/views/test_batch.py
@@ -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)
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 8e451ee..06d8ed1 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -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")