Compare commits

3 Commits

Author SHA1 Message Date
859256b68d remove dated examples 2026-10-07 16:47:42 -03:00
ec31abecea Merge branch 'rig-work' 2026-09-30 10:12:35 -03:00
c669662088 updated make docs 2026-09-30 10:12:32 -03:00
81 changed files with 53 additions and 2564 deletions

6
.gitignore vendored
View File

@@ -28,12 +28,10 @@ gen/
# browser, not source — and often a client's real data. # browser, not source — and often a client's real data.
soleprint/station/tools/shuntgen/uploads/ soleprint/station/tools/shuntgen/uploads/
# Room configurations (separate repo - contains credentials and room-specific data) # Client room configurations. They carry credentials and a client's data, and were
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms # moved out of cfg/ (2026-10-07); still ignored so a restored copy is never committed.
cfg/amar/ cfg/amar/
cfg/dlt/ cfg/dlt/
# Add new rooms here as they are created
# cfg/<room>/
# Client rigs. A rig is a copy of rig/ renamed after the environment it models, # Client rigs. A rig is a copy of rig/ renamed after the environment it models,
# so its k8s files spell out a real architecture — exactly the thing that must # so its k8s files spell out a real architecture — exactly the thing that must

View File

@@ -29,8 +29,8 @@ export PYTHON
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),) ifneq ($(ARGS),)
# Declares each extra word as a target that does nothing: `:` is an empty rule # Declares each extra word as a target that does nothing: `:` is an empty rule
# body and `@` silences it. Without this, `make build sample` runs the build and # body and `@` silences it. Without this, `make build myroom` runs the build and
# then fails with "No rule to make target 'sample'", because make reads every # then fails with "No rule to make target 'myroom'", because make reads every
# word on the line as something it has been asked to build. # word on the line as something it has been asked to build.
$(eval $(ARGS):;@:) $(eval $(ARGS):;@:)
# ...and as PHONY, because some of those words name real directories. `cfg`, # ...and as PHONY, because some of those words name real directories. `cfg`,

17
cfg/.gitignore vendored
View File

