From b05f30d9febf89fcf1ede907d77450bcfc84d731 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 19 Jan 2015 00:52:40 -0600 Subject: [PATCH] New batch system! Hopefully nothing else broke... Attempt number 5,176 at a decent batch system, we'll see. --- tailbone/__init__.py | 7 + tailbone/static/js/tailbone.js | 25 +- tailbone/templates/batch/create.mako | 12 + tailbone/templates/batch/index.mako | 12 + tailbone/templates/batch/rows.mako | 14 + tailbone/templates/batch/view.mako | 65 ++ .../templates/vendors/catalogs/create.mako | 3 + .../templates/vendors/catalogs/index.mako | 3 + tailbone/templates/vendors/catalogs/view.mako | 3 + tailbone/views/batch.py | 849 ++++++++++++++++++ tailbone/views/crud.py | 52 +- tailbone/views/progress.py | 4 +- tailbone/views/vendors/__init__.py | 34 + tailbone/views/vendors/catalogs.py | 158 ++++ .../views/{vendors.py => vendors/core.py} | 4 +- 15 files changed, 1213 insertions(+), 32 deletions(-) create mode 100644 tailbone/templates/batch/create.mako create mode 100644 tailbone/templates/batch/index.mako create mode 100644 tailbone/templates/batch/rows.mako create mode 100644 tailbone/templates/batch/view.mako create mode 100644 tailbone/templates/vendors/catalogs/create.mako create mode 100644 tailbone/templates/vendors/catalogs/index.mako create mode 100644 tailbone/templates/vendors/catalogs/view.mako create mode 100644 tailbone/views/batch.py create mode 100644 tailbone/views/vendors/__init__.py create mode 100644 tailbone/views/vendors/catalogs.py rename tailbone/views/{vendors.py => vendors/core.py} (96%) 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 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 + + +${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 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 + + +${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 -*- +
    + + + + + +
    + ${search.render()} +
    + + ${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 name="head_tags()"> + + + + +
    + + + + ${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