From 901c2fc5732c257bf68baa3f6aafa624f80241dd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 23 Aug 2016 13:11:13 -0500 Subject: [PATCH] Add `MasterView.has_rows` concept and related logic Now the `BatchMasterView` no longer provides most of these goodies. Also tweak some custom batch views to reflect changes etc. --- tailbone/templates/master/edit_row.mako | 17 ++ .../{newbatch => master}/view_row.mako | 10 +- tailbone/templates/themes/better/base.mako | 2 +- tailbone/views/batch.py | 256 ++++-------------- tailbone/views/handheld.py | 30 +- tailbone/views/inventory.py | 63 ++++- tailbone/views/master.py | 254 ++++++++++++++++- tailbone/views/vendors/catalogs.py | 2 +- tailbone/views/vendors/invoices.py | 2 +- 9 files changed, 410 insertions(+), 226 deletions(-) create mode 100644 tailbone/templates/master/edit_row.mako rename tailbone/templates/{newbatch => master}/view_row.mako (78%) diff --git a/tailbone/templates/master/edit_row.mako b/tailbone/templates/master/edit_row.mako new file mode 100644 index 00000000..c3cfc0af --- /dev/null +++ b/tailbone/templates/master/edit_row.mako @@ -0,0 +1,17 @@ +## -*- coding: utf-8 -*- +<%inherit file="/master/edit.mako" /> + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to {}".format(model_title), index_url)}
  • + % if master.rows_viewable and request.has_perm('{}.view'.format(row_permission_prefix)): +
  • ${h.link_to("View this {}".format(row_model_title), row_action_url('view', instance))}
  • + % endif + % if master.rows_deletable and instance_deletable and request.has_perm('{}.delete'.format(row_permission_prefix)): +
  • ${h.link_to("Delete this {}".format(row_model_title), row_action_url('delete', instance))}
  • + % endif + % if master.rows_creatable and request.has_perm('{}.create'.format(row_permission_prefix)): +
  • ${h.link_to("Create a new {}".format(row_model_title), url('{}.create'.format(row_route_prefix)))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/newbatch/view_row.mako b/tailbone/templates/master/view_row.mako similarity index 78% rename from tailbone/templates/newbatch/view_row.mako rename to tailbone/templates/master/view_row.mako index 27be7115..dc5fd532 100644 --- a/tailbone/templates/newbatch/view_row.mako +++ b/tailbone/templates/master/view_row.mako @@ -4,7 +4,7 @@ <%def name="title()">${model_title} <%def name="context_menu_items()"> -
  • ${h.link_to("Back to {}".format(batch_model_title), index_url)}
  • +
  • ${h.link_to("Back to {}".format(parent_model_title), index_url)}
  • % if master.rows_editable and instance_editable and request.has_perm('{}.edit'.format(permission_prefix)):
  • ${h.link_to("Edit this {}".format(model_title), action_url('edit', instance))}
  • % endif @@ -16,4 +16,10 @@ % endif -${parent.body()} + + +
    + ${form.render()|n} +
    diff --git a/tailbone/templates/themes/better/base.mako b/tailbone/templates/themes/better/base.mako index 1c5c21bf..57fcc3bc 100644 --- a/tailbone/templates/themes/better/base.mako +++ b/tailbone/templates/themes/better/base.mako @@ -42,7 +42,7 @@ % if master.listing: ${model_title_plural} % else: - ${h.link_to(model_title_plural, index_url, class_='global')} + ${h.link_to(index_title, index_url, class_='global')} % if master.viewing and grid_index: ${grid_index_nav()} % endif diff --git a/tailbone/views/batch.py b/tailbone/views/batch.py index 87dfec86..b8f56b91 100644 --- a/tailbone/views/batch.py +++ b/tailbone/views/batch.py @@ -63,11 +63,9 @@ class BatchMasterView(MasterView): """ Base class for all "batch master" views. """ - refreshable = True - rows_viewable = True - rows_creatable = False - rows_editable = False + has_rows = True rows_deletable = True + refreshable = True def __init__(self, request): super(BatchMasterView, self).__init__(request) @@ -96,45 +94,17 @@ class BatchMasterView(MasterView): return load_object(spec)(self.rattail_config) return self.batch_handler_class(self.rattail_config) - def view(self): - """ - View for viewing details of an existing model record. - """ - self.viewing = True - batch = self.get_instance() - form = self.make_form(batch) - grid = self.make_row_grid(batch=batch) + def template_kwargs_view(self, **kwargs): + batch = kwargs['instance'] + kwargs['batch'] = batch + kwargs['handler'] = self.handler + kwargs['execute_title'] = self.get_execute_title(batch) + kwargs['execute_enabled'] = self.executable(batch) + if kwargs['execute_enabled'] and self.has_execution_options: + kwargs['rendered_execution_options'] = self.render_execution_options(batch) + return kwargs - # If user just refreshed the page with a reset instruction, issue a - # redirect in order to clear out the query string. - if self.request.GET.get('reset-to-default-filters') == 'true': - return self.redirect(self.request.current_route_url(_query=None)) - - if self.request.params.get('partial'): - self.request.response.content_type = b'text/html' - self.request.response.text = grid.render_grid() - return self.request.response - - context = { - 'handler': self.handler, - 'instance': batch, - 'instance_title': self.get_instance_title(batch), - 'instance_editable': self.editable_instance(batch), - 'instance_deletable': self.deletable_instance(batch), - 'form': form, - 'batch': batch, - 'execute_title': self.get_execute_title(batch), - 'execute_enabled': self.executable(batch), - 'rows_grid': grid.render_complete(allow_save_defaults=False), - } - - if context['execute_enabled'] and self.has_execution_options: - context['rendered_execution_options'] = self.render_execution_options() - - return self.render_to_response('view', context) - - def render_execution_options(self): - batch = self.get_instance() + def render_execution_options(self, batch): form = self.make_execution_options_form(batch) kwargs = { 'batch': batch, @@ -302,6 +272,7 @@ class BatchMasterView(MasterView): """ return True + # TODO: some of this at least can go to master now right? def edit(self): """ Don't allow editing a batch which has already been executed. @@ -343,7 +314,7 @@ class BatchMasterView(MasterView): } if context['execute_enabled'] and self.has_execution_options: - context['rendered_execution_options'] = self.render_execution_options() + context['rendered_execution_options'] = self.render_execution_options(batch) return self.render_to_response('edit', context) @@ -500,175 +471,80 @@ class BatchMasterView(MasterView): # batch rows ######################################## - def get_row_model_title(self): - return "{} Row".format(self.get_model_title()) - - def get_row_model_title_plural(self): - return "{} Rows".format(self.get_model_title()) - - @classmethod - def get_row_permission_prefix(cls): - """ - Permission prefix specific to the row-level data for this batch type, - e.g. ``'vendorcatalogs.rows'``. - """ - return "{}.rows".format(cls.get_permission_prefix()) - - @classmethod - def get_row_route_prefix(cls): - """ - Route prefix specific to the row-level views for a batch, e.g. - ``'vendorcatalogs.rows'``. - """ - return "{}.rows".format(cls.get_route_prefix()) - - def make_row_grid(self, **kwargs): - """ - Make and return a new (configured) batch rows grid instance. - """ - batch = kwargs.pop('batch', self.get_instance()) - key = '{}.{}'.format(self.get_grid_key(), batch.uuid) - data = self.get_row_data(batch) - kwargs = self.make_row_grid_kwargs(**kwargs) - grid = grids.AlchemyGrid(key, self.request, data=data, model_class=self.batch_row_class, **kwargs) - self._preconfigure_row_grid(grid) - self.configure_row_grid(grid) - grid.load_settings() - return grid + def get_row_instance_title(self, row): + return "Row {}".format(row.sequence) def _preconfigure_row_grid(self, g): g.filters['status_code'].label = "Status" - g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.batch_row_class.STATUS)) + g.filters['status_code'].set_value_renderer(grids.filters.EnumValueRenderer(self.model_row_class.STATUS)) g.default_sortkey = 'sequence' g.sequence.set(label="Seq.") g.status_code.set(label="Status", - renderer=StatusRenderer(self.batch_row_class.STATUS)) - - def configure_row_grid(self, grid): - grid.configure() + renderer=StatusRenderer(self.model_row_class.STATUS)) def get_row_data(self, batch): """ Generate the base data set for a rows grid. """ - session = orm.object_session(batch) - return session.query(self.batch_row_class)\ - .filter(self.batch_row_class.batch == batch)\ - .filter(self.batch_row_class.removed == False) + return self.Session.query(self.model_row_class)\ + .filter(self.model_row_class.batch == batch)\ + .filter(self.model_row_class.removed == False) - def make_row_grid_kwargs(self, **kwargs): + def row_editable(self, row): """ - Return a dict of kwargs to be used when constructing a new rows grid. + Batch rows are editable only until batch has been executed. """ - route_prefix = self.get_row_route_prefix() - permission_prefix = self.get_row_permission_prefix() + return self.rows_editable and not row.batch.executed - defaults = { - 'width': 'full', - 'filterable': True, - 'sortable': True, - 'pageable': True, - 'row_attrs': self.row_grid_row_attrs, - 'model_title': self.get_row_model_title(), - 'model_title_plural': self.get_row_model_title_plural(), - 'permission_prefix': permission_prefix, - 'route_prefix': route_prefix, - } + def row_edit_action_url(self, row, i): + if self.row_editable(row): + return self.get_row_action_url('edit', row) - if 'main_actions' not in defaults: - actions = [] - - # view action - if self.rows_viewable: - view = lambda r, i: self.request.route_url('{}.view'.format(route_prefix), - uuid=r.uuid) - actions.append(grids.GridAction('view', icon='zoomin', url=view)) - - # delete action - batch = self.get_instance() - if self.editing and self.rows_deletable and not batch.executed: - delete = lambda r, i: self.request.route_url('{}.delete'.format(route_prefix), - uuid=r.uuid) - actions.append(grids.GridAction('delete', icon='trash', url=delete)) - - defaults['main_actions'] = actions - - defaults.update(kwargs) - return defaults - - def row_grid_row_attrs(self, row, i): - return {} - - def view_row(self): + def row_deletable(self, row): """ - View for viewing details of a single batch row. + Batch rows are deletable only until batch has been executed. """ - self.viewing = True - row = self.get_row_instance() - form = self.make_row_form(row) - return self.render_to_response('view_row', { - 'instance': row, - 'instance_title': self.get_row_instance_title(row), - 'instance_editable': False, - 'instance_deletable': False, - 'model_title': self.get_row_model_title(), - 'model_title_plural': self.get_row_model_title_plural(), - 'batch_model_title': self.get_model_title(), - 'index_url': self.get_action_url('view', row.batch), - 'index_title': '{} {}'.format( - self.get_model_title(), - self.get_instance_title(row.batch)), - 'form': form}) + return self.rows_deletable and not row.batch.executed - def get_row_instance(self): - key = self.request.matchdict[self.get_model_key()] - instance = self.Session.query(self.batch_row_class).get(key) - if not instance: - raise httpexceptions.HTTPNotFound() - return instance + def row_delete_action_url(self, row, i): + if self.row_deletable(row): + return self.get_row_action_url('delete', row) - def get_row_instance_title(self, instance): - return self.get_row_model_title() - - def make_row_form(self, instance, **kwargs): - """ - Make a FormAlchemy form for use with CRUD views for a batch *row*. - """ - # TODO: Some hacky stuff here, to accommodate old form cruft. Probably - # should refactor forms soon too, but trying to avoid it for the moment. - - kwargs.setdefault('creating', self.creating) - kwargs.setdefault('editing', self.editing) - - fieldset = self.make_fieldset(instance) - self.configure_row_fieldset(fieldset) - - kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) - if self.creating: - kwargs.setdefault('cancel_url', self.get_action_url('view', instance.batch)) - else: - kwargs.setdefault('cancel_url', self.request.route_url('{}.view'.format(self.get_row_route_prefix()), - uuid=instance.uuid)) - form = forms.AlchemyForm(self.request, fieldset, **kwargs) - form.readonly = self.viewing - return form + def _preconfigure_row_fieldset(self, fs): + fs.sequence.set(readonly=True) + fs.status_code.set(renderer=StatusRenderer(self.model_row_class.STATUS), + label="Status", readonly=True) + fs.status_text.set(readonly=True) + fs.removed.set(readonly=True) + try: + fs.product.set(readonly=True) + except AttributeError: + pass def configure_row_fieldset(self, fs): fs.configure() + del fs.batch + + def template_kwargs_view_row(self, **kwargs): + kwargs['batch_model_title'] = kwargs['parent_model_title'] + return kwargs + + def get_parent(self, row): + return row.batch def delete_row(self): """ "Delete" a row from the batch. This sets the ``removed`` flag on the row but does not truly delete it. """ - row = self.Session.query(self.batch_row_class).get(self.request.matchdict['uuid']) + row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid']) if not row: raise httpexceptions.HTTPNotFound() row.removed = True - return self.redirect(self.get_action_url('edit', row.batch)) + return self.redirect(self.get_action_url('view', self.get_parent(row))) def bulk_delete_rows(self): """ @@ -679,17 +555,6 @@ class BatchMasterView(MasterView): query.update({'removed': True}, synchronize_session=False) return self.redirect(self.get_action_url('view', self.get_instance())) - def get_effective_row_query(self): - """ - Convenience method which returns the "effective" query for the master - grid, filtered and sorted to match what would show on the UI, but not - paged etc. - """ - batch = self.get_instance() - grid = self.make_row_grid(batch=batch, sortable=False, pageable=False, - main_actions=[]) - return grid._fa_grid.rows - def execute(self): """ Execute a batch. Starts a separate thread for the execution, and @@ -795,7 +660,7 @@ class BatchMasterView(MasterView): Return the list of fields to be written to CSV download. """ fields = [] - mapper = orm.class_mapper(self.batch_row_class) + mapper = orm.class_mapper(self.model_row_class) for prop in mapper.iterate_properties: if isinstance(prop, orm.ColumnProperty): if prop.key != 'removed' and not prop.key.endswith('uuid'): @@ -824,24 +689,11 @@ class BatchMasterView(MasterView): permission_prefix = cls.get_permission_prefix() model_title = cls.get_model_title() - # view row - if cls.rows_viewable: - config.add_route('{}.rows.view'.format(route_prefix), '{}/rows/{{uuid}}'.format(url_prefix)) - config.add_view(cls, attr='view_row', route_name='{}.rows.view'.format(route_prefix), - permission='{}.rows.view'.format(permission_prefix)) - config.add_tailbone_permission(permission_prefix, '{}.rows.view'.format(permission_prefix), - "View {} Row Details".format(model_title)) - # refresh rows 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='{}.create'.format(permission_prefix)) - # delete row - config.add_route('{}.rows.delete'.format(route_prefix), '{}/delete-row/{{uuid}}'.format(url_prefix)) - config.add_view(cls, attr='delete_row', route_name='{}.rows.delete'.format(route_prefix), - permission='{}.edit'.format(permission_prefix)) - # bulk delete rows config.add_route('{}.rows.bulk_delete'.format(route_prefix), '{}/{{uuid}}/rows/delete'.format(url_prefix)) config.add_view(cls, attr='bulk_delete_rows', route_name='{}.rows.bulk_delete'.format(route_prefix), diff --git a/tailbone/views/handheld.py b/tailbone/views/handheld.py index 0edb96ea..4f1d7042 100644 --- a/tailbone/views/handheld.py +++ b/tailbone/views/handheld.py @@ -73,11 +73,16 @@ class HandheldBatchView(FileBatchMasterView): """ model_class = model.HandheldBatch model_title_plural = "Handheld Batches" - batch_row_class = model.HandheldBatchRow batch_handler_class = HandheldBatchHandler route_prefix = 'batch.handheld' url_prefix = '/batch/handheld' execution_options_schema = ExecutionOptions + editable = False + refreshable = False + + model_row_class = model.HandheldBatchRow + rows_creatable = False + rows_editable = True def configure_grid(self, g): g.configure( @@ -105,7 +110,7 @@ class HandheldBatchView(FileBatchMasterView): ]) if self.creating: del fs.id - elif self.viewing: + elif self.viewing and fs.model.inventory_batch: fs.append(fa.Field('inventory_batch', value=fs.model.inventory_batch, renderer=InventoryBatchFieldRenderer)) def configure_row_grid(self, g): @@ -128,6 +133,27 @@ class HandheldBatchView(FileBatchMasterView): attrs['class_'] = 'warning' return attrs + def _preconfigure_row_fieldset(self, fs): + super(HandheldBatchView, self)._preconfigure_row_fieldset(fs) + fs.upc.set(readonly=True, label="UPC", renderer=forms.renderers.GPCFieldRenderer, + attrs={'link': lambda r: self.request.route_url('products.view', uuid=r.product_uuid)}) + fs.brand_name.set(readonly=True) + fs.description.set(readonly=True) + fs.size.set(readonly=True) + + def configure_row_fieldset(self, fs): + fs.configure( + include=[ + fs.sequence, + fs.upc, + fs.brand_name, + fs.description, + fs.size, + fs.status_code, + fs.cases, + fs.units, + ]) + def get_exec_options_kwargs(self, **kwargs): kwargs['ACTION_OPTIONS'] = list(ACTION_OPTIONS.iteritems()) return kwargs diff --git a/tailbone/views/inventory.py b/tailbone/views/inventory.py index 49519a82..ee37db14 100644 --- a/tailbone/views/inventory.py +++ b/tailbone/views/inventory.py @@ -26,6 +26,8 @@ Views for inventory batches from __future__ import unicode_literals, absolute_import +from rattail import enum +from rattail.db import model from rattail.db.batch.inventory.handler import InventoryBatchHandler import formalchemy as fa @@ -34,8 +36,6 @@ from webhelpers.html import tags from tailbone import forms from tailbone.views.batch import BatchMasterView -from dtail.db import model - class HandheldBatchFieldRenderer(fa.FieldRenderer): """ @@ -57,11 +57,30 @@ class InventoryBatchView(BatchMasterView): """ model_class = model.InventoryBatch model_title_plural = "Inventory Batches" - batch_row_class = model.InventoryBatchRow batch_handler_class = InventoryBatchHandler route_prefix = 'batch.inventory' url_prefix = '/batch/inventory' creatable = False + editable = False + refreshable = False + + model_row_class = model.InventoryBatchRow + rows_editable = True + + def _preconfigure_grid(self, g): + super(InventoryBatchView, self)._preconfigure_grid(g) + g.mode.set(renderer=forms.renderers.EnumFieldRenderer(enum.INVENTORY_MODE), + label="Count Mode") + + def configure_grid(self, g): + super(InventoryBatchView, self).configure_grid(g) + g.append(g.mode) + + def _preconfigure_fieldset(self, fs): + super(InventoryBatchView, self)._preconfigure_fieldset(fs) + fs.handheld_batch.set(renderer=HandheldBatchFieldRenderer, readonly=True) + fs.mode.set(renderer=forms.renderers.EnumFieldRenderer(enum.INVENTORY_MODE), + label="Count Mode") def configure_fieldset(self, fs): fs.configure( @@ -69,24 +88,29 @@ class InventoryBatchView(BatchMasterView): fs.id, fs.created, fs.created_by, - fs.handheld_batch.with_renderer(HandheldBatchFieldRenderer), + fs.handheld_batch, + fs.mode, fs.executed, fs.executed_by, ]) - if not self.viewing: - del fs.handheld_batch + + def _preconfigure_row_grid(self, g): + super(InventoryBatchView, self)._preconfigure_row_grid(g) + g.upc.set(label="UPC") + g.brand_name.set(label="Brand") + g.status_code.set(label="Status") def configure_row_grid(self, g): g.configure( include=[ g.sequence, - g.upc.label("UPC"), - g.brand_name.label("Brand"), + g.upc, + g.brand_name, g.description, g.size, g.cases, g.units, - g.status_code.label("Status"), + g.status_code, ], readonly=True) @@ -96,6 +120,27 @@ class InventoryBatchView(BatchMasterView): attrs['class_'] = 'warning' return attrs + def _preconfigure_row_fieldset(self, fs): + super(InventoryBatchView, self)._preconfigure_row_fieldset(fs) + fs.upc.set(readonly=True, label="UPC", renderer=forms.renderers.GPCFieldRenderer, + attrs={'link': lambda r: self.request.route_url('products.view', uuid=r.product_uuid)}) + fs.brand_name.set(readonly=True) + fs.description.set(readonly=True) + fs.size.set(readonly=True) + + def configure_row_fieldset(self, fs): + fs.configure( + include=[ + fs.sequence, + fs.upc, + fs.brand_name, + fs.description, + fs.size, + fs.status_code, + fs.cases, + fs.units, + ]) + @classmethod def defaults(cls, config): diff --git a/tailbone/views/master.py b/tailbone/views/master.py index acf6e9bf..640da516 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -71,6 +71,10 @@ class MasterView(View): has_rows = False model_row_class = None + rows_viewable = True + rows_creatable = False + rows_editable = False + rows_deletable = False @property def Session(self): @@ -146,12 +150,16 @@ class MasterView(View): form = self.make_form(instance) if self.has_rows: + # must make grid prior to redirecting from filter reset, b/c the + # grid will detect the filter reset request and store defaults in + # the session, that way redirect will then show The Right Thing + grid = self.make_row_grid(instance=instance) + # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. if self.request.GET.get('reset-to-default-filters') == 'true': return self.redirect(self.request.current_route_url(_query=None)) - grid = self.make_row_grid(instance=instance) if self.request.params.get('partial'): self.request.response.content_type = b'text/html' self.request.response.text = grid.render_grid() @@ -176,16 +184,29 @@ class MasterView(View): """ Make and return a new (configured) rows grid instance. """ - instance = kwargs.pop('instance', self.get_instance()) - data = self.get_row_data(instance) + parent = kwargs.pop('instance', self.get_instance()) + data = self.get_row_data(parent) + kwargs['instance'] = parent kwargs = self.make_row_grid_kwargs(**kwargs) key = '{}.{}'.format(self.get_grid_key(), self.request.matchdict[self.get_model_key()]) - grid = grids.AlchemyGrid(key, self.request, data=data, model_class=self.model_row_class, **kwargs) + factory = self.get_grid_factory() + grid = factory(key, self.request, data=data, model_class=self.model_row_class, **kwargs) self._preconfigure_row_grid(grid) self.configure_row_grid(grid) grid.load_settings() return grid + def get_effective_row_query(self): + """ + Convenience method which returns the "effective" query for the master + grid, filtered and sorted to match what would show on the UI, but not + paged etc. + """ + parent = self.get_instance() + grid = self.make_row_grid(instance=parent, sortable=False, pageable=False, + main_actions=[]) + return grid._fa_grid.rows + def _preconfigure_row_grid(self, g): pass @@ -206,6 +227,14 @@ class MasterView(View): """ return "{}.rows".format(cls.get_route_prefix()) + @classmethod + def get_row_url_prefix(cls): + """ + Returns a prefix which (by default) applies to all URLs provided by the + master view class, for "row" views, e.g. '/products/rows'. + """ + return getattr(cls, 'row_url_prefix', '{}/rows'.format(cls.get_url_prefix())) + @classmethod def get_row_permission_prefix(cls): """ @@ -232,17 +261,44 @@ class MasterView(View): 'permission_prefix': permission_prefix, 'route_prefix': route_prefix, } + + if self.has_rows and 'main_actions' not in defaults: + actions = [] + + # view action + if self.rows_viewable: + view = lambda r, i: self.get_row_action_url('view', r) + actions.append(grids.GridAction('view', icon='zoomin', url=view)) + + # edit action + if self.rows_editable: + actions.append(grids.GridAction('edit', icon='pencil', url=self.row_edit_action_url)) + + # delete action + if self.rows_deletable: + actions.append(grids.GridAction('delete', icon='trash', url=self.row_delete_action_url)) + + defaults['main_actions'] = actions + defaults.update(kwargs) return defaults + def row_edit_action_url(self, row, i): + return self.get_row_action_url('edit', row) + + def row_delete_action_url(self, row, i): + return self.get_row_action_url('delete', row) + def row_grid_row_attrs(self, row, i): return {} - def get_row_model_title(self): - return "{} Row".format(self.get_model_title()) + @classmethod + def get_row_model_title(cls): + return "{} Row".format(cls.get_model_title()) - def get_row_model_title_plural(self): - return "{} Rows".format(self.get_model_title()) + @classmethod + def get_row_model_title_plural(cls): + return "{} Rows".format(cls.get_model_title()) def view_index(self): """ @@ -459,8 +515,17 @@ class MasterView(View): 'action_url': self.get_action_url, 'grid_index': self.grid_index, } + if self.grid_index: context['grid_count'] = self.grid_count + + if self.has_rows: + context['row_route_prefix'] = self.get_row_route_prefix() + context['row_permission_prefix'] = self.get_row_permission_prefix() + context['row_model_title'] = self.get_row_model_title() + context['row_model_title_plural'] = self.get_row_model_title_plural() + context['row_action_url'] = self.get_row_action_url + context.update(data) context.update(self.template_kwargs(**context)) if hasattr(self, 'template_kwargs_{}'.format(template)): @@ -870,6 +935,155 @@ class MasterView(View): return self.after_delete_url return self.get_index_url() + ############################## + # Associated Rows Stuff + ############################## + + def view_row(self): + """ + View for viewing details of a single data row. + """ + self.viewing = True + row = self.get_row_instance() + form = self.make_row_form(row) + parent = self.get_parent(row) + return self.render_to_response('view_row', { + 'instance': row, + 'instance_title': self.get_row_instance_title(row), + 'instance_editable': self.row_editable(row), + 'instance_deletable': self.row_deletable(row), + 'model_title': self.get_row_model_title(), + 'model_title_plural': self.get_row_model_title_plural(), + 'parent_model_title': self.get_model_title(), + 'index_url': self.get_action_url('view', parent), + 'index_title': '{} {}'.format( + self.get_model_title(), + self.get_instance_title(parent)), + 'action_url': self.get_row_action_url, + 'form': form}) + + def edit_row(self): + """ + View for editing an existing model record. + """ + self.editing = True + row = self.get_row_instance() + form = self.make_row_form(row) + + if self.request.method == 'POST': + if form.validate(): + self.save_edit_row_form(form) + return self.redirect_after_edit_row(row) + + parent = self.get_parent(row) + return self.render_to_response('edit_row', { + 'instance': row, + 'instance_title': self.get_row_instance_title(row), + 'instance_deletable': self.row_deletable(row), + 'index_url': self.get_action_url('view', parent), + 'index_title': '{} {}'.format( + self.get_model_title(), + self.get_instance_title(parent)), + 'form': form}) + + def save_edit_row_form(self, form): + self.save_row_form(form) + self.after_edit_row(form.fieldset.model) + + def save_row_form(self, form): + form.save() + + def after_edit_row(self, row): + """ + Event hook, called just after an existing row object is saved. + """ + + def redirect_after_edit_row(self, row): + return self.redirect(self.get_action_url('view', self.get_parent(row))) + + def row_editable(self, row): + """ + Returns boolean indicating whether or not the given row can be + considered "editable". Returns ``True`` by default; override as + necessary. + """ + return True + + def row_deletable(self, row): + """ + Returns boolean indicating whether or not the given row can be + considered "deletable". Returns ``True`` by default; override as + necessary. + """ + return True + + def delete_row(self): + """ + "Delete" a sub-row from the parent. + """ + row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid']) + if not row: + raise httpexceptions.HTTPNotFound() + self.Session.delete(row) + return self.redirect(self.get_action_url('edit', self.get_parent(row))) + + def get_parent(self, row): + raise NotImplementedError + + def get_row_instance_title(self, instance): + return self.get_row_model_title() + + def get_row_instance(self): + key = self.request.matchdict[self.get_model_key()] + instance = self.Session.query(self.model_row_class).get(key) + if not instance: + raise httpexceptions.HTTPNotFound() + return instance + + def make_row_form(self, instance, **kwargs): + """ + Make a FormAlchemy form for use with CRUD views for a data *row*. + """ + # TODO: Some hacky stuff here, to accommodate old form cruft. Probably + # should refactor forms soon too, but trying to avoid it for the moment. + + kwargs.setdefault('creating', self.creating) + kwargs.setdefault('editing', self.editing) + + fieldset = self.make_fieldset(instance) + self._preconfigure_row_fieldset(fieldset) + self.configure_row_fieldset(fieldset) + + kwargs.setdefault('action_url', self.request.current_route_url(_query=None)) + if self.creating: + kwargs.setdefault('cancel_url', self.get_action_url('view', self.get_parent(instance))) + else: + kwargs.setdefault('cancel_url', self.get_row_action_url('view', instance)) + + form = forms.AlchemyForm(self.request, fieldset, **kwargs) + form.readonly = self.viewing + return form + + def _preconfigure_row_fieldset(self, fs): + pass + + def configure_row_fieldset(self, fs): + fs.configure() + + def get_row_action_url(self, action, row): + """ + Generate a URL for the given action on the given row. + """ + return self.request.route_url('{}.{}'.format(self.get_row_route_prefix(), action), + **self.get_row_action_route_kwargs(row)) + + def get_row_action_route_kwargs(self, row): + """ + Hopefully generic kwarg generator for basic action routes. + """ + # TODO: make this smarter? + return {'uuid': row.uuid} + ############################## # Config Stuff ############################## @@ -892,6 +1106,10 @@ class MasterView(View): model_key = cls.get_model_key() model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() + if cls.has_rows: + row_route_prefix = cls.get_row_route_prefix() + row_url_prefix = cls.get_row_url_prefix() + row_model_title = cls.get_row_model_title() config.add_tailbone_permission_group(permission_prefix, model_title_plural, overwrite=False) @@ -940,3 +1158,23 @@ class MasterView(View): permission='{0}.delete'.format(permission_prefix)) config.add_tailbone_permission(permission_prefix, '{0}.delete'.format(permission_prefix), "Delete {0}".format(model_title)) + + ### sub-rows stuff follows + + # view row + if cls.has_rows and cls.rows_viewable: + config.add_route('{}.view'.format(row_route_prefix), '{}/{{uuid}}'.format(row_url_prefix)) + config.add_view(cls, attr='view_row', route_name='{}.view'.format(row_route_prefix), + permission='{}.view'.format(permission_prefix)) + + # edit row + if cls.has_rows and cls.rows_editable: + config.add_route('{}.edit'.format(row_route_prefix), '{}/{{uuid}}/edit'.format(row_url_prefix)) + config.add_view(cls, attr='edit_row', route_name='{}.edit'.format(row_route_prefix), + permission='{}.edit'.format(permission_prefix)) + + # delete row + if cls.has_rows and cls.rows_deletable: + config.add_route('{}.delete'.format(row_route_prefix), '{}/{{uuid}}/delete'.format(row_url_prefix)) + config.add_view(cls, attr='delete_row', route_name='{}.delete'.format(row_route_prefix), + permission='{}.edit'.format(permission_prefix)) diff --git a/tailbone/views/vendors/catalogs.py b/tailbone/views/vendors/catalogs.py index b0ae1249..cc04d067 100644 --- a/tailbone/views/vendors/catalogs.py +++ b/tailbone/views/vendors/catalogs.py @@ -47,7 +47,7 @@ class VendorCatalogsView(FileBatchMasterView): Master view for vendor catalog batches. """ model_class = model.VendorCatalog - batch_row_class = model.VendorCatalogRow + model_row_class = model.VendorCatalogRow batch_handler_class = VendorCatalogHandler url_prefix = '/vendors/catalogs' diff --git a/tailbone/views/vendors/invoices.py b/tailbone/views/vendors/invoices.py index c056cea2..4883fb41 100644 --- a/tailbone/views/vendors/invoices.py +++ b/tailbone/views/vendors/invoices.py @@ -41,7 +41,7 @@ class VendorInvoicesView(FileBatchMasterView): Master view for vendor invoice batches. """ model_class = model.VendorInvoice - batch_row_class = model.VendorInvoiceRow + model_row_class = model.VendorInvoiceRow batch_handler_class = VendorInvoiceHandler url_prefix = '/vendors/invoices'