@@ -1,17 +0,0 @@
# Environment files with credentials (use .env.example as template)
**/.env
# Database dumps (sensitive data)
**/dumps/*.sql
# Python
__pycache__/
*.pyc
*.pyo
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.

View File

@@ -1,125 +1,11 @@
# Soleprint Room Configurations # cfg
Private repository containing room-specific configurations for Soleprint instances. Room configurations for `build.py` (`make build [room]` → `gen/<room>/`).
## Structure - `standalone/`: the base room, built by default.
``` Rooms are a pre-rig way of running spr beside an app. Wrapping an inherited site,
cfg/ with spr's sidebar injected by nginx `sub_filter`, is meant to become a rig overlay.
├── amar/ # Amar managed room
│ ├── config.json
│ ├── data/
│ ├── soleprint/ # Soleprint customizations
│ ├── link/ # Bridge to managed app
│ └── ctrl/ # Build/run scripts
├── dlt/ # DLT placeholder room
└── README.md
```
## Setup The client rooms that lived here (with their own git repo, untracked by spr) were moved
out on 2026-10-07 and are kept as reference outside spr.
This repo lives inside the main soleprint repo at `cfg/`. The `standalone/` folder is tracked in the main soleprint repo as a sample.
### Fresh clone (new machine)
```bash
# Clone main soleprint repo
git clone <soleprint-repo-url> spr
cd spr
# Clone this cfg repo into cfg/ (standalone/ already exists from main repo)
git clone <this-cfg-repo-url> cfg-private
mv cfg-private/.git cfg/
mv cfg-private/* cfg/ 2>/dev/null
rm -rf cfg-private
# Now cfg/ has both:
# - standalone/ (from main soleprint repo)
# - amar/, dlt/ (from this cfg repo)
```
### Alternative: Separate directories
```bash
# Keep repos separate, use --cfg-path
git clone <soleprint-repo-url> spr
git clone <this-cfg-repo-url> spr-cfg
# Build with external cfg path
cd spr
python build.py --cfg amar --cfg-path ../spr-cfg
```
## Building
From the main soleprint repo:
```bash
python build.py # Build standalone (sample)
python build.py --cfg amar # Build amar room
python build.py --cfg dlt # Build dlt room
```
## Deploy/Sync Workflow
### Local Development
```bash
# 1. Build the room
python build.py --cfg amar
# 2. Start with Docker
cd gen/amar/soleprint && docker compose up -d
# 3. For managed rooms, also start the app
cd gen/amar/amar && docker compose up -d
```
### Production Deploy
```bash
# On deploy server:
# 1. Pull both repos
cd /opt/spr && git pull
cd /opt/spr/cfg && git pull
# 2. Rebuild
python build.py --cfg amar
# 3. Restart services
cd gen/amar/soleprint && docker compose up -d --build
```
### CI/CD Pipeline (example)
```yaml
# Deploy soleprint changes
deploy-soleprint:
script:
- ssh deploy@server "cd /opt/spr && git pull && python build.py --cfg amar"
- ssh deploy@server "cd /opt/spr/gen/amar/soleprint && docker compose up -d --build"
# Deploy cfg changes (private repo)
deploy-cfg:
script:
- ssh deploy@server "cd /opt/spr/cfg && git pull && python ../build.py --cfg amar"
- ssh deploy@server "cd /opt/spr/gen/amar/soleprint && docker compose up -d --build"
```
## Credentials
- `.env` files are gitignored - copy from `.env.example`
- Never commit actual credentials
- Database dumps in `*/dumps/` are also gitignored
## Adding a New Room
```bash
# From cfg/ directory
mkdir -p newroom/data newroom/soleprint
cp ../cfg/standalone/config.json newroom/ # Use standalone as template
# Edit config.json for your room
# Add room-specific customizations in newroom/soleprint/
git add newroom && git commit -m "Add newroom configuration"
```

View File

@@ -1,150 +0,0 @@
{
"framework": {
"name": "soleprint",
"slug": "soleprint",
"version": "0.1.0",
"description": "Development workflow and documentation system",
"tagline": "Mapping development footprints",
"icon": "",
"hub_port": 12030
},
"auth": {
"enabled": true,
"provider": "google",
"allowed_domains": [],
"allowed_emails": [],
"session_secret": "ENV:AUTH_SESSION_SECRET"
},
"veins": ["google"],
"managed": {
"name": "sample",
"repos": {
"backend": "examples/fixture-invoicing/backend",
"frontend": "examples/fixture-invoicing/frontend"
}
},
"k8s": {
"enabled": true
},
"systems": [
{
"key": "data_flow",
"name": "artery",
"slug": "artery",
"title": "Artery",
"tagline": "Todo lo vital",
"icon": ""
},
{
"key": "documentation",
"name": "atlas",
"slug": "atlas",
"title": "Atlas",
"tagline": "Documentacion accionable",
"icon": ""
},
{
"key": "execution",
"name": "station",
"slug": "station",
"title": "Station",
"tagline": "Monitores, Entornos y Herramientas",
"icon": ""
}
],
"components": {
"shared": {
"config": {
"name": "room",
"title": "Room",
"description": "Runtime environment configuration",
"plural": "rooms"
},
"data": {
"name": "depot",
"title": "Depot",
"description": "Data storage / provisions",
"plural": "depots"
}
},
"data_flow": {
"connector": {
"name": "vein",
"title": "Vein",
"description": "Stateless API connector",
"plural": "veins"
},
"mock": {
"name": "shunt",
"title": "Shunt",
"description": "Fake connector for testing",
"plural": "shunts"
},
"composed": {
"name": "pulse",
"title": "Pulse",
"description": "Composed data flow",
"plural": "pulses",
"formula": "Vein + Room + Depot"
},
"app": {
"name": "plexus",
"title": "Plexus",
"description": "Full app with backend, frontend and DB",
"plural": "plexus"
}
},
"documentation": {
"pattern": {
"name": "template",
"title": "Template",
"description": "Documentation pattern",
"plural": "templates"
},
"library": {
"name": "book",
"title": "Book",
"description": "Documentation library"
},
"composed": {
"name": "book",
"title": "Book",
"description": "Composed documentation",
"plural": "books",
"formula": "Template + Depot"
}
},
"execution": {
"utility": {
"name": "tool",
"title": "Tool",
"description": "Execution utility",
"plural": "tools"
},
"watcher": {
"name": "monitor",
"title": "Monitor",
"description": "Service monitor",
"plural": "monitors"
},
"container": {
"name": "cabinet",
"title": "Cabinet",
"description": "Tool container",
"plural": "cabinets"
},
"workspace": {
"name": "desk",
"title": "Desk",
"description": "Execution workspace"
},
"composed": {
"name": "desk",
"title": "Desk",
"description": "Composed execution bundle",
"plural": "desks",
"formula": "Cabinet + Room + Depots"
}
}
}
}

View File

@@ -1,62 +0,0 @@
#!/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,45 +0,0 @@
#!/bin/bash
# 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=""
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
# 3) Soleprint (+ nginx sidebar injection)
(cd "$SPR_DIR" && docker compose -f docker-compose.yml $NGINX_OVERRIDE up $DETACH $BUILD)

View File

@@ -1,15 +0,0 @@
#!/bin/bash
# Stop sample room
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Stopping sample ==="
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

View File

@@ -1,156 +0,0 @@
"""
Soleprint 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
{
"items": [
{
"name": "datagen",
"slug": "datagen",
"title": "Datagen",
"status": "live",
"system": "station",
"type": "app",
"description": "Generate realistic test data via faker-backed generators.",
"path": "station/tools/datagen",
"url": "/station/tools/datagen/"
},
{
"name": "graphgen",
"slug": "graphgen",
"title": "Graphgen",
"status": "live",
"system": "station",
"type": "app",
"description": "Render the data model as an interactive graph (entities + FK edges).",
"path": "station/tools/graphgen",
"url": "/station/tools/graphgen/"
},
{
"name": "modelgen",
"slug": "modelgen",
"title": "Modelgen",
"status": "live",
"system": "station",
"type": "cli",
"description": "Generate platform-specific models (Pydantic, Django, Prisma) from JSON Schema.",
"path": "station/tools/modelgen",
"cli": "python -m modelgen"
}
]
}

View File

@@ -1,12 +0,0 @@
{
"items": [
{
"name": "google",
"slug": "google",
"title": "Google",
"status": "live",
"system": "artery",
"description": "Google OAuth login + Drive/Calendar/Gmail APIs."
}
]
}

View File

@@ -1,74 +0,0 @@
# 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

@@ -1,15 +0,0 @@
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

@@ -1,43 +0,0 @@
"""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

@@ -1,24 +0,0 @@
# 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}

View File

@@ -1,19 +0,0 @@
"""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

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

View File

@@ -1,14 +0,0 @@
# =============================================================================
# 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

@@ -1,23 +0,0 @@
# =============================================================================
# Sample Soleprint Configuration — copy to .env and fill in
# =============================================================================
DEPLOYMENT_NAME=sample_spr
NETWORK_NAME=sample_network
# Ports (unique per room)
SOLEPRINT_PORT=12030
NGINX_PORT=8130
# =============================================================================
# Google OAuth — get values from console.cloud.google.com
# =============================================================================
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URI=http://sample.spr.local.ar/artery/google/oauth/callback
# =============================================================================
# Auth
# =============================================================================
AUTH_BYPASS=true
AUTH_SESSION_SECRET=change-me-to-a-long-random-string

View File

@@ -1,35 +0,0 @@
# Nginx Reverse Proxy for Docker-based Local Development
#
# Usage:
# # Start all services including nginx:
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
#
# # Or start nginx separately after other services:
# docker compose -f docker-compose.nginx.yml up -d
#
# Routes:
# - sample.spr.local.ar -> frontend with sidebar injection
# - sample.local.ar -> frontend without sidebar
#
# Note: Requires /etc/hosts entries:
# 127.0.0.1 sample.spr.local.ar sample.local.ar
name: ${DEPLOYMENT_NAME}_nginx
services:
nginx:
image: nginx:alpine
container_name: ${DEPLOYMENT_NAME}_nginx
ports:
- "${NGINX_PORT:-80}:80"
volumes:
- ./nginx/local.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- default
depends_on:
- soleprint
restart: unless-stopped
networks:
default:
name: ${NETWORK_NAME}

View File

@@ -1,37 +0,0 @@
# Soleprint Services - Docker Compose
#
# Runs soleprint hub as a single service
# Artery, atlas, station are accessed via path-based routing
#
# Usage:
# cd gen/<room>/soleprint && docker compose up -d
name: ${DEPLOYMENT_NAME}
services:
soleprint:
build:
context: .
dockerfile: Dockerfile
container_name: ${DEPLOYMENT_NAME}
user: "${UID:-1000}:${GID:-1000}"
volumes:
- .:/app
ports:
- "${SOLEPRINT_PORT}:8000"
env_file:
- .env
environment:
# For single-port mode, all subsystems are internal routes
- ARTERY_EXTERNAL_URL=/artery
- ATLAS_EXTERNAL_URL=/atlas
- STATION_EXTERNAL_URL=/station
networks:
- default
# Use run.py for single-port bare-metal mode
command: uvicorn run:app --host 0.0.0.0 --port 8000 --reload
networks:
default:
external: true
name: ${NETWORK_NAME}

View File

@@ -1,78 +0,0 @@
# Sample Room — Nginx Config
# Uses Docker DNS resolver so upstreams resolve at request time
# (lets nginx start before backend/frontend containers are up).
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;
set $soleprint sample_spr;
set $backend sample_backend;
set $frontend sample_frontend;
# Soleprint routes (sidebar API + static assets)
location /spr/ {
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;
}
# 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 (Vite dev server on 5173) with sidebar injection
location / {
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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Accept-Encoding "";
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
sub_filter_once off;
}
}
# sample.local.ar — direct fixture access (no sidebar)
server {
listen 80;
server_name sample.local.ar;
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: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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -1,60 +0,0 @@
{
"$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

@@ -1,57 +0,0 @@
{
"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

@@ -1,124 +0,0 @@
"""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

@@ -1,44 +0,0 @@
{
"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

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

View File

@@ -1,52 +0,0 @@
"""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)

View File

@@ -3,8 +3,8 @@
# #
# Usage: # Usage:
# ./ctrl/start.sh # standalone, foreground # ./ctrl/start.sh # standalone, foreground
# ./ctrl/start.sh amar -d # amar, detached # ./ctrl/start.sh myroom -d # a named room, detached
# ./ctrl/start.sh sample --build # flags pass straight through # ./ctrl/start.sh myroom --build # flags pass straight through
# #
# Every room ships its own start script (gen/<room>/ctrl/start.sh) and stays # Every room ships its own start script (gen/<room>/ctrl/start.sh) and stays
# runnable on its own — this only saves cd'ing there and picks the default room. # runnable on its own — this only saves cd'ing there and picks the default room.

View File

@@ -10,7 +10,6 @@ The logic lives in the scripts, never in the Makefile.
```bash ```bash
make build # cfg/standalone -> gen/standalone make build # cfg/standalone -> gen/standalone
make build sample # cfg/sample -> gen/sample
make start # run it make start # run it
``` ```
@@ -27,8 +26,8 @@ make start # run it
| `make component [list\|publish\|sync\|watch\|diff]` | `ctrl/spr.py` | publish a distributable component | | `make component [list\|publish\|sync\|watch\|diff]` | `ctrl/spr.py` | publish a distributable component |
| `make deploy` | `ctrl/deploy.sh` | rsync `gen/standalone` to the server and restart | | `make deploy` | `ctrl/deploy.sh` | rsync `gen/standalone` to the server and restart |
Bare words pass straight through, so `make build sample` becomes Bare words pass straight through, so `make build myroom` becomes
`ctrl/build.sh sample`. Anything starting with a dash would be eaten by make `ctrl/build.sh myroom`. Anything starting with a dash would be eaten by make
itself, so those go through `ARGS`: itself, so those go through `ARGS`:
```bash ```bash

View File

@@ -59,10 +59,10 @@ Open [http://localhost:9000](http://localhost:9000) and configure your room from
Start from a sample configuration: Start from a sample configuration:
```bash ```bash
python -m init.cli myroom --from sample python -m init.cli myroom --from standalone
``` ```
This copies the sample room's config as your starting point. This copies the standalone room's config as your starting point.
## Rebuild ## Rebuild

View File

@@ -10,7 +10,7 @@ python -m init.cli myroom
python -m init.web python -m init.web
# Clone from existing room # Clone from existing room
python -m init.cli myroom --from sample python -m init.cli myroom --from standalone
``` ```
## Layers ## Layers

View File

@@ -28,16 +28,16 @@ files:
Then build. The compose merge is a step in [Export / Compile](#export): Then build. The compose merge is a step in [Export / Compile](#export):
```bash ```bash
python build.py --cfg sample python build.py --cfg <room>
cd gen/sample && docker compose up -d cd gen/<room> && docker compose up -d
``` ```
`build.py` merges each cabinet's compose fragment into the room's `build.py` merges each cabinet's compose fragment into the room's
`docker-compose.yml`, declares its named volumes, and appends its settings to `docker-compose.yml`, declares its named volumes, and appends its settings to
`.env.example` — never to `.env`. `.env.example` — never to `.env`.
**A service the room already declares wins.** `cfg/amar/docker-compose.yml` **A service the room already declares wins.** A room whose `docker-compose.yml`
ships its own `db`; switching the postgres cabinet on will not replace it. The ships its own `db`: switching the postgres cabinet on will not replace it. The
build says so when it skips one: build says so when it skips one:
``` ```

6
examples/.gitignore vendored
View File

@@ -1,6 +0,0 @@
node_modules/
dist/
.vite/
__pycache__/
*.pyc
.venv/

View File

@@ -1,46 +0,0 @@
# Fixture Invoicing
> **⚠ THIS IS A FIXTURE, NOT A PRODUCT.**
> A deliberately minimal invoicing app used as a test surface for the
> [Soleprint](../../README.md) framework. Seed data is obviously placeholder
> ("Acme Widget Co.", "Test Customer 001"). Do not mistake this for real
> invoicing software.
## What's here
- `backend/` — FastAPI + SQLAlchemy against Postgres
- `frontend/` — Vue 3 + Vite, three pages (customers, invoices, invoice detail)
- `docker-compose.yml` — backend + frontend + postgres as three containers
## Entities
```
Customer ──< Invoice ──< LineItem
└──────< Payment
```
- `Customer` — name, email
- `Invoice` — number, customer_id (FK), issued_at, due_at, status
- `LineItem` — invoice_id (FK), description, quantity, unit_price
- `Payment` — invoice_id (FK), amount, method, paid_at
## Run it standalone
```bash
cd examples/fixture-invoicing
docker compose up --build
```
- Backend: http://localhost:8100
- Frontend: http://localhost:3100
- Postgres: localhost:5532 (user `postgres`, password `fixture`, db `fixture`)
## Run it wrapped by soleprint
The `cfg/sample/` room wraps this fixture. See the main soleprint docs.
```bash
cd ../.. # back to repo root
python build.py --cfg sample
cd gen/sample && ./ctrl/start.sh
```

View File

@@ -1,15 +0,0 @@
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

@@ -1,53 +0,0 @@
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()

View File

@@ -1,71 +0,0 @@
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()

View File

@@ -1,40 +0,0 @@
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()

View File

@@ -1,49 +0,0 @@
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()

View File

@@ -1,29 +0,0 @@
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()

View File

@@ -1,52 +0,0 @@
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"])

View File

@@ -1,63 +0,0 @@
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")

View File

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

View File

@@ -1,58 +0,0 @@
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

View File

@@ -1,64 +0,0 @@
"""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()

View File

@@ -1,54 +0,0 @@
name: fixture_invoicing
services:
db:
image: postgres:16-alpine
container_name: fixture_db
environment:
POSTGRES_DB: fixture
POSTGRES_USER: postgres
POSTGRES_PASSWORD: fixture
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5532:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d fixture"]
interval: 5s
timeout: 3s
retries: 10
backend:
build:
context: ./backend
container_name: fixture_backend
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: fixture
DB_USER: postgres
DB_PASSWORD: fixture
SEED_ON_START: "true"
ports:
- "8100:8000"
depends_on:
db:
condition: service_healthy
frontend:
build:
context: ./frontend
container_name: fixture_frontend
environment:
VITE_API_URL: http://localhost:8100
ports:
- "3100:5173"
depends_on:
- backend
volumes:
- ./frontend/src:/app/src
- ./frontend/index.html:/app/index.html
- ./frontend/vite.config.js:/app/vite.config.js
volumes:
pgdata:

View File

@@ -1,11 +0,0 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fixture Invoicing — Soleprint Demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -1,19 +0,0 @@
{
"name": "fixture-invoicing-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"vue": "^3.5.12",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^5.4.11"
}
}

View File

@@ -1,17 +0,0 @@
<template>
<div class="fixture-banner">
⚠ FIXTURE APP — SOLEPRINT DEMO — NOT A REAL PRODUCT ⚠
</div>
<header class="app-nav">
<h1>Fixture Invoicing</h1>
<nav>
<router-link to="/customers">Customers</router-link>
<router-link to="/invoices">Invoices</router-link>
</nav>
</header>
<main class="app-main">
<router-view />
</main>
</template>

View File

@@ -1,42 +0,0 @@
const apiBase = import.meta.env.VITE_API_URL || "";
async function request(path, options = {}) {
const resp = await fetch(`${apiBase}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
if (resp.status === 204) return null;
return resp.json();
}
export const api = {
listCustomers: () => request("/api/customers"),
createCustomer: (data) =>
request("/api/customers", { method: "POST", body: JSON.stringify(data) }),
deleteCustomer: (id) => request(`/api/customers/${id}`, { method: "DELETE" }),
listInvoices: (params = {}) => {
const qs = new URLSearchParams(params).toString();
return request(`/api/invoices${qs ? "?" + qs : ""}`);
},
getInvoice: (id) => request(`/api/invoices/${id}`),
createInvoice: (data) =>
request("/api/invoices", { method: "POST", body: JSON.stringify(data) }),
deleteInvoice: (id) => request(`/api/invoices/${id}`, { method: "DELETE" }),
addLineItem: (invoiceId, data) =>
request(`/api/line-items/invoices/${invoiceId}`, {
method: "POST",
body: JSON.stringify(data),
}),
deleteLineItem: (id) =>
request(`/api/line-items/${id}`, { method: "DELETE" }),
recordPayment: (invoiceId, data) =>
request(`/api/payments/invoices/${invoiceId}`, {
method: "POST",
body: JSON.stringify(data),
}),
deletePayment: (id) => request(`/api/payments/${id}`, { method: "DELETE" }),
};

