New batch system! Hopefully nothing else broke...

Attempt number 5,176 at a decent batch system, we'll see.
This commit is contained in:
Lance Edgar 2015-01-19 00:52:40 -06:00
parent c4a19f279b
commit b05f30d9fe
15 changed files with 1213 additions and 32 deletions

View file

@ -29,6 +29,13 @@ Backoffice Web Application for Rattail
from ._version import __version__
# TODO: Ugh, hack to get batch models loaded before views can complain...
from rattail.db import model
from rattail.db.batch.vendorcatalog.model import VendorCatalog, VendorCatalogRow
model.VendorCatalog = VendorCatalog
model.VendorCatalogRow = VendorCatalogRow
def includeme(config):
config.include('tailbone.static')
config.include('tailbone.subscribers')

View file

@ -115,7 +115,7 @@ $(function() {
/*
* When filter labels are clicked, (un)check the associated checkbox.
*/
$('div.grid-wrapper div.filter label').on('click', function() {
$('body').on('click', '.grid-wrapper .filter label', function() {
var checkbox = $(this).prev('input[type="checkbox"]');
if (checkbox.prop('checked')) {
checkbox.prop('checked', false);
@ -130,7 +130,7 @@ $(function() {
* element. If all available filters have been displayed, the "add filter"
* dropdown will be hidden.
*/
$('#add-filter').on('change', function() {
$('body').on('change', '#add-filter', function() {
var select = $(this);
var filters = select.parents('div.filters:first');
var filter = filters.find('#filter-' + select.val());
@ -156,7 +156,7 @@ $(function() {
* When user clicks the grid filters search button, perform the search in
* the background and reload the grid in-place.
*/
$('div.filters form').submit(function() {
$('body').on('submit', '.filters form', function() {
var form = $(this);
var wrapper = form.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
@ -174,7 +174,7 @@ $(function() {
* When user clicks the grid filters reset button, manually clear all
* filter input elements, and submit a new search.
*/
$('div.filters form button[type="reset"]').click(function() {
$('body').on('click', '.filters form button[type="reset"]', function() {
var form = $(this).parents('form');
form.find('div.filter').each(function() {
$(this).find('div.value input').val('');
@ -183,7 +183,7 @@ $(function() {
return false;
});
$('div.grid-wrapper').on('click', 'div.grid th.sortable a', function() {
$('body').on('click', '.grid thead th.sortable a', function() {
var th = $(this).parent();
var wrapper = th.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
@ -201,29 +201,29 @@ $(function() {
return false;
});
$('#body').on('mouseenter', 'div.grid.hoverable table tbody tr', function() {
$('body').on('mouseenter', '.grid.hoverable tbody tr', function() {
$(this).addClass('hovering');
});
$('#body').on('mouseleave', 'div.grid.hoverable table tbody tr', function() {
$('body').on('mouseleave', '.grid.hoverable tbody tr', function() {
$(this).removeClass('hovering');
});
$('div.grid-wrapper').on('click', 'div.grid table tbody td.view', function() {
$('body').on('click', '.grid tbody td.view', function() {
var url = $(this).attr('url');
if (url) {
location.href = url;
}
});
$('div.grid-wrapper').on('click', 'div.grid table tbody td.edit', function() {
$('body').on('click', '.grid tbody td.edit', function() {
var url = $(this).attr('url');
if (url) {
location.href = url;
}
});
$('div.grid-wrapper').on('click', 'div.grid table tbody td.delete', function() {
$('body').on('click', '.grid tbody td.delete', function() {
var url = $(this).attr('url');
if (url) {
if (confirm("Do you really wish to delete this object?")) {
@ -232,7 +232,8 @@ $(function() {
}
});
$('div.grid-wrapper').on('change', 'div.grid div.pager select#grid-page-count', function() {
// $('div.grid-wrapper').on('change', 'div.grid div.pager select#grid-page-count', function() {
$('body').on('change', '.grid .pager #grid-page-count', function() {
var select = $(this);
var wrapper = select.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
@ -269,7 +270,7 @@ $(function() {
/*
* Add "check all" functionality to tables with checkboxes.
*/
$('body').on('click', 'div.grid table thead th.checkbox input[type="checkbox"]', function() {
$('body').on('click', '.grid thead th.checkbox input[type="checkbox"]', function() {
var table = $(this).parents('table:first');
var checked = $(this).prop('checked');
table.find('tbody tr').each(function() {

View file

@ -0,0 +1,12 @@
## -*- coding: utf-8 -*-
<%inherit file="/crud.mako" />
<%def name="title()">Upload ${batch_display}</%def>
<%def name="context_menu_items()">
% if request.has_perm('{0}.view'.format(permission_prefix)):
<li>${h.link_to("Back to {0}".format(batch_display_plural), url(route_prefix))}</li>
% endif
</%def>
${parent.body()}

View file

@ -0,0 +1,12 @@
## -*- coding: utf-8 -*-
<%inherit file="/grid.mako" />
<%def name="title()">${batch_display_plural}</%def>
<%def name="context_menu_items()">
% if request.has_perm('{0}.create'.format(permission_prefix)):
<li>${h.link_to("Create a new {0}".format(batch_display), url('{0}.create'.format(route_prefix)))}</li>
% endif
</%def>
${parent.body()}

View file

@ -0,0 +1,14 @@
## -*- coding: utf-8 -*-
<div class="grid-wrapper">
<table class="grid-header">
<tr>
<td rowspan="2" class="form">
${search.render()}
</td>
</tr>
</table>
${grid}
</div>

View file

@ -0,0 +1,65 @@
## -*- coding: utf-8 -*-
<%inherit file="/crud.mako" />
<%def name="title()">View ${batch_display}</%def>
<%def name="head_tags()">
<script type="text/javascript">
$(function() {
$('#rows-wrapper').load('${url('{0}.rows'.format(route_prefix), uuid=batch.uuid)}', function() {
// TODO: It'd be nice if we didn't have to do this here.
$(this).find('button').button();
$(this).find('input[type=submit]').button();
});
});
</script>
<style type="text/css">
#rows-wrapper {
margin-top: 10px;
}
.grid tr.notice.odd {
background-color: #fe8;
}
.grid tr.notice.even {
background-color: #fd6;
}
.grid tr.notice.hovering {
background-color: #ec7;
}
.grid tr.warning.odd {
background-color: #ebb;
}
.grid tr.warning.even {
background-color: #fcc;
}
.grid tr.warning.hovering {
background-color: #daa;
}
</style>
</%def>
<div class="form-wrapper">
<ul class="context-menu">
<li>${h.link_to("Back to {0}".format(batch_display_plural), url(route_prefix))}</li>
% if not batch.executed:
% if request.has_perm('{0}.edit'.format(permission_prefix)):
## <li>${h.link_to("Edit this {0}".format(batch_display), url('{0}.edit'.format(route_prefix), uuid=batch.uuid))}</li>
% if batch.refreshable:
<li>${h.link_to("Refresh Data for this {0}".format(batch_display), url('{0}.refresh'.format(route_prefix), uuid=batch.uuid))}</li>
% endif
% endif
% if request.has_perm('{0}.execute'.format(permission_prefix)):
<li>${h.link_to("Execute this {0}".format(batch_display), url('{0}.execute'.format(route_prefix), uuid=batch.uuid))}</li>
% endif
% endif
% if request.has_perm('{0}.delete'.format(permission_prefix)):
<li>${h.link_to("Delete this {0}".format(batch_display), url('{0}.delete'.format(route_prefix), uuid=batch.uuid))}</li>
% endif
</ul>
${form.render()|n}
</div>
<div id="rows-wrapper"></div>

View file

@ -0,0 +1,3 @@
## -*- coding: utf-8 -*-
<%inherit file="/batch/create.mako" />
${parent.body()}

View file

@ -0,0 +1,3 @@
## -*- coding: utf-8 -*-
<%inherit file="/batch/index.mako" />
${parent.body()}

View file

@ -0,0 +1,3 @@
## -*- coding: utf-8 -*-
<%inherit file="/batch/view.mako" />
${parent.body()}

849
tailbone/views/batch.py Normal file
View file

@ -0,0 +1,849 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 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 very experimental.
"""
from __future__ import unicode_literals
import os
import datetime
import logging
import formalchemy
from pyramid.renderers import render_to_response
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from rattail.db import model
from rattail.db import Session as RatSession
from rattail.threads import Thread
from tailbone.db import Session
from tailbone.views import SearchableAlchemyGridView, CrudView
from tailbone.forms import DateTimeFieldRenderer, UserFieldRenderer, EnumFieldRenderer
from tailbone.grids.search import BooleanSearchFilter, EnumSearchFilter
from tailbone.progress import SessionProgress
log = logging.getLogger(__name__)
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_plural(self):
"""
Plural display text for the batch type.
"""
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),
}
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.
"""
config = self.make_filter_config(
filter_factory_executed=BooleanSearchFilter,
filter_type_executed='is',
executed=False,
include_filter_executed=True)
config.update(self.filter_config_extras())
return config
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))
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.set(renderer=DateTimeFieldRenderer(self.request.rattail_config))
g.created_by.set(renderer=UserFieldRenderer)
g.cognized.set(renderer=DateTimeFieldRenderer(self.request.rattail_config))
g.cognized_by.set(renderer=UserFieldRenderer)
g.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config))
g.executed_by.set(renderer=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 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_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 mapped_class(self):
return self.batch_class
@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 home_route(self):
"""
The "home" route for the batch type, i.e. its grid view.
"""
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.batch_handler_class(config=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(renderer=DateTimeFieldRenderer(self.request.rattail_config))
fs.created_by.set(label="Created by", renderer=UserFieldRenderer)
fs.cognized.set(renderer=DateTimeFieldRenderer(self.request.rattail_config))
fs.cognized_by.set(label="Cognized by", renderer=UserFieldRenderer)
fs.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config))
fs.executed_by.set(label="Executed by", renderer=UserFieldRenderer)
self.configure_fieldset(fs)
if self.creating:
del fs.created
del fs.created_by
del fs.cognized
del fs.cognized_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 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
return {
'batch': batch,
'batch_display': self.batch_display,
'batch_display_plural': self.batch_display_plural,
'route_prefix': self.route_prefix,
'permission_prefix': self.permission_prefix,
}
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.")
return 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, progress_factory=None):
"""
Instruct the batch handler to refresh all data for the batch.
"""
self.handler.refresh_data(session, batch, progress_factory=progress_factory)
batch.cognized = datetime.datetime.utcnow()
batch.cognized_by = self.request.user
def refresh_thread(self, batch_uuid, progress):
"""
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 = RatSession()
batch = session.query(self.batch_class).get(batch_uuid)
self.refresh_data(session, batch, progress_factory=progress)
session.commit()
session.refresh(batch)
session.close()
# Finalize progress indicator.
progress.session.load()
progress.session['complete'] = True
progress.session['success_url'] = 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 execute(self):
batch = self.current_batch()
if self.handler.execute(batch):
batch.executed = datetime.datetime.utcnow()
batch.executed_by = self.request.user
return HTTPFound(location=self.view_url(batch.uuid))
class FileBatchCrud(BatchCrud):
"""
Base CRUD view for batches which involve a file upload as the first step.
"""
refreshable = True
def pre_crud(self, batch):
"""
Force refresh if batch has yet to be cognized.
"""
if not self.creating and not batch.cognized:
return HTTPFound(location=self.request.route_url(
'{0}.refresh'.format(self.route_prefix), uuid=batch.uuid))
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", renderer=DateTimeFieldRenderer(self.request.rattail_config))
fs.created_by.set(label="Uploaded by", renderer=UserFieldRenderer)
fs.cognized.set(renderer=DateTimeFieldRenderer(self.request.rattail_config))
fs.cognized_by.set(label="Cognized by", renderer=UserFieldRenderer)
fs.executed.set(renderer=DateTimeFieldRenderer(self.request.rattail_config))
fs.executed_by.set(label="Executed by", renderer=UserFieldRenderer)
fs.append(formalchemy.Field('data_file'))
fs.data_file.set(renderer=formalchemy.fields.FileFieldRenderer)
self.configure_fieldset(fs)
if self.creating:
del fs.created
del fs.created_by
del fs.filename
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 'data_file' in fs.render_fields:
del fs.data_file
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.data_file,
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.
"""
# 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:
batch.created_by = self.request.user
batch.filename = form.fieldset.data_file.renderer._filename
# Expunge batch from session to prevent it from being flushed.
Session.expunge(batch)
self.init_batch(batch)
Session.add(batch)
batch.write_file(self.request.rattail_config, form.fieldset.data_file.value)
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.
"""
def post_save_url(self, form):
"""
Redirect to "view batch" after creating or updating a batch.
"""
batch = form.fieldset.model
return self.view_url(batch.uuid)
def pre_delete(self, batch):
"""
Delete all data (files etc.) for the batch.
"""
batch.delete_data(self.request.rattail_config)
del batch.data_rows[:]
class BatchRowGrid(BaseGrid):
"""
Base grid view for batch rows, which can be filtered and sorted.
"""
@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'])
def current_batch(self):
"""
Return the current batch, based on the UUID within the URL.
"""
batch_class = self.row_class.__batch_class__
return Session.query(batch_class).get(self.request.matchdict['uuid'])
def modify_query(self, q):
q = super(BatchRowGrid, self).modify_query(q)
q = q.filter_by(batch=self.current_batch())
q = q.filter_by(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=EnumFieldRenderer(self.row_class.STATUS))
self._configure_grid(g)
self.configure_grid(g)
batch = self.current_batch()
# g.viewable = True
# g.view_route_name = '{0}.rows.view'.format(self.route_prefix)
if 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
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=['upc', 'status_code'],
ilike=['brand_name', 'description', 'size'])
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
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:
return HTTPNotFound()
row.removed = True
return HTTPFound(location=self.request.route_url(
'{0}.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}.edit'.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))
# 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))
# 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))

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2014 Lance Edgar
# Copyright © 2010-2015 Lance Edgar
#
# This file is part of Rattail.
#
@ -87,13 +87,6 @@ class CrudView(View):
return self.make_fieldset(model)
def make_form(self, model, form_factory=AlchemyForm, **kwargs):
if self.readonly:
self.creating = False
self.updating = False
else:
self.creating = model is self.mapped_class
self.updating = not self.creating
fieldset = self.fieldset(model)
kwargs.setdefault('pretty_name', self.pretty_name)
kwargs.setdefault('action_url', self.request.current_route_url())
@ -119,21 +112,32 @@ class CrudView(View):
def form(self, model):
return self.make_form(model)
def save_form(self, form):
form.save()
def crud(self, model, readonly=False):
if readonly:
self.readonly = True
self.readonly = readonly
if self.readonly:
self.creating = False
self.updating = False
else:
self.creating = model is self.mapped_class
self.updating = not self.creating
result = self.pre_crud(model)
if result is not None:
return result
form = self.form(model)
if readonly:
form.readonly = True
form.readonly = self.readonly
if not self.readonly and self.request.method == 'POST':
if not form.readonly and self.request.POST:
if form.validate():
form.save()
self.save_form(form)
result = self.post_save(form)
if result:
if result is not None:
return result
if form.creating:
@ -153,6 +157,9 @@ class CrudView(View):
kwargs['form'] = form
return kwargs
def pre_crud(self, model):
pass
def template_kwargs(self, form):
return {}
@ -206,14 +213,25 @@ class CrudView(View):
return self.crud(model)
def delete(self):
"""
View for deleting a record. Derived classes shouldn't override this,
but see also :meth:`pre_delete()` and :meth:`post_delete()`.
"""
model = self.get_model_from_request()
if not model:
return HTTPNotFound()
# Let derived classes prep for (or cancel) deletion.
result = self.pre_delete(model)
if result:
if result is not None:
return result
# Flush the deletion immediately so that we know it will succeed prior
# to setting a flash message etc.
Session.delete(model)
Session.flush() # Don't set flash message if delete fails.
Session.flush()
# Derived classes can do extra things here; set flash and go home.
self.post_delete(model)
self.flash_delete(model)
return HTTPFound(location=self.home_url)

View file

@ -33,7 +33,9 @@ def progress(request):
key = request.matchdict['key']
session = get_progress_session(request, key)
if session.get('complete'):
request.session.flash(session.get('success_msg', "The process has completed successfully."))
msg = session.get('success_msg')
if msg:
request.session.flash(msg)
elif session.get('error'):
request.session.flash(session.get('error_msg', "An unspecified error occurred."), 'error')
return session

34
tailbone/views/vendors/__init__.py vendored Normal file
View file

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 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/>.
#
################################################################################
"""
Views pertaining to vendors
"""
from __future__ import unicode_literals
from .core import VendorsGrid, VendorCrud, VendorsAutocomplete, add_routes
def includeme(config):
config.include('tailbone.views.vendors.core')
config.include('tailbone.views.vendors.catalogs')

158
tailbone/views/vendors/catalogs.py vendored Normal file
View file

@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 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/>.
#
################################################################################
"""
Views for maintaining vendor catalogs
"""
from __future__ import unicode_literals
from rattail.db import model
from rattail.db.api import get_vendor
from rattail.db.batch.vendorcatalog import VendorCatalogHandler
from rattail.vendors.catalogs import iter_catalog_parsers, require_catalog_parser
import formalchemy
from tailbone.db import Session
from tailbone.views.batch import FileBatchGrid, FileBatchCrud, BatchRowGrid, BatchRowCrud, defaults
class VendorCatalogGrid(FileBatchGrid):
"""
Grid view for vendor catalogs.
"""
batch_class = model.VendorCatalog
batch_display = "Vendor Catalog"
route_prefix = 'vendors.catalogs'
def join_map_extras(self):
return {'vendor': lambda q: q.join(model.Vendor)}
def filter_map_extras(self):
return {'vendor': self.filter_ilike(model.Vendor.name)}
def filter_config_extras(self):
return {'filter_type_vendor': 'lk',
'include_filter_vendor': True}
def sort_map_extras(self):
return {'vendor': self.sorter(model.Vendor.name)}
def configure_grid(self, g):
g.configure(
include=[
g.created,
g.created_by,
g.vendor,
g.effective,
g.filename,
g.executed,
],
readonly=True)
class VendorCatalogCrud(FileBatchCrud):
"""
CRUD view for vendor catalogs.
"""
batch_class = model.VendorCatalog
batch_handler_class = VendorCatalogHandler
route_prefix = 'vendors.catalogs'
batch_display = "Vendor Catalog"
flash = {'create': "New vendor catalog has been uploaded.",
'delete': "Vendor catalog has been deleted."}
def configure_fieldset(self, fs):
parsers = sorted(iter_catalog_parsers(), key=lambda p: p.display)
parser_options = [(p.display, p.key) for p in parsers]
parser_options.insert(0, ("(please choose)", ''))
fs.parser_key.set(renderer=formalchemy.fields.SelectFieldRenderer,
options=parser_options)
fs.configure(
include=[
fs.created,
fs.created_by,
fs.vendor,
fs.data_file.label("Catalog File"),
fs.filename,
fs.parser_key.label("File Type"),
fs.effective,
fs.executed,
fs.executed_by,
])
if self.creating:
del fs.vendor
del fs.effective
else:
del fs.parser_key
def init_batch(self, batch):
parser = require_catalog_parser(batch.parser_key)
batch.vendor = get_vendor(Session, parser.vendor_key)
class VendorCatalogRowGrid(BatchRowGrid):
"""
Grid view for vendor catalog rows.
"""
row_class = model.VendorCatalogRow
route_prefix = 'vendors.catalogs'
def filter_map_extras(self):
return {'ilike': ['upc', 'brand_name', 'description', 'size', 'vendor_code']}
def filter_config_extras(self):
return {'filter_label_upc': "UPC",
'filter_label_brand_name': "Brand"}
def configure_grid(self, g):
g.configure(
include=[
g.sequence,
g.upc.label("UPC"),
g.brand_name.label("Brand"),
g.description,
g.size,
g.vendor_code,
g.old_unit_cost.label("Old Cost"),
g.unit_cost.label("New Cost"),
g.unit_cost_diff.label("Diff."),
g.status_code,
],
readonly=True)
def tr_class(self, row, i):
if row.status_code in (row.STATUS_NEW_COST, row.STATUS_UPDATE_COST):
return 'notice'
if row.status_code == row.STATUS_PRODUCT_NOT_FOUND:
return 'warning'
class VendorCatalogRowCrud(BatchRowCrud):
row_class = model.VendorCatalogRow
route_prefix = 'vendors.catalogs'
def includeme(config):
defaults(config, VendorCatalogGrid, VendorCatalogCrud, VendorCatalogRowGrid, VendorCatalogRowCrud, '/vendors/catalogs/')

View file

@ -26,8 +26,8 @@
Vendor Views
"""
from . import SearchableAlchemyGridView, CrudView, AutocompleteView
from ..forms import AssociationProxyField, PersonFieldRenderer
from tailbone.views import SearchableAlchemyGridView, CrudView, AutocompleteView
from tailbone.forms import AssociationProxyField, PersonFieldRenderer
from rattail.db.model import Vendor