Capstone: Orders and Tests
Rules, safety nets and an interface.
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.

- 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.
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.
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])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.
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)... ---------------------------------------------------------------------- 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.
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))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.
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