View File

@@ -1,6 +0,0 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router.js";
import "./style.css";
createApp(App).use(router).mount("#app");

View File

@@ -1,15 +0,0 @@
import { createRouter, createWebHistory } from "vue-router";
import Customers from "./views/Customers.vue";
import Invoices from "./views/Invoices.vue";
import InvoiceDetail from "./views/InvoiceDetail.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/invoices" },
{ path: "/customers", component: Customers },
{ path: "/invoices", component: Invoices },
{ path: "/invoices/:id", component: InvoiceDetail, props: true },
],
});

View File

@@ -1,119 +0,0 @@
:root {
--bg: #0d1b2a;
--panel: #152438;
--panel-2: #1b2e45;
--text: #e5e5e5;
--muted: #8ca0b4;
--accent: #6fc3df;
--warn: #ffb454;
--danger: #ff6b6b;
--good: #7dd87d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
#app { min-height: 100vh; display: flex; flex-direction: column; }
.fixture-banner {
background: repeating-linear-gradient(
45deg, #3a1f00, #3a1f00 10px, #4a2800 10px, #4a2800 20px
);
color: var(--warn);
text-align: center;
padding: 8px;
font-weight: 700;
letter-spacing: 2px;
font-size: 12px;
border-bottom: 2px solid var(--warn);
}
header.app-nav {
background: var(--panel);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 24px;
border-bottom: 1px solid #223;
}
header.app-nav h1 {
font-size: 18px;
color: var(--accent);
}
header.app-nav nav a {
color: var(--muted);
text-decoration: none;
margin-right: 16px;
padding: 4px 8px;
border-radius: 4px;
}
header.app-nav nav a.router-link-active {
color: var(--text);
background: var(--panel-2);
}
main.app-main {
flex: 1;
padding: 24px;
max-width: 1100px;
width: 100%;
margin: 0 auto;
}
h2 { color: var(--accent); margin-bottom: 16px; font-size: 22px; }
h3 { color: var(--text); margin: 16px 0 8px; font-size: 16px; }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border-radius: 6px;
overflow: hidden;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #223;
}
th { background: var(--panel-2); color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; }
tr:last-child td { border-bottom: none; }
.status { padding: 2px 8px; border-radius: 10px; font-size: 11px; text-transform: uppercase; }
.status.draft { background: #334; color: var(--muted); }
.status.sent { background: #3a2c10; color: var(--warn); }
.status.paid { background: #1e3a1e; color: var(--good); }
.status.void { background: #3a1e1e; color: var(--danger); }
.card {
background: var(--panel);
padding: 16px;
border-radius: 6px;
margin-bottom: 16px;
}
.muted { color: var(--muted); font-size: 13px; }
button, .btn {
background: var(--accent);
color: #0d1b2a;
border: none;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
}
button.ghost { background: transparent; color: var(--muted); border: 1px solid #334; }
a.link { color: var(--accent); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.actions { margin-top: 12px; display: flex; gap: 8px; }

View File

@@ -1,71 +0,0 @@
<template>
<h2>Customers</h2>
<div class="card">
<h3>Add customer</h3>
<div class="actions">
<input v-model="draft.name" placeholder="Name" />
<input v-model="draft.email" placeholder="Email" />
<button @click="add" :disabled="!draft.name || !draft.email">Add</button>
</div>
</div>
<table v-if="customers.length">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in customers" :key="c.id">
<td>{{ c.id }}</td>
<td>{{ c.name }}</td>
<td>{{ c.email }}</td>
<td>
<button class="ghost" @click="remove(c.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="muted">No customers yet.</p>
</template>
<script setup>
import { ref, onMounted, reactive } from "vue";
import { api } from "../api.js";
const customers = ref([]);
const draft = reactive({ name: "", email: "" });
async function load() {
customers.value = await api.listCustomers();
}
async function add() {
await api.createCustomer({ name: draft.name, email: draft.email });
draft.name = "";
draft.email = "";
await load();
}
async function remove(id) {
await api.deleteCustomer(id);
await load();
}
onMounted(load);
</script>
<style scoped>
.actions input {
padding: 8px 10px;
border: 1px solid #334;
border-radius: 4px;
background: #0d1b2a;
color: var(--text);
min-width: 180px;
}
</style>

View File

@@ -1,148 +0,0 @@
<template>
<div v-if="invoice">
<h2>
Invoice {{ invoice.number }}
<span :class="['status', invoice.status]">{{ invoice.status }}</span>
</h2>
<p class="muted">
Customer: <strong>{{ invoice.customer.name }}</strong> ({{ invoice.customer.email }})
· Issued {{ invoice.issued_at?.slice(0, 10) }}
<span v-if="invoice.due_at"> · Due {{ invoice.due_at.slice(0, 10) }}</span>
</p>
<div class="card">
<h3>Line items</h3>
<table v-if="invoice.line_items.length">
<thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th>Unit price</th>
<th>Total</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="li in invoice.line_items" :key="li.id">
<td>{{ li.description }}</td>
<td>{{ li.quantity }}</td>
<td>${{ li.unit_price }}</td>
<td>${{ (li.quantity * li.unit_price).toFixed(2) }}</td>
<td><button class="ghost" @click="removeLine(li.id)">Remove</button></td>
</tr>
</tbody>
</table>
<p v-else class="muted">No line items.</p>
<div class="actions" style="margin-top: 12px;">
<input v-model="lineDraft.description" placeholder="Description" />
<input type="number" v-model.number="lineDraft.quantity" style="width: 80px;" />
<input type="number" step="0.01" v-model.number="lineDraft.unit_price" style="width: 120px;" />
<button @click="addLine" :disabled="!lineDraft.description">Add line</button>
</div>
</div>
<div class="card">
<h3>Payments</h3>
<table v-if="invoice.payments.length">
<thead>
<tr>
<th>Amount</th>
<th>Method</th>
<th>Paid at</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in invoice.payments" :key="p.id">
<td>${{ p.amount }}</td>
<td>{{ p.method }}</td>
<td>{{ p.paid_at?.slice(0, 10) }}</td>
<td><button class="ghost" @click="removePayment(p.id)">Remove</button></td>
</tr>
</tbody>
</table>
<p v-else class="muted">No payments.</p>
<div class="actions" style="margin-top: 12px;">
<input type="number" step="0.01" v-model.number="payDraft.amount" placeholder="Amount" />
<select v-model="payDraft.method">
<option value="cash">cash</option>
<option value="card">card</option>
<option value="transfer">transfer</option>
</select>
<button @click="addPayment" :disabled="!payDraft.amount">Record payment</button>
</div>
</div>
<p class="muted">
Total billed: <strong>${{ totalBilled.toFixed(2) }}</strong>
· Total paid: <strong>${{ totalPaid.toFixed(2) }}</strong>
· Balance: <strong>${{ (totalBilled - totalPaid).toFixed(2) }}</strong>
</p>
</div>
<p v-else class="muted">Loading…</p>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from "vue";
import { api } from "../api.js";
const props = defineProps({ id: { type: [String, Number], required: true } });
const invoice = ref(null);
const lineDraft = reactive({ description: "", quantity: 1, unit_price: 0 });
const payDraft = reactive({ amount: 0, method: "cash" });
const totalBilled = computed(() =>
(invoice.value?.line_items || []).reduce(
(sum, li) => sum + Number(li.quantity) * Number(li.unit_price), 0
)
);
const totalPaid = computed(() =>
(invoice.value?.payments || []).reduce(
(sum, p) => sum + Number(p.amount), 0
)
);
async function load() {
invoice.value = await api.getInvoice(props.id);
}
async function addLine() {
await api.addLineItem(props.id, { ...lineDraft });
lineDraft.description = "";
lineDraft.quantity = 1;
lineDraft.unit_price = 0;
await load();
}
async function removeLine(id) {
await api.deleteLineItem(id);
await load();
}
async function addPayment() {
await api.recordPayment(props.id, { ...payDraft });
payDraft.amount = 0;
await load();
}
async function removePayment(id) {
await api.deletePayment(id);
await load();
}
watch(() => props.id, load);
onMounted(load);
</script>
<style scoped>
.actions input, .actions select {
padding: 8px 10px;
border: 1px solid #334;
border-radius: 4px;
background: #0d1b2a;
color: var(--text);
}
</style>

View File

@@ -1,93 +0,0 @@
<template>
<h2>Invoices</h2>
<div class="card">
<h3>Create invoice</h3>
<div class="actions">
<input v-model="draft.number" placeholder="Number (e.g. DEMO-2026-004)" />
<select v-model="draft.customer_id">
<option :value="null" disabled>Customer…</option>
<option v-for="c in customers" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
<button @click="add" :disabled="!draft.number || !draft.customer_id">
Create
</button>
</div>
</div>
<table v-if="invoices.length">
<thead>
<tr>
<th>#</th>
<th>Number</th>
<th>Customer</th>
<th>Issued</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invoices" :key="inv.id">
<td>{{ inv.id }}</td>
<td>
<router-link :to="`/invoices/${inv.id}`" class="link">
{{ inv.number }}
</router-link>
</td>
<td>{{ customerName(inv.customer_id) }}</td>
<td>{{ inv.issued_at?.slice(0, 10) }}</td>
<td><span :class="['status', inv.status]">{{ inv.status }}</span></td>
</tr>
</tbody>
</table>
<p v-else class="muted">No invoices yet.</p>
</template>
<script setup>
import { ref, onMounted, reactive, computed } from "vue";
import { api } from "../api.js";
const invoices = ref([]);
const customers = ref([]);
const draft = reactive({ number: "", customer_id: null });
const customerById = computed(() =>
Object.fromEntries(customers.value.map((c) => [c.id, c]))
);
function customerName(id) {
return customerById.value[id]?.name || `#${id}`;
}
async function load() {
[invoices.value, customers.value] = await Promise.all([
api.listInvoices(),
api.listCustomers(),
]);
}
async function add() {
await api.createInvoice({
number: draft.number,
customer_id: draft.customer_id,
status: "draft",
});
draft.number = "";
draft.customer_id = null;
await load();
}
onMounted(load);
</script>
<style scoped>
.actions input, .actions select {
padding: 8px 10px;
border: 1px solid #334;
border-radius: 4px;
background: #0d1b2a;
color: var(--text);
min-width: 200px;
}
</style>

View File

@@ -1,12 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
host: "0.0.0.0",
port: 5173,
allowedHosts: true,
watch: { usePolling: true },
},
});

View File

@@ -7,7 +7,7 @@ generating cfg/<room>/ for a new soleprint room.
Usage: Usage:
python -m init.cli myroom # Interactive wizard python -m init.cli myroom # Interactive wizard
python -m init.cli myroom --from sample # Clone existing room as variant python -m init.cli myroom --from standalone # Clone existing room as variant
""" """
import argparse import argparse

View File

@@ -1 +1 @@
0.1.2 0.1.3

View File

@@ -22,17 +22,26 @@ serve() {
fi fi
# One worker and no access log: a few static pages need no more, and the # One worker and no access log: a few static pages need no more, and the
# terminal then shows only real errors. The config is a temp file, gone on exit. # terminal then shows only real errors. The config is a temp file, gone on exit.
# nginx runs as the caller, not as its own user: it reads what the caller can read,
# whatever the umask (a 027 one leaves files unreadable to "others", which is a 403).
# So no port below 1024 inside, and pid and temp files go to /tmp.
# Global, not local: the trap runs after serve() has returned. # Global, not local: the trap runs after serve() has returned.
DOCS_CONF=$(mktemp) DOCS_CONF=$(mktemp)
trap 'rm -f "$DOCS_CONF"' EXIT trap 'rm -f "$DOCS_CONF"' EXIT
cat > "$DOCS_CONF" <<'NGINX' cat > "$DOCS_CONF" <<'NGINX'
worker_processes 1; worker_processes 1;
error_log stderr error; error_log stderr error;
pid /tmp/nginx.pid;
events { worker_connections 64; } events { worker_connections 64; }
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
access_log off; access_log off;
server { listen 80; root /usr/share/nginx/html; } client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
server { listen 8080; root /usr/share/nginx/html; }
} }
NGINX NGINX
chmod 644 "$DOCS_CONF" chmod 644 "$DOCS_CONF"
@@ -41,11 +50,12 @@ NGINX
echo " (ctrl-c to stop; nothing is installed and nothing persists)" echo " (ctrl-c to stop; nothing is installed and nothing persists)"
docker run --rm \ docker run --rm \
--name "${CLUSTER}-docs" \ --name "${CLUSTER}-docs" \
-e NGINX_ENTRYPOINT_QUIET_LOGS=1 \ --user "$(id -u):$(id -g)" \
-p "${DOCS_PORT}:80" \ --entrypoint nginx \
-p "${DOCS_PORT}:8080" \
-v "$DOCS:/usr/share/nginx/html:ro" \ -v "$DOCS:/usr/share/nginx/html:ro" \
-v "$DOCS_CONF:/etc/nginx/nginx.conf:ro" \ -v "$DOCS_CONF:/etc/nginx/nginx.conf:ro" \
nginx:alpine nginx:alpine -g 'daemon off;'
} }
graphs() { graphs() {

View File

@@ -8,6 +8,12 @@ out `python -m http.server` too — a minimal Debian has no python3. What it doe
have, by definition, is Docker: the single prerequisite rig already demands. So a have, by definition, is Docker: the single prerequisite rig already demands. So a
throwaway nginx container serves a read-only bind mount. throwaway nginx container serves a read-only bind mount.
nginx runs as the caller (`--user`), not as its own `nginx` user. A machine whose umask is
027 creates files readable by their owner and group only, and nginx's own user would get a
403 on every page. As the caller it reads whatever the caller can, so nobody has to chmod
their docs. Unprivileged, it listens on 8080 inside the container and keeps its pid and temp
files in /tmp.
It runs with a config of its own: one worker (nginx's `auto` starts one per core, It runs with a config of its own: one worker (nginx's `auto` starts one per core,
twelve on an ordinary laptop, for a handful of static pages), no access log and a twelve on an ordinary laptop, for a handful of static pages), no access log and a
quiet entrypoint, so the terminal shows the URL and nothing else unless something quiet entrypoint, so the terminal shows the URL and nothing else unless something

View File

@@ -759,7 +759,7 @@ python3 -m docgen view ir.json --shape <span class="c"># what will this
<a href="viewer.html?src=img/erd.svg"> <a href="viewer.html?src=img/erd.svg">
<img src="img/erd.svg" alt="an entity-relationship diagram"> <img src="img/erd.svg" alt="an entity-relationship diagram">
</a> </a>
<figcaption>A schema from the sample room. Same emitter, same style file as <figcaption>An invoicing schema. Same emitter, same style file as
every other diagram here.</figcaption> every other diagram here.</figcaption>
</figure> </figure>

View File

@@ -9,7 +9,7 @@ reading it means this extractor works for every source modelgen supports
(Django, SQLAlchemy, OpenAPI, CSV/ODS, a live database) without knowing about (Django, SQLAlchemy, OpenAPI, CSV/ODS, a live database) without knowing about
any of them. any of them.
python3 -m docgen extract db --schema cfg/sample/.../graphgen/schema.json python3 -m docgen extract db --schema path/to/schema.json
Connecting to a live database is **not here**. `modelgen from-db --url ...` Connecting to a live database is **not here**. `modelgen from-db --url ...`
reflects via SQLAlchemy's Inspector across dialects and writes the schema.json reflects via SQLAlchemy's Inspector across dialects and writes the schema.json

View File

@@ -11,7 +11,7 @@ so the background goes transparent and the text goes initial-black on a design
that assumed dark. Unstyled, not merely unbranded. that assumed dark. Unstyled, not merely unbranded.
That matters because `/theme.css` is an absolute path and soleprint is not That matters because `/theme.css` is an absolute path and soleprint is not
always at the root. In the sample room's nginx, soleprint sits under `/spr/` always at the root. In a wrapped site's nginx, soleprint sits under `/spr/`
while `location /` proxies to the frontend, so `/theme.css` reaches the wrong while `location /` proxies to the frontend, so `/theme.css` reaches the wrong
service. Same story for a page opened over file://. service. Same story for a page opened over file://.

View File

@@ -35,12 +35,12 @@ Then build. `python build.py --cfg <room>` merges each cabinet's `service.yml`
into the room's `docker-compose.yml` and appends its settings to `.env.example`: into the room's `docker-compose.yml` and appends its settings to `.env.example`:
```bash ```bash
python build.py --cfg sample python build.py --cfg <room>
cd gen/sample && docker compose up -d cd gen/<room> && docker compose up -d
``` ```
**A service the room already declares wins.** `cfg/amar/docker-compose.yml` **A service the room already declares wins.** A room whose `docker-compose.yml`
ships its own `db`; switching on the postgres cabinet will not overwrite it. ships its own `db`: switching on the postgres cabinet will not overwrite it.
Build says so when it skips one. Build says so when it skips one.
## On a cluster ## On a cluster

View File

@@ -26,8 +26,7 @@ method name *is* the model name — there is no registry to update.
```python ```python
from faker import Faker from faker import Faker
# Guarded so the file also runs on its own, outside a built instance — # Guarded so the file also runs on its own, outside a built instance.
# see cfg/sample/soleprint/station/tools/datagen/fixture.py.
try: try:
from station.tools.datagen.base import BaseDataGenerator from station.tools.datagen.base import BaseDataGenerator
except ImportError: except ImportError:

View File

@@ -80,7 +80,7 @@ Select one via `POST /tools/tester/api/environment/select`, or set the env direc
WRITING ONE WRITING ONE
─────────── ───────────
The domain below is the in-repo invoicing fixture (cfg/sample) — deliberately The domain below is an invoicing example — deliberately
generic. Substitute your own; the shape is the part that transfers. generic. Substitute your own; the shape is the part that transfers.
from ..base import ContractTestCase from ..base import ContractTestCase