Consolidate master API view logic

also let all API views use new config defaults convention
This commit is contained in:
Lance Edgar 2022-08-14 00:52:53 -05:00
parent f2c73acd3b
commit bc51a868ce
18 changed files with 320 additions and 225 deletions

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2020 Lance Edgar
# Copyright © 2010-2022 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
# TODO: remove this
from .master2 import APIMasterView2

View file

@ -219,5 +219,12 @@ class AuthenticationView(APIView):
config.add_cornice_service(change_password)
def includeme(config):
def defaults(config, **kwargs):
base = globals()
AuthenticationView = kwargs.get('AuthenticationView', base['AuthenticationView'])
AuthenticationView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -30,12 +30,9 @@ import logging
import six
from rattail.time import localtime
from rattail.util import load_object
from cornice import Service
from cornice import resource, Service
from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
log = logging.getLogger(__name__)
@ -70,10 +67,11 @@ class APIBatchMixin(object):
table name, although technically it is whatever value returns from the
``batch_key`` attribute of the main batch model class.
"""
app = self.get_rattail_app()
key = self.get_batch_class().batch_key
spec = self.rattail_config.get('rattail.batch', '{}.handler'.format(key),
default=self.default_handler_spec)
return load_object(spec)(self.rattail_config)
return app.load_object(spec)(self.rattail_config)
class APIBatchView(APIBatchMixin, APIMasterView):
@ -89,12 +87,12 @@ class APIBatchView(APIBatchMixin, APIMasterView):
self.handler = self.get_handler()
def normalize(self, batch):
created = localtime(self.rattail_config, batch.created, from_utc=True)
app = self.get_rattail_app()
created = app.localtime(batch.created, from_utc=True)
executed = None
if batch.executed:
executed = localtime(self.rattail_config, batch.executed, from_utc=True)
executed = app.localtime(batch.executed, from_utc=True)
return {
'uuid': batch.uuid,

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -178,6 +178,15 @@ class InventoryBatchRowViews(APIBatchRowView):
return row
def includeme(config):
def defaults(config, **kwargs):
base = globals()
InventoryBatchViews = kwargs.get('InventoryBatchViews', base['InventoryBatchViews'])
InventoryBatchViews.defaults(config)
InventoryBatchRowViews = kwargs.get('InventoryBatchRowViews', base['InventoryBatchRowViews'])
InventoryBatchRowViews.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -68,6 +68,15 @@ class LabelBatchRowViews(APIBatchRowView):
return data
def includeme(config):
def defaults(config, **kwargs):
base = globals()
LabelBatchViews = kwargs.get('LabelBatchViews', base['LabelBatchViews'])
LabelBatchViews.defaults(config)
LabelBatchRowViews = kwargs.get('LabelBatchRowViews', base['LabelBatchRowViews'])
LabelBatchRowViews.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -31,7 +31,6 @@ from __future__ import unicode_literals, absolute_import
import six
from rattail.core import Object
from rattail.db import model
from rattail.util import pretty_quantity
@ -274,6 +273,15 @@ class OrderingBatchRowViews(APIBatchRowView):
return row
def includeme(config):
def defaults(config, **kwargs):
base = globals()
OrderingBatchViews = kwargs.get('OrderingBatchViews', base['OrderingBatchViews'])
OrderingBatchViews.defaults(config)
OrderingBatchRowViews = kwargs.get('OrderingBatchRowViews', base['OrderingBatchRowViews'])
OrderingBatchRowViews.defaults(config)
def includeme(config):
defaults(config)

View file

@ -32,7 +32,6 @@ import six
import humanize
from rattail.db import model
from rattail.time import make_utc
from rattail.util import pretty_quantity
from deform import widget as dfwidget
@ -392,7 +391,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
data['received_alert'] = None
if self.handler.get_units_confirmed(row):
msg = "You have already received some of this product; last update was {}.".format(
humanize.naturaltime(make_utc() - row.modified))
humanize.naturaltime(app.make_utc() - row.modified))
data['received_alert'] = msg
return data
@ -444,6 +443,15 @@ class ReceivingBatchRowViews(APIBatchRowView):
renderer='json')
def includeme(config):
def defaults(config, **kwargs):
base = globals()
ReceivingBatchViews = kwargs.get('ReceivingBatchViews', base['ReceivingBatchViews'])
ReceivingBatchViews.defaults(config)
ReceivingBatchRowViews = kwargs.get('ReceivingBatchRowViews', base['ReceivingBatchRowViews'])
ReceivingBatchRowViews.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -129,5 +129,12 @@ class CommonView(APIView):
config.add_cornice_service(feedback)
def includeme(config):
def defaults(config, **kwargs):
base = globals()
CommonView = kwargs.get('CommonView', base['CommonView'])
CommonView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2020 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -26,8 +26,6 @@ Tailbone Web API - Core Views
from __future__ import unicode_literals, absolute_import
from rattail.util import load_object
from tailbone.views import View
@ -102,6 +100,8 @@ class APIView(View):
info.pop('short_name', None)
return info
"""
app = self.get_rattail_app()
# basic / default info
is_admin = user.is_admin()
employee = user.employee
@ -119,7 +119,7 @@ class APIView(View):
extra = self.rattail_config.get('tailbone.api', 'extra_user_info',
usedb=False)
if extra:
extra = load_object(extra)
extra = app.load_object(extra)
info = extra(self.request, user, **info)
return info

