Add initial versioning support with SQLAlchemy-Continuum.

This commit is contained in:
Lance Edgar 2015-02-09 15:33:16 -06:00
parent 41dd2ef17b
commit def466935b
43 changed files with 717 additions and 26 deletions

View file

@ -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')

View file

@ -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
View 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))

View file

@ -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

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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):

View file

@ -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')