AI Hints
Home›Python Tutorial›Capstone: Leafy Corner
Python · Lesson 48 of 49

Capstone: Orders and Tests

Rules, safety nets and an interface.

⏱ 2 min read▶ Video at 2:24:12🎓 Free
Watch this lesson in the Python courseWatch on YouTube ↗Full Python course

Now the part that makes money, taking orders. An order has several items. Before accepting it, the service must check the stock, for every item. If anything is missing, the whole order is refused, and nothing changes. If all is well, the stock is reduced, and the total is calculated, with an optional discount.

Capstone: Orders and Tests
From the video: Capstone: Orders and Tests
  • An order has several items
  • Check the stock first
  • All or nothing

The service layer

Here is the service. For each requested item, it loads the plant, and checks the stock, raising our own OutOfStock error, with a clear message. Only after every check has passed, does it reduce the stock. Then it returns the order lines, and the total, after the discount. Checking first, and changing second, keeps the data consistent.

service.pypython
from models import Line

class OutOfStock(Exception):
    pass

def place_order(store, items, discount=0.0):
    lines = []
    for name, qty in items:
        plant = store.get(name)
        if plant.stock < qty:
            raise OutOfStock(f"only {plant.stock} {name} left")
        lines.append(Line(name, qty, plant.price))
    for ln in lines:
        store.set_stock(ln.plant, store.get(ln.plant).stock - ln.qty)
    total = sum(ln.total for ln in lines)
    return lines, round(total * (1 - discount), 2)

Placing orders

Let's place orders. A valid order for two ferns, and one ivy, returns the lines, and a total of twenty six, with no discount. The stock drops. A second order asks for far too many ivy, and is refused, with a clear message, and the stock stays the same. The rules work.

demo_orders.pypython
from models import Plant
from store import Store
from service import place_order, OutOfStock

s = Store()
s.add(Plant("fern", 9.5, 12))
s.add(Plant("ivy", 7.0, 4))

lines, total = place_order(s, [("fern", 2), ("ivy", 1)])
print(total, s.get("fern").stock, s.get("ivy").stock)
try:
    place_order(s, [("fern", 1), ("ivy", 50)])
except OutOfStock as e:
    print("refused:", e)
print(s.get("fern").stock)
print(place_order(s, [("fern", 1)], discount=0.1)[1])
Output
26.0 10 3
refused: only 3 ivy left
10
8.55
Also uses models.py
from dataclasses import dataclass

@dataclass
class Plant:
    name: str
    price: float
    stock: int = 0

@dataclass
class Line:
    plant: str
    qty: int
    price: float

    @property
    def total(self) -> float:
        return round(self.qty * self.price, 2)
Also uses store.py
import sqlite3
from models import Plant

class Store:
    def __init__(self, path=":memory:"):
        self.db = sqlite3.connect(path)
        self.db.execute("CREATE TABLE IF NOT EXISTS plants (name TEXT PRIMARY KEY, price REAL, stock INTEGER)")

    def add(self, plant: Plant):
        self.db.execute("INSERT OR REPLACE INTO plants VALUES (?, ?, ?)", (plant.name, plant.price, plant.stock))
        self.db.commit()

    def get(self, name: str) -> Plant:
        row = self.db.execute("SELECT name, price, stock FROM plants WHERE name = ?", (name,)).fetchone()
        if row is None:
            raise KeyError(name)
        return Plant(*row)

    def set_stock(self, name: str, stock: int):
        self.db.execute("UPDATE plants SET stock = ? WHERE name = ?", (stock, name))
        self.db.commit()
Also uses service.py
from models import Line

class OutOfStock(Exception):
    pass

def place_order(store, items, discount=0.0):
    lines = []
    for name, qty in items:
        plant = store.get(name)
        if plant.stock < qty:
            raise OutOfStock(f"only {plant.stock} {name} left")
        lines.append(Line(name, qty, plant.price))
    for ln in lines:
        store.set_stock(ln.plant, store.get(ln.plant).stock - ln.qty)
    total = sum(ln.total for ln in lines)
    return lines, round(total * (1 - discount), 2)

Tests that protect the rules

Now the safety net. Tests for the key rules. A valid order reduces the stock. An order that is too large raises OutOfStock, and changes nothing. A discount applies correctly. Run them, and all three pass. From now on, anyone can change the code, and know within a second, whether they broke something.

test_orders.pypython
import unittest
from models import Plant
from store import Store
from service import place_order, OutOfStock

class TestOrders(unittest.TestCase):
    def setUp(self):
        self.s = Store()
        self.s.add(Plant("fern", 10.0, 5))

    def test_reduces_stock(self):
        place_order(self.s, [("fern", 2)])
        self.assertEqual(self.s.get("fern").stock, 3)

    def test_out_of_stock_changes_nothing(self):
        with self.assertRaises(OutOfStock):
            place_order(self.s, [("fern", 9)])
        self.assertEqual(self.s.get("fern").stock, 5)

    def test_discount(self):
        _, total = place_order(self.s, [("fern", 2)], discount=0.25)
        self.assertEqual(total, 15.0)

unittest.main(argv=["x"], exit=False)
Output
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK
Also uses models.py
from dataclasses import dataclass

