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:
2026-04-29 05:30:52 -03:00
parent b886455431
commit 5f9cac1947
78 changed files with 3025 additions and 201 deletions

5
cfg/.gitignore vendored
View File

@@ -10,6 +10,5 @@ __pycache__/
*.pyc
*.pyo
# These are kept in main soleprint repo as templates
standalone/
sample/
# standalone/ and sample/ are kept in the main soleprint repo as templates.
# Other rooms (amar/, dlt/, etc.) are listed in the repo-root .gitignore.

14
cfg/sample/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# =============================================================================
# sample room — Copy to .env and customize
# =============================================================================
DEPLOYMENT_NAME=sample
NETWORK_NAME=sample_network
BACKEND_PORT=8120
FRONTEND_PORT=3120
DB_PORT=5532
POSTGRES_DB=fixture
POSTGRES_USER=postgres
POSTGRES_PASSWORD=localdev123

View File

@@ -17,7 +17,14 @@
},
"veins": ["google"],
"managed": {
"name": "sample"
"name": "sample",
"repos": {
"backend": "examples/fixture-invoicing/backend",
"frontend": "examples/fixture-invoicing/frontend"
}
},
"k8s": {
"enabled": true
},
"systems": [
{

62
cfg/sample/ctrl/deploy.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Deploy dlt to AWS
#
# Usage:
# ./ctrl/deploy.sh # Sync and restart
# ./ctrl/deploy.sh --rebuild # Rebuild container image
# ./ctrl/deploy.sh --dry-run # Preview sync
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOM_DIR="$(dirname "$SCRIPT_DIR")"
ROOM_NAME="$(basename "$ROOM_DIR")"
SPR_DIR="$(cd "$ROOM_DIR/../.." && pwd)"
SERVER="${DEPLOY_SERVER:-mcrn.ar}"
REMOTE_BASE="${DEPLOY_REMOTE_PATH:-~/soleprint/gen}"
REMOTE_PATH="$REMOTE_BASE/$ROOM_NAME/soleprint"
LOCAL_PATH="$SPR_DIR/gen/$ROOM_NAME/soleprint"
DRY_RUN=""
REBUILD=""
[ "$1" == "--dry-run" ] && DRY_RUN="--dry-run"
[ "$1" == "--rebuild" ] && REBUILD="--build"
# Check if built
if [[ ! -d "$LOCAL_PATH" ]]; then
echo "Error: $LOCAL_PATH not found"
echo "Build first: cd $SPR_DIR && python build.py --cfg $ROOM_NAME"
exit 1
fi
echo "=== Deploying $ROOM_NAME to $SERVER ==="
# Sync files, excluding local-only
rsync -avz --delete --progress $DRY_RUN \
--exclude='.env' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
--exclude='**/storage/' \
"$LOCAL_PATH/" \
"$SERVER:$REMOTE_PATH/"
if [ -n "$DRY_RUN" ]; then
echo "=== Dry run complete ==="
exit 0
fi
# Get container name from AWS .env
CONTAINER_NAME=$(ssh "$SERVER" "grep DEPLOYMENT_NAME $REMOTE_PATH/.env 2>/dev/null | cut -d= -f2" || echo "${ROOM_NAME}_spr")
echo ""
echo "=== Restarting $CONTAINER_NAME ==="
ssh "$SERVER" "cd $REMOTE_PATH && docker compose down && docker compose up -d $REBUILD"
echo ""
echo "=== Connecting to gateway network ==="
ssh "$SERVER" "docker network connect gateway $CONTAINER_NAME 2>/dev/null || true"
echo ""
echo "=== Done ==="
echo "Test at: https://$ROOM_NAME.spr.mcrn.ar"

View File

@@ -1,28 +1,45 @@
#!/bin/bash
# Start all sample services
# Usage: ./ctrl/start.sh [-d]
# Start sample room (fixture invoicing + link + soleprint)
#
# Usage:
# ./start.sh # Start all (foreground, with nginx)
# ./start.sh -d # Start all (detached)
# ./start.sh --no-nginx # Skip nginx reverse proxy
# ./start.sh --build # Rebuild images
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
APP_DIR="$ROOT_DIR/sample"
LINK_DIR="$ROOT_DIR/link"
SPR_DIR="$ROOT_DIR/soleprint"
BUILD=""
DETACH=""
if [[ "$1" == "-d" ]]; then
DETACH="-d"
NGINX_OVERRIDE="-f docker-compose.nginx.yml"
for arg in "$@"; do
case $arg in
-d|--detached) DETACH="-d" ;;
--build) BUILD="--build" ;;
--no-nginx) NGINX_OVERRIDE="" ;;
esac
done
# Ensure shared docker network exists (soleprint + managed app share it)
NETWORK="$(grep -m1 '^NETWORK_NAME=' "$APP_DIR/.env" | cut -d= -f2)"
docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK"
echo "=== Starting sample ==="
# 1) Managed app (db + backend + frontend)
(cd "$APP_DIR" && docker compose up -d $BUILD)
# 2) Link bridge
if [[ -f "$LINK_DIR/docker-compose.yml" ]]; then
(cd "$LINK_DIR" && docker compose --env-file "$APP_DIR/.env" up -d $BUILD)
fi
echo "=== Starting sample services ==="
# Start soleprint
echo "Starting soleprint..."
cd "$ROOT_DIR/soleprint"
docker compose up $DETACH &
# Start sample app
if [[ -f "$ROOT_DIR/sample/docker-compose.yml" ]]; then
echo "Starting sample app..."
cd "$ROOT_DIR/sample"
docker compose up $DETACH &
fi
wait
echo "=== All services started ==="
# 3) Soleprint (+ nginx sidebar injection)
(cd "$SPR_DIR" && docker compose -f docker-compose.yml $NGINX_OVERRIDE up $DETACH $BUILD)

