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')
1 Answer 1
Broadly speaking this does a lot of things right: self-written exceptions, (the start of) type hinting, overridden __str__
, etc.
floor_to_nearest_two
is dubious. There are better ways to round down - floor-division //
probably suiting you - but you should you be rounding at all? And if you do, can you wait until the output stage? If you can wait (which is ideal), just use .2f
in a format string or better yet call currency()
which includes rounding and a currency symbol.
You need more type hints. You need to hint every method parameter and return value; if there's no return mark it -> None
.
You can delete the __init__
from InvalidInput
and assume the constructor of the parent; just write pass
.
if count < 0 and self.count < count
is odd. I guess you're passing a negative count
if you're removing from inventory; but in a case like that, shouldn't it be
if count + self.count < 0
?
Your mutation model is problematic, and with it the update
and recalculate
methods. It's understandable for some of your classes to need to mutate, e.g. Cart
; but at this scale re-calculating price subtotals and totals is so cheap that you should just have a one-pass total calculation function, no persisted totals or subtotals, and no price updating. Currently the recalculation strategy is fragile, vulnerable to bugs (even if there aren't any currently).
Prefer paren-wrapping ()
instead of escaping \
for multi-line string expressions.
dict()
and list()
are equivalent to literals {}
and []
.
Rather than loops like this:
for item in self.items.values():
result += f'{str(item)}\n'
prefer '\n'.join()
.
This:
def apply_promotion(self, count, product_price):
divider = self.x + 1
return (count // divider + (0 if count % divider == 0 else 1)) * product_price
is a little nasty. If I understand correctly, a more legible way of expressing this "buy one get X" is
divider = self.x + 1 # e.g. buy one get 3 -> divider = 4
groups = count // divider # number of item groups
if count % divider: # if purchase count is not an even multiple
groups += 1 # pay for the last group
return groups + product_price # pay once per group
Rather than remove_promo
early-returning, invert the condition.
.get(x, None)
is just .get(x)
.
You should convert your test method to actual tests - don't print
; add assert
s.
Suggested
Covering some of the above,
from locale import currency, LC_ALL, setlocale
setlocale(LC_ALL, '')
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):
pass
class Product:
def __init__(self, product_id: int, name: str, price: float) -> None:
self.product_id = product_id
self.name = name
self.price = price
def __str__(self) -> str:
return f'Product: {self.name}, Price: {currency(self.price)}'
class InventoryItem:
def __init__(self, product: Product) -> None:
self.product = product
self.count = 0
def update_item_count(self, count: int) -> None:
if count == 0:
return
if count < 0 and self.count < count:
raise NotEnoughInventoryItem()
self.count += count
def __str__(self) -> str:
return f'{self.product}, Count: {self.count}'
class CartItem:
def __init__(self, product: Product) -> None:
self.product = product
self.count = 0
self.total_price = 0
def update_item_count(self, count: int) -> float:
self.count += count
price_delta = self.product.price * count
self.total_price += price_delta
return price_delta
def recalculate_total_price(self) -> None:
"""
If the price of a product gets changed after the product is added to the cart,
the cart should reflect it.
"""
self.total_price = self.product.price * self.count
def __str__(self) -> str:
return (
f'Product: {self.product.name}, Count: {self.count}, '
f'Total: {currency(self.total_price)}'
)
class Cart:
def __init__(self) -> None:
self.items: dict[int, CartItem] = {}
self.total_price = 0
def add_item(self, product: Product, count: int = 1) -> None:
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: Product, count: int = 1) -> None:
if count <= 0:
raise InvalidInput('Count must be a positive integer.')
item = self.items.get(product.product_id)
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: Product) -> None:
item = self.items.get(product.product_id, None)
if item:
self.remove_item(product=product, count=item.count)
def empty_cart(self):
self.items.clear()
self.total_price = 0
def recalculate_total_prices(self) -> None:
self.total_price = 0
for item in self.items.values():
item.recalculate_total_price()
self.total_price += item.total_price
def __str__(self) -> str:
result = (
'Cart\n'
+ '\n'.join(str(item) for item in self.items.values())
+ f'Total Price: {currency(self.total_price)}'
)
return result
class Inventory:
def __init__(self) -> None:
self.items: dict[int, InventoryItem] = dict()
def add_item(self, product: Product, count: int = 1) -> None:
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: Product, count: int = 1) -> None:
if count <= 0:
raise InvalidInput('Count must be a positive integer.')
item = self.items.get(product.product_id)
if not item or item.count < count:
raise NotEnoughInventoryItem()
item.update_item_count(count=-count)
def check_stock(self, product: Product, count: int) -> bool:
item = self.items.get(product.product_id, None)
if not item or item.count < count:
return False
return True
def __str__(self) -> str:
result = 'Inventory\n' + '\n'.join(str(v) for v in self.items.values())
return result
class BasePromotion:
def __init__(self, name: str) -> None:
self.name = name
def apply_promotion(self, count: int, product_price: float) -> float:
raise NotImplementedError()
class DiscountPromotion(BasePromotion):
def __init__(self, name: str, discount_percentage: float) -> None:
super().__init__(name=name)
self.multiplier = 1 - discount_percentage / 100
def apply_promotion(self, count: int, product_price: float) -> float:
return count * product_price * self.multiplier
class BuyOneGetXPromotion(BasePromotion):
def __init__(self, name: str, x: int) -> None:
if x <= 0:
raise InvalidInput('x must be a positive integer')
super().__init__(name=name)
self.x = x
def apply_promotion(self, count: int, product_price: float) -> float:
divider = self.x + 1
return (count // divider + (0 if count % divider == 0 else 1)) * product_price
class ReceiptItem:
def __init__(
self,
product_name: str,
price: float,
count: int,
original_total: float,
final_total: float,
) -> None:
self.product_name = product_name
self.price = price
self.count = count
self.original_total = original_total
self.final_total = final_total
def __str__(self) -> str:
return (
f'{self.product_name}: {currency(self.price)} * {self.count}.'
f' Original: {currency(self.original_total)}'
f' => Final: {currency(self.final_total)}'
)
class Receipt:
def __init__(self) -> None:
self.items: list[ReceiptItem] = []
self.original_total = 0
self.final_total = 0
def add_item(self, item: ReceiptItem):
self.items.append(item)
def __str__(self) -> str:
result = (
'Receipt\n'
+ '\n'.join(str(item) for item in self.items)
+ f'Original Total: {currency(self.original_total)}\n'
+ f'Final Total: {currency(self.final_total)}\n'
)
return result
class CheckoutManager:
def __init__(self, inventory: Inventory) -> None:
self.inventory = inventory
self.promotions: dict[int, BasePromotion] = dict()
def add_promo(self, product_id: int, promo: BasePromotion) -> None:
# 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: int) -> None:
if product_id not in self.promotions:
return
del self.promotions[product_id]
def checkout(self, cart: Cart) -> None:
if not cart.items:
raise NotEnoughCartItem()
for cart_item in cart.items.values():
inventory_item = self.inventory.items.get(cart_item.product.product_id)
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}')
def test() -> None:
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')
if __name__ == '__main__':
test()
Output
Inventory
Product: Fuji Apple, Price: 2ドル.50, Count: 10
Product: Gala Apple, Price: 3ドル.00, Count: 100
Product: Milk, Price: 4ドル.99, Count: 50
Product: Coke, Price: 2ドル.00, Count: 3
Cart
Product: Fuji Apple, Count: 4, Total: 10ドル.00
Product: Gala Apple, Count: 5, Total: 15ドル.00
Product: Milk, Count: 10, Total: 49ドル.90
Product: Coke, Count: 3, Total: 6ドル.00Total Price: 80ドル.90
Cart
Product: Fuji Apple, Count: 4, Total: 10ドル.00
Product: Gala Apple, Count: 5, Total: 15ドル.00
Product: Milk, Count: 10, Total: 49ドル.90
Product: Coke, Count: 2, Total: 4ドル.00Total Price: 78ドル.90
Cart
Product: Fuji Apple, Count: 4, Total: 10ドル.00
Product: Gala Apple, Count: 5, Total: 15ドル.00
Product: Milk, Count: 10, Total: 49ドル.90Total Price: 74ドル.90
Cart
Product: Fuji Apple, Count: 4, Total: 10ドル.00
Product: Gala Apple, Count: 5, Total: 15ドル.00
Product: Milk, Count: 10, Total: 49ドル.90Total Price: 74ドル.90
Cart
Product: Fuji Apple, Count: 4, Total: 10ドル.00
Product: Gala Apple, Count: 5, Total: 5ドル.00
Product: Milk, Count: 10, Total: 49ドル.90Total Price: 64ドル.90
Cart
Product: Fuji Apple, Count: 4, Total: 10ドル.00
Product: Gala Apple, Count: 5, Total: 5ドル.00Total Price: 15ドル.00
Checkout Successful!
Receipt
Fuji Apple: 2ドル.50 * 4. Original: 10ドル.00 => Final: 7ドル.00
Gala Apple: 1ドル.00 * 5. Original: 5ドル.00 => Final: 2ドル.50
Milk: 4ドル.99 * 9. Original: 44ドル.91 => Final: 14ドル.97Original Total: 59ドル.91
Final Total: 24ドル.47
Cart
Total Price: 0ドル.00
Inventory
Product: Fuji Apple, Price: 2ドル.50, Count: 6
Product: Gala Apple, Price: 1ドル.00, Count: 95
Product: Milk, Price: 4ドル.99, Count: 41
Product: Coke, Price: 2ドル.00, Count: 3
-
\$\begingroup\$ Awesome! These feedbacks are really helpful. I really appreciate it. Thank you very much! \$\endgroup\$whiteSkar– whiteSkar2022年06月12日 20:54:24 +00:00Commented Jun 12, 2022 at 20:54
Explore related questions
See similar questions with these tags.