add fixture-invoicing example, sample-room wrap, kind cluster support
- examples/fixture-invoicing/: FastAPI + Vue + Postgres demo (4-entity invoice fixture)
- cfg/sample/: wraps the fixture (managed.repos points at examples/)
- ctrl/kind-{up,down,status}.sh + per-room k8s render in soleprint/ctrl/k8s/
- build.py: relative repo paths, resilient rmtree, optional k8s render hook
- cfg/.gitignore: stop ignoring sample/ and standalone/ template rooms
Manifests render cleanly but kind cluster has not been run end-to-end yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
examples/fixture-invoicing/backend/Dockerfile
Normal file
15
examples/fixture-invoicing/backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
0
examples/fixture-invoicing/backend/api/__init__.py
Normal file
0
examples/fixture-invoicing/backend/api/__init__.py
Normal file
53
examples/fixture-invoicing/backend/api/customers.py
Normal file
53
examples/fixture-invoicing/backend/api/customers.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db import get_session
|
||||
from models import Customer
|
||||
from schemas import CustomerIn, CustomerOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[CustomerOut])
|
||||
def list_customers(session: Session = Depends(get_session)):
|
||||
return session.query(Customer).order_by(Customer.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=CustomerOut, status_code=201)
|
||||
def create_customer(payload: CustomerIn, session: Session = Depends(get_session)):
|
||||
customer = Customer(**payload.model_dump())
|
||||
session.add(customer)
|
||||
session.commit()
|
||||
session.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerOut)
|
||||
def get_customer(customer_id: int, session: Session = Depends(get_session)):
|
||||
customer = session.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(404, "customer not found")
|
||||
return customer
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=CustomerOut)
|
||||
def update_customer(
|
||||
customer_id: int, payload: CustomerIn, session: Session = Depends(get_session)
|
||||
):
|
||||
customer = session.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(404, "customer not found")
|
||||
for k, v in payload.model_dump().items():
|
||||
setattr(customer, k, v)
|
||||
session.commit()
|
||||
session.refresh(customer)
|
||||
return customer
|
||||
|
||||
|
||||
@router.delete("/{customer_id}", status_code=204)
|
||||
def delete_customer(customer_id: int, session: Session = Depends(get_session)):
|
||||
customer = session.get(Customer, customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(404, "customer not found")
|
||||
session.delete(customer)
|
||||
session.commit()
|
||||
71
examples/fixture-invoicing/backend/api/invoices.py
Normal file
71
examples/fixture-invoicing/backend/api/invoices.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from db import get_session
|
||||
from models import Invoice
|
||||
from schemas import InvoiceDetail, InvoiceIn, InvoiceOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[InvoiceOut])
|
||||
def list_invoices(
|
||||
status: str | None = None,
|
||||
customer_id: int | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
query = session.query(Invoice).order_by(Invoice.issued_at.desc())
|
||||
if status:
|
||||
query = query.filter(Invoice.status == status)
|
||||
if customer_id:
|
||||
query = query.filter(Invoice.customer_id == customer_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("", response_model=InvoiceOut, status_code=201)
|
||||
def create_invoice(payload: InvoiceIn, session: Session = Depends(get_session)):
|
||||
invoice = Invoice(**payload.model_dump())
|
||||
session.add(invoice)
|
||||
session.commit()
|
||||
session.refresh(invoice)
|
||||
return invoice
|
||||
|
||||
|
||||
@router.get("/{invoice_id}", response_model=InvoiceDetail)
|
||||
def get_invoice(invoice_id: int, session: Session = Depends(get_session)):
|
||||
invoice = (
|
||||
session.query(Invoice)
|
||||
.options(
|
||||
joinedload(Invoice.customer),
|
||||
joinedload(Invoice.line_items),
|
||||
joinedload(Invoice.payments),
|
||||
)
|
||||
.filter(Invoice.id == invoice_id)
|
||||
.first()
|
||||
)
|
||||
if not invoice:
|
||||
raise HTTPException(404, "invoice not found")
|
||||
return invoice
|
||||
|
||||
|
||||
@router.put("/{invoice_id}", response_model=InvoiceOut)
|
||||
def update_invoice(
|
||||
invoice_id: int, payload: InvoiceIn, session: Session = Depends(get_session)
|
||||
):
|
||||
invoice = session.get(Invoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(404, "invoice not found")
|
||||
for k, v in payload.model_dump().items():
|
||||
setattr(invoice, k, v)
|
||||
session.commit()
|
||||
session.refresh(invoice)
|
||||
return invoice
|
||||
|
||||
|
||||
@router.delete("/{invoice_id}", status_code=204)
|
||||
def delete_invoice(invoice_id: int, session: Session = Depends(get_session)):
|
||||
invoice = session.get(Invoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(404, "invoice not found")
|
||||
session.delete(invoice)
|
||||
session.commit()
|
||||
40
examples/fixture-invoicing/backend/api/line_items.py
Normal file
40
examples/fixture-invoicing/backend/api/line_items.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db import get_session
|
||||
from models import Invoice, LineItem
|
||||
from schemas import LineItemIn, LineItemOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[LineItemOut])
|
||||
def list_line_items(
|
||||
invoice_id: int | None = None, session: Session = Depends(get_session)
|
||||
):
|
||||
query = session.query(LineItem).order_by(LineItem.id)
|
||||
if invoice_id:
|
||||
query = query.filter(LineItem.invoice_id == invoice_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("/invoices/{invoice_id}", response_model=LineItemOut, status_code=201)
|
||||
def add_line_item(
|
||||
invoice_id: int, payload: LineItemIn, session: Session = Depends(get_session)
|
||||
):
|
||||
if not session.get(Invoice, invoice_id):
|
||||
raise HTTPException(404, "invoice not found")
|
||||
item = LineItem(invoice_id=invoice_id, **payload.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{line_item_id}", status_code=204)
|
||||
def delete_line_item(line_item_id: int, session: Session = Depends(get_session)):
|
||||
item = session.get(LineItem, line_item_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "line item not found")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
49
examples/fixture-invoicing/backend/api/payments.py
Normal file
49
examples/fixture-invoicing/backend/api/payments.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db import get_session
|
||||
from models import Invoice, Payment
|
||||
from schemas import PaymentIn, PaymentOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[PaymentOut])
|
||||
def list_payments(
|
||||
invoice_id: int | None = None, session: Session = Depends(get_session)
|
||||
):
|
||||
query = session.query(Payment).order_by(Payment.paid_at.desc())
|
||||
if invoice_id:
|
||||
query = query.filter(Payment.invoice_id == invoice_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("/invoices/{invoice_id}", response_model=PaymentOut, status_code=201)
|
||||
def record_payment(
|
||||
invoice_id: int, payload: PaymentIn, session: Session = Depends(get_session)
|
||||
):
|
||||
invoice = session.get(Invoice, invoice_id)
|
||||
if not invoice:
|
||||
raise HTTPException(404, "invoice not found")
|
||||
payment = Payment(invoice_id=invoice_id, **payload.model_dump())
|
||||
session.add(payment)
|
||||
|
||||
total_paid = sum(
|
||||
(p.amount for p in invoice.payments), start=payment.amount
|
||||
)
|
||||
total_owed = sum((li.unit_price * li.quantity for li in invoice.line_items), start=0)
|
||||
if total_owed > 0 and total_paid >= total_owed:
|
||||
invoice.status = "paid"
|
||||
|
||||
session.commit()
|
||||
session.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
@router.delete("/{payment_id}", status_code=204)
|
||||
def delete_payment(payment_id: int, session: Session = Depends(get_session)):
|
||||
payment = session.get(Payment, payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(404, "payment not found")
|
||||
session.delete(payment)
|
||||
session.commit()
|
||||
29
examples/fixture-invoicing/backend/db.py
Normal file
29
examples/fixture-invoicing/backend/db.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
|
||||
def _db_url() -> str:
|
||||
host = os.getenv("DB_HOST", "localhost")
|
||||
port = os.getenv("DB_PORT", "5432")
|
||||
name = os.getenv("DB_NAME", "fixture")
|
||||
user = os.getenv("DB_USER", "postgres")
|
||||
password = os.getenv("DB_PASSWORD", "fixture")
|
||||
return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}"
|
||||
|
||||
|
||||
engine = create_engine(_db_url(), echo=False, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_session():
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
52
examples/fixture-invoicing/backend/main.py
Normal file
52
examples/fixture-invoicing/backend/main.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api import customers, invoices, line_items, payments
|
||||
from db import Base, engine
|
||||
from seed import seed_if_empty
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("fixture")
|
||||
|
||||
app = FastAPI(
|
||||
title="Fixture Invoicing",
|
||||
description="SOLEPRINT FIXTURE APP — not a real invoicing product",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
if os.getenv("SEED_ON_START", "false").lower() == "true":
|
||||
seed_if_empty()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"app": "fixture-invoicing",
|
||||
"warning": "THIS IS A SOLEPRINT FIXTURE — NOT A REAL PRODUCT",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(customers.router, prefix="/api/customers", tags=["customers"])
|
||||
app.include_router(invoices.router, prefix="/api/invoices", tags=["invoices"])
|
||||
app.include_router(line_items.router, prefix="/api/line-items", tags=["line-items"])
|
||||
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
|
||||
63
examples/fixture-invoicing/backend/models.py
Normal file
63
examples/fixture-invoicing/backend/models.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from db import Base
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customer"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
invoices: Mapped[list["Invoice"]] = relationship(
|
||||
back_populates="customer", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Invoice(Base):
|
||||
__tablename__ = "invoice"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
number: Mapped[str] = mapped_column(String(40), nullable=False, unique=True)
|
||||
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"), nullable=False)
|
||||
issued_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||||
|
||||
customer: Mapped[Customer] = relationship(back_populates="invoices")
|
||||
line_items: Mapped[list["LineItem"]] = relationship(
|
||||
back_populates="invoice", cascade="all, delete-orphan"
|
||||
)
|
||||
payments: Mapped[list["Payment"]] = relationship(
|
||||
back_populates="invoice", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class LineItem(Base):
|
||||
__tablename__ = "line_item"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice.id"), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, default=1)
|
||||
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0)
|
||||
|
||||
invoice: Mapped[Invoice] = relationship(back_populates="line_items")
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = "payment"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoice.id"), nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
method: Mapped[str] = mapped_column(String(20), default="cash")
|
||||
paid_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
invoice: Mapped[Invoice] = relationship(back_populates="payments")
|
||||
5
examples/fixture-invoicing/backend/requirements.txt
Normal file
5
examples/fixture-invoicing/backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic==2.9.2
|
||||
58
examples/fixture-invoicing/backend/schemas.py
Normal file
58
examples/fixture-invoicing/backend/schemas.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CustomerIn(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
class CustomerOut(CustomerIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LineItemIn(BaseModel):
|
||||
description: str
|
||||
quantity: int = 1
|
||||
unit_price: Decimal = Decimal("0")
|
||||
|
||||
|
||||
class LineItemOut(LineItemIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
invoice_id: int
|
||||
|
||||
|
||||
class PaymentIn(BaseModel):
|
||||
amount: Decimal
|
||||
method: str = "cash"
|
||||
|
||||
|
||||
class PaymentOut(PaymentIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
invoice_id: int
|
||||
paid_at: datetime
|
||||
|
||||
|
||||
class InvoiceIn(BaseModel):
|
||||
number: str
|
||||
customer_id: int
|
||||
due_at: datetime | None = None
|
||||
status: str = "draft"
|
||||
|
||||
|
||||
class InvoiceOut(InvoiceIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
issued_at: datetime
|
||||
|
||||
|
||||
class InvoiceDetail(InvoiceOut):
|
||||
line_items: list[LineItemOut] = []
|
||||
payments: list[PaymentOut] = []
|
||||
customer: CustomerOut
|
||||
64
examples/fixture-invoicing/backend/seed.py
Normal file
64
examples/fixture-invoicing/backend/seed.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Seed obviously-fake demo data so the fixture is non-empty on first boot."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from db import SessionLocal
|
||||
from models import Customer, Invoice, LineItem, Payment
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seed_if_empty() -> None:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
if session.query(Customer).count() > 0:
|
||||
return
|
||||
|
||||
log.info("Seeding fixture data…")
|
||||
acme = Customer(name="Acme Widget Co.", email="billing@acme.example")
|
||||
test = Customer(name="Test Customer 001", email="test@example.invalid")
|
||||
session.add_all([acme, test])
|
||||
session.flush()
|
||||
|
||||
inv1 = Invoice(
|
||||
number="DEMO-2026-001",
|
||||
customer_id=acme.id,
|
||||
issued_at=datetime(2026, 1, 15),
|
||||
due_at=datetime(2026, 2, 15),
|
||||
status="sent",
|
||||
)
|
||||
inv2 = Invoice(
|
||||
number="DEMO-2026-002",
|
||||
customer_id=acme.id,
|
||||
issued_at=datetime(2026, 2, 1),
|
||||
due_at=datetime(2026, 3, 1),
|
||||
status="paid",
|
||||
)
|
||||
inv3 = Invoice(
|
||||
number="DEMO-2026-003",
|
||||
customer_id=test.id,
|
||||
issued_at=datetime(2026, 3, 10),
|
||||
due_at=datetime(2026, 4, 10),
|
||||
status="draft",
|
||||
)
|
||||
session.add_all([inv1, inv2, inv3])
|
||||
session.flush()
|
||||
|
||||
session.add_all([
|
||||
LineItem(invoice_id=inv1.id, description="Widget type A", quantity=10, unit_price=Decimal("19.99")),
|
||||
LineItem(invoice_id=inv1.id, description="Widget type B", quantity=5, unit_price=Decimal("49.00")),
|
||||
LineItem(invoice_id=inv2.id, description="Consulting (hours)", quantity=8, unit_price=Decimal("120.00")),
|
||||
LineItem(invoice_id=inv3.id, description="Placeholder line item", quantity=1, unit_price=Decimal("0")),
|
||||
])
|
||||
|
||||
session.add_all([
|
||||
Payment(invoice_id=inv2.id, amount=Decimal("960.00"), method="transfer", paid_at=datetime(2026, 2, 20)),
|
||||
Payment(invoice_id=inv1.id, amount=Decimal("100.00"), method="card", paid_at=datetime(2026, 2, 1)),
|
||||
])
|
||||
|
||||
session.commit()
|
||||
log.info("Seed complete.")
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user