Just practicing some objected oriented design questions. Please let me know what can be improved to be more OOD and extensible. Thank you.
Functional Requirements
- Cart can hold multiple products
- A product has a name and price. Name is unique.
- Should show the total price
- Should show itemized price
- Should be able to add/remove products
Follow up
- Checkout
- Inventory
- Add/remove/apply promotions
Misc
def floor_to_nearest_two(number):
return int(number * 100) / 100
class NotEnoughCartItem(Exception):
def __init__(self):
super().__init__('Not enough items in the cart')
class NotEnoughInventoryItem(Exception):
def __init__(self):
super().__init__('Not enough items in the inventory')
class InvalidInput(Exception):
def __init__(self, msg):
super().__init__(msg)
Product
class Product:
def __init__(self, product_id, name, price):
self.product_id = product_id
self.name = name
self.price = price
def __str__(self):
return f'Product: {self.name}, Price: ${self.price}'
InventoryItem
class InventoryItem:
def __init__(self, product):
self.product = product
self.count = 0
def update_item_count(self, count):
if count == 0:
return
if count < 0 and self.count < count:
raise NotEnoughInventoryItem()
self.count += count
def __str__(self):
return f'{self.product}, Count: {self.count}'
CartItem
class CartItem:
def __init__(self, product):
self.product = product
self.count = 0
self.total_price = 0
def update_item_count(self, count):
self.count += count
price_delta = self.product.price * count
self.total_price += price_delta
return price_delta
# If the price of a product gets changed after the product is added to the cart,
# the cart should reflect it.
def recalculate_total_price(self):
self.total_price = self.product.price * self.count
def __str__(self):
return f'Product: {self.product.name}, Count: {self.count}, ' \
f'Total: ${floor_to_nearest_two(number=self.total_price)}'
Cart
class Cart:
def __init__(self):
self.items: dict[int, CartItem] = dict()
self.total_price = 0
def add_item(self, product, count=1):
if count <= 0:
raise InvalidInput('Count must be a positive integer.')
item = self.items.setdefault(product.product_id, CartItem(product))
price_delta = item.update_item_count(count=count)
self.total_price += price_delta
def remove_item(self, product, count=1):
if count <= 0:
raise InvalidInput('Count must be a positive integer.')
item = self.items.get(product.product_id, None)
if not item or item.count < count:
raise NotEnoughCartItem()
price_delta = item.update_item_count(count=-count)
self.total_price += price_delta
if item.count == 0:
del self.items[product.product_id]
def delete_item(self, product):
item = self.items.get(product.product_id, None)
if not item:
return
self.remove_item(product=product, count=item.count)
def empty_cart(self):
self.items.clear()
self.total_price = 0
def recalculate_total_prices(self):
self.total_price = 0
for item in self.items.values():
item.recalculate_total_price()
self.total_price += item.total_price
def __str__(self):
result = 'Cart\n'
for item in self.items.values():
result += f'{str(item)}\n'
result += f'Total Price: ${floor_to_nearest_two(number=self.total_price)}'
return result
Inventory
class Inventory:
def __init__(self):
self.items: dict[int, InventoryItem] = dict()
def add_item(self, product, count=1):
if count <= 0:
raise InvalidInput('Count must be a positive integer.')
item = self.items.setdefault(product.product_id, InventoryItem(product))
item.update_item_count(count=count)
def remove_item(self, product, count=1):
if count <= 0:
raise InvalidInput('Count must be a positive integer.')
item = self.items.get(product.product_id, None)
if not item or item.count < count:
raise NotEnoughInventoryItem()
item.update_item_count(count=-count)
def check_stock(self, product, count):
item = self.items.get(product.product_id, None)
if not item or item.count < count:
return False
return True
def __str__(self):
result = 'Inventory\n'
for item in self.items.values():
result += f'{item}\n'
return result
Promotions
class BasePromotion:
def __init__(self, name):
self.name = name
def apply_promotion(self, count, product_price):
raise NotImplementedError()
class DiscountPromotion(BasePromotion):
def __init__(self, name, discount_percentage):
super().__init__(name=name)
self.multiplier = 1 - discount_percentage / 100
def apply_promotion(self, count, product_price):
return count * product_price * self.multiplier
class BuyOneGetXPromotion(BasePromotion):
def __init__(self, name, x):
if x <= 0:
raise InvalidInput('x must be a positive integer')
super().__init__(name=name)
self.x = x
def apply_promotion(self, count, product_price):
divider = self.x + 1
return (count // divider + (0 if count % divider == 0 else 1)) * product_price
ReceiptItem
class ReceiptItem:
def __init__(self, product_name, price, count, original_total, final_total):
self.product_name = product_name
self.price = price
self.count = count
self.original_total = original_total
self.final_total = final_total
def __str__(self):
return f'{self.product_name}: ${self.price} * {self.count}.' \
f' Original: ${floor_to_nearest_two(number=self.original_total)}' \
f' => Final: ${floor_to_nearest_two(number=self.final_total)}'
Receipt
class Receipt:
def __init__(self):
self.items: list[ReceiptItem] = list()
self.original_total = 0
self.final_total = 0
def add_item(self, item: ReceiptItem):
self.items.append(item)
def __str__(self):
result = 'Receipt\n'
for item in self.items:
result += f'{str(item)}\n'
result += f'Original Total: ${floor_to_nearest_two(number=self.original_total)}\n'
result += f'Final Total: ${floor_to_nearest_two(number=self.final_total)}\n'
return result
CheckoutManager
class CheckoutManager:
def __init__(self, inventory: Inventory):
self.inventory = inventory
self.promotions: dict[int, BasePromotion] = dict()
def add_promo(self, product_id, promo: BasePromotion):
# Product does not need to exist in the inventory since the user may add the promo first.
# Latest promo overrides any existing ones.
self.promotions[product_id] = promo
def remove_promo(self, product_id):
if product_id not in self.promotions:
return
del self.promotions[product_id]
def checkout(self, cart: Cart):
if not cart.items:
raise NotEnoughCartItem()
for cart_item in cart.items.values():
inventory_item = self.inventory.items.get(cart_item.product.product_id, None)
if not inventory_item or inventory_item.count < cart_item.count:
raise NotEnoughInventoryItem()
receipt = Receipt()
original_total_price = 0
final_total_price = 0
for cart_item in cart.items.values():
product = cart_item.product
self.inventory.remove_item(product=product, count=cart_item.count)
total_price = cart_item.total_price
original_total_price += total_price
receipt_item = ReceiptItem(
product_name=product.name, price=product.price, count=cart_item.count,
original_total=total_price, final_total=total_price)
receipt.add_item(item=receipt_item)
promo = self.promotions.get(product.product_id, None)
if promo:
total_price = promo.apply_promotion(count=cart_item.count, product_price=product.price)
receipt_item.final_total = total_price
final_total_price += total_price
receipt.original_total = original_total_price
receipt.final_total = final_total_price
cart.empty_cart()
print(f'Checkout Successful!\n{receipt}')
Test Code
def test():
fuji_apple = Product(product_id=1, name='Fuji Apple', price=2.5)
gala_apple = Product(product_id=2, name='Gala Apple', price=3)
milk = Product(product_id=3, name='Milk', price=4.99)
coke = Product(product_id=4, name='Coke', price=2)
inventory = Inventory()
inventory.add_item(product=fuji_apple, count=10)
inventory.add_item(product=gala_apple, count=100)
inventory.add_item(product=milk, count=50)
inventory.add_item(product=coke)
inventory.add_item(product=coke)
inventory.add_item(product=coke)
print(f'{inventory}\n')
fuji_discount_promo = DiscountPromotion(name='Fuji 30% Discount!', discount_percentage=30)
gala_discount_promo = DiscountPromotion(name='Gala 50% Discount!', discount_percentage=50)
milk_buy_one_get_3_promo = BuyOneGetXPromotion(name='Buy One Milk and Get Three!', x=3)
checkout_manager = CheckoutManager(inventory=inventory)
checkout_manager.add_promo(product_id=fuji_apple.product_id, promo=fuji_discount_promo)
checkout_manager.add_promo(product_id=gala_apple.product_id, promo=gala_discount_promo)
checkout_manager.add_promo(product_id=milk.product_id, promo=milk_buy_one_get_3_promo)
cart = Cart()
cart.add_item(product=fuji_apple)
cart.add_item(product=fuji_apple, count=3)
cart.add_item(product=gala_apple, count=5)
cart.add_item(product=milk, count=10)
cart.add_item(product=coke)
cart.add_item(product=coke)
cart.add_item(product=coke)
print(f'{cart}\n')
cart.remove_item(product=coke)
print(f'{cart}\n')
cart.remove_item(product=coke, count=2)
print(f'{cart}\n')
gala_apple.price = 1
print(f'{cart}\n')
cart.recalculate_total_prices()
print(f'{cart}\n')
cart.delete_item(product=milk)
print(f'{cart}\n')
cart.add_item(product=milk, count=9)
checkout_manager.checkout(cart=cart)
print(f'{cart}\n')
print(f'{inventory}\n')