View file

@ -30,7 +30,7 @@ import six
from rattail.db import model
from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
class CustomerView(APIMasterView):
@ -53,5 +53,12 @@ class CustomerView(APIMasterView):
}
def includeme(config):
def defaults(config, **kwargs):
base = globals()
CustomerView = kwargs.get('CustomerView', base['CustomerView'])
CustomerView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -27,10 +27,11 @@ Tailbone Web API - Master View
from __future__ import unicode_literals, absolute_import
import json
import six
from rattail.config import parse_bool
from cornice import resource, Service
from tailbone.api import APIView, api
from tailbone.db import Session
@ -46,6 +47,14 @@ class APIMasterView(APIView):
"""
Base class for data model REST API views.
"""
listable = True
creatable = True
viewable = True
editable = True
deletable = True
supports_autocomplete = False
supports_download = False
supports_rawbytes = False
@property
def Session(self):
@ -120,6 +129,34 @@ class APIMasterView(APIView):
return cls.collection_key
return '{}s'.format(cls.get_object_key())
@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 make_filter_spec(self):
if not self.request.GET.has_key('filters'):
return []
@ -371,6 +408,67 @@ class APIMasterView(APIView):
# that's all we can do here, subclass must override if more needed
return obj
##############################
# delete
##############################
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()
##############################
# download
##############################
def download(self):
"""
GET view allowing for download of a single file, which is attached to a
given record.
"""
obj = self.get_object()
filename = self.request.GET.get('filename', None)
if not filename:
raise self.notfound()
path = self.download_path(obj, filename)
response = self.file_response(path)
return response
def download_path(self, obj, filename):
"""
Should return absolute path on disk, for the given object and filename.
Result will be used to return a file response to client.
"""
raise NotImplementedError
def rawbytes(self):
"""
GET view allowing for direct access to the raw bytes of a file, which
is attached to a given record. Basically the same as 'download' except
this does not come as an attachment.
"""
obj = self.get_object()
filename = self.request.GET.get('filename', None)
if not filename:
raise self.notfound()
path = self.download_path(obj, filename)
response = self.file_response(path, attachment=False)
return response
##############################
# autocomplete
##############################
@ -426,3 +524,81 @@ class APIMasterView(APIView):
autocomplete query.
"""
return term
@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')
if hasattr(cls, 'permission_to_create'):
permission = cls.permission_to_create
else:
permission = '{}.create'.format(permission_prefix)
resource.add_view(cls.collection_post, permission=permission)
# 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,
permission='{}.list'.format(permission_prefix))
config.add_cornice_service(autocomplete)
# download
if cls.supports_download:
download = Service(name='{}.download'.format(route_prefix),
# TODO: probably should allow for other (composite?) key fields
path='{}/{{uuid}}/download'.format(object_url_prefix))
download.add_view('GET', 'download', klass=cls,
permission='{}.download'.format(permission_prefix))
config.add_cornice_service(download)
# rawbytes
if cls.supports_rawbytes:
rawbytes = Service(name='{}.rawbytes'.format(route_prefix),
# TODO: probably should allow for other (composite?) key fields
path='{}/{{uuid}}/rawbytes'.format(object_url_prefix))
rawbytes.add_view('GET', 'rawbytes', klass=cls,
permission='{}.download'.format(permission_prefix))
config.add_cornice_service(rawbytes)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -26,8 +26,7 @@ Tailbone Web API - Master View (v2)
from __future__ import unicode_literals, absolute_import
from pyramid.response import FileResponse
from cornice import resource, Service
import warnings
from tailbone.api import APIMasterView
@ -36,174 +35,9 @@ class APIMasterView2(APIMasterView):
"""
Base class for data model REST API views.
"""
listable = True
creatable = True
viewable = True
editable = True
deletable = True
supports_autocomplete = False
supports_download = False
supports_rawbytes = 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()
##############################
# download
##############################
def download(self):
"""
GET view allowing for download of a single file, which is attached to a
given record.
"""
obj = self.get_object()
filename = self.request.GET.get('filename', None)
if not filename:
raise self.notfound()
path = self.download_path(obj, filename)
response = self.file_response(path)
return response
def download_path(self, obj, filename):
"""
Should return absolute path on disk, for the given object and filename.
Result will be used to return a file response to client.
"""
raise NotImplementedError
def rawbytes(self):
"""
GET view allowing for direct access to the raw bytes of a file, which
is attached to a given record. Basically the same as 'download' except
this does not come as an attachment.
"""
obj = self.get_object()
filename = self.request.GET.get('filename', None)
if not filename:
raise self.notfound()
path = self.download_path(obj, filename)
response = self.file_response(path, attachment=False)
return response
@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')
if hasattr(cls, 'permission_to_create'):
permission = cls.permission_to_create
else:
permission = '{}.create'.format(permission_prefix)
resource.add_view(cls.collection_post, permission=permission)
# 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,
permission='{}.list'.format(permission_prefix))
config.add_cornice_service(autocomplete)
# download
if cls.supports_download:
download = Service(name='{}.download'.format(route_prefix),
# TODO: probably should allow for other (composite?) key fields
path='{}/{{uuid}}/download'.format(object_url_prefix))
download.add_view('GET', 'download', klass=cls,
permission='{}.download'.format(permission_prefix))
config.add_cornice_service(download)
# rawbytes
if cls.supports_rawbytes:
rawbytes = Service(name='{}.rawbytes'.format(route_prefix),
# TODO: probably should allow for other (composite?) key fields
path='{}/{{uuid}}/rawbytes'.format(object_url_prefix))
rawbytes.add_view('GET', 'rawbytes', klass=cls,
permission='{}.download'.format(permission_prefix))
config.add_cornice_service(rawbytes)
def __init__(self, request, context=None):
warnings.warn("APIMasterView2 class is deprecated; please use "
"APIMasterView instead",
DeprecationWarning, stacklevel=2)
super(APIMasterView2, self).__init__(request, context=context)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2021 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -30,7 +30,7 @@ import six
from rattail.db import model
from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
class PersonView(APIMasterView):
@ -52,5 +52,12 @@ class PersonView(APIMasterView):
}
def includeme(config):
def defaults(config, **kwargs):
base = globals()
PersonView = kwargs.get('PersonView', base['PersonView'])
PersonView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2020 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -32,7 +32,7 @@ from sqlalchemy import orm
from rattail.db import model
from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
class ProductView(APIMasterView):
@ -78,5 +78,12 @@ class ProductView(APIMasterView):
return product.full_description
def includeme(config):
def defaults(config, **kwargs):
base = globals()
ProductView = kwargs.get('ProductView', base['ProductView'])
ProductView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2020 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -30,7 +30,7 @@ import six
from rattail.db import model
from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
class UpgradeView(APIMasterView):
@ -57,5 +57,12 @@ class UpgradeView(APIMasterView):
return data
def includeme(config):
def defaults(config, **kwargs):
base = globals()
UpgradeView = kwargs.get('UpgradeView', base['UpgradeView'])
UpgradeView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2020 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -26,11 +26,9 @@ Tailbone Web API - User Views
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
class UserView(APIMasterView):
@ -60,5 +58,12 @@ class UserView(APIMasterView):
return query
def includeme(config):
def defaults(config, **kwargs):
base = globals()
UserView = kwargs.get('UserView', base['UserView'])
UserView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2020 Lance Edgar
# Copyright © 2010-2022 Lance Edgar
#
# This file is part of Rattail.
#
@ -30,7 +30,7 @@ import six
from rattail.db import model
from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
class VendorView(APIMasterView):
@ -50,5 +50,12 @@ class VendorView(APIMasterView):
}
def includeme(config):
def defaults(config, **kwargs):
base = globals()
VendorView = kwargs.get('VendorView', base['VendorView'])
VendorView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -31,12 +31,10 @@ import datetime
import six
from rattail.db.model import WorkOrder
from rattail.time import localtime
from rattail.util import OrderedDict
from cornice import Service
from tailbone.api import APIMasterView2 as APIMasterView
from tailbone.api import APIMasterView
class WorkOrderView(APIMasterView):