Add new "master" API view class; refactor products and batches to use it

This commit is contained in:
Lance Edgar 2020-03-01 16:45:24 -06:00
parent df00dd600a
commit 113c0af49d
8 changed files with 183 additions and 90 deletions

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2018 Lance Edgar # Copyright © 2010-2020 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import
from .core import APIView, api from .core import APIView, api
from .master import APIMasterView, SortColumn from .master import APIMasterView, SortColumn
from .master2 import APIMasterView2
def includeme(config): def includeme(config):

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2019 Lance Edgar # Copyright © 2010-2020 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -33,7 +33,7 @@ from rattail.util import load_object
from cornice import resource from cornice import resource
from tailbone.api import APIMasterView from tailbone.api import APIMasterView2 as APIMasterView
class APIBatchMixin(object): class APIBatchMixin(object):
@ -197,6 +197,7 @@ class APIBatchView(APIBatchMixin, APIMasterView):
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._defaults(config)
cls._batch_defaults(config) cls._batch_defaults(config)
@classmethod @classmethod
@ -206,14 +207,6 @@ class APIBatchView(APIBatchMixin, APIMasterView):
collection_url_prefix = cls.get_collection_url_prefix() collection_url_prefix = cls.get_collection_url_prefix()
object_url_prefix = cls.get_object_url_prefix() object_url_prefix = cls.get_object_url_prefix()
# primary / typical API
resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix))
resource.add_view(cls.collection_post, permission='{}.create'.format(permission_prefix))
resource.add_view(cls.get, permission='{}.view'.format(permission_prefix))
batch_resource = resource.add_resource(cls, collection_path=collection_url_prefix,
path='{}/{{uuid}}'.format(object_url_prefix))
config.add_cornice_resource(batch_resource)
if cls.supports_toggle_complete: if cls.supports_toggle_complete:
# mark complete # mark complete
@ -299,6 +292,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._defaults(config)
cls._batch_row_defaults(config) cls._batch_row_defaults(config)
@classmethod @classmethod
@ -308,14 +302,6 @@ class APIBatchRowView(APIBatchMixin, APIMasterView):
collection_url_prefix = cls.get_collection_url_prefix() collection_url_prefix = cls.get_collection_url_prefix()
object_url_prefix = cls.get_object_url_prefix() object_url_prefix = cls.get_object_url_prefix()
resource.add_view(cls.collection_get, permission='{}.view'.format(permission_prefix))
resource.add_view(cls.get, permission='{}.view'.format(permission_prefix))
if cls.editable:
resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix))
rows_resource = resource.add_resource(cls, collection_path=collection_url_prefix,
path='{}/{{uuid}}'.format(object_url_prefix))
config.add_cornice_resource(rows_resource)
if cls.supports_quick_entry: if cls.supports_quick_entry:
# quick entry # quick entry

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2019 Lance Edgar # Copyright © 2010-2020 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -43,15 +43,6 @@ class LabelBatchViews(APIBatchView):
object_url_prefix = '/label-batch' object_url_prefix = '/label-batch'
supports_toggle_complete = True supports_toggle_complete = True
def collection_get(self):
return self._collection_get()
def collection_post(self):
return self._collection_post()
def get(self):
return self._get()
class LabelBatchRowViews(APIBatchRowView): class LabelBatchRowViews(APIBatchRowView):
@ -74,12 +65,6 @@ class LabelBatchRowViews(APIBatchRowView):
data['full_description'] = row.product.full_description if row.product else row.description data['full_description'] = row.product.full_description if row.product else row.description
return data return data
def collection_get(self):
return self._collection_get()
def get(self):
return self._get()
def includeme(config): def includeme(config):
LabelBatchViews.defaults(config) LabelBatchViews.defaults(config)

View file