View File

@@ -1,22 +1,15 @@
#!/bin/bash
# Stop all sample services
# Usage: ./ctrl/stop.sh
# Stop sample room
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Stopping sample services ==="
echo "=== Stopping sample ==="
# Stop sample app
if [[ -f "$ROOT_DIR/sample/docker-compose.yml" ]]; then
echo "Stopping sample app..."
cd "$ROOT_DIR/sample"
docker compose down
fi
# Stop soleprint
echo "Stopping soleprint..."
cd "$ROOT_DIR/soleprint"
docker compose down
echo "=== All services stopped ==="
for d in "$ROOT_DIR/soleprint" "$ROOT_DIR/link" "$ROOT_DIR/sample"; do
if [[ -f "$d/docker-compose.yml" ]]; then
(cd "$d" && docker compose down)
fi
done

156
cfg/sample/data/__init__.py Normal file
View File

@@ -0,0 +1,156 @@
"""
Pawprint Data Layer
JSON file storage (future: MongoDB)
"""
import json
from pathlib import Path
from typing import List, Optional
# Add parent to path for models import
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from models.pydantic import (
Vein, Nest, Larder, Template, Tool,
Pulse, Book, Table,
VeinCollection, NestCollection, LarderCollection,
TemplateCollection, ToolCollection,
PulseCollection, BookCollection, TableCollection,
Status
)
DATA_DIR = Path(__file__).parent.resolve()
# Debug: print data dir on import
print(f"[data] DATA_DIR: {DATA_DIR}")
print(f"[data] DATA_DIR exists: {DATA_DIR.exists()}")
print(f"[data] veins.json exists: {(DATA_DIR / 'veins.json').exists()}")
def _load_json(filename: str) -> dict:
filepath = DATA_DIR / filename
if filepath.exists():
with open(filepath) as f:
data = json.load(f)
print(f"[data] Loaded {filename}: {len(data.get('items', []))} items")
return data
print(f"[data] File not found: {filepath}")
return {"items": []}
def _save_json(filename: str, data: dict):
filepath = DATA_DIR / filename
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
# === Loaders ===
def get_veins() -> List[Vein]:
data = _load_json("veins.json")
return VeinCollection(**data).items
def get_nests() -> List[Nest]:
data = _load_json("nests.json")
return NestCollection(**data).items
def get_larders() -> List[Larder]:
data = _load_json("larders.json")
return LarderCollection(**data).items
def get_templates() -> List[Template]:
data = _load_json("templates.json")
return TemplateCollection(**data).items
def get_tools() -> List[Tool]:
data = _load_json("tools.json")
return ToolCollection(**data).items
def get_cabinets() -> list:
"""Load cabinets (simple dict, no pydantic yet)."""
data = _load_json("cabinets.json")
return data.get("items", [])
def get_monitors() -> list:
"""Load monitors (simple dict, no pydantic yet)."""
data = _load_json("monitors.json")
return data.get("items", [])
def get_pulses() -> List[Pulse]:
data = _load_json("pulses.json")
return PulseCollection(**data).items
def get_books() -> List[Book]:
data = _load_json("books.json")
return BookCollection(**data).items
def get_tables() -> List[Table]:
data = _load_json("tables.json")
return TableCollection(**data).items
# === Helpers ===
def get_vein(name: str) -> Optional[Vein]:
for v in get_veins():
if v.name == name:
return v
return None
def get_nest(name: str) -> Optional[Nest]:
for n in get_nests():
if n.name == name:
return n
return None
def get_larder(name: str) -> Optional[Larder]:
for l in get_larders():
if l.name == name:
return l
return None
# === For frontend rendering ===
def get_artery_data() -> dict:
"""Data for artery frontend."""
return {
"veins": [v.model_dump() for v in get_veins()],
"nests": [n.model_dump() for n in get_nests()],
"larders": [l.model_dump() for l in get_larders()],
"pulses": [p.model_dump() for p in get_pulses()],
}
def get_album_data() -> dict:
"""Data for album frontend."""
return {
"templates": [t.model_dump() for t in get_templates()],
"larders": [l.model_dump() for l in get_larders()],
"books": [b.model_dump() for b in get_books()],
}
def get_ward_data() -> dict:
"""Data for ward frontend."""
return {
"tools": [t.model_dump() for t in get_tools()],
"monitors": get_monitors(),
"cabinets": get_cabinets(),
"nests": [n.model_dump() for n in get_nests()],
"larders": [l.model_dump() for l in get_larders()],
"tables": [t.model_dump() for t in get_tables()],
}

