diff --git a/bitcoinstore/inventory/exceptions.py b/bitcoinstore/inventory/exceptions.py new file mode 100644 index 0000000..eef5dd1 --- /dev/null +++ b/bitcoinstore/inventory/exceptions.py @@ -0,0 +1,10 @@ +class ItemNotFoundException(Exception): + pass + + +class InsufficientInventoryException(Exception): + pass + + +class AlreadyExistingInventoryException(Exception): + pass diff --git a/bitcoinstore/inventory/fungible_item.py b/bitcoinstore/inventory/fungible_item.py new file mode 100644 index 0000000..6d43f55 --- /dev/null +++ b/bitcoinstore/inventory/fungible_item.py @@ -0,0 +1,14 @@ + +class FungibleItem: + """ + A class object to hold contents of a row of fungible_inventory database. + This is the object returned from the DAO, and to clients of the InventoryService. + """ + def __init__(self, sku = '', name = '', description = '', price_cents = 0, shipping_weight = 0, quantity = 0): + self.sku = sku + self.name = name + self.description = description + self.price_cents = price_cents + self.shipping_weight = shipping_weight + self.quantity = quantity + diff --git a/bitcoinstore/inventory/inventory_dao.py b/bitcoinstore/inventory/inventory_dao.py new file mode 100644 index 0000000..cc18a7c --- /dev/null +++ b/bitcoinstore/inventory/inventory_dao.py @@ -0,0 +1,150 @@ +from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, Boolean, text + +from bitcoinstore.inventory.fungible_item import FungibleItem +from bitcoinstore.inventory.non_fungible_item import NonFungibleItem + +engine = create_engine("sqlite+pysqlite:///:memory:", echo=True, future=True) +meta = MetaData() + +fungible_inventory = Table( + 'fungible_inventory', meta, + Column('sku', String, primary_key=True), + Column('name', String), + Column('description', String), + Column('price', Integer), + Column('shipping_weight', Integer), + Column('quantity', Integer) +) + +non_fungible_inventory = Table( + 'non_fungible_inventory', meta, + Column('sku', String, primary_key=True), + Column('serial', String, primary_key=True), + Column('name', String), + Column('description', String), + Column('unique_description', String), + Column('price', Integer), + Column('shipping_weight', Integer), + Column('is_available', Boolean) +) + +""" +* +* +* +For the purpose of testing, below will create the tables, and insert some static rows. +* +* +* +""" + +meta.create_all(engine) + +with engine.begin() as conn: + conn.execute( + fungible_inventory.insert(), [ + {'sku': 'A', 'name': 'name of A', 'description': 'Description of A', + 'price': 300, 'shipping_weight': '142', 'quantity': 5}, + {'sku': 'B', 'name': 'name of B', 'description': 'Description of B', + 'price': 400, 'shipping_weight': '152', 'quantity': 8}, + {'sku': 'C', 'name': 'name of C', 'description': 'Description of C', + 'price': 100, 'shipping_weight': '12', 'quantity': 0} + ] + ) + conn.execute( + non_fungible_inventory.insert(), [ + {'sku': 'D', 'serial': 'D1', 'name': 'name of D', 'description': 'Description of D', + 'unique_description': 'D1 of D', 'price': 300, 'shipping_weight': '142', 'is_available': True}, + {'sku': 'E', 'serial': 'E1', 'name': 'name of E', 'description': 'Description of E', + 'unique_description': 'E1 of E', 'price': 330, 'shipping_weight': '13', 'is_available': False} + ] + ) + + +class FungibleInventoryDao: + """ + DAO for the table fungible_inventory + """ + + def __init__(self): + self._conn = engine.connect() + + def list_items(self): + ex = fungible_inventory.select() + rows = self._conn.execute(ex) + items = [] + for row in rows: + sku, name, description, price, shipping_weight, quantity = row + item = FungibleItem(sku, name, description, price, shipping_weight, quantity) + items.append(item) + return items + + def get_item(self, sku): + t = text("SELECT * FROM non_fungible_inventory WHERE SKU = :sku") + rows = self._conn.execute(t, {"sku": sku}).fetchall() + assert (len(rows) < 2), "Multiple items for given SKU!" + if len(rows) == 0: + return None + sku, name, description, price, shipping_weight, quantity = rows[0] + return FungibleItem(sku, name, description, price, shipping_weight, quantity) + + def update_item_count(self, sku, count): + t = text("UPDATE fungible_inventory SET quantity = :count WHERE SKU = :sku") + self._conn.execute(t, count=count, sku=sku) + + def create_item(self, fungible_item): + t = text("INSERT INTO fungible_inventory (sku, name, description, price, shipping_weight, quantity) " + "VALUES (:sku, :name, :desc, :price, shipping_wt, :qty)") + self._conn.execute(t, sku=fungible_item.sku, name=fungible_item.name, desc=fungible_item.description, + price=fungible_item.price, shipping_wt=fungible_item.shipping_weight, + qty=fungible_item.quantity) + + def delete_item(self, sku): + t = text("DELETE FROM fungible_inventory WHERE SKU = :sku") + self._conn.execute(t, sku=sku) + + +class NonFungibleInventoryDao: + """ + DAO for the table non_fungible_inventory + """ + + def __init__(self): + self._conn = engine.connect() + + def list_items(self): + query = non_fungible_inventory.select() + rows = self._conn.execute(query) + items = [] + for row in rows: + sku, serial, name, description, unique_description, price, shipping_weight, is_available = row + item = NonFungibleItem(sku, serial, name, description, unique_description, price, shipping_weight, + is_available) + items.append(item) + return items + + def get_item(self, sku, serial): + t = text("select * from non_fungible_inventory where SKU = :sku and serial = :serial") + rows = self._conn.execute(t, sku=sku, serial=serial).fetchall() + assert (len(rows) < 2), "Multiple items for given SKU and Serial number!" + if len(rows) == 0: + return None + sku, serial, name, description, unique_description, price, shipping_weight, is_available = rows[0] + return FungibleItem(sku, serial, name, description, unique_description, price, shipping_weight, is_available) + + def update_item_availability(self, sku, serial, is_available): + t = text("update non_fungible_inventory set is_available = :availability where SKU = :sku and serial = :serial") + self._conn.execute(t, availability=is_available, sku=sku, serial=serial) + + def create_item(self, non_fungible_item): + t = text("INSERT INTO non_fungible_inventory (sku, serial, name, description, unique_description, price, " + "shipping_weight, quantity) VALUES (:sku, :serial, :name, :desc, :unique_desc, :price, shipping_wt, " + ":qty)") + self._conn.execute(t, sku=non_fungible_item.sku, serial=non_fungible_item.serial_number, + name=non_fungible_item.name, desc=non_fungible_item.description, + unique_desc=non_fungible_item.unique_description, price=non_fungible_item.price, + shipping_wt=non_fungible_item.shipping_weight, qty=non_fungible_item.quantity) + + def delete_item(self, sku, serial): + t = text("DELETE FROM non_fungible_inventory WHERE SKU = :sku and serial = :serial") + self._conn.execute(t, sku=sku, serial=serial) diff --git a/bitcoinstore/inventory/inventory_service.py b/bitcoinstore/inventory/inventory_service.py new file mode 100644 index 0000000..28e3b50 --- /dev/null +++ b/bitcoinstore/inventory/inventory_service.py @@ -0,0 +1,161 @@ +from bitcoinstore.inventory.inventory_dao import FungibleInventoryDao, NonFungibleInventoryDao +from threading import Lock +from bitcoinstore.inventory.exceptions import * + + +class InventoryService: + """ + Implementation for the Inventory Service. + This is to be called by the main browser frontend, Cart Service, Reservation Timeout Serivce, and backend inventory + management service. + + Note that this service is NOT aware of how the reservation system works. Whenever an item is added to a cart, that + essentially removes an inventory item from this service. And when item is removed from cart, manually or via a + timeout system, the item is added back to the inventory via this service. The difference between reserved items, vs + fulfilled items is maintained by the Reservation Service, outside of Inventory Service. + + Note that Inventory Service is not directly handling client requests, so it doesn't handle authorization for the + user context. It is assumed higher level services will do those checks. + + This class is thread safe. + """ + + # (TODO): The locks below are not granular currently. Ideally replace them by named resource locks, which can + # lock on a unique key like SKU or serial. This way, a thread will only lock the DB rows that it needs to touch. + _fungible_lock = Lock() + _non__fungible_lock = Lock() + + def __init__(self): + self._fungible_dao = FungibleInventoryDao() + self._non_fungible_dao = NonFungibleInventoryDao() + + def list_fungible_items(self): + """ + Returns list of all fungible items. + Doesn't require acquiring a lock. + """ + return self._fungible_dao.list_items() + + def get_fungible_item(self, sku): + """ + Returns a particular fungible item identified by the sku nmme. + Doesn't require acquiring a lock. + """ + return self._fungible_dao.get_item(sku) + + def remove_fungible_item(self, sku, count): + """ + Removes few items from a particular fungible item identified by the sku nmme. + Returns errors if item doesn't exist or short of inventory. + + Note that if removal of items reduced invetory size to 0, this DOES NOT delete the item from inventory. + + It requires acquiring a lock, since we are adjusting inventory quantity over 2 different db calls. + """ + + self._fungible_lock.acquire() + item = self.get_fungible_item(sku) + if item is None: + self._fungible_lock.release() + raise ItemNotFoundException() + if item.quantity < count: + self._fungible_lock.release() + raise InsufficientInventoryException() + new_count = item.quantity - count + self._fungible_dao.update_item_count(sku, new_count) + self._fungible_lock.release() + + def add_fungible_item(self, sku, count): + """ + Adds a few items(count) to a particular fungible item identified by the sku nmme. + Returns errors if the inventory item doesn't exist. + + It requires acquiring a lock, since we are adjusting inventory quantity over 2 different db calls. + """ + self._fungible_lock.acquire() + item = self.get_fungible_item(sku) + if item is None: + self._fungible_lock.release() + raise ItemNotFoundException() + new_count = item.quantity + count + self._fungible_dao.update_item_count(sku, new_count) + self._fungible_lock.release() + + def create_new_fungible_item(self, fungible_item): + """ + Creates a new fungible item. + Returns error if the inventory item already exist. + + It requires acquiring a lock, since we want only the winning thread in a race condition to return success. The + losing thread in the race condition will get an error, indicating its version failed to apply. + """ + self._fungible_lock.acquire() + item = self.get_fungible_item(fungible_item.sku) + if item is not None: + self._fungible_lock.release() + raise AlreadyExistingInventoryException() + self._fungible_dao.create_item(fungible_item) + self._fungible_lock.release() + + def delete_fungible_item(self, sku): + """ + Deletes a fungible item, identified by the sku. + Throws error if item didn't exist. + + Doesn't require a lock. + """ + item = self.get_fungible_item(sku) + if item is None: + raise ItemNotFoundException() + self._fungible_dao.delete_item(sku) + + """ + All methods below are for non-fungible inventory, and implemented similarly to the fungible inventory, except for + minor details on the quantity vs is_available columns. + The documentation for below methods should be almost same as for above methods for fungible inventory. + """ + + def list_non_fungible_items(self): + return self._non_fungible_dao.list_items() + + def get_non_fungible_item(self, sku, serial): + return self._non_fungible_dao.get_item(sku, serial) + + def remove_non_fungible_item(self, sku, serial): + self._non_fungible_lock.acquire() + item = self.get_non_fungible_item(sku, serial) + if item is None: + self._fungible_lock.release() + raise ItemNotFoundException() + if not item.is_available: + self._fungible_lock.release() + raise InsufficientInventoryException() + self._non_fungible_dao.update_item_availability(sku, serial, False) + self._non_fungible_lock.release() + + def add_non_fungible_item(self, sku, serial): + self._non_fungible_lock.acquire() + item = self.get_non_fungible_item(sku, serial) + if item is None: + self._fungible_lock.release() + raise ItemNotFoundException() + if item.is_available: + self._fungible_lock.release() + raise AlreadyExistingInventoryException() + self._non_fungible_dao.update_item_availability(sku, serial, True) + self._non_fungible_lock.release() + + def create_new_non_fungible_item(self, non_fungible_item): + self._non_fungible_lock.acquire() + item = self.get_non_fungible_item(non_fungible_item.sku, non_fungible_item.serial) + if item is not None: + self._fungible_lock.release() + raise AlreadyExistingInventoryException() + self._non_fungible_dao.create_item(non_fungible_item) + self._non_fungible_lock.release() + + def delete_non_fungible_item(self, sku, serial): + item = self.get_non_fungible_item(sku, serial) + if item is None: + raise ItemNotFoundException() + self._non_fungible_dao.delete_item(sku, serial) diff --git a/bitcoinstore/inventory/non_fungible_item.py b/bitcoinstore/inventory/non_fungible_item.py new file mode 100644 index 0000000..9aa9493 --- /dev/null +++ b/bitcoinstore/inventory/non_fungible_item.py @@ -0,0 +1,17 @@ + +class NonFungibleItem: + """ + A class object to hold contents of a row of non_fungible_inventory database. + This is the object returned from the DAO, and to clients of the InventoryService. + """ + + def __init__(self, sku = '', serial_number = '', name = '', description = '', unique_description = '', + price_cents = 0, shipping_weight = 0, is_available = False): + self.sku = sku + self.serial_number = serial_number + self.name = name + self.description = description + self.unique_description = unique_description + self.price_cents = price_cents + self.shipping_weight = shipping_weight + self.is_available = is_available diff --git a/test/bitcoinstore/inventory/test_functional.py b/test/bitcoinstore/inventory/test_functional.py new file mode 100644 index 0000000..b319ca9 --- /dev/null +++ b/test/bitcoinstore/inventory/test_functional.py @@ -0,0 +1,26 @@ + +import pytest + +from bitcoinstore.inventory.exceptions import ItemNotFoundException, InsufficientInventoryException, \ + AlreadyExistingInventoryException +from bitcoinstore.inventory.fungible_item import FungibleItem +from bitcoinstore.inventory.inventory_dao import FungibleInventoryDao +from bitcoinstore.inventory.inventory_service import InventoryService + +class TestFunctionalInventoryService(): + + @pytest.fixture(autouse=True) + def setup(self): + self.service = InventoryService() + + def test_list_fungible_items(self): + items = self.service.list_fungible_items() + assert len(items) == 3 + for item in items: + assert item.sku is not None + + def test_get_fungible_item(self): + sku = 'A' + item = self.service.get_fungible_item(sku) + + # (TODO) Finish the test, and other functional tests. diff --git a/test/bitcoinstore/inventory/test_inventory_dao.py b/test/bitcoinstore/inventory/test_inventory_dao.py new file mode 100644 index 0000000..1c660ad --- /dev/null +++ b/test/bitcoinstore/inventory/test_inventory_dao.py @@ -0,0 +1,17 @@ +from unittest import mock +from unittest.mock import MagicMock + +import pytest + +from bitcoinstore.inventory.exceptions import ItemNotFoundException, InsufficientInventoryException, \ + AlreadyExistingInventoryException +from bitcoinstore.inventory.fungible_item import FungibleItem +from bitcoinstore.inventory.inventory_dao import FungibleInventoryDao +from bitcoinstore.inventory.inventory_service import InventoryService + + +class TestInventoryService(): + """ + (TODO): Write unit-tests + """ + pass diff --git a/test/bitcoinstore/inventory/test_inventory_service.py b/test/bitcoinstore/inventory/test_inventory_service.py new file mode 100644 index 0000000..1a72302 --- /dev/null +++ b/test/bitcoinstore/inventory/test_inventory_service.py @@ -0,0 +1,95 @@ +from unittest import mock +from unittest.mock import MagicMock + +import pytest + +from bitcoinstore.inventory.exceptions import ItemNotFoundException, InsufficientInventoryException, \ + AlreadyExistingInventoryException +from bitcoinstore.inventory.fungible_item import FungibleItem +from bitcoinstore.inventory.inventory_dao import FungibleInventoryDao +from bitcoinstore.inventory.inventory_service import InventoryService + + +class TestInventoryService(): + + @pytest.fixture(autouse=True) + def setup(self): + self.service = InventoryService() + + @mock.patch.object(FungibleInventoryDao, 'list_items') + def test_list_fungible_items(self, mock_list_items): + self.service.list_fungible_items() + + mock_list_items.assert_called_once() + + @mock.patch.object(FungibleInventoryDao, 'get_item') + def test_get_fungible_item(self, mock_get_item): + sku = "Test Sku" + + self.service.get_fungible_item(sku) + + mock_get_item.assert_called_once_with(sku) + + @mock.patch.object(FungibleInventoryDao, 'get_item', return_value=None) + @mock.patch.object(FungibleInventoryDao, 'update_item_count') + def test_remove_fungible_item_non_existent_item(self, mock_update_item_count, mock_get_item): + sku = "Test Sku" + + try: + self.service.remove_fungible_item(sku, 5) + except ItemNotFoundException: + pass + + mock_get_item.assert_called_once_with(sku) + mock_update_item_count.assert_not_called() + + @mock.patch.object(FungibleInventoryDao, 'get_item', return_value=FungibleItem(quantity = 3)) + @mock.patch.object(FungibleInventoryDao, 'update_item_count') + def test_remove_fungible_item_insufficient_inventory(self, mock_update_item_count, mock_get_item): + sku = "Test Sku" + + try: + self.service.remove_fungible_item(sku, 5) + except InsufficientInventoryException: + pass + + mock_get_item.assert_called_once_with(sku) + mock_update_item_count.assert_not_called() + + @mock.patch.object(FungibleInventoryDao, 'get_item', return_value=FungibleItem(quantity = 3)) + @mock.patch.object(FungibleInventoryDao, 'update_item_count') + def test_remove_fungible_item_success(self, mock_update_item_count, mock_get_item): + sku = "Test Sku" + + self.service.remove_fungible_item(sku, 3) + + mock_get_item.assert_called_once_with(sku) + mock_update_item_count.assert_called_once_with(sku, 0) + + @mock.patch.object(FungibleInventoryDao, 'get_item', return_value=FungibleItem(sku='A')) + @mock.patch.object(FungibleInventoryDao, 'create_item') + def test_create_new_fungible_item_failure(self, mock_create_item, mock_get_item): + sku = "A" + + try: + self.service.create_new_fungible_item(FungibleItem(sku=sku)) + except AlreadyExistingInventoryException: + pass + + mock_get_item.assert_called_once_with(sku) + mock_create_item.assert_not_called() + + @mock.patch.object(FungibleInventoryDao, 'get_item', return_value=None) + @mock.patch.object(FungibleInventoryDao, 'create_item') + def test_create_new_fungible_item_success(self, mock_create_item, mock_get_item): + sku = 'A' + item = FungibleItem(sku=sku) + + self.service.create_new_fungible_item(item) + + mock_get_item.assert_called_once_with(sku) + mock_create_item.assert_called_once_with(item) + +""" +(TODO): Finish all tests for non fungible items too +"""