@ -84,15 +84,6 @@ class OrderingBatchViews(APIBatchView):
batch = super(OrderingBatchViews, self).create_object(data) batch = super(OrderingBatchViews, self).create_object(data)
return batch return batch
def collection_get(self):
return self._collection_get()
def collection_post(self):
return self._collection_post()
def get(self):
return self._get()
class OrderingBatchRowViews(APIBatchRowView): class OrderingBatchRowViews(APIBatchRowView):
@ -103,6 +94,7 @@ class OrderingBatchRowViews(APIBatchRowView):
collection_url_prefix = '/ordering-batch-rows' collection_url_prefix = '/ordering-batch-rows'
object_url_prefix = '/ordering-batch-row' object_url_prefix = '/ordering-batch-row'
supports_quick_entry = True supports_quick_entry = True
editable = True
def normalize(self, row): def normalize(self, row):
batch = row.batch batch = row.batch
@ -138,15 +130,6 @@ class OrderingBatchRowViews(APIBatchRowView):
return data return data
def collection_get(self):
return self._collection_get()
def get(self):
return self._get()
def post(self):
return self._post()
def update_object(self, row, data): def update_object(self, row, data):
""" """
Overrides the default logic as follows: Overrides the default logic as follows:

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2019 Lance Edgar # Copyright © 2010-2020 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -86,15 +86,6 @@ class ReceivingBatchViews(APIBatchView):
batch = super(ReceivingBatchViews, self).create_object(data) batch = super(ReceivingBatchViews, self).create_object(data)
return batch return batch
def collection_get(self):
return self._collection_get()
def collection_post(self):
return self._collection_post()
def get(self):
return self._get()
def mark_receiving_complete(self): def mark_receiving_complete(self):
""" """
Mark the given batch as "receiving complete". Mark the given batch as "receiving complete".
@ -148,6 +139,7 @@ class ReceivingBatchViews(APIBatchView):
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._defaults(config)
cls._batch_defaults(config) cls._batch_defaults(config)
cls._receiving_batch_defaults(config) cls._receiving_batch_defaults(config)
@ -340,12 +332,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
return data return data
def collection_get(self):
return self._collection_get()
def get(self):
return self._get()
def receive(self): def receive(self):
""" """
View which handles "receiving" against a particular batch row. View which handles "receiving" against a particular batch row.
@ -375,6 +361,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._defaults(config)
cls._batch_row_defaults(config) cls._batch_row_defaults(config)
cls._receiving_batch_row_defaults(config) cls._receiving_batch_row_defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2019 Lance Edgar # Copyright © 2010-2020 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -81,7 +81,7 @@ class APIMasterView(APIView):
Returns a prefix which (by default) applies to all permissions Returns a prefix which (by default) applies to all permissions
leveraged by this view class. leveraged by this view class.
""" """
prefix = getattr(cls, 'permission_prefix') prefix = getattr(cls, 'permission_prefix', None)
if prefix: if prefix:
return prefix return prefix
return cls.get_route_prefix() return cls.get_route_prefix()
@ -371,6 +371,10 @@ class APIMasterView(APIView):
############################## ##############################
def autocomplete(self): def autocomplete(self):
"""
View which accepts a single ``term`` param, and returns a list of
autocomplete results to match.
"""
term = self.request.params.get('term', '').strip() term = self.request.params.get('term', '').strip()
term = self.prepare_autocomplete_term(term) term = self.prepare_autocomplete_term(term)
if not term: if not term:

141
tailbone/api/master2.py Normal file
View file