@dataclass
class Plant:
    name: str
    price: float
    stock: int = 0

@dataclass
class Line:
    plant: str
    qty: int
    price: float

    @property
    def total(self) -> float:
        return round(self.qty * self.price, 2)
Also uses store.py
import sqlite3
from models import Plant

class Store:
    def __init__(self, path=":memory:"):
        self.db = sqlite3.connect(path)
        self.db.execute("CREATE TABLE IF NOT EXISTS plants (name TEXT PRIMARY KEY, price REAL, stock INTEGER)")

    def add(self, plant: Plant):
        self.db.execute("INSERT OR REPLACE INTO plants VALUES (?, ?, ?)", (plant.name, plant.price, plant.stock))
        self.db.commit()

    def get(self, name: str) -> Plant:
        row = self.db.execute("SELECT name, price, stock FROM plants WHERE name = ?", (name,)).fetchone()
        if row is None:
            raise KeyError(name)
        return Plant(*row)

    def set_stock(self, name: str, stock: int):
        self.db.execute("UPDATE plants SET stock = ? WHERE name = ?", (stock, name))
        self.db.commit()
Also uses service.py
from models import Line

class OutOfStock(Exception):
    pass

def place_order(store, items, discount=0.0):
    lines = []
    for name, qty in items:
        plant = store.get(name)
        if plant.stock < qty:
            raise OutOfStock(f"only {plant.stock} {name} left")
        lines.append(Line(name, qty, plant.price))
    for ln in lines:
        store.set_stock(ln.plant, store.get(ln.plant).stock - ln.qty)
    total = sum(ln.total for ln in lines)
    return lines, round(total * (1 - discount), 2)

The command line

Last, the command line. It ties the layers together. The stock command prints the current plants. The order command places an order, from the arguments, and prints a receipt, or an error message. We run it, by passing arguments in a list, exactly as a user would type them. This is the top layer, thin and friendly.

cli.pypython
import argparse
from models import Plant
from store import Store
from service import place_order, OutOfStock

def main(argv, store):
    p = argparse.ArgumentParser(prog="leafy")
    p.add_argument("plant")
    p.add_argument("qty", type=int)
    args = p.parse_args(argv)
    try:
        lines, total = place_order(store, [(args.plant, args.qty)])
    except (OutOfStock, KeyError) as e:
        print("Sorry:", e)
        return 1
    print(f"Sold {lines[0].qty} x {lines[0].plant} = ${total}")
    return 0

s = Store()
s.add(Plant("fern", 9.5, 5))
print(main(["fern", "2"], s))
print(main(["fern", "9"], s))
Output
Sold 2 x fern = $19.0
0
Sorry: only 3 fern left
1
Also uses models.py
from dataclasses import dataclass

@dataclass
class Plant:
    name: str
    price: float
    stock: int = 0

@dataclass
class Line:
    plant: str
    qty: int
    price: float

    @property
    def total(self) -> float:
        return round(self.qty * self.price, 2)
Also uses store.py
import sqlite3
from models import Plant

class Store:
    def __init__(self, path=":memory:"):
        self.db = sqlite3.connect(path)
        self.db.execute("CREATE TABLE IF NOT EXISTS plants (name TEXT PRIMARY KEY, price REAL, stock INTEGER)")

    def add(self, plant: Plant):
        self.db.execute("INSERT OR REPLACE INTO plants VALUES (?, ?, ?)", (plant.name, plant.price, plant.stock))
        self.db.commit()

    def get(self, name: str) -> Plant:
        row = self.db.execute("SELECT name, price, stock FROM plants WHERE name = ?", (name,)).fetchone()
        if row is None:
            raise KeyError(name)
        return Plant(*row)

    def set_stock(self, name: str, stock: int):
        self.db.execute("UPDATE plants SET stock = ? WHERE name = ?", (stock, name))
        self.db.commit()
Also uses service.py
from models import Line

class OutOfStock(Exception):
    pass

def place_order(store, items, discount=0.0):
    lines = []
    for name, qty in items:
        plant = store.get(name)
        if plant.stock < qty:
            raise OutOfStock(f"only {plant.stock} {name} left")
        lines.append(Line(name, qty, plant.price))
    for ln in lines:
        store.set_stock(ln.plant, store.get(ln.plant).stock - ln.qty)
    total = sum(ln.total for ln in lines)
    return lines, round(total * (1 - discount), 2)

Where to go from here

Ava's system works, and you built it. To grow it, you could add a web interface, with FastAPI. You could add customers and order history tables. You could draw sales charts, with pandas and matplotlib. You could deploy it with Docker, and add automated tests, that run on every change. Each step uses skills you already have.

Add a web APIFastAPI, in my other course
Customers and historymore tables, with joins
Charts and reportspandas + matplotlib
Deploy itDocker, CI, and logging

Summary

Congratulations. You built a complete, layered application, with data classes, a database, business rules, tests, and a command line. This is how professional Python projects are built. One last lesson remains, a roadmap for what to learn next.

Key takeaways
  • Layers: models, store, service, CLI
  • Check first, change second
  • Tests protect your rules
Next up: Your Python Roadmap — where to go from here