diff --git a/tailbone/__init__.py b/tailbone/__init__.py
index 23169426..e9a45dd9 100644
--- a/tailbone/__init__.py
+++ b/tailbone/__init__.py
@@ -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')
diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js
index eac2ebd1..515e2277 100644
--- a/tailbone/static/js/tailbone.js
+++ b/tailbone/static/js/tailbone.js
@@ -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() {
diff --git a/tailbone/templates/batch/create.mako b/tailbone/templates/batch/create.mako
new file mode 100644
index 00000000..6e4c57b6
--- /dev/null
+++ b/tailbone/templates/batch/create.mako
@@ -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)):
+
${h.link_to("Back to {0}".format(batch_display_plural), url(route_prefix))}
+ % endif
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako
new file mode 100644
index 00000000..f7a6550a
--- /dev/null
+++ b/tailbone/templates/batch/index.mako
@@ -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)):
+ ${h.link_to("Create a new {0}".format(batch_display), url('{0}.create'.format(route_prefix)))}
+ % endif
+%def>
+
+${parent.body()}
diff --git a/tailbone/templates/batch/rows.mako b/tailbone/templates/batch/rows.mako
new file mode 100644
index 00000000..756606e6
--- /dev/null
+++ b/tailbone/templates/batch/rows.mako
@@ -0,0 +1,14 @@
+## -*- coding: utf-8 -*-
+
+
+
+
+ ${grid}
+
+
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
new file mode 100644
index 00000000..63e5e16d
--- /dev/null
+++ b/tailbone/templates/batch/view.mako
@@ -0,0 +1,65 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/crud.mako" />
+
+<%def name="title()">View ${batch_display}%def>
+
+<%def name="head_tags()">
+
+
+%def>
+
+
+
+
+
+ ${form.render()|n}
+
+
+
+
diff --git a/tailbone/templates/vendors/catalogs/create.mako b/tailbone/templates/vendors/catalogs/create.mako
new file mode 100644
index 00000000..2e46901c
--- /dev/null
+++ b/tailbone/templates/vendors/catalogs/create.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/batch/create.mako" />
+${parent.body()}
diff --git a/tailbone/templates/vendors/catalogs/index.mako b/tailbone/templates/vendors/catalogs/index.mako
new file mode 100644
index 00000000..acddd2fb
--- /dev/null
+++ b/tailbone/templates/vendors/catalogs/index.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/batch/index.mako" />
+${parent.body()}
diff --git a/tailbone/templates/vendors/catalogs/view.mako b/tailbone/templates/vendors/catalogs/view.mako
new file mode 100644
index 00000000..9b89af91
--- /dev/null
+++ b/tailbone/templates/vendors/catalogs/view.mako
@@ -0,0 +1,3 @@
+## -*- coding: utf-8 -*-
+<%inherit file="/batch/view.mako" />
+${parent.body()}
diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py
new file mode 100644
index 00000000..3c945d93
--- /dev/null
+++ b/tailbone/views/batch.py
@@ -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 .
+#
+################################################################################
+"""
+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))
diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py
index 38f044e5..1a2d3203 100644
--- a/tailbone/views/crud.py
+++ b/tailbone/views/crud.py
@@ -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)
diff --git a/tailbone/views/progress.py b/tailbone/views/progress.py
index 878c1eb5..de823e00 100644
--- a/tailbone/views/progress.py
+++ b/tailbone/views/progress.py
@@ -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
diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py
new file mode 100644
index 00000000..54f2c7f1
--- /dev/null
+++ b/tailbone/views/vendors/__init__.py
@@ -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 .
+#
+################################################################################
+"""
+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')
diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py
new file mode 100644
index 00000000..d7f3cec1
--- /dev/null
+++ b/tailbone/views/vendors/catalogs.py
@@ -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 .
+#
+################################################################################
+"""
+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/')
diff --git a/tailbone/views/vendors.py b/tailbone/views/vendors/core.py
similarity index 96%
rename from tailbone/views/vendors.py
rename to tailbone/views/vendors/core.py
index ad4e0e30..edf973aa 100644
--- a/tailbone/views/vendors.py
+++ b/tailbone/views/vendors/core.py
@@ -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