From ce23d93f83809569e726d4403eb623b2a06653d5 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Mon, 7 Jun 2021 13:22:15 -0400 Subject: [PATCH 01/13] Introduce api/v1 blueprint for api endpoints And move page.up endpoint therein. --- bitcoinstore/api.py | 13 +++++++++++++ bitcoinstore/app.py | 2 ++ bitcoinstore/page/views.py | 10 ---------- test/bitcoinstore/api/test_api.py | 11 +++++++++++ test/bitcoinstore/page/test_views.py | 6 ------ 5 files changed, 26 insertions(+), 16 deletions(-) create mode 100644 bitcoinstore/api.py create mode 100644 test/bitcoinstore/api/test_api.py diff --git a/bitcoinstore/api.py b/bitcoinstore/api.py new file mode 100644 index 0000000..cf4d1a0 --- /dev/null +++ b/bitcoinstore/api.py @@ -0,0 +1,13 @@ +from flask import Blueprint + +from bitcoinstore.extensions import db +from bitcoinstore.initializers import redis + +api = Blueprint("api/v1", __name__) + + +@api.get("/up") +def up(): + redis.ping() + db.engine.execute("SELECT 1") + return "" diff --git a/bitcoinstore/app.py b/bitcoinstore/app.py index a2847ca..1f7946a 100644 --- a/bitcoinstore/app.py +++ b/bitcoinstore/app.py @@ -4,6 +4,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix from bitcoinstore.page.views import page +from bitcoinstore.api import api from bitcoinstore.extensions import db from bitcoinstore.extensions import debug_toolbar from bitcoinstore.extensions import flask_static_digest @@ -52,6 +53,7 @@ def create_app(settings_override=None): middleware(app) app.register_blueprint(page) + app.register_blueprint(api, url_prefix="/api/v1") extensions(app) diff --git a/bitcoinstore/page/views.py b/bitcoinstore/page/views.py index cba5aef..41d2df7 100644 --- a/bitcoinstore/page/views.py +++ b/bitcoinstore/page/views.py @@ -4,9 +4,6 @@ from flask import __version__ from flask import render_template -from bitcoinstore.extensions import db -from bitcoinstore.initializers import redis - page = Blueprint("page", __name__, template_folder="templates") @@ -19,10 +16,3 @@ def home(): python_ver=os.environ["PYTHON_VERSION"], flask_env=os.environ["FLASK_ENV"], ) - - -@page.get("/up") -def up(): - redis.ping() - db.engine.execute("SELECT 1") - return "" diff --git a/test/bitcoinstore/api/test_api.py b/test/bitcoinstore/api/test_api.py new file mode 100644 index 0000000..df13f63 --- /dev/null +++ b/test/bitcoinstore/api/test_api.py @@ -0,0 +1,11 @@ +from flask import url_for + +from lib.test import ViewTestMixin + + +class TestApi(ViewTestMixin): + def test_up_page(self): + """ Up page should respond with a success 200. """ + response = self.client.get(url_for("api/v1.up")) + + assert response.status_code == 200 diff --git a/test/bitcoinstore/page/test_views.py b/test/bitcoinstore/page/test_views.py index 8d64017..84cd9e8 100644 --- a/test/bitcoinstore/page/test_views.py +++ b/test/bitcoinstore/page/test_views.py @@ -9,9 +9,3 @@ def test_home_page(self): response = self.client.get(url_for("page.home")) assert response.status_code == 200 - - def test_up_page(self): - """ Up page should respond with a success 200. """ - response = self.client.get(url_for("page.up")) - - assert response.status_code == 200 From 14818eb6afa6b5cd6e79db98780d67035bccdb35 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Mon, 7 Jun 2021 14:18:10 -0400 Subject: [PATCH 02/13] Explicitly set the return status of api/v1/up --- bitcoinstore/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bitcoinstore/api.py b/bitcoinstore/api.py index cf4d1a0..99d81a3 100644 --- a/bitcoinstore/api.py +++ b/bitcoinstore/api.py @@ -1,4 +1,5 @@ from flask import Blueprint +from http import HTTPStatus from bitcoinstore.extensions import db from bitcoinstore.initializers import redis @@ -10,4 +11,4 @@ def up(): redis.ping() db.engine.execute("SELECT 1") - return "" + return ("", HTTPStatus.OK) From 86397c10d34e133189d4c624f651b1b743484687 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Tue, 8 Jun 2021 12:25:55 -0400 Subject: [PATCH 03/13] Move api Blueprint into a folder --- bitcoinstore/{api.py => api/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename bitcoinstore/{api.py => api/__init__.py} (100%) diff --git a/bitcoinstore/api.py b/bitcoinstore/api/__init__.py similarity index 100% rename from bitcoinstore/api.py rename to bitcoinstore/api/__init__.py From 7070de9a962ebc5d90020532a906ed695da562bc Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Wed, 9 Jun 2021 22:34:08 -0400 Subject: [PATCH 04/13] Add initial Product model and api/products#create endpoint --- bitcoinstore/api/__init__.py | 5 +++ bitcoinstore/api/products.py | 50 ++++++++++++++++++++++++++ bitcoinstore/extensions.py | 11 ++++++ test/bitcoinstore/api/test_products.py | 28 +++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 bitcoinstore/api/products.py create mode 100644 test/bitcoinstore/api/test_products.py diff --git a/bitcoinstore/api/__init__.py b/bitcoinstore/api/__init__.py index 99d81a3..9f082c0 100644 --- a/bitcoinstore/api/__init__.py +++ b/bitcoinstore/api/__init__.py @@ -4,6 +4,8 @@ from bitcoinstore.extensions import db from bitcoinstore.initializers import redis +from .products import products + api = Blueprint("api/v1", __name__) @@ -12,3 +14,6 @@ def up(): redis.ping() db.engine.execute("SELECT 1") return ("", HTTPStatus.OK) + + +api.register_blueprint(products, url_prefix="/products") diff --git a/bitcoinstore/api/products.py b/bitcoinstore/api/products.py new file mode 100644 index 0000000..c75d6e7 --- /dev/null +++ b/bitcoinstore/api/products.py @@ -0,0 +1,50 @@ +from decimal import Decimal +from flask import Blueprint, request, current_app +from http import HTTPStatus +from sqlalchemy.exc import SQLAlchemyError + +from bitcoinstore.extensions import db, Product + +products = Blueprint("products", __name__) + + +@products.post("/") +def create(): + """Create a Product + + Accepts the following parameters: + * sku (string) - stock keeping unit + * name (string) - short name + * description (string) - long description + * color (string) + * unit_price_subunits (integer) - price denominated in subunits of the + relevant currency, e.g. in cents for USD or in sats for BTC + * unit_price_currency (string) - the native currency for the price, denoted + in three-letter ISO 4217 currency codes for fiat, or 'BTC' for Bitcoin. + Defaults to 'USD'. + * shipping_weight_kg (decimal) - the shipping weight of the product in KG, + or decimal amounts thereof. + + On success, returns HTTP Created response, with the product's URL in the + Location response header. + """ + args = request.args + product = Product( + sku=args.get("sku"), + name=args.get("name"), + description=args.get("description"), + color=args.get("color"), + unit_price_subunits=args.get("unit_price_subunits", type=int), + unit_price_currency=args.get("unit_price_currency", default="USD"), + shipping_weight_kg=args.get("shipping_weight_kg", type=Decimal), + ) + + try: + db.session.add(product) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.CREATED) diff --git a/bitcoinstore/extensions.py b/bitcoinstore/extensions.py index 27040bf..33c8a2a 100644 --- a/bitcoinstore/extensions.py +++ b/bitcoinstore/extensions.py @@ -6,3 +6,14 @@ debug_toolbar = DebugToolbarExtension() db = SQLAlchemy() flask_static_digest = FlaskStaticDigest() + + +class Product(db.Model): + id = db.Column(db.BigInteger, primary_key=True) + sku = db.Column(db.String, unique=True, nullable=False) + name = db.Column(db.String, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String) + unit_price_subunits = db.Column(db.Integer, nullable=False) + unit_price_currency = db.Column(db.String(3), nullable=False) + shipping_weight_kg = db.Column(db.Numeric) diff --git a/test/bitcoinstore/api/test_products.py b/test/bitcoinstore/api/test_products.py new file mode 100644 index 0000000..2cf908d --- /dev/null +++ b/test/bitcoinstore/api/test_products.py @@ -0,0 +1,28 @@ +from flask import url_for + +from lib.test import ViewTestMixin + + +class TestProducts(ViewTestMixin): + def test_create_without_requred_params(self): + """ Up page should respond with a success 200. """ + response = self.client.post(url_for("api/v1.products.create")) + + assert response.status_code == 422 + + def test_create_valid(self): + """ Valid create should respond with a created 201. """ + response = self.client.post( + url_for( + "api/v1.products.create", + sku="12341234", + name="Wooden Pencil, Yellow, #2, Pre-Sharpened, 30-pack", + description="", + color="Yellow", + unit_price_subunits=300, + shipping_weight_kg=0.1, + amount_in_stock=973, + ) + ) + + assert response.status_code == 201 From 6f6211073a2001b37bc89a0b556e81815a2e003f Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Thu, 10 Jun 2021 11:07:28 -0400 Subject: [PATCH 05/13] Move models from extensions into a models folder --- bitcoinstore/api/products.py | 3 ++- bitcoinstore/extensions.py | 11 ----------- bitcoinstore/models/__init__.py | 1 + bitcoinstore/models/product.py | 11 +++++++++++ 4 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 bitcoinstore/models/__init__.py create mode 100644 bitcoinstore/models/product.py diff --git a/bitcoinstore/api/products.py b/bitcoinstore/api/products.py index c75d6e7..451e226 100644 --- a/bitcoinstore/api/products.py +++ b/bitcoinstore/api/products.py @@ -3,7 +3,8 @@ from http import HTTPStatus from sqlalchemy.exc import SQLAlchemyError -from bitcoinstore.extensions import db, Product +from bitcoinstore.extensions import db +from bitcoinstore.models import Product products = Blueprint("products", __name__) diff --git a/bitcoinstore/extensions.py b/bitcoinstore/extensions.py index 33c8a2a..27040bf 100644 --- a/bitcoinstore/extensions.py +++ b/bitcoinstore/extensions.py @@ -6,14 +6,3 @@ debug_toolbar = DebugToolbarExtension() db = SQLAlchemy() flask_static_digest = FlaskStaticDigest() - - -class Product(db.Model): - id = db.Column(db.BigInteger, primary_key=True) - sku = db.Column(db.String, unique=True, nullable=False) - name = db.Column(db.String, nullable=False) - description = db.Column(db.Text) - color = db.Column(db.String) - unit_price_subunits = db.Column(db.Integer, nullable=False) - unit_price_currency = db.Column(db.String(3), nullable=False) - shipping_weight_kg = db.Column(db.Numeric) diff --git a/bitcoinstore/models/__init__.py b/bitcoinstore/models/__init__.py new file mode 100644 index 0000000..17d7be2 --- /dev/null +++ b/bitcoinstore/models/__init__.py @@ -0,0 +1 @@ +from .product import Product diff --git a/bitcoinstore/models/product.py b/bitcoinstore/models/product.py new file mode 100644 index 0000000..0831f6c --- /dev/null +++ b/bitcoinstore/models/product.py @@ -0,0 +1,11 @@ +from bitcoinstore.extensions import db + +class Product(db.Model): + id = db.Column(db.BigInteger, primary_key=True) + sku = db.Column(db.String, unique=True, nullable=False) + name = db.Column(db.String, nullable=False) + description = db.Column(db.Text) + color = db.Column(db.String) + unit_price_subunits = db.Column(db.Integer, nullable=False) + unit_price_currency = db.Column(db.String(3), nullable=False) + shipping_weight_kg = db.Column(db.Numeric) From 76f914c99a7a719b0cc062a0bc82a949ad3d42d6 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Fri, 11 Jun 2021 12:17:15 -0400 Subject: [PATCH 06/13] Add the ability to update products Introduce a product factory for easier testing of existing products. --- bitcoinstore/api/products.py | 61 ++++++++++++++++++++++++-- bitcoinstore/models/__init__.py | 1 - bitcoinstore/models/product.py | 1 + requirements-lock.txt | 5 ++- requirements.txt | 2 + test/bitcoinstore/api/test_products.py | 35 +++++++++++++++ test/factories/__init__.py | 30 +++++++++++++ 7 files changed, 129 insertions(+), 6 deletions(-) delete mode 100644 bitcoinstore/models/__init__.py create mode 100644 test/factories/__init__.py diff --git a/bitcoinstore/api/products.py b/bitcoinstore/api/products.py index 451e226..bc01155 100644 --- a/bitcoinstore/api/products.py +++ b/bitcoinstore/api/products.py @@ -1,10 +1,10 @@ from decimal import Decimal -from flask import Blueprint, request, current_app +from flask import Blueprint, current_app, request from http import HTTPStatus from sqlalchemy.exc import SQLAlchemyError from bitcoinstore.extensions import db -from bitcoinstore.models import Product +from bitcoinstore.models.product import Product products = Blueprint("products", __name__) @@ -26,10 +26,10 @@ def create(): * shipping_weight_kg (decimal) - the shipping weight of the product in KG, or decimal amounts thereof. - On success, returns HTTP Created response, with the product's URL in the - Location response header. + On success, returns HTTP Created response. """ args = request.args + product = Product( sku=args.get("sku"), name=args.get("name"), @@ -49,3 +49,56 @@ def create(): return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) return ("", HTTPStatus.CREATED) + + +@products.patch("/") +def update(id): + """Update a product, by id + + Accepts the following parameters, and updates the product field if the + value is not blank: + * name (string) - short name + * description (string) - long description + * color (string) + * unit_price_subunits (integer) - price denominated in subunits of the + relevant currency, e.g. in cents for USD or in sats for BTC + * unit_price_currency (string) - the native currency for the price, denoted + in three-letter ISO 4217 currency codes for fiat, or 'BTC' for Bitcoin. + Defaults to 'USD'. + * shipping_weight_kg (decimal) - the shipping weight of the product in KG, + or decimal amounts thereof. + + On success, returns HTTP OK response. + """ + args = request.args + + product = Product.query.get_or_404(id) + + if args.get("sku"): + product.sku = args.get("sku") + if args.get("name"): + product.name = args.get("name") + if args.get("description"): + product.description = args.get("description") + if args.get("color"): + product.color = args.get("color") + if args.get("unit_price_subunits"): + product.unit_price_subunits = args.get("unit_price_subunits", type=int) + if args.get("unit_price_currency"): + product.unit_price_currency = args.get( + "unit_price_currency", default="USD" + ) + if args.get("shipping_weight_kg"): + product.shipping_weight_kg = args.get( + "shipping_weight_kg", type=Decimal + ) + + try: + db.session.add(product) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.OK) diff --git a/bitcoinstore/models/__init__.py b/bitcoinstore/models/__init__.py deleted file mode 100644 index 17d7be2..0000000 --- a/bitcoinstore/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .product import Product diff --git a/bitcoinstore/models/product.py b/bitcoinstore/models/product.py index 0831f6c..97ebc6f 100644 --- a/bitcoinstore/models/product.py +++ b/bitcoinstore/models/product.py @@ -1,5 +1,6 @@ from bitcoinstore.extensions import db + class Product(db.Model): id = db.Column(db.BigInteger, primary_key=True) sku = db.Column(db.String, unique=True, nullable=False) diff --git a/requirements-lock.txt b/requirements-lock.txt index 1feeee0..a421588 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -9,8 +9,10 @@ celery==5.1.0 click==7.1.2 click-didyoumean==0.0.3 click-plugins==1.1.1 -click-repl==0.1.6 +click-repl==0.2.0 coverage==5.5 +factory-boy==3.2.0 +Faker==8.6.0 flake8==3.9.2 Flask==2.0.1 Flask-DB==0.3.1 @@ -47,6 +49,7 @@ regex==2021.4.4 six==1.16.0 SQLAlchemy==1.4.15 SQLAlchemy-Utils==0.37.4 +text-unidecode==1.3 toml==0.10.2 typed-ast==1.4.3 typing-extensions==3.10.0.0 diff --git a/requirements.txt b/requirements.txt index e33fd6d..42c8831 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,8 @@ pytest==6.2.4 pytest-cov==2.12.0 flake8==3.9.2 black==20.8b1 +factory-boy==3.2.0 +Faker==8.6.0 flask-debugtoolbar==0.11.0 Flask-Static-Digest==0.2.1 diff --git a/test/bitcoinstore/api/test_products.py b/test/bitcoinstore/api/test_products.py index 2cf908d..86480d9 100644 --- a/test/bitcoinstore/api/test_products.py +++ b/test/bitcoinstore/api/test_products.py @@ -2,6 +2,8 @@ from lib.test import ViewTestMixin +from test.factories import ProductFactory + class TestProducts(ViewTestMixin): def test_create_without_requred_params(self): @@ -26,3 +28,36 @@ def test_create_valid(self): ) assert response.status_code == 201 + + def test_update_without_params(self): + """ No-op updating should respond with success 200. """ + product = ProductFactory.create() + print(product.id) + response = self.client.patch( + url_for("api/v1.products.update", id=product.id) + ) + + assert response.status_code == 200 + + def test_update_valid(self): + """ Updating should respond with success 200. """ + product = ProductFactory.create() + print(product.id) + response = self.client.patch( + url_for( + "api/v1.products.update", + id=product.id, + ), + data=dict( + sku="12341234", + name="Pen, Black, 30-pack", + description="Great!", + color="Black", + unit_price_subunits=20000, + unit_price_currency="BTC", + shipping_weight_kg=0.05, + amount_in_stock=973, + ), + ) + + assert response.status_code == 200 diff --git a/test/factories/__init__.py b/test/factories/__init__.py new file mode 100644 index 0000000..cd41810 --- /dev/null +++ b/test/factories/__init__.py @@ -0,0 +1,30 @@ +from bitcoinstore.extensions import db +from bitcoinstore.models.product import Product + +import factory +from faker import Factory as FakerFactory +from faker.providers import color, currency, isbn + +from random import randint, random + +faker = FakerFactory.create() +faker.add_provider(color) +faker.add_provider(currency) +faker.add_provider(isbn) + + +class ProductFactory(factory.alchemy.SQLAlchemyModelFactory): + """Product factory.""" + + sku = factory.Faker("isbn13") + name = factory.Faker("name") + description = factory.Faker("text") + color = factory.Faker("color") + unit_price_subunits = randint(1, 100000) + unit_price_currency = factory.Faker("currency_code") + shipping_weight_kg = random() + + class Meta: + model = Product + sqlalchemy_session = db.session + sqlalchemy_session_persistence = "commit" From fcac3b7217fa077691323cfc42cc9497a4e70065 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Fri, 11 Jun 2021 12:32:37 -0400 Subject: [PATCH 07/13] Return product url via Location header on products#create And the product id as via a json response. --- bitcoinstore/api/products.py | 11 ++++++++--- test/bitcoinstore/api/test_products.py | 7 +++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/bitcoinstore/api/products.py b/bitcoinstore/api/products.py index bc01155..54604a2 100644 --- a/bitcoinstore/api/products.py +++ b/bitcoinstore/api/products.py @@ -1,5 +1,5 @@ from decimal import Decimal -from flask import Blueprint, current_app, request +from flask import Blueprint, current_app, jsonify, request, url_for from http import HTTPStatus from sqlalchemy.exc import SQLAlchemyError @@ -26,7 +26,8 @@ def create(): * shipping_weight_kg (decimal) - the shipping weight of the product in KG, or decimal amounts thereof. - On success, returns HTTP Created response. + On success, returns HTTP Created response, with the product's URL in the + Location response header. """ args = request.args @@ -48,7 +49,11 @@ def create(): db.session.rollback() return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) - return ("", HTTPStatus.CREATED) + return ( + jsonify(id=product.id), + HTTPStatus.CREATED, + {"Location": url_for("api/v1.products.update", id=product.id)}, + ) @products.patch("/") diff --git a/test/bitcoinstore/api/test_products.py b/test/bitcoinstore/api/test_products.py index 86480d9..b0036f8 100644 --- a/test/bitcoinstore/api/test_products.py +++ b/test/bitcoinstore/api/test_products.py @@ -1,3 +1,4 @@ +import json from flask import url_for from lib.test import ViewTestMixin @@ -28,11 +29,14 @@ def test_create_valid(self): ) assert response.status_code == 201 + body = json.loads(response.data) + assert response.headers["Location"] == url_for( + "api/v1.products.update", id=body["id"] + ) def test_update_without_params(self): """ No-op updating should respond with success 200. """ product = ProductFactory.create() - print(product.id) response = self.client.patch( url_for("api/v1.products.update", id=product.id) ) @@ -42,7 +46,6 @@ def test_update_without_params(self): def test_update_valid(self): """ Updating should respond with success 200. """ product = ProductFactory.create() - print(product.id) response = self.client.patch( url_for( "api/v1.products.update", From b97a40a9438bacf7ba3a70335ad3765f45a43bc9 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Fri, 11 Jun 2021 14:47:54 -0400 Subject: [PATCH 08/13] Extract static var for common test routes --- test/bitcoinstore/api/test_products.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/bitcoinstore/api/test_products.py b/test/bitcoinstore/api/test_products.py index b0036f8..a64d38f 100644 --- a/test/bitcoinstore/api/test_products.py +++ b/test/bitcoinstore/api/test_products.py @@ -7,9 +7,12 @@ class TestProducts(ViewTestMixin): + create_route = "api/v1.products.create" + update_route = "api/v1.products.update" + def test_create_without_requred_params(self): """ Up page should respond with a success 200. """ - response = self.client.post(url_for("api/v1.products.create")) + response = self.client.post(url_for(self.create_route)) assert response.status_code == 422 @@ -17,7 +20,7 @@ def test_create_valid(self): """ Valid create should respond with a created 201. """ response = self.client.post( url_for( - "api/v1.products.create", + self.create_route, sku="12341234", name="Wooden Pencil, Yellow, #2, Pre-Sharpened, 30-pack", description="", @@ -31,14 +34,14 @@ def test_create_valid(self): assert response.status_code == 201 body = json.loads(response.data) assert response.headers["Location"] == url_for( - "api/v1.products.update", id=body["id"] + self.update_route, id=body["id"] ) def test_update_without_params(self): """ No-op updating should respond with success 200. """ product = ProductFactory.create() response = self.client.patch( - url_for("api/v1.products.update", id=product.id) + url_for(self.update_route, id=product.id) ) assert response.status_code == 200 @@ -48,7 +51,7 @@ def test_update_valid(self): product = ProductFactory.create() response = self.client.patch( url_for( - "api/v1.products.update", + self.update_route, id=product.id, ), data=dict( From f014bbb05bc29aa182f9776a9b92c682d54edbf0 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Fri, 11 Jun 2021 14:48:36 -0400 Subject: [PATCH 09/13] Add ProductItems as a nested blueprint under products Allows create and update. Here I split the attributes between Product and ProductItem such that Product is a category, always having one or more ProductItem. In the case of fungible items, the ProductItem is conceptually a variant of the product. In the case of non-fungible items, each ProductItem represents a separate item, and has a amount_in_stock value of 1. --- bitcoinstore/api/__init__.py | 4 +- .../api/{products.py => products/__init__.py} | 31 +---- bitcoinstore/api/products/items.py | 124 ++++++++++++++++++ bitcoinstore/models/product.py | 4 - bitcoinstore/models/product_item.py | 21 +++ test/bitcoinstore/api/test_product_items.py | 95 ++++++++++++++ test/bitcoinstore/api/test_products.py | 15 +-- test/factories/__init__.py | 21 ++- 8 files changed, 268 insertions(+), 47 deletions(-) rename bitcoinstore/api/{products.py => products/__init__.py} (65%) create mode 100644 bitcoinstore/api/products/items.py create mode 100644 bitcoinstore/models/product_item.py create mode 100644 test/bitcoinstore/api/test_product_items.py diff --git a/bitcoinstore/api/__init__.py b/bitcoinstore/api/__init__.py index 9f082c0..96bbc56 100644 --- a/bitcoinstore/api/__init__.py +++ b/bitcoinstore/api/__init__.py @@ -7,6 +7,7 @@ from .products import products api = Blueprint("api/v1", __name__) +api.register_blueprint(products, url_prefix="/products") @api.get("/up") @@ -14,6 +15,3 @@ def up(): redis.ping() db.engine.execute("SELECT 1") return ("", HTTPStatus.OK) - - -api.register_blueprint(products, url_prefix="/products") diff --git a/bitcoinstore/api/products.py b/bitcoinstore/api/products/__init__.py similarity index 65% rename from bitcoinstore/api/products.py rename to bitcoinstore/api/products/__init__.py index 54604a2..01a4d53 100644 --- a/bitcoinstore/api/products.py +++ b/bitcoinstore/api/products/__init__.py @@ -1,4 +1,3 @@ -from decimal import Decimal from flask import Blueprint, current_app, jsonify, request, url_for from http import HTTPStatus from sqlalchemy.exc import SQLAlchemyError @@ -6,7 +5,10 @@ from bitcoinstore.extensions import db from bitcoinstore.models.product import Product +from .items import items + products = Blueprint("products", __name__) +products.register_blueprint(items, url_prefix="//items") @products.post("/") @@ -14,17 +16,9 @@ def create(): """Create a Product Accepts the following parameters: - * sku (string) - stock keeping unit + * sku (string) - stock keeping unit, must be unique * name (string) - short name * description (string) - long description - * color (string) - * unit_price_subunits (integer) - price denominated in subunits of the - relevant currency, e.g. in cents for USD or in sats for BTC - * unit_price_currency (string) - the native currency for the price, denoted - in three-letter ISO 4217 currency codes for fiat, or 'BTC' for Bitcoin. - Defaults to 'USD'. - * shipping_weight_kg (decimal) - the shipping weight of the product in KG, - or decimal amounts thereof. On success, returns HTTP Created response, with the product's URL in the Location response header. @@ -35,10 +29,6 @@ def create(): sku=args.get("sku"), name=args.get("name"), description=args.get("description"), - color=args.get("color"), - unit_price_subunits=args.get("unit_price_subunits", type=int), - unit_price_currency=args.get("unit_price_currency", default="USD"), - shipping_weight_kg=args.get("shipping_weight_kg", type=Decimal), ) try: @@ -62,6 +52,7 @@ def update(id): Accepts the following parameters, and updates the product field if the value is not blank: + * sku (string) - stock keeping unit, must be unique * name (string) - short name * description (string) - long description * color (string) @@ -85,18 +76,6 @@ def update(id): product.name = args.get("name") if args.get("description"): product.description = args.get("description") - if args.get("color"): - product.color = args.get("color") - if args.get("unit_price_subunits"): - product.unit_price_subunits = args.get("unit_price_subunits", type=int) - if args.get("unit_price_currency"): - product.unit_price_currency = args.get( - "unit_price_currency", default="USD" - ) - if args.get("shipping_weight_kg"): - product.shipping_weight_kg = args.get( - "shipping_weight_kg", type=Decimal - ) try: db.session.add(product) diff --git a/bitcoinstore/api/products/items.py b/bitcoinstore/api/products/items.py new file mode 100644 index 0000000..70978a9 --- /dev/null +++ b/bitcoinstore/api/products/items.py @@ -0,0 +1,124 @@ +from decimal import Decimal +from http import HTTPStatus +from flask import Blueprint, current_app, g, jsonify, request, url_for +from sqlalchemy.exc import SQLAlchemyError + +from bitcoinstore.extensions import db +from bitcoinstore.models.product_item import ProductItem + +items = Blueprint("items", __name__) + + +@items.url_value_preprocessor +def get_product_id(endpoint, values): + g.product_id = values.pop("product_id") + + +@items.post("/") +def create(): + """Create a Product Items + + Accepts the following parameters: + * serial_num (string) - serial number, must be unique for this product + * description (string) - long description + * color (string) + * unit_price_subunits (integer) - price denominated in subunits of the + relevant currency, e.g. in cents for USD or in sats for BTC + * unit_price_currency (string) - the native currency for the price, denoted + in three-letter ISO 4217 currency codes for fiat, or 'BTC' for Bitcoin. + Defaults to 'USD'. + * shipping_weight_kg (decimal) - the shipping weight of the product in KG, + or decimal amounts thereof. + * amount_in_stock (integer) - the number of available units in stock + + On success, returns HTTP Created response, with the product's URL in the + Location response header. + """ + args = request.args + + product_item = ProductItem( + product_id=g.product_id, + serial_num=args.get("serial_num"), + description=args.get("description"), + color=args.get("color"), + unit_price_subunits=args.get("unit_price_subunits", type=int), + unit_price_currency=args.get("unit_price_currency", default="USD"), + shipping_weight_kg=args.get("shipping_weight_kg", type=Decimal), + amount_in_stock=args.get("amount_in_stock", type=int), + ) + + try: + db.session.add(product_item) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ( + jsonify(id=product_item.id), + HTTPStatus.CREATED, + { + "Location": url_for( + "api/v1.products.items.update", + product_id=g.product_id, + id=product_item.id, + ) + }, + ) + + +@items.patch("/") +def update(id): + """Update a product item, by id + + Accepts the following parameters, and updates the product field if the + value is not blank: + * serial_num (string) - serial number, must be unique for this product + * description (string) - long description + * color (string) + * unit_price_subunits (integer) - price denominated in subunits of the + relevant currency, e.g. in cents for USD or in sats for BTC + * unit_price_currency (string) - the native currency for the price, denoted + in three-letter ISO 4217 currency codes for fiat, or 'BTC' for Bitcoin. + Defaults to 'USD'. + * shipping_weight_kg (decimal) - the shipping weight of the product in KG, + or decimal amounts thereof. + * amount_in_stock (integer) - the number of available units in stock + + On success, returns HTTP OK response. + """ + args = request.args + + product_item = ProductItem.query.get_or_404(g.product_id, id) + + if args.get("serial_num"): + product_item.serial_num = args.get("serial_num") + if args.get("description"): + product_item.description = args.get("description") + if args.get("color"): + product_item.color = args.get("color") + if args.get("unit_price_subunits"): + product_item.unit_price_subunits = args.get( + "unit_price_subunits", type=int + ) + if args.get("unit_price_currency"): + product_item.unit_price_currency = args.get( + "unit_price_currency", default="USD" + ) + if args.get("shipping_weight_kg"): + product_item.shipping_weight_kg = args.get( + "shipping_weight_kg", type=Decimal + ) + if args.get("amount_in_stock"): + product_item.amount_in_stock = args.get("amount_in_stock", type=int) + + try: + db.session.add(product_item) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.OK) diff --git a/bitcoinstore/models/product.py b/bitcoinstore/models/product.py index 97ebc6f..0d40782 100644 --- a/bitcoinstore/models/product.py +++ b/bitcoinstore/models/product.py @@ -6,7 +6,3 @@ class Product(db.Model): sku = db.Column(db.String, unique=True, nullable=False) name = db.Column(db.String, nullable=False) description = db.Column(db.Text) - color = db.Column(db.String) - unit_price_subunits = db.Column(db.Integer, nullable=False) - unit_price_currency = db.Column(db.String(3), nullable=False) - shipping_weight_kg = db.Column(db.Numeric) diff --git a/bitcoinstore/models/product_item.py b/bitcoinstore/models/product_item.py new file mode 100644 index 0000000..8634c76 --- /dev/null +++ b/bitcoinstore/models/product_item.py @@ -0,0 +1,21 @@ +from bitcoinstore.extensions import db +from .product import Product + + +class ProductItem(db.Model): + id = db.Column(db.BigInteger, primary_key=True) + product_id = db.Column( + db.BigInteger, db.ForeignKey(Product.id), nullable=False + ) + + serial_num = db.Column(db.String) + description = db.Column(db.Text) + color = db.Column(db.String) + unit_price_subunits = db.Column(db.Integer, nullable=False) + unit_price_currency = db.Column(db.String(3), nullable=False) + shipping_weight_kg = db.Column(db.Numeric) + amount_in_stock = db.Column(db.Integer, nullable=False) + + __table_args__ = ( + db.Index("index_serial_num", "product_id", "serial_num", unique=True), + ) diff --git a/test/bitcoinstore/api/test_product_items.py b/test/bitcoinstore/api/test_product_items.py new file mode 100644 index 0000000..c289c88 --- /dev/null +++ b/test/bitcoinstore/api/test_product_items.py @@ -0,0 +1,95 @@ +from decimal import Decimal +import json +import pytest +import werkzeug +from flask import url_for + +from lib.test import ViewTestMixin + +from bitcoinstore.extensions import db + +from test.factories import ProductFactory, ProductItemFactory + + +class TestProductItems(ViewTestMixin): + create_route = "api/v1.products.items.create" + update_route = "api/v1.products.items.update" + + def test_create_without_product_id(self): + """ Create without product id returns 405. """ + with pytest.raises(werkzeug.routing.BuildError): + url_for(self.create_route) + + response = self.client.post("/products/items") + assert response.status_code == 405 + + def test_create_without_required_attrs(self): + """ Create without required item attrs returns 422. """ + product = ProductFactory.create() + response = self.client.post( + url_for(self.create_route, product_id=product.id) + ) + + assert response.status_code == 422 + + def test_create_valid(self): + """ Valid create should respond with a created 201. """ + product = ProductFactory.create() + response = self.client.post( + url_for( + self.create_route, + product_id=product.id, + serial_num="12341234", + description="", + color="Yellow", + unit_price_subunits=300, + shipping_weight_kg=0.1, + amount_in_stock=973, + ) + ) + + assert response.status_code == 201 + body = json.loads(response.data) + assert response.headers["Location"] == url_for( + self.update_route, product_id=product.id, id=body["id"] + ) + + def test_update_without_params(self): + """ No-op updating should respond with success 200. """ + product = ProductFactory.create() + product_item = ProductItemFactory.create(product=product) + response = self.client.patch( + url_for( + self.update_route, product_id=product.id, id=product_item.id + ) + ) + + assert response.status_code == 200 + + def test_update_valid(self): + """ Updating should respond with success 200. """ + product_item = ProductItemFactory.create() + response = self.client.patch( + url_for( + self.update_route, + product_id=product_item.product_id, + id=product_item.id, + serial_num="12341234", + description="Great!", + color="Black", + unit_price_subunits=20000, + unit_price_currency="BTC", + shipping_weight_kg=0.05, + amount_in_stock=973, + ), + ) + + assert response.status_code == 200 + + assert product_item.serial_num == "12341234" + assert product_item.description == "Great!" + assert product_item.color == "Black" + assert product_item.unit_price_subunits == 20000 + assert product_item.unit_price_currency == "BTC" + assert product_item.shipping_weight_kg == Decimal("0.05") + assert product_item.amount_in_stock == 973 diff --git a/test/bitcoinstore/api/test_products.py b/test/bitcoinstore/api/test_products.py index a64d38f..0a66dc7 100644 --- a/test/bitcoinstore/api/test_products.py +++ b/test/bitcoinstore/api/test_products.py @@ -11,7 +11,7 @@ class TestProducts(ViewTestMixin): update_route = "api/v1.products.update" def test_create_without_requred_params(self): - """ Up page should respond with a success 200. """ + """ Create without required attrs returns 422. """ response = self.client.post(url_for(self.create_route)) assert response.status_code == 422 @@ -24,10 +24,6 @@ def test_create_valid(self): sku="12341234", name="Wooden Pencil, Yellow, #2, Pre-Sharpened, 30-pack", description="", - color="Yellow", - unit_price_subunits=300, - shipping_weight_kg=0.1, - amount_in_stock=973, ) ) @@ -40,9 +36,7 @@ def test_create_valid(self): def test_update_without_params(self): """ No-op updating should respond with success 200. """ product = ProductFactory.create() - response = self.client.patch( - url_for(self.update_route, id=product.id) - ) + response = self.client.patch(url_for(self.update_route, id=product.id)) assert response.status_code == 200 @@ -58,11 +52,6 @@ def test_update_valid(self): sku="12341234", name="Pen, Black, 30-pack", description="Great!", - color="Black", - unit_price_subunits=20000, - unit_price_currency="BTC", - shipping_weight_kg=0.05, - amount_in_stock=973, ), ) diff --git a/test/factories/__init__.py b/test/factories/__init__.py index cd41810..e0913ee 100644 --- a/test/factories/__init__.py +++ b/test/factories/__init__.py @@ -1,5 +1,6 @@ from bitcoinstore.extensions import db from bitcoinstore.models.product import Product +from bitcoinstore.models.product_item import ProductItem import factory from faker import Factory as FakerFactory @@ -19,12 +20,30 @@ class ProductFactory(factory.alchemy.SQLAlchemyModelFactory): sku = factory.Faker("isbn13") name = factory.Faker("name") description = factory.Faker("text") + + class Meta: + model = Product + sqlalchemy_session = db.session + sqlalchemy_session_persistence = "commit" + + +class ProductItemFactory(factory.alchemy.SQLAlchemyModelFactory): + """Product Item factory.""" + + # hack to generate product and id https://stackoverflow.com/a/51208287 + product = factory.SubFactory(ProductFactory) + product_id = factory.LazyAttribute(lambda o: o.product.id) + + serial_num = factory.Faker("isbn13") + description = factory.Faker("text") color = factory.Faker("color") unit_price_subunits = randint(1, 100000) unit_price_currency = factory.Faker("currency_code") shipping_weight_kg = random() + amount_in_stock = randint(1, 100) class Meta: - model = Product + model = ProductItem sqlalchemy_session = db.session sqlalchemy_session_persistence = "commit" + exclude = ["product"] From dab70157bdd73a1f75a731dab322fdbd670a613f Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Fri, 11 Jun 2021 14:59:55 -0400 Subject: [PATCH 10/13] Move products/items into its own folder to make space for reservations --- .../api/products/{items.py => items/__init__.py} | 0 test/bitcoinstore/api/test_product_items.py | 10 +++++----- 2 files changed, 5 insertions(+), 5 deletions(-) rename bitcoinstore/api/products/{items.py => items/__init__.py} (100%) diff --git a/bitcoinstore/api/products/items.py b/bitcoinstore/api/products/items/__init__.py similarity index 100% rename from bitcoinstore/api/products/items.py rename to bitcoinstore/api/products/items/__init__.py diff --git a/test/bitcoinstore/api/test_product_items.py b/test/bitcoinstore/api/test_product_items.py index c289c88..2372d5e 100644 --- a/test/bitcoinstore/api/test_product_items.py +++ b/test/bitcoinstore/api/test_product_items.py @@ -16,7 +16,7 @@ class TestProductItems(ViewTestMixin): update_route = "api/v1.products.items.update" def test_create_without_product_id(self): - """ Create without product id returns 405. """ + """ Create without product id returns 405.""" with pytest.raises(werkzeug.routing.BuildError): url_for(self.create_route) @@ -24,7 +24,7 @@ def test_create_without_product_id(self): assert response.status_code == 405 def test_create_without_required_attrs(self): - """ Create without required item attrs returns 422. """ + """ Create without required item attrs returns 422.""" product = ProductFactory.create() response = self.client.post( url_for(self.create_route, product_id=product.id) @@ -33,7 +33,7 @@ def test_create_without_required_attrs(self): assert response.status_code == 422 def test_create_valid(self): - """ Valid create should respond with a created 201. """ + """ Valid create should respond with a created 201.""" product = ProductFactory.create() response = self.client.post( url_for( @@ -55,7 +55,7 @@ def test_create_valid(self): ) def test_update_without_params(self): - """ No-op updating should respond with success 200. """ + """ No-op updating should respond with success 200.""" product = ProductFactory.create() product_item = ProductItemFactory.create(product=product) response = self.client.patch( @@ -67,7 +67,7 @@ def test_update_without_params(self): assert response.status_code == 200 def test_update_valid(self): - """ Updating should respond with success 200. """ + """ Updating should respond with success 200.""" product_item = ProductItemFactory.create() response = self.client.patch( url_for( From 49f34bd6b74ed0179669db2deb5243d5270d8b52 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Sat, 12 Jun 2021 01:58:51 -0400 Subject: [PATCH 11/13] Initial product items reservations implementation Note this is a partial implementation: only create and destroy/cancel are supported, and only the current request is considered against the total amount on stock. --- bitcoinstore/api/products/items/__init__.py | 9 +- .../api/products/items/reservations.py | 100 ++++++++++++++++++ bitcoinstore/models/product.py | 7 ++ bitcoinstore/models/product_item.py | 11 ++ bitcoinstore/models/reservation.py | 22 ++++ .../api/test_product_item_reservations.py | 86 +++++++++++++++ test/bitcoinstore/api/test_product_items.py | 11 +- test/factories/__init__.py | 18 ++++ 8 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 bitcoinstore/api/products/items/reservations.py create mode 100644 bitcoinstore/models/reservation.py create mode 100644 test/bitcoinstore/api/test_product_item_reservations.py diff --git a/bitcoinstore/api/products/items/__init__.py b/bitcoinstore/api/products/items/__init__.py index 70978a9..d167dc6 100644 --- a/bitcoinstore/api/products/items/__init__.py +++ b/bitcoinstore/api/products/items/__init__.py @@ -6,7 +6,12 @@ from bitcoinstore.extensions import db from bitcoinstore.models.product_item import ProductItem +from .reservations import reservations + items = Blueprint("items", __name__) +items.register_blueprint( + reservations, url_prefix="//reservations" +) @items.url_value_preprocessor @@ -90,7 +95,9 @@ def update(id): """ args = request.args - product_item = ProductItem.query.get_or_404(g.product_id, id) + product_item = ProductItem.query.filter_by( + product_id=g.product_id, id=id + ).first_or_404() if args.get("serial_num"): product_item.serial_num = args.get("serial_num") diff --git a/bitcoinstore/api/products/items/reservations.py b/bitcoinstore/api/products/items/reservations.py new file mode 100644 index 0000000..6dc8314 --- /dev/null +++ b/bitcoinstore/api/products/items/reservations.py @@ -0,0 +1,100 @@ +from http import HTTPStatus +from flask import Blueprint, current_app, g, jsonify, request, url_for +from sqlalchemy.exc import SQLAlchemyError + +from bitcoinstore.extensions import db +from bitcoinstore.models.product_item import ProductItem +from bitcoinstore.models.reservation import Reservation + +reservations = Blueprint("reservations", __name__) + + +@reservations.url_value_preprocessor +def get_product_item_id(endpoint, values): + # note product_id is loaded up the chain, in the ProductItem + # url_value_preprocessor + g.product_item_id = values.pop("product_item_id") + + +@reservations.post("/") +def create(): + """Create a Reservation for a ProductItem + + For example, when an item is added to a cart, it is reserved for the + current user. + + Accepts the following parameters: + * cart_id (integer) - cart for whom this item is being reserved + * amount (integer, default=1) - number of items to be reserved + + On success, returns HTTP Created (201) response, with the product's URL in + the Location response header. + On too few items being available, returns Request Entity Too Large (413) + """ + args = request.args + + product_item = ProductItem.query.filter_by( + product_id=g.product_id, id=g.product_item_id + ).first_or_404() + + # TODO: in a reasonable implementation, this endpoint would check + # for authorization to create a reservation for the given cart. + reservation = Reservation( + product_item_id=g.product_item_id, + cart_id=args.get("cart_id"), + amount=args.get("amount", type=int, default=1), + ) + + if reservation.amount > product_item.amount_in_stock: + # Note, this response is a bit of a hack, probably a mis-use of http + # response codes. + return ( + jsonify({"amount_in_stock": product_item.amount_in_stock}), + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + ) + + try: + db.session.add(reservation) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ( + jsonify(id=reservation.id), + HTTPStatus.CREATED, + { + "Location": url_for( + "api/v1.products.items.reservations.destroy", + product_id=g.product_id, + product_item_id=g.product_item_id, + id=reservation.id, + ) + }, + ) + + +@reservations.delete("/") +def destroy(id): + """Cancel a reservation + + Removes the reservation and frees the item to be reserved by others. + + On success, returns HTTP OK response. + """ + # TODO: in a reasonable implementation, this endpoint would check the + # caller for authorization to destroy this reservation. + reservation = Reservation.query.filter_by( + product_item_id=g.product_item_id, id=id + ).first_or_404() + + try: + db.session.delete(reservation) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.OK) diff --git a/bitcoinstore/models/product.py b/bitcoinstore/models/product.py index 0d40782..efb211e 100644 --- a/bitcoinstore/models/product.py +++ b/bitcoinstore/models/product.py @@ -2,7 +2,14 @@ class Product(db.Model): + """Records an product type. + Specific stocked items and variants are recorded under associated + ProductItem records. + """ + id = db.Column(db.BigInteger, primary_key=True) sku = db.Column(db.String, unique=True, nullable=False) name = db.Column(db.String, nullable=False) description = db.Column(db.Text) + + items = db.relationship("ProductItem", back_populates="product") diff --git a/bitcoinstore/models/product_item.py b/bitcoinstore/models/product_item.py index 8634c76..765bbf3 100644 --- a/bitcoinstore/models/product_item.py +++ b/bitcoinstore/models/product_item.py @@ -3,10 +3,21 @@ class ProductItem(db.Model): + """Records an orderable variant or individual non-fungible item. + + All product items belong to a product, which is a sort of container + associating related variants/items. + """ + id = db.Column(db.BigInteger, primary_key=True) product_id = db.Column( db.BigInteger, db.ForeignKey(Product.id), nullable=False ) + product = db.relationship("Product", back_populates="items") + reservations = db.relationship( + "Reservation", + back_populates="product_item" + ) serial_num = db.Column(db.String) description = db.Column(db.Text) diff --git a/bitcoinstore/models/reservation.py b/bitcoinstore/models/reservation.py new file mode 100644 index 0000000..9b1496c --- /dev/null +++ b/bitcoinstore/models/reservation.py @@ -0,0 +1,22 @@ +from bitcoinstore.extensions import db +from .product_item import ProductItem + + +class Reservation(db.Model): + """Records a reservation for a specific product item""" + + id = db.Column(db.BigInteger, primary_key=True) + product_item_id = db.Column( + db.BigInteger, db.ForeignKey(ProductItem.id), nullable=False + ) + product_item = db.relationship( + "ProductItem", + back_populates="reservations" + ) + + cart_id = db.Column(db.String, nullable=False) + amount = db.Column(db.Integer, nullable=False) + + created_at = db.Column( + db.DateTime, nullable=False, server_default=db.func.current_timestamp() + ) diff --git a/test/bitcoinstore/api/test_product_item_reservations.py b/test/bitcoinstore/api/test_product_item_reservations.py new file mode 100644 index 0000000..fdd4b12 --- /dev/null +++ b/test/bitcoinstore/api/test_product_item_reservations.py @@ -0,0 +1,86 @@ +import json +import pytest +import werkzeug +from flask import url_for + +from lib.test import ViewTestMixin + +from test.factories import ProductItemFactory, ProductItemReservationFactory + + +class TestProductItemReservations(ViewTestMixin): + create_route = "api/v1.products.items.reservations.create" + destroy_route = "api/v1.products.items.reservations.destroy" + + def test_create_without_product_id(self): + """ Create without product id returns 405. """ + with pytest.raises(werkzeug.routing.BuildError): + url_for(self.create_route) + + response = self.client.post("/products//items//reservations") + assert response.status_code == 405 + + def test_create_without_required_attrs(self): + """ Create without required item attrs returns 422.""" + product_item = ProductItemFactory.create() + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + ) + ) + + assert response.status_code == 422 + + def test_create_insufficient_available(self): + """Create without sufficient items in stock should respond with + payload too large 413. + """ + product_item = ProductItemFactory.create(amount_in_stock=3) + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=10, + ) + ) + assert response.status_code == 413 + + def test_create_valid(self): + """ Valid create should respond with a created 201.""" + product_item = ProductItemFactory.create(amount_in_stock=10) + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + + assert response.status_code == 201 + body = json.loads(response.data) + assert response.headers["Location"] == url_for( + self.destroy_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + id=body["id"], + ) + + def test_destroy_valid(self): + """ Destroying should respond with success 200.""" + reservation = ProductItemReservationFactory.create() + response = self.client.delete( + url_for( + self.destroy_route, + product_id=reservation.product_item.product_id, + product_item_id=reservation.product_item_id, + id=reservation.id, + ) + ) + + assert response.status_code == 200 diff --git a/test/bitcoinstore/api/test_product_items.py b/test/bitcoinstore/api/test_product_items.py index 2372d5e..5b6e8a4 100644 --- a/test/bitcoinstore/api/test_product_items.py +++ b/test/bitcoinstore/api/test_product_items.py @@ -6,7 +6,7 @@ from lib.test import ViewTestMixin -from bitcoinstore.extensions import db +from bitcoinstore.models.product_item import ProductItem from test.factories import ProductFactory, ProductItemFactory @@ -54,6 +54,15 @@ def test_create_valid(self): self.update_route, product_id=product.id, id=body["id"] ) + product_item = ProductItem.query.get(body["id"]) + assert product_item.serial_num == "12341234" + assert product_item.description == "" + assert product_item.color == "Yellow" + assert product_item.unit_price_subunits == 300 + assert product_item.unit_price_currency == "USD" + assert product_item.shipping_weight_kg == Decimal("0.1") + assert product_item.amount_in_stock == 973 + def test_update_without_params(self): """ No-op updating should respond with success 200.""" product = ProductFactory.create() diff --git a/test/factories/__init__.py b/test/factories/__init__.py index e0913ee..81450d1 100644 --- a/test/factories/__init__.py +++ b/test/factories/__init__.py @@ -1,6 +1,7 @@ from bitcoinstore.extensions import db from bitcoinstore.models.product import Product from bitcoinstore.models.product_item import ProductItem +from bitcoinstore.models.reservation import Reservation import factory from faker import Factory as FakerFactory @@ -47,3 +48,20 @@ class Meta: sqlalchemy_session = db.session sqlalchemy_session_persistence = "commit" exclude = ["product"] + + +class ProductItemReservationFactory(factory.alchemy.SQLAlchemyModelFactory): + """Product Item factory.""" + + # hack to generate product and id https://stackoverflow.com/a/51208287 + product_item = factory.SubFactory(ProductItemFactory) + product_item_id = factory.LazyAttribute(lambda o: o.product_item.id) + + cart_id = factory.Faker("isbn13") + amount = randint(1, 100) + + class Meta: + model = Reservation + sqlalchemy_session = db.session + sqlalchemy_session_persistence = "commit" + exclude = ["product_item"] From bd4d154b81fa6bcb78f9cf7cc456c0b6a1b72b1e Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Sun, 13 Jun 2021 22:44:22 -0400 Subject: [PATCH 12/13] Track current reserved amount on the ProductItem --- .../api/products/items/reservations.py | 25 ++++++++-- bitcoinstore/models/product_item.py | 2 + .../api/test_product_item_reservations.py | 46 +++++++++++++++++-- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/bitcoinstore/api/products/items/reservations.py b/bitcoinstore/api/products/items/reservations.py index 6dc8314..51b43b5 100644 --- a/bitcoinstore/api/products/items/reservations.py +++ b/bitcoinstore/api/products/items/reservations.py @@ -33,9 +33,13 @@ def create(): """ args = request.args - product_item = ProductItem.query.filter_by( - product_id=g.product_id, id=g.product_item_id - ).first_or_404() + product_item = ( + ProductItem.query.filter_by( + product_id=g.product_id, id=g.product_item_id + ) + .with_for_update(nowait=True) + .first_or_404() + ) # TODO: in a reasonable implementation, this endpoint would check # for authorization to create a reservation for the given cart. @@ -45,7 +49,8 @@ def create(): amount=args.get("amount", type=int, default=1), ) - if reservation.amount > product_item.amount_in_stock: + new_amount_reserved = product_item.amount_reserved + reservation.amount + if new_amount_reserved > product_item.amount_in_stock: # Note, this response is a bit of a hack, probably a mis-use of http # response codes. return ( @@ -53,7 +58,9 @@ def create(): HTTPStatus.REQUEST_ENTITY_TOO_LARGE, ) + product_item.amount_reserved = new_amount_reserved try: + db.session.add(product_item) db.session.add(reservation) db.session.commit() except SQLAlchemyError as e: @@ -83,13 +90,23 @@ def destroy(id): On success, returns HTTP OK response. """ + product_item = ( + ProductItem.query.filter_by( + product_id=g.product_id, id=g.product_item_id + ) + .with_for_update(nowait=True) + .first_or_404() + ) + # TODO: in a reasonable implementation, this endpoint would check the # caller for authorization to destroy this reservation. reservation = Reservation.query.filter_by( product_item_id=g.product_item_id, id=id ).first_or_404() + product_item.amount_reserved -= reservation.amount try: + db.session.add(product_item) db.session.delete(reservation) db.session.commit() except SQLAlchemyError as e: diff --git a/bitcoinstore/models/product_item.py b/bitcoinstore/models/product_item.py index 765bbf3..a81ff57 100644 --- a/bitcoinstore/models/product_item.py +++ b/bitcoinstore/models/product_item.py @@ -26,7 +26,9 @@ class ProductItem(db.Model): unit_price_currency = db.Column(db.String(3), nullable=False) shipping_weight_kg = db.Column(db.Numeric) amount_in_stock = db.Column(db.Integer, nullable=False) + amount_reserved = db.Column(db.Integer, nullable=False, default=0) __table_args__ = ( db.Index("index_serial_num", "product_id", "serial_num", unique=True), + db.CheckConstraint("amount_in_stock >= amount_reserved"), ) diff --git a/test/bitcoinstore/api/test_product_item_reservations.py b/test/bitcoinstore/api/test_product_item_reservations.py index fdd4b12..04470b7 100644 --- a/test/bitcoinstore/api/test_product_item_reservations.py +++ b/test/bitcoinstore/api/test_product_item_reservations.py @@ -30,7 +30,6 @@ def test_create_without_required_attrs(self): product_item_id=product_item.id, ) ) - assert response.status_code == 422 def test_create_insufficient_available(self): @@ -49,6 +48,30 @@ def test_create_insufficient_available(self): ) assert response.status_code == 413 + # Small requests succed, then later fail if their attempts + # push past limits + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + assert response.status_code == 201 + + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + assert response.status_code == 413 + def test_create_valid(self): """ Valid create should respond with a created 201.""" product_item = ProductItemFactory.create(amount_in_stock=10) @@ -73,14 +96,27 @@ def test_create_valid(self): def test_destroy_valid(self): """ Destroying should respond with success 200.""" - reservation = ProductItemReservationFactory.create() + product_item = ProductItemFactory.create(amount_in_stock=5, amount_reserved=5) + reservation = ProductItemReservationFactory.create(product_item=product_item, amount=5) response = self.client.delete( url_for( self.destroy_route, - product_id=reservation.product_item.product_id, - product_item_id=reservation.product_item_id, + product_id=product_item.product_id, + product_item_id=product_item.id, id=reservation.id, ) ) - assert response.status_code == 200 + + # After destroy, should be able to create again + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + + assert response.status_code == 201 From 5375fde6c2ebdbfa53188050fed4160450a79390 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Sun, 13 Jun 2021 23:42:51 -0400 Subject: [PATCH 13/13] Support fulfilling a reservation --- .../api/products/items/reservations.py | 32 +++++++++++++++ .../api/test_product_item_reservations.py | 39 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/bitcoinstore/api/products/items/reservations.py b/bitcoinstore/api/products/items/reservations.py index 51b43b5..73a3ede 100644 --- a/bitcoinstore/api/products/items/reservations.py +++ b/bitcoinstore/api/products/items/reservations.py @@ -82,6 +82,38 @@ def create(): ) +@reservations.post("//fulfill") +def fulfill(id): + """Fulfill a reservation + + Called when a reservation is shipped, removes the reserved product from the + the amount in stock. + """ + product_item = ( + ProductItem.query.filter_by( + product_id=g.product_id, id=g.product_item_id + ) + .with_for_update(nowait=True) + .first_or_404() + ) + reservation = Reservation.query.filter_by( + product_item_id=product_item.id, id=id + ).first_or_404() + + product_item.amount_reserved -= reservation.amount + product_item.amount_in_stock -= reservation.amount + + try: + db.session.add(product_item) + db.session.commit() + except SQLAlchemyError as e: + current_app.logger.error(e) + db.session.rollback() + return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY) + + return ("", HTTPStatus.OK) + + @reservations.delete("/") def destroy(id): """Cancel a reservation diff --git a/test/bitcoinstore/api/test_product_item_reservations.py b/test/bitcoinstore/api/test_product_item_reservations.py index 04470b7..06ed07e 100644 --- a/test/bitcoinstore/api/test_product_item_reservations.py +++ b/test/bitcoinstore/api/test_product_item_reservations.py @@ -10,6 +10,7 @@ class TestProductItemReservations(ViewTestMixin): create_route = "api/v1.products.items.reservations.create" + fulfill_route = "api/v1.products.items.reservations.fulfill" destroy_route = "api/v1.products.items.reservations.destroy" def test_create_without_product_id(self): @@ -94,10 +95,44 @@ def test_create_valid(self): id=body["id"], ) + def test_fulfill_valid(self): + """ Fulfill without required item attrs returns 422.""" + product_item = ProductItemFactory.create( + amount_in_stock=5, amount_reserved=5 + ) + reservation = ProductItemReservationFactory.create( + product_item=product_item, amount=5 + ) + response = self.client.post( + url_for( + self.fulfill_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + id=reservation.id, + ) + ) + assert response.status_code == 200 + + # After fulfill, should not be able to create again + response = self.client.post( + url_for( + self.create_route, + product_id=product_item.product_id, + product_item_id=product_item.id, + cart_id="Foo", + amount=2, + ) + ) + assert response.status_code == 413 + def test_destroy_valid(self): """ Destroying should respond with success 200.""" - product_item = ProductItemFactory.create(amount_in_stock=5, amount_reserved=5) - reservation = ProductItemReservationFactory.create(product_item=product_item, amount=5) + product_item = ProductItemFactory.create( + amount_in_stock=5, amount_reserved=5 + ) + reservation = ProductItemReservationFactory.create( + product_item=product_item, amount=5 + ) response = self.client.delete( url_for( self.destroy_route,