From fb140f24c13a41c47ddf20b03d7d6241bc73eb50 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 6 Jan 2018 20:28:59 -0600 Subject: [PATCH] Add basic UI support for "importer batch" feature --- .../templates/batch/importer/view_row.mako | 69 +++++ tailbone/views/batch/core.py | 22 +- tailbone/views/batch/core2.py | 11 +- tailbone/views/batch/importer.py | 277 ++++++++++++++++++ 4 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 tailbone/templates/batch/importer/view_row.mako create mode 100644 tailbone/views/batch/importer.py diff --git a/tailbone/templates/batch/importer/view_row.mako b/tailbone/templates/batch/importer/view_row.mako new file mode 100644 index 00000000..55efb6d1 --- /dev/null +++ b/tailbone/templates/batch/importer/view_row.mako @@ -0,0 +1,69 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view_row.mako" /> + +<%def name="context_menu_items()"> + % if not batch.executed and request.has_perm('{}.delete_row'.format(permission_prefix)): +
  • ${h.link_to("Delete this Row", url('{}.delete_row'.format(route_prefix), uuid=batch.uuid, row_uuid=instance.uuid))}
  • + % endif + + +${parent.body()} + +% if instance.status_code == enum.IMPORTER_BATCH_ROW_STATUS_CREATE: + + + + + + + + + + % for field in diff_fields: + + + + + + % endfor + +
    field nameold valuenew value
    ${field} ${repr(diff_new_values[field])}
    +% elif instance.status_code in (enum.IMPORTER_BATCH_ROW_STATUS_UPDATE, enum.IMPORTER_BATCH_ROW_STATUS_NOCHANGE): + + + + + + + + + + % for field in diff_fields: + + + + + + % endfor + +
    field nameold valuenew value
    ${field}${repr(diff_old_values[field])}${repr(diff_new_values[field])}
    +% elif instance.status_code == enum.IMPORTER_BATCH_ROW_STATUS_DELETE: + + + + + + + + + + % for field in diff_fields: + + + + + + % endfor + +
    field nameold valuenew value
    ${field}${repr(diff_old_values[field])} 
    +% endif diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 318bbea0..5e0359ec 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -439,7 +439,8 @@ class BatchMasterView(MasterView): """ if hasattr(batch, 'delete_data'): batch.delete_data(self.rattail_config) - del batch.data_rows[:] + if hasattr(batch, 'data_rows'): + del batch.data_rows[:] super(BatchMasterView, self).delete_instance(batch) def get_fallback_templates(self, template, mobile=False): @@ -688,14 +689,18 @@ class BatchMasterView(MasterView): """ Batch rows are editable only until batch has been executed. """ - batch = row.batch + batch = self.get_parent(row) return self.rows_editable and not batch.executed and not batch.complete def row_deletable(self, row): """ Batch rows are deletable only until batch has been executed. """ - return self.rows_deletable and not row.batch.executed + if self.rows_deletable: + batch = self.get_parent(row) + if not batch.executed: + return True + return False def _preconfigure_row_fieldset(self, fs): fs.sequence.set(readonly=True) @@ -958,11 +963,12 @@ class BatchMasterView(MasterView): renderer='json', permission='{}.worksheet'.format(permission_prefix)) # refresh batch data - config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix)) - config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix), - permission='{}.refresh'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), - "Refresh data for {}".format(model_title)) + if cls.refreshable: + config.add_route('{}.refresh'.format(route_prefix), '{}/{{uuid}}/refresh'.format(url_prefix)) + config.add_view(cls, attr='refresh', route_name='{}.refresh'.format(route_prefix), + permission='{}.refresh'.format(permission_prefix)) + config.add_tailbone_permission(permission_prefix, '{}.refresh'.format(permission_prefix), + "Refresh data for {}".format(model_title)) # bulk delete rows if cls.rows_bulk_deletable: diff --git a/tailbone/views/batch/core2.py b/tailbone/views/batch/core2.py index 17669b92..3a70366b 100644 --- a/tailbone/views/batch/core2.py +++ b/tailbone/views/batch/core2.py @@ -96,11 +96,13 @@ class BatchMasterView2(MasterView2, BatchMasterView): def configure_row_grid(self, g): super(BatchMasterView2, self).configure_row_grid(g) - g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) + if 'status_code' in g.filters: + g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) g.set_sort_defaults('sequence') - g.set_enum('status_code', self.model_row_class.STATUS) + if self.model_row_class: + g.set_enum('status_code', self.model_row_class.STATUS) g.set_renderer('status_code', self.render_row_status) @@ -108,11 +110,14 @@ class BatchMasterView2(MasterView2, BatchMasterView): g.set_label('status_code', "Status") g.set_label('item_id', "Item ID") + def get_row_status_enum(self): + return self.model_row_class.STATUS + def render_row_status(self, row, column): code = row.status_code if code is None: return "" - text = self.model_row_class.STATUS.get(code, six.text_type(code)) + text = self.get_row_status_enum().get(code, six.text_type(code)) if row.status_text: return HTML.tag('span', title=row.status_text, c=text) return text diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py new file mode 100644 index 00000000..fd87942d --- /dev/null +++ b/tailbone/views/batch/importer.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2018 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Views for importer batches +""" + +from __future__ import unicode_literals, absolute_import + +import sqlalchemy as sa + +from rattail.core import Object +from rattail.db import model + +from tailbone import forms, forms2 +from tailbone.views.batch import BatchMasterView2 as BatchMasterView + + +class ImporterBatchView(BatchMasterView): + """ + Master view for importer batches. + """ + model_class = model.ImporterBatch + default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler' + model_title_plural = "Import / Export Batches" + route_prefix = 'batch.importer' + url_prefix = '/batches/importer' + template_prefix = '/batch/importer' + creatable = False + refreshable = False + bulk_deletable = True + rows_downloadable_csv = False + rows_bulk_deletable = True + + grid_columns = [ + 'id', + 'description', + 'host_title', + 'local_title', + 'importer_key', + 'created', + 'created_by', + 'rowcount', + 'executed', + 'executed_by', + ] + + labels = { + 'host_title': "Source", + 'local_title': "Target", + 'importer_key': "Model", + } + + row_grid_columns = [ + 'sequence', + 'object_key', + 'object_str', + 'status_code', + ] + + def configure_fieldset(self, fs): + fs.configure( + include=[ + fs.id, + fs.description, + # fs.batch_handler_spec.readonly(), + fs.import_handler_spec.readonly(), + fs.host_title.readonly().label("Source"), + fs.local_title.readonly().label("Target"), + fs.importer_key.readonly().label("Model"), + fs.notes, + fs.created, + fs.created_by, + fs.row_table.readonly(), + fs.rowcount, + fs.executed, + fs.executed_by, + ]) + + def delete_instance(self, batch): + self.make_row_table(batch.row_table) + self.current_row_table.drop() + super(ImporterBatchView, self).delete_instance(batch) + + def make_row_table(self, name): + if not hasattr(self, 'current_row_table'): + metadata = sa.MetaData(schema='batch', bind=self.Session.bind) + self.current_row_table = sa.Table(name, metadata, autoload=True) + + def get_row_data(self, batch): + self.make_row_table(batch.row_table) + return self.Session.query(self.current_row_table) + + def get_row_status_enum(self): + return self.enum.IMPORTER_BATCH_ROW_STATUS + + def configure_row_grid(self, g): + super(ImporterBatchView, self).configure_row_grid(g) + + def make_filter(field, **kwargs): + column = getattr(self.current_row_table.c, field) + g.set_filter(field, column, **kwargs) + + make_filter('object_key') + make_filter('object_str') + make_filter('status_code', label="Status", + value_enum=self.enum.IMPORTER_BATCH_ROW_STATUS) + + def make_sorter(field): + column = getattr(self.current_row_table.c, field) + g.sorters[field] = lambda q, d: q.order_by(getattr(column, d)()) + + make_sorter('sequence') + make_sorter('object_key') + make_sorter('object_str') + make_sorter('status_code') + + g.set_sort_defaults('sequence') + + g.set_label('object_str', "Object Description") + + g.set_link('sequence') + g.set_link('object_key') + g.set_link('object_str') + + def row_grid_extra_class(self, row, i): + if row.status_code == self.enum.IMPORTER_BATCH_ROW_STATUS_DELETE: + return 'warning' + if row.status_code in (self.enum.IMPORTER_BATCH_ROW_STATUS_CREATE, + self.enum.IMPORTER_BATCH_ROW_STATUS_UPDATE): + return 'notice' + + def get_row_action_route_kwargs(self, row): + return { + 'uuid': self.current_row_table.name, + 'row_uuid': row.uuid, + } + + def get_row_instance(self): + batch_uuid = self.request.matchdict['uuid'] + row_uuid = self.request.matchdict['row_uuid'] + self.make_row_table(batch_uuid) + return self.Session.query(self.current_row_table)\ + .filter(self.current_row_table.c.uuid == row_uuid)\ + .one() + + def get_parent(self, row): + uuid = self.current_row_table.name + return self.Session.query(model.ImporterBatch).get(uuid) + + def get_row_instance_title(self, row): + if row.object_str: + return row.object_str + if row.object_key: + return row.object_key + return "Row {}".format(row.sequence) + + def template_kwargs_view_row(self, **kwargs): + batch = kwargs['parent_instance'] + row = kwargs['instance'] + kwargs['batch'] = batch + kwargs['instance_title'] = batch.id_str + + fields = set() + old_values = {} + new_values = {} + for col in self.current_row_table.c: + if col.name.startswith('key_'): + field = col.name[4:] + fields.add(field) + old_values[field] = new_values[field] = getattr(row, col.name) + elif col.name.startswith('pre_'): + field = col.name[4:] + fields.add(field) + old_values[field] = getattr(row, col.name) + elif col.name.startswith('post_'): + field = col.name[5:] + fields.add(field) + new_values[field] = getattr(row, col.name) + + kwargs['diff_fields'] = sorted(fields) + kwargs['diff_old_values'] = old_values + kwargs['diff_new_values'] = new_values + return kwargs + + def make_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): + """ + Creates a new form for the given model class/instance + """ + if factory is None: + factory = forms2.Form + if fields is None: + fields = ['sequence', 'object_key', 'object_str', 'status_code'] + for col in self.current_row_table.c: + if col.name.startswith('key_'): + fields.append(col.name) + + if not self.creating: + kwargs['model_instance'] = instance + kwargs = self.make_row_form_kwargs(**kwargs) + form = factory(fields, schema, **kwargs) + self.configure_row_form(form) + return form + + def make_row_form_kwargs(self, **kwargs): + """ + Return a dictionary of kwargs to be passed to the factory when creating + new row form instances. + """ + defaults = { + 'request': self.request, + 'readonly': self.viewing, + 'model_class': getattr(self, 'model_class', None), + 'action_url': self.request.current_route_url(_query=None), + } + instance = kwargs['model_instance'] + defaults.update(kwargs) + return defaults + + def configure_row_form(self, f): + """ + Configure the row form. + """ + # object_str + f.set_label('object_str', "Object Description") + + # status_code + f.set_renderer('status_code', self.render_row_status_code) + f.set_label('status_code', "Status") + + def render_row_status_code(self, row, field): + status = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code] + if row.status_code == self.enum.IMPORTER_BATCH_ROW_STATUS_UPDATE and row.status_text: + return "{} ({})".format(status, row.status_text) + return status + + def delete_row(self): + row = self.get_row_instance() + if not row: + raise self.notfound() + + batch = self.get_parent(row) + query = self.current_row_table.delete().where(self.current_row_table.c.uuid == row.uuid) + query.execute() + batch.rowcount -= 1 + return self.redirect(self.get_action_url('view', batch)) + + def bulk_delete_rows(self): + batch = self.get_instance() + query = self.get_effective_row_data(sort=False) + batch.rowcount -= query.count() + delete_query = self.current_row_table.delete().where(self.current_row_table.c.uuid.in_([row.uuid for row in query])) + delete_query.execute() + return self.redirect(self.get_action_url('view', batch)) + + +def includeme(config): + ImporterBatchView.defaults(config)