remove dated examples
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -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
17
cfg/.gitignore
vendored
@@ -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.
|
|
||||||
128
cfg/README.md
128
cfg/README.md
@@ -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"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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()],
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"name": "google",
|
|
||||||
"slug": "google",
|
|
||||||
"title": "Google",
|
|
||||||
"status": "live",
|
|
||||||
"system": "artery",
|
|
||||||
"description": "Google OAuth login + Drive/Calendar/Gmail APIs."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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]}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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")
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
fastapi==0.115.0
|
|
||||||
uvicorn[standard]==0.32.0
|
|
||||||
sqlalchemy==2.0.36
|
|
||||||
psycopg2-binary==2.9.10
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
6
examples/.gitignore
vendored
@@ -1,6 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
.vite/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.venv/
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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"])
|
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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:
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
FROM node:20-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 5173
|
|
||||||
CMD ["npm", "run", "dev"]
|
|
||||||
@@ -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>
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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" }),
|
|
||||||
};
|
|
||||||
@@ -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");
|
|
||||||
@@ -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 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -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; }
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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://.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user