1245 lines
48 KiB
Python
1245 lines
48 KiB
Python
# -*- coding: utf-8; -*-
|
|
################################################################################
|
|
#
|
|
# Rattail -- Retail Software Framework
|
|
# Copyright © 2010-2018 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 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 General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License along with
|
|
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
################################################################################
|
|
"""
|
|
Base views for maintaining "new-style" batches.
|
|
"""
|
|
|
|
from __future__ import unicode_literals, absolute_import
|
|
|
|
import os
|
|
import datetime
|
|
import logging
|
|
import tempfile
|
|
from six import StringIO
|
|
|
|
import six
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import orm
|
|
|
|
from rattail.db import model, Session as RattailSession
|
|
from rattail.threads import Thread
|
|
from rattail.util import load_object, prettify
|
|
|
|
import colander
|
|
import deform
|
|
from pyramid import httpexceptions
|
|
from pyramid.renderers import render_to_response
|
|
from pyramid.response import FileResponse
|
|
from webhelpers2.html import HTML, tags
|
|
|
|
from tailbone import forms, grids
|
|
from tailbone.db import Session
|
|
from tailbone.views import MasterView
|
|
from tailbone.progress import SessionProgress
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class BatchMasterView(MasterView):
|
|
"""
|
|
Base class for all "batch master" views.
|
|
"""
|
|
default_handler_spec = None
|
|
has_rows = True
|
|
rows_deletable = True
|
|
rows_downloadable_csv = True
|
|
refreshable = True
|
|
refresh_after_create = False
|
|
cloneable = False
|
|
executable = True
|
|
results_executable = False
|
|
supports_mobile = True
|
|
mobile_filterable = True
|
|
mobile_rows_viewable = True
|
|
has_worksheet = False
|
|
|
|
grid_columns = [
|
|
'id',
|
|
'description',
|
|
'created',
|
|
'created_by',
|
|
'rowcount',
|
|
# 'status_code',
|
|
# 'complete',
|
|
'executed',
|
|
'executed_by',
|
|
]
|
|
|
|
form_fields = [
|
|
'id',
|
|
'created',
|
|
'created_by',
|
|
'rowcount',
|
|
'status_code',
|
|
'executed',
|
|
'executed_by',
|
|
'purge',
|
|
]
|
|
|
|
row_labels = {
|
|
'status_code': "Status",
|
|
}
|
|
|
|
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),
|
|
default=self.default_handler_spec)
|
|
if spec:
|
|
return load_object(spec)(self.rattail_config)
|
|
return self.batch_handler_class(self.rattail_config)
|
|
|
|
def download_path(self, batch, filename):
|
|
return self.rattail_config.batch_filepath(batch.batch_key, batch.uuid, filename)
|
|
|
|
def template_kwargs_view(self, **kwargs):
|
|
batch = kwargs['instance']
|
|
kwargs['batch'] = batch
|
|
kwargs['handler'] = self.handler
|
|
kwargs['execute_title'] = self.get_execute_title(batch)
|
|
kwargs['execute_enabled'] = self.instance_executable(batch)
|
|
if kwargs['execute_enabled']:
|
|
url = self.get_action_url('execute', batch)
|
|
kwargs['execute_form'] = self.make_execute_form(batch, action_url=url)
|
|
else:
|
|
kwargs['why_not_execute'] = self.handler.why_not_execute(batch)
|
|
return kwargs
|
|
|
|
def allow_worksheet(self, batch):
|
|
return not batch.executed and not batch.complete
|
|
|
|
def configure_grid(self, g):
|
|
super(BatchMasterView, self).configure_grid(g)
|
|
|
|
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'
|
|
|
|
# TODO: not sure this todo is still relevant?
|
|
# TODO: in some cases grid has no sorters yet..e.g. when building query for bulk-delete
|
|
# if hasattr(g, 'sorters'):
|
|
g.sorters['created_by'] = g.make_sorter(model.User.username)
|
|
g.sorters['executed_by'] = g.make_sorter(model.User.username)
|
|
|
|
g.set_sort_defaults('id', 'desc')
|
|
|
|
g.set_enum('status_code', self.model_class.STATUS)
|
|
|
|
g.set_type('created', 'datetime')
|
|
g.set_type('executed', 'datetime')
|
|
|
|
g.set_renderer('id', self.render_batch_id)
|
|
|
|
g.set_link('id')
|
|
g.set_link('description')
|
|
g.set_link('created')
|
|
g.set_link('executed')
|
|
|
|
g.set_label('id', "Batch ID")
|
|
g.set_label('created_by', "Created by")
|
|
g.set_label('rowcount', "Rows")
|
|
g.set_label('status_code', "Status")
|
|
g.set_label('executed_by', "Executed by")
|
|
|
|
def render_batch_id(self, batch, column):
|
|
return batch.id_str
|
|
|
|
def template_kwargs_index(self, **kwargs):
|
|
route_prefix = self.get_route_prefix()
|
|
if self.results_executable:
|
|
url = self.request.route_url('{}.execute_results'.format(route_prefix))
|
|
kwargs['execute_form'] = self.make_execute_form(action_url=url)
|
|
return kwargs
|
|
|
|
def get_exec_options_kwargs(self, **kwargs):
|
|
return kwargs
|
|
|
|
def get_instance_title(self, batch):
|
|
if batch.description:
|
|
return "{} {}".format(batch.id_str, batch.description)
|
|
return batch.id_str
|
|
|
|
def get_mobile_data(self, session=None):
|
|
return super(BatchMasterView, self).get_mobile_data(session=session)\
|
|
.order_by(self.model_class.id.desc())
|
|
|
|
def make_mobile_filters(self):
|
|
"""
|
|
Returns a set of filters for the mobile grid.
|
|
"""
|
|
filters = grids.filters.GridFilterSet()
|
|
filters['status'] = MobileBatchStatusFilter(self.model_class, 'status', default_value='pending')
|
|
return filters
|
|
|
|
def configure_form(self, f):
|
|
super(BatchMasterView, self).configure_form(f)
|
|
|
|
# id
|
|
f.set_readonly('id')
|
|
f.set_renderer('id', self.render_batch_id)
|
|
f.set_label('id', "Batch ID")
|
|
|
|
# created
|
|
f.set_readonly('created')
|
|
f.set_readonly('created_by')
|
|
f.set_renderer('created_by', self.render_user)
|
|
f.set_label('created_by', "Created by")
|
|
|
|
# cognized
|
|
f.set_renderer('cognized_by', self.render_user)
|
|
f.set_label('cognized_by', "Cognized by")
|
|
|
|
# row count
|
|
f.set_readonly('rowcount')
|
|
f.set_label('rowcount', "Row Count")
|
|
|
|
# status_code
|
|
f.set_readonly('status_code')
|
|
f.set_renderer('status_code', self.make_status_renderer(self.model_class.STATUS))
|
|
f.set_label('status_code', "Status")
|
|
|
|
# executed
|
|
f.set_readonly('executed')
|
|
f.set_readonly('executed_by')
|
|
f.set_renderer('executed_by', self.render_user)
|
|
f.set_label('executed_by', "Executed by")
|
|
|
|
# notes
|
|
f.set_type('notes', 'text')
|
|
|
|
# if self.creating and self.request.user:
|
|
# batch = fs.model
|
|
# batch.created_by_uuid = self.request.user.uuid
|
|
|
|
if self.creating:
|
|
f.remove_fields('id',
|
|
'rowcount',
|
|
'created',
|
|
'created_by',
|
|
'cognized',
|
|
'cognized_by',
|
|
'executed',
|
|
'executed_by',
|
|
'purge')
|
|
|
|
else: # not creating
|
|
batch = self.get_instance()
|
|
if not batch.executed:
|
|
f.remove_fields('executed',
|
|
'executed_by')
|
|
|
|
def make_status_renderer(self, enum):
|
|
def render_status(batch, field):
|
|
value = batch.status_code
|
|
if value is None:
|
|
return ""
|
|
status_code_text = enum.get(value, six.text_type(value))
|
|
if batch.status_text:
|
|
return HTML.tag('span', title=batch.status_text, c=status_code_text)
|
|
return status_code_text
|
|
return render_status
|
|
|
|
def render_user(self, batch, field):
|
|
user = getattr(batch, field)
|
|
if not user:
|
|
return ""
|
|
title = six.text_type(user)
|
|
url = self.request.route_url('users.view', uuid=user.uuid)
|
|
return tags.link_to(title, url)
|
|
|
|
def configure_mobile_form(self, f):
|
|
super(BatchMasterView, self).configure_mobile_form(f)
|
|
batch = f.model_instance
|
|
|
|
if self.creating:
|
|
f.remove_fields('id',
|
|
'rowcount',
|
|
'created',
|
|
'created_by',
|
|
'cognized',
|
|
'cognized_by',
|
|
'executed',
|
|
'executed_by',
|
|
'purge')
|
|
|
|
else: # not creating
|
|
if not batch.executed:
|
|
f.remove_fields('executed',
|
|
'executed_by')
|
|
if not batch.complete:
|
|
f.remove_field('complete')
|
|
|
|
def save_create_form(self, form):
|
|
uploads = self.normalize_uploads(form)
|
|
self.before_create(form)
|
|
|
|
session = self.Session()
|
|
with session.no_autoflush:
|
|
|
|
# transfer form data to batch instance
|
|
batch = self.objectify(form, self.form_deserialized)
|
|
|
|
# current user is batch creator
|
|
batch.created_by = self.request.user or self.late_login_user()
|
|
|
|
# obtain kwargs for making batch via handler, below
|
|
kwargs = self.get_batch_kwargs(batch)
|
|
|
|
# TODO: this needs work yet surely...why is this an issue?
|
|
# treat 'filename' field specially, for some reason it can be a filedict?
|
|
if 'filename' in kwargs and not isinstance(kwargs['filename'], six.string_types):
|
|
kwargs['filename'] = '' # null not allowed
|
|
|
|
# TODO: is this still necessary with colander?
|
|
# destroy initial batch and re-make using handler
|
|
# if batch in self.Session:
|
|
# self.Session.expunge(batch)
|
|
batch = self.handler.make_batch(session, **kwargs)
|
|
|
|
self.Session.flush()
|
|
self.process_uploads(batch, form, uploads)
|
|
return batch
|
|
|
|
def process_uploads(self, batch, form, uploads):
|
|
for key, upload in six.iteritems(uploads):
|
|
self.handler.set_input_file(batch, upload['temp_path'], attr=key)
|
|
os.remove(upload['temp_path'])
|
|
os.rmdir(upload['tempdir'])
|
|
|
|
def save_mobile_create_form(self, form):
|
|
self.before_create(form)
|
|
session = self.Session()
|
|
with session.no_autoflush:
|
|
|
|
# transfer form data to batch instance
|
|
batch = self.objectify(form, self.form_deserialized)
|
|
|
|
# current user is batch creator
|
|
batch.created_by = self.request.user
|
|
|
|
# TODO: is this still necessary with colander?
|
|
# destroy initial batch and re-make using handler
|
|
kwargs = self.get_batch_kwargs(batch)
|
|
if batch in session:
|
|
session.expunge(batch)
|
|
batch = self.handler.make_batch(session, **kwargs)
|
|
|
|
session.flush()
|
|
return batch
|
|
|
|
def get_batch_kwargs(self, batch, mobile=False):
|
|
"""
|
|
Return a kwargs dict for use with ``self.handler.make_batch()``, using
|
|
the given batch as a template.
|
|
"""
|
|
kwargs = {}
|
|
if batch.created_by:
|
|
kwargs['created_by'] = batch.created_by
|
|
elif batch.created_by_uuid:
|
|
kwargs['created_by_uuid'] = batch.created_by_uuid
|
|
kwargs['description'] = batch.description
|
|
kwargs['notes'] = batch.notes
|
|
if hasattr(batch, 'filename'):
|
|
kwargs['filename'] = batch.filename
|
|
kwargs['complete'] = batch.complete
|
|
return kwargs
|
|
|
|
# TODO: deprecate / remove this (is it used at all now?)
|
|
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 redirect_after_create(self, batch, mobile=False):
|
|
if self.handler.should_populate(batch):
|
|
return self.redirect(self.get_action_url('prefill', batch, mobile=mobile))
|
|
elif self.refresh_after_create:
|
|
return self.redirect(self.get_action_url('refresh', batch, mobile=mobile))
|
|
else:
|
|
return self.redirect(self.get_action_url('view', batch, mobile=mobile))
|
|
|
|
def template_kwargs_edit(self, **kwargs):
|
|
batch = kwargs['instance']
|
|
kwargs['batch'] = batch
|
|
return kwargs
|
|
|
|
def mobile_mark_complete(self):
|
|
batch = self.get_instance()
|
|
batch.complete = True
|
|
return self.redirect(self.get_index_url(mobile=True))
|
|
|
|
def mobile_mark_pending(self):
|
|
batch = self.get_instance()
|
|
batch.complete = False
|
|
return self.redirect(self.get_action_url('view', batch, mobile=True))
|
|
|
|
def rows_creatable_for(self, batch):
|
|
"""
|
|
Only allow creating new rows on a batch if it hasn't yet been executed.
|
|
"""
|
|
return not batch.executed
|
|
|
|
def configure_row_grid(self, g):
|
|
super(BatchMasterView, self).configure_row_grid(g)
|
|
|
|
if 'status_code' in g.filters:
|
|
g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS))
|
|
|
|
g.set_sort_defaults('sequence')
|
|
|
|
if self.model_row_class:
|
|
g.set_enum('status_code', self.model_row_class.STATUS)
|
|
|
|
g.set_renderer('status_code', self.render_row_status)
|
|
|
|
g.set_label('sequence', "Seq.")
|
|
g.set_label('status_code', "Status")
|
|
g.set_label('item_id', "Item ID")
|
|
|
|
def get_row_status_enum(self):
|
|
return self.model_row_class.STATUS
|
|
|
|
def render_row_status(self, row, column):
|
|
code = row.status_code
|
|
if code is None:
|
|
return ""
|
|
text = self.get_row_status_enum().get(code, six.text_type(code))
|
|
if row.status_text:
|
|
return HTML.tag('span', title=row.status_text, c=text)
|
|
return text
|
|
|
|
def create_row(self):
|
|
"""
|
|
Only allow creating a new row if the batch hasn't yet been executed.
|
|
"""
|
|
batch = self.get_instance()
|
|
if batch.executed:
|
|
self.request.session.flash("You cannot add new rows to a batch which has been executed")
|
|
return self.redirect(self.get_action_url('view', batch))
|
|
return super(BatchMasterView, self).create_row()
|
|
|
|
def mobile_create_row(self):
|
|
"""
|
|
Only allow creating a new row if the batch hasn't yet been executed.
|
|
"""
|
|
batch = self.get_instance()
|
|
if batch.executed:
|
|
self.request.session.flash("You cannot add new rows to a batch which has been executed")
|
|
return self.redirect(self.get_action_url('view', batch, mobile=True))
|
|
return super(BatchMasterView, self).mobile_create_row()
|
|
|
|
def save_create_row_form(self, form):
|
|
batch = self.get_instance()
|
|
row = self.objectify(form, self.form_deserialized)
|
|
self.handler.add_row(batch, row)
|
|
self.Session.flush()
|
|
return row
|
|
|
|
def after_create_row(self, row):
|
|
self.handler.refresh_row(row)
|
|
|
|
def configure_row_form(self, f):
|
|
super(BatchMasterView, self).configure_row_form(f)
|
|
|
|
# sequence
|
|
f.set_readonly('sequence')
|
|
|
|
# status_code
|
|
if self.model_row_class:
|
|
f.set_enum('status_code', self.model_row_class.STATUS)
|
|
f.set_renderer('status_code', self.render_row_status)
|
|
f.set_readonly('status_code')
|
|
f.set_label('status_code', "Status")
|
|
|
|
def configure_mobile_row_form(self, f):
|
|
super(BatchMasterView, self).configure_mobile_row_form(f)
|
|
|
|
# sequence
|
|
f.set_readonly('sequence')
|
|
|
|
# status_code
|
|
if self.model_row_class:
|
|
f.set_enum('status_code', self.model_row_class.STATUS)
|
|
f.set_renderer('status_code', self.render_row_status)
|
|
f.set_readonly('status_code')
|
|
f.set_label('status_code', "Status")
|
|
|
|
def make_default_row_grid_tools(self, batch):
|
|
if self.rows_creatable and not batch.executed:
|
|
permission_prefix = self.get_permission_prefix()
|
|
if self.request.has_perm('{}.create_row'.format(permission_prefix)):
|
|
link = tags.link_to("Create a new {}".format(self.get_row_model_title()),
|
|
self.get_action_url('create_row', batch))
|
|
return HTML.tag('p', c=link)
|
|
|
|
def make_batch_row_grid_tools(self, batch):
|
|
if self.rows_bulk_deletable and not batch.executed and self.request.has_perm('{}.delete_rows'.format(self.get_permission_prefix())):
|
|
url = self.request.route_url('{}.delete_rows'.format(self.get_route_prefix()), uuid=batch.uuid)
|
|
return HTML.tag('p', c=[tags.link_to("Delete all rows matching current search", url)])
|
|
|
|
def make_row_grid_kwargs(self, **kwargs):
|
|
"""
|
|
Whether or not rows may be edited or deleted will depend partially on
|
|
whether the parent batch has been executed.
|
|
"""
|
|
batch = self.get_instance()
|
|
|
|
# TODO: most of this logic is copied from MasterView, should refactor/merge somehow...
|
|
if 'main_actions' not in kwargs:
|
|
actions = []
|
|
|
|
# view action
|
|
if self.rows_viewable:
|
|
view = lambda r, i: self.get_row_action_url('view', r)
|
|
actions.append(grids.GridAction('view', icon='zoomin', url=view))
|
|
|
|
# edit and delete are NOT allowed after execution, or if batch is "complete"
|
|
if not batch.executed and not batch.complete:
|
|
|
|
# edit action
|
|
if self.rows_editable:
|
|
actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url))
|
|
|
|
# delete action
|
|
permission_prefix = self.get_permission_prefix()
|
|
if self.rows_deletable and self.request.has_perm('{}.delete_row'.format(permission_prefix)):
|
|
actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url))
|
|
kwargs.setdefault('delete_speedbump', self.rows_deletable_speedbump)
|
|
|
|
kwargs['main_actions'] = actions
|
|
|
|
return super(BatchMasterView, self).make_row_grid_kwargs(**kwargs)
|
|
|
|
def make_row_grid_tools(self, batch):
|
|
return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '')
|
|
|
|
def get_mobile_row_data(self, batch):
|
|
return super(BatchMasterView, self).get_mobile_row_data(batch)\
|
|
.order_by(self.model_row_class.sequence)
|
|
|
|
def redirect_after_edit(self, batch):
|
|
"""
|
|
If refresh flag is set, do that; otherwise go (back) to view/edit page.
|
|
"""
|
|
if self.request.params.get('refresh') == 'true':
|
|
return self.redirect(self.get_action_url('refresh', batch))
|
|
return self.redirect(self.get_action_url('view', batch))
|
|
|
|
def delete_instance(self, batch):
|
|
"""
|
|
Delete all data (files etc.) for the batch.
|
|
"""
|
|
self.handler.delete(batch)
|
|
super(BatchMasterView, self).delete_instance(batch)
|
|
|
|
def get_fallback_templates(self, template, mobile=False):
|
|
if mobile:
|
|
return [
|
|
'/mobile/batch/{}.mako'.format(template),
|
|
'/mobile/master/{}.mako'.format(template),
|
|
]
|
|
return [
|
|
'/batch/{}.mako'.format(template),
|
|
'/master/{}.mako'.format(template),
|
|
]
|
|
|
|
def editable_instance(self, batch):
|
|
return not bool(batch.executed)
|
|
|
|
def after_edit_row(self, row):
|
|
self.handler.refresh_row(row)
|
|
|
|
def instance_executable(self, batch=None):
|
|
return self.handler.executable(batch)
|
|
|
|
def batch_refreshable(self, batch):
|
|
"""
|
|
Return a boolean indicating whether the given batch should allow a
|
|
refresh operation.
|
|
"""
|
|
# TODO: deprecate/remove this?
|
|
if not self.refreshable:
|
|
return False
|
|
|
|
# (this is how it should be done i think..)
|
|
if callable(self.handler.refreshable):
|
|
return self.handler.refreshable(batch)
|
|
|
|
# TODO: deprecate/remove this
|
|
return self.handler.refreshable and not batch.executed
|
|
|
|
def has_execution_options(self, batch=None):
|
|
return bool(self.execution_options_schema)
|
|
|
|
# TODO
|
|
execution_options_schema = None
|
|
|
|
def make_execute_schema(self, batch):
|
|
return self.execution_options_schema().bind(batch=batch)
|
|
|
|
def make_execute_form(self, batch=None, **kwargs):
|
|
"""
|
|
Return a proper Form for execution options.
|
|
"""
|
|
defaults = {}
|
|
route_prefix = self.get_route_prefix()
|
|
|
|
if self.has_execution_options(batch):
|
|
if batch is None:
|
|
batch = self.model_class
|
|
schema = self.make_execute_schema(batch)
|
|
for field in schema:
|
|
|
|
# if field does not yet have a default, maybe provide one from session storage
|
|
if field.default is colander.null:
|
|
key = 'batch.{}.execute_option.{}'.format(batch.batch_key, field.name)
|
|
if key in self.request.session:
|
|
defaults[field.name] = self.request.session[key]
|
|
|
|
else:
|
|
schema = colander.Schema()
|
|
|
|
return forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs)
|
|
|
|
def get_execute_title(self, batch):
|
|
if hasattr(self.handler, 'get_execute_title'):
|
|
return self.handler.get_execute_title(batch)
|
|
return "Execute Batch"
|
|
|
|
def prefill(self):
|
|
"""
|
|
View which will attempt to prefill 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()
|
|
|
|
# showing progress requires a separate thread; start that first
|
|
key = '{}.prefill'.format(route_prefix)
|
|
progress = SessionProgress(self.request, key)
|
|
thread = Thread(target=self.prefill_thread, args=(batch.uuid, progress))
|
|
thread.start()
|
|
|
|
# Send user to progress page.
|
|
kwargs = {
|
|
'cancel_url': self.get_action_url('view', batch),
|
|
'cancel_msg': "Batch prefill 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 self.render_progress(progress, kwargs)
|
|
|
|
def prefill_thread(self, batch_uuid, progress):
|
|
"""
|
|
Thread target for prefilling batch data with progress indicator.
|
|
"""
|
|
# mustn't use tailbone web session here
|
|
session = RattailSession()
|
|
batch = session.query(self.model_class).get(batch_uuid)
|
|
try:
|
|
self.handler.populate(batch, progress=progress)
|
|
self.handler.refresh_batch_status(batch)
|
|
except Exception as error:
|
|
session.rollback()
|
|
log.warning("batch population failed: %s", batch, exc_info=True)
|
|
session.close()
|
|
if progress:
|
|
progress.session.load()
|
|
progress.session['error'] = True
|
|
progress.session['error_msg'] = "Batch population failed: {} - {}".format(error.__class__.__name__, error)
|
|
progress.session.save()
|
|
return
|
|
|
|
session.commit()
|
|
session.refresh(batch)
|
|
session.close()
|
|
|
|
# finalize progress
|
|
if progress:
|
|
progress.session.load()
|
|
progress.session['complete'] = True
|
|
progress.session['success_url'] = self.get_action_url('view', batch)
|
|
progress.session.save()
|
|
|
|
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()
|
|
|
|
# TODO: deprecate / remove this
|
|
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
|
|
|
|
# TODO: refresh should probably always imply/use progress
|
|
# If handler doesn't declare the need for progress indicator, things
|
|
# are nice and simple.
|
|
if not getattr(self.handler, 'show_progress', True):
|
|
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 = {
|
|
'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 self.render_progress(progress, kwargs)
|
|
|
|
def refresh_data(self, session, batch, cognizer=None, progress=None):
|
|
"""
|
|
Instruct the batch handler to refresh all data for the batch.
|
|
"""
|
|
# TODO: deprecate/remove this
|
|
if hasattr(self.handler, 'refresh_data'):
|
|
self.handler.refresh_data(session, batch, progress=progress)
|
|
batch.cognized = datetime.datetime.utcnow()
|
|
batch.cognized_by = cognizer or session.merge(self.request.user)
|
|
|
|
else: # the future
|
|
self.handler.refresh(batch, progress=progress)
|
|
|
|
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.__class__.__name__, 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_instance_title(self, row):
|
|
return "Row {}".format(row.sequence)
|
|
|
|
hide_row_status_codes = []
|
|
|
|
def get_row_data(self, batch):
|
|
"""
|
|
Generate the base data set for a rows grid.
|
|
"""
|
|
query = self.Session.query(self.model_row_class)\
|
|
.filter(self.model_row_class.batch == batch)\
|
|
.filter(self.model_row_class.removed == False)
|
|
if self.hide_row_status_codes:
|
|
query = query.filter(~self.model_row_class.status_code.in_(self.hide_row_status_codes))
|
|
return query
|
|
|
|
def row_editable(self, row):
|
|
"""
|
|
Batch rows are editable only until batch is complete or executed.
|
|
"""
|
|
batch = self.get_parent(row)
|
|
return self.rows_editable and not batch.executed and not batch.complete
|
|
|
|
def row_deletable(self, row):
|
|
"""
|
|
Batch rows are deletable only until batch is complete or executed.
|
|
"""
|
|
if self.rows_deletable:
|
|
batch = self.get_parent(row)
|
|
if not batch.executed and not batch.complete:
|
|
return True
|
|
return False
|
|
|
|
def template_kwargs_view_row(self, **kwargs):
|
|
kwargs['batch_model_title'] = kwargs['parent_model_title']
|
|
return kwargs
|
|
|
|
def get_parent(self, row):
|
|
return row.batch
|
|
|
|
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.model_row_class).get(self.request.matchdict['row_uuid'])
|
|
if not row:
|
|
raise httpexceptions.HTTPNotFound()
|
|
row.removed = True
|
|
batch = self.get_parent(row)
|
|
self.handler.refresh_batch_status(batch)
|
|
if batch.rowcount is not None:
|
|
batch.rowcount -= 1
|
|
return self.redirect(self.get_action_url('view', 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.
|
|
"""
|
|
batch = self.get_instance()
|
|
query = self.get_effective_row_data(sort=False)
|
|
if batch.rowcount is not None:
|
|
batch.rowcount -= query.count()
|
|
query.update({'removed': True}, synchronize_session=False)
|
|
return self.redirect(self.get_action_url('view', batch))
|
|
|
|
def execute(self):
|
|
"""
|
|
Execute a batch. Starts a separate thread for the execution, and
|
|
displays a progress indicator page.
|
|
"""
|
|
batch = self.get_instance()
|
|
self.executing = True
|
|
form = self.make_execute_form(batch)
|
|
if form.validate(newstyle=True):
|
|
kwargs = dict(form.validated)
|
|
|
|
# cache options to use as defaults next time
|
|
for key, value in form.validated.items():
|
|
self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value
|
|
|
|
key = '{}.execute'.format(self.model_class.__tablename__)
|
|
progress = SessionProgress(self.request, key)
|
|
kwargs['progress'] = progress
|
|
thread = Thread(target=self.execute_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs)
|
|
thread.start()
|
|
|
|
return self.render_progress(progress, {
|
|
'cancel_url': self.get_action_url('view', batch),
|
|
'cancel_msg': "Batch execution was canceled.",
|
|
})
|
|
|
|
self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error')
|
|
return self.redirect(self.get_action_url('view', batch))
|
|
|
|
def mobile_execute(self):
|
|
"""
|
|
Mobile view which can prompt user for execution options if applicable,
|
|
and/or execute a batch. For now this is done in a "blocking" fashion,
|
|
i.e. no progress bar.
|
|
"""
|
|
batch = self.get_instance()
|
|
model_title = self.get_model_title()
|
|
instance_title = self.get_instance_title(batch)
|
|
view_url = self.get_action_url('view', batch, mobile=True)
|
|
self.executing = True
|
|
form = self.make_execute_form(batch)
|
|
if form.validate(newstyle=True):
|
|
kwargs = dict(form.validated)
|
|
|
|
# cache options to use as defaults next time
|
|
for key, value in form.validated.items():
|
|
self.request.session['batch.{}.execute_option.{}'.format(batch.batch_key, key)] = value
|
|
|
|
try:
|
|
result = self.handler.execute(batch, user=self.request.user, **kwargs)
|
|
except Exception as err:
|
|
log.exception("failed to execute %s %s", model_title, batch.id_str)
|
|
self.request.session.flash(self.execute_error_message(err), 'error')
|
|
else:
|
|
if result:
|
|
batch.executed = datetime.datetime.utcnow()
|
|
batch.executed_by = self.request.user
|
|
self.request.session.flash("{} was executed: {}".format(model_title, instance_title))
|
|
else:
|
|
log.error("not sure why, but failed to execute %s %s: %s", model_title, batch.id_str, batch)
|
|
self.request.session.flash("Failed to execute {}: {}".format(model_title, err), 'error')
|
|
return self.redirect(view_url)
|
|
|
|
form.mobile = True
|
|
form.submit_label = "Execute"
|
|
form.cancel_url = view_url
|
|
return self.render_to_response('execute', {
|
|
'form': form,
|
|
'instance_title': instance_title,
|
|
'instance_url': view_url,
|
|
}, mobile=True)
|
|
|
|
def execute_error_message(self, error):
|
|
return "Batch execution failed: {}: {}".format(type(error).__name__, error)
|
|
|
|
def execute_thread(self, batch_uuid, user_uuid, progress=None, **kwargs):
|
|
"""
|
|
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)
|
|
user = session.query(model.User).get(user_uuid)
|
|
try:
|
|
result = self.handler.execute(batch, user=user, progress=progress, **kwargs)
|
|
|
|
# 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'] = self.execute_error_message(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 = user
|
|
session.commit()
|
|
# TODO: this doesn't always work...?
|
|
self.request.session.flash("{} has been executed: {}".format(
|
|
self.get_model_title(), batch.id_str))
|
|
else:
|
|
session.rollback()
|
|
|
|
session.refresh(batch)
|
|
success_url = self.get_execute_success_url(batch, result, **kwargs)
|
|
session.close()
|
|
|
|
if progress:
|
|
progress.session.load()
|
|
progress.session['complete'] = True
|
|
progress.session['success_url'] = success_url
|
|
progress.session.save()
|
|
|
|
def get_execute_success_url(self, batch, result, **kwargs):
|
|
return self.get_action_url('view', batch)
|
|
|
|
def execute_results(self):
|
|
"""
|
|
Execute all batches which are returned from the current index query.
|
|
Starts a separate thread for the execution, and displays a progress
|
|
indicator page.
|
|
"""
|
|
form = self.make_execute_form()
|
|
if form.validate(newstyle=True):
|
|
kwargs = dict(form.validated)
|
|
|
|
# cache options to use as defaults next time
|
|
for key, value in form.validated.items():
|
|
self.request.session['batch.{}.execute_option.{}'.format(self.model_class.batch_key, key)] = value
|
|
|
|
key = '{}.execute_results'.format(self.model_class.__tablename__)
|
|
batches = self.get_effective_data()
|
|
progress = SessionProgress(self.request, key)
|
|
kwargs['progress'] = progress
|
|
thread = Thread(target=self.execute_results_thread, args=(batches, self.request.user.uuid), kwargs=kwargs)
|
|
thread.start()
|
|
|
|
return self.render_progress(progress, {
|
|
'cancel_url': self.get_index_url(),
|
|
'cancel_msg': "Batch execution was canceled",
|
|
})
|
|
|
|
self.request.session.flash("Invalid request: {}".format(form.make_deform_form().error), 'error')
|
|
return self.redirect(self.get_index_url())
|
|
|
|
def execute_results_thread(self, batches, user_uuid, progress=None, **kwargs):
|
|
"""
|
|
Thread target for executing multiple batches with progress indicator.
|
|
"""
|
|
session = RattailSession()
|
|
batches = batches.with_session(session).all()
|
|
user = session.query(model.User).get(user_uuid)
|
|
try:
|
|
result = self.handler.execute_many(batches, user=user, progress=progress, **kwargs)
|
|
|
|
# If anything goes wrong, rollback and log the error etc.
|
|
except Exception as error:
|
|
session.rollback()
|
|
log.exception("execution failed for batch results")
|
|
session.close()
|
|
if progress:
|
|
progress.session.load()
|
|
progress.session['error'] = True
|
|
progress.session['error_msg'] = self.execute_error_message(error)
|
|
progress.session.save()
|
|
|
|
# If no error, check result flag (false means user canceled).
|
|
else:
|
|
if result:
|
|
session.commit()
|
|
# TODO: this doesn't always work...?
|
|
self.request.session.flash("{} {} were executed".format(
|
|
len(batches), self.get_model_title_plural()))
|
|
success_url = self.get_execute_results_success_url(result, **kwargs)
|
|
else:
|
|
session.rollback()
|
|
success_url = self.get_index_url()
|
|
session.close()
|
|
|
|
if progress:
|
|
progress.session.load()
|
|
progress.session['complete'] = True
|
|
progress.session['success_url'] = success_url
|
|
progress.session.save()
|
|
|
|
def get_execute_results_success_url(self, result, **kwargs):
|
|
return self.get_index_url()
|
|
|
|
def get_row_csv_fields(self):
|
|
fields = super(BatchMasterView, self).get_row_csv_fields()
|
|
fields = [field for field in fields
|
|
if field != 'removed' and not field.endswith('uuid')]
|
|
return fields
|
|
|
|
def get_row_results_csv_filename(self, batch):
|
|
return '{}.{}.csv'.format(self.get_route_prefix(), batch.id_str)
|
|
|
|
def clone(self):
|
|
"""
|
|
Clone current batch as new batch
|
|
"""
|
|
batch = self.get_instance()
|
|
batch = self.handler.clone(batch, created_by=self.request.user)
|
|
return self.redirect(self.get_action_url('view', batch))
|
|
|
|
@classmethod
|
|
def defaults(cls, config):
|
|
cls._batch_defaults(config)
|
|
cls._defaults(config)
|
|
|
|
@classmethod
|
|
def _batch_defaults(cls, config):
|
|
model_key = cls.get_model_key()
|
|
route_prefix = cls.get_route_prefix()
|
|
url_prefix = cls.get_url_prefix()
|
|
permission_prefix = cls.get_permission_prefix()
|
|
model_title = cls.get_model_title()
|
|
model_title_plural = cls.get_model_title_plural()
|
|
|
|
# TODO: currently must do this here (in addition to `_defaults()` or
|
|
# else the perm group label will not display correctly...
|
|
config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False)
|
|
|
|
# prefill row data
|
|
config.add_route('{}.prefill'.format(route_prefix), '{}/{{uuid}}/prefill'.format(url_prefix))
|
|
config.add_view(cls, attr='prefill', route_name='{}.prefill'.format(route_prefix),
|
|
permission='{}.create'.format(permission_prefix))
|
|
|
|
# worksheet
|
|
if cls.has_worksheet:
|
|
config.add_tailbone_permission(permission_prefix, '{}.worksheet'.format(permission_prefix),
|
|
"Edit {} data as worksheet".format(model_title))
|
|
config.add_route('{}.worksheet'.format(route_prefix), '{}/{{{}}}/worksheet'.format(url_prefix, model_key))
|
|
config.add_view(cls, attr='worksheet', route_name='{}.worksheet'.format(route_prefix),
|
|
permission='{}.worksheet'.format(permission_prefix))
|
|
config.add_route('{}.worksheet_update'.format(route_prefix), '{}/{{{}}}/worksheet/update'.format(url_prefix, model_key))
|
|
config.add_view(cls, attr='worksheet_update', route_name='{}.worksheet_update'.format(route_prefix),
|
|
renderer='json', permission='{}.worksheet'.format(permission_prefix))
|
|
|
|
# refresh batch data
|
|
if cls.refreshable:
|
|
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='{}.refresh'.format(permission_prefix))
|
|
config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix),
|
|
"Refresh data for {}".format(model_title))
|
|
|
|
# bulk delete rows
|
|
if cls.rows_bulk_deletable:
|
|
config.add_route('{}.delete_rows'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix))
|
|
config.add_view(cls, attr='bulk_delete_rows', route_name='{}.delete_rows'.format(route_prefix),
|
|
permission='{}.delete_rows'.format(permission_prefix))
|
|
config.add_tailbone_permission(permission_prefix, '{}.delete_rows'.format(permission_prefix),
|
|
"Bulk-delete data rows from {}".format(model_title))
|
|
|
|
# mobile mark complete
|
|
config.add_route('mobile.{}.mark_complete'.format(route_prefix), '/mobile{}/{{{}}}/mark-complete'.format(url_prefix, model_key))
|
|
config.add_view(cls, attr='mobile_mark_complete', route_name='mobile.{}.mark_complete'.format(route_prefix),
|
|
permission='{}.edit'.format(permission_prefix))
|
|
|
|
# mobile mark pending
|
|
config.add_route('mobile.{}.mark_pending'.format(route_prefix), '/mobile{}/{{{}}}/mark-pending'.format(url_prefix, model_key))
|
|
config.add_view(cls, attr='mobile_mark_pending', route_name='mobile.{}.mark_pending'.format(route_prefix),
|
|
permission='{}.edit'.format(permission_prefix))
|
|
|
|
# execute (multiple) batch results
|
|
if cls.results_executable:
|
|
config.add_route('{}.execute_results'.format(route_prefix), '{}/execute-results'.format(url_prefix))
|
|
config.add_view(cls, attr='execute_results', route_name='{}.execute_results'.format(route_prefix),
|
|
permission='{}.execute_multiple'.format(permission_prefix))
|
|
config.add_tailbone_permission(permission_prefix, '{}.execute_multiple'.format(permission_prefix),
|
|
"Execute multiple {}".format(model_title_plural))
|
|
|
|
|
|
class FileBatchMasterView(BatchMasterView):
|
|
"""
|
|
Base class for all file-based "batch master" views.
|
|
"""
|
|
downloadable = 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 configure_form(self, f):
|
|
super(FileBatchMasterView, self).configure_form(f)
|
|
batch = f.model_instance
|
|
|
|
# filename
|
|
if self.creating:
|
|
# TODO: what's up with this re-insertion again..?
|
|
# if 'filename' not in f.fields:
|
|
# f.fields.insert(0, 'filename')
|
|
f.set_type('filename', 'file')
|
|
else:
|
|
f.set_readonly('filename')
|
|
f.set_renderer('filename', self.render_filename)
|
|
|
|
def render_filename(self, batch, field):
|
|
filename = getattr(batch, field)
|
|
if not filename:
|
|
return ""
|
|
path = batch.filepath(self.rattail_config, filename=filename)
|
|
url = self.get_action_url('download', batch)
|
|
return self.render_file_field(path, url)
|
|
|
|
|
|
class MobileBatchStatusFilter(grids.filters.MobileFilter):
|
|
|
|
value_choices = ['pending', 'complete', 'executed', 'all']
|
|
|
|
def __init__(self, model_class, key, **kwargs):
|
|
self.model_class = model_class
|
|
super(MobileBatchStatusFilter, self).__init__(key, **kwargs)
|
|
|
|
def filter_equal(self, query, value):
|
|
|
|
if value == 'pending':
|
|
return query.filter(self.model_class.executed == None)\
|
|
.filter(sa.or_(
|
|
self.model_class.complete == None,
|
|
self.model_class.complete == False))
|
|
|
|
if value == 'complete':
|
|
return query.filter(self.model_class.executed == None)\
|
|
.filter(self.model_class.complete == True)
|
|
|
|
if value == 'executed':
|
|
return query.filter(self.model_class.executed != None)
|
|
|
|
return query
|
|
|
|
def iter_choices(self):
|
|
for value in self.value_choices:
|
|
yield value, prettify(value)
|