Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions bitcoinstore/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from flask import Blueprint
from http import HTTPStatus

from bitcoinstore.extensions import db
from bitcoinstore.initializers import redis

from .products import products

api = Blueprint("api/v1", __name__)
api.register_blueprint(products, url_prefix="/products")


@api.get("/up")
def up():
redis.ping()
db.engine.execute("SELECT 1")
return ("", HTTPStatus.OK)
88 changes: 88 additions & 0 deletions bitcoinstore/api/products/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from flask import Blueprint, current_app, jsonify, request, url_for
from http import HTTPStatus
from sqlalchemy.exc import SQLAlchemyError

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="/<int:product_id>/items")


@products.post("/")
def create():
"""Create a Product

Accepts the following parameters:
* sku (string) - stock keeping unit, must be unique
* name (string) - short name
* description (string) - long description

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"),
)

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 (
jsonify(id=product.id),
HTTPStatus.CREATED,
{"Location": url_for("api/v1.products.update", id=product.id)},
)


@products.patch("/<int:id>")
def update(id):
"""Update a product, by 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)
* 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")

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)
131 changes: 131 additions & 0 deletions bitcoinstore/api/products/items/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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

from .reservations import reservations

items = Blueprint("items", __name__)
items.register_blueprint(
reservations, url_prefix="/<int:product_item_id>/reservations"
)


@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("/<int:id>")
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.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")
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)
149 changes: 149 additions & 0 deletions bitcoinstore/api/products/items/reservations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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
)
.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.
reservation = Reservation(
product_item_id=g.product_item_id,
cart_id=args.get("cart_id"),
amount=args.get("amount", type=int, default=1),
)

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 (
jsonify({"amount_in_stock": product_item.amount_in_stock}),
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:
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.post("/<int:id>/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("/<int:id>")
def destroy(id):
"""Cancel a reservation

Removes the reservation and frees the item to be reserved by others.

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:
current_app.logger.error(e)
db.session.rollback()
return (repr(e), HTTPStatus.UNPROCESSABLE_ENTITY)

return ("", HTTPStatus.OK)
Loading