Add initial versioning support with SQLAlchemy-Continuum.
This commit is contained in:
parent
41dd2ef17b
commit
def466935b
125
tailbone/db.py
125
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())
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -8,6 +8,9 @@
|
|||
% elif form.updating:
|
||||
<li>${h.link_to("View this Brand", url('brand.read', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
% if not form.creating and request.has_perm('brand.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('brand.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/brands/versions/index.mako
Normal file
3
tailbone/templates/brands/versions/index.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/brands/versions/view.mako
Normal file
3
tailbone/templates/brands/versions/view.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
|
@ -8,6 +8,9 @@
|
|||
% elif form.updating:
|
||||
<li>${h.link_to("View this Category", url('category.read', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
% if not form.creating and request.has_perm('category.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('category.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/categories/versions/index.mako
Normal file
3
tailbone/templates/categories/versions/index.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/categories/versions/view.mako
Normal file
3
tailbone/templates/categories/versions/view.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
|
@ -8,6 +8,9 @@
|
|||
% elif form.updating:
|
||||
<li>${h.link_to("View this Department", url('department.read', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
% if not form.creating and request.has_perm('department.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('department.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/departments/versions/index.mako
Normal file
3
tailbone/templates/departments/versions/index.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/departments/versions/view.mako
Normal file
3
tailbone/templates/departments/versions/view.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
|
@ -11,6 +11,9 @@
|
|||
<li>${h.link_to("Edit Printer Settings", url('label_profile.printer_settings', uuid=profile.uuid))}</li>
|
||||
% endif
|
||||
% endif
|
||||
% if not form.creating and request.has_perm('labelprofile.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('labelprofile.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/labels/profiles/versions/index.mako
Normal file
3
tailbone/templates/labels/profiles/versions/index.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/labels/profiles/versions/view.mako
Normal file
3
tailbone/templates/labels/profiles/versions/view.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
|
@ -8,6 +8,9 @@
|
|||
% elif form.updating:
|
||||
<li>${h.link_to("View this Product", url('product.read', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
% if not form.creating and request.has_perm('product.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('product.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/products/versions/index.mako
Normal file
3
tailbone/templates/products/versions/index.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/products/versions/view.mako
Normal file
3
tailbone/templates/products/versions/view.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
|
@ -14,6 +14,9 @@
|
|||
<li>${h.link_to("View this Role", url('role.read', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
<li>${h.link_to("Delete this Role", url('role.delete', uuid=form.fieldset.model.uuid), class_='delete')}</li>
|
||||
% if not form.creating and request.has_perm('role.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('role.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/roles/versions/index.mako
Normal file
3
tailbone/templates/roles/versions/index.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/roles/versions/view.mako
Normal file
3
tailbone/templates/roles/versions/view.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
|
@ -8,6 +8,9 @@
|
|||
% elif form.updating:
|
||||
<li>${h.link_to("View this Subdepartment", url('subdepartment.read', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
% if not form.creating and request.has_perm('subdepartment.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('subdepartment.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/subdepartments/versions/index.mako
Normal file
3
tailbone/templates/subdepartments/versions/index.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/subdepartments/versions/view.mako
Normal file
3
tailbone/templates/subdepartments/versions/view.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
|
@ -8,6 +8,9 @@
|
|||
% elif form.updating:
|
||||
<li>${h.link_to("View this User", url('user.read', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
% if not form.creating and request.has_perm('user.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/users/versions/index.mako
Normal file
3
tailbone/templates/users/versions/index.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/users/versions/view.mako
Normal file
3
tailbone/templates/users/versions/view.mako
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/vendors/crud.mako
vendored
3
tailbone/templates/vendors/crud.mako
vendored
|
@ -8,6 +8,9 @@
|
|||
% elif form.updating:
|
||||
<li>${h.link_to("View this Vendor", url('vendor.read', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
% if not form.creating and request.has_perm('vendor.versions.view'):
|
||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('vendor.versions', uuid=form.fieldset.model.uuid))}</li>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
||||
|
|
3
tailbone/templates/vendors/versions/index.mako
vendored
Normal file
3
tailbone/templates/vendors/versions/index.mako
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/index.mako" />
|
||||
${parent.body()}
|
3
tailbone/templates/vendors/versions/view.mako
vendored
Normal file
3
tailbone/templates/vendors/versions/view.mako
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/versions/view.mako" />
|
||||
${parent.body()}
|
15
tailbone/templates/versions/index.mako
Normal file
15
tailbone/templates/versions/index.mako
Normal file
|
@ -0,0 +1,15 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/grid.mako" />
|
||||
|
||||
<%def name="title()">${model_title} Change History</%def>
|
||||
|
||||
<%def name="context_menu_items()">
|
||||
<li>${h.link_to("Back to all {0}".format(model_title_plural), url(route_model_list))}</li>
|
||||
<li>${h.link_to("Back to current {0}".format(model_title), url(route_model_view, uuid=model_instance.uuid))}</li>
|
||||
</%def>
|
||||
|
||||
<%def name="form()">
|
||||
<h2>Changes for ${model_title}: ${model_instance}</h2>
|
||||
</%def>
|
||||
|
||||
${parent.body()}
|
103
tailbone/templates/versions/view.mako
Normal file
103
tailbone/templates/versions/view.mako
Normal file
|
@ -0,0 +1,103 @@
|
|||
## -*- coding: utf-8 -*-
|
||||
<%inherit file="/base.mako" />
|
||||
|
||||
<%def name="title()">${model_title} Version Details</%def>
|
||||
|
||||
<%def name="head_tags()">
|
||||
<style type="text/css">
|
||||
td.oldvalue {
|
||||
background-color: #fcc;
|
||||
}
|
||||
td.newvalue {
|
||||
background-color: #cfc;
|
||||
}
|
||||
</style>
|
||||
</%def>
|
||||
|
||||
<%def name="context_menu_items()">
|
||||
<li>${h.link_to("Back to all {0}".format(model_title_plural), url(route_model_list))}</li>
|
||||
<li>${h.link_to("Back to current {0}".format(model_title), url(route_model_view, uuid=model_instance.uuid))}</li>
|
||||
<li>${h.link_to("Back to Version History", url('{0}.versions'.format(route_prefix), uuid=model_instance.uuid))}</li>
|
||||
</%def>
|
||||
|
||||
<div class="form-wrapper">
|
||||
|
||||
<ul class="context-menu">
|
||||
${self.context_menu_items()}
|
||||
</ul>
|
||||
|
||||
<div class="form">
|
||||
|
||||
<div>
|
||||
% 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:
|
||||
<span>(oldest version)</span>
|
||||
% 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:
|
||||
<span>(newest version)</span>
|
||||
% endif
|
||||
% else:
|
||||
<span>(only version)</span>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
|
||||
<div class="field-wrapper">
|
||||
<label>When:</label>
|
||||
<div class="field">${h.pretty_datetime(request.rattail_config, transaction.issued_at)}</div>
|
||||
</div>
|
||||
<div class="field-wrapper">
|
||||
<label>Who:</label>
|
||||
<div class="field">${transaction.user or "(unknown / system)"}</div>
|
||||
</div>
|
||||
<div class="field-wrapper">
|
||||
<label>Where:</label>
|
||||
<div class="field">${transaction.remote_addr}</div>
|
||||
</div>
|
||||
|
||||
% for ver in versions:
|
||||
|
||||
<div class="field-wrapper">
|
||||
<label>What:</label>
|
||||
<div class="field" style="font-weight: bold;">${ver.version_parent.__class__.__name__}: ${ver.version_parent}</div>
|
||||
</div>
|
||||
|
||||
<div class="field-wrapper">
|
||||
<label>Changes:</label>
|
||||
<div class="field">
|
||||
<div class="grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Old Value</th>
|
||||
<th>New Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
% for key in sorted(ver.changeset):
|
||||
<tr>
|
||||
<td>${key}</td>
|
||||
<td class="oldvalue">${ver.changeset[key][0]}</td>
|
||||
<td class="newvalue">${ver.changeset[key][1]}</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
% endfor
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
"""
|
||||
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')
|
||||
|
|
|
@ -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')
|
||||
|
|
231
tailbone/views/continuum.py
Normal file
231
tailbone/views/continuum.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
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))
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
3
tailbone/views/vendors/__init__.py
vendored
3
tailbone/views/vendors/__init__.py
vendored
|
@ -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):
|
||||
|
|
25
tailbone/views/vendors/core.py
vendored
25
tailbone/views/vendors/core.py
vendored
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
|
||||
"""
|
||||
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')
|
||||
|
|
Loading…
Reference in a new issue