From 113c0af49dc405676afa637ea64ac7784d6ca30b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 1 Mar 2020 16:45:24 -0600 Subject: [PATCH] Add new "master" API view class; refactor products and batches to use it --- tailbone/api/__init__.py | 3 +- tailbone/api/batch/core.py | 22 +---- tailbone/api/batch/labels.py | 17 +--- tailbone/api/batch/ordering.py | 19 +---- tailbone/api/batch/receiving.py | 19 +---- tailbone/api/master.py | 8 +- tailbone/api/master2.py | 141 ++++++++++++++++++++++++++++++++ tailbone/api/products.py | 44 +++++----- 8 files changed, 183 insertions(+), 90 deletions(-) create mode 100644 tailbone/api/master2.py diff --git a/tailbone/api/__init__.py b/tailbone/api/__init__.py index 787165fe..0b669b6c 100644 --- a/tailbone/api/__init__.py +++ b/tailbone/api/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2018 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -28,6 +28,7 @@ from __future__ import unicode_literals, absolute_import from .core import APIView, api from .master import APIMasterView, SortColumn +from .master2 import APIMasterView2 def includeme(config): diff --git a/tailbone/api/batch/core.py b/tailbone/api/batch/core.py index 1f725641..7f9232a9 100644 --- a/tailbone/api/batch/core.py +++ b/tailbone/api/batch/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -33,7 +33,7 @@ from rattail.util import load_object from cornice import resource -from tailbone.api import APIMasterView +from tailbone.api import APIMasterView2 as APIMasterView class APIBatchMixin(object): @@ -197,6 +197,7 @@ class APIBatchView(APIBatchMixin, APIMasterView): @classmethod def defaults(cls, config): + cls._defaults(config) cls._batch_defaults(config) @classmethod @@ -206,14 +207,6 @@ class APIBatchView(APIBatchMixin, APIMasterView): collection_url_prefix = cls.get_collection_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: # mark complete @@ -299,6 +292,7 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): @classmethod def defaults(cls, config): + cls._defaults(config) cls._batch_row_defaults(config) @classmethod @@ -308,14 +302,6 @@ class APIBatchRowView(APIBatchMixin, APIMasterView): collection_url_prefix = cls.get_collection_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: # quick entry diff --git a/tailbone/api/batch/labels.py b/tailbone/api/batch/labels.py index 02af03ba..0648a0c9 100644 --- a/tailbone/api/batch/labels.py +++ b/tailbone/api/batch/labels.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -43,15 +43,6 @@ class LabelBatchViews(APIBatchView): object_url_prefix = '/label-batch' 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): @@ -74,12 +65,6 @@ class LabelBatchRowViews(APIBatchRowView): data['full_description'] = row.product.full_description if row.product else row.description return data - def collection_get(self): - return self._collection_get() - - def get(self): - return self._get() - def includeme(config): LabelBatchViews.defaults(config) diff --git a/tailbone/api/batch/ordering.py b/tailbone/api/batch/ordering.py index de8fde0b..1b611381 100644 --- a/tailbone/api/batch/ordering.py +++ b/tailbone/api/batch/ordering.py @@ -84,15 +84,6 @@ class OrderingBatchViews(APIBatchView): batch = super(OrderingBatchViews, self).create_object(data) 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): @@ -103,6 +94,7 @@ class OrderingBatchRowViews(APIBatchRowView): collection_url_prefix = '/ordering-batch-rows' object_url_prefix = '/ordering-batch-row' supports_quick_entry = True + editable = True def normalize(self, row): batch = row.batch @@ -138,15 +130,6 @@ class OrderingBatchRowViews(APIBatchRowView): 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): """ Overrides the default logic as follows: diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index a5437eaf..a44540b8 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -86,15 +86,6 @@ class ReceivingBatchViews(APIBatchView): batch = super(ReceivingBatchViews, self).create_object(data) 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): """ Mark the given batch as "receiving complete". @@ -148,6 +139,7 @@ class ReceivingBatchViews(APIBatchView): @classmethod def defaults(cls, config): + cls._defaults(config) cls._batch_defaults(config) cls._receiving_batch_defaults(config) @@ -340,12 +332,6 @@ class ReceivingBatchRowViews(APIBatchRowView): return data - def collection_get(self): - return self._collection_get() - - def get(self): - return self._get() - def receive(self): """ View which handles "receiving" against a particular batch row. @@ -375,6 +361,7 @@ class ReceivingBatchRowViews(APIBatchRowView): @classmethod def defaults(cls, config): + cls._defaults(config) cls._batch_row_defaults(config) cls._receiving_batch_row_defaults(config) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 78bc9262..114efdc0 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -81,7 +81,7 @@ class APIMasterView(APIView): Returns a prefix which (by default) applies to all permissions leveraged by this view class. """ - prefix = getattr(cls, 'permission_prefix') + prefix = getattr(cls, 'permission_prefix', None) if prefix: return prefix return cls.get_route_prefix() @@ -371,6 +371,10 @@ class APIMasterView(APIView): ############################## 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.prepare_autocomplete_term(term) if not term: diff --git a/tailbone/api/master2.py b/tailbone/api/master2.py new file mode 100644 index 00000000..a062343f --- /dev/null +++ b/tailbone/api/master2.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/tailbone/api/products.py b/tailbone/api/products.py index 0d7415b1..d7aeabcd 100644 --- a/tailbone/api/products.py +++ b/tailbone/api/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2019 Lance Edgar +# Copyright © 2010-2020 Lance Edgar # # This file is part of Rattail. # @@ -27,18 +27,22 @@ Tailbone Web API - Product Views from __future__ import unicode_literals, absolute_import import six +import sqlalchemy as sa +from sqlalchemy import orm from rattail.db import model -from cornice.resource import resource, view - -from tailbone.api import APIMasterView +from tailbone.api import APIMasterView2 as APIMasterView -@resource(collection_path='/products', path='/product/{uuid}') class ProductView(APIMasterView): - + """ + API views for Product data + """ model_class = model.Product + collection_url_prefix = '/products' + object_url_prefix = '/product' + supports_autocomplete = True def normalize(self, product): 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, } - @view(permission='products.list') - def collection_get(self): - return self._collection_get() + def make_autocomplete_query(self, term): + query = self.Session.query(model.Product)\ + .outerjoin(model.Brand)\ + .filter(sa.or_( + model.Brand.name.ilike('%{}%'.format(term)), + model.Product.description.ilike('%{}%'.format(term)))) - @view(permission='products.create') - def collection_post(self): - return self._collection_post() + if not self.request.has_perm('products.view_deleted'): + query = query.filter(model.Product.deleted == False) - @view(permission='products.view') - def get(self): - return self._get() + query = query.order_by(model.Brand.name, + model.Product.description)\ + .options(orm.joinedload(model.Product.brand)) + return query - @view(permission='products.edit') - def post(self): - return self._post() + def autocomplete_display(self, product): + return product.full_description def includeme(config): - config.scan(__name__) + ProductView.defaults(config)