tailbone/tailbone/views/batch.py

2095 lines
77 KiB
Python

# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2016 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# Rattail 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 Affero General Public License for
# more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Base views for maintaining "new-style" batches.
.. note::
This is all still somewhat experimental.
"""
from __future__ import unicode_literals, absolute_import
import os
import datetime
import logging
from cStringIO import StringIO
from sqlalchemy import orm
from rattail.db import model, Session as RattailSession
from rattail.threads import Thread
from rattail.csvutil import UnicodeDictWriter
from rattail.util import load_object
import formalchemy
from pyramid import httpexceptions
from pyramid.renderers import render_to_response
from pyramid.response import FileResponse
from webhelpers.html import HTML
from tailbone import forms, newgrids as grids
from tailbone.db import Session
from tailbone.views import MasterView, SearchableAlchemyGridView, CrudView
from tailbone.forms.renderers.batch import FileFieldRenderer
from tailbone.grids.search import BooleanSearchFilter, EnumSearchFilter
from tailbone.progress import SessionProgress
log = logging.getLogger(__name__)
class BatchMasterView(MasterView):
"""
Base class for all "batch master" views.
"""
refreshable = False
rows_viewable = True
rows_creatable = False
rows_editable = False
rows_deletable = True
def __init__(self, request):
super(BatchMasterView, self).__init__(request)
self.handler = self.get_handler()
def get_handler(self):
"""
Returns a `BatchHandler` instance for the view. All (?) custom batch
views should define a default handler class; however this may in all
(?) cases be overridden by config also. The specific setting required
to do so will depend on the 'key' for the type of batch involved, e.g.
assuming the 'vendor_catalog' batch:
.. code-block:: ini
[rattail.batch]
vendor_catalog.handler = myapp.batch.vendorcatalog:CustomCatalogHandler
Note that the 'key' for a batch is generally the same as its primary
table name, although technically it is whatever value returns from the
``batch_key`` attribute of the main batch model class.
"""
key = self.model_class.batch_key
spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key))
if spec:
return load_object(spec)(self.rattail_config)
return self.batch_handler_class(self.rattail_config)
def view(self):
"""
View for viewing details of an existing model record.
"""
self.viewing = True
batch = self.get_instance()
form = self.make_form(batch)
grid = self.make_row_grid(batch=batch)
# If user just refreshed the page with a reset instruction, issue a
# redirect in order to clear out the query string.
if self.request.GET.get('reset-to-default-filters') == 'true':
return self.redirect(self.request.current_route_url(_query=None))
if self.request.params.get('partial'):
self.request.response.content_type = b'text/html'
self.request.response.text = grid.render_grid()
return self.request.response
return self.render_to_response('view', {
'instance': batch,
'instance_title': self.get_instance_title(batch),
'instance_editable': self.editable_instance(batch),
'instance_deletable': self.deletable_instance(batch),
'form': form,
'batch': batch,
'execute_title': self.get_execute_title(batch),
'execute_enabled': self.executable(batch),
'rows_grid': grid.render_complete(allow_save_defaults=False),
})
def _preconfigure_grid(self, g):
"""
Apply some commonly-useful pre-configuration to the main batch grid.
"""
g.joiners['created_by'] = lambda q: q.join(model.User,
model.User.uuid == self.model_class.created_by_uuid)
g.joiners['executed_by'] = lambda q: q.outerjoin(model.User,
model.User.uuid == self.model_class.executed_by_uuid)
g.filters['executed'].default_active = True
g.filters['executed'].default_verb = 'is_null'
g.sorters['created_by'] = g.make_sorter(model.User.username)
g.sorters['executed_by'] = g.make_sorter(model.User.username)
g.default_sortkey = 'created'
g.default_sortdir = 'desc'
g.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer)
g.cognized_by.set(renderer=forms.renderers.UserFieldRenderer)
g.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer)
def configure_grid(self, g):
"""
Apply final configuration to the main batch grid. Custom batch views
are encouraged to override this method.
"""
g.configure(
include=[
g.created,
g.created_by,
g.executed,
g.executed_by,
],
readonly=True)
def _preconfigure_fieldset(self, fs):
"""
Apply some commonly-useful pre-configuration to the main batch
fieldset.
"""
fs.created.set(readonly=True)
fs.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer,
readonly=True)
fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer)
fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer)
if self.request.user:
batch = fs.model
batch.created_by_uuid = self.request.user.uuid
def configure_fieldset(self, fs):
"""
Apply final configuration to the main batch fieldset. Custom batch
views are encouraged to override this method.
"""
if self.creating:
fs.configure(
include=[
fs.created_by.hidden(),
])
else:
batch = fs.model
if batch.executed:
fs.configure(
include=[
fs.created,
fs.created_by,
fs.executed,
fs.executed_by,
])
else:
fs.configure(
include=[
fs.created,
fs.created_by,
])
def save_create_form(self, form):
"""
Save the uploaded data file if necessary, etc. If batch initialization
fails, don't persist the batch at all; the user will be sent back to
the "create batch" page in that case.
"""
self.before_create(form)
# Transfer form data to batch instance.
form.fieldset.sync()
batch = form.fieldset.model
# Assign current user as creator.
with Session.no_autoflush:
batch.created_by = self.request.user or self.late_login_user()
# TODO: Wouldn't this be handled sufficiently by `no_autoflush` ?
# Expunge batch from session to prevent it from being flushed
# during init. This is done as a convenience to views which
# provide an init method. Some batches may have required fields
# which aren't filled in yet, but the view may need to query the
# database to obtain the values. This will cause a session flush,
# and the missing fields will trigger data integrity errors.
Session.expunge(batch)
self.batch_inited = self.init_batch(batch)
if self.batch_inited:
Session.add(batch)
Session.flush()
else: # batch init failed
# Here we assume that the :meth:`init_batch()` method responsible
# for indicating the failure will have set a flash message for the
# user with more info.
raise self.redirect(self.request.current_route_url())
def init_batch(self, batch):
"""
Initialize a new batch. Derived classes can override this to
effectively provide default values for a batch, etc. This method is
invoked after a batch has been fully prepared for insertion to the
database, but before the push to the database occurs.
Note that the return value of this function matters; if it is boolean
false then the batch will not be persisted at all, and the user will be
redirected to the "create batch" page.
"""
return True
def edit(self):
"""
Don't allow editing a batch which has already been executed.
"""
self.editing = True
batch = self.get_instance()
if batch.executed:
return self.redirect(self.get_action_url('view', batch))
grid = self.make_row_grid(batch=batch)
# If user just refreshed the page with a reset instruction, issue a
# redirect in order to clear out the query string.
if self.request.GET.get('reset-to-default-filters') == 'true':
return self.redirect(self.request.current_route_url(_query=None))
if self.request.params.get('partial'):
self.request.response.content_type = b'text/html'
self.request.response.text = grid.render_grid()
return self.request.response
form = self.make_form(batch)
if self.request.method == 'POST':
if form.validate():
self.save_edit_form(form)
self.request.session.flash("{0} {1} has been updated.".format(
self.get_model_title(), self.get_instance_title(batch)))
return self.redirect_after_edit(batch)
return self.render_to_response('edit', {
'instance': batch,
'instance_title': self.get_instance_title(batch),
'instance_deletable': self.deletable_instance(batch),
'form': form,
'batch': batch,
'rows_grid': grid,
'execute_title': self.get_execute_title(batch),
'execute_enabled': self.executable(batch),
})
def redirect_after_edit(self, batch):
"""
Redirect back to edit batch page after editing a batch, unless the
refresh flag is set, in which case do that.
"""
if self.request.params.get('refresh') == 'true':
return self.redirect(self.get_action_url('refresh', batch))
return self.redirect(self.request.current_route_url())
def delete_instance(self, batch):
"""
Delete all data (files etc.) for the batch.
"""
batch.delete_data(self.rattail_config)
del batch.data_rows[:]
super(BatchMasterView, self).delete_instance(batch)
def get_fallback_templates(self, template):
return [
'/newbatch/{}.mako'.format(template),
'/master/{}.mako'.format(template),
]
def editable_instance(self, batch):
return not bool(batch.executed)
def executable(self, batch):
return self.handler.executable(batch)
def get_execute_title(self, batch):
return self.handler.get_execute_title(batch)
def refresh(self):
"""
View which will attempt to refresh all data for the batch. What
exactly this means will depend on the type of batch etc.
"""
batch = self.get_instance()
route_prefix = self.get_route_prefix()
permission_prefix = self.get_permission_prefix()
cognizer = self.request.user
if not cognizer:
uuid = self.request.session.pop('late_login_user', None)
cognizer = Session.query(model.User).get(uuid) if uuid else None
# If handler doesn't declare the need for progress indicator, things
# are nice and simple.
if not self.handler.show_progress:
self.refresh_data(Session, batch, cognizer=cognizer)
self.request.session.flash("Batch data has been refreshed.")
# TODO: This seems hacky...it exists for (only) one specific scenario.
if not self.request.has_perm('{}.view'.format(permission_prefix)):
return self.redirect(self.request.route_url('{}.create'.format(route_prefix)))
return self.redirect(self.get_action_url('view', batch))
# Showing progress requires a separate thread; start that first.
key = '{}.refresh'.format(self.model_class.__tablename__)
progress = SessionProgress(self.request, key)
# success_url = self.request.route_url('vendors.scangenius.create') if not self.request.user else None
# TODO: This seems hacky...it exists for (only) one specific scenario.
success_url = None
if not self.request.user:
success_url = self.request.route_url('{}.create'.format(route_prefix))
thread = Thread(target=self.refresh_thread, args=(batch.uuid, progress,
cognizer.uuid if cognizer else None,
success_url))
thread.start()
# Send user to progress page.
kwargs = {
'key': key,
'cancel_url': self.get_action_url('view', batch),
'cancel_msg': "Batch refresh was canceled.",
}
# TODO: This seems hacky...it exists for (only) one specific scenario.
if not self.request.has_perm('{}.view'.format(permission_prefix)):
kwargs['cancel_url'] = self.request.route_url('{}.create'.format(route_prefix))
return render_to_response('/progress.mako', kwargs, request=self.request)
def refresh_data(self, session, batch, cognizer=None, progress=None):
"""
Instruct the batch handler to refresh all data for the batch.
"""
self.handler.refresh_data(session, batch, progress=progress)
batch.cognized = datetime.datetime.utcnow()
batch.cognized_by = cognizer or session.merge(self.request.user)
def refresh_thread(self, batch_uuid, progress=None, cognizer_uuid=None, success_url=None):
"""
Thread target for refreshing batch data with progress indicator.
"""
# Refresh data for the batch, with progress. Note that we must use the
# rattail session here; can't use tailbone because it has web request
# transaction binding etc.
session = RattailSession()
batch = session.query(self.model_class).get(batch_uuid)
cognizer = session.query(model.User).get(cognizer_uuid) if cognizer_uuid else None
try:
self.refresh_data(session, batch, cognizer=cognizer, progress=progress)
except Exception as error:
session.rollback()
log.warning("refreshing data for batch failed: {}".format(batch), exc_info=True)
session.close()
if progress:
progress.session.load()
progress.session['error'] = True
progress.session['error_msg'] = "Data refresh failed: {}".format(error)
progress.session.save()
return
session.commit()
session.refresh(batch)
session.close()
# Finalize progress indicator.
if progress:
progress.session.load()
progress.session['complete'] = True
progress.session['success_url'] = success_url or self.get_action_url('view', batch)
progress.session.save()
########################################
# batch rows
########################################
def get_row_model_title(self):
return "{} Row".format(self.get_model_title())
def get_row_model_title_plural(self):
return "{} Rows".format(self.get_model_title())
@classmethod
def get_row_permission_prefix(cls):
"""
Permission prefix specific to the row-level data for this batch type,
e.g. ``'vendorcatalogs.rows'``.
"""
return "{}.rows".format(cls.get_permission_prefix())
@classmethod
def get_row_route_prefix(cls):
"""
Route prefix specific to the row-level views for a batch, e.g.
``'vendorcatalogs.rows'``.
"""
return "{}.rows".format(cls.get_route_prefix())
def make_row_grid(self, **kwargs):
"""
Make and return a new (configured) batch rows grid instance.
"""
batch = kwargs.pop('batch', self.get_instance())
key = '{}.{}'.format(self.get_grid_key(), batch.uuid)
data = self.get_row_data(batch)
kwargs = self.make_row_grid_kwargs(**kwargs)
grid = grids.AlchemyGrid(key, self.request, data=data, model_class=self.batch_row_class, **kwargs)
self._preconfigure_row_grid(grid)
self.configure_row_grid(grid)
grid.load_settings()
return grid
def _preconfigure_row_grid(self, g):
g.filters['status_code'].label = "Status"
g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.batch_row_class.STATUS))
g.default_sortkey = 'sequence'
g.sequence.set(label="Seq.")
g.status_code.set(label="Status",
renderer=StatusRenderer(self.batch_row_class.STATUS))
def configure_row_grid(self, grid):
grid.configure()
def get_row_data(self, batch):
"""
Generate the base data set for a rows grid.
"""
session = orm.object_session(batch)
return session.query(self.batch_row_class)\
.filter(self.batch_row_class.batch == batch)\
.filter(self.batch_row_class.removed == False)
def make_row_grid_kwargs(self, **kwargs):
"""
Return a dict of kwargs to be used when constructing a new rows grid.
"""
route_prefix = self.get_row_route_prefix()
permission_prefix = self.get_row_permission_prefix()
defaults = {
'width': 'full',
'filterable': True,
'sortable': True,
'pageable': True,
'row_attrs': self.row_grid_row_attrs,
'model_title': self.get_row_model_title(),
'model_title_plural': self.get_row_model_title_plural(),
'permission_prefix': permission_prefix,
'route_prefix': route_prefix,
}
if 'main_actions' not in defaults:
actions = []
# view action
if self.rows_viewable:
view = lambda r: self.request.route_url('{}.view'.format(route_prefix),
uuid=r.uuid)
actions.append(grids.GridAction('view', icon='zoomin', url=view))
# delete action
batch = self.get_instance()
if self.editing and self.rows_deletable and not batch.executed:
delete = lambda r: self.request.route_url('{}.delete'.format(route_prefix),
uuid=r.uuid)
actions.append(grids.GridAction('delete', icon='trash', url=delete))
defaults['main_actions'] = actions
defaults.update(kwargs)
return defaults
def row_grid_row_attrs(self, row, i):
pass
def view_row(self):
"""
View for viewing details of a single batch row.
"""
self.viewing = True
row = self.get_row_instance()
form = self.make_row_form(row)
return self.render_to_response('view_row', {
'instance': row,
'instance_title': self.get_row_instance_title(row),
'instance_editable': False,
'instance_deletable': False,
'model_title': self.get_row_model_title(),
'model_title_plural': self.get_row_model_title_plural(),
'batch_model_title': self.get_model_title(),
'index_url': self.get_action_url('view', row.batch),
'form': form})
def get_row_instance(self):
key = self.request.matchdict[self.get_model_key()]
instance = self.Session.query(self.batch_row_class).get(key)
if not instance:
raise httpexceptions.HTTPNotFound()
return instance
def get_row_instance_title(self, instance):
return self.get_row_model_title()
def make_row_form(self, instance, **kwargs):
"""
Make a FormAlchemy form for use with CRUD views for a batch *row*.
"""
# TODO: Some hacky stuff here, to accommodate old form cruft. Probably
# should refactor forms soon too, but trying to avoid it for the moment.
kwargs.setdefault('creating', self.creating)
kwargs.setdefault('editing', self.editing)
fieldset = self.make_fieldset(instance)
self.configure_row_fieldset(fieldset)
kwargs.setdefault('action_url', self.request.current_route_url(_query=None))
if self.creating:
kwargs.setdefault('cancel_url', self.get_action_url('view', instance.batch))
else:
kwargs.setdefault('cancel_url', self.request.route_url('{}.view'.format(self.get_row_route_prefix()),
uuid=instance.uuid))
form = forms.AlchemyForm(self.request, fieldset, **kwargs)
form.readonly = self.viewing
return form
def configure_row_fieldset(self, fs):
fs.configure()
def delete_row(self):
"""
"Delete" a row from the batch. This sets the ``removed`` flag on the
row but does not truly delete it.
"""
row = self.Session.query(self.batch_row_class).get(self.request.matchdict['uuid'])
if not row:
raise httpexceptions.HTTPNotFound()
row.removed = True
return self.redirect(self.get_action_url('edit', row.batch))
def bulk_delete_rows(self):
"""
"Delete" all rows matching the current row grid view query. This sets
the ``removed`` flag on the rows but does not truly delete them.
"""
query = self.get_effective_row_query()
query.update({'removed': True}, synchronize_session=False)
return self.redirect(self.get_action_url('view', self.get_instance()))
def get_effective_row_query(self):
"""
Convenience method which returns the "effective" query for the master
grid, filtered and sorted to match what would show on the UI, but not
paged etc.
"""
batch = self.get_instance()
grid = self.make_row_grid(batch=batch, sortable=False, pageable=False,
main_actions=[])
return grid._fa_grid.rows
def execute(self):
"""
Execute a batch. Starts a separate thread for the execution, and
displays a progress indicator page.
"""
batch = self.get_instance()
key = '{}.execute'.format(self.model_class.__tablename__)
progress = SessionProgress(self.request, key)
thread = Thread(target=self.execute_thread, args=(batch.uuid, self.request.user.uuid, progress))
thread.start()
kwargs = {
'key': key,
'cancel_url': self.get_action_url('view', batch),
'cancel_msg': "Batch execution was canceled.",
}
return render_to_response('/progress.mako', kwargs, request=self.request)
def execute_thread(self, batch_uuid, user_uuid, progress=None):
"""
Thread target for executing a batch with progress indicator.
"""
# Execute the batch, with progress. Note that we must use the rattail
# session here; can't use tailbone because it has web request
# transaction binding etc.
session = RattailSession()
batch = session.query(self.model_class).get(batch_uuid)
try:
result = self.handler.execute(batch, progress=progress)
# If anything goes wrong, rollback and log the error etc.
except Exception as error:
session.rollback()
log.exception("execution failed for batch: {}".format(batch))
session.close()
if progress:
progress.session.load()
progress.session['error'] = True
progress.session['error_msg'] = "Batch execution failed: {}".format(error)
progress.session.save()
# If no error, check result flag (false means user canceled).
else:
if result:
batch.executed = datetime.datetime.utcnow()
batch.executed_by_uuid = user_uuid
session.commit()
else:
session.rollback()
session.refresh(batch)
session.close()
if progress:
progress.session.load()
progress.session['complete'] = True
progress.session['success_url'] = self.get_action_url('view', batch)
progress.session.save()
def csv(self):
"""
Download batch data as CSV.
"""
batch = self.get_instance()
fields = self.get_csv_fields()
data = StringIO()
writer = UnicodeDictWriter(data, fields)
writer.writeheader()
for row in batch.data_rows:
if not row.removed:
writer.writerow(self.get_csv_row(row, fields))
response = self.request.response
response.text = data.getvalue().decode('utf_8')
data.close()
response.content_length = len(response.text)
response.content_type = b'text/csv'
response.content_disposition = b'attachment; filename=batch.csv'
return response
def get_csv_fields(self):
"""
Return the list of fields to be written to CSV download.
"""
fields = []
mapper = orm.class_mapper(self.batch_row_class)
for prop in mapper.iterate_properties:
if isinstance(prop, orm.ColumnProperty):
if prop.key != 'removed' and not prop.key.endswith('uuid'):
fields.append(prop.key)
return fields
def get_csv_row(self, row, fields):
"""
Return a dict for use when writing the row's data to CSV download.
"""
csvrow = {}
for field in fields:
value = getattr(row, field)
csvrow[field] = '' if value is None else unicode(value)
return csvrow
@classmethod
def defaults(cls, config):
cls._batch_defaults(config)
cls._defaults(config)
@classmethod
def _batch_defaults(cls, config):
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
permission_prefix = cls.get_permission_prefix()
model_title = cls.get_model_title()
# view row
if cls.rows_viewable:
config.add_route('{}.rows.view'.format(route_prefix), '{}/rows/{{uuid}}'.format(url_prefix))
config.add_view(cls, attr='view_row', route_name='{}.rows.view'.format(route_prefix),
permission='{}.rows.view'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.rows.view'.format(permission_prefix),
"View {} Row Details".format(model_title))
# refresh rows data
config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix))
config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix),
permission='{}.create'.format(permission_prefix))
# delete row
config.add_route('{}.rows.delete'.format(route_prefix), '{}/delete-row/{{uuid}}'.format(url_prefix))
config.add_view(cls, attr='delete_row', route_name='{}.rows.delete'.format(route_prefix),
permission='{}.edit'.format(permission_prefix))
# bulk delete rows
config.add_route('{}.rows.bulk_delete'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix))
config.add_view(cls, attr='bulk_delete_rows', route_name='{}.rows.bulk_delete'.format(route_prefix),
permission='{}.edit'.format(permission_prefix))
# execute batch
config.add_route('{}.execute'.format(route_prefix), '{}/{{uuid}}/execute'.format(url_prefix))
config.add_view(cls, attr='execute', route_name='{}.execute'.format(route_prefix),
permission='{}.execute'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.execute'.format(permission_prefix),
"Execute {} batches".format(model_title))
# download rows as CSV
config.add_route('{}.csv'.format(route_prefix), '{}/{{uuid}}/csv'.format(url_prefix))
config.add_view(cls, attr='csv', route_name='{}.csv'.format(route_prefix),
permission='{}.csv'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.csv'.format(permission_prefix),
"Download {} rows as CSV".format(model_title))
class FileBatchMasterView(BatchMasterView):
"""
Base class for all file-based "batch master" views.
"""
refreshable = True
@property
def upload_dir(self):
"""
The path to the root upload folder, to be used as the ``storage_path``
argument for the file field renderer.
"""
uploads = os.path.join(
self.rattail_config.require('rattail', 'batch.files'),
'uploads')
uploads = self.rattail_config.get('tailbone', 'batch.uploads',
default=uploads)
if not os.path.exists(uploads):
os.makedirs(uploads)
return uploads
def preconfigure_grid(self, g):
super(FileBatchMasterView, self).preconfigure_grid(g)
g.created.set(label="Uploaded")
g.created_by.set(label="Uploaded by")
def _preconfigure_fieldset(self, fs):
super(FileBatchMasterView, self)._preconfigure_fieldset(fs)
fs.created.set(label="Uploaded")
fs.created_by.set(label="Uploaded by")
fs.filename.set(label="Data File", renderer=FileFieldRenderer.new(self))
if self.editing:
fs.filename.set(readonly=True)
def configure_fieldset(self, fs):
"""
Apply final configuration to the main batch fieldset. Custom batch
views are encouraged to override this method.
"""
if self.creating:
fs.configure(
include=[
fs.filename,
])
else:
batch = fs.model
if batch.executed:
fs.configure(
include=[
fs.created,
fs.created_by,
fs.filename,
fs.executed,
fs.executed_by,
])
else:
fs.configure(
include=[
fs.created,
fs.created_by,
fs.filename,
])
def save_create_form(self, form):
self.before_create(form)
# Transfer form data to batch instance.
form.fieldset.sync()
batch = form.fieldset.model
# Assign current user as creator.
with Session.no_autoflush:
batch.created_by = self.request.user or self.late_login_user()
# TODO: Wouldn't this be handled sufficiently by `no_autoflush` ?
# Expunge batch from session to prevent it from being flushed
# during init. This is done as a convenience to views which
# provide an init method. Some batches may have required fields
# which aren't filled in yet, but the view may need to query the
# database to obtain the values. This will cause a session flush,
# and the missing fields will trigger data integrity errors.
Session.expunge(batch)
self.batch_inited = self.init_batch(batch)
if self.batch_inited:
Session.add(batch)
Session.flush()
# Handler saves a copy of the file and updates the batch filename.
path = os.path.join(self.upload_dir, batch.filename)
self.handler.set_data_file(batch, path)
os.remove(path)
else: # batch init failed
# Here we assume that the :meth:`init_batch()` method responsible
# for indicating the failure will have set a flash message for the
# user with more info.
raise self.redirect(self.request.current_route_url())
def redirect_after_create(self, batch):
return self.redirect(self.get_action_url('refresh', batch))
def download(self):
"""
View for downloading the data file associated with a batch.
"""
batch = self.get_instance()
if not batch:
raise httpexceptions.HTTPNotFound()
path = self.handler.data_path(batch)
response = FileResponse(path, request=self.request)
response.headers[b'Content-Length'] = str(os.path.getsize(path))
filename = os.path.basename(batch.filename).encode('ascii', 'replace')
response.headers[b'Content-Disposition'] = b'attachment; filename="{}"'.format(filename)
return response
@classmethod
def defaults(cls, config):
cls._filebatch_defaults(config)
cls._batch_defaults(config)
cls._defaults(config)
@classmethod
def _filebatch_defaults(cls, config):
route_prefix = cls.get_route_prefix()
url_prefix = cls.get_url_prefix()
permission_prefix = cls.get_permission_prefix()
model_title = cls.get_model_title()
# download batch data file
config.add_route('{}.download'.format(route_prefix), '{}{{uuid}}/download'.format(url_prefix))
config.add_view(cls, attr='download', route_name='{}.download'.format(route_prefix),
permission='{}.download'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.download'.format(permission_prefix),
"Download existing {} data file".format(model_title))
class BaseGrid(SearchableAlchemyGridView):
"""
Base view for batch and batch row grid views. You should not derive from
this class, but :class:`BatchGrid` or :class:`BatchRowGrid` instead.
"""
@property
def config_prefix(self):
"""
Config prefix for the grid view. This is used to keep track of current
filtering and sorting, within the user's session. Derived classes may
override this.
"""
return self.mapped_class.__name__.lower()
@property
def permission_prefix(self):
"""
Permission prefix for the grid view. This is used to automatically
protect certain views common to all batches. Derived classes can
override this.
"""
return self.route_prefix
def join_map_extras(self):
"""
Derived classes can override this. The value returned will be used to
supplement the default join map.
"""
return {}
def filter_map_extras(self):
"""
Derived classes can override this. The value returned will be used to
supplement the default filter map.
"""
return {}
def make_filter_map(self, **kwargs):
"""
Make a filter map by combining kwargs from the base class, with extras
supplied by a derived class.
"""
extras = self.filter_map_extras()
exact = extras.pop('exact', None)
if exact:
kwargs.setdefault('exact', []).extend(exact)
ilike = extras.pop('ilike', None)
if ilike:
kwargs.setdefault('ilike', []).extend(ilike)
kwargs.update(extras)
return super(BaseGrid, self).make_filter_map(**kwargs)
def filter_config_extras(self):
"""
Derived classes can override this. The value returned will be used to
supplement the default filter config.
"""
return {}
def sort_map_extras(self):
"""
Derived classes can override this. The value returned will be used to
supplement the default sort map.
"""
return {}
def _configure_grid(self, grid):
"""
Internal method for configuring the grid. This is meant only for base
classes; derived classes should not need to override it.
"""
def configure_grid(self, grid):
"""
Derived classes can override this. Customizes a grid which has already
been created with defaults by the base class.
"""
class BatchGrid(BaseGrid):
"""
Base grid view for batches, which can be filtered and sorted.
"""
@property
def batch_class(self):
raise NotImplementedError
@property
def mapped_class(self):
return self.batch_class
@property
def batch_display(self):
"""
Singular display text for the batch type, e.g. "Vendor Invoice".
Override this as necessary.
"""
return self.batch_class.__name__
@property
def batch_display_plural(self):
"""
Plural display text for the batch type, e.g. "Vendor Invoices".
Override this as necessary.
"""
return "{0}s".format(self.batch_display)
def join_map(self):
"""
Provides the default join map for batch grid views. Derived classes
should *not* override this, but :meth:`join_map_extras()` instead.
"""
map_ = {
'created_by':
lambda q: q.join(model.User, model.User.uuid == self.batch_class.created_by_uuid),
'executed_by':
lambda q: q.outerjoin(model.User, model.User.uuid == self.batch_class.executed_by_uuid),
}
map_.update(self.join_map_extras())
return map_
def filter_map(self):
"""
Provides the default filter map for batch grid views. Derived classes
should *not* override this, but :meth:`filter_map_extras()` instead.
"""
def executed_is(q, v):
if v == 'True':
return q.filter(self.batch_class.executed != None)
else:
return q.filter(self.batch_class.executed == None)
def executed_nt(q, v):
if v == 'True':
return q.filter(self.batch_class.executed == None)
else:
return q.filter(self.batch_class.executed != None)
return self.make_filter_map(
executed={'is': executed_is, 'nt': executed_nt})
def filter_config(self):
"""
Provides the default filter config for batch grid views. Derived
classes should *not* override this, but :meth:`filter_config_extras()`
instead.
"""
defaults = self.filter_config_extras()
config = self.make_filter_config(
filter_factory_executed=BooleanSearchFilter,
filter_type_executed='is',
executed=False,
include_filter_executed=True)
defaults.update(config)
return defaults
def sort_map(self):
"""
Provides the default sort map for batch grid views. Derived classes
should *not* override this, but :meth:`sort_map_extras()` instead.
"""
map_ = self.make_sort_map(
created_by=self.sorter(model.User.username),
executed_by=self.sorter(model.User.username))
map_.update(self.sort_map_extras())
return map_
def sort_config(self):
"""
Provides the default sort config for batch grid views. Derived classes
may override this.
"""
return self.make_sort_config(sort='created', dir='desc')
def grid(self):
"""
Creates the grid for the view. Derived classes should *not* override
this, but :meth:`configure_grid()` instead.
"""
g = self.make_grid()
g.created_by.set(renderer=forms.renderers.UserFieldRenderer)
g.cognized_by.set(renderer=forms.renderers.UserFieldRenderer)
g.executed_by.set(renderer=forms.renderers.UserFieldRenderer)
self._configure_grid(g)
self.configure_grid(g)
if self.request.has_perm('{0}.view'.format(self.permission_prefix)):
g.viewable = True
g.view_route_name = '{0}.view'.format(self.route_prefix)
if self.request.has_perm('{0}.edit'.format(self.permission_prefix)):
g.editable = True
g.edit_route_name = '{0}.edit'.format(self.route_prefix)
if self.request.has_perm('{0}.delete'.format(self.permission_prefix)):
g.deletable = True
g.delete_route_name = '{0}.delete'.format(self.route_prefix)
return g
def _configure_grid(self, grid):
grid.created_by.set(label="Created by")
grid.executed_by.set(label="Executed by")
def configure_grid(self, grid):
"""
Derived classes can override this. Customizes a grid which has already
been created with defaults by the base class.
"""
g = grid
g.configure(
include=[
g.created,
g.created_by,
g.executed,
g.executed_by,
],
readonly=True)
def render_kwargs(self):
"""
Add some things to the template context: batch type display name, route
and permission prefixes.
"""
return {
'batch_display': self.batch_display,
'batch_display_plural': self.batch_display_plural,
'route_prefix': self.route_prefix,
'permission_prefix': self.permission_prefix,
}
class FileBatchGrid(BatchGrid):
"""
Base grid view for batches, which involve primarily a file upload.
"""
def _configure_grid(self, g):
super(FileBatchGrid, self)._configure_grid(g)
g.created.set(label="Uploaded")
g.created_by.set(label="Uploaded by")
def configure_grid(self, grid):
"""
Derived classes can override this. Customizes a grid which has already
been created with defaults by the base class.
"""
g = grid
g.configure(
include=[
g.created,
g.created_by,
g.filename,
g.executed,
g.executed_by,
],
readonly=True)
class BaseCrud(CrudView):
"""
Base CRUD view for batches and batch rows.
"""
flash = {}
@property
def home_route(self):
"""
The "home" route for the batch type, i.e. its grid view.
"""
return self.route_prefix
@property
def permission_prefix(self):
"""
Permission prefix used to generically protect certain views common to
all batches. Derived classes can override this.
"""
return self.route_prefix
def flash_create(self, model):
if 'create' in self.flash:
self.request.session.flash(self.flash['create'])
else:
super(BaseCrud, self).flash_create(model)
def flash_update(self, model):
if 'update' in self.flash:
self.request.session.flash(self.flash['update'])
else:
super(BaseCrud, self).flash_update(model)
def flash_delete(self, model):
if 'delete' in self.flash:
self.request.session.flash(self.flash['delete'])
else:
super(BaseCrud, self).flash_delete(model)
class BatchCrud(BaseCrud):
"""
Base CRUD view for batches.
"""
refreshable = False
flash = {}
@property
def batch_class(self):
raise NotImplementedError
@property
def batch_row_class(self):
raise NotImplementedError
@property
def mapped_class(self):
return self.batch_class
@classmethod
def get_route_prefix(cls):
return cls.route_prefix
@property
def permission_prefix(self):
"""
Permission prefix for the grid view. This is used to automatically
protect certain views common to all batches. Derived classes can - and
typically should - override this.
"""
return self.route_prefix
@property
def batch_display_plural(self):
"""
Plural display text for the batch type.
"""
return "{0}s".format(self.batch_display)
def __init__(self, request):
self.request = request
self.handler = self.get_handler()
def get_handler(self):
"""
Returns a `BatchHandler` instance for the view. Derived classes may
override this as needed. The default is to create an instance of
:attr:`batch_handler_class`.
"""
return self.batch_handler_class(self.request.rattail_config)
def fieldset(self, model):
"""
Creates the fieldset for the view. Derived classes should *not*
override this, but :meth:`configure_fieldset()` instead.
"""
fs = self.make_fieldset(model)
fs.created.set(readonly=True)
fs.created_by.set(label="Created by", renderer=forms.renderers.UserFieldRenderer,
readonly=True)
fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer)
fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer)
self.configure_fieldset(fs)
if self.creating:
del fs.created
del fs.created_by
if 'cognized' in fs.render_fields:
del fs.cognized
if 'cognized_by' in fs.render_fields:
del fs.cognized_by
if 'executed' in fs.render_fields:
del fs.executed
if 'executed_by' in fs.render_fields:
del fs.executed_by
else:
batch = fs.model
if not batch.executed:
if 'executed' in fs.render_fields:
del fs.executed
if 'executed_by' in fs.render_fields:
del fs.executed_by
return fs
def configure_fieldset(self, fieldset):
"""
Derived classes can override this. Customizes a fieldset which has
already been created with defaults by the base class.
"""
fs = fieldset
fs.configure(
include=[
fs.created,
fs.created_by,
# fs.cognized,
# fs.cognized_by,
fs.executed,
fs.executed_by,
])
def init_batch(self, batch):
"""
Initialize a new batch. Derived classes can override this to
effectively provide default values for a batch, etc. This method is
invoked after a batch has been fully prepared for insertion to the
database, but before the push to the database occurs.
Note that the return value of this function matters; if it is boolean
false then the batch will not be persisted at all, and the user will be
redirected to the "create batch" page.
"""
return True
def save_form(self, form):
"""
Save the uploaded data file if necessary, etc. If batch initialization
fails, don't persist the batch at all; the user will be sent back to
the "create batch" page in that case.
"""
# Transfer form data to batch instance.
form.fieldset.sync()
batch = form.fieldset.model
# For new batches, assign current user as creator, etc.
if self.creating:
with Session.no_autoflush:
batch.created_by = self.request.user or self.late_login_user()
# Expunge batch from session to prevent it from being flushed
# during init. This is done as a convenience to views which
# provide an init method. Some batches may have required fields
# which aren't filled in yet, but the view may need to query the
# database to obtain the values. This will cause a session flush,
# and the missing fields will trigger data integrity errors.
Session.expunge(batch)
self.batch_inited = self.init_batch(batch)
if self.batch_inited:
Session.add(batch)
Session.flush()
def update(self):
"""
Don't allow editing a batch which has already been executed.
"""
batch = self.get_model_from_request()
if not batch:
raise httpexceptions.HTTPNotFound()
if batch.executed:
raise httpexceptions.HTTPFound(location=self.view_url(batch.uuid))
return self.crud(batch)
def post_create_url(self, form):
"""
Redirect to view batch after creating a batch.
"""
batch = form.fieldset.model
Session.flush()
return self.view_url(batch.uuid)
def post_update_url(self, form):
"""
Redirect back to edit batch page after editing a batch, unless the
refresh flag is set, in which case do that.
"""
if self.request.params.get('refresh') == 'true':
return self.refresh_url()
return self.request.current_route_url()
def template_kwargs(self, form):
"""
Add some things to the template context: current batch model, batch
type display name, route and permission prefixes, batch row grid.
"""
batch = form.fieldset.model
batch.refreshable = self.refreshable
kwargs = {
'batch': batch,
'batch_display': self.batch_display,
'batch_display_plural': self.batch_display_plural,
'execute_title': self.handler.get_execute_title(batch),
'route_prefix': self.route_prefix,
'permission_prefix': self.permission_prefix,
}
if not self.creating:
kwargs['execute_enabled'] = self.executable(batch)
return kwargs
def executable(self, batch):
return self.handler.executable(batch)
def flash_create(self, batch):
if 'create' in self.flash:
self.request.session.flash(self.flash['create'])
else:
super(BatchCrud, self).flash_create(batch)
def flash_delete(self, batch):
if 'delete' in self.flash:
self.request.session.flash(self.flash['delete'])
else:
super(BatchCrud, self).flash_delete(batch)
def current_batch(self):
"""
Return the current batch, based on the UUID within the URL.
"""
return Session.query(self.mapped_class).get(self.request.matchdict['uuid'])
def refresh(self):
"""
View which will attempt to refresh all data for the batch. What
exactly this means will depend on the type of batch etc.
"""
batch = self.current_batch()
# If handler doesn't declare the need for progress indicator, things
# are nice and simple.
if not self.handler.show_progress:
self.refresh_data(Session, batch)
self.request.session.flash("Batch data has been refreshed.")
raise httpexceptions.HTTPFound(location=self.view_url(batch.uuid))
# Showing progress requires a separate thread; start that first.
key = '{0}.refresh'.format(self.batch_class.__tablename__)
progress = SessionProgress(self.request, key)
thread = Thread(target=self.refresh_thread, args=(batch.uuid, progress))
thread.start()
# Send user to progress page.
kwargs = {
'key': key,
'cancel_url': self.view_url(batch.uuid),
'cancel_msg': "Batch refresh was canceled.",
}
return render_to_response('/progress.mako', kwargs, request=self.request)
def refresh_data(self, session, batch, cognizer=None, progress=None):
"""
Instruct the batch handler to refresh all data for the batch.
"""
self.handler.refresh_data(session, batch, progress=progress)
batch.cognized = datetime.datetime.utcnow()
if cognizer is not None:
batch.cognized_by = cognizer
else:
batch.cognized_by = session.merge(self.request.user)
def refresh_thread(self, batch_uuid, progress=None, cognizer_uuid=None, success_url=None):
"""
Thread target for refreshing batch data with progress indicator.
"""
# Refresh data for the batch, with progress. Note that we must use the
# rattail session here; can't use tailbone because it has web request
# transaction binding etc.
session = RattailSession()
batch = session.query(self.batch_class).get(batch_uuid)
cognizer = session.query(model.User).get(cognizer_uuid) if cognizer_uuid else None
try:
self.refresh_data(session, batch, cognizer=cognizer, progress=progress)
except Exception as error:
session.rollback()
log.warning("refreshing data for batch failed: {0}".format(batch), exc_info=True)
session.close()
if progress:
progress.session.load()
progress.session['error'] = True
progress.session['error_msg'] = "Data refresh failed: {0}".format(error)
progress.session.save()
return
session.commit()
session.refresh(batch)
session.close()
# Finalize progress indicator.
if progress:
progress.session.load()
progress.session['complete'] = True
progress.session['success_url'] = success_url or self.view_url(batch.uuid)
progress.session.save()
def view_url(self, uuid=None):
"""
Returns the URL for viewing a batch; defaults to current batch.
"""
if uuid is None:
uuid = self.request.matchdict['uuid']
return self.request.route_url('{0}.view'.format(self.route_prefix), uuid=uuid)
def refresh_url(self, uuid=None):
"""
Returns the URL for refreshing a batch; defaults to current batch.
"""
if uuid is None:
uuid = self.request.matchdict['uuid']
return self.request.route_url('{0}.refresh'.format(self.route_prefix), uuid=uuid)
def execute(self):
"""
Execute a batch. Starts a separate thread for the execution, and
displays a progress indicator page.
"""
batch = self.current_batch()
key = '{0}.execute'.format(self.batch_class.__tablename__)
progress = SessionProgress(self.request, key)
thread = Thread(target=self.execute_thread, args=(batch.uuid, progress))
thread.start()
kwargs = {
'key': key,
'cancel_url': self.view_url(batch.uuid),
'cancel_msg': "Batch execution was canceled.",
}
return render_to_response('/progress.mako', kwargs, request=self.request)
def execute_thread(self, batch_uuid, progress=None):
"""
Thread target for executing a batch with progress indicator.
"""
# Execute the batch, with progress. Note that we must use the rattail
# session here; can't use tailbone because it has web request
# transaction binding etc.
session = RattailSession()
batch = session.query(self.batch_class).get(batch_uuid)
try:
result = self.handler.execute(batch, progress=progress)
# If anything goes wrong, rollback and log the error etc.
except Exception as error:
session.rollback()
log.exception("execution failed for batch: {0}".format(batch))
session.close()
if progress:
progress.session.load()
progress.session['error'] = True
progress.session['error_msg'] = "Batch execution failed: {0}".format(error)
progress.session.save()
# If no error, check result flag (false means user canceled).
else:
if result:
batch.executed = datetime.datetime.utcnow()
batch.executed_by = session.merge(self.request.user)
session.commit()
else:
session.rollback()
session.refresh(batch)
session.close()
if progress:
progress.session.load()
progress.session['complete'] = True
progress.session['success_url'] = self.view_url(batch.uuid)
progress.session.save()
def csv(self):
"""
Download batch data as CSV.
"""
batch = self.current_batch()
fields = self.get_csv_fields()
data = StringIO()
writer = UnicodeDictWriter(data, fields)
writer.writeheader()
for row in batch.data_rows:
if not row.removed:
writer.writerow(self.get_csv_row(row, fields))
response = self.request.response
response.text = data.getvalue().decode('utf_8')
data.close()
response.content_length = len(response.text)
response.content_type = b'text/csv'
response.content_disposition = b'attachment; filename=batch.csv'
return response
def get_csv_fields(self):
"""
Return the list of fields to be written to CSV download.
"""
fields = []
mapper = orm.class_mapper(self.batch_row_class)
for prop in mapper.iterate_properties:
if isinstance(prop, orm.ColumnProperty):
if prop.key != 'removed' and not prop.key.endswith('uuid'):
fields.append(prop.key)
return fields
def get_csv_row(self, row, fields):
"""
Return a dict for use when writing the row's data to CSV download.
"""
csvrow = {}
for field in fields:
value = getattr(row, field)
csvrow[field] = '' if value is None else unicode(value)
return csvrow
class FileBatchCrud(BatchCrud):
"""
Base CRUD view for batches which involve a file upload as the first step.
"""
refreshable = True
def post_create_url(self, form):
"""
Redirect to refresh batch after creating a batch.
"""
batch = form.fieldset.model
Session.flush()
return self.refresh_url(batch.uuid)
@property
def upload_dir(self):
"""
The path to the root upload folder, to be used as the ``storage_path``
argument for the file field renderer.
"""
uploads = os.path.join(
self.request.rattail_config.require('rattail', 'batch.files'),
'uploads')
uploads = self.request.rattail_config.get(
'tailbone', 'batch.uploads', default=uploads)
if not os.path.exists(uploads):
os.makedirs(uploads)
return uploads
def fieldset(self, model):
"""
Creates the fieldset for the view. Derived classes should *not*
override this, but :meth:`configure_fieldset()` instead.
"""
fs = self.make_fieldset(model)
fs.created.set(label="Uploaded", readonly=True)
fs.created_by.set(label="Uploaded by", renderer=forms.renderers.UserFieldRenderer, readonly=True)
fs.cognized_by.set(label="Cognized by", renderer=forms.renderers.UserFieldRenderer)
fs.executed_by.set(label="Executed by", renderer=forms.renderers.UserFieldRenderer)
fs.filename.set(renderer=FileFieldRenderer.new(self), label="Data File")
self.configure_fieldset(fs)
if self.creating:
if 'created' in fs.render_fields:
del fs.created
if 'created_by' in fs.render_fields:
del fs.created_by
if 'cognized' in fs.render_fields:
del fs.cognized
if 'cognized_by' in fs.render_fields:
del fs.cognized_by
if 'executed' in fs.render_fields:
del fs.executed
if 'executed_by' in fs.render_fields:
del fs.executed_by
if 'data_rows' in fs.render_fields:
del fs.data_rows
else:
if self.updating and 'filename' in fs.render_fields:
fs.filename.set(readonly=True)
batch = fs.model
if not batch.executed:
if 'executed' in fs.render_fields:
del fs.executed
if 'executed_by' in fs.render_fields:
del fs.executed_by
return fs
def configure_fieldset(self, fieldset):
"""
Derived classes can override this. Customizes a fieldset which has
already been created with defaults by the base class.
"""
fs = fieldset
fs.configure(
include=[
fs.created,
fs.created_by,
fs.filename,
# fs.cognized,
# fs.cognized_by,
fs.executed,
fs.executed_by,
])
def save_form(self, form):
"""
Save the uploaded data file if necessary, etc. If batch initialization
fails, don't persist the batch at all; the user will be sent back to
the "create batch" page in that case.
"""
# Transfer form data to batch instance.
form.fieldset.sync()
batch = form.fieldset.model
# For new batches, assign current user as creator, save file etc.
if self.creating:
with Session.no_autoflush:
batch.created_by = self.request.user or self.late_login_user()
# Expunge batch from session to prevent it from being flushed
# during init. This is done as a convenience to views which
# provide an init method. Some batches may have required fields
# which aren't filled in yet, but the view may need to query the
# database to obtain the values. This will cause a session flush,
# and the missing fields will trigger data integrity errors.
Session.expunge(batch)
self.batch_inited = self.init_batch(batch)
if self.batch_inited:
Session.add(batch)
Session.flush()
# Handler saves a copy of the file and updates the batch filename.
path = os.path.join(self.upload_dir, batch.filename)
self.handler.set_data_file(batch, path)
os.remove(path)
def post_save(self, form):
"""
This checks for failed batch initialization when creating a new batch.
If a failure is detected, the user is redirected to the page for
creating new batches. The assumption here is that the
:meth:`init_batch()` method responsible for indicating the failure will
have set a flash message for the user with more info.
"""
if self.creating and not self.batch_inited:
raise httpexceptions.HTTPFound(location=self.request.route_url(
'{}.create'.format(self.route_prefix)))
def pre_delete(self, batch):
"""
Delete all data (files etc.) for the batch.
"""
batch.delete_data(self.request.rattail_config)
del batch.data_rows[:]
def download(self):
"""
View for downloading the data file associated with a batch.
"""
batch = self.current_batch()
if not batch:
raise httpexceptions.HTTPNotFound()
path = self.handler.data_path(batch)
response = FileResponse(path, request=self.request)
response.headers[b'Content-Length'] = str(os.path.getsize(path))
filename = os.path.basename(batch.filename).encode('ascii', 'replace')
response.headers[b'Content-Disposition'] = b'attachment; filename="{0}"'.format(filename)
return response
class StatusRenderer(forms.renderers.EnumFieldRenderer):
"""
Custom renderer for ``status_code`` fields. Adds ``status_text`` value as
title attribute if it exists.
"""
def render_readonly(self, **kwargs):
value = self.raw_value
if value is None:
return ''
status_code_text = self.enumeration.get(value, unicode(value))
row = self.field.parent.model
if row.status_text:
return HTML.tag('span', title=row.status_text, c=status_code_text)
return status_code_text
class BatchRowGrid(BaseGrid):
"""
Base grid view for batch rows, which can be filtered and sorted. Also it
can delete all rows matching the current list view query.
"""
@property
def row_class(self):
raise NotImplementedError
@property
def mapped_class(self):
return self.row_class
@property
def config_prefix(self):
"""
Config prefix for the grid view. This is used to keep track of current
filtering and sorting, within the user's session. Derived classes may
override this.
"""
return '{0}.{1}'.format(self.mapped_class.__name__.lower(),
self.request.matchdict['uuid'])
@property
def batch_class(self):
"""
Model class of the batch to which the rows belong.
"""
return self.row_class.__batch_class__
@property
def batch_display(self):
"""
Singular display text for the batch type, e.g. "Vendor Invoice".
Override this as necessary.
"""
return self.batch_class.__name__
def current_batch(self):
"""
Return the current batch, based on the UUID within the URL.
"""
return Session.query(self.batch_class).get(self.request.matchdict['uuid'])
def modify_query(self, q):
q = super(BatchRowGrid, self).modify_query(q)
q = q.filter(self.row_class.batch == self.current_batch())
q = q.filter(self.row_class.removed == False)
return q
def join_map(self):
"""
Provides the default join map for batch row grid views. Derived
classes should *not* override this, but :meth:`join_map_extras()`
instead.
"""
return self.join_map_extras()
def filter_map(self):
"""
Provides the default filter map for batch row grid views. Derived
classes should *not* override this, but :meth:`filter_map_extras()`
instead.
"""
return self.make_filter_map(exact=['status_code'])
def filter_config(self):
"""
Provides the default filter config for batch grid views. Derived
classes should *not* override this, but :meth:`filter_config_extras()`
instead.
"""
kwargs = {'filter_label_status_code': "Status",
'filter_factory_status_code': EnumSearchFilter(self.row_class.STATUS)}
kwargs.update(self.filter_config_extras())
return self.make_filter_config(**kwargs)
def sort_map(self):
"""
Provides the default sort map for batch grid views. Derived classes
should *not* override this, but :meth:`sort_map_extras()` instead.
"""
map_ = self.make_sort_map()
map_.update(self.sort_map_extras())
return map_
def sort_config(self):
"""
Provides the default sort config for batch grid views. Derived classes
may override this.
"""
return self.make_sort_config(sort='sequence', dir='asc')
def grid(self):
"""
Creates the grid for the view. Derived classes should *not* override
this, but :meth:`configure_grid()` instead.
"""
g = self.make_grid()
g.extra_row_class = self.tr_class
g.sequence.set(label="Seq.")
g.status_code.set(label="Status", renderer=StatusRenderer(self.row_class.STATUS))
self._configure_grid(g)
self.configure_grid(g)
batch = self.current_batch()
g.viewable = True
g.view_route_name = '{0}.row.view'.format(self.route_prefix)
# TODO: Fix this check for edit mode.
edit_mode = self.request.referrer.endswith('/edit')
if edit_mode and not batch.executed and self.request.has_perm('{0}.edit'.format(self.permission_prefix)):
# g.editable = True
# g.edit_route_name = '{0}.rows.edit'.format(self.route_prefix)
g.deletable = True
g.delete_route_name = '{0}.rows.delete'.format(self.route_prefix)
return g
def tr_class(self, row, i):
pass
def render_kwargs(self):
"""
Add the current batch and route prefix to the template context.
"""
return {'batch': self.current_batch(),
'route_prefix': self.route_prefix}
def bulk_delete(self):
"""
"Delete" all rows matching the current row grid view query. This sets
the ``removed`` flag on the rows but does not truly delete them.
"""
self.query().update({'removed': True}, synchronize_session=False)
return httpexceptions.HTTPFound(location=self.request.route_url('{}.view'.format(self.route_prefix),
uuid=self.request.matchdict['uuid']))
class ProductBatchRowGrid(BatchRowGrid):
"""
Base grid view for batch rows which deal directly with products.
"""
def filter_map(self):
"""
Provides the default filter map for batch row grid views. Derived
classes should *not* override this, but :meth:`filter_map_extras()`
instead.
"""
return self.make_filter_map(exact=['status_code'],
ilike=['brand_name', 'description', 'size'],
upc=self.filter_gpc(self.row_class.upc))
def filter_config(self):
"""
Provides the default filter config for batch grid views. Derived
classes should *not* override this, but :meth:`filter_config_extras()`
instead.
"""
kwargs = {'filter_label_status_code': "Status",
'filter_factory_status_code': EnumSearchFilter(self.row_class.STATUS),
'filter_label_upc': "UPC",
'filter_label_brand_name': "Brand"}
kwargs.update(self.filter_config_extras())
return self.make_filter_config(**kwargs)
class BatchRowCrud(BaseCrud):
"""
Base CRUD view for batch rows.
"""
@property
def row_class(self):
raise NotImplementedError
@property
def mapped_class(self):
return self.row_class
@property
def batch_class(self):
"""
Model class of the batch to which the rows belong.
"""
return self.row_class.__batch_class__
@property
def batch_display(self):
"""
Singular display text for the batch type, e.g. "Vendor Invoice".
Override this as necessary.
"""
return self.batch_class.__name__
def fieldset(self, row):
"""
Creates the fieldset for the view. Derived classes should *not*
override this, but :meth:`configure_fieldset()` instead.
"""
fs = self.make_fieldset(row)
self.configure_fieldset(fs)
return fs
def configure_fieldset(self, fieldset):
"""
Derived classes can override this. Customizes a fieldset which has
already been created with defaults by the base class.
"""
fieldset.configure()
def template_kwargs(self, form):
"""
Add batch row instance etc. to template context.
"""
row = form.fieldset.model
return {
'row': row,
'batch_display': self.batch_display,
'route_prefix': self.route_prefix,
'permission_prefix': self.permission_prefix,
}
def delete(self):
"""
"Delete" a row from the batch. This sets the ``removed`` flag on the
row but does not truly delete it.
"""
row = self.get_model_from_request()
if not row:
raise httpexceptions.HTTPNotFound()
row.removed = True
return httpexceptions.HTTPFound(location=self.request.route_url('{}.view'.format(self.route_prefix),
uuid=row.batch_uuid))
def defaults(config, batch_grid, batch_crud, row_grid, row_crud, url_prefix,
route_prefix=None, permission_prefix=None, template_prefix=None):
"""
Apply default configuration to the Pyramid configurator object, for the
given batch grid and CRUD views.
"""
assert batch_grid
assert batch_crud
assert url_prefix
if route_prefix is None:
route_prefix = batch_grid.route_prefix
if permission_prefix is None:
permission_prefix = route_prefix
if template_prefix is None:
template_prefix = url_prefix
template_prefix.rstrip('/')
# Batches grid
config.add_route(route_prefix, url_prefix)
config.add_view(batch_grid, route_name=route_prefix,
renderer='{0}/index.mako'.format(template_prefix),
permission='{0}.view'.format(permission_prefix))
# Create batch
config.add_route('{0}.create'.format(route_prefix), '{0}new'.format(url_prefix))
config.add_view(batch_crud, attr='create', route_name='{0}.create'.format(route_prefix),
renderer='{0}/create.mako'.format(template_prefix),
permission='{0}.create'.format(permission_prefix))
# View batch
config.add_route('{0}.view'.format(route_prefix), '{0}{{uuid}}'.format(url_prefix))
config.add_view(batch_crud, attr='read', route_name='{0}.view'.format(route_prefix),
renderer='{0}/view.mako'.format(template_prefix),
permission='{0}.view'.format(permission_prefix))
# Edit batch
config.add_route('{0}.edit'.format(route_prefix), '{0}{{uuid}}/edit'.format(url_prefix))
config.add_view(batch_crud, attr='update', route_name='{0}.edit'.format(route_prefix),
renderer='{0}/edit.mako'.format(template_prefix),
permission='{0}.edit'.format(permission_prefix))
# Refresh batch row data
config.add_route('{0}.refresh'.format(route_prefix), '{0}{{uuid}}/refresh'.format(url_prefix))
config.add_view(batch_crud, attr='refresh', route_name='{0}.refresh'.format(route_prefix),
permission='{0}.create'.format(permission_prefix))
# Execute batch
config.add_route('{0}.execute'.format(route_prefix), '{0}{{uuid}}/execute'.format(url_prefix))
config.add_view(batch_crud, attr='execute', route_name='{0}.execute'.format(route_prefix),
permission='{0}.execute'.format(permission_prefix))
# Download batch row data as CSV
config.add_route('{0}.csv'.format(route_prefix), '{0}{{uuid}}/csv'.format(url_prefix))
config.add_view(batch_crud, attr='csv', route_name='{0}.csv'.format(route_prefix),
permission='{0}.csv'.format(permission_prefix))
# Download batch data file
if hasattr(batch_crud, 'download'):
config.add_route('{0}.download'.format(route_prefix), '{0}{{uuid}}/download'.format(url_prefix))
config.add_view(batch_crud, attr='download', route_name='{0}.download'.format(route_prefix),
permission='{0}.download'.format(permission_prefix))
# Delete batch
config.add_route('{0}.delete'.format(route_prefix), '{0}{{uuid}}/delete'.format(url_prefix))
config.add_view(batch_crud, attr='delete', route_name='{0}.delete'.format(route_prefix),
permission='{0}.delete'.format(permission_prefix))
# Batch rows grid
config.add_route('{0}.rows'.format(route_prefix), '{0}{{uuid}}/rows/'.format(url_prefix))
config.add_view(row_grid, route_name='{0}.rows'.format(route_prefix),
renderer='/batch/rows.mako',
permission='{0}.view'.format(permission_prefix))
# view batch row
config.add_route('{0}.row.view'.format(route_prefix), '{0}row/{{uuid}}'.format(url_prefix))
config.add_view(row_crud, attr='read', route_name='{0}.row.view'.format(route_prefix),
renderer='{0}/row.view.mako'.format(template_prefix),
permission='{0}.view'.format(permission_prefix))
# Bulk delete batch rows
config.add_route('{0}.rows.bulk_delete'.format(route_prefix), '{0}{{uuid}}/rows/delete'.format(url_prefix))
config.add_view(row_grid, attr='bulk_delete', route_name='{0}.rows.bulk_delete'.format(route_prefix),
permission='{0}.edit'.format(permission_prefix))
# Delete batch row
config.add_route('{0}.rows.delete'.format(route_prefix), '{0}delete-row/{{uuid}}'.format(url_prefix))
config.add_view(row_crud, attr='delete', route_name='{0}.rows.delete'.format(route_prefix),
permission='{0}.edit'.format(permission_prefix))