4
\$\begingroup\$

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')
\$\endgroup\$

1 Answer 1

5
\$\begingroup\$

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 asserts.

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
\$\endgroup\$
1
  • \$\begingroup\$ Awesome! These feedbacks are really helpful. I really appreciate it. Thank you very much! \$\endgroup\$
    – whiteSkar
    Jun 12, 2022 at 20:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.