View File

@@ -0,0 +1,14 @@
{
"items": [
{
"name": "feature-flow",
"slug": "feature-flow",
"title": "Feature Flow Pipeline",
"status": "ready",
"template": null,
"larder": null,
"output_larder": null,
"system": "atlas"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"items": [
{
"name": "feature-form",
"slug": "feature-form",
"title": "Feature Forms",
"status": "ready",
"source_template": "feature-form",
"data_path": "album/book/feature-form-samples/feature-form"
}
]
}

View File

@@ -0,0 +1,3 @@
{
"items": []
}

View File

@@ -0,0 +1,22 @@
{
"items": [
{
"name": "turnos",
"slug": "turnos",
"title": "Turnos Monitor",
"status": "dev",
"system": "ward",
"description": "Pipeline view of requests → turnos. Shows vet-petowner at a glance.",
"path": "ward/monitor/turnos"
},
{
"name": "data_browse",
"slug": "data-browse",
"title": "Data Browse",
"status": "ready",
"system": "ward",
"description": "Quick navigation to test users and data states. Book/larder pattern with SQL mode for manual testing workflows.",
"path": "ward/monitor/data_browse"
}
]
}

View File

@@ -0,0 +1,3 @@
{
"items": []
}

View File

@@ -0,0 +1,3 @@
{
"items": []
}

View File

@@ -0,0 +1,5 @@
{
"items": [
{"name": "pawprint-local", "slug": "pawprint-local", "title": "Pawprint Local", "status": "dev", "config_path": "deploy/pawprint-local"}
]
}

View File

@@ -0,0 +1,18 @@
{
"items": [
{
"name": "mercadopago",
"slug": "mercadopago",
"title": "MercadoPago",
"status": "ready",
"description": "Mock payment API for testing"
},
{
"name": "example",
"slug": "example",
"title": "Example",
"status": "ready",
"description": "Example shunt template"
}
]
}

View File

@@ -0,0 +1,3 @@
{
"items": []
}

View File

@@ -0,0 +1,38 @@
# {{nombre_flujo}}
## Tipo de usuario
{{tipo_usuario}}
## Donde empieza
{{punto_entrada}}
## Que quiere hacer el usuario
{{objetivo}}
## Pasos
1. {{paso_1}}
2. {{paso_2}}
3. {{paso_3}}
## Que deberia pasar
- {{resultado_1}}
- {{resultado_2}}
## Problemas comunes
- {{problema_1}}
- {{problema_2}}
## Casos especiales
- {{caso_especial_1}}
## Flujos relacionados
- {{flujo_relacionado_1}}
## Notas tecnicas
- {{nota_tecnica_1}}

View File

@@ -0,0 +1,12 @@
{
"items": [
{
"name": "feature-form",
"slug": "feature-form",
"title": "Feature Form Template",
"status": "ready",
"template_path": "data/template/feature-form",
"system": "album"
}
]
}

View File

@@ -0,0 +1,48 @@
{
"items": [
{
"name": "tester",
"slug": "tester",
"title": "Contract Tests",
"status": "live",
"system": "ward",
"type": "app",
"description": "HTTP contract test runner with multi-environment support. Filter, run, and track tests against dev/stage/prod.",
"path": "ward/tools/tester",
"url": "/tools/tester/"
},
{
"name": "datagen",
"slug": "datagen",
"title": "Test Data Generator",
"status": "live",
"system": "ward",
"type": "cli",
"description": "Generate realistic test data for Amar domain (users, pets, services) and MercadoPago API responses. Used by mock veins and test seeders.",
"path": "ward/tools/datagen",
"cli": "python -m datagen"
},
{
"name": "generate_test_data",
"slug": "generate-test-data",
"title": "DB Test Data Extractor",
"status": "dev",
"system": "ward",
"type": "cli",
"description": "Extract representative subsets from PostgreSQL dumps for testing/development.",
"path": "ward/tools/generate_test_data",
"cli": "python -m generate_test_data"
},
{
"name": "modelgen",
"slug": "modelgen",
"title": "Model Generator",
"status": "dev",
"system": "ward",
"type": "cli",
"description": "Generate platform-specific models (Pydantic, Django, Prisma) from JSON Schema.",
"path": "ward/tools/modelgen",
"cli": "python -m modelgen"
}
]
}

View File

@@ -0,0 +1,60 @@
{
"items": [
{
"name": "jira",
"slug": "jira",
"title": "Jira",
"status": "live",
"system": "artery"
},
{
"name": "slack",
"slug": "slack",
"title": "Slack",
"status": "building",
"system": "artery"
},
{
"name": "google",
"slug": "google",
"title": "Google",
"status": "building",
"system": "artery"
},
{
"name": "maps",
"slug": "maps",
"title": "Maps",
"status": "planned",
"system": "artery"
},
{
"name": "whatsapp",
"slug": "whatsapp",
"title": "WhatsApp",
"status": "planned",
"system": "artery"
},
{
"name": "gnucash",
"slug": "gnucash",
"title": "GNUCash",
"status": "planned",
"system": "artery"
},
{
"name": "vpn",
"slug": "vpn",
"title": "VPN",
"status": "planned",
"system": "artery"
},
{
"name": "ia",
"slug": "ia",
"title": "IA",
"status": "live",
"system": "artery"
}
]
}

View File

@@ -0,0 +1,74 @@
# Sample Room — Managed App Services (Fixture Invoicing)
#
# Runs backend + frontend + postgres on the shared sample_network,
# so soleprint + nginx can wrap them from cfg/sample/soleprint/.
#
# Usage (from gen/sample/sample/):
# docker compose up -d
name: ${DEPLOYMENT_NAME}
services:
db:
image: postgres:16-alpine
container_name: ${DEPLOYMENT_NAME}_db
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "${DB_PORT}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
networks:
- default
backend:
build:
context: ./backend
container_name: ${DEPLOYMENT_NAME}_backend
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
SEED_ON_START: "true"
ports:
- "${BACKEND_PORT}:8000"
depends_on:
db:
condition: service_healthy
networks:
- default
frontend:
build:
context: ./frontend
container_name: ${DEPLOYMENT_NAME}_frontend
environment:
# Browser hits /api/ via nginx, so leave blank to use same-origin
VITE_API_URL: ""
ports:
- "${FRONTEND_PORT}:5173"
depends_on:
- backend
volumes:
- ./frontend/src:/app/src
- ./frontend/index.html:/app/index.html
- ./frontend/vite.config.js:/app/vite.config.js
networks:
- default
volumes:
pgdata:
networks:
default:
external: true
name: ${NETWORK_NAME}

View 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"]

View File

View File

@@ -0,0 +1,43 @@
"""SQLAlchemy bridge — read tables via reflection.
Works against the fixture's postgres. Reflection means the link does not
need to import the app's model code — it discovers tables at runtime.
"""
import os
from fastapi import APIRouter, HTTPException
from sqlalchemy import MetaData, create_engine, inspect, select
router = APIRouter()
def _db_url() -> str:
host = os.getenv("DB_HOST", "db")
port = os.getenv("DB_PORT", "5432")
name = os.getenv("DB_NAME", "fixture")
user = os.getenv("DB_USER", "postgres")
password = os.getenv("DB_PASSWORD", "localdev123")
return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}"
engine = create_engine(_db_url(), future=True)
metadata = MetaData()
@router.get("/tables")
def list_tables():
insp = inspect(engine)
return {"tables": insp.get_table_names()}
@router.get("/tables/{name}")
def table_rows(name: str, limit: int = 50):
insp = inspect(engine)
if name not in insp.get_table_names():
raise HTTPException(404, f"table '{name}' not found")
metadata.reflect(bind=engine, only=[name])
table = metadata.tables[name]
with engine.connect() as conn:
rows = conn.execute(select(table).limit(limit)).mappings().all()
return {"table": name, "rows": [dict(r) for r in rows]}

View File

@@ -0,0 +1,24 @@
# Link — Fixture DB bridge
name: ${DEPLOYMENT_NAME}_link
services:
link:
build:
context: .
dockerfile: Dockerfile
container_name: ${DEPLOYMENT_NAME}_link
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${LINK_PORT:-8121}:8000"
networks:
- default
networks:
default:
external: true
name: ${NETWORK_NAME}

19
cfg/sample/link/main.py Normal file
View File

@@ -0,0 +1,19 @@
"""Link — DB bridge for the fixture invoicing app.
Exposes a small HTTP API that wraps SQLAlchemy access to the fixture's
postgres so soleprint tools can read/write without touching the app.
"""
from fastapi import FastAPI
from adapters.sqlalchemy_bridge import router as db_router
app = FastAPI(title="Link — Fixture Bridge")
@app.get("/health")
def health():
return {"status": "ok", "target": "fixture-invoicing"}
app.include_router(db_router, prefix="/db")

View File

@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
psycopg2-binary==2.9.10

View File

@@ -1,15 +1,21 @@
# =============================================================================
# Sample Managed App - Environment Configuration
# sample room — Managed App (Fixture Invoicing)
# =============================================================================
# Copy this to cfg/<your-room>/<app-name>/.env and customize
# This room wraps examples/fixture-invoicing/ — a deliberate fixture, not a real app.
# =============================================================================
# DEPLOYMENT
# =============================================================================
DEPLOYMENT_NAME=sample
NETWORK_NAME=sample_network
# =============================================================================
# PORTS
# PORTS (host-exposed)
# =============================================================================
FRONTEND_PORT=3020
BACKEND_PORT=8120
FRONTEND_PORT=3120
DB_PORT=5532
# =============================================================================
# DATABASE (Postgres)
# =============================================================================
POSTGRES_DB=fixture
POSTGRES_USER=postgres
POSTGRES_PASSWORD=localdev123

View File

@@ -1,19 +0,0 @@
# Sample Mock Frontend
# Simple nginx serving static HTML
#
# For a real app, customize this with your actual services
services:
frontend:
image: nginx:alpine
container_name: ${DEPLOYMENT_NAME}_frontend
volumes:
- ./index.html:/usr/share/nginx/html/index.html:ro
ports:
- "${FRONTEND_PORT}:80"
networks:
- default
networks:
default:
name: ${NETWORK_NAME}

View File

@@ -1,102 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sample - Public Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
background: linear-gradient(135deg, #1e3a5f 0%, #0d1b2a 100%);
color: #e5e5e5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, #3a86ff, #8338ec);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
font-size: 1.2rem;
color: #a3a3a3;
margin-bottom: 2rem;
}
.status {
display: inline-block;
padding: 0.5rem 1rem;
background: rgba(58, 134, 255, 0.2);
border: 1px solid #3a86ff;
border-radius: 4px;
color: #3a86ff;
font-size: 0.9rem;
}
.info {
margin-top: 3rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
max-width: 400px;
}
.info h3 {
color: #8338ec;
margin-bottom: 1rem;
}
.info ul {
list-style: none;
text-align: left;
}
.info li {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.info li:last-child {
border-bottom: none;
}
a {
color: #3a86ff;
}
</style>
</head>
<body>
<div class="container">
<h1>Sample</h1>
<p>Public demo - open to any Gmail account</p>
<span class="status">Public Demo</span>
<div class="info">
<h3>Soleprint Managed Room</h3>
<ul>
<li>
With sidebar:
<a href="https://sample.spr.mcrn.ar"
>sample.spr.mcrn.ar</a
>
</li>
<li>
Standalone:
<a href="https://sample.mcrn.ar">sample.mcrn.ar</a>
</li>
<li>Login with any Google account</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -16,6 +16,7 @@ NETWORK_NAME=sample_network
# PORTS (unique per room)
# =============================================================================
SOLEPRINT_PORT=12030
NGINX_PORT=8130
# =============================================================================
# GOOGLE OAUTH

View File

@@ -0,0 +1,44 @@
# Fixture Invoicing — Soleprint Demo
> **This book describes a deliberately-fake invoicing app used as a test
> fixture for the Soleprint framework.** It is not a real product.
## Purpose
The fixture exercises every soleprint tool end-to-end so we can iterate on
the framework without needing a real managed app. See
[`examples/fixture-invoicing/`](../../../../../../examples/fixture-invoicing/)
for the app itself.
## Data model
```
Customer ──< Invoice ──< LineItem
└──────< Payment
```
| Table | Key fields |
|-------------|-------------------------------------------------|
| `customer` | id · name · email · created_at |
| `invoice` | id · number · customer_id · issued_at · status |
| `line_item` | id · invoice_id · description · qty · unit_price|
| `payment` | id · invoice_id · amount · method · paid_at |
## Happy-path flow
1. Create a customer (`POST /api/customers`)
2. Create a draft invoice for them (`POST /api/invoices`)
3. Add one or more line items (`POST /api/line-items/invoices/{id}`)
4. Record a payment (`POST /api/payments/invoices/{id}`) — if the total
paid ≥ total billed, the invoice auto-transitions to `paid`.
## How this connects to Soleprint
| Soleprint tool | Fixture hook |
|----------------|--------------|
| datagen | `station/tools/datagen/fixture.py` — FixtureInvoicingGenerator |
| graphgen | `station/tools/graphgen/schema.json` — 4 models + FK edges |
| databrowse | `station/tools/databrowse/depot/{schema,views}.json` |
| tester | `station/tools/tester/tests/fixture/` |
| link | SQLAlchemy reflection against `customer`, `invoice`, etc. |
| sbwrapper | Injected into fixture's Vue frontend by nginx |

View File

@@ -21,7 +21,7 @@ services:
image: nginx:alpine
container_name: ${DEPLOYMENT_NAME}_nginx
ports:
- "80:80"
- "${NGINX_PORT:-80}:80"
volumes:
- ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro
networks:

View File

@@ -1,34 +1,40 @@
# Sample Room - Nginx Config for Docker
#
# This config uses docker service names (soleprint, frontend, backend)
# which resolve within the docker network.
# Sample Room Nginx Config
# Uses Docker DNS resolver so upstreams resolve at request time
# (lets nginx start before backend/frontend containers are up).
# sample.spr.local.ar - frontend with soleprint sidebar
resolver 127.0.0.11 valid=10s;
# sample.spr.local.ar — fixture wrapped by soleprint (sidebar injected)
server {
listen 80;
server_name sample.spr.local.ar;
# Soleprint routes - sidebar API and assets
set $soleprint sample_spr;
set $backend sample_backend;
set $frontend sample_frontend;
# Soleprint routes (sidebar API + static assets)
location /spr/ {
proxy_pass http://soleprint:8000/;
rewrite ^/spr/(.*)$ /$1 break;
proxy_pass http://$soleprint:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API (uncomment if your app has a backend)
# location /api/ {
# proxy_pass http://backend:8000/api/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# Fixture backend API
location /api/ {
proxy_pass http://$backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend with sidebar injection
# Frontend (Vite dev server on 5173) with sidebar injection
location / {
proxy_pass http://frontend:80;
proxy_pass http://$frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -38,29 +44,29 @@ server {
proxy_set_header Connection "upgrade";
proxy_set_header Accept-Encoding "";
# Inject sidebar CSS and JS into head
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
sub_filter_once off;
sub_filter_types text/html;
}
}
# sample.local.ar - frontend without sidebar (direct access)
# sample.local.ar — direct fixture access (no sidebar)
server {
listen 80;
server_name sample.local.ar;
# Backend API (uncomment if your app has a backend)
# location /api/ {
# proxy_pass http://backend:8000/api/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
set $backend sample_backend;
set $frontend sample_frontend;
location /api/ {
proxy_pass http://$backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://frontend:80;
proxy_pass http://$frontend:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -0,0 +1,60 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Fixture Invoicing Data Model",
"description": "Tables of the fixture-invoicing app — all data is obviously placeholder.",
"version": "0.1.0",
"meta": {
"purpose": "Browse and QA fixture data while iterating on soleprint tools.",
"modes": ["sql"]
},
"definitions": {
"Customer": {
"type": "object",
"description": "A fixture customer.",
"table": "customer",
"properties": {
"id": {"type": "integer", "column": "id"},
"name": {"type": "string", "column": "name"},
"email": {"type": "string", "column": "email"},
"created_at": {"type": "string", "format": "date-time", "column": "created_at"}
}
},
"Invoice": {
"type": "object",
"description": "An invoice issued to a fixture customer.",
"table": "invoice",
"properties": {
"id": {"type": "integer", "column": "id"},
"number": {"type": "string", "column": "number"},
"customer_id": {"type": "integer", "column": "customer_id", "fk": "Customer"},
"issued_at": {"type": "string", "format": "date-time", "column": "issued_at"},
"due_at": {"type": "string", "format": "date-time", "column": "due_at"},
"status": {"type": "string", "column": "status"}
}
},
"LineItem": {
"type": "object",
"description": "A billable line on an invoice.",
"table": "line_item",
"properties": {
"id": {"type": "integer", "column": "id"},
"invoice_id": {"type": "integer", "column": "invoice_id", "fk": "Invoice"},
"description": {"type": "string", "column": "description"},
"quantity": {"type": "integer", "column": "quantity"},
"unit_price": {"type": "number", "column": "unit_price"}
}
},
"Payment": {
"type": "object",
"description": "A payment recorded against an invoice.",
"table": "payment",
"properties": {
"id": {"type": "integer", "column": "id"},
"invoice_id": {"type": "integer", "column": "invoice_id", "fk": "Invoice"},
"amount": {"type": "number", "column": "amount"},
"method": {"type": "string", "column": "method"},
"paid_at": {"type": "string", "format": "date-time", "column": "paid_at"}
}
}
}
}

View File

@@ -0,0 +1,57 @@
{
"views": [
{
"name": "customers",
"title": "Customers",
"slug": "customers",
"description": "All fixture customers.",
"mode": "sql",
"entity": "Customer",
"order_by": "id ASC",
"fields": ["id", "name", "email", "created_at"],
"display_fields": {
"id": {"label": "ID", "width": "60px"},
"name": {"label": "Name", "width": "200px", "primary": true},
"email": {"label": "Email", "width": "220px"},
"created_at": {"label": "Created", "width": "180px"}
}
},
{
"name": "invoices_by_status",
"title": "Invoices by Status",
"slug": "invoices-by-status",
"description": "Invoices grouped by lifecycle status.",
"mode": "sql",
"entity": "Invoice",
"group_by": "status",
"order_by": "issued_at DESC",
"fields": ["id", "number", "customer_id", "issued_at", "due_at", "status"],
"display_fields": {
"number": {"label": "Number", "width": "150px", "primary": true},
"status": {"label": "Status", "width": "100px"},
"issued_at": {"label": "Issued", "width": "140px"},
"due_at": {"label": "Due", "width": "140px"}
}
},
{
"name": "line_items",
"title": "Line Items",
"slug": "line-items",
"description": "All line items across fixture invoices.",
"mode": "sql",
"entity": "LineItem",
"order_by": "invoice_id, id",
"fields": ["id", "invoice_id", "description", "quantity", "unit_price"]
},
{
"name": "payments",
"title": "Payments",
"slug": "payments",
"description": "All payments recorded against fixture invoices.",
"mode": "sql",
"entity": "Payment",
"order_by": "paid_at DESC",
"fields": ["id", "invoice_id", "amount", "method", "paid_at"]
}
]
}

View File

@@ -0,0 +1,124 @@
"""Datagen for the fixture-invoicing app.
Generates Customer / Invoice / LineItem / Payment records that match the
fixture's SQLAlchemy models. Obviously-fake names make it clear these are
not real business records.
"""
import random
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
try:
from station.tools.datagen.base import BaseDataGenerator
except ImportError:
class BaseDataGenerator:
pass
_COMPANIES = [
"Acme Widget Co.", "Fixture Industries", "Demo Corp.", "Placeholder LLC",
"Test Customer Holdings", "Lorem Enterprises", "Ipsum & Sons",
"Sample Partners", "Dummy Data Group", "Stub Systems",
]
_METHODS = ["cash", "card", "transfer"]
_STATUSES = ["draft", "sent", "paid", "void"]
class FixtureInvoicingGenerator(BaseDataGenerator):
"""Generates obviously-fake records for the fixture app."""
def customer(self, **kwargs) -> dict:
name = kwargs.pop("name", None) or random.choice(_COMPANIES)
slug = name.lower().replace(" ", "-").replace(".", "").replace(",", "")
return {
"id": str(uuid.uuid4()),
"name": name,
"email": f"billing+{slug}@example.invalid",
"created_at": datetime.utcnow().isoformat(),
**kwargs,
}
def invoice(self, customer_id: int | str | None = None, **kwargs) -> dict:
number = kwargs.pop("number", f"DEMO-{random.randint(1000, 9999)}")
issued = datetime.utcnow() - timedelta(days=random.randint(0, 60))
return {
"id": str(uuid.uuid4()),
"number": number,
"customer_id": customer_id or 1,
"issued_at": issued.isoformat(),
"due_at": (issued + timedelta(days=30)).isoformat(),
"status": kwargs.pop("status", random.choice(_STATUSES)),
**kwargs,
}
def line_item(self, invoice_id: int | str | None = None, **kwargs) -> dict:
qty = kwargs.pop("quantity", random.randint(1, 20))
price = kwargs.pop("unit_price", round(random.uniform(5, 500), 2))
return {
"id": str(uuid.uuid4()),
"invoice_id": invoice_id or 1,
"description": kwargs.pop(
"description", f"Widget {random.choice(['A', 'B', 'C'])} (fixture)"
),
"quantity": qty,
"unit_price": float(Decimal(str(price))),
**kwargs,
}
def payment(self, invoice_id: int | str | None = None, **kwargs) -> dict:
return {
"id": str(uuid.uuid4()),
"invoice_id": invoice_id or 1,
"amount": kwargs.pop("amount", round(random.uniform(50, 2000), 2)),
"method": kwargs.pop("method", random.choice(_METHODS)),
"paid_at": datetime.utcnow().isoformat(),
**kwargs,
}
def schema(self) -> dict:
return {
"models": {
"Customer": {
"doc": "A fixture customer — obviously-placeholder name.",
"fields": {
"id": {"type": "UUID", "pk": True},
"name": {"type": "str"},
"email": {"type": "str"},
"created_at": {"type": "datetime"},
},
},
"Invoice": {
"doc": "An invoice issued to a fixture customer.",
"fields": {
"id": {"type": "UUID", "pk": True},
"number": {"type": "str"},
"customer_id": {"type": "FK:Customer"},
"issued_at": {"type": "datetime"},
"due_at": {"type": "datetime", "nullable": True},
"status": {"type": "str"},
},
},
"LineItem": {
"doc": "A single billable line on an invoice.",
"fields": {
"id": {"type": "UUID", "pk": True},
"invoice_id": {"type": "FK:Invoice"},
"description": {"type": "str"},
"quantity": {"type": "int"},
"unit_price": {"type": "Decimal"},
},
},
"Payment": {
"doc": "A payment recorded against an invoice.",
"fields": {
"id": {"type": "UUID", "pk": True},
"invoice_id": {"type": "FK:Invoice"},
"amount": {"type": "Decimal"},
"method": {"type": "str"},
"paid_at": {"type": "datetime"},
},
},
}
}

View File

@@ -0,0 +1,44 @@
{
"models": {
"Customer": {
"doc": "A fixture customer (obviously-placeholder names).",
"fields": {
"id": {"type": "int", "pk": true},
"name": {"type": "str"},
"email": {"type": "str"},
"created_at": {"type": "datetime"}
}
},
"Invoice": {
"doc": "An invoice issued to a customer. Owns line items and payments.",
"fields": {
"id": {"type": "int", "pk": true},
"number": {"type": "str"},
"customer_id": {"type": "FK:Customer"},
"issued_at": {"type": "datetime"},
"due_at": {"type": "datetime", "nullable": true},
"status": {"type": "str"}
}
},
"LineItem": {
"doc": "A single billable line on an invoice.",
"fields": {
"id": {"type": "int", "pk": true},
"invoice_id": {"type": "FK:Invoice"},
"description": {"type": "str"},
"quantity": {"type": "int"},
"unit_price": {"type": "Decimal"}
}
},
"Payment": {
"doc": "A payment recorded against an invoice.",
"fields": {
"id": {"type": "int", "pk": true},
"invoice_id": {"type": "FK:Invoice"},
"amount": {"type": "Decimal"},
"method": {"type": "str"},
"paid_at": {"type": "datetime"}
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"environments": [
{
"name": "docker",
"url": "http://sample_backend:8000",
"auth_type": "none",
"default": true
},
{
"name": "local",
"url": "http://localhost:8120",
"auth_type": "none"
}
]
}

View File

@@ -0,0 +1,52 @@
"""Smoke tests for the fixture-invoicing API.
Runs via the soleprint tester. Target URL is read from environments.json
(local = http://sample_backend:8000 inside the soleprint docker network).
"""
from tests.base import ContractTestCase
class CustomersContractTest(ContractTestCase):
def test_list_customers_returns_list(self):
resp = self.get("/api/customers")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIsInstance(data, list)
# seed should have produced Acme + Test Customer
names = {c["name"] for c in data}
self.assertIn("Acme Widget Co.", names)
def test_create_and_delete_customer(self):
payload = {"name": "Ephemeral Corp.", "email": "e@example.invalid"}
created = self.post("/api/customers", json=payload)
self.assertEqual(created.status_code, 201)
customer_id = created.json()["id"]
fetched = self.get(f"/api/customers/{customer_id}")
self.assertEqual(fetched.status_code, 200)
self.assertEqual(fetched.json()["name"], "Ephemeral Corp.")
self.delete(f"/api/customers/{customer_id}")
missing = self.get(f"/api/customers/{customer_id}")
self.assertEqual(missing.status_code, 404)
class InvoicesContractTest(ContractTestCase):
def test_list_invoices_returns_seeded(self):
resp = self.get("/api/invoices")
self.assertEqual(resp.status_code, 200)
invoices = resp.json()
self.assertGreaterEqual(len(invoices), 3)
numbers = {i["number"] for i in invoices}
self.assertIn("DEMO-2026-001", numbers)
def test_invoice_detail_has_customer_and_lines(self):
invoices = self.get("/api/invoices").json()
first = invoices[0]
detail = self.get(f"/api/invoices/{first['id']}")
self.assertEqual(detail.status_code, 200)
body = detail.json()
self.assertIn("customer", body)
self.assertIn("line_items", body)
self.assertIn("payments", body)