@ -0,0 +1,141 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2020 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Tailbone Web API - Master View (v2)
"""
from __future__ import unicode_literals, absolute_import
from cornice import resource, Service
from tailbone.api import APIMasterView
class APIMasterView2(APIMasterView):
"""
Base class for data model REST API views.
"""
listable = True
creatable = True
viewable = True
editable = True
deletable = True
supports_autocomplete = False
@classmethod
def establish_method(cls, method_name):
"""
Establish the given HTTP method for this Cornice Resource.
Cornice will auto-register any class methods for a resource, if they
are named according to what it expects (i.e. 'get', 'collection_get'
etc.). Tailbone API tries to make things automagical for the sake of
e.g. Poser logic, but in this case if we predefine all of these methods
and then some subclass view wants to *not* allow one, it's not clear
how to "undefine" it per se. Or at least, the more straightforward
thing (I think) is to not define such a method in the first place, if
it was not wanted.
Enter ``establish_method()``, which is what finally "defines" each
resource method according to what the subclass has declared via its
various attributes (:attr:`creatable`, :attr:`deletable` etc.).
Note that you will not likely have any need to use this
``establish_method()`` yourself! But we describe its purpose here, for
clarity.
"""
def method(self):
internal_method = getattr(self, '_{}'.format(method_name))
return internal_method()
setattr(cls, method_name, method)
def _delete(self):
"""
View to handle DELETE action for an existing record/object.
"""
obj = self.get_object()
self.delete_object(obj)
def delete_object(self, obj):
"""
Delete the object, or mark it as deleted, or whatever you need to do.
"""
# flush immediately to force any pending integrity errors etc.
self.Session.delete(obj)
self.Session.flush()
@classmethod
def defaults(cls, config):
cls._defaults(config)
@classmethod
def _defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
collection_url_prefix = cls.get_collection_url_prefix()
object_url_prefix = cls.get_object_url_prefix()
# first, the primary resource API
# list/search
if cls.listable:
cls.establish_method('collection_get')
resource.add_view(cls.collection_get, permission='{}.list'.format(permission_prefix))
# create
if cls.creatable:
cls.establish_method('collection_post')
resource.add_view(cls.collection_post, permission='{}.create'.format(permission_prefix))
# view
if cls.viewable:
cls.establish_method('get')
resource.add_view(cls.get, permission='{}.view'.format(permission_prefix))
# edit
if cls.editable:
cls.establish_method('post')
resource.add_view(cls.post, permission='{}.edit'.format(permission_prefix))
# delete
if cls.deletable:
cls.establish_method('delete')
resource.add_view(cls.delete, permission='{}.delete'.format(permission_prefix))
# register primary resource API via cornice
object_resource = resource.add_resource(
cls,
collection_path=collection_url_prefix,
# TODO: probably should allow for other (composite?) key fields
path='{}/{{uuid}}'.format(object_url_prefix))
config.add_cornice_resource(object_resource)
# now for some more "custom" things, which are still somewhat generic
# autocomplete
if cls.supports_autocomplete:
autocomplete = Service(name='{}.autocomplete'.format(route_prefix),
path='{}/autocomplete'.format(collection_url_prefix))
autocomplete.add_view('GET', 'autocomplete', klass=cls)
config.add_cornice_service(autocomplete)

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2019 Lance Edgar # Copyright © 2010-2020 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -27,18 +27,22 @@ Tailbone Web API - Product Views
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
import six import six
import sqlalchemy as sa
from sqlalchemy import orm
from rattail.db import model from rattail.db import model
from cornice.resource import resource, view from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
@resource(collection_path='/products', path='/product/{uuid}')
class ProductView(APIMasterView): class ProductView(APIMasterView):
"""
API views for Product data
"""
model_class = model.Product model_class = model.Product
collection_url_prefix = '/products'
object_url_prefix = '/product'
supports_autocomplete = True
def normalize(self, product): def normalize(self, product):
cost = product.cost cost = product.cost
@ -55,22 +59,24 @@ class ProductView(APIMasterView):
'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None, 'default_unit_cost_display': "${:0.2f}".format(cost.unit_cost) if cost and cost.unit_cost is not None else None,
} }
@view(permission='products.list') def make_autocomplete_query(self, term):
def collection_get(self): query = self.Session.query(model.Product)\
return self._collection_get() .outerjoin(model.Brand)\
.filter(sa.or_(
model.Brand.name.ilike('%{}%'.format(term)),
model.Product.description.ilike('%{}%'.format(term))))
@view(permission='products.create') if not self.request.has_perm('products.view_deleted'):
def collection_post(self): query = query.filter(model.Product.deleted == False)
return self._collection_post()
@view(permission='products.view') query = query.order_by(model.Brand.name,
def get(self): model.Product.description)\
return self._get() .options(orm.joinedload(model.Product.brand))
return query
@view(permission='products.edit') def autocomplete_display(self, product):
def post(self): return product.full_description
return self._post()
def includeme(config): def includeme(config):
config.scan(__name__) ProductView.defaults(config)