From def466935b7bd3cd7e1f61bd974275bd13035957 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 9 Feb 2015 15:33:16 -0600 Subject: [PATCH] Add initial versioning support with SQLAlchemy-Continuum. --- tailbone/db.py | 125 +++++++++- tailbone/subscribers.py | 2 + tailbone/templates/brands/crud.mako | 3 + tailbone/templates/brands/versions/index.mako | 3 + tailbone/templates/brands/versions/view.mako | 3 + tailbone/templates/categories/crud.mako | 3 + .../templates/categories/versions/index.mako | 3 + .../templates/categories/versions/view.mako | 3 + tailbone/templates/departments/crud.mako | 3 + .../templates/departments/versions/index.mako | 3 + .../templates/departments/versions/view.mako | 3 + tailbone/templates/labels/profiles/read.mako | 3 + .../labels/profiles/versions/index.mako | 3 + .../labels/profiles/versions/view.mako | 3 + tailbone/templates/products/crud.mako | 3 + .../templates/products/versions/index.mako | 3 + .../templates/products/versions/view.mako | 3 + tailbone/templates/roles/crud.mako | 3 + tailbone/templates/roles/versions/index.mako | 3 + tailbone/templates/roles/versions/view.mako | 3 + tailbone/templates/subdepartments/crud.mako | 3 + .../subdepartments/versions/index.mako | 3 + .../subdepartments/versions/view.mako | 3 + tailbone/templates/users/crud.mako | 3 + tailbone/templates/users/versions/index.mako | 3 + tailbone/templates/users/versions/view.mako | 3 + tailbone/templates/vendors/crud.mako | 3 + .../templates/vendors/versions/index.mako | 3 + tailbone/templates/vendors/versions/view.mako | 3 + tailbone/templates/versions/index.mako | 15 ++ tailbone/templates/versions/view.mako | 103 ++++++++ tailbone/views/brands.py | 22 +- tailbone/views/categories.py | 14 ++ tailbone/views/continuum.py | 231 ++++++++++++++++++ tailbone/views/crud.py | 26 +- tailbone/views/departments.py | 12 + tailbone/views/labels.py | 17 +- tailbone/views/products.py | 28 ++- tailbone/views/roles.py | 14 ++ tailbone/views/subdepartments.py | 12 + tailbone/views/users.py | 13 + tailbone/views/vendors/__init__.py | 3 +- tailbone/views/vendors/core.py | 25 +- 43 files changed, 717 insertions(+), 26 deletions(-) create mode 100644 tailbone/templates/brands/versions/index.mako create mode 100644 tailbone/templates/brands/versions/view.mako create mode 100644 tailbone/templates/categories/versions/index.mako create mode 100644 tailbone/templates/categories/versions/view.mako create mode 100644 tailbone/templates/departments/versions/index.mako create mode 100644 tailbone/templates/departments/versions/view.mako create mode 100644 tailbone/templates/labels/profiles/versions/index.mako create mode 100644 tailbone/templates/labels/profiles/versions/view.mako create mode 100644 tailbone/templates/products/versions/index.mako create mode 100644 tailbone/templates/products/versions/view.mako create mode 100644 tailbone/templates/roles/versions/index.mako create mode 100644 tailbone/templates/roles/versions/view.mako create mode 100644 tailbone/templates/subdepartments/versions/index.mako create mode 100644 tailbone/templates/subdepartments/versions/view.mako create mode 100644 tailbone/templates/users/versions/index.mako create mode 100644 tailbone/templates/users/versions/view.mako create mode 100644 tailbone/templates/vendors/versions/index.mako create mode 100644 tailbone/templates/vendors/versions/view.mako create mode 100644 tailbone/templates/versions/index.mako create mode 100644 tailbone/templates/versions/view.mako create mode 100644 tailbone/views/continuum.py diff --git a/tailbone/db.py b/tailbone/db.py index de50902b..7e12f5b2 100644 --- a/tailbone/db.py +++ b/tailbone/db.py @@ -26,17 +26,124 @@ Database Stuff """ +import sqlalchemy as sa +from zope.sqlalchemy import datamanager +import sqlalchemy_continuum as continuum from sqlalchemy.orm import sessionmaker, scoped_session - -Session = scoped_session(sessionmaker()) +from rattail.db import SessionBase +from rattail.db import model -try: - # Requires zope.sqlalchemy >= 0.7.4 - from zope.sqlalchemy import register -except ImportError: - from zope.sqlalchemy import ZopeTransactionExtension - Session.configure(extension=ZopeTransactionExtension()) -else: +Session = scoped_session(sessionmaker(class_=SessionBase)) + + +class TailboneSessionDataManager(datamanager.SessionDataManager): + """Integrate a top level sqlalchemy session transaction into a zope transaction + + One phase variant. + + .. note:: + This class appears to be necessary in order for the Continuum + integration to work alongside the Zope transaction integration. + """ + + def tpc_vote(self, trans): + # for a one phase data manager commit last in tpc_vote + if self.tx is not None: # there may have been no work to do + + # Force creation of Continuum versions for current session. + mgr = continuum.get_versioning_manager(model.Product) # any ol' model will do + uow = mgr.unit_of_work(self.session) + uow.make_versions(self.session) + + self.tx.commit() + self._finish('committed') + + +def join_transaction(session, initial_state=datamanager.STATUS_ACTIVE, transaction_manager=datamanager.zope_transaction.manager, keep_session=False): + """Join a session to a transaction using the appropriate datamanager. + + It is safe to call this multiple times, if the session is already joined + then it just returns. + + `initial_state` is either STATUS_ACTIVE, STATUS_INVALIDATED or STATUS_READONLY + + If using the default initial status of STATUS_ACTIVE, you must ensure that + mark_changed(session) is called when data is written to the database. + + The ZopeTransactionExtesion SessionExtension can be used to ensure that this is + called automatically after session write operations. + + .. note:: + This function is copied from upstream, and tweaked so that our custom + :class:`TailboneSessionDataManager` will be used. + """ + if datamanager._SESSION_STATE.get(id(session), None) is None: + if session.twophase: + DataManager = datamanager.TwoPhaseSessionDataManager + else: + DataManager = TailboneSessionDataManager + DataManager(session, initial_state, transaction_manager, keep_session=keep_session) + + +class ZopeTransactionExtension(datamanager.ZopeTransactionExtension): + """Record that a flush has occurred on a session's connection. This allows + the DataManager to rollback rather than commit on read only transactions. + + .. note:: + This class is copied from upstream, and tweaked so that our custom + :func:`join_transaction()` will be used. + """ + + def after_begin(self, session, transaction, connection): + join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + + def after_attach(self, session, instance): + join_transaction(session, self.initial_state, self.transaction_manager, self.keep_session) + + +def register(session, initial_state=datamanager.STATUS_ACTIVE, + transaction_manager=datamanager.zope_transaction.manager, keep_session=False): + """Register ZopeTransaction listener events on the + given Session or Session factory/class. + + This function requires at least SQLAlchemy 0.7 and makes use + of the newer sqlalchemy.event package in order to register event listeners + on the given Session. + + The session argument here may be a Session class or subclass, a + sessionmaker or scoped_session instance, or a specific Session instance. + Event listening will be specific to the scope of the type of argument + passed, including specificity to its subclass as well as its identity. + + .. note:: + This function is copied from upstream, and tweaked so that our custom + :class:`ZopeTransactionExtension` will be used. + """ + + from sqlalchemy import __version__ + assert tuple(int(x) for x in __version__.split(".")) >= (0, 7), \ + "SQLAlchemy version 0.7 or greater required to use register()" + + from sqlalchemy import event + + ext = ZopeTransactionExtension( + initial_state=initial_state, + transaction_manager=transaction_manager, + keep_session=keep_session, + ) + + event.listen(session, "after_begin", ext.after_begin) + event.listen(session, "after_attach", ext.after_attach) + event.listen(session, "after_flush", ext.after_flush) + event.listen(session, "after_bulk_update", ext.after_bulk_update) + event.listen(session, "after_bulk_delete", ext.after_bulk_delete) + event.listen(session, "before_commit", ext.before_commit) + + +# TODO: We can probably assume a new SA version since we use Continuum now. +if tuple(int(x) for x in sa.__version__.split('.')) >= (0, 7): register(Session) +else: + Session.configure(extension=ZopeTransactionExtension()) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index 890d6809..fb7c5d36 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -97,6 +97,8 @@ def context_found(event): uuid = authenticated_userid(request) if uuid: request.user = Session.query(User).get(uuid) + if request.user: + Session().set_continuum_user(request.user) def has_perm(perm): return has_permission(Session(), request.user, perm) diff --git a/tailbone/templates/brands/crud.mako b/tailbone/templates/brands/crud.mako index 3a35a993..dea13003 100644 --- a/tailbone/templates/brands/crud.mako +++ b/tailbone/templates/brands/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Brand", url('brand.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('brand.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('brand.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/brands/versions/index.mako b/tailbone/templates/brands/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/brands/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/brands/versions/view.mako b/tailbone/templates/brands/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/brands/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/categories/crud.mako b/tailbone/templates/categories/crud.mako index 62ee9b43..b1ce53e7 100644 --- a/tailbone/templates/categories/crud.mako +++ b/tailbone/templates/categories/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Category", url('category.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('category.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('category.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/categories/versions/index.mako b/tailbone/templates/categories/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/categories/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/categories/versions/view.mako b/tailbone/templates/categories/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/categories/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/departments/crud.mako b/tailbone/templates/departments/crud.mako index e726ec77..8d819021 100644 --- a/tailbone/templates/departments/crud.mako +++ b/tailbone/templates/departments/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Department", url('department.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('department.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('department.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/departments/versions/index.mako b/tailbone/templates/departments/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/departments/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/departments/versions/view.mako b/tailbone/templates/departments/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/departments/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/labels/profiles/read.mako b/tailbone/templates/labels/profiles/read.mako index 074a287a..8af13c45 100644 --- a/tailbone/templates/labels/profiles/read.mako +++ b/tailbone/templates/labels/profiles/read.mako @@ -11,6 +11,9 @@
  • ${h.link_to("Edit Printer Settings", url('label_profile.printer_settings', uuid=profile.uuid))}
  • % endif % endif + % if not form.creating and request.has_perm('labelprofile.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('labelprofile.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/labels/profiles/versions/index.mako b/tailbone/templates/labels/profiles/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/labels/profiles/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/labels/profiles/versions/view.mako b/tailbone/templates/labels/profiles/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/labels/profiles/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/products/crud.mako b/tailbone/templates/products/crud.mako index 4df96af2..68370002 100644 --- a/tailbone/templates/products/crud.mako +++ b/tailbone/templates/products/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Product", url('product.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('product.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('product.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/products/versions/index.mako b/tailbone/templates/products/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/products/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/products/versions/view.mako b/tailbone/templates/products/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/products/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/roles/crud.mako b/tailbone/templates/roles/crud.mako index 1a7acafd..0965e937 100644 --- a/tailbone/templates/roles/crud.mako +++ b/tailbone/templates/roles/crud.mako @@ -14,6 +14,9 @@
  • ${h.link_to("View this Role", url('role.read', uuid=form.fieldset.model.uuid))}
  • % endif
  • ${h.link_to("Delete this Role", url('role.delete', uuid=form.fieldset.model.uuid), class_='delete')}
  • + % if not form.creating and request.has_perm('role.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('role.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/roles/versions/index.mako b/tailbone/templates/roles/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/roles/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/roles/versions/view.mako b/tailbone/templates/roles/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/roles/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/subdepartments/crud.mako b/tailbone/templates/subdepartments/crud.mako index b71cc553..e64a1b7f 100644 --- a/tailbone/templates/subdepartments/crud.mako +++ b/tailbone/templates/subdepartments/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Subdepartment", url('subdepartment.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('subdepartment.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('subdepartment.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/subdepartments/versions/index.mako b/tailbone/templates/subdepartments/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/subdepartments/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/subdepartments/versions/view.mako b/tailbone/templates/subdepartments/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/subdepartments/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/users/crud.mako b/tailbone/templates/users/crud.mako index e8006cc2..e18c1b27 100644 --- a/tailbone/templates/users/crud.mako +++ b/tailbone/templates/users/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this User", url('user.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('user.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/users/versions/index.mako b/tailbone/templates/users/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/users/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/users/versions/view.mako b/tailbone/templates/users/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/users/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/crud.mako b/tailbone/templates/vendors/crud.mako index db718ae9..b3b2fd2a 100644 --- a/tailbone/templates/vendors/crud.mako +++ b/tailbone/templates/vendors/crud.mako @@ -8,6 +8,9 @@ % elif form.updating:
  • ${h.link_to("View this Vendor", url('vendor.read', uuid=form.fieldset.model.uuid))}
  • % endif + % if not form.creating and request.has_perm('vendor.versions.view'): +
  • ${h.link_to("View Change History ({0})".format(version_count), url('vendor.versions', uuid=form.fieldset.model.uuid))}
  • + % endif ${parent.body()} diff --git a/tailbone/templates/vendors/versions/index.mako b/tailbone/templates/vendors/versions/index.mako new file mode 100644 index 00000000..d26bebd4 --- /dev/null +++ b/tailbone/templates/vendors/versions/index.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/index.mako" /> +${parent.body()} diff --git a/tailbone/templates/vendors/versions/view.mako b/tailbone/templates/vendors/versions/view.mako new file mode 100644 index 00000000..94567acb --- /dev/null +++ b/tailbone/templates/vendors/versions/view.mako @@ -0,0 +1,3 @@ +## -*- coding: utf-8 -*- +<%inherit file="/versions/view.mako" /> +${parent.body()} diff --git a/tailbone/templates/versions/index.mako b/tailbone/templates/versions/index.mako new file mode 100644 index 00000000..be7d956e --- /dev/null +++ b/tailbone/templates/versions/index.mako @@ -0,0 +1,15 @@ +## -*- coding: utf-8 -*- +<%inherit file="/grid.mako" /> + +<%def name="title()">${model_title} Change History + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to all {0}".format(model_title_plural), url(route_model_list))}
  • +
  • ${h.link_to("Back to current {0}".format(model_title), url(route_model_view, uuid=model_instance.uuid))}
  • + + +<%def name="form()"> +

    Changes for ${model_title}:  ${model_instance}

    + + +${parent.body()} diff --git a/tailbone/templates/versions/view.mako b/tailbone/templates/versions/view.mako new file mode 100644 index 00000000..49d7c4e5 --- /dev/null +++ b/tailbone/templates/versions/view.mako @@ -0,0 +1,103 @@ +## -*- coding: utf-8 -*- +<%inherit file="/base.mako" /> + +<%def name="title()">${model_title} Version Details + +<%def name="head_tags()"> + + + +<%def name="context_menu_items()"> +
  • ${h.link_to("Back to all {0}".format(model_title_plural), url(route_model_list))}
  • +
  • ${h.link_to("Back to current {0}".format(model_title), url(route_model_view, uuid=model_instance.uuid))}
  • +
  • ${h.link_to("Back to Version History", url('{0}.versions'.format(route_prefix), uuid=model_instance.uuid))}
  • + + +
    + + + +
    + +
    + % if previous_transaction or next_transaction: + % if previous_transaction: + ${h.link_to("<< older version", url('{0}.version'.format(route_prefix), uuid=model_instance.uuid, transaction_id=previous_transaction.id))} + % else: + (oldest version) + % endif +   |   + % if next_transaction: + ${h.link_to("newer version >>", url('{0}.version'.format(route_prefix), uuid=model_instance.uuid, transaction_id=next_transaction.id))} + % else: + (newest version) + % endif + % else: + (only version) + % endif +
    + +
    + +
    + +
    ${h.pretty_datetime(request.rattail_config, transaction.issued_at)}
    +
    +
    + +
    ${transaction.user or "(unknown / system)"}
    +
    +
    + +
    ${transaction.remote_addr}
    +
    + + % for ver in versions: + +
    + +
    ${ver.version_parent.__class__.__name__}:  ${ver.version_parent}
    +
    + +
    + +
    +
    + + + + + + + + + + % for key in sorted(ver.changeset): + + + + + + % endfor + +
    FieldOld ValueNew Value
    ${key}${ver.changeset[key][0]}${ver.changeset[key][1]}
    +
    +
    +
    + + % endfor + +
    + +
    + +
    diff --git a/tailbone/views/brands.py b/tailbone/views/brands.py index ceaf6d3d..6099c809 100644 --- a/tailbone/views/brands.py +++ b/tailbone/views/brands.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,15 +20,18 @@ # along with Rattail. If not, see . # ################################################################################ - """ Brand Views """ -from . import SearchableAlchemyGridView, CrudView, AutocompleteView +from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import Brand +from . import SearchableAlchemyGridView, CrudView, AutocompleteView +from .continuum import VersionView, version_defaults + class BrandsGrid(SearchableAlchemyGridView): @@ -81,6 +83,14 @@ class BrandCrud(CrudView): return fs +class BrandVersionView(VersionView): + """ + View which shows version history for a brand. + """ + parent_class = model.Brand + route_model_view = 'brand.read' + + class BrandsAutocomplete(AutocompleteView): mapped_class = Brand @@ -122,3 +132,5 @@ def includeme(config): config.add_view(BrandCrud, attr='delete', route_name='brand.delete', permission='brands.delete') + + version_defaults(config, BrandVersionView, 'brand') diff --git a/tailbone/views/categories.py b/tailbone/views/categories.py index 18e92baa..8947a78f 100644 --- a/tailbone/views/categories.py +++ b/tailbone/views/categories.py @@ -26,9 +26,11 @@ Category Views from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import Category from . import SearchableAlchemyGridView, CrudView +from .continuum import VersionView, version_defaults class CategoriesGrid(SearchableAlchemyGridView): @@ -85,6 +87,16 @@ class CategoryCrud(CrudView): return fs +class CategoryVersionView(VersionView): + """ + View which shows version history for a category. + """ + parent_class = model.Category + model_title_plural = "Categories" + route_model_list = 'categories' + route_model_view = 'category.read' + + def add_routes(config): config.add_route('categories', '/categories') config.add_route('category.create', '/categories/new') @@ -109,3 +121,5 @@ def includeme(config): renderer='/categories/crud.mako', permission='categories.update') config.add_view(CategoryCrud, attr='delete', route_name='category.delete', permission='categories.delete') + + version_defaults(config, CategoryVersionView, 'category', template_prefix='/categories') diff --git a/tailbone/views/continuum.py b/tailbone/views/continuum.py new file mode 100644 index 00000000..d7f0bdb2 --- /dev/null +++ b/tailbone/views/continuum.py @@ -0,0 +1,231 @@ +# -*- 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 . +# +################################################################################ +""" +Continuum Version Views +""" + +from __future__ import unicode_literals + +import sqlalchemy as sa +import sqlalchemy_continuum as continuum + +from rattail.db import model +from rattail.db.model.continuum import model_transaction_query + +import formalchemy +from pyramid.httpexceptions import HTTPNotFound + +from tailbone.db import Session +from tailbone.views import PagedAlchemyGridView, View +from tailbone.forms import DateTimeFieldRenderer + + +class VersionView(PagedAlchemyGridView): + """ + View which shows version history for a model instance. + """ + + @property + def parent_class(self): + """ + Model class which is "parent" to the version class. + """ + raise NotImplementedError("Please set `parent_class` on your `VersionView` subclass.") + + @property + def child_classes(self): + """ + Model class(es) which are "children" to the version's parent class. + """ + return [] + + @property + def model_title(self): + """ + Human-friendly title for the parent model class. + """ + return self.parent_class.__name__ + + @property + def model_title_plural(self): + """ + Plural version of the human-friendly title for the parent model class. + """ + return '{0}s'.format(self.model_title) + + @property + def prefix(self): + return self.parent_class.__name__.lower() + + @property + def config_prefix(self): + return self.prefix + + @property + def transaction_class(self): + return continuum.transaction_class(self.parent_class) + + @property + def mapped_class(self): + return self.transaction_class + + @property + def version_class(self): + return continuum.version_class(self.parent_class) + + @property + def route_model_list(self): + return '{0}s'.format(self.prefix) + + @property + def route_model_view(self): + return self.prefix + + def join_map(self): + return { + 'user': + lambda q: q.outerjoin(model.User, self.transaction_class.user_uuid == model.User.uuid), + } + + def sort_config(self): + return self.make_sort_config(sort='issued_at', dir='desc') + + def sort_map(self): + return self.make_sort_map('issued_at', 'remote_addr', + user=self.sorter(model.User.username)) + + def transaction_query(self, session=Session): + uuid = self.request.matchdict['uuid'] + return model_transaction_query(session, uuid, self.parent_class, + child_classes=self.child_classes) + + def make_query(self, session=Session): + query = self.transaction_query(session) + return self.modify_query(query) + + def grid(self): + g = self.make_grid() + g.issued_at.set(renderer=DateTimeFieldRenderer(self.request.rattail_config)) + g.configure( + include=[ + g.issued_at.label("When"), + g.user.label("Who"), + g.remote_addr.label("Client IP"), + ], + readonly=True) + g.viewable = True + g.view_route_name = '{0}.version'.format(self.prefix) + g.view_route_kwargs = self.view_route_kwargs + return g + + def render_kwargs(self): + instance = Session.query(self.parent_class).get(self.request.matchdict['uuid']) + return {'model_title': self.model_title, + 'model_title_plural': self.model_title_plural, + 'model_instance': instance, + 'route_model_list': self.route_model_list, + 'route_model_view': self.route_model_view} + + def view_route_kwargs(self, transaction): + return {'uuid': self.request.matchdict['uuid'], + 'transaction_id': transaction.id} + + def list(self): + """ + View which shows the version history list for a model instance. + """ + return self() + + def details(self): + """ + View which shows the change details of a model version. + """ + kwargs = self.render_kwargs() + uuid = self.request.matchdict['uuid'] + transaction_id = self.request.matchdict['transaction_id'] + transaction = Session.query(self.transaction_class).get(transaction_id) + if not transaction: + raise HTTPNotFound + + version = Session.query(self.version_class).get((uuid, transaction_id)) + + def normalize_child_classes(): + classes = [] + for cls in self.child_classes: + if not isinstance(cls, tuple): + cls = (cls, 'uuid') + classes.append(cls) + return classes + + versions = [] + if version: + versions.append(version) + for model_class, attr in normalize_child_classes(): + if isinstance(model_class, type) and issubclass(model_class, model.Base): + cls = continuum.version_class(model_class) + ver = Session.query(cls).filter_by(transaction_id=transaction_id, **{attr: uuid}).first() + if ver: + versions.append(ver) + + previous_transaction = self.transaction_query()\ + .order_by(self.transaction_class.id.desc())\ + .filter(self.transaction_class.id < transaction.id)\ + .first() + + next_transaction = self.transaction_query()\ + .order_by(self.transaction_class.id.asc())\ + .filter(self.transaction_class.id > transaction.id)\ + .first() + + kwargs.update({ + 'route_prefix': self.prefix, + 'version': version, + 'transaction': transaction, + 'versions': versions, + 'parent_class': continuum.parent_class, + 'previous_transaction': previous_transaction, + 'next_transaction': next_transaction, + }) + + return kwargs + + +def version_defaults(config, VersionView, prefix, template_prefix=None): + """ + Apply default route/view configuration for the given ``VersionView``. + """ + if template_prefix is None: + template_prefix = '/{0}s'.format(prefix) + template_prefix = template_prefix.rstrip('/') + + # list changesets + config.add_route('{0}.versions'.format(prefix), '/{0}/{{uuid}}/changesets/'.format(prefix)) + config.add_view(VersionView, attr='list', route_name='{0}.versions'.format(prefix), + renderer='{0}/versions/index.mako'.format(template_prefix), + permission='{0}.versions.view'.format(prefix)) + + # view changeset + config.add_route('{0}.version'.format(prefix), '/{0}/{{uuid}}/changeset/{{transaction_id}}'.format(prefix)) + config.add_view(VersionView, attr='details', route_name='{0}.version'.format(prefix), + renderer='{0}/versions/view.mako'.format(template_prefix), + permission='{0}.versions.view'.format(prefix)) diff --git a/tailbone/views/crud.py b/tailbone/views/crud.py index 1a2d3203..ab74aa60 100644 --- a/tailbone/views/crud.py +++ b/tailbone/views/crud.py @@ -32,16 +32,23 @@ except ImportError: inspect = None from sqlalchemy.orm import class_mapper +import sqlalchemy as sa +from sqlalchemy_continuum import transaction_class, version_class + +from rattail.db import model +from rattail.db.model.continuum import count_versions, model_transaction_query + from pyramid.httpexceptions import HTTPFound, HTTPNotFound from .core import View from ..forms import AlchemyForm from formalchemy import FieldSet -from ..db import Session from edbob.util import prettify +from tailbone.db import Session + __all__ = ['CrudView'] @@ -51,6 +58,7 @@ class CrudView(View): readonly = False allow_successive_creates = False update_cancel_route = None + child_version_classes = [] @property def mapped_class(self): @@ -161,7 +169,21 @@ class CrudView(View): pass def template_kwargs(self, form): - return {} + if form.creating: + return {} + return {'version_count': self.count_versions()} + + def count_versions(self): + query = self.transaction_query() + return query.count() + + def transaction_query(self, parent_class=None, child_classes=None): + uuid = self.request.matchdict['uuid'] + if parent_class is None: + parent_class = self.mapped_class + if child_classes is None: + child_classes = self.child_version_classes + return model_transaction_query(Session, uuid, parent_class, child_classes=child_classes) def post_save(self, form): pass diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 367577b2..b477f065 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -26,9 +26,11 @@ Department Views from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import Department, Product, ProductCost, Vendor from . import SearchableAlchemyGridView, CrudView, AlchemyGridView, AutocompleteView +from .continuum import VersionView, version_defaults class DepartmentsGrid(SearchableAlchemyGridView): @@ -83,6 +85,14 @@ class DepartmentCrud(CrudView): return fs +class DepartmentVersionView(VersionView): + """ + View which shows version history for a department. + """ + parent_class = model.Department + route_model_view = 'department.read' + + class DepartmentsByVendorGrid(AlchemyGridView): mapped_class = Department @@ -150,3 +160,5 @@ def includeme(config): renderer='/departments/crud.mako', permission='departments.update') config.add_view(DepartmentCrud, attr='delete', route_name='department.delete', permission='departments.delete') + + version_defaults(config, DepartmentVersionView, 'department') diff --git a/tailbone/views/labels.py b/tailbone/views/labels.py index bd292fcb..fdf8699b 100644 --- a/tailbone/views/labels.py +++ b/tailbone/views/labels.py @@ -26,6 +26,9 @@ Label Views from __future__ import unicode_literals +from rattail.db import model +from rattail.db.model import LabelProfile + from pyramid.httpexceptions import HTTPFound import formalchemy @@ -36,7 +39,7 @@ from ..db import Session from . import SearchableAlchemyGridView, CrudView from ..grids.search import BooleanSearchFilter -from rattail.db.model import LabelProfile +from .continuum import VersionView, version_defaults class ProfilesGrid(SearchableAlchemyGridView): @@ -129,6 +132,16 @@ class ProfileCrud(CrudView): uuid=form.fieldset.model.uuid) +class LabelProfileVersionView(VersionView): + """ + View which shows version history for a label profile. + """ + parent_class = model.LabelProfile + model_title = "Label Profile" + route_model_list = 'label_profiles' + route_model_view = 'label_profile.read' + + def printer_settings(request): uuid = request.matchdict['uuid'] profile = Session.query(LabelProfile).get(uuid) if uuid else None @@ -187,3 +200,5 @@ def includeme(config): config.add_view(printer_settings, route_name='label_profile.printer_settings', renderer='/labels/profiles/printer.mako', permission='label_profiles.update') + + version_defaults(config, LabelProfileVersionView, 'labelprofile', template_prefix='/labels/profiles') diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 54f88c6f..d30aa2cf 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -56,6 +56,7 @@ from rattail.pod import get_image_url, get_image_path from ..db import Session from ..forms import GPCFieldRenderer, BrandFieldRenderer, PriceFieldRenderer from . import CrudView +from .continuum import VersionView, version_defaults from ..progress import SessionProgress @@ -237,9 +238,16 @@ class ProductsGrid(SearchableAlchemyGridView): class ProductCrud(CrudView): - + """ + Product CRUD view class. + """ mapped_class = Product home_route = 'products' + child_version_classes = [ + (model.ProductCode, 'product_uuid'), + (model.ProductCost, 'product_uuid'), + (model.ProductPrice, 'product_uuid'), + ] def get_model(self, key): model = super(ProductCrud, self).get_model(key) @@ -277,7 +285,8 @@ class ProductCrud(CrudView): return fs def template_kwargs(self, form): - kwargs = {'image': False} + kwargs = super(ProductCrud, self).template_kwargs(form) + kwargs['image'] = False product = form.fieldset.model if product.upc: kwargs['image_url'] = get_image_url( @@ -289,6 +298,19 @@ class ProductCrud(CrudView): return kwargs +class ProductVersionView(VersionView): + """ + View which shows version history for a product. + """ + parent_class = model.Product + route_model_view = 'product.read' + child_classes = [ + (model.ProductCode, 'product_uuid'), + (model.ProductCost, 'product_uuid'), + (model.ProductPrice, 'product_uuid'), + ] + + def products_search(request): """ Locate a product(s) by UPC. @@ -443,3 +465,5 @@ def includeme(config): permission='products.delete') config.add_view(products_search, route_name='products.search', renderer='json', permission='products.list') + + version_defaults(config, ProductVersionView, 'product') diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py index ce6520e2..926a1658 100644 --- a/tailbone/views/roles.py +++ b/tailbone/views/roles.py @@ -26,6 +26,8 @@ Role Views """ +from rattail.db import model + from . import SearchableAlchemyGridView, CrudView from pyramid.httpexceptions import HTTPFound @@ -37,6 +39,8 @@ import formalchemy from webhelpers.html import tags from webhelpers.html import HTML +from .continuum import VersionView, version_defaults + default_permissions = [ ("Batches", [ @@ -264,6 +268,14 @@ class RoleCrud(CrudView): return HTTPFound(location=self.request.get_referrer()) +class RoleVersionView(VersionView): + """ + View which shows version history for a role. + """ + parent_class = model.Role + route_model_view = 'role.read' + + def includeme(config): config.add_route('roles', '/roles') @@ -294,3 +306,5 @@ def includeme(config): config.add_route('role.delete', '/roles/{uuid}/delete') config.add_view(RoleCrud, attr='delete', route_name='role.delete', permission='roles.delete') + + version_defaults(config, RoleVersionView, 'role') diff --git a/tailbone/views/subdepartments.py b/tailbone/views/subdepartments.py index f9a89a8d..ee3f3aa0 100644 --- a/tailbone/views/subdepartments.py +++ b/tailbone/views/subdepartments.py @@ -26,9 +26,11 @@ Subdepartment Views from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import Subdepartment from . import SearchableAlchemyGridView, CrudView +from .continuum import VersionView, version_defaults class SubdepartmentsGrid(SearchableAlchemyGridView): @@ -85,6 +87,14 @@ class SubdepartmentCrud(CrudView): return fs +class SubdepartmentVersionView(VersionView): + """ + View which shows version history for a subdepartment. + """ + parent_class = model.Subdepartment + route_model_view = 'subdepartment.read' + + def add_routes(config): config.add_route('subdepartments', '/subdepartments') config.add_route('subdepartment.create', '/subdepartments/new') @@ -109,3 +119,5 @@ def includeme(config): renderer='/subdepartments/crud.mako', permission='subdepartments.update') config.add_view(SubdepartmentCrud, attr='delete', route_name='subdepartment.delete', permission='subdepartments.delete') + + version_defaults(config, SubdepartmentVersionView, 'subdepartment') diff --git a/tailbone/views/users.py b/tailbone/views/users.py index 983fe3ad..f18090d5 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -26,6 +26,7 @@ User Views from __future__ import unicode_literals +from rattail.db import model from rattail.db.model import User, Person, Role from rattail.db.auth import guest_role, set_user_password @@ -40,6 +41,8 @@ from ..forms import PersonFieldLinkRenderer from ..db import Session from tailbone.grids.search import BooleanSearchFilter +from .continuum import VersionView, version_defaults + class UsersGrid(SearchableAlchemyGridView): @@ -205,6 +208,14 @@ class UserCrud(CrudView): return fs +class UserVersionView(VersionView): + """ + View which shows version history for a user. + """ + parent_class = model.User + route_model_view = 'user.read' + + def add_routes(config): config.add_route(u'users', u'/users') config.add_route(u'user.create', u'/users/new') @@ -233,3 +244,5 @@ def includeme(config): permission='users.update') config.add_view(UserCrud, attr='delete', route_name='user.delete', permission='users.delete') + + version_defaults(config, UserVersionView, 'user') diff --git a/tailbone/views/vendors/__init__.py b/tailbone/views/vendors/__init__.py index 54f2c7f1..f41ba844 100644 --- a/tailbone/views/vendors/__init__.py +++ b/tailbone/views/vendors/__init__.py @@ -26,7 +26,8 @@ Views pertaining to vendors from __future__ import unicode_literals -from .core import VendorsGrid, VendorCrud, VendorsAutocomplete, add_routes +from .core import (VendorsGrid, VendorCrud, VendorVersionView, + VendorsAutocomplete, add_routes) def includeme(config): diff --git a/tailbone/views/vendors/core.py b/tailbone/views/vendors/core.py index edf973aa..96146b1b 100644 --- a/tailbone/views/vendors/core.py +++ b/tailbone/views/vendors/core.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2012 Lance Edgar +# Copyright © 2010-2015 Lance Edgar # # This file is part of Rattail. # @@ -21,15 +20,19 @@ # along with Rattail. If not, see . # ################################################################################ - """ Vendor Views """ -from tailbone.views import SearchableAlchemyGridView, CrudView, AutocompleteView -from tailbone.forms import AssociationProxyField, PersonFieldRenderer +from __future__ import unicode_literals + +from rattail.db import model from rattail.db.model import Vendor +from tailbone.views import SearchableAlchemyGridView, CrudView, AutocompleteView +from tailbone.views.continuum import VersionView, version_defaults +from tailbone.forms import AssociationProxyField, PersonFieldRenderer + class VendorsGrid(SearchableAlchemyGridView): @@ -93,6 +96,14 @@ class VendorCrud(CrudView): return fs +class VendorVersionView(VersionView): + """ + View which shows version history for a vendor. + """ + parent_class = model.Vendor + route_model_view = 'vendor.read' + + class VendorsAutocomplete(AutocompleteView): mapped_class = Vendor @@ -127,3 +138,5 @@ def includeme(config): permission='vendors.update') config.add_view(VendorCrud, attr='delete', route_name='vendor.delete', permission='vendors.delete') + + version_defaults(config, VendorVersionView, 'vendor')