Compare commits
18 Commits
6e18324a43
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b4b87851 | |||
| b4081cff3e | |||
|
|
35796c0c3b | ||
|
|
0351e5c7a6 | ||
|
|
3df1465bf5 | ||
|
|
dcc5191ba3 | ||
|
|
220d3dc5a6 | ||
|
|
fa7bbe3953 | ||
|
|
ed1c8f6c96 | ||
|
|
5603979d5c | ||
|
|
2babd47835 | ||
|
|
027f73794d | ||
|
|
8c5deb74e8 | ||
|
|
dd47f9c66f | ||
|
|
e1f81889fc | ||
|
|
fecb978a5f | ||
|
|
cae5a913ca | ||
|
|
c4e702eae3 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -12,5 +12,9 @@ venv/
|
|||||||
# Generated runnable instance (entirely gitignored - regenerate with build.py)
|
# Generated runnable instance (entirely gitignored - regenerate with build.py)
|
||||||
gen/
|
gen/
|
||||||
|
|
||||||
# Database dumps (sensitive data)
|
# Room configurations (separate repo - contains credentials and room-specific data)
|
||||||
cfg/*/dumps/*.sql
|
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
|
||||||
|
cfg/amar/
|
||||||
|
cfg/dlt/
|
||||||
|
# Add new rooms here as they are created
|
||||||
|
# cfg/<room>/
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Soleprint Pipeline
|
|
||||||
|
|
||||||
when:
|
|
||||||
- event: push
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: notify
|
|
||||||
image: alpine
|
|
||||||
commands:
|
|
||||||
- echo "=== Soleprint ==="
|
|
||||||
- "echo Branch: $CI_COMMIT_BRANCH"
|
|
||||||
- "echo Commit: $CI_COMMIT_SHA"
|
|
||||||
- "echo Build locally: ./ctrl/deploy-domains.sh standalone --build"
|
|
||||||
61
build.py
61
build.py
@@ -26,7 +26,6 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -179,10 +178,25 @@ def build_managed(output_dir: Path, cfg_name: str, config: dict):
|
|||||||
):
|
):
|
||||||
copy_path(item, managed_dir / item.name)
|
copy_path(item, managed_dir / item.name)
|
||||||
|
|
||||||
# Scripts from ctrl/ -> managed/ctrl/
|
# Copy managed app config from cfg/<room>/<managed_name>/ (e.g., .env, dumps/)
|
||||||
|
room_managed_cfg = room_cfg / managed_name
|
||||||
|
if room_managed_cfg.exists():
|
||||||
|
log.info(f" Copying {managed_name} config...")
|
||||||
|
for item in room_managed_cfg.iterdir():
|
||||||
|
if item.is_file():
|
||||||
|
copy_path(item, managed_dir / item.name, quiet=True)
|
||||||
|
elif item.is_dir():
|
||||||
|
target = managed_dir / item.name
|
||||||
|
if target.exists():
|
||||||
|
# Merge into existing repo directory
|
||||||
|
merge_into(item, target)
|
||||||
|
else:
|
||||||
|
copy_path(item, target)
|
||||||
|
|
||||||
|
# Scripts from ctrl/ -> output_dir/ctrl/ (sibling of managed, link, soleprint)
|
||||||
room_ctrl = room_cfg / "ctrl"
|
room_ctrl = room_cfg / "ctrl"
|
||||||
if room_ctrl.exists():
|
if room_ctrl.exists():
|
||||||
ctrl_dir = managed_dir / "ctrl"
|
ctrl_dir = output_dir / "ctrl"
|
||||||
ensure_dir(ctrl_dir)
|
ensure_dir(ctrl_dir)
|
||||||
for item in room_ctrl.iterdir():
|
for item in room_ctrl.iterdir():
|
||||||
if item.is_file():
|
if item.is_file():
|
||||||
@@ -204,6 +218,8 @@ def build_link(output_dir: Path, cfg_name: str):
|
|||||||
|
|
||||||
def generate_models(output_dir: Path, room: str):
|
def generate_models(output_dir: Path, room: str):
|
||||||
"""Generate models using modelgen tool."""
|
"""Generate models using modelgen tool."""
|
||||||
|
from soleprint.station.tools.modelgen import ModelGenerator, load_config
|
||||||
|
|
||||||
config_path = SPR_ROOT / "cfg" / room / "config.json"
|
config_path = SPR_ROOT / "cfg" / room / "config.json"
|
||||||
|
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
@@ -213,21 +229,18 @@ def generate_models(output_dir: Path, room: str):
|
|||||||
models_file = output_dir / "models" / "pydantic" / "__init__.py"
|
models_file = output_dir / "models" / "pydantic" / "__init__.py"
|
||||||
models_file.parent.mkdir(parents=True, exist_ok=True)
|
models_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
cmd = [
|
try:
|
||||||
sys.executable,
|
config = load_config(config_path)
|
||||||
"-m",
|
generator = ModelGenerator(
|
||||||
"soleprint.station.tools.modelgen",
|
config=config,
|
||||||
"from-config",
|
output_path=models_file,
|
||||||
"--config",
|
output_format="pydantic",
|
||||||
str(config_path),
|
)
|
||||||
"--output",
|
generator.generate()
|
||||||
str(models_file),
|
return True
|
||||||
"--format",
|
except Exception as e:
|
||||||
"pydantic",
|
log.error(f"Model generation failed: {e}")
|
||||||
]
|
return False
|
||||||
|
|
||||||
result = subprocess.run(cmd, cwd=SPR_ROOT)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def copy_cfg(output_dir: Path, room: str):
|
def copy_cfg(output_dir: Path, room: str):
|
||||||
@@ -294,6 +307,10 @@ def build_soleprint(output_dir: Path, room: str):
|
|||||||
if source.exists():
|
if source.exists():
|
||||||
copy_path(source, output_dir / system)
|
copy_path(source, output_dir / system)
|
||||||
|
|
||||||
|
# Common modules (auth, etc)
|
||||||
|
if (soleprint / "common").exists():
|
||||||
|
copy_path(soleprint / "common", output_dir / "common")
|
||||||
|
|
||||||
# Room config (includes merging room-specific artery/atlas/station)
|
# Room config (includes merging room-specific artery/atlas/station)
|
||||||
copy_cfg(output_dir, room)
|
copy_cfg(output_dir, room)
|
||||||
|
|
||||||
@@ -303,13 +320,19 @@ def build_soleprint(output_dir: Path, room: str):
|
|||||||
log.warning("Model generation failed")
|
log.warning("Model generation failed")
|
||||||
|
|
||||||
|
|
||||||
def build(output_dir: Path, cfg_name: str | None = None):
|
def build(output_dir: Path, cfg_name: str | None = None, clean: bool = True):
|
||||||
"""Build complete room instance."""
|
"""Build complete room instance."""
|
||||||
room = cfg_name or "standalone"
|
room = cfg_name or "standalone"
|
||||||
config = load_config(cfg_name)
|
config = load_config(cfg_name)
|
||||||
managed = config.get("managed")
|
managed = config.get("managed")
|
||||||
|
|
||||||
log.info(f"\n=== Building {room} ===")
|
log.info(f"\n=== Building {room} ===")
|
||||||
|
|
||||||
|
# Clean output directory first
|
||||||
|
if clean and output_dir.exists():
|
||||||
|
log.info(f"Cleaning {output_dir}...")
|
||||||
|
shutil.rmtree(output_dir)
|
||||||
|
|
||||||
ensure_dir(output_dir)
|
ensure_dir(output_dir)
|
||||||
|
|
||||||
if managed:
|
if managed:
|
||||||
|
|||||||
15
cfg/.gitignore
vendored
Normal file
15
cfg/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Environment files with credentials (use .env.example as template)
|
||||||
|
**/.env
|
||||||
|
!sample/**/.env
|
||||||
|
|
||||||
|
# Database dumps (sensitive data)
|
||||||
|
**/dumps/*.sql
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# These are kept in main soleprint repo as templates
|
||||||
|
standalone/
|
||||||
|
sample/
|
||||||
125
cfg/README.md
Normal file
125
cfg/README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Soleprint Room Configurations
|
||||||
|
|
||||||
|
Private repository containing room-specific configurations for Soleprint instances.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cfg/
|
||||||
|
├── 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
|
||||||
|
|
||||||
|
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"
|
||||||
|
```
|
||||||
143
cfg/sample/config.json
Normal file
143
cfg/sample/config.json
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
cfg/sample/ctrl/start.sh
Executable file
28
cfg/sample/ctrl/start.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start all sample services
|
||||||
|
# Usage: ./ctrl/start.sh [-d]
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
DETACH=""
|
||||||
|
if [[ "$1" == "-d" ]]; then
|
||||||
|
DETACH="-d"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Starting sample services ==="
|
||||||
|
|
||||||
|
# Start soleprint
|
||||||
|
echo "Starting soleprint..."
|
||||||
|
cd "$ROOT_DIR/soleprint"
|
||||||
|
docker compose up $DETACH &
|
||||||
|
|
||||||
|
# Start sample app
|
||||||
|
if [[ -f "$ROOT_DIR/sample/docker-compose.yml" ]]; then
|
||||||
|
echo "Starting sample app..."
|
||||||
|
cd "$ROOT_DIR/sample"
|
||||||
|
docker compose up $DETACH &
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait
|
||||||
|
echo "=== All services started ==="
|
||||||
22
cfg/sample/ctrl/stop.sh
Executable file
22
cfg/sample/ctrl/stop.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stop all sample services
|
||||||
|
# Usage: ./ctrl/stop.sh
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
echo "=== Stopping sample services ==="
|
||||||
|
|
||||||
|
# Stop sample app
|
||||||
|
if [[ -f "$ROOT_DIR/sample/docker-compose.yml" ]]; then
|
||||||
|
echo "Stopping sample app..."
|
||||||
|
cd "$ROOT_DIR/sample"
|
||||||
|
docker compose down
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop soleprint
|
||||||
|
echo "Stopping soleprint..."
|
||||||
|
cd "$ROOT_DIR/soleprint"
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
echo "=== All services stopped ==="
|
||||||
15
cfg/sample/sample/.env
Normal file
15
cfg/sample/sample/.env
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Sample Managed App - Environment Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this to cfg/<your-room>/<app-name>/.env and customize
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DEPLOYMENT
|
||||||
|
# =============================================================================
|
||||||
|
DEPLOYMENT_NAME=sample
|
||||||
|
NETWORK_NAME=sample_network
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PORTS
|
||||||
|
# =============================================================================
|
||||||
|
FRONTEND_PORT=3020
|
||||||
19
cfg/sample/sample/docker-compose.yml
Normal file
19
cfg/sample/sample/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Sample Mock Frontend
|
||||||
|
# Simple nginx serving static HTML
|
||||||
|
#
|
||||||
|
# For a real app, customize this with your actual services
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: ${DEPLOYMENT_NAME}_frontend
|
||||||
|
volumes:
|
||||||
|
- ./index.html:/usr/share/nginx/html/index.html:ro
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT}:80"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: ${NETWORK_NAME}
|
||||||
102
cfg/sample/sample/index.html
Normal file
102
cfg/sample/sample/index.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sample - Public Demo</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1e3a5f 0%, #0d1b2a 100%);
|
||||||
|
color: #e5e5e5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: linear-gradient(135deg, #3a86ff, #8338ec);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #a3a3a3;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(58, 134, 255, 0.2);
|
||||||
|
border: 1px solid #3a86ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #3a86ff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.info h3 {
|
||||||
|
color: #8338ec;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.info ul {
|
||||||
|
list-style: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.info li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.info li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #3a86ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Sample</h1>
|
||||||
|
<p>Public demo - open to any Gmail account</p>
|
||||||
|
<span class="status">Public Demo</span>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<h3>Soleprint Managed Room</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
With sidebar:
|
||||||
|
<a href="https://sample.spr.mcrn.ar"
|
||||||
|
>sample.spr.mcrn.ar</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Standalone:
|
||||||
|
<a href="https://sample.mcrn.ar">sample.mcrn.ar</a>
|
||||||
|
</li>
|
||||||
|
<li>Login with any Google account</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
cfg/sample/soleprint/.env
Normal file
31
cfg/sample/soleprint/.env
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Sample Soleprint Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DEPLOYMENT
|
||||||
|
# =============================================================================
|
||||||
|
DEPLOYMENT_NAME=sample_spr
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# NETWORK
|
||||||
|
# =============================================================================
|
||||||
|
NETWORK_NAME=sample_network
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PORTS (unique per room)
|
||||||
|
# =============================================================================
|
||||||
|
SOLEPRINT_PORT=12030
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GOOGLE OAUTH
|
||||||
|
# =============================================================================
|
||||||
|
GOOGLE_CLIENT_ID=1076380473867-k6gvdg8etujj2e51bqejve78ft99hnqd.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=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
|
||||||
|
GOOGLE_REDIRECT_URI=http://sample.spr.local.ar/artery/google/oauth/callback
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AUTH
|
||||||
|
# =============================================================================
|
||||||
|
AUTH_BYPASS=true
|
||||||
|
AUTH_SESSION_SECRET=sample-dev-secret-change-in-production
|
||||||
35
cfg/sample/soleprint/docker-compose.nginx.yml
Normal file
35
cfg/sample/soleprint/docker-compose.nginx.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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:
|
||||||
|
- "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}
|
||||||
36
cfg/sample/soleprint/docker-compose.yml
Normal file
36
cfg/sample/soleprint/docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 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:
|
||||||
|
name: ${NETWORK_NAME}
|
||||||
72
cfg/sample/soleprint/nginx/local.conf
Normal file
72
cfg/sample/soleprint/nginx/local.conf
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Sample Room - Nginx Config for Docker
|
||||||
|
#
|
||||||
|
# This config uses docker service names (soleprint, frontend, backend)
|
||||||
|
# which resolve within the docker network.
|
||||||
|
|
||||||
|
# sample.spr.local.ar - frontend with soleprint sidebar
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name sample.spr.local.ar;
|
||||||
|
|
||||||
|
# Soleprint routes - sidebar API and assets
|
||||||
|
location /spr/ {
|
||||||
|
proxy_pass http://soleprint:8000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API (uncomment if your app has a backend)
|
||||||
|
# location /api/ {
|
||||||
|
# proxy_pass http://backend:8000/api/;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Frontend with sidebar injection
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
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 "";
|
||||||
|
|
||||||
|
# Inject sidebar CSS and JS into head
|
||||||
|
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types text/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# sample.local.ar - frontend without sidebar (direct access)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name sample.local.ar;
|
||||||
|
|
||||||
|
# Backend API (uncomment if your app has a backend)
|
||||||
|
# location /api/ {
|
||||||
|
# proxy_pass http://backend:8000/api/;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# }
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
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,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"showcase_url": "https://sample.spr.mcrn.ar",
|
||||||
"framework": {
|
"framework": {
|
||||||
"name": "soleprint",
|
"name": "soleprint",
|
||||||
"slug": "soleprint",
|
"slug": "soleprint",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"name": "google",
|
"name": "google",
|
||||||
"slug": "google",
|
"slug": "google",
|
||||||
"title": "Google",
|
"title": "Google",
|
||||||
"status": "planned",
|
"status": "building",
|
||||||
"system": "artery"
|
"system": "artery"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,9 +43,9 @@
|
|||||||
"system": "artery"
|
"system": "artery"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vnc",
|
"name": "vpn",
|
||||||
"slug": "vnc",
|
"slug": "vpn",
|
||||||
"title": "VNC",
|
"title": "VPN",
|
||||||
"status": "planned",
|
"status": "planned",
|
||||||
"system": "artery"
|
"system": "artery"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,12 +5,15 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# cd gen/standalone && docker compose up -d
|
# cd gen/standalone && docker compose up -d
|
||||||
|
|
||||||
|
name: soleprint_standalone
|
||||||
|
|
||||||
services:
|
services:
|
||||||
soleprint:
|
soleprint:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: soleprint
|
container_name: soleprint
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<g id="clust6" class="cluster">
|
<g id="clust6" class="cluster">
|
||||||
<title>cluster_room</title>
|
<title>cluster_room</title>
|
||||||
<polygon fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" points="642,-92.75 642,-184.38 952,-184.38 952,-92.75 642,-92.75"/>
|
<polygon fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" points="642,-92.75 642,-184.38 952,-184.38 952,-92.75 642,-92.75"/>
|
||||||
<text xml:space="preserve" text-anchor="middle" x="797" y="-165.18" font-family="Helvetica,sans-Serif" font-size="16.00">Managed Room (e.g., AMAR)</text>
|
<text xml:space="preserve" text-anchor="middle" x="797" y="-165.18" font-family="Helvetica,sans-Serif" font-size="16.00">Managed Room</text>
|
||||||
</g>
|
</g>
|
||||||
<!-- hub -->
|
<!-- hub -->
|
||||||
<g id="node1" class="node">
|
<g id="node1" class="node">
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
217
docs/architecture/05-sidebar-injection.md
Normal file
217
docs/architecture/05-sidebar-injection.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Sidebar Injection Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The soleprint sidebar is injected into managed app pages using a hybrid nginx + JavaScript approach. This allows any frontend framework (React, Next.js, static HTML) to receive the sidebar without code modifications.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser Request │
|
||||||
|
│ http://room.spr.local.ar/ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nginx │
|
||||||
|
│ │
|
||||||
|
│ 1. Routes /spr/* → soleprint:PORT (sidebar assets + API) │
|
||||||
|
│ 2. Routes /* → frontend:PORT (app pages) │
|
||||||
|
│ 3. Injects CSS+JS into HTML responses via sub_filter │
|
||||||
|
│ │
|
||||||
|
│ sub_filter '</head>' │
|
||||||
|
│ '<link rel="stylesheet" href="/spr/sidebar.css"> │
|
||||||
|
│ <script src="/spr/sidebar.js" defer></script></head>'; │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser Renders │
|
||||||
|
│ │
|
||||||
|
│ 1. Page loads with injected CSS (sidebar styles ready) │
|
||||||
|
│ 2. sidebar.js executes (deferred, after DOM ready) │
|
||||||
|
│ 3. JS fetches /spr/api/sidebar/config (room name, auth, etc) │
|
||||||
|
│ 4. JS creates sidebar DOM elements and injects into page │
|
||||||
|
│ 5. Sidebar appears on left side, pushes content with margin │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Why nginx sub_filter instead of modifying app code?
|
||||||
|
|
||||||
|
- **Framework agnostic**: Works with any frontend (Next.js, React, Vue, static HTML)
|
||||||
|
- **No app changes needed**: The managed app doesn't need to know about soleprint
|
||||||
|
- **Easy to disable**: Just access `room.local.ar` instead of `room.spr.local.ar`
|
||||||
|
|
||||||
|
### Why inject into `</head>` instead of `</body>`?
|
||||||
|
|
||||||
|
Next.js and other streaming SSR frameworks may not include `</body>` in the initial HTML response. The `</head>` tag is always present, so we inject both CSS and JS there using `defer` to ensure JS runs after DOM is ready.
|
||||||
|
|
||||||
|
### Why JavaScript injection instead of iframe?
|
||||||
|
|
||||||
|
- **No iframe isolation issues**: Sidebar can interact with page if needed
|
||||||
|
- **Better UX**: No double scrollbars, native feel
|
||||||
|
- **Simpler CSS**: Just push content with `margin-left`
|
||||||
|
|
||||||
|
## Nginx Configuration
|
||||||
|
|
||||||
|
There are two options for local development:
|
||||||
|
|
||||||
|
### Option 1: Docker Nginx (Recommended for portability)
|
||||||
|
|
||||||
|
Each room includes a docker-compose.nginx.yml that runs nginx in a container.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to /etc/hosts
|
||||||
|
127.0.0.1 sample.spr.local.ar sample.local.ar
|
||||||
|
|
||||||
|
# Start room with nginx
|
||||||
|
cd gen/<room>/soleprint
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The nginx config in `cfg/<room>/soleprint/nginx/local.conf` uses docker service names:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /spr/ {
|
||||||
|
proxy_pass http://soleprint:8000/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
# ... sub_filter for sidebar injection
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Portable, no system dependencies, isolated per room
|
||||||
|
**Cons**: Only one room can use port 80 at a time
|
||||||
|
|
||||||
|
### Option 2: System Nginx (For running multiple rooms)
|
||||||
|
|
||||||
|
If you need multiple rooms running simultaneously, use your system's nginx.
|
||||||
|
|
||||||
|
1. Install nginx: `sudo apt install nginx`
|
||||||
|
|
||||||
|
2. Add hosts entries for all rooms:
|
||||||
|
```
|
||||||
|
# /etc/hosts
|
||||||
|
127.0.0.1 amar.spr.local.ar amar.local.ar
|
||||||
|
127.0.0.1 dlt.spr.local.ar dlt.local.ar
|
||||||
|
127.0.0.1 sample.spr.local.ar sample.local.ar
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create config in `/etc/nginx/sites-enabled/spr_local.conf`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# room.spr.local.ar - app with sidebar
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name room.spr.local.ar;
|
||||||
|
|
||||||
|
# Soleprint assets and API
|
||||||
|
location /spr/ {
|
||||||
|
proxy_pass http://127.0.0.1:SOLEPRINT_PORT/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API (if applicable)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:BACKEND_PORT/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend with sidebar injection
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:FRONTEND_PORT;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Accept-Encoding ""; # Required for sub_filter
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Inject sidebar
|
||||||
|
sub_filter '</head>' '<link rel="stylesheet" href="/spr/sidebar.css"><script src="/spr/sidebar.js" defer></script></head>';
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types text/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# room.local.ar - app without sidebar (direct access)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name room.local.ar;
|
||||||
|
# ... same locations but without sub_filter in / block
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Reload nginx: `sudo nginx -t && sudo systemctl reload nginx`
|
||||||
|
|
||||||
|
**Pros**: Multiple rooms on port 80 via different hostnames
|
||||||
|
**Cons**: Requires system nginx, manual config updates
|
||||||
|
|
||||||
|
## Port Allocation
|
||||||
|
|
||||||
|
Each room should use unique ports to allow concurrent operation:
|
||||||
|
|
||||||
|
| Room | Soleprint | Frontend | Backend |
|
||||||
|
|--------|-----------|----------|---------|
|
||||||
|
| amar | 12000 | 3000 | 8001 |
|
||||||
|
| dlt | 12010 | 3010 | - |
|
||||||
|
| sample | 12020 | 3020 | 8020 |
|
||||||
|
|
||||||
|
## Sidebar Assets
|
||||||
|
|
||||||
|
The sidebar consists of two files served by soleprint:
|
||||||
|
|
||||||
|
- `/sidebar.css` - Styles for the sidebar (dark theme, positioning)
|
||||||
|
- `/sidebar.js` - Self-contained JS that fetches config and renders sidebar
|
||||||
|
|
||||||
|
Source location: `soleprint/station/tools/sbwrapper/`
|
||||||
|
|
||||||
|
## Sidebar Config API
|
||||||
|
|
||||||
|
The sidebar JS fetches configuration from `/spr/api/sidebar/config`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room": "amar",
|
||||||
|
"soleprint_base": "/spr",
|
||||||
|
"auth_enabled": true,
|
||||||
|
"tools": {
|
||||||
|
"artery": "/spr/artery",
|
||||||
|
"atlas": "/spr/atlas",
|
||||||
|
"station": "/spr/station"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login_url": "/spr/artery/google/oauth/login",
|
||||||
|
"logout_url": "/spr/artery/google/oauth/logout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Sidebar not appearing
|
||||||
|
|
||||||
|
1. Check if soleprint is running: `curl http://room.spr.local.ar/spr/sidebar.js`
|
||||||
|
2. Check browser console for `[Soleprint]` messages
|
||||||
|
3. Verify nginx has `Accept-Encoding ""` set (required for sub_filter)
|
||||||
|
4. Hard refresh (Ctrl+Shift+R) to clear cached HTML
|
||||||
|
|
||||||
|
### Wrong room config showing
|
||||||
|
|
||||||
|
Each room needs its own docker network (`room_network`) to isolate services. Check `NETWORK_NAME` in `.env` files and ensure containers are on correct networks.
|
||||||
|
|
||||||
|
### sub_filter not working
|
||||||
|
|
||||||
|
- Ensure `proxy_set_header Accept-Encoding ""` is set
|
||||||
|
- Check that response is `text/html` (sub_filter_types)
|
||||||
|
- Streaming SSR may not have `</body>`, use `</head>` injection instead
|
||||||
191
docs/architecture/sidebar-injection.html
Normal file
191
docs/architecture/sidebar-injection.html
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sidebar Injection - Soleprint</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
<style>
|
||||||
|
pre {
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: monospace;
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #3f3f3f;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="../lang-toggle.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div id="lang-toggle"></div>
|
||||||
|
<h1>Sidebar Injection</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
<span class="lang-en">How managed room sidebar works</span>
|
||||||
|
<span class="lang-es">Como funciona el sidebar del managed room</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p class="lang-en">The soleprint sidebar is injected into managed app pages using a hybrid nginx + JavaScript approach. This allows any frontend framework (React, Next.js, static HTML) to receive the sidebar without code modifications.</p>
|
||||||
|
<p class="lang-es">El sidebar de soleprint se inyecta en las paginas de apps manejadas usando un enfoque hibrido nginx + JavaScript. Esto permite que cualquier framework frontend (React, Next.js, HTML estatico) reciba el sidebar sin modificaciones de codigo.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">How It Works</span>
|
||||||
|
<span class="lang-es">Como Funciona</span>
|
||||||
|
</h2>
|
||||||
|
<pre>
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser Request │
|
||||||
|
│ http://room.spr.local.ar/ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nginx │
|
||||||
|
│ │
|
||||||
|
│ 1. Routes /spr/* → soleprint:PORT (sidebar assets + API) │
|
||||||
|
│ 2. Routes /* → frontend:PORT (app pages) │
|
||||||
|
│ 3. Injects CSS+JS into HTML responses via sub_filter │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser Renders │
|
||||||
|
│ │
|
||||||
|
│ 1. Page loads with injected CSS (sidebar styles ready) │
|
||||||
|
│ 2. sidebar.js executes (deferred, after DOM ready) │
|
||||||
|
│ 3. JS fetches /spr/api/sidebar/config │
|
||||||
|
│ 4. JS creates sidebar DOM elements and injects into page │
|
||||||
|
│ 5. Sidebar appears on left side, pushes content with margin │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Key Design Decisions</span>
|
||||||
|
<span class="lang-es">Decisiones de Diseno Clave</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3><span class="lang-en">Why nginx sub_filter?</span><span class="lang-es">Por que nginx sub_filter?</span></h3>
|
||||||
|
<p class="lang-en"><strong>Framework agnostic</strong>: Works with any frontend. No app changes needed. Easy to disable.</p>
|
||||||
|
<p class="lang-es"><strong>Agnostico de framework</strong>: Funciona con cualquier frontend. Sin cambios en la app. Facil de deshabilitar.</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3><span class="lang-en">Why inject into </head>?</span><span class="lang-es">Por que inyectar en </head>?</span></h3>
|
||||||
|
<p class="lang-en">Next.js and streaming SSR may not include </body> in initial response. </head> is always present.</p>
|
||||||
|
<p class="lang-es">Next.js y SSR streaming pueden no incluir </body> en la respuesta inicial. </head> siempre esta presente.</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3><span class="lang-en">Why JS instead of iframe?</span><span class="lang-es">Por que JS en vez de iframe?</span></h3>
|
||||||
|
<p class="lang-en">No isolation issues, better UX (no double scrollbars), simpler CSS with margin-left.</p>
|
||||||
|
<p class="lang-es">Sin problemas de aislamiento, mejor UX (sin doble scrollbar), CSS mas simple con margin-left.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Nginx Configuration</span>
|
||||||
|
<span class="lang-es">Configuracion de Nginx</span>
|
||||||
|
</h2>
|
||||||
|
<p class="lang-en">The nginx config injects CSS+JS into HTML responses:</p>
|
||||||
|
<p class="lang-es">La config de nginx inyecta CSS+JS en las respuestas HTML:</p>
|
||||||
|
<pre><code>location / {
|
||||||
|
proxy_pass http://frontend:PORT;
|
||||||
|
proxy_set_header Accept-Encoding ""; # Required for sub_filter
|
||||||
|
|
||||||
|
# Inject sidebar
|
||||||
|
sub_filter '</head>'
|
||||||
|
'<link rel="stylesheet" href="/spr/sidebar.css">
|
||||||
|
<script src="/spr/sidebar.js" defer></script></head>';
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types text/html;
|
||||||
|
}</code></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Port Allocation</span>
|
||||||
|
<span class="lang-es">Asignacion de Puertos</span>
|
||||||
|
</h2>
|
||||||
|
<p class="lang-en">Each room uses unique ports for concurrent operation:</p>
|
||||||
|
<p class="lang-es">Cada room usa puertos unicos para operacion concurrente:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Room</th><th>Soleprint</th><th>Frontend</th><th>Backend</th></tr>
|
||||||
|
<tr><td>amar</td><td>12000</td><td>3000</td><td>8001</td></tr>
|
||||||
|
<tr><td>dlt</td><td>12010</td><td>3010</td><td>-</td></tr>
|
||||||
|
<tr><td>sample</td><td>12020</td><td>3020</td><td>8020</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Sidebar Config API</span>
|
||||||
|
<span class="lang-es">API de Config del Sidebar</span>
|
||||||
|
</h2>
|
||||||
|
<p class="lang-en">The sidebar JS fetches configuration from <code>/spr/api/sidebar/config</code>:</p>
|
||||||
|
<p class="lang-es">El JS del sidebar obtiene configuracion de <code>/spr/api/sidebar/config</code>:</p>
|
||||||
|
<pre><code>{
|
||||||
|
"room": "amar",
|
||||||
|
"soleprint_base": "/spr",
|
||||||
|
"auth_enabled": true,
|
||||||
|
"tools": {
|
||||||
|
"artery": "/spr/artery",
|
||||||
|
"atlas": "/spr/atlas",
|
||||||
|
"station": "/spr/station"
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3><span class="lang-en">Sidebar not appearing</span><span class="lang-es">Sidebar no aparece</span></h3>
|
||||||
|
<p class="lang-en">Check if soleprint is running. Verify nginx has <code>Accept-Encoding ""</code>. Hard refresh (Ctrl+Shift+R).</p>
|
||||||
|
<p class="lang-es">Verificar que soleprint esta corriendo. Verificar que nginx tiene <code>Accept-Encoding ""</code>. Refresco forzado (Ctrl+Shift+R).</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3><span class="lang-en">sub_filter not working</span><span class="lang-es">sub_filter no funciona</span></h3>
|
||||||
|
<p class="lang-en">Ensure <code>proxy_set_header Accept-Encoding ""</code> is set. Check response is <code>text/html</code>.</p>
|
||||||
|
<p class="lang-es">Asegurar que <code>proxy_set_header Accept-Encoding ""</code> esta seteado. Verificar que la respuesta es <code>text/html</code>.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p><a href="../"><span class="lang-en">← Back to index</span><span class="lang-es">← Volver al indice</span></a></p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
314
docs/artery/index.html
Normal file
314
docs/artery/index.html
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Artery - Soleprint</title>
|
||||||
|
<link rel="stylesheet" href="../architecture/styles.css" />
|
||||||
|
<style>
|
||||||
|
.composition {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 2px solid #b91c1c;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.composition h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.composition > p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
.components {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.component {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #3f3f3f;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.component h4 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.component p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
.model-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="../lang-toggle.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div id="lang-toggle"></div>
|
||||||
|
<h1>Artery</h1>
|
||||||
|
<p class="subtitle">Todo lo vital</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Model</span
|
||||||
|
><span class="lang-es">Modelo</span>
|
||||||
|
</h2>
|
||||||
|
<div class="model-grid">
|
||||||
|
<div class="composition">
|
||||||
|
<h3>Pulse</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Composed data flow: a vein configured for a room
|
||||||
|
with storage
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Flujo de datos compuesto: vein configurado para un
|
||||||
|
room con almacenamiento
|
||||||
|
</p>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component">
|
||||||
|
<h4>Vein</h4>
|
||||||
|
<p class="lang-en">API connector</p>
|
||||||
|
<p class="lang-es">Conector API</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Room</h4>
|
||||||
|
<p class="lang-en">Config/env</p>
|
||||||
|
<p class="lang-es">Config/entorno</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Depot</h4>
|
||||||
|
<p class="lang-en">Data storage</p>
|
||||||
|
<p class="lang-es">Almacenamiento</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="composition">
|
||||||
|
<h3>Shunt</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Mock connector for testing - same interface, fake
|
||||||
|
data
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Conector mock para testing - misma interfaz, datos
|
||||||
|
falsos
|
||||||
|
</p>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component">
|
||||||
|
<h4>Vein Interface</h4>
|
||||||
|
<p class="lang-en">Same API shape</p>
|
||||||
|
<p class="lang-es">Misma forma de API</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Mock Data</h4>
|
||||||
|
<p class="lang-en">Fake responses</p>
|
||||||
|
<p class="lang-es">Respuestas falsas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="composition">
|
||||||
|
<h3>Plexus</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Full application when you need more than data flow
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Aplicacion completa cuando necesitas mas que flujo
|
||||||
|
de datos
|
||||||
|
</p>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component">
|
||||||
|
<h4>Backend</h4>
|
||||||
|
<p>FastAPI server</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Frontend</h4>
|
||||||
|
<p>Web UI</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Infra</h4>
|
||||||
|
<p class="lang-en">DB, queues, etc</p>
|
||||||
|
<p class="lang-es">DB, colas, etc</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Architecture</span
|
||||||
|
><span class="lang-es">Arquitectura</span>
|
||||||
|
</h2>
|
||||||
|
<img
|
||||||
|
src="../architecture/02-artery-hierarchy.svg"
|
||||||
|
alt="Artery Hierarchy"
|
||||||
|
style="
|
||||||
|
max-width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Components</span
|
||||||
|
><span class="lang-es">Componentes</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Vein</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Stateless API connector. Connects to external
|
||||||
|
services like Google Sheets, Jira, Slack. Pure data
|
||||||
|
flow - no state, no storage.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Conector API sin estado. Conecta a servicios
|
||||||
|
externos como Google Sheets, Jira, Slack. Flujo de
|
||||||
|
datos puro.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Shunt</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Mock connector for testing. Same interface as a vein
|
||||||
|
but returns fake data.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Conector mock para testing. Misma interfaz que un
|
||||||
|
vein pero devuelve datos falsos.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Pulse</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Composed data flow. Formula:
|
||||||
|
<strong>Vein + Room + Depot</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Flujo de datos compuesto. Formula:
|
||||||
|
<strong>Vein + Room + Depot</strong>.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Plexus</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Full application with backend, frontend, and
|
||||||
|
infrastructure.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Aplicacion completa con backend, frontend e
|
||||||
|
infraestructura.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Shared Components</span
|
||||||
|
><span class="lang-es">Componentes Compartidos</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Room</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Runtime environment configuration. Each room is an
|
||||||
|
isolated instance with its own config and
|
||||||
|
credentials.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Configuracion del entorno. Cada room es una
|
||||||
|
instancia aislada con su propia config y
|
||||||
|
credenciales.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Depot</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Data storage / provisions. JSON files, configs,
|
||||||
|
cached responses.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Almacenamiento de datos. Archivos JSON, configs,
|
||||||
|
respuestas cacheadas.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Available Veins</span
|
||||||
|
><span class="lang-es">Veins Disponibles</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<a
|
||||||
|
href="../veins/index.html"
|
||||||
|
class="finding-card"
|
||||||
|
style="text-decoration: none"
|
||||||
|
>
|
||||||
|
<h3>Google</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Google Sheets API. OAuth authentication, read/write
|
||||||
|
spreadsheets.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Google Sheets API. Autenticacion OAuth,
|
||||||
|
leer/escribir hojas de calculo.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="../veins/index.html"
|
||||||
|
class="finding-card"
|
||||||
|
style="text-decoration: none"
|
||||||
|
>
|
||||||
|
<h3>Jira</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Jira Cloud API. Query issues, projects, sprints.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Jira Cloud API. Consultar issues, proyectos,
|
||||||
|
sprints.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="../veins/index.html"
|
||||||
|
class="finding-card"
|
||||||
|
style="text-decoration: none"
|
||||||
|
>
|
||||||
|
<h3>Slack</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Slack API. Channels, messages, users.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Slack API. Canales, mensajes, usuarios.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
<a href="../"
|
||||||
|
><span class="lang-en">← Back to index</span
|
||||||
|
><span class="lang-es">← Volver al indice</span></a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
209
docs/atlas/index.html
Normal file
209
docs/atlas/index.html
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Atlas - Soleprint</title>
|
||||||
|
<link rel="stylesheet" href="../architecture/styles.css" />
|
||||||
|
<style>
|
||||||
|
.composition {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 2px solid #15803d;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.composition h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.composition > p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
.components {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.component {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #3f3f3f;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.component h4 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.component p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
.model-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="../lang-toggle.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div id="lang-toggle"></div>
|
||||||
|
<h1>Atlas</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
<span class="lang-en">Actionable Documentation</span
|
||||||
|
><span class="lang-es">Documentacion Accionable</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Model</span
|
||||||
|
><span class="lang-es">Modelo</span>
|
||||||
|
</h2>
|
||||||
|
<div class="model-grid">
|
||||||
|
<div class="composition">
|
||||||
|
<h3>Plain Book</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Static documentation with an index page
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Documentacion estatica con una pagina indice
|
||||||
|
</p>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component">
|
||||||
|
<h4>index.html</h4>
|
||||||
|
<p class="lang-en">Entry point</p>
|
||||||
|
<p class="lang-es">Punto de entrada</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Depot</h4>
|
||||||
|
<p class="lang-en">Static content</p>
|
||||||
|
<p class="lang-es">Contenido estatico</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="composition">
|
||||||
|
<h3>Templated Book</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Dynamic docs from template + data elements
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Docs dinamicos desde template + elementos de datos
|
||||||
|
</p>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component">
|
||||||
|
<h4>Template</h4>
|
||||||
|
<p class="lang-en">Jinja2 pattern</p>
|
||||||
|
<p class="lang-es">Patron Jinja2</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Depot</h4>
|
||||||
|
<p class="lang-en">Data elements</p>
|
||||||
|
<p class="lang-es">Elementos de datos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Architecture</span
|
||||||
|
><span class="lang-es">Arquitectura</span>
|
||||||
|
</h2>
|
||||||
|
<img
|
||||||
|
src="../architecture/01-system-overview.svg"
|
||||||
|
alt="System Overview"
|
||||||
|
style="
|
||||||
|
max-width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Components</span
|
||||||
|
><span class="lang-es">Componentes</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Book</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Collection of related documentation. Can be plain
|
||||||
|
(static HTML) or templated (template + depot
|
||||||
|
elements).
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Coleccion de documentacion relacionada. Puede ser
|
||||||
|
plain (HTML estatico) o templated (template +
|
||||||
|
elementos de depot).
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Template</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Jinja2 templates that generate documentation. Define
|
||||||
|
the structure once, fill with data from depot.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Templates Jinja2 que generan documentacion. Definen
|
||||||
|
la estructura una vez, llenan con datos del depot.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Shared Components</span
|
||||||
|
><span class="lang-es">Componentes Compartidos</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Room</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Runtime environment configuration. Each room can
|
||||||
|
have its own atlas with project-specific books.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Configuracion del entorno. Cada room puede tener su
|
||||||
|
propio atlas con books especificos del proyecto.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Depot</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Data storage. For plain books: static files. For
|
||||||
|
templated books: elements that fill the template.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Almacenamiento de datos. Para plain books: archivos
|
||||||
|
estaticos. Para templated books: elementos que
|
||||||
|
llenan el template.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
<a href="../"
|
||||||
|
><span class="lang-en">← Back to index</span
|
||||||
|
><span class="lang-es">← Volver al indice</span></a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
267
docs/index.html
267
docs/index.html
@@ -5,39 +5,151 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Soleprint - Documentation</title>
|
<title>Soleprint - Documentation</title>
|
||||||
<link rel="stylesheet" href="architecture/styles.css" />
|
<link rel="stylesheet" href="architecture/styles.css" />
|
||||||
|
<style>
|
||||||
|
.one-liner {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.demo-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.demo-link:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.systems-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.system-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
.system-card h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.system-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.arch-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.arch-card {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.arch-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.arch-card h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.arch-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="lang-toggle.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
<div id="lang-toggle"></div>
|
||||||
<h1>Soleprint</h1>
|
<h1>Soleprint</h1>
|
||||||
<p class="subtitle">
|
<p class="subtitle">Cada paso deja huella</p>
|
||||||
Cada paso deja huella / Each step leaves a mark
|
<p class="one-liner">
|
||||||
|
<span class="lang-en"
|
||||||
|
>Pluggable stuff to tackle any challenge</span
|
||||||
|
>
|
||||||
|
<span class="lang-es"
|
||||||
|
>Piezas enchufables para cualquier desafio</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
<a href="https://sample.spr.mcrn.ar" class="demo-link">
|
||||||
|
<span class="lang-en">Try the Demo</span>
|
||||||
|
<span class="lang-es">Ver Demo</span>
|
||||||
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section class="findings-section">
|
<section class="findings-section">
|
||||||
<h2>Documentation</h2>
|
<h2>
|
||||||
<div class="findings-grid">
|
<span class="lang-en">The Three Systems</span>
|
||||||
|
<span class="lang-es">Los Tres Sistemas</span>
|
||||||
|
</h2>
|
||||||
|
<div class="systems-grid">
|
||||||
<a
|
<a
|
||||||
href="architecture/index.html"
|
href="artery/"
|
||||||
class="finding-card"
|
class="system-card"
|
||||||
style="text-decoration: none"
|
style="text-decoration: none"
|
||||||
>
|
>
|
||||||
<h3>Architecture</h3>
|
<h3>Artery</h3>
|
||||||
<p>
|
<p class="lang-en">
|
||||||
System overview, connector hierarchy, build flow,
|
API connectors and data flow. Veins for real APIs,
|
||||||
and room configuration diagrams.
|
shunts for mocks.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Conectores y flujo de datos. Veins para APIs reales,
|
||||||
|
shunts para mocks.
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="veins/index.html"
|
href="atlas/"
|
||||||
class="finding-card"
|
class="system-card"
|
||||||
style="text-decoration: none"
|
style="text-decoration: none"
|
||||||
>
|
>
|
||||||
<h3>Veins & Shunts</h3>
|
<h3>Atlas</h3>
|
||||||
<p>
|
<p class="lang-en">
|
||||||
API connectors (Jira, Slack, Google) and mock
|
Actionable documentation. Templates that generate
|
||||||
connectors for testing.
|
living docs.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Documentacion accionable. Templates que generan docs
|
||||||
|
vivos.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="station/"
|
||||||
|
class="system-card"
|
||||||
|
style="text-decoration: none"
|
||||||
|
>
|
||||||
|
<h3>Station</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Tools and monitors. Everything to run, test, and
|
||||||
|
observe.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Herramientas y monitores. Todo para correr, testear,
|
||||||
|
y observar.
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,53 +157,106 @@
|
|||||||
|
|
||||||
<section class="findings-section">
|
<section class="findings-section">
|
||||||
<h2>Quick Start</h2>
|
<h2>Quick Start</h2>
|
||||||
<div class="finding-card">
|
<pre>
|
||||||
<h3>Build & Run</h3>
|
# Clone
|
||||||
<pre
|
git clone https://git.mcrn.ar/soleprint
|
||||||
style="
|
cd soleprint
|
||||||
background: var(--bg-primary);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
# Build standalone
|
|
||||||
python build.py
|
|
||||||
cd gen/standalone && .venv/bin/python run.py
|
|
||||||
|
|
||||||
# Build with room config
|
# Setup (once)
|
||||||
python build.py --cfg amar
|
python -m venv .venv && source .venv/bin/activate
|
||||||
cd gen/amar && .venv/bin/python run.py
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Visit http://localhost:12000</pre
|
# Option 1: Build standalone (soleprint only)
|
||||||
>
|
python build.py --cfg standalone
|
||||||
</div>
|
cd gen/standalone && ./ctrl/start.sh
|
||||||
|
# Visit http://localhost:12000
|
||||||
|
|
||||||
|
# Option 2: Build managed room (soleprint + your app)
|
||||||
|
python build.py --cfg myroom
|
||||||
|
cd gen/myroom && ./ctrl/start.sh
|
||||||
|
# Visit http://myroom.spr.local.ar
|
||||||
|
|
||||||
|
# Option 3: Use the installer (coming soon)</pre
|
||||||
|
>
|
||||||
|
|
||||||
|
<p style="margin-top: 1rem">
|
||||||
|
<span class="lang-en">Minimal config example:</span>
|
||||||
|
<span class="lang-es">Ejemplo de config minima:</span>
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
// cfg/myroom/config.json
|
||||||
|
{
|
||||||
|
"room": "myroom",
|
||||||
|
"artery": {
|
||||||
|
"veins": ["google", "jira", "slack"],
|
||||||
|
"shunts": ["mercadopago"]
|
||||||
|
},
|
||||||
|
"atlas": {
|
||||||
|
"books": ["gherkin", "feature-flow"]
|
||||||
|
},
|
||||||
|
"station": {
|
||||||
|
"tools": ["tester", "modelgen"],
|
||||||
|
"monitors": ["databrowse"]
|
||||||
|
}
|
||||||
|
}</pre
|
||||||
|
>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="findings-section">
|
<section class="findings-section">
|
||||||
<h2>What Soleprint Solves</h2>
|
<h2>
|
||||||
|
<span class="lang-en">Architecture</span>
|
||||||
|
<span class="lang-es">Arquitectura</span>
|
||||||
|
</h2>
|
||||||
|
<p class="lang-en">Deep dive into how soleprint works.</p>
|
||||||
|
<p class="lang-es">Profundizando en como funciona soleprint.</p>
|
||||||
<div class="findings-grid">
|
<div class="findings-grid">
|
||||||
<article class="finding-card">
|
<a
|
||||||
<h3>Freelance Standardization</h3>
|
href="architecture/"
|
||||||
<p>Consistent framework across projects.</p>
|
class="finding-card"
|
||||||
</article>
|
style="text-decoration: none"
|
||||||
<article class="finding-card">
|
>
|
||||||
<h3>Missing Infrastructure</h3>
|
<h3>
|
||||||
<p>
|
<span class="lang-en">Architecture Diagrams →</span
|
||||||
Mock systems not ready yet - DBs, APIs, Kubernetes.
|
><span class="lang-es"
|
||||||
|
>Diagramas de Arquitectura →</span
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
System overview, artery hierarchy, build flow, room
|
||||||
|
configuration.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
<p class="lang-es">
|
||||||
<article class="finding-card">
|
Vista general, jerarquia de artery, flujo de build,
|
||||||
<h3>Reliable Testing</h3>
|
configuracion de rooms.
|
||||||
<p>BDD -> Gherkin -> Tests.</p>
|
</p>
|
||||||
</article>
|
</a>
|
||||||
|
<a
|
||||||
|
href="architecture/sidebar-injection.html"
|
||||||
|
class="finding-card"
|
||||||
|
style="text-decoration: none"
|
||||||
|
>
|
||||||
|
<h3>
|
||||||
|
<span class="lang-en">Sidebar Injection →</span
|
||||||
|
><span class="lang-es">Inyeccion de Sidebar →</span>
|
||||||
|
</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
How the managed room sidebar works with nginx +
|
||||||
|
JavaScript.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Como funciona el sidebar del managed room con nginx
|
||||||
|
+ JavaScript.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>Soleprint - Development Workflow Platform</p>
|
<p>
|
||||||
|
<a href="https://soleprint.mcrn.ar">soleprint.mcrn.ar</a> ·
|
||||||
|
<a href="https://sample.spr.mcrn.ar">Live Demo</a>
|
||||||
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
54
docs/lang-toggle.js
Normal file
54
docs/lang-toggle.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Language toggle for soleprint docs
|
||||||
|
// Include this script and add: <div id="lang-toggle"></div> in header
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
function setLang(lang) {
|
||||||
|
localStorage.setItem("spr-docs-lang", lang);
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.querySelectorAll(".lang-toggle button").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.dataset.lang === lang);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const currentLang = localStorage.getItem("spr-docs-lang") || "en";
|
||||||
|
|
||||||
|
// Inject toggle HTML
|
||||||
|
const container = document.getElementById("lang-toggle");
|
||||||
|
if (container) {
|
||||||
|
container.className = "lang-toggle";
|
||||||
|
|
||||||
|
const btnEn = document.createElement("button");
|
||||||
|
btnEn.textContent = "EN";
|
||||||
|
btnEn.dataset.lang = "en";
|
||||||
|
btnEn.addEventListener("click", () => setLang("en"));
|
||||||
|
|
||||||
|
const btnEs = document.createElement("button");
|
||||||
|
btnEs.textContent = "ES";
|
||||||
|
btnEs.dataset.lang = "es";
|
||||||
|
btnEs.addEventListener("click", () => setLang("es"));
|
||||||
|
|
||||||
|
container.appendChild(btnEn);
|
||||||
|
container.appendChild(btnEs);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLang(currentLang);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject styles
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = `
|
||||||
|
.lang-toggle { position: absolute; top: 1.5rem; right: 2rem; display: flex; border: 1px solid #888; border-radius: 4px; overflow: hidden; }
|
||||||
|
.lang-toggle button { background: #1a1a1a; border: none; color: #888; padding: 0.4rem 0.8rem; font-family: inherit; font-size: 0.75rem; cursor: pointer; }
|
||||||
|
.lang-toggle button:first-child { border-right: 1px solid #888; }
|
||||||
|
.lang-toggle button:hover { background: #0a0a0a; color: #fff; }
|
||||||
|
.lang-toggle button.active { background: var(--accent, #b91c1c); color: #fff; }
|
||||||
|
header { position: relative; }
|
||||||
|
.lang-en, .lang-es { display: none; }
|
||||||
|
html[lang="en"] .lang-en { display: block; }
|
||||||
|
html[lang="es"] .lang-es { display: block; }
|
||||||
|
html[lang="en"] span.lang-en { display: inline; }
|
||||||
|
html[lang="es"] span.lang-es { display: inline; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
336
docs/station/index.html
Normal file
336
docs/station/index.html
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Station - Soleprint</title>
|
||||||
|
<link rel="stylesheet" href="../architecture/styles.css" />
|
||||||
|
<style>
|
||||||
|
.composition {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 2px solid #7c3aed;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.composition h3 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
.composition > p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
.components {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.component {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #3f3f3f;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.component h4 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
.component p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
.model-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="../lang-toggle.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div id="lang-toggle"></div>
|
||||||
|
<h1>Station</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
<span class="lang-en">Monitors, Environments & Tools</span
|
||||||
|
><span class="lang-es">Monitores, Entornos y Herramientas</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Model</span
|
||||||
|
><span class="lang-es">Modelo</span>
|
||||||
|
</h2>
|
||||||
|
<div class="model-grid">
|
||||||
|
<div class="composition">
|
||||||
|
<h3>Desk</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Control center - collection of monitors
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Centro de control - coleccion de monitores
|
||||||
|
</p>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component">
|
||||||
|
<h4>Monitor</h4>
|
||||||
|
<p>Web UI</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Monitor</h4>
|
||||||
|
<p>Web UI</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>...</h4>
|
||||||
|
<p></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="composition">
|
||||||
|
<h3>Monitor</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Web interface - always running, always watching
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Interfaz web - siempre corriendo, siempre observando
|
||||||
|
</p>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component">
|
||||||
|
<h4>Web UI</h4>
|
||||||
|
<p>Dashboard</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Room</h4>
|
||||||
|
<p class="lang-en">Config/env</p>
|
||||||
|
<p class="lang-es">Config/entorno</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Depot</h4>
|
||||||
|
<p class="lang-en">Data source</p>
|
||||||
|
<p class="lang-es">Fuente de datos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="composition">
|
||||||
|
<h3>Tool</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
CLI utility - run once, get results
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Utilidad CLI - ejecutar una vez, obtener resultados
|
||||||
|
</p>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component">
|
||||||
|
<h4>CLI</h4>
|
||||||
|
<p class="lang-en">Command interface</p>
|
||||||
|
<p class="lang-es">Interfaz de comandos</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Room</h4>
|
||||||
|
<p class="lang-en">Config/env</p>
|
||||||
|
<p class="lang-es">Config/entorno</p>
|
||||||
|
</div>
|
||||||
|
<div class="component">
|
||||||
|
<h4>Depot</h4>
|
||||||
|
<p class="lang-en">Output storage</p>
|
||||||
|
<p class="lang-es">Almacenamiento de salida</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Architecture</span
|
||||||
|
><span class="lang-es">Arquitectura</span>
|
||||||
|
</h2>
|
||||||
|
<img
|
||||||
|
src="../architecture/01-system-overview.svg"
|
||||||
|
alt="System Overview"
|
||||||
|
style="
|
||||||
|
max-width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Components</span
|
||||||
|
><span class="lang-es">Componentes</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Desk</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Collection of monitors. Your control center with all
|
||||||
|
the views you need.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Coleccion de monitores. Tu centro de control con
|
||||||
|
todas las vistas que necesitas.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Monitor</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Web interfaces for observation. Data browsers,
|
||||||
|
dashboards, log viewers. Always running.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Interfaces web para observacion. Navegadores de
|
||||||
|
datos, dashboards, visores de logs. Siempre
|
||||||
|
corriendo.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Tool</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
CLI utilities and scripts. Code generators, test
|
||||||
|
runners, infra provisioners. Run once, get results.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Utilidades CLI y scripts. Generadores de codigo,
|
||||||
|
test runners, provisioners de infra. Ejecutar una
|
||||||
|
vez, obtener resultados.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Shared Components</span
|
||||||
|
><span class="lang-es">Componentes Compartidos</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Room</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Runtime environment configuration. Tools and
|
||||||
|
monitors are configured per-room for isolation.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Configuracion del entorno. Tools y monitors se
|
||||||
|
configuran por room para aislamiento.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Depot</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Data storage. For tools: output files, results. For
|
||||||
|
monitors: data to display.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Almacenamiento de datos. Para tools: archivos de
|
||||||
|
salida, resultados. Para monitors: datos a mostrar.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Available Tools</span
|
||||||
|
><span class="lang-es">Tools Disponibles</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Tester</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
API and Playwright test runner. Discover tests, run
|
||||||
|
them, collect artifacts.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Test runner de API y Playwright. Descubrir tests,
|
||||||
|
ejecutarlos, recolectar artefactos.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>ModelGen</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Generate model diagrams from code. Introspect
|
||||||
|
soleprint structure, output SVG.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Generar diagramas de modelo desde codigo.
|
||||||
|
Introspeccionar estructura de soleprint, generar
|
||||||
|
SVG.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>Infra</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Infrastructure provisioners. AWS, GCP, DigitalOcean
|
||||||
|
deployment helpers.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Provisioners de infraestructura. Helpers de deploy
|
||||||
|
para AWS, GCP, DigitalOcean.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>DataGen</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Test data generation. Create realistic fake data for
|
||||||
|
development.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Generacion de datos de test. Crear datos falsos
|
||||||
|
realistas para desarrollo.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>DataBrowse</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Navigable data model graphs generated from existing
|
||||||
|
models.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Grafos de modelo de datos navegables generados desde
|
||||||
|
modelos existentes.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="findings-section">
|
||||||
|
<h2>
|
||||||
|
<span class="lang-en">Available Monitors</span
|
||||||
|
><span class="lang-es">Monitors Disponibles</span>
|
||||||
|
</h2>
|
||||||
|
<div class="findings-grid">
|
||||||
|
<article class="finding-card">
|
||||||
|
<h3>DataBrowse</h3>
|
||||||
|
<p class="lang-en">
|
||||||
|
Navigable data model graphs generated from existing
|
||||||
|
models.
|
||||||
|
</p>
|
||||||
|
<p class="lang-es">
|
||||||
|
Grafos de modelo de datos navegables generados desde
|
||||||
|
modelos existentes.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
<a href="../"
|
||||||
|
><span class="lang-en">← Back to index</span
|
||||||
|
><span class="lang-es">← Volver al indice</span></a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -17,4 +17,4 @@ COPY . .
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "run:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -419,6 +419,21 @@
|
|||||||
<div class="component"><h4>Depot</h4></div>
|
<div class="component"><h4>Depot</h4></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="composition" style="margin-top: 1rem">
|
||||||
|
<h3>Shunt</h3>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component"><h4>Vein Interface</h4></div>
|
||||||
|
<div class="component"><h4>Mock Data</h4></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="composition" style="margin-top: 1rem">
|
||||||
|
<h3>Plexus</h3>
|
||||||
|
<div class="components">
|
||||||
|
<div class="component"><h4>Backend</h4></div>
|
||||||
|
<div class="component"><h4>Frontend</h4></div>
|
||||||
|
<div class="component"><h4>Infra</h4></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -762,12 +777,116 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Placeholder tabs -->
|
<!-- Google API Tab -->
|
||||||
<section id="tab-google" class="tab-content">
|
<section id="tab-google_api" class="tab-content">
|
||||||
<h2>Google</h2>
|
<h2>Google Sheets</h2>
|
||||||
<p>Google connector. Planned.</p>
|
|
||||||
|
<!-- Auth Status -->
|
||||||
|
<div id="google-auth-status" class="api-form">
|
||||||
|
<div id="google-not-connected">
|
||||||
|
<p style="color: #a3a3a3; margin: 0 0 1rem 0">
|
||||||
|
Connect your Google account to access Sheets.
|
||||||
|
</p>
|
||||||
|
<div class="api-controls" style="margin-top: 0">
|
||||||
|
<button id="btn-google-connect">
|
||||||
|
Connect Google Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="google-connected" style="display: none">
|
||||||
|
<p style="color: #4ade80; margin: 0 0 1rem 0">
|
||||||
|
✓ Connected to Google
|
||||||
|
</p>
|
||||||
|
<div class="api-controls" style="margin-top: 0">
|
||||||
|
<button id="btn-google-disconnect" class="tab-button">
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sheets Form (shown when connected) -->
|
||||||
|
<div id="google-sheets-form" style="display: none">
|
||||||
|
<div class="api-form">
|
||||||
|
<label for="spreadsheet-id">Spreadsheet ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="spreadsheet-id"
|
||||||
|
placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"
|
||||||
|
/>
|
||||||
|
<p style="color: #666; font-size: 0.8rem; margin: 0.5rem 0">
|
||||||
|
Find this in the spreadsheet URL:
|
||||||
|
docs.google.com/spreadsheets/d/<strong>SPREADSHEET_ID</strong>/edit
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="api-controls" style="margin-top: 1rem">
|
||||||
|
<button id="btn-list-sheets" class="tab-button">
|
||||||
|
List Sheets
|
||||||
|
</button>
|
||||||
|
<button id="btn-get-metadata" class="tab-button">
|
||||||
|
Get Metadata
|
||||||
|
</button>
|
||||||
|
<label style="margin-left: auto">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="google-text-mode"
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
Text output
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-form" style="margin-top: 1.5rem">
|
||||||
|
<label for="sheet-range">Range (A1 notation)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="sheet-range"
|
||||||
|
placeholder="Sheet1!A1:D10"
|
||||||
|
/>
|
||||||
|
<div class="api-controls">
|
||||||
|
<button id="btn-get-values">Get Values</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output -->
|
||||||
|
<div id="google-output-container" class="output-container">
|
||||||
|
<div id="google-output" class="output-area scrollable"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top: 2rem">Endpoints</h2>
|
||||||
|
<ul class="endpoints">
|
||||||
|
<li>
|
||||||
|
<code>/artery/google_api/oauth/start</code>
|
||||||
|
<span class="desc">Start OAuth flow</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/artery/google_api/oauth/status</code>
|
||||||
|
<span class="desc">Check connection status</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/artery/google_api/spreadsheets/{id}</code>
|
||||||
|
<span class="desc">Spreadsheet metadata</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>/artery/google_api/spreadsheets/{id}/sheets</code>
|
||||||
|
<span class="desc">List sheets</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code
|
||||||
|
>/artery/google_api/spreadsheets/{id}/values?range=...</code
|
||||||
|
>
|
||||||
|
<span class="desc">Get cell values</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 1rem; font-size: 0.85rem; color: #666">
|
||||||
|
Add <code>?text=true</code> for LLM-friendly output.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Placeholder tabs -->
|
||||||
|
|
||||||
<section id="tab-maps" class="tab-content">
|
<section id="tab-maps" class="tab-content">
|
||||||
<h2>Maps</h2>
|
<h2>Maps</h2>
|
||||||
<p>Maps connector. Planned.</p>
|
<p>Maps connector. Planned.</p>
|
||||||
@@ -1487,6 +1606,223 @@
|
|||||||
showError(output, e.message);
|
showError(output, e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Google Tab
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const googleNotConnected = document.getElementById(
|
||||||
|
"google-not-connected",
|
||||||
|
);
|
||||||
|
const googleConnected = document.getElementById("google-connected");
|
||||||
|
const googleSheetsForm =
|
||||||
|
document.getElementById("google-sheets-form");
|
||||||
|
const googleOutput = document.getElementById("google-output");
|
||||||
|
const googleOutputContainer = document.getElementById(
|
||||||
|
"google-output-container",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function checkGoogleAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/artery/google_api/oauth/status");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.authenticated) {
|
||||||
|
googleNotConnected.style.display = "none";
|
||||||
|
googleConnected.style.display = "block";
|
||||||
|
googleSheetsForm.style.display = "block";
|
||||||
|
} else {
|
||||||
|
googleNotConnected.style.display = "block";
|
||||||
|
googleConnected.style.display = "none";
|
||||||
|
googleSheetsForm.style.display = "none";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to check Google auth status:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check auth on page load if Google tab elements exist
|
||||||
|
if (googleNotConnected) {
|
||||||
|
checkGoogleAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check when switching to Google API tab
|
||||||
|
document
|
||||||
|
.querySelectorAll('.vein[data-tab="google_api"]')
|
||||||
|
.forEach((vein) => {
|
||||||
|
vein.addEventListener("click", () => {
|
||||||
|
checkGoogleAuth();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect button
|
||||||
|
document
|
||||||
|
.getElementById("btn-google-connect")
|
||||||
|
?.addEventListener("click", () => {
|
||||||
|
// Redirect to OAuth start, will come back to /artery after auth
|
||||||
|
window.location.href =
|
||||||
|
"/artery/google_api/oauth/start?redirect=/artery";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disconnect button
|
||||||
|
document
|
||||||
|
.getElementById("btn-google-disconnect")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
await fetch("/artery/google_api/oauth/logout");
|
||||||
|
checkGoogleAuth();
|
||||||
|
googleOutputContainer.classList.remove("visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Google output helpers
|
||||||
|
function showGoogleOutput(text, isError = false) {
|
||||||
|
googleOutput.textContent = text;
|
||||||
|
googleOutput.classList.toggle("error", isError);
|
||||||
|
googleOutputContainer.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
// List Sheets
|
||||||
|
document
|
||||||
|
.getElementById("btn-list-sheets")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
const spreadsheetId = document
|
||||||
|
.getElementById("spreadsheet-id")
|
||||||
|
.value.trim();
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
showGoogleOutput(
|
||||||
|
"Error: Please enter a Spreadsheet ID",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMode =
|
||||||
|
document.getElementById("google-text-mode").checked;
|
||||||
|
showGoogleOutput("Loading...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (textMode) params.set("text", "true");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/artery/google_api/spreadsheets/${spreadsheetId}/sheets?${params}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textMode) {
|
||||||
|
showGoogleOutput(await res.text());
|
||||||
|
} else {
|
||||||
|
showGoogleOutput(
|
||||||
|
JSON.stringify(await res.json(), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showGoogleOutput("Error: " + e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Metadata
|
||||||
|
document
|
||||||
|
.getElementById("btn-get-metadata")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
const spreadsheetId = document
|
||||||
|
.getElementById("spreadsheet-id")
|
||||||
|
.value.trim();
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
showGoogleOutput(
|
||||||
|
"Error: Please enter a Spreadsheet ID",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMode =
|
||||||
|
document.getElementById("google-text-mode").checked;
|
||||||
|
showGoogleOutput("Loading...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (textMode) params.set("text", "true");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/artery/google_api/spreadsheets/${spreadsheetId}?${params}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textMode) {
|
||||||
|
showGoogleOutput(await res.text());
|
||||||
|
} else {
|
||||||
|
showGoogleOutput(
|
||||||
|
JSON.stringify(await res.json(), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showGoogleOutput("Error: " + e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Values
|
||||||
|
document
|
||||||
|
.getElementById("btn-get-values")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
const spreadsheetId = document
|
||||||
|
.getElementById("spreadsheet-id")
|
||||||
|
.value.trim();
|
||||||
|
const range = document
|
||||||
|
.getElementById("sheet-range")
|
||||||
|
.value.trim();
|
||||||
|
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
showGoogleOutput(
|
||||||
|
"Error: Please enter a Spreadsheet ID",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!range) {
|
||||||
|
showGoogleOutput(
|
||||||
|
"Error: Please enter a Range (e.g., Sheet1!A1:D10)",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMode =
|
||||||
|
document.getElementById("google-text-mode").checked;
|
||||||
|
showGoogleOutput("Loading...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("range", range);
|
||||||
|
if (textMode) params.set("text", "true");
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/artery/google_api/spreadsheets/${spreadsheetId}/values?${params}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail || res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textMode) {
|
||||||
|
showGoogleOutput(await res.text());
|
||||||
|
} else {
|
||||||
|
showGoogleOutput(
|
||||||
|
JSON.stringify(await res.json(), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showGoogleOutput("Error: " + e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
0
soleprint/artery/shunts/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/example/README.md
Normal file → Executable file
0
soleprint/artery/shunts/example/README.md
Normal file → Executable file
0
soleprint/artery/shunts/example/depot/responses.json
Normal file → Executable file
0
soleprint/artery/shunts/example/depot/responses.json
Normal file → Executable file
0
soleprint/artery/shunts/example/main.py
Normal file → Executable file
0
soleprint/artery/shunts/example/main.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/.env.example
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/.env.example
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/README.md
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/README.md
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/api/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/api/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/api/routes.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/api/routes.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/core/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/core/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/core/config.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/core/config.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/main.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/main.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/requirements.txt
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/requirements.txt
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/run.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/run.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/templates/index.html
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/templates/index.html
Normal file → Executable file
@@ -2,22 +2,59 @@
|
|||||||
API routes for Google vein.
|
API routes for Google vein.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
import json
|
||||||
from fastapi.responses import PlainTextResponse, RedirectResponse
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from core.oauth import GoogleOAuth
|
from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response
|
||||||
from core.sheets import GoogleSheetsClient, GoogleSheetsError
|
from fastapi.responses import PlainTextResponse, RedirectResponse
|
||||||
from models.spreadsheet import SpreadsheetMetadata, SheetValues
|
|
||||||
from models.formatter import format_spreadsheet_metadata, format_sheet_values
|
|
||||||
|
|
||||||
# Import from parent vein module
|
# Import shared OAuth utilities from veins parent
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
vein_path = Path(__file__).parent.parent.parent
|
|
||||||
sys.path.insert(0, str(vein_path))
|
|
||||||
from oauth import TokenStorage
|
from oauth import TokenStorage
|
||||||
|
|
||||||
|
SESSION_COOKIE = "spr_session"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_auth_config():
|
||||||
|
"""Load auth config from room config.json."""
|
||||||
|
config_path = Path("/app/cfg/config.json")
|
||||||
|
if not config_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
return config.get("auth", {})
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_user_allowed(email: str, domain: str) -> bool:
|
||||||
|
"""Check if user is allowed based on auth config."""
|
||||||
|
auth = _load_auth_config()
|
||||||
|
allowed_domains = auth.get("allowed_domains", [])
|
||||||
|
allowed_emails = auth.get("allowed_emails", [])
|
||||||
|
|
||||||
|
# No restrictions = allow all
|
||||||
|
if not allowed_domains and not allowed_emails:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check email list
|
||||||
|
if email in allowed_emails:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check domain list
|
||||||
|
if domain and domain in allowed_domains:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
from ..core.oauth import GoogleOAuth
|
||||||
|
from ..core.sheets import GoogleSheetsClient, GoogleSheetsError
|
||||||
|
from ..models.formatter import format_sheet_values, format_spreadsheet_metadata
|
||||||
|
from ..models.spreadsheet import SheetValues, SpreadsheetMetadata
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# OAuth client and token storage
|
# OAuth client and token storage
|
||||||
@@ -71,18 +108,30 @@ def _maybe_text(data, text: bool, formatter):
|
|||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
"""Check if user is authenticated."""
|
"""Check vein health and configuration status."""
|
||||||
|
from ..core.config import settings
|
||||||
|
|
||||||
|
configured = bool(settings.google_client_id and settings.google_client_secret)
|
||||||
|
if not configured:
|
||||||
|
return {
|
||||||
|
"status": "not_configured",
|
||||||
|
"configured": False,
|
||||||
|
"message": "Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tokens = token_storage.load_tokens(DEFAULT_USER_ID)
|
tokens = token_storage.load_tokens(DEFAULT_USER_ID)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return {
|
return {
|
||||||
"status": "not_authenticated",
|
"status": "not_authenticated",
|
||||||
"message": "Visit /google/oauth/start to login",
|
"configured": True,
|
||||||
|
"message": "Visit /artery/google/oauth/start to login",
|
||||||
}
|
}
|
||||||
|
|
||||||
expired = token_storage.is_expired(tokens)
|
expired = token_storage.is_expired(tokens)
|
||||||
return {
|
return {
|
||||||
"status": "ok" if not expired else "token_expired",
|
"status": "ok" if not expired else "token_expired",
|
||||||
|
"configured": True,
|
||||||
"has_refresh_token": "refresh_token" in tokens,
|
"has_refresh_token": "refresh_token" in tokens,
|
||||||
"user": DEFAULT_USER_ID,
|
"user": DEFAULT_USER_ID,
|
||||||
}
|
}
|
||||||
@@ -91,14 +140,31 @@ async def health():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/oauth/start")
|
@router.get("/oauth/start")
|
||||||
async def start_oauth(state: Optional[str] = None):
|
async def start_oauth(
|
||||||
"""Start OAuth flow - redirect to Google authorization."""
|
state: Optional[str] = None,
|
||||||
auth_url = oauth_client.get_authorization_url(state=state)
|
hd: Optional[str] = None,
|
||||||
|
redirect: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Start OAuth flow - redirect to Google authorization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: CSRF token (passed through to callback)
|
||||||
|
hd: Hosted domain hint (e.g., 'company.com') to pre-select account
|
||||||
|
redirect: URL to redirect after successful auth
|
||||||
|
"""
|
||||||
|
# Encode redirect in state if provided
|
||||||
|
full_state = state or ""
|
||||||
|
if redirect:
|
||||||
|
full_state = f"{full_state}|{redirect}" if full_state else redirect
|
||||||
|
|
||||||
|
auth_url = oauth_client.get_authorization_url(state=full_state, hd=hd)
|
||||||
return RedirectResponse(auth_url)
|
return RedirectResponse(auth_url)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/oauth/callback")
|
@router.get("/oauth/callback")
|
||||||
async def oauth_callback(
|
async def oauth_callback(
|
||||||
|
request: Request,
|
||||||
code: Optional[str] = None,
|
code: Optional[str] = None,
|
||||||
state: Optional[str] = None,
|
state: Optional[str] = None,
|
||||||
error: Optional[str] = None,
|
error: Optional[str] = None,
|
||||||
@@ -110,23 +176,123 @@ async def oauth_callback(
|
|||||||
if not code:
|
if not code:
|
||||||
raise HTTPException(400, "Missing authorization code")
|
raise HTTPException(400, "Missing authorization code")
|
||||||
|
|
||||||
|
# Extract redirect URL from state if present
|
||||||
|
# Format: "csrf_state|redirect_url" OR just "redirect_url" (path starting with /)
|
||||||
|
redirect_url = None
|
||||||
|
if state:
|
||||||
|
if "|" in state:
|
||||||
|
parts = state.split("|", 1)
|
||||||
|
redirect_url = parts[1] if len(parts) > 1 else None
|
||||||
|
else:
|
||||||
|
# No separator, state is the redirect URL itself
|
||||||
|
redirect_url = state
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tokens = oauth_client.exchange_code_for_tokens(code)
|
# First get user info to validate before storing tokens
|
||||||
token_storage.save_tokens(DEFAULT_USER_ID, tokens)
|
user_info = oauth_client.exchange_code_for_user(code)
|
||||||
return {
|
user_email = user_info.get("email", "")
|
||||||
"status": "ok",
|
user_domain = user_info.get("hd", "") # hosted domain (for Google Workspace)
|
||||||
"message": "Successfully authenticated with Google",
|
|
||||||
"user": DEFAULT_USER_ID,
|
# Check if user is allowed
|
||||||
|
if not _is_user_allowed(user_email, user_domain):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
f"Access restricted. Your account ({user_email}) is not authorized.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate session ID for this browser
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Store tokens with session ID
|
||||||
|
tokens = {
|
||||||
|
"access_token": user_info.get("access_token", ""),
|
||||||
|
"refresh_token": user_info.get("refresh_token", ""),
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"email": user_email,
|
||||||
}
|
}
|
||||||
|
if tokens["access_token"]:
|
||||||
|
token_storage.save_tokens(session_id, tokens)
|
||||||
|
|
||||||
|
# Create response with session cookie
|
||||||
|
if redirect_url:
|
||||||
|
response = RedirectResponse(url=redirect_url)
|
||||||
|
else:
|
||||||
|
response = RedirectResponse(url="/")
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
key=SESSION_COOKIE,
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=60 * 60 * 24 * 30, # 30 days
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, f"Failed to exchange code: {e}")
|
raise HTTPException(500, f"Failed to exchange code: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oauth/userinfo")
|
||||||
|
async def get_userinfo(
|
||||||
|
code: str = Query(..., description="Authorization code from callback"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Exchange code and return user info (identity flow).
|
||||||
|
|
||||||
|
Used by common/auth for login - returns user identity without storing tokens.
|
||||||
|
For API access flows, use /oauth/callback instead.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_info = oauth_client.exchange_code_for_user(code)
|
||||||
|
return user_info
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Failed to get user info: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oauth/status")
|
||||||
|
async def oauth_status(spr_session: Optional[str] = Cookie(None)):
|
||||||
|
"""Check if this browser's session is authenticated."""
|
||||||
|
try:
|
||||||
|
if not spr_session:
|
||||||
|
return {"authenticated": False}
|
||||||
|
|
||||||
|
# Load tokens for this session
|
||||||
|
tokens = token_storage.load_tokens(spr_session)
|
||||||
|
if not tokens:
|
||||||
|
return {"authenticated": False}
|
||||||
|
|
||||||
|
# Validate token content
|
||||||
|
access_token = tokens.get("access_token")
|
||||||
|
email = tokens.get("email")
|
||||||
|
|
||||||
|
if not access_token or not email:
|
||||||
|
return {"authenticated": False}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authenticated": True,
|
||||||
|
"user": {"email": email},
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {"authenticated": False}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/oauth/logout")
|
@router.get("/oauth/logout")
|
||||||
async def logout():
|
async def logout(redirect: str = "/", spr_session: Optional[str] = Cookie(None)):
|
||||||
"""Clear stored tokens."""
|
"""Clear this browser's session and redirect."""
|
||||||
token_storage.delete_tokens(DEFAULT_USER_ID)
|
response = RedirectResponse(url=redirect)
|
||||||
return {"status": "ok", "message": "Logged out"}
|
|
||||||
|
# Delete token file for this session
|
||||||
|
if spr_session:
|
||||||
|
storage_path = Path("/app/artery/veins/google/storage")
|
||||||
|
token_file = storage_path / f"tokens_{spr_session}.json"
|
||||||
|
if token_file.exists():
|
||||||
|
token_file.unlink()
|
||||||
|
|
||||||
|
# Clear the session cookie
|
||||||
|
response.delete_cookie(key=SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/spreadsheets/{spreadsheet_id}")
|
@router.get("/spreadsheets/{spreadsheet_id}")
|
||||||
|
|||||||
@@ -3,21 +3,37 @@ Google OAuth2 configuration loaded from .env file.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||||
|
|
||||||
|
# OpenID scopes for identity verification
|
||||||
|
IDENTITY_SCOPES = [
|
||||||
|
"openid",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
]
|
||||||
|
|
||||||
|
# API scopes for data access (Sheets, Drive)
|
||||||
|
API_SCOPES = [
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
||||||
|
"https://www.googleapis.com/auth/drive.readonly",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class GoogleConfig(BaseSettings):
|
class GoogleConfig(BaseSettings):
|
||||||
google_client_id: str
|
google_client_id: str = ""
|
||||||
google_client_secret: str
|
google_client_secret: str = ""
|
||||||
google_redirect_uri: str # e.g., https://artery.mcrn.ar/google/oauth/callback
|
google_redirect_uri: str = "http://localhost:12000/artery/google/oauth/callback"
|
||||||
google_scopes: str = "https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly"
|
# Default to identity-only scopes; add API scopes when needed
|
||||||
|
google_scopes: str = " ".join(IDENTITY_SCOPES)
|
||||||
api_port: int = 8003
|
api_port: int = 8003
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"env_file": ENV_FILE,
|
"env_file": ENV_FILE,
|
||||||
"env_file_encoding": "utf-8",
|
"env_file_encoding": "utf-8",
|
||||||
|
"extra": "ignore",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
Google OAuth2 flow implementation.
|
Google OAuth2 flow implementation.
|
||||||
|
|
||||||
Isolated OAuth2 client that can run without FastAPI.
|
Isolated OAuth2 client that can run without FastAPI.
|
||||||
|
Supports both identity (OpenID) and API access flows.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
|
from google.oauth2 import id_token
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from google_auth_oauthlib.flow import Flow
|
from google_auth_oauthlib.flow import Flow
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -52,21 +57,29 @@ class GoogleOAuth:
|
|||||||
)
|
)
|
||||||
return flow
|
return flow
|
||||||
|
|
||||||
def get_authorization_url(self, state: Optional[str] = None) -> str:
|
def get_authorization_url(
|
||||||
|
self, state: Optional[str] = None, hd: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate OAuth2 authorization URL.
|
Generate OAuth2 authorization URL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
state: Optional state parameter for CSRF protection
|
state: Optional state parameter for CSRF protection
|
||||||
|
hd: Hosted domain hint (e.g., 'company.com') to pre-select account
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
URL to redirect user for Google authorization
|
URL to redirect user for Google authorization
|
||||||
"""
|
"""
|
||||||
flow = self._create_flow()
|
flow = self._create_flow()
|
||||||
|
extra_params = {}
|
||||||
|
if hd:
|
||||||
|
extra_params["hd"] = hd
|
||||||
|
|
||||||
auth_url, _ = flow.authorization_url(
|
auth_url, _ = flow.authorization_url(
|
||||||
access_type="offline", # Request refresh token
|
access_type="offline", # Request refresh token
|
||||||
include_granted_scopes="true",
|
include_granted_scopes="true",
|
||||||
state=state,
|
state=state,
|
||||||
|
**extra_params,
|
||||||
)
|
)
|
||||||
return auth_url
|
return auth_url
|
||||||
|
|
||||||
@@ -97,6 +110,45 @@ class GoogleOAuth:
|
|||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def exchange_code_for_user(self, code: str) -> dict:
|
||||||
|
"""
|
||||||
|
Exchange authorization code and return user identity info.
|
||||||
|
|
||||||
|
Used for identity/login flows (OpenID Connect).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Authorization code from callback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User info dict containing:
|
||||||
|
- email
|
||||||
|
- name
|
||||||
|
- picture
|
||||||
|
- hd (hosted domain, if Google Workspace account)
|
||||||
|
"""
|
||||||
|
flow = self._create_flow()
|
||||||
|
flow.fetch_token(code=code)
|
||||||
|
|
||||||
|
credentials = flow.credentials
|
||||||
|
|
||||||
|
# Fetch user info from Google's userinfo endpoint
|
||||||
|
userinfo_response = requests.get(
|
||||||
|
"https://www.googleapis.com/oauth2/v3/userinfo",
|
||||||
|
headers={"Authorization": f"Bearer {credentials.token}"},
|
||||||
|
)
|
||||||
|
userinfo_response.raise_for_status()
|
||||||
|
userinfo = userinfo_response.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": userinfo.get("email"),
|
||||||
|
"name": userinfo.get("name"),
|
||||||
|
"picture": userinfo.get("picture"),
|
||||||
|
"hd": userinfo.get("hd"), # Hosted domain (Google Workspace)
|
||||||
|
# Include tokens for storage
|
||||||
|
"access_token": credentials.token,
|
||||||
|
"refresh_token": credentials.refresh_token,
|
||||||
|
}
|
||||||
|
|
||||||
def refresh_access_token(self, refresh_token: str) -> dict:
|
def refresh_access_token(self, refresh_token: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Refresh an expired access token.
|
Refresh an expired access token.
|
||||||
@@ -126,7 +178,9 @@ class GoogleOAuth:
|
|||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_credentials(self, access_token: str, refresh_token: Optional[str] = None) -> Credentials:
|
def get_credentials(
|
||||||
|
self, access_token: str, refresh_token: Optional[str] = None
|
||||||
|
) -> Credentials:
|
||||||
"""
|
"""
|
||||||
Create Google Credentials object from tokens.
|
Create Google Credentials object from tokens.
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,24 @@ Jira credentials loaded from .env file.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||||
|
|
||||||
|
|
||||||
class JiraConfig(BaseSettings):
|
class JiraConfig(BaseSettings):
|
||||||
jira_url: str
|
jira_url: str = "" # Required for use, optional for loading
|
||||||
jira_email: str | None = None # Optional: can be provided per-request via headers
|
jira_email: str | None = None # Optional: can be provided per-request via headers
|
||||||
jira_api_token: str | None = None # Optional: can be provided per-request via headers
|
jira_api_token: str | None = (
|
||||||
|
None # Optional: can be provided per-request via headers
|
||||||
|
)
|
||||||
api_port: int = 8001
|
api_port: int = 8001
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"env_file": ENV_FILE,
|
"env_file": ENV_FILE,
|
||||||
"env_file_encoding": "utf-8",
|
"env_file_encoding": "utf-8",
|
||||||
|
"extra": "ignore",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from datetime import datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .base import BaseVein, TClient, TCredentials
|
from base import BaseVein, TClient, TCredentials
|
||||||
|
|
||||||
|
|
||||||
class TokenStorage:
|
class TokenStorage:
|
||||||
|
|||||||
3
soleprint/common/__init__.py
Normal file
3
soleprint/common/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Common module - shared abstractions reusable across soleprint systems.
|
||||||
|
"""
|
||||||
10
soleprint/common/auth/__init__.py
Normal file
10
soleprint/common/auth/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Generic authentication framework for soleprint.
|
||||||
|
|
||||||
|
Provider-agnostic - delegates to configured provider vein (e.g., google_login).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config import AuthConfig, load_auth_config
|
||||||
|
from .session import get_current_user, require_auth
|
||||||
|
|
||||||
|
__all__ = ["AuthConfig", "load_auth_config", "get_current_user", "require_auth"]
|
||||||
44
soleprint/common/auth/config.py
Normal file
44
soleprint/common/auth/config.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Authentication configuration.
|
||||||
|
|
||||||
|
Generic config that works with any provider vein.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AuthConfig(BaseModel):
|
||||||
|
"""Authentication configuration for a room."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
provider: str = "google" # Vein name to use for auth
|
||||||
|
allowed_domains: list[str] = [] # Empty = allow any domain
|
||||||
|
allowed_emails: list[str] = [] # Specific emails to allow
|
||||||
|
session_secret: str = "" # Required if enabled, can be "ENV:VAR_NAME"
|
||||||
|
session_timeout_hours: int = 24
|
||||||
|
login_redirect: str = "/"
|
||||||
|
public_routes: list[str] = [
|
||||||
|
"/health",
|
||||||
|
"/auth/login",
|
||||||
|
"/auth/callback",
|
||||||
|
"/auth/logout",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_auth_config(config: dict) -> Optional[AuthConfig]:
|
||||||
|
"""
|
||||||
|
Load auth config from room config.json.
|
||||||
|
|
||||||
|
Returns None if auth is not enabled.
|
||||||
|
"""
|
||||||
|
auth_data = config.get("auth")
|
||||||
|
if not auth_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth_config = AuthConfig(**auth_data)
|
||||||
|
if not auth_config.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return auth_config
|
||||||
110
soleprint/common/auth/middleware.py
Normal file
110
soleprint/common/auth/middleware.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
Authentication middleware for route protection.
|
||||||
|
|
||||||
|
Generic middleware, provider-agnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from .config import AuthConfig
|
||||||
|
|
||||||
|
# Local dev bypass - set via environment variable only, can't be triggered remotely
|
||||||
|
AUTH_BYPASS = os.environ.get("AUTH_BYPASS", "").lower() == "true"
|
||||||
|
AUTH_BYPASS_USER = os.environ.get("AUTH_BYPASS_USER", "dev@local")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""
|
||||||
|
Middleware that protects routes by requiring authentication.
|
||||||
|
|
||||||
|
- Public routes (configurable) are allowed without auth
|
||||||
|
- Unauthenticated browser requests redirect to /auth/login
|
||||||
|
- Unauthenticated API requests get 401 JSON response
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app, auth_config: AuthConfig):
|
||||||
|
super().__init__(app)
|
||||||
|
self.config = auth_config
|
||||||
|
self.public_routes = set(auth_config.public_routes)
|
||||||
|
# Also allow static files and common paths
|
||||||
|
self.public_prefixes = ["/static", "/favicon", "/artery"]
|
||||||
|
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Local dev bypass - auto-authenticate
|
||||||
|
if AUTH_BYPASS:
|
||||||
|
request.state.user = {
|
||||||
|
"email": AUTH_BYPASS_USER,
|
||||||
|
"name": "Dev User",
|
||||||
|
"domain": "local",
|
||||||
|
}
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Check if route is public
|
||||||
|
if self._is_public(path):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Check session
|
||||||
|
session = request.session
|
||||||
|
user_email = session.get("user_email")
|
||||||
|
expires_at = session.get("expires_at")
|
||||||
|
|
||||||
|
if not user_email:
|
||||||
|
return self._unauthorized(request, "Not authenticated")
|
||||||
|
|
||||||
|
# Check expiry
|
||||||
|
if expires_at:
|
||||||
|
if datetime.fromisoformat(expires_at) < datetime.now():
|
||||||
|
session.clear()
|
||||||
|
return self._unauthorized(request, "Session expired")
|
||||||
|
|
||||||
|
# Check domain/email restriction
|
||||||
|
user_domain = session.get("domain")
|
||||||
|
email_allowed = user_email in self.config.allowed_emails
|
||||||
|
domain_allowed = user_domain and user_domain in self.config.allowed_domains
|
||||||
|
no_restrictions = (
|
||||||
|
not self.config.allowed_domains and not self.config.allowed_emails
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (email_allowed or domain_allowed or no_restrictions):
|
||||||
|
session.clear()
|
||||||
|
return self._unauthorized(
|
||||||
|
request,
|
||||||
|
f"Access restricted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach user to request state for downstream use
|
||||||
|
request.state.user = {
|
||||||
|
"email": user_email,
|
||||||
|
"name": session.get("user_name"),
|
||||||
|
"domain": user_domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
def _is_public(self, path: str) -> bool:
|
||||||
|
"""Check if path is public (no auth required)."""
|
||||||
|
if path in self.public_routes:
|
||||||
|
return True
|
||||||
|
for prefix in self.public_prefixes:
|
||||||
|
if path.startswith(prefix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _unauthorized(self, request, message: str):
|
||||||
|
"""Return appropriate unauthorized response."""
|
||||||
|
# API requests get JSON 401
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
if "application/json" in accept:
|
||||||
|
return JSONResponse({"error": message}, status_code=401)
|
||||||
|
|
||||||
|
# Browser requests redirect to login with return URL
|
||||||
|
next_url = str(request.url.path)
|
||||||
|
if request.url.query:
|
||||||
|
next_url += f"?{request.url.query}"
|
||||||
|
return RedirectResponse(url=f"/auth/login?next={next_url}")
|
||||||
173
soleprint/common/auth/routes.py
Normal file
173
soleprint/common/auth/routes.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
Authentication routes.
|
||||||
|
|
||||||
|
Generic routes that delegate to configured provider vein.
|
||||||
|
|
||||||
|
/auth/login - Start login flow (redirects to provider)
|
||||||
|
/auth/callback - Handle provider callback, create session
|
||||||
|
/auth/logout - Clear session
|
||||||
|
/auth/me - Get current user info
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from .config import AuthConfig
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Will be initialized by setup_auth() in run.py
|
||||||
|
auth_config: Optional[AuthConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_auth(config: AuthConfig):
|
||||||
|
"""
|
||||||
|
Initialize auth module with configuration.
|
||||||
|
|
||||||
|
Called by run.py when setting up authentication.
|
||||||
|
"""
|
||||||
|
global auth_config
|
||||||
|
auth_config = config
|
||||||
|
|
||||||
|
|
||||||
|
def _get_provider_base_url() -> str:
|
||||||
|
"""Get base URL for the configured provider vein."""
|
||||||
|
if not auth_config:
|
||||||
|
raise HTTPException(500, "Auth not configured")
|
||||||
|
# Provider is a vein name like "google_login"
|
||||||
|
return f"/artery/{auth_config.provider}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
async def login(request: Request, next: str = "/"):
|
||||||
|
"""
|
||||||
|
Start login flow.
|
||||||
|
|
||||||
|
Redirects to the configured provider vein's OAuth start endpoint.
|
||||||
|
"""
|
||||||
|
if not auth_config:
|
||||||
|
raise HTTPException(500, "Auth not configured")
|
||||||
|
|
||||||
|
# Generate CSRF state token
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
request.session["oauth_state"] = state
|
||||||
|
request.session["oauth_next"] = next
|
||||||
|
|
||||||
|
# Get domain hint from config (first allowed domain)
|
||||||
|
hd = auth_config.allowed_domains[0] if auth_config.allowed_domains else None
|
||||||
|
|
||||||
|
# Build provider OAuth URL
|
||||||
|
provider_url = _get_provider_base_url()
|
||||||
|
params = f"?state={state}"
|
||||||
|
if hd:
|
||||||
|
params += f"&hd={hd}"
|
||||||
|
|
||||||
|
# Redirect includes callback to our /auth/callback
|
||||||
|
return RedirectResponse(url=f"{provider_url}/oauth/start{params}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def callback(
|
||||||
|
request: Request,
|
||||||
|
code: Optional[str] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Handle OAuth callback.
|
||||||
|
|
||||||
|
Receives code from provider, exchanges for user info, creates session.
|
||||||
|
"""
|
||||||
|
if not auth_config:
|
||||||
|
raise HTTPException(500, "Auth not configured")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
raise HTTPException(400, f"OAuth error: {error}")
|
||||||
|
|
||||||
|
# Verify state
|
||||||
|
expected_state = request.session.get("oauth_state")
|
||||||
|
if not state or state != expected_state:
|
||||||
|
raise HTTPException(400, "Invalid state parameter")
|
||||||
|
|
||||||
|
# Call provider vein to exchange code for user info
|
||||||
|
provider_url = _get_provider_base_url()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Get base URL from request
|
||||||
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
response = await client.get(
|
||||||
|
f"{base_url}{provider_url}/oauth/userinfo",
|
||||||
|
params={"code": code},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(400, f"Provider error: {response.text}")
|
||||||
|
user_info = response.json()
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise HTTPException(500, f"Failed to contact provider: {e}")
|
||||||
|
|
||||||
|
# Verify domain/email restriction
|
||||||
|
user_email = user_info.get("email")
|
||||||
|
user_domain = user_info.get("hd")
|
||||||
|
email_allowed = user_email in auth_config.allowed_emails
|
||||||
|
domain_allowed = user_domain and user_domain in auth_config.allowed_domains
|
||||||
|
no_restrictions = not auth_config.allowed_domains and not auth_config.allowed_emails
|
||||||
|
|
||||||
|
if not (email_allowed or domain_allowed or no_restrictions):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
f"Access restricted. Your account ({user_email}) is not authorized.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
expires_at = datetime.now() + timedelta(hours=auth_config.session_timeout_hours)
|
||||||
|
request.session.update(
|
||||||
|
{
|
||||||
|
"user_email": user_info["email"],
|
||||||
|
"user_name": user_info.get("name"),
|
||||||
|
"user_picture": user_info.get("picture"),
|
||||||
|
"domain": user_domain,
|
||||||
|
"authenticated_at": datetime.now().isoformat(),
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up oauth state
|
||||||
|
request.session.pop("oauth_state", None)
|
||||||
|
next_url = request.session.pop("oauth_next", "/")
|
||||||
|
|
||||||
|
return RedirectResponse(url=next_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Clear session and redirect to login."""
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(request: Request):
|
||||||
|
"""
|
||||||
|
Return current user info.
|
||||||
|
|
||||||
|
API endpoint for checking auth status.
|
||||||
|
"""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if not user:
|
||||||
|
# Try to get from session directly (in case middleware didn't run)
|
||||||
|
user_email = request.session.get("user_email")
|
||||||
|
if not user_email:
|
||||||
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
user = {
|
||||||
|
"email": user_email,
|
||||||
|
"name": request.session.get("user_name"),
|
||||||
|
"picture": request.session.get("user_picture"),
|
||||||
|
"domain": request.session.get("domain"),
|
||||||
|
}
|
||||||
|
return user
|
||||||
51
soleprint/common/auth/session.py
Normal file
51
soleprint/common/auth/session.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Session helpers for authentication.
|
||||||
|
|
||||||
|
Generic session management, provider-agnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get current authenticated user from session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User dict with email, name, domain, etc. or None if not authenticated.
|
||||||
|
"""
|
||||||
|
session = getattr(request, "session", None)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_email = session.get("user_email")
|
||||||
|
if not user_email:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check expiry
|
||||||
|
expires_at = session.get("expires_at")
|
||||||
|
if expires_at:
|
||||||
|
if datetime.fromisoformat(expires_at) < datetime.now():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": user_email,
|
||||||
|
"name": session.get("user_name"),
|
||||||
|
"picture": session.get("user_picture"),
|
||||||
|
"domain": session.get("domain"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(request: Request) -> dict:
|
||||||
|
"""
|
||||||
|
Get current user or raise 401.
|
||||||
|
|
||||||
|
For use as FastAPI dependency.
|
||||||
|
"""
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
return user
|
||||||
@@ -1,174 +1,432 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>soleprint</title>
|
<title>soleprint</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E">
|
<link
|
||||||
<style>
|
rel="icon"
|
||||||
* { box-sizing: border-box; }
|
type="image/svg+xml"
|
||||||
html { background: #0a0a0a; }
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E"
|
||||||
body {
|
/>
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
<style>
|
||||||
max-width: 960px;
|
* {
|
||||||
margin: 0 auto;
|
box-sizing: border-box;
|
||||||
padding: 2rem 1rem;
|
}
|
||||||
line-height: 1.6;
|
html {
|
||||||
color: #e5e5e5;
|
background: #0a0a0a;
|
||||||
background: #0a0a0a;
|
}
|
||||||
}
|
body {
|
||||||
header {
|
font-family:
|
||||||
display: flex;
|
system-ui,
|
||||||
align-items: center;
|
-apple-system,
|
||||||
gap: 1rem;
|
sans-serif;
|
||||||
margin-bottom: 1rem;
|
max-width: 960px;
|
||||||
}
|
margin: 0 auto;
|
||||||
.logo { width: 64px; height: 64px; }
|
padding: 2rem 1rem;
|
||||||
h1 { font-size: 2.5rem; margin: 0; color: white; }
|
line-height: 1.6;
|
||||||
.tagline {
|
color: #e5e5e5;
|
||||||
color: #a3a3a3;
|
background: #0a0a0a;
|
||||||
margin-bottom: 2rem;
|
}
|
||||||
border-bottom: 1px solid #333;
|
/* Sidebar styles */
|
||||||
padding-bottom: 2rem;
|
.sidebar {
|
||||||
}
|
position: fixed;
|
||||||
.mission {
|
top: 0;
|
||||||
background: #1a1a1a;
|
left: 0;
|
||||||
border-left: 3px solid #d4a574;
|
width: 60px;
|
||||||
padding: 1rem 1.5rem;
|
height: 100vh;
|
||||||
margin: 2rem 0;
|
background: #1a1a1a;
|
||||||
border-radius: 0 8px 8px 0;
|
border-right: 1px solid #333;
|
||||||
color: #d4a574;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
.systems {
|
align-items: center;
|
||||||
display: grid;
|
padding: 1rem 0;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
z-index: 99999;
|
||||||
gap: 1.5rem;
|
}
|
||||||
margin: 2rem 0;
|
.sidebar-item {
|
||||||
}
|
width: 44px;
|
||||||
.system {
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 1rem;
|
justify-content: center;
|
||||||
text-decoration: none;
|
border-radius: 8px;
|
||||||
padding: 1.5rem;
|
margin-bottom: 0.5rem;
|
||||||
border-radius: 12px;
|
text-decoration: none;
|
||||||
transition: transform 0.15s, box-shadow 0.15s;
|
color: #a3a3a3;
|
||||||
}
|
transition: all 0.2s;
|
||||||
.system:hover {
|
}
|
||||||
transform: translateY(-2px);
|
a.sidebar-item:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
background: #333;
|
||||||
}
|
color: white;
|
||||||
.system.disabled {
|
}
|
||||||
opacity: 0.5;
|
.sidebar-item.active {
|
||||||
pointer-events: none;
|
background: #d4a574;
|
||||||
}
|
color: #0a0a0a;
|
||||||
.system svg { width: 48px; height: 48px; flex-shrink: 0; }
|
}
|
||||||
.system-info h2 { margin: 0 0 0.25rem 0; font-size: 1.2rem; }
|
.sidebar-item svg {
|
||||||
.system-info p { margin: 0; font-size: 0.9rem; color: #a3a3a3; }
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.sidebar-divider {
|
||||||
|
width: 32px;
|
||||||
|
height: 1px;
|
||||||
|
background: #333;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.sidebar-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.sidebar-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.sidebar-item .tooltip {
|
||||||
|
position: absolute;
|
||||||
|
left: 70px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.sidebar-item:hover .tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
body.has-sidebar {
|
||||||
|
margin-left: 60px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.tagline {
|
||||||
|
color: #a3a3a3;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.mission {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-left: 3px solid #d4a574;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: #d4a574;
|
||||||
|
}
|
||||||
|
.systems {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
.system {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.system:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.system.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.system svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.system-info h2 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.system-info p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #a3a3a3;
|
||||||
|
}
|
||||||
|
|
||||||
.artery { background: #1a1a1a; border: 1px solid #b91c1c; }
|
.artery {
|
||||||
.artery h2 { color: #fca5a5; }
|
background: #1a1a1a;
|
||||||
.artery svg { color: #b91c1c; }
|
border: 1px solid #b91c1c;
|
||||||
|
}
|
||||||
|
.artery h2 {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.artery svg {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.atlas { background: #1a1a1a; border: 1px solid #15803d; }
|
.atlas {
|
||||||
.atlas h2 { color: #86efac; }
|
background: #1a1a1a;
|
||||||
.atlas svg { color: #15803d; }
|
border: 1px solid #15803d;
|
||||||
|
}
|
||||||
|
.atlas h2 {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.atlas svg {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
.station { background: #1a1a1a; border: 1px solid #1d4ed8; }
|
.station {
|
||||||
.station h2 { color: #93c5fd; }
|
background: #1a1a1a;
|
||||||
.station svg { color: #1d4ed8; }
|
border: 1px solid #1d4ed8;
|
||||||
|
}
|
||||||
|
.station h2 {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
.station svg {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
border-top: 1px solid #333;
|
border-top: 1px solid #333;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<!-- Two shoe prints walking -->
|
|
||||||
<svg class="logo" viewBox="0 0 64 64" fill="currentColor">
|
|
||||||
<!-- Left shoe print (back, lower) -->
|
|
||||||
<g transform="rotate(-15 18 38)">
|
|
||||||
<!-- Sole -->
|
|
||||||
<ellipse cx="18" cy="32" rx="7" ry="13"/>
|
|
||||||
<!-- Heel -->
|
|
||||||
<ellipse cx="18" cy="48" rx="6" ry="7"/>
|
|
||||||
</g>
|
|
||||||
<!-- Right shoe print (front, higher) -->
|
|
||||||
<g transform="rotate(15 46 28)">
|
|
||||||
<!-- Sole -->
|
|
||||||
<ellipse cx="46" cy="22" rx="7" ry="13"/>
|
|
||||||
<!-- Heel -->
|
|
||||||
<ellipse cx="46" cy="38" rx="6" ry="7"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<h1>soleprint</h1>
|
|
||||||
</header>
|
|
||||||
<p class="tagline">Cada paso deja huella</p>
|
|
||||||
|
|
||||||
<p class="mission" style="display:none;"><!-- placeholder for session alerts --></p>
|
.showcase-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.showcase-link {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #d4a574, #b8956a);
|
||||||
|
color: #0a0a0a;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.showcase-link:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(212, 165, 116, 0.3);
|
||||||
|
}
|
||||||
|
.showcase-hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.showcase-hint:hover {
|
||||||
|
color: #d4a574;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% if managed %}
|
||||||
|
<link rel="stylesheet" href="/sidebar.css">
|
||||||
|
<script src="/sidebar.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
</head>
|
||||||
|
<body{% if managed %} class="has-sidebar"{% endif %}>
|
||||||
|
|
||||||
<div class="systems">
|
<header>
|
||||||
<a {% if artery %}href="{{ artery }}"{% endif %} class="system artery{% if not artery %} disabled{% endif %}">
|
<!-- Two shoe prints walking -->
|
||||||
<!-- Flux capacitor style -->
|
<svg class="logo" viewBox="0 0 64 64" fill="currentColor">
|
||||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.5">
|
<!-- Left shoe print (back, lower) -->
|
||||||
<path d="M24 4 L24 20 M24 20 L8 40 M24 20 L40 40"/>
|
<g transform="rotate(-15 18 38)">
|
||||||
<circle cx="24" cy="4" r="3" fill="currentColor"/>
|
<!-- Sole -->
|
||||||
<circle cx="8" cy="40" r="3" fill="currentColor"/>
|
<ellipse cx="18" cy="32" rx="7" ry="13" />
|
||||||
<circle cx="40" cy="40" r="3" fill="currentColor"/>
|
<!-- Heel -->
|
||||||
<circle cx="24" cy="20" r="5" fill="none"/>
|
<ellipse cx="18" cy="48" rx="6" ry="7" />
|
||||||
<circle cx="24" cy="20" r="2" fill="currentColor"/>
|
</g>
|
||||||
|
<!-- Right shoe print (front, higher) -->
|
||||||
|
<g transform="rotate(15 46 28)">
|
||||||
|
<!-- Sole -->
|
||||||
|
<ellipse cx="46" cy="22" rx="7" ry="13" />
|
||||||
|
<!-- Heel -->
|
||||||
|
<ellipse cx="46" cy="38" rx="6" ry="7" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="system-info">
|
<h1>soleprint</h1>
|
||||||
<h2>Artery</h2>
|
</header>
|
||||||
<p>Todo lo vital</p>
|
<p class="tagline">Cada paso deja huella</p>
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a {% if atlas %}href="{{ atlas }}"{% endif %} class="system atlas{% if not atlas %} disabled{% endif %}">
|
{% if showcase_url %}
|
||||||
<!-- Map/Atlas with compass rose -->
|
<div class="showcase-container">
|
||||||
<svg viewBox="0 0 48 48" fill="currentColor">
|
<a href="{{ showcase_url }}" class="showcase-link">Managed Room Demo</a>
|
||||||
<!-- Map fold lines -->
|
<a href="/artery" class="showcase-hint">what's a room?</a>
|
||||||
<path d="M4 8 L44 8 M4 16 L44 16 M4 24 L44 24 M4 32 L44 32 M4 40 L44 40" stroke="currentColor" stroke-width="1.5" opacity="0.3" fill="none"/>
|
<a href="https://mariano.mcrn.ar/docs/soleprint/" class="showcase-hint">see docs</a>
|
||||||
<path d="M16 4 L16 44 M32 4 L32 44" stroke="currentColor" stroke-width="1.5" opacity="0.3" fill="none"/>
|
</div>
|
||||||
<!-- Compass rose in center -->
|
{% endif %}
|
||||||
<circle cx="24" cy="24" r="8" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<path d="M24 16 L24 32 M16 24 L32 24" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<path d="M24 16 L26 20 L24 24 L22 20 Z" fill="currentColor"/><!-- North arrow -->
|
|
||||||
</svg>
|
|
||||||
<div class="system-info">
|
|
||||||
<h2>Atlas</h2>
|
|
||||||
<p>Documentación accionable</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a {% if station %}href="{{ station }}"{% endif %} class="system station{% if not station %} disabled{% endif %}">
|
<p class="mission" style="display: none">
|
||||||
<!-- Control panel with knobs and meters -->
|
<!-- placeholder for session alerts -->
|
||||||
<svg viewBox="0 0 48 48" fill="currentColor">
|
</p>
|
||||||
<!-- Panel frame -->
|
|
||||||
<rect x="4" y="8" width="40" height="32" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<!-- Knobs -->
|
|
||||||
<circle cx="14" cy="18" r="5"/>
|
|
||||||
<circle cx="14" cy="18" r="2" fill="white"/>
|
|
||||||
<circle cx="34" cy="18" r="5"/>
|
|
||||||
<circle cx="34" cy="18" r="2" fill="white"/>
|
|
||||||
<!-- Meter displays -->
|
|
||||||
<rect x="10" y="28" width="8" height="6" rx="1" fill="white" opacity="0.6"/>
|
|
||||||
<rect x="30" y="28" width="8" height="6" rx="1" fill="white" opacity="0.6"/>
|
|
||||||
<!-- Indicator lights -->
|
|
||||||
<circle cx="24" cy="14" r="2" fill="white" opacity="0.8"/>
|
|
||||||
</svg>
|
|
||||||
<div class="system-info">
|
|
||||||
<h2>Station</h2>
|
|
||||||
<p>Monitores, Entornos y Herramientas</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>soleprint</footer>
|
<div class="systems">
|
||||||
</body>
|
<a
|
||||||
|
{%
|
||||||
|
if
|
||||||
|
artery
|
||||||
|
%}href="{{ artery }}"
|
||||||
|
{%
|
||||||
|
endif
|
||||||
|
%}
|
||||||
|
class="system artery{% if not artery %} disabled{% endif %}"
|
||||||
|
>
|
||||||
|
<!-- Flux capacitor style -->
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path d="M24 4 L24 20 M24 20 L8 40 M24 20 L40 40" />
|
||||||
|
<circle cx="24" cy="4" r="3" fill="currentColor" />
|
||||||
|
<circle cx="8" cy="40" r="3" fill="currentColor" />
|
||||||
|
<circle cx="40" cy="40" r="3" fill="currentColor" />
|
||||||
|
<circle cx="24" cy="20" r="5" fill="none" />
|
||||||
|
<circle cx="24" cy="20" r="2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<div class="system-info">
|
||||||
|
<h2>Artery</h2>
|
||||||
|
<p>Todo lo vital</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{%
|
||||||
|
if
|
||||||
|
atlas
|
||||||
|
%}href="{{ atlas }}"
|
||||||
|
{%
|
||||||
|
endif
|
||||||
|
%}
|
||||||
|
class="system atlas{% if not atlas %} disabled{% endif %}"
|
||||||
|
>
|
||||||
|
<!-- Map/Atlas with compass rose -->
|
||||||
|
<svg viewBox="0 0 48 48" fill="currentColor">
|
||||||
|
<!-- Map fold lines -->
|
||||||
|
<path
|
||||||
|
d="M4 8 L44 8 M4 16 L44 16 M4 24 L44 24 M4 32 L44 32 M4 40 L44 40"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
opacity="0.3"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 4 L16 44 M32 4 L32 44"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
opacity="0.3"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<!-- Compass rose in center -->
|
||||||
|
<circle
|
||||||
|
cx="24"
|
||||||
|
cy="24"
|
||||||
|
r="8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M24 16 L24 32 M16 24 L32 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M24 16 L26 20 L24 24 L22 20 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<!-- North arrow -->
|
||||||
|
</svg>
|
||||||
|
<div class="system-info">
|
||||||
|
<h2>Atlas</h2>
|
||||||
|
<p>Documentación accionable</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{%
|
||||||
|
if
|
||||||
|
station
|
||||||
|
%}href="{{ station }}"
|
||||||
|
{%
|
||||||
|
endif
|
||||||
|
%}
|
||||||
|
class="system station{% if not station %} disabled{% endif %}"
|
||||||
|
>
|
||||||
|
<!-- Control panel with knobs and meters -->
|
||||||
|
<svg viewBox="0 0 48 48" fill="currentColor">
|
||||||
|
<!-- Panel frame -->
|
||||||
|
<rect
|
||||||
|
x="4"
|
||||||
|
y="8"
|
||||||
|
width="40"
|
||||||
|
height="32"
|
||||||
|
rx="2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<!-- Knobs -->
|
||||||
|
<circle cx="14" cy="18" r="5" />
|
||||||
|
<circle cx="14" cy="18" r="2" fill="white" />
|
||||||
|
<circle cx="34" cy="18" r="5" />
|
||||||
|
<circle cx="34" cy="18" r="2" fill="white" />
|
||||||
|
<!-- Meter displays -->
|
||||||
|
<rect
|
||||||
|
x="10"
|
||||||
|
y="28"
|
||||||
|
width="8"
|
||||||
|
height="6"
|
||||||
|
rx="1"
|
||||||
|
fill="white"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="30"
|
||||||
|
y="28"
|
||||||
|
width="8"
|
||||||
|
height="6"
|
||||||
|
rx="1"
|
||||||
|
fill="white"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<!-- Indicator lights -->
|
||||||
|
<circle cx="24" cy="14" r="2" fill="white" opacity="0.8" />
|
||||||
|
</svg>
|
||||||
|
<div class="system-info">
|
||||||
|
<h2>Station</h2>
|
||||||
|
<p>Monitores, Entornos y Herramientas</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>soleprint</footer>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
311
soleprint/run.py
311
soleprint/run.py
@@ -11,6 +11,7 @@ Usage:
|
|||||||
This is for soleprint development only, not for managed rooms (use docker for those).
|
This is for soleprint development only, not for managed rooms (use docker for those).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -34,6 +35,212 @@ DATA_DIR = SPR_ROOT / "data"
|
|||||||
CFG_DIR = SPR_ROOT / "cfg"
|
CFG_DIR = SPR_ROOT / "cfg"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Vein Loading
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Preload pip packages that share names with veins to prevent shadowing
|
||||||
|
# These must be imported BEFORE any vein that might shadow them
|
||||||
|
_preloaded_packages = []
|
||||||
|
for _pkg in ["google.auth", "jira", "slack_sdk"]:
|
||||||
|
try:
|
||||||
|
__import__(_pkg)
|
||||||
|
_preloaded_packages.append(_pkg)
|
||||||
|
except ImportError:
|
||||||
|
pass # Package not installed, vein will fail gracefully
|
||||||
|
|
||||||
|
|
||||||
|
def load_vein(vein_name: str):
|
||||||
|
"""
|
||||||
|
Load a vein's router dynamically.
|
||||||
|
|
||||||
|
Veins use relative imports for their internal modules (..core, ..models)
|
||||||
|
and absolute imports for shared utilities (oauth, base from veins/).
|
||||||
|
|
||||||
|
IMPORTANT: Vein folder names may shadow pip packages (e.g., 'google', 'jira').
|
||||||
|
We ONLY register under prefixed names (vein_google) to avoid shadowing.
|
||||||
|
Relative imports work because we set __package__ correctly on each module.
|
||||||
|
"""
|
||||||
|
vein_path = SPR_ROOT / "artery" / "veins" / vein_name
|
||||||
|
if not vein_path.exists():
|
||||||
|
raise FileNotFoundError(f"Vein not found: {vein_path}")
|
||||||
|
|
||||||
|
routes_file = vein_path / "api" / "routes.py"
|
||||||
|
if not routes_file.exists():
|
||||||
|
raise FileNotFoundError(f"Vein routes not found: {routes_file}")
|
||||||
|
|
||||||
|
# Use prefixed name to avoid shadowing pip packages
|
||||||
|
vein_prefix = f"vein_{vein_name}"
|
||||||
|
|
||||||
|
# Clear any previously loaded vein modules to avoid conflicts
|
||||||
|
for mod_name in list(sys.modules.keys()):
|
||||||
|
if mod_name == vein_prefix or mod_name.startswith(f"{vein_prefix}."):
|
||||||
|
del sys.modules[mod_name]
|
||||||
|
# Clear shared modules that might have stale references
|
||||||
|
for mod_name in ["oauth", "base"]:
|
||||||
|
if mod_name in sys.modules:
|
||||||
|
del sys.modules[mod_name]
|
||||||
|
|
||||||
|
# Add veins directory to path for shared modules (oauth.py, base.py)
|
||||||
|
veins_path = vein_path.parent
|
||||||
|
if str(veins_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(veins_path))
|
||||||
|
|
||||||
|
# Create the vein package module
|
||||||
|
vein_init = vein_path / "__init__.py"
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
vein_prefix,
|
||||||
|
vein_init if vein_init.exists() else None,
|
||||||
|
submodule_search_locations=[str(vein_path)],
|
||||||
|
)
|
||||||
|
vein_pkg = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[vein_prefix] = vein_pkg
|
||||||
|
|
||||||
|
if spec.loader:
|
||||||
|
spec.loader.exec_module(vein_pkg)
|
||||||
|
|
||||||
|
# Load subpackages (core, api, models)
|
||||||
|
for subpkg in ["core", "models", "api"]:
|
||||||
|
subpkg_path = vein_path / subpkg
|
||||||
|
if subpkg_path.exists():
|
||||||
|
_load_vein_subpackage(vein_pkg, vein_prefix, subpkg, subpkg_path)
|
||||||
|
|
||||||
|
# Load individual modules in core/ and models/ that routes.py needs
|
||||||
|
for subpkg in ["core", "models"]:
|
||||||
|
subpkg_path = vein_path / subpkg
|
||||||
|
if subpkg_path.exists():
|
||||||
|
for py_file in subpkg_path.glob("*.py"):
|
||||||
|
if py_file.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
module_name = py_file.stem
|
||||||
|
_load_vein_module(vein_pkg, vein_prefix, subpkg, module_name, py_file)
|
||||||
|
|
||||||
|
# Now load routes.py with all dependencies available
|
||||||
|
routes_mod = _load_vein_module(vein_pkg, vein_prefix, "api", "routes", routes_file)
|
||||||
|
|
||||||
|
return routes_mod.router
|
||||||
|
|
||||||
|
|
||||||
|
def _load_vein_subpackage(vein_pkg, vein_prefix, subpkg, subpkg_path):
|
||||||
|
"""Load a vein subpackage (core, api, models)."""
|
||||||
|
subpkg_init = subpkg_path / "__init__.py"
|
||||||
|
sub_spec = importlib.util.spec_from_file_location(
|
||||||
|
f"{vein_prefix}.{subpkg}",
|
||||||
|
subpkg_init if subpkg_init.exists() else None,
|
||||||
|
submodule_search_locations=[str(subpkg_path)],
|
||||||
|
)
|
||||||
|
sub_mod = importlib.util.module_from_spec(sub_spec)
|
||||||
|
sys.modules[f"{vein_prefix}.{subpkg}"] = sub_mod
|
||||||
|
setattr(vein_pkg, subpkg, sub_mod)
|
||||||
|
|
||||||
|
if sub_spec.loader:
|
||||||
|
sub_spec.loader.exec_module(sub_mod)
|
||||||
|
|
||||||
|
return sub_mod
|
||||||
|
|
||||||
|
|
||||||
|
def _load_vein_module(vein_pkg, vein_prefix, subpkg, module_name, file_path):
|
||||||
|
"""Load a specific module within a vein subpackage."""
|
||||||
|
mod_spec = importlib.util.spec_from_file_location(
|
||||||
|
f"{vein_prefix}.{subpkg}.{module_name}",
|
||||||
|
file_path,
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(mod_spec)
|
||||||
|
|
||||||
|
# Set __package__ so relative imports resolve via vein_prefix
|
||||||
|
mod.__package__ = f"{vein_prefix}.{subpkg}"
|
||||||
|
|
||||||
|
sys.modules[f"{vein_prefix}.{subpkg}.{module_name}"] = mod
|
||||||
|
|
||||||
|
# Also set on parent package
|
||||||
|
parent_pkg = getattr(vein_pkg, subpkg, None)
|
||||||
|
if parent_pkg:
|
||||||
|
setattr(parent_pkg, module_name, mod)
|
||||||
|
|
||||||
|
mod_spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def mount_veins(app):
|
||||||
|
"""Auto-discover and mount all veins from artery/veins/."""
|
||||||
|
veins_dir = SPR_ROOT / "artery" / "veins"
|
||||||
|
if not veins_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
for vein_path in sorted(veins_dir.iterdir()):
|
||||||
|
if not vein_path.is_dir():
|
||||||
|
continue
|
||||||
|
if vein_path.name.startswith(("_", ".")):
|
||||||
|
continue
|
||||||
|
# Skip non-vein directories (no api/routes.py)
|
||||||
|
if not (vein_path / "api" / "routes.py").exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
vein_name = vein_path.name
|
||||||
|
try:
|
||||||
|
router = load_vein(vein_name)
|
||||||
|
app.include_router(router, prefix=f"/artery/{vein_name}", tags=[vein_name])
|
||||||
|
print(f"Vein mounted: /artery/{vein_name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not load vein '{vein_name}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Authentication Setup (optional, based on room config)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def setup_auth(app, config: dict):
|
||||||
|
"""
|
||||||
|
Configure authentication if enabled in room config.
|
||||||
|
|
||||||
|
Auth is optional - rooms without auth config run without authentication.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from common.auth.config import load_auth_config
|
||||||
|
|
||||||
|
auth_config = load_auth_config(config)
|
||||||
|
if not auth_config:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Auth: enabled for domains {auth_config.allowed_domains or ['*']}")
|
||||||
|
|
||||||
|
# Get session secret
|
||||||
|
session_secret = auth_config.session_secret
|
||||||
|
if session_secret.startswith("ENV:"):
|
||||||
|
session_secret = os.getenv(session_secret[4:], "dev-secret-change-in-prod")
|
||||||
|
|
||||||
|
# Add session middleware
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=session_secret,
|
||||||
|
session_cookie="soleprint_session",
|
||||||
|
max_age=auth_config.session_timeout_hours * 3600,
|
||||||
|
same_site="lax",
|
||||||
|
https_only=os.getenv("HTTPS_ONLY", "").lower() == "true",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize auth routes
|
||||||
|
from common.auth.routes import init_auth
|
||||||
|
from common.auth.routes import router as auth_router
|
||||||
|
|
||||||
|
init_auth(auth_config)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
print("Auth: routes mounted at /auth")
|
||||||
|
|
||||||
|
# Add auth middleware
|
||||||
|
from common.auth.middleware import AuthMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(AuthMiddleware, auth_config=auth_config)
|
||||||
|
except ImportError:
|
||||||
|
# common.auth not available (standalone without auth)
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Auth setup error: {e}")
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict:
|
def load_config() -> dict:
|
||||||
"""Load config.json from cfg/ directory."""
|
"""Load config.json from cfg/ directory."""
|
||||||
config_path = CFG_DIR / "config.json"
|
config_path = CFG_DIR / "config.json"
|
||||||
@@ -91,6 +298,15 @@ def scan_directory(base_path: Path, pattern: str = "*") -> list[dict]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Initialize: Load config, setup auth, mount veins
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
_config = load_config()
|
||||||
|
setup_auth(app, _config)
|
||||||
|
mount_veins(app)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {
|
return {
|
||||||
@@ -135,6 +351,10 @@ def artery_index(request: Request):
|
|||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
|
||||||
|
# Load rooms and depots
|
||||||
|
rooms = load_data("rooms.json") or []
|
||||||
|
depots = load_data("depots.json") or []
|
||||||
|
|
||||||
template = Template(html_path.read_text())
|
template = Template(html_path.read_text())
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
template.render(
|
template.render(
|
||||||
@@ -145,6 +365,8 @@ def artery_index(request: Request):
|
|||||||
pulses=pulses,
|
pulses=pulses,
|
||||||
shunts=shunts,
|
shunts=shunts,
|
||||||
plexuses=plexuses,
|
plexuses=plexuses,
|
||||||
|
rooms=rooms,
|
||||||
|
depots=depots,
|
||||||
soleprint_url="/",
|
soleprint_url="/",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -296,20 +518,107 @@ def station_route(path: str):
|
|||||||
return {"system": "station", "path": path}
|
return {"system": "station", "path": path}
|
||||||
|
|
||||||
|
|
||||||
|
# === Sidebar Wrapper (served at /spr/* when proxied) ===
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sidebar.css")
|
||||||
|
def sidebar_css():
|
||||||
|
"""Serve sidebar CSS for injection."""
|
||||||
|
css_path = SPR_ROOT / "station" / "tools" / "sbwrapper" / "sidebar.css"
|
||||||
|
if css_path.exists():
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
return Response(content=css_path.read_text(), media_type="text/css")
|
||||||
|
return Response(content="/* sidebar.css not found */", media_type="text/css")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sidebar.js")
|
||||||
|
def sidebar_js():
|
||||||
|
"""Serve sidebar JS for injection."""
|
||||||
|
js_path = SPR_ROOT / "station" / "tools" / "sbwrapper" / "sidebar.js"
|
||||||
|
if js_path.exists():
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=js_path.read_text(), media_type="application/javascript"
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content="/* sidebar.js not found */", media_type="application/javascript"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sidebar/config")
|
||||||
|
def sidebar_config(request: Request):
|
||||||
|
"""Return sidebar configuration for the current room."""
|
||||||
|
config = load_config()
|
||||||
|
managed = config.get("managed", {})
|
||||||
|
auth = config.get("auth", {})
|
||||||
|
|
||||||
|
# Get soleprint URL (where tools are)
|
||||||
|
host = request.headers.get("host", "localhost")
|
||||||
|
host_no_port = host.split(":")[0]
|
||||||
|
scheme = request.headers.get("x-forwarded-proto", "http")
|
||||||
|
|
||||||
|
# Soleprint tools are at root (no prefix needed)
|
||||||
|
soleprint_base = ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"room": managed.get("name", "standalone"),
|
||||||
|
"soleprint_base": soleprint_base,
|
||||||
|
"auth_enabled": auth.get("enabled", False),
|
||||||
|
"veins": config.get("veins", []),
|
||||||
|
"auth": {
|
||||||
|
"login_url": f"{soleprint_base}/artery/google/oauth/start",
|
||||||
|
"status_url": f"{soleprint_base}/artery/google/oauth/status",
|
||||||
|
"logout_url": f"{soleprint_base}/artery/google/oauth/logout",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# === Main ===
|
# === Main ===
|
||||||
|
|
||||||
|
|
||||||
|
def get_managed_url(request: Request, managed: dict) -> str | None:
|
||||||
|
"""
|
||||||
|
Derive managed app URL from current host.
|
||||||
|
|
||||||
|
Pattern: <room>.spr.<domain> -> <room>.<domain>
|
||||||
|
localhost:port -> None (no managed URL for direct port access)
|
||||||
|
"""
|
||||||
|
if not managed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
host = request.headers.get("host", "localhost")
|
||||||
|
host_no_port = host.split(":")[0]
|
||||||
|
|
||||||
|
# Check if host matches pattern: <name>.spr.<domain>
|
||||||
|
if ".spr." in host_no_port:
|
||||||
|
# Replace .spr. with . to get managed app domain
|
||||||
|
managed_host = host_no_port.replace(".spr.", ".")
|
||||||
|
scheme = request.headers.get("x-forwarded-proto", "http")
|
||||||
|
return f"{scheme}://{managed_host}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index(request: Request):
|
def index(request: Request):
|
||||||
"""Landing page with links to subsystems."""
|
"""Landing page with links to subsystems."""
|
||||||
|
config = load_config()
|
||||||
|
managed = config.get("managed", {})
|
||||||
|
managed_url = get_managed_url(request, managed)
|
||||||
|
showcase_url = config.get("showcase_url")
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"index.html",
|
"index.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
# In bare-metal mode, all routes are internal
|
|
||||||
"artery": "/artery",
|
"artery": "/artery",
|
||||||
"atlas": "/atlas",
|
"atlas": "/atlas",
|
||||||
"station": "/station",
|
"station": "/station",
|
||||||
|
"managed": managed,
|
||||||
|
"managed_url": managed_url,
|
||||||
|
"showcase_url": showcase_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,38 @@ Modelgen - Generic Model Generation Tool
|
|||||||
Generates typed models from various sources to various output formats.
|
Generates typed models from various sources to various output formats.
|
||||||
|
|
||||||
Input sources:
|
Input sources:
|
||||||
- Configuration files (soleprint.config.json style)
|
- Configuration files (soleprint config.json style)
|
||||||
- JSON Schema (planned)
|
- Python dataclasses in schema/ folder
|
||||||
- Existing codebases: Django, SQLAlchemy, Prisma (planned - for databrowse)
|
- Existing codebases: Django, SQLAlchemy, Prisma (for extraction)
|
||||||
|
|
||||||
Output formats:
|
Output formats:
|
||||||
- pydantic: Pydantic BaseModel classes
|
- pydantic: Pydantic BaseModel classes
|
||||||
- django: Django ORM models (planned)
|
- django: Django ORM models
|
||||||
- prisma: Prisma schema (planned)
|
- typescript: TypeScript interfaces
|
||||||
- sqlalchemy: SQLAlchemy models (planned)
|
- protobuf: Protocol Buffer definitions
|
||||||
|
- prisma: Prisma schema
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m station.tools.modelgen from-config -c config.json -o models.py -f pydantic
|
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
|
||||||
python -m station.tools.modelgen list-formats
|
python -m soleprint.station.tools.modelgen from-schema -o models/ --targets pydantic,typescript
|
||||||
|
python -m soleprint.station.tools.modelgen extract --source /path/to/django --targets pydantic
|
||||||
|
python -m soleprint.station.tools.modelgen list-formats
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
from .config_loader import ConfigLoader, load_config
|
from .generator import GENERATORS, BaseGenerator
|
||||||
from .model_generator import WRITERS, ModelGenerator
|
from .loader import ConfigLoader, load_config
|
||||||
|
from .model_generator import ModelGenerator
|
||||||
|
|
||||||
__all__ = ["ModelGenerator", "ConfigLoader", "load_config", "WRITERS"]
|
# Backwards compatibility
|
||||||
|
WRITERS = GENERATORS
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ModelGenerator",
|
||||||
|
"ConfigLoader",
|
||||||
|
"load_config",
|
||||||
|
"GENERATORS",
|
||||||
|
"WRITERS",
|
||||||
|
"BaseGenerator",
|
||||||
|
]
|
||||||
|
|||||||
@@ -4,34 +4,34 @@ Modelgen - Generic Model Generation Tool
|
|||||||
Generates typed models from various sources to various formats.
|
Generates typed models from various sources to various formats.
|
||||||
|
|
||||||
Input sources:
|
Input sources:
|
||||||
- Configuration files (soleprint.config.json style)
|
- from-config: Configuration files (soleprint config.json style)
|
||||||
- JSON Schema (planned)
|
- from-schema: Python dataclasses in schema/ folder
|
||||||
- Existing codebases: Django, SQLAlchemy, Prisma (planned - for databrowse)
|
- extract: Existing codebases (Django, SQLAlchemy, Prisma)
|
||||||
|
|
||||||
Output formats:
|
Output formats:
|
||||||
- pydantic: Pydantic BaseModel classes
|
- pydantic: Pydantic BaseModel classes
|
||||||
- django: Django ORM models (planned)
|
- django: Django ORM models
|
||||||
- prisma: Prisma schema (planned)
|
- typescript: TypeScript interfaces
|
||||||
- sqlalchemy: SQLAlchemy models (planned)
|
- protobuf: Protocol Buffer definitions
|
||||||
|
- prisma: Prisma schema
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m station.tools.modelgen --help
|
python -m soleprint.station.tools.modelgen --help
|
||||||
python -m station.tools.modelgen from-config -c config.json -o models/ -f pydantic
|
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
|
||||||
python -m station.tools.modelgen from-schema -s schema.json -o models/ -f pydantic
|
python -m soleprint.station.tools.modelgen from-schema -o models/ --targets pydantic,typescript
|
||||||
python -m station.tools.modelgen extract -s /path/to/django/app -o models/ -f pydantic
|
python -m soleprint.station.tools.modelgen extract --source /path/to/django --targets pydantic
|
||||||
|
|
||||||
This is a GENERIC tool. For soleprint-specific builds, use:
|
|
||||||
python build.py dev|deploy
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .generator import GENERATORS
|
||||||
|
|
||||||
|
|
||||||
def cmd_from_config(args):
|
def cmd_from_config(args):
|
||||||
"""Generate models from a configuration file (soleprint.config.json style)."""
|
"""Generate models from a configuration file (soleprint config.json style)."""
|
||||||
from .config_loader import load_config
|
from .loader import load_config
|
||||||
from .model_generator import ModelGenerator
|
from .model_generator import ModelGenerator
|
||||||
|
|
||||||
config_path = Path(args.config)
|
config_path = Path(args.config)
|
||||||
@@ -52,35 +52,121 @@ def cmd_from_config(args):
|
|||||||
)
|
)
|
||||||
result_path = generator.generate()
|
result_path = generator.generate()
|
||||||
|
|
||||||
print(f"✓ Models generated: {result_path}")
|
print(f"Models generated: {result_path}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_from_schema(args):
|
def cmd_from_schema(args):
|
||||||
"""Generate models from JSON Schema."""
|
"""Generate models from Python dataclasses in schema/ folder."""
|
||||||
print("Error: from-schema not yet implemented", file=sys.stderr)
|
from .loader import load_schema
|
||||||
print("Use from-config with a soleprint.config.json file for now", file=sys.stderr)
|
from .writer import write_file
|
||||||
sys.exit(1)
|
|
||||||
|
# Determine schema path
|
||||||
|
schema_path = Path(args.schema) if args.schema else Path.cwd() / "schema"
|
||||||
|
|
||||||
|
if not schema_path.exists():
|
||||||
|
print(f"Error: Schema folder not found: {schema_path}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"Create a schema/ folder with Python dataclasses and an __init__.py",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print("that exports DATACLASSES and ENUMS lists.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Loading schema: {schema_path}")
|
||||||
|
schema = load_schema(schema_path)
|
||||||
|
|
||||||
|
print(f"Found {len(schema.models)} models, {len(schema.enums)} enums")
|
||||||
|
|
||||||
|
# Parse targets
|
||||||
|
targets = [t.strip() for t in args.targets.split(",")]
|
||||||
|
output_dir = Path(args.output)
|
||||||
|
|
||||||
|
for target in targets:
|
||||||
|
if target not in GENERATORS:
|
||||||
|
print(f"Warning: Unknown target '{target}', skipping", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
generator = GENERATORS[target]()
|
||||||
|
ext = generator.file_extension()
|
||||||
|
|
||||||
|
# Determine output filename (use target name to avoid overwrites)
|
||||||
|
if len(targets) == 1 and args.output.endswith(ext):
|
||||||
|
output_file = output_dir
|
||||||
|
else:
|
||||||
|
output_file = output_dir / f"models_{target}{ext}"
|
||||||
|
|
||||||
|
print(f"Generating {target} to: {output_file}")
|
||||||
|
generator.generate(schema, output_file)
|
||||||
|
|
||||||
|
print("Done!")
|
||||||
|
|
||||||
|
|
||||||
def cmd_extract(args):
|
def cmd_extract(args):
|
||||||
"""Extract models from existing codebase (for databrowse graphs)."""
|
"""Extract models from existing codebase."""
|
||||||
print("Error: extract not yet implemented", file=sys.stderr)
|
from .loader.extract import EXTRACTORS
|
||||||
print(
|
|
||||||
"This will extract models from Django/SQLAlchemy/Prisma codebases.",
|
source_path = Path(args.source)
|
||||||
file=sys.stderr,
|
if not source_path.exists():
|
||||||
)
|
print(f"Error: Source path not found: {source_path}", file=sys.stderr)
|
||||||
print("Use cases:", file=sys.stderr)
|
sys.exit(1)
|
||||||
print(" - Generate browsable graphs for databrowse tool", file=sys.stderr)
|
|
||||||
print(" - Convert between ORM formats", file=sys.stderr)
|
# Auto-detect or use specified framework
|
||||||
sys.exit(1)
|
framework = args.framework
|
||||||
|
extractor = None
|
||||||
|
|
||||||
|
if framework == "auto":
|
||||||
|
for name, extractor_cls in EXTRACTORS.items():
|
||||||
|
ext = extractor_cls(source_path)
|
||||||
|
if ext.detect():
|
||||||
|
framework = name
|
||||||
|
extractor = ext
|
||||||
|
print(f"Detected framework: {framework}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not extractor:
|
||||||
|
print("Error: Could not auto-detect framework", file=sys.stderr)
|
||||||
|
print(f"Available frameworks: {list(EXTRACTORS.keys())}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
if framework not in EXTRACTORS:
|
||||||
|
print(f"Error: Unknown framework: {framework}", file=sys.stderr)
|
||||||
|
print(f"Available: {list(EXTRACTORS.keys())}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
extractor = EXTRACTORS[framework](source_path)
|
||||||
|
|
||||||
|
print(f"Extracting from: {source_path}")
|
||||||
|
models, enums = extractor.extract()
|
||||||
|
|
||||||
|
print(f"Extracted {len(models)} models, {len(enums)} enums")
|
||||||
|
|
||||||
|
# Parse targets
|
||||||
|
targets = [t.strip() for t in args.targets.split(",")]
|
||||||
|
output_dir = Path(args.output)
|
||||||
|
|
||||||
|
for target in targets:
|
||||||
|
if target not in GENERATORS:
|
||||||
|
print(f"Warning: Unknown target '{target}', skipping", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
generator = GENERATORS[target]()
|
||||||
|
ext = generator.file_extension()
|
||||||
|
|
||||||
|
# Determine output filename (use target name to avoid overwrites)
|
||||||
|
if len(targets) == 1 and args.output.endswith(ext):
|
||||||
|
output_file = output_dir
|
||||||
|
else:
|
||||||
|
output_file = output_dir / f"models_{target}{ext}"
|
||||||
|
|
||||||
|
print(f"Generating {target} to: {output_file}")
|
||||||
|
generator.generate((models, enums), output_file)
|
||||||
|
|
||||||
|
print("Done!")
|
||||||
|
|
||||||
|
|
||||||
def cmd_list_formats(args):
|
def cmd_list_formats(args):
|
||||||
"""List available output formats."""
|
"""List available output formats."""
|
||||||
from .model_generator import ModelGenerator
|
|
||||||
|
|
||||||
print("Available output formats:")
|
print("Available output formats:")
|
||||||
for fmt in ModelGenerator.available_formats():
|
for fmt in GENERATORS.keys():
|
||||||
print(f" - {fmt}")
|
print(f" - {fmt}")
|
||||||
|
|
||||||
|
|
||||||
@@ -88,22 +174,25 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Modelgen - Generic Model Generation Tool",
|
description="Modelgen - Generic Model Generation Tool",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog=__doc__,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
# Available formats for help text
|
||||||
|
formats = list(GENERATORS.keys())
|
||||||
|
formats_str = ", ".join(formats)
|
||||||
|
|
||||||
# from-config command
|
# from-config command
|
||||||
config_parser = subparsers.add_parser(
|
config_parser = subparsers.add_parser(
|
||||||
"from-config",
|
"from-config",
|
||||||
help="Generate models from configuration file",
|
help="Generate models from soleprint configuration file",
|
||||||
)
|
)
|
||||||
config_parser.add_argument(
|
config_parser.add_argument(
|
||||||
"--config",
|
"--config",
|
||||||
"-c",
|
"-c",
|
||||||
type=str,
|
type=str,
|
||||||
required=True,
|
required=True,
|
||||||
help="Path to configuration file (e.g., soleprint.config.json)",
|
help="Path to configuration file (e.g., config.json)",
|
||||||
)
|
)
|
||||||
config_parser.add_argument(
|
config_parser.add_argument(
|
||||||
"--output",
|
"--output",
|
||||||
@@ -117,22 +206,22 @@ def main():
|
|||||||
"-f",
|
"-f",
|
||||||
type=str,
|
type=str,
|
||||||
default="pydantic",
|
default="pydantic",
|
||||||
choices=["pydantic", "django", "prisma", "sqlalchemy"],
|
choices=["pydantic"], # Only pydantic for config mode
|
||||||
help="Output format (default: pydantic)",
|
help="Output format (default: pydantic)",
|
||||||
)
|
)
|
||||||
config_parser.set_defaults(func=cmd_from_config)
|
config_parser.set_defaults(func=cmd_from_config)
|
||||||
|
|
||||||
# from-schema command (placeholder)
|
# from-schema command
|
||||||
schema_parser = subparsers.add_parser(
|
schema_parser = subparsers.add_parser(
|
||||||
"from-schema",
|
"from-schema",
|
||||||
help="Generate models from JSON Schema (not yet implemented)",
|
help="Generate models from Python dataclasses in schema/ folder",
|
||||||
)
|
)
|
||||||
schema_parser.add_argument(
|
schema_parser.add_argument(
|
||||||
"--schema",
|
"--schema",
|
||||||
"-s",
|
"-s",
|
||||||
type=str,
|
type=str,
|
||||||
required=True,
|
default=None,
|
||||||
help="Path to JSON Schema file",
|
help="Path to schema folder (default: ./schema)",
|
||||||
)
|
)
|
||||||
schema_parser.add_argument(
|
schema_parser.add_argument(
|
||||||
"--output",
|
"--output",
|
||||||
@@ -142,19 +231,18 @@ def main():
|
|||||||
help="Output path (file or directory)",
|
help="Output path (file or directory)",
|
||||||
)
|
)
|
||||||
schema_parser.add_argument(
|
schema_parser.add_argument(
|
||||||
"--format",
|
"--targets",
|
||||||
"-f",
|
"-t",
|
||||||
type=str,
|
type=str,
|
||||||
default="pydantic",
|
default="pydantic",
|
||||||
choices=["pydantic", "django", "prisma", "sqlalchemy"],
|
help=f"Comma-separated output targets ({formats_str})",
|
||||||
help="Output format (default: pydantic)",
|
|
||||||
)
|
)
|
||||||
schema_parser.set_defaults(func=cmd_from_schema)
|
schema_parser.set_defaults(func=cmd_from_schema)
|
||||||
|
|
||||||
# extract command (placeholder for databrowse)
|
# extract command
|
||||||
extract_parser = subparsers.add_parser(
|
extract_parser = subparsers.add_parser(
|
||||||
"extract",
|
"extract",
|
||||||
help="Extract models from existing codebase (not yet implemented)",
|
help="Extract models from existing codebase",
|
||||||
)
|
)
|
||||||
extract_parser.add_argument(
|
extract_parser.add_argument(
|
||||||
"--source",
|
"--source",
|
||||||
@@ -165,10 +253,11 @@ def main():
|
|||||||
)
|
)
|
||||||
extract_parser.add_argument(
|
extract_parser.add_argument(
|
||||||
"--framework",
|
"--framework",
|
||||||
|
"-f",
|
||||||
type=str,
|
type=str,
|
||||||
choices=["django", "sqlalchemy", "prisma", "auto"],
|
choices=["django", "sqlalchemy", "prisma", "auto"],
|
||||||
default="auto",
|
default="auto",
|
||||||
help="Source framework to extract from (default: auto-detect)",
|
help="Source framework (default: auto-detect)",
|
||||||
)
|
)
|
||||||
extract_parser.add_argument(
|
extract_parser.add_argument(
|
||||||
"--output",
|
"--output",
|
||||||
@@ -178,12 +267,11 @@ def main():
|
|||||||
help="Output path (file or directory)",
|
help="Output path (file or directory)",
|
||||||
)
|
)
|
||||||
extract_parser.add_argument(
|
extract_parser.add_argument(
|
||||||
"--format",
|
"--targets",
|
||||||
"-f",
|
"-t",
|
||||||
type=str,
|
type=str,
|
||||||
default="pydantic",
|
default="pydantic",
|
||||||
choices=["pydantic", "django", "prisma", "sqlalchemy"],
|
help=f"Comma-separated output targets ({formats_str})",
|
||||||
help="Output format (default: pydantic)",
|
|
||||||
)
|
)
|
||||||
extract_parser.set_defaults(func=cmd_extract)
|
extract_parser.set_defaults(func=cmd_extract)
|
||||||
|
|
||||||
|
|||||||
40
soleprint/station/tools/modelgen/generator/__init__.py
Normal file
40
soleprint/station/tools/modelgen/generator/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Generator - Stack-specific code generators for modelgen.
|
||||||
|
|
||||||
|
Supported generators:
|
||||||
|
- PydanticGenerator: Pydantic BaseModel classes
|
||||||
|
- DjangoGenerator: Django ORM models
|
||||||
|
- TypeScriptGenerator: TypeScript interfaces
|
||||||
|
- ProtobufGenerator: Protocol Buffer definitions
|
||||||
|
- PrismaGenerator: Prisma schema
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Type
|
||||||
|
|
||||||
|
from .base import BaseGenerator
|
||||||
|
from .django import DjangoGenerator
|
||||||
|
from .prisma import PrismaGenerator
|
||||||
|
from .protobuf import ProtobufGenerator
|
||||||
|
from .pydantic import PydanticGenerator
|
||||||
|
from .typescript import TypeScriptGenerator
|
||||||
|
|
||||||
|
# Registry of available generators
|
||||||
|
GENERATORS: Dict[str, Type[BaseGenerator]] = {
|
||||||
|
"pydantic": PydanticGenerator,
|
||||||
|
"django": DjangoGenerator,
|
||||||
|
"typescript": TypeScriptGenerator,
|
||||||
|
"ts": TypeScriptGenerator, # Alias
|
||||||
|
"protobuf": ProtobufGenerator,
|
||||||
|
"proto": ProtobufGenerator, # Alias
|
||||||
|
"prisma": PrismaGenerator,
|
||||||
|
}
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseGenerator",
|
||||||
|
"PydanticGenerator",
|
||||||
|
"DjangoGenerator",
|
||||||
|
"TypeScriptGenerator",
|
||||||
|
"ProtobufGenerator",
|
||||||
|
"PrismaGenerator",
|
||||||
|
"GENERATORS",
|
||||||
|
]
|
||||||
23
soleprint/station/tools/modelgen/generator/base.py
Normal file
23
soleprint/station/tools/modelgen/generator/base.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
Base Generator
|
||||||
|
|
||||||
|
Abstract base class for all code generators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class BaseGenerator(ABC):
|
||||||
|
"""Abstract base for code generators."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, models: Any, output_path: Path) -> None:
|
||||||
|
"""Generate code for the given models to the specified path."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
"""Return the file extension for this format."""
|
||||||
|
pass
|
||||||
268
soleprint/station/tools/modelgen/generator/django.py
Normal file
268
soleprint/station/tools/modelgen/generator/django.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Django Generator
|
||||||
|
|
||||||
|
Generates Django ORM models from model definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dataclasses as dc
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, get_type_hints
|
||||||
|
|
||||||
|
from ..helpers import format_opts, get_origin_name, get_type_name, unwrap_optional
|
||||||
|
from ..loader.schema import EnumDefinition, ModelDefinition
|
||||||
|
from ..types import DJANGO_SPECIAL, DJANGO_TYPES
|
||||||
|
from .base import BaseGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class DjangoGenerator(BaseGenerator):
|
||||||
|
"""Generates Django ORM model files."""
|
||||||
|
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
return ".py"
|
||||||
|
|
||||||
|
def generate(self, models, output_path: Path) -> None:
|
||||||
|
"""Generate Django models to output_path."""
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Handle different input types
|
||||||
|
if hasattr(models, "models"):
|
||||||
|
# SchemaLoader or similar
|
||||||
|
content = self._generate_from_definitions(
|
||||||
|
models.models, getattr(models, "enums", [])
|
||||||
|
)
|
||||||
|
elif isinstance(models, tuple):
|
||||||
|
# (models, enums) tuple
|
||||||
|
content = self._generate_from_definitions(models[0], models[1])
|
||||||
|
elif isinstance(models, list):
|
||||||
|
# List of dataclasses (MPR style)
|
||||||
|
content = self._generate_from_dataclasses(models)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported input type: {type(models)}")
|
||||||
|
|
||||||
|
output_path.write_text(content)
|
||||||
|
|
||||||
|
def _generate_from_definitions(
|
||||||
|
self, models: List[ModelDefinition], enums: List[EnumDefinition]
|
||||||
|
) -> str:
|
||||||
|
"""Generate from ModelDefinition objects."""
|
||||||
|
lines = self._generate_header()
|
||||||
|
|
||||||
|
# Generate enums as TextChoices
|
||||||
|
for enum_def in enums:
|
||||||
|
lines.extend(self._generate_text_choices(enum_def))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Generate models
|
||||||
|
for model_def in models:
|
||||||
|
lines.extend(self._generate_model_from_definition(model_def))
|
||||||
|
lines.extend(["", ""])
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
|
||||||
|
"""Generate from Python dataclasses (MPR style)."""
|
||||||
|
lines = self._generate_header()
|
||||||
|
|
||||||
|
for cls in dataclasses:
|
||||||
|
lines.extend(self._generate_model_from_dataclass(cls))
|
||||||
|
lines.extend(["", ""])
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_header(self) -> List[str]:
|
||||||
|
"""Generate file header."""
|
||||||
|
return [
|
||||||
|
'"""',
|
||||||
|
"Django ORM Models - GENERATED FILE",
|
||||||
|
"",
|
||||||
|
"Do not edit directly. Regenerate using modelgen.",
|
||||||
|
'"""',
|
||||||
|
"",
|
||||||
|
"import uuid",
|
||||||
|
"from django.db import models",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _generate_text_choices(self, enum_def: EnumDefinition) -> List[str]:
|
||||||
|
"""Generate Django TextChoices from EnumDefinition."""
|
||||||
|
lines = [
|
||||||
|
f"class {enum_def.name}(models.TextChoices):",
|
||||||
|
]
|
||||||
|
for name, value in enum_def.values:
|
||||||
|
label = name.replace("_", " ").title()
|
||||||
|
lines.append(f' {name} = "{value}", "{label}"')
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_model_from_definition(self, model_def: ModelDefinition) -> List[str]:
|
||||||
|
"""Generate Django model from ModelDefinition."""
|
||||||
|
docstring = model_def.docstring or model_def.name
|
||||||
|
lines = [
|
||||||
|
f"class {model_def.name}(models.Model):",
|
||||||
|
f' """{docstring.strip().split(chr(10))[0]}"""',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in model_def.fields:
|
||||||
|
django_field = self._resolve_field_type(
|
||||||
|
field.name, field.type_hint, field.default, field.optional
|
||||||
|
)
|
||||||
|
lines.append(f" {field.name} = {django_field}")
|
||||||
|
|
||||||
|
# Add Meta and __str__
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
" class Meta:",
|
||||||
|
' ordering = ["-created_at"]'
|
||||||
|
if any(f.name == "created_at" for f in model_def.fields)
|
||||||
|
else " pass",
|
||||||
|
"",
|
||||||
|
" def __str__(self):",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine __str__ return
|
||||||
|
field_names = [f.name for f in model_def.fields]
|
||||||
|
if "filename" in field_names:
|
||||||
|
lines.append(" return self.filename")
|
||||||
|
elif "name" in field_names:
|
||||||
|
lines.append(" return self.name")
|
||||||
|
else:
|
||||||
|
lines.append(" return str(self.id)")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_model_from_dataclass(self, cls: type) -> List[str]:
|
||||||
|
"""Generate Django model from a dataclass (MPR style)."""
|
||||||
|
docstring = cls.__doc__ or cls.__name__
|
||||||
|
lines = [
|
||||||
|
f"class {cls.__name__}(models.Model):",
|
||||||
|
f' """{docstring.strip().split(chr(10))[0]}"""',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
hints = get_type_hints(cls)
|
||||||
|
fields = {f.name: f for f in dc.fields(cls)}
|
||||||
|
|
||||||
|
# Check for enums and add Status inner class if needed
|
||||||
|
for type_hint in hints.values():
|
||||||
|
base, _ = unwrap_optional(type_hint)
|
||||||
|
if isinstance(base, type) and issubclass(base, Enum):
|
||||||
|
lines.append(" class Status(models.TextChoices):")
|
||||||
|
for member in base:
|
||||||
|
label = member.name.replace("_", " ").title()
|
||||||
|
lines.append(f' {member.name} = "{member.value}", "{label}"')
|
||||||
|
lines.append("")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Generate fields
|
||||||
|
for name, type_hint in hints.items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
field = fields.get(name)
|
||||||
|
default = dc.MISSING
|
||||||
|
if field and field.default is not dc.MISSING:
|
||||||
|
default = field.default
|
||||||
|
django_field = self._resolve_field_type(name, type_hint, default, False)
|
||||||
|
lines.append(f" {name} = {django_field}")
|
||||||
|
|
||||||
|
# Add Meta and __str__
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
" class Meta:",
|
||||||
|
' ordering = ["-created_at"]'
|
||||||
|
if "created_at" in hints
|
||||||
|
else " pass",
|
||||||
|
"",
|
||||||
|
" def __str__(self):",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if "filename" in hints:
|
||||||
|
lines.append(" return self.filename")
|
||||||
|
elif "name" in hints:
|
||||||
|
lines.append(" return self.name")
|
||||||
|
else:
|
||||||
|
lines.append(" return str(self.id)")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _resolve_field_type(
|
||||||
|
self, name: str, type_hint: Any, default: Any, optional: bool
|
||||||
|
) -> str:
|
||||||
|
"""Resolve Python type to Django field."""
|
||||||
|
# Special fields
|
||||||
|
if name in DJANGO_SPECIAL:
|
||||||
|
return DJANGO_SPECIAL[name]
|
||||||
|
|
||||||
|
base, is_optional = unwrap_optional(type_hint)
|
||||||
|
optional = optional or is_optional
|
||||||
|
origin = get_origin_name(base)
|
||||||
|
type_name = get_type_name(base)
|
||||||
|
opts = format_opts(optional)
|
||||||
|
|
||||||
|
# Container types
|
||||||
|
if origin == "dict":
|
||||||
|
return DJANGO_TYPES["dict"]
|
||||||
|
if origin == "list":
|
||||||
|
return DJANGO_TYPES["list"]
|
||||||
|
|
||||||
|
# UUID / datetime
|
||||||
|
if type_name == "UUID":
|
||||||
|
return DJANGO_TYPES["UUID"].format(opts=opts)
|
||||||
|
if type_name == "datetime":
|
||||||
|
return DJANGO_TYPES["datetime"].format(opts=opts)
|
||||||
|
|
||||||
|
# Enum
|
||||||
|
if isinstance(base, type) and issubclass(base, Enum):
|
||||||
|
extra = []
|
||||||
|
if optional:
|
||||||
|
extra.append("null=True, blank=True")
|
||||||
|
if default is not dc.MISSING and isinstance(default, Enum):
|
||||||
|
extra.append(f"default=Status.{default.name}")
|
||||||
|
return DJANGO_TYPES["enum"].format(
|
||||||
|
opts=", " + ", ".join(extra) if extra else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Text fields (based on name heuristics)
|
||||||
|
if base is str and any(
|
||||||
|
x in name for x in ("message", "comments", "description")
|
||||||
|
):
|
||||||
|
return DJANGO_TYPES["text"]
|
||||||
|
|
||||||
|
# BigInt fields
|
||||||
|
if base is int and name in ("file_size", "bitrate"):
|
||||||
|
return DJANGO_TYPES["bigint"].format(opts=opts)
|
||||||
|
|
||||||
|
# String with max_length
|
||||||
|
if base is str:
|
||||||
|
max_length = 1000 if "path" in name else 500 if "filename" in name else 255
|
||||||
|
return DJANGO_TYPES[str].format(
|
||||||
|
max_length=max_length, opts=", " + opts if opts else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Integer
|
||||||
|
if base is int:
|
||||||
|
extra = [opts] if opts else []
|
||||||
|
if default is not dc.MISSING and not callable(default):
|
||||||
|
extra.append(f"default={default}")
|
||||||
|
return DJANGO_TYPES[int].format(opts=", ".join(extra))
|
||||||
|
|
||||||
|
# Float
|
||||||
|
if base is float:
|
||||||
|
extra = [opts] if opts else []
|
||||||
|
if default is not dc.MISSING and not callable(default):
|
||||||
|
extra.append(f"default={default}")
|
||||||
|
return DJANGO_TYPES[float].format(opts=", ".join(extra))
|
||||||
|
|
||||||
|
# Boolean
|
||||||
|
if base is bool:
|
||||||
|
default_val = default if default is not dc.MISSING else False
|
||||||
|
return DJANGO_TYPES[bool].format(default=default_val)
|
||||||
|
|
||||||
|
# Fallback to CharField
|
||||||
|
return DJANGO_TYPES[str].format(
|
||||||
|
max_length=255, opts=", " + opts if opts else ""
|
||||||
|
)
|
||||||
173
soleprint/station/tools/modelgen/generator/prisma.py
Normal file
173
soleprint/station/tools/modelgen/generator/prisma.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
Prisma Generator
|
||||||
|
|
||||||
|
Generates Prisma schema from model definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, get_type_hints
|
||||||
|
|
||||||
|
from ..helpers import get_origin_name, get_type_name, unwrap_optional
|
||||||
|
from ..loader.schema import EnumDefinition, ModelDefinition
|
||||||
|
from ..types import PRISMA_SPECIAL, PRISMA_TYPES
|
||||||
|
from .base import BaseGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class PrismaGenerator(BaseGenerator):
|
||||||
|
"""Generates Prisma schema files."""
|
||||||
|
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
return ".prisma"
|
||||||
|
|
||||||
|
def generate(self, models, output_path: Path) -> None:
|
||||||
|
"""Generate Prisma schema to output_path."""
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Handle different input types
|
||||||
|
if hasattr(models, "models"):
|
||||||
|
# SchemaLoader
|
||||||
|
content = self._generate_from_definitions(
|
||||||
|
models.models, getattr(models, "enums", [])
|
||||||
|
)
|
||||||
|
elif isinstance(models, tuple):
|
||||||
|
# (models, enums) tuple
|
||||||
|
content = self._generate_from_definitions(models[0], models[1])
|
||||||
|
elif isinstance(models, list):
|
||||||
|
# List of dataclasses (MPR style)
|
||||||
|
content = self._generate_from_dataclasses(models)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported input type: {type(models)}")
|
||||||
|
|
||||||
|
output_path.write_text(content)
|
||||||
|
|
||||||
|
def _generate_from_definitions(
|
||||||
|
self, models: List[ModelDefinition], enums: List[EnumDefinition]
|
||||||
|
) -> str:
|
||||||
|
"""Generate from ModelDefinition objects."""
|
||||||
|
lines = self._generate_header()
|
||||||
|
|
||||||
|
# Generate enums
|
||||||
|
for enum_def in enums:
|
||||||
|
lines.extend(self._generate_enum(enum_def))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Generate models
|
||||||
|
for model_def in models:
|
||||||
|
lines.extend(self._generate_model_from_definition(model_def))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
|
||||||
|
"""Generate from Python dataclasses (MPR style)."""
|
||||||
|
lines = self._generate_header()
|
||||||
|
|
||||||
|
# Collect and generate enums first
|
||||||
|
enums_generated = set()
|
||||||
|
for cls in dataclasses:
|
||||||
|
hints = get_type_hints(cls)
|
||||||
|
for type_hint in hints.values():
|
||||||
|
base, _ = unwrap_optional(type_hint)
|
||||||
|
if isinstance(base, type) and issubclass(base, Enum):
|
||||||
|
if base.__name__ not in enums_generated:
|
||||||
|
lines.extend(self._generate_enum_from_python(base))
|
||||||
|
lines.append("")
|
||||||
|
enums_generated.add(base.__name__)
|
||||||
|
|
||||||
|
# Generate models
|
||||||
|
for cls in dataclasses:
|
||||||
|
lines.extend(self._generate_model_from_dataclass(cls))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_header(self) -> List[str]:
|
||||||
|
"""Generate file header with datasource and generator."""
|
||||||
|
return [
|
||||||
|
"// Prisma Schema - GENERATED FILE",
|
||||||
|
"//",
|
||||||
|
"// Do not edit directly. Regenerate using modelgen.",
|
||||||
|
"",
|
||||||
|
"generator client {",
|
||||||
|
' provider = "prisma-client-py"',
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"datasource db {",
|
||||||
|
' provider = "postgresql"',
|
||||||
|
' url = env("DATABASE_URL")',
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _generate_enum(self, enum_def: EnumDefinition) -> List[str]:
|
||||||
|
"""Generate Prisma enum from EnumDefinition."""
|
||||||
|
lines = [f"enum {enum_def.name} {{"]
|
||||||
|
for name, _ in enum_def.values:
|
||||||
|
lines.append(f" {name}")
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_enum_from_python(self, enum_cls: type) -> List[str]:
|
||||||
|
"""Generate Prisma enum from Python Enum."""
|
||||||
|
lines = [f"enum {enum_cls.__name__} {{"]
|
||||||
|
for member in enum_cls:
|
||||||
|
lines.append(f" {member.name}")
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_model_from_definition(self, model_def: ModelDefinition) -> List[str]:
|
||||||
|
"""Generate Prisma model from ModelDefinition."""
|
||||||
|
lines = [f"model {model_def.name} {{"]
|
||||||
|
|
||||||
|
for field in model_def.fields:
|
||||||
|
prisma_type = self._resolve_type(
|
||||||
|
field.name, field.type_hint, field.optional
|
||||||
|
)
|
||||||
|
lines.append(f" {field.name} {prisma_type}")
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_model_from_dataclass(self, cls: type) -> List[str]:
|
||||||
|
"""Generate Prisma model from a dataclass."""
|
||||||
|
lines = [f"model {cls.__name__} {{"]
|
||||||
|
|
||||||
|
for name, type_hint in get_type_hints(cls).items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
prisma_type = self._resolve_type(name, type_hint, False)
|
||||||
|
lines.append(f" {name} {prisma_type}")
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _resolve_type(self, name: str, type_hint: Any, optional: bool) -> str:
|
||||||
|
"""Resolve Python type to Prisma type string."""
|
||||||
|
# Special fields
|
||||||
|
if name in PRISMA_SPECIAL:
|
||||||
|
return PRISMA_SPECIAL[name]
|
||||||
|
|
||||||
|
base, is_optional = unwrap_optional(type_hint)
|
||||||
|
optional = optional or is_optional
|
||||||
|
origin = get_origin_name(base)
|
||||||
|
type_name = get_type_name(base)
|
||||||
|
|
||||||
|
# Container types
|
||||||
|
if origin == "dict" or origin == "list":
|
||||||
|
result = PRISMA_TYPES.get(origin, "Json")
|
||||||
|
return f"{result}?" if optional else result
|
||||||
|
|
||||||
|
# UUID / datetime
|
||||||
|
if type_name in ("UUID", "datetime"):
|
||||||
|
result = PRISMA_TYPES.get(type_name, "String")
|
||||||
|
return f"{result}?" if optional else result
|
||||||
|
|
||||||
|
# Enum
|
||||||
|
if isinstance(base, type) and issubclass(base, Enum):
|
||||||
|
result = base.__name__
|
||||||
|
return f"{result}?" if optional else result
|
||||||
|
|
||||||
|
# Basic types
|
||||||
|
result = PRISMA_TYPES.get(base, "String")
|
||||||
|
return f"{result}?" if optional else result
|
||||||
168
soleprint/station/tools/modelgen/generator/protobuf.py
Normal file
168
soleprint/station/tools/modelgen/generator/protobuf.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Protobuf Generator
|
||||||
|
|
||||||
|
Generates Protocol Buffer definitions from model definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, get_type_hints
|
||||||
|
|
||||||
|
from ..helpers import get_origin_name, unwrap_optional
|
||||||
|
from ..loader.schema import GrpcServiceDefinition, ModelDefinition
|
||||||
|
from ..types import PROTO_RESOLVERS
|
||||||
|
from .base import BaseGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class ProtobufGenerator(BaseGenerator):
|
||||||
|
"""Generates Protocol Buffer definition files."""
|
||||||
|
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
return ".proto"
|
||||||
|
|
||||||
|
def generate(self, models, output_path: Path) -> None:
|
||||||
|
"""Generate protobuf definitions to output_path."""
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Handle different input types
|
||||||
|
if hasattr(models, "grpc_messages"):
|
||||||
|
# SchemaLoader with gRPC definitions
|
||||||
|
content = self._generate_from_loader(models)
|
||||||
|
elif isinstance(models, tuple) and len(models) >= 3:
|
||||||
|
# (messages, service_def) tuple
|
||||||
|
content = self._generate_from_definitions(models[0], models[1])
|
||||||
|
elif isinstance(models, list):
|
||||||
|
# List of dataclasses (MPR style)
|
||||||
|
content = self._generate_from_dataclasses(models)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported input type: {type(models)}")
|
||||||
|
|
||||||
|
output_path.write_text(content)
|
||||||
|
|
||||||
|
def _generate_from_loader(self, loader) -> str:
|
||||||
|
"""Generate from SchemaLoader."""
|
||||||
|
messages = loader.grpc_messages
|
||||||
|
service = loader.grpc_service
|
||||||
|
|
||||||
|
lines = self._generate_header(
|
||||||
|
service.package if service else "service",
|
||||||
|
service.name if service else "Service",
|
||||||
|
service.methods if service else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
for model_def in messages:
|
||||||
|
lines.extend(self._generate_message_from_definition(model_def))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_from_definitions(
|
||||||
|
self, messages: List[ModelDefinition], service: GrpcServiceDefinition
|
||||||
|
) -> str:
|
||||||
|
"""Generate from ModelDefinition objects."""
|
||||||
|
lines = self._generate_header(service.package, service.name, service.methods)
|
||||||
|
|
||||||
|
for model_def in messages:
|
||||||
|
lines.extend(self._generate_message_from_definition(model_def))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
|
||||||
|
"""Generate from Python dataclasses (MPR style)."""
|
||||||
|
lines = self._generate_header("service", "Service", [])
|
||||||
|
|
||||||
|
for cls in dataclasses:
|
||||||
|
lines.extend(self._generate_message_from_dataclass(cls))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_header(
|
||||||
|
self, package: str, service_name: str, methods: List[dict]
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate file header with service definition."""
|
||||||
|
lines = [
|
||||||
|
"// Protocol Buffer Definitions - GENERATED FILE",
|
||||||
|
"//",
|
||||||
|
"// Do not edit directly. Regenerate using modelgen.",
|
||||||
|
"",
|
||||||
|
'syntax = "proto3";',
|
||||||
|
"",
|
||||||
|
f"package {package};",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if methods:
|
||||||
|
lines.append(f"service {service_name} {{")
|
||||||
|
for m in methods:
|
||||||
|
req = (
|
||||||
|
m["request"].__name__
|
||||||
|
if hasattr(m["request"], "__name__")
|
||||||
|
else str(m["request"])
|
||||||
|
)
|
||||||
|
resp = (
|
||||||
|
m["response"].__name__
|
||||||
|
if hasattr(m["response"], "__name__")
|
||||||
|
else str(m["response"])
|
||||||
|
)
|
||||||
|
returns = f"stream {resp}" if m.get("stream_response") else resp
|
||||||
|
lines.append(f" rpc {m['name']}({req}) returns ({returns});")
|
||||||
|
lines.extend(["}", ""])
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_message_from_definition(
|
||||||
|
self, model_def: ModelDefinition
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate proto message from ModelDefinition."""
|
||||||
|
lines = [f"message {model_def.name} {{"]
|
||||||
|
|
||||||
|
if not model_def.fields:
|
||||||
|
lines.append(" // Empty")
|
||||||
|
else:
|
||||||
|
for i, field in enumerate(model_def.fields, 1):
|
||||||
|
proto_type, optional = self._resolve_type(field.type_hint)
|
||||||
|
prefix = (
|
||||||
|
"optional "
|
||||||
|
if optional and not proto_type.startswith("repeated")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
lines.append(f" {prefix}{proto_type} {field.name} = {i};")
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_message_from_dataclass(self, cls: type) -> List[str]:
|
||||||
|
"""Generate proto message from a dataclass."""
|
||||||
|
lines = [f"message {cls.__name__} {{"]
|
||||||
|
|
||||||
|
hints = get_type_hints(cls)
|
||||||
|
if not hints:
|
||||||
|
lines.append(" // Empty")
|
||||||
|
else:
|
||||||
|
for i, (name, type_hint) in enumerate(hints.items(), 1):
|
||||||
|
proto_type, optional = self._resolve_type(type_hint)
|
||||||
|
prefix = (
|
||||||
|
"optional "
|
||||||
|
if optional and not proto_type.startswith("repeated")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
lines.append(f" {prefix}{proto_type} {name} = {i};")
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _resolve_type(self, type_hint: Any) -> tuple[str, bool]:
|
||||||
|
"""Resolve Python type to proto type. Returns (type, is_optional)."""
|
||||||
|
base, optional = unwrap_optional(type_hint)
|
||||||
|
origin = get_origin_name(base)
|
||||||
|
|
||||||
|
# Look up resolver
|
||||||
|
resolver = PROTO_RESOLVERS.get(origin) or PROTO_RESOLVERS.get(base)
|
||||||
|
|
||||||
|
if resolver:
|
||||||
|
result = resolver(base)
|
||||||
|
is_repeated = result.startswith("repeated")
|
||||||
|
return result, optional and not is_repeated
|
||||||
|
|
||||||
|
return "string", optional
|
||||||
427
soleprint/station/tools/modelgen/generator/pydantic.py
Normal file
427
soleprint/station/tools/modelgen/generator/pydantic.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""
|
||||||
|
Pydantic Generator
|
||||||
|
|
||||||
|
Generates Pydantic BaseModel classes from model definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, get_type_hints
|
||||||
|
|
||||||
|
from ..helpers import get_origin_name, get_type_name, unwrap_optional
|
||||||
|
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
|
||||||
|
from ..types import PYDANTIC_RESOLVERS
|
||||||
|
from .base import BaseGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class PydanticGenerator(BaseGenerator):
|
||||||
|
"""Generates Pydantic model files."""
|
||||||
|
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
return ".py"
|
||||||
|
|
||||||
|
def generate(self, models, output_path: Path) -> None:
|
||||||
|
"""Generate Pydantic models to output_path."""
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Detect input type and generate accordingly
|
||||||
|
if hasattr(models, "get_shared_component"):
|
||||||
|
# ConfigLoader (soleprint config)
|
||||||
|
content = self._generate_from_config(models)
|
||||||
|
elif hasattr(models, "models"):
|
||||||
|
# SchemaLoader
|
||||||
|
content = self._generate_from_definitions(
|
||||||
|
models.models, getattr(models, "enums", [])
|
||||||
|
)
|
||||||
|
elif isinstance(models, tuple):
|
||||||
|
# (models, enums) tuple from extractor
|
||||||
|
content = self._generate_from_definitions(models[0], models[1])
|
||||||
|
elif isinstance(models, list):
|
||||||
|
# List of dataclasses (MPR style)
|
||||||
|
content = self._generate_from_dataclasses(models)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported input type: {type(models)}")
|
||||||
|
|
||||||
|
output_path.write_text(content)
|
||||||
|
|
||||||
|
def _generate_from_definitions(
|
||||||
|
self, models: List[ModelDefinition], enums: List[EnumDefinition]
|
||||||
|
) -> str:
|
||||||
|
"""Generate from ModelDefinition objects (schema/extract mode)."""
|
||||||
|
lines = self._generate_header()
|
||||||
|
|
||||||
|
# Generate enums
|
||||||
|
for enum_def in enums:
|
||||||
|
lines.extend(self._generate_enum(enum_def))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Generate models
|
||||||
|
for model_def in models:
|
||||||
|
lines.extend(self._generate_model_from_definition(model_def))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
|
||||||
|
"""Generate from Python dataclasses (MPR style)."""
|
||||||
|
lines = self._generate_header()
|
||||||
|
|
||||||
|
# Collect and generate enums first
|
||||||
|
enums_generated = set()
|
||||||
|
for cls in dataclasses:
|
||||||
|
hints = get_type_hints(cls)
|
||||||
|
for type_hint in hints.values():
|
||||||
|
base, _ = unwrap_optional(type_hint)
|
||||||
|
if isinstance(base, type) and issubclass(base, Enum):
|
||||||
|
if base.__name__ not in enums_generated:
|
||||||
|
lines.extend(self._generate_enum_from_python(base))
|
||||||
|
lines.append("")
|
||||||
|
enums_generated.add(base.__name__)
|
||||||
|
|
||||||
|
# Generate models
|
||||||
|
for cls in dataclasses:
|
||||||
|
lines.extend(self._generate_model_from_dataclass(cls))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_header(self) -> List[str]:
|
||||||
|
"""Generate file header."""
|
||||||
|
return [
|
||||||
|
'"""',
|
||||||
|
"Pydantic Models - GENERATED FILE",
|
||||||
|
"",
|
||||||
|
"Do not edit directly. Regenerate using modelgen.",
|
||||||
|
'"""',
|
||||||
|
"",
|
||||||
|
"from datetime import datetime",
|
||||||
|
"from enum import Enum",
|
||||||
|
"from typing import Any, Dict, List, Optional",
|
||||||
|
"from uuid import UUID",
|
||||||
|
"",
|
||||||
|
"from pydantic import BaseModel, Field",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _generate_enum(self, enum_def: EnumDefinition) -> List[str]:
|
||||||
|
"""Generate Pydantic enum from EnumDefinition."""
|
||||||
|
lines = [f"class {enum_def.name}(str, Enum):"]
|
||||||
|
for name, value in enum_def.values:
|
||||||
|
lines.append(f' {name} = "{value}"')
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_enum_from_python(self, enum_cls: type) -> List[str]:
|
||||||
|
"""Generate Pydantic enum from Python Enum."""
|
||||||
|
lines = [f"class {enum_cls.__name__}(str, Enum):"]
|
||||||
|
for member in enum_cls:
|
||||||
|
lines.append(f' {member.name} = "{member.value}"')
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_model_from_definition(self, model_def: ModelDefinition) -> List[str]:
|
||||||
|
"""Generate Pydantic model from ModelDefinition."""
|
||||||
|
docstring = model_def.docstring or model_def.name
|
||||||
|
lines = [
|
||||||
|
f"class {model_def.name}(BaseModel):",
|
||||||
|
f' """{docstring.strip().split(chr(10))[0]}"""',
|
||||||
|
]
|
||||||
|
|
||||||
|
if not model_def.fields:
|
||||||
|
lines.append(" pass")
|
||||||
|
else:
|
||||||
|
for field in model_def.fields:
|
||||||
|
py_type = self._resolve_type(field.type_hint, field.optional)
|
||||||
|
default = self._format_default(field.default, field.optional)
|
||||||
|
lines.append(f" {field.name}: {py_type}{default}")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_model_from_dataclass(self, cls: type) -> List[str]:
|
||||||
|
"""Generate Pydantic model from a dataclass."""
|
||||||
|
import dataclasses as dc
|
||||||
|
|
||||||
|
docstring = cls.__doc__ or cls.__name__
|
||||||
|
lines = [
|
||||||
|
f"class {cls.__name__}(BaseModel):",
|
||||||
|
f' """{docstring.strip().split(chr(10))[0]}"""',
|
||||||
|
]
|
||||||
|
|
||||||
|
hints = get_type_hints(cls)
|
||||||
|
fields = {f.name: f for f in dc.fields(cls)}
|
||||||
|
|
||||||
|
for name, type_hint in hints.items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
field = fields.get(name)
|
||||||
|
default_val = dc.MISSING
|
||||||
|
if field:
|
||||||
|
if field.default is not dc.MISSING:
|
||||||
|
default_val = field.default
|
||||||
|
|
||||||
|
py_type = self._resolve_type(type_hint, False)
|
||||||
|
default = self._format_default(default_val, "Optional" in py_type)
|
||||||
|
lines.append(f" {name}: {py_type}{default}")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _resolve_type(self, type_hint: Any, optional: bool) -> str:
|
||||||
|
"""Resolve Python type to Pydantic type string."""
|
||||||
|
base, is_optional = unwrap_optional(type_hint)
|
||||||
|
optional = optional or is_optional
|
||||||
|
origin = get_origin_name(base)
|
||||||
|
type_name = get_type_name(base)
|
||||||
|
|
||||||
|
# Look up resolver
|
||||||
|
resolver = (
|
||||||
|
PYDANTIC_RESOLVERS.get(origin)
|
||||||
|
or PYDANTIC_RESOLVERS.get(type_name)
|
||||||
|
or PYDANTIC_RESOLVERS.get(base)
|
||||||
|
or (
|
||||||
|
PYDANTIC_RESOLVERS["enum"]
|
||||||
|
if isinstance(base, type) and issubclass(base, Enum)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resolver(base) if resolver else "str"
|
||||||
|
return f"Optional[{result}]" if optional else result
|
||||||
|
|
||||||
|
def _format_default(self, default: Any, optional: bool) -> str:
|
||||||
|
"""Format default value for field."""
|
||||||
|
import dataclasses as dc
|
||||||
|
|
||||||
|
if optional:
|
||||||
|
return " = None"
|
||||||
|
if default is dc.MISSING or default is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(default, str):
|
||||||
|
return f' = "{default}"'
|
||||||
|
if isinstance(default, Enum):
|
||||||
|
return f" = {default.__class__.__name__}.{default.name}"
|
||||||
|
if callable(default):
|
||||||
|
return " = Field(default_factory=list)" if "list" in str(default) else ""
|
||||||
|
return f" = {default!r}"
|
||||||
|
|
||||||
|
def _generate_from_config(self, config) -> str:
|
||||||
|
"""Generate from ConfigLoader (soleprint config.json mode)."""
|
||||||
|
# Get component names from config
|
||||||
|
config_comp = config.get_shared_component("config")
|
||||||
|
data_comp = config.get_shared_component("data")
|
||||||
|
|
||||||
|
data_flow_sys = config.get_system("data_flow")
|
||||||
|
doc_sys = config.get_system("documentation")
|
||||||
|
exec_sys = config.get_system("execution")
|
||||||
|
|
||||||
|
connector_comp = config.get_component("data_flow", "connector")
|
||||||
|
pulse_comp = config.get_component("data_flow", "composed")
|
||||||
|
|
||||||
|
pattern_comp = config.get_component("documentation", "pattern")
|
||||||
|
doc_composed = config.get_component("documentation", "composed")
|
||||||
|
|
||||||
|
tool_comp = config.get_component("execution", "utility")
|
||||||
|
monitor_comp = config.get_component("execution", "watcher")
|
||||||
|
cabinet_comp = config.get_component("execution", "container")
|
||||||
|
exec_composed = config.get_component("execution", "composed")
|
||||||
|
|
||||||
|
return f'''"""
|
||||||
|
Pydantic models - Generated from {config.framework.name}.config.json
|
||||||
|
|
||||||
|
DO NOT EDIT MANUALLY - Regenerate from config
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Status(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
PLANNED = "planned"
|
||||||
|
BUILDING = "building"
|
||||||
|
DEV = "dev"
|
||||||
|
LIVE = "live"
|
||||||
|
READY = "ready"
|
||||||
|
|
||||||
|
|
||||||
|
class System(str, Enum):
|
||||||
|
{data_flow_sys.name.upper()} = "{data_flow_sys.name}"
|
||||||
|
{doc_sys.name.upper()} = "{doc_sys.name}"
|
||||||
|
{exec_sys.name.upper()} = "{exec_sys.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolType(str, Enum):
|
||||||
|
APP = "app"
|
||||||
|
CLI = "cli"
|
||||||
|
|
||||||
|
|
||||||
|
# === Shared Components ===
|
||||||
|
|
||||||
|
|
||||||
|
class {config_comp.title}(BaseModel):
|
||||||
|
"""{config_comp.description}. Shared across {data_flow_sys.name}, {exec_sys.name}."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
config_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class {data_comp.title}(BaseModel):
|
||||||
|
"""{data_comp.description}. Shared across all systems."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
source_template: Optional[str] = None
|
||||||
|
data_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# === System-Specific Components ===
|
||||||
|
|
||||||
|
|
||||||
|
class {connector_comp.title}(BaseModel):
|
||||||
|
"""{connector_comp.description} ({data_flow_sys.name})."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
system: Literal["{data_flow_sys.name}"] = "{data_flow_sys.name}"
|
||||||
|
mock: Optional[bool] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class {pattern_comp.title}(BaseModel):
|
||||||
|
"""{pattern_comp.description} ({doc_sys.name})."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
template_path: Optional[str] = None
|
||||||
|
system: Literal["{doc_sys.name}"] = "{doc_sys.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class {tool_comp.title}(BaseModel):
|
||||||
|
"""{tool_comp.description} ({exec_sys.name})."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
|
||||||
|
type: Optional[ToolType] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
path: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
cli: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class {monitor_comp.title}(BaseModel):
|
||||||
|
"""{monitor_comp.description} ({exec_sys.name})."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class {cabinet_comp.title}(BaseModel):
|
||||||
|
"""{cabinet_comp.description} ({exec_sys.name})."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
tools: List[{tool_comp.title}] = Field(default_factory=list)
|
||||||
|
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
|
||||||
|
|
||||||
|
|
||||||
|
# === Composed Types ===
|
||||||
|
|
||||||
|
|
||||||
|
class {pulse_comp.title}(BaseModel):
|
||||||
|
"""{pulse_comp.description} ({data_flow_sys.name}). Formula: {pulse_comp.formula}."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
{connector_comp.name}: Optional[{connector_comp.title}] = None
|
||||||
|
{config_comp.name}: Optional[{config_comp.title}] = None
|
||||||
|
{data_comp.name}: Optional[{data_comp.title}] = None
|
||||||
|
system: Literal["{data_flow_sys.name}"] = "{data_flow_sys.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class {doc_composed.title}(BaseModel):
|
||||||
|
"""{doc_composed.description} ({doc_sys.name}). Formula: {doc_composed.formula}."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
template: Optional[{pattern_comp.title}] = None
|
||||||
|
{data_comp.name}: Optional[{data_comp.title}] = None
|
||||||
|
output_{data_comp.name}: Optional[{data_comp.title}] = None
|
||||||
|
system: Literal["{doc_sys.name}"] = "{doc_sys.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class {exec_composed.title}(BaseModel):
|
||||||
|
"""{exec_composed.description} ({exec_sys.name}). Formula: {exec_composed.formula}."""
|
||||||
|
|
||||||
|
name: str # Unique identifier
|
||||||
|
slug: str # URL-friendly identifier
|
||||||
|
title: str # Display title for UI
|
||||||
|
status: Optional[Status] = None
|
||||||
|
cabinet: Optional[{cabinet_comp.title}] = None
|
||||||
|
{config_comp.name}: Optional[{config_comp.title}] = None
|
||||||
|
{data_comp.plural}: List[{data_comp.title}] = Field(default_factory=list)
|
||||||
|
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
|
||||||
|
|
||||||
|
|
||||||
|
# === Collection wrappers for JSON files ===
|
||||||
|
|
||||||
|
|
||||||
|
class {config_comp.title}Collection(BaseModel):
|
||||||
|
items: List[{config_comp.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {data_comp.title}Collection(BaseModel):
|
||||||
|
items: List[{data_comp.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {connector_comp.title}Collection(BaseModel):
|
||||||
|
items: List[{connector_comp.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {pattern_comp.title}Collection(BaseModel):
|
||||||
|
items: List[{pattern_comp.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {tool_comp.title}Collection(BaseModel):
|
||||||
|
items: List[{tool_comp.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {monitor_comp.title}Collection(BaseModel):
|
||||||
|
items: List[{monitor_comp.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {cabinet_comp.title}Collection(BaseModel):
|
||||||
|
items: List[{cabinet_comp.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {pulse_comp.title}Collection(BaseModel):
|
||||||
|
items: List[{pulse_comp.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {doc_composed.title}Collection(BaseModel):
|
||||||
|
items: List[{doc_composed.title}] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class {exec_composed.title}Collection(BaseModel):
|
||||||
|
items: List[{exec_composed.title}] = Field(default_factory=list)
|
||||||
|
'''
|
||||||
144
soleprint/station/tools/modelgen/generator/typescript.py
Normal file
144
soleprint/station/tools/modelgen/generator/typescript.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
TypeScript Generator
|
||||||
|
|
||||||
|
Generates TypeScript interfaces from model definitions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, get_type_hints
|
||||||
|
|
||||||
|
from ..helpers import get_origin_name, get_type_name, unwrap_optional
|
||||||
|
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
|
||||||
|
from ..types import TS_RESOLVERS
|
||||||
|
from .base import BaseGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class TypeScriptGenerator(BaseGenerator):
|
||||||
|
"""Generates TypeScript interface files."""
|
||||||
|
|
||||||
|
def file_extension(self) -> str:
|
||||||
|
return ".ts"
|
||||||
|
|
||||||
|
def generate(self, models, output_path: Path) -> None:
|
||||||
|
"""Generate TypeScript types to output_path."""
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Handle different input types
|
||||||
|
if hasattr(models, "models"):
|
||||||
|
# SchemaLoader
|
||||||
|
content = self._generate_from_definitions(
|
||||||
|
models.models, getattr(models, "enums", [])
|
||||||
|
)
|
||||||
|
elif isinstance(models, tuple):
|
||||||
|
# (models, enums) tuple
|
||||||
|
content = self._generate_from_definitions(models[0], models[1])
|
||||||
|
elif isinstance(models, list):
|
||||||
|
# List of dataclasses (MPR style)
|
||||||
|
content = self._generate_from_dataclasses(models)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported input type: {type(models)}")
|
||||||
|
|
||||||
|
output_path.write_text(content)
|
||||||
|
|
||||||
|
def _generate_from_definitions(
|
||||||
|
self, models: List[ModelDefinition], enums: List[EnumDefinition]
|
||||||
|
) -> str:
|
||||||
|
"""Generate from ModelDefinition objects."""
|
||||||
|
lines = self._generate_header()
|
||||||
|
|
||||||
|
# Generate enums as union types
|
||||||
|
for enum_def in enums:
|
||||||
|
values = " | ".join(f'"{v}"' for _, v in enum_def.values)
|
||||||
|
lines.append(f"export type {enum_def.name} = {values};")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Generate interfaces
|
||||||
|
for model_def in models:
|
||||||
|
lines.extend(self._generate_interface_from_definition(model_def))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
|
||||||
|
"""Generate from Python dataclasses (MPR style)."""
|
||||||
|
lines = self._generate_header()
|
||||||
|
|
||||||
|
# Collect and generate enums first
|
||||||
|
enums_generated = set()
|
||||||
|
for cls in dataclasses:
|
||||||
|
hints = get_type_hints(cls)
|
||||||
|
for type_hint in hints.values():
|
||||||
|
base, _ = unwrap_optional(type_hint)
|
||||||
|
if isinstance(base, type) and issubclass(base, Enum):
|
||||||
|
if base.__name__ not in enums_generated:
|
||||||
|
values = " | ".join(f'"{m.value}"' for m in base)
|
||||||
|
lines.append(f"export type {base.__name__} = {values};")
|
||||||
|
enums_generated.add(base.__name__)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Generate interfaces
|
||||||
|
for cls in dataclasses:
|
||||||
|
lines.extend(self._generate_interface_from_dataclass(cls))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _generate_header(self) -> List[str]:
|
||||||
|
"""Generate file header."""
|
||||||
|
return [
|
||||||
|
"/**",
|
||||||
|
" * TypeScript Types - GENERATED FILE",
|
||||||
|
" *",
|
||||||
|
" * Do not edit directly. Regenerate using modelgen.",
|
||||||
|
" */",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _generate_interface_from_definition(
|
||||||
|
self, model_def: ModelDefinition
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate TypeScript interface from ModelDefinition."""
|
||||||
|
lines = [f"export interface {model_def.name} {{"]
|
||||||
|
|
||||||
|
for field in model_def.fields:
|
||||||
|
ts_type = self._resolve_type(field.type_hint, field.optional)
|
||||||
|
lines.append(f" {field.name}: {ts_type};")
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _generate_interface_from_dataclass(self, cls: type) -> List[str]:
|
||||||
|
"""Generate TypeScript interface from a dataclass."""
|
||||||
|
lines = [f"export interface {cls.__name__} {{"]
|
||||||
|
|
||||||
|
for name, type_hint in get_type_hints(cls).items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
ts_type = self._resolve_type(type_hint, False)
|
||||||
|
lines.append(f" {name}: {ts_type};")
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _resolve_type(self, type_hint: Any, optional: bool) -> str:
|
||||||
|
"""Resolve Python type to TypeScript type string."""
|
||||||
|
base, is_optional = unwrap_optional(type_hint)
|
||||||
|
optional = optional or is_optional
|
||||||
|
origin = get_origin_name(base)
|
||||||
|
type_name = get_type_name(base)
|
||||||
|
|
||||||
|
# Look up resolver
|
||||||
|
resolver = (
|
||||||
|
TS_RESOLVERS.get(origin)
|
||||||
|
or TS_RESOLVERS.get(type_name)
|
||||||
|
or TS_RESOLVERS.get(base)
|
||||||
|
or (
|
||||||
|
TS_RESOLVERS["enum"]
|
||||||
|
if isinstance(base, type) and issubclass(base, Enum)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resolver(base) if resolver else "string"
|
||||||
|
return f"{result} | null" if optional else result
|
||||||
72
soleprint/station/tools/modelgen/helpers.py
Normal file
72
soleprint/station/tools/modelgen/helpers.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Type Helpers
|
||||||
|
|
||||||
|
Utilities for type introspection and resolution.
|
||||||
|
Used by generators and loaders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dataclasses as dc
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Union, get_args, get_origin
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_optional(type_hint: Any) -> tuple[Any, bool]:
|
||||||
|
"""Unwrap Optional[T] -> (T, True) or (T, False) if not optional."""
|
||||||
|
origin = get_origin(type_hint)
|
||||||
|
if origin is Union:
|
||||||
|
args = [a for a in get_args(type_hint) if a is not type(None)]
|
||||||
|
return (args[0] if args else str, True)
|
||||||
|
return (type_hint, False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_origin_name(type_hint: Any) -> str | None:
|
||||||
|
"""Get origin type name: 'dict', 'list', or None."""
|
||||||
|
origin = get_origin(type_hint)
|
||||||
|
if origin is dict:
|
||||||
|
return "dict"
|
||||||
|
if origin is list:
|
||||||
|
return "list"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_type_name(type_hint: Any) -> str | None:
|
||||||
|
"""Get type name for special types like UUID, datetime."""
|
||||||
|
if hasattr(type_hint, "__name__"):
|
||||||
|
return type_hint.__name__
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_inner(type_hint: Any) -> str:
|
||||||
|
"""Get inner type of List[T]."""
|
||||||
|
args = get_args(type_hint)
|
||||||
|
if args and args[0] in (str, int, float, bool):
|
||||||
|
return {str: "str", int: "int", float: "float", bool: "bool"}[args[0]]
|
||||||
|
return "str"
|
||||||
|
|
||||||
|
|
||||||
|
def get_field_default(field: dc.Field) -> Any:
|
||||||
|
"""Get default value from dataclass field."""
|
||||||
|
if field.default is not dc.MISSING:
|
||||||
|
return field.default
|
||||||
|
return dc.MISSING
|
||||||
|
|
||||||
|
|
||||||
|
def format_opts(optional: bool, extra: list[str] | None = None) -> str:
|
||||||
|
"""Format field options string for Django."""
|
||||||
|
parts = []
|
||||||
|
if optional:
|
||||||
|
parts.append("null=True, blank=True")
|
||||||
|
if extra:
|
||||||
|
parts.extend(extra)
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def is_enum(type_hint: Any) -> bool:
|
||||||
|
"""Check if type is an Enum."""
|
||||||
|
base, _ = unwrap_optional(type_hint)
|
||||||
|
return isinstance(base, type) and issubclass(base, Enum)
|
||||||
|
|
||||||
|
|
||||||
|
def get_enum_values(enum_class: type) -> list[tuple[str, str]]:
|
||||||
|
"""Get list of (name, value) pairs from an Enum."""
|
||||||
|
return [(m.name, m.value) for m in enum_class]
|
||||||
37
soleprint/station/tools/modelgen/loader/__init__.py
Normal file
37
soleprint/station/tools/modelgen/loader/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Loader - Input source handlers for modelgen.
|
||||||
|
|
||||||
|
Supported loaders:
|
||||||
|
- ConfigLoader: Load from soleprint config.json
|
||||||
|
- SchemaLoader: Load from Python dataclasses in schema/ folder
|
||||||
|
- Extractors: Extract from existing codebases (Django, SQLAlchemy, Prisma)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config import ConfigLoader, load_config
|
||||||
|
from .extract import EXTRACTORS, BaseExtractor, DjangoExtractor
|
||||||
|
from .schema import (
|
||||||
|
EnumDefinition,
|
||||||
|
FieldDefinition,
|
||||||
|
GrpcServiceDefinition,
|
||||||
|
ModelDefinition,
|
||||||
|
SchemaLoader,
|
||||||
|
load_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Config loader
|
||||||
|
"ConfigLoader",
|
||||||
|
"load_config",
|
||||||
|
# Schema loader
|
||||||
|
"SchemaLoader",
|
||||||
|
"load_schema",
|
||||||
|
# Model definitions
|
||||||
|
"ModelDefinition",
|
||||||
|
"FieldDefinition",
|
||||||
|
"EnumDefinition",
|
||||||
|
"GrpcServiceDefinition",
|
||||||
|
# Extractors
|
||||||
|
"BaseExtractor",
|
||||||
|
"DjangoExtractor",
|
||||||
|
"EXTRACTORS",
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Configuration Loader
|
Configuration Loader
|
||||||
|
|
||||||
Loads and validates framework configuration files.
|
Loads and validates framework configuration files (soleprint config.json style).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -114,22 +114,3 @@ def load_config(config_path: str | Path) -> ConfigLoader:
|
|||||||
"""Load and validate configuration file"""
|
"""Load and validate configuration file"""
|
||||||
loader = ConfigLoader(config_path)
|
loader = ConfigLoader(config_path)
|
||||||
return loader.load()
|
return loader.load()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Test with pawprint config
|
|
||||||
import sys
|
|
||||||
|
|
||||||
config_path = Path(__file__).parent.parent / "pawprint.config.json"
|
|
||||||
|
|
||||||
loader = load_config(config_path)
|
|
||||||
|
|
||||||
print(f"Framework: {loader.framework.name} v{loader.framework.version}")
|
|
||||||
print(f"Tagline: {loader.framework.tagline}")
|
|
||||||
print(f"\nSystems:")
|
|
||||||
for sys in loader.systems:
|
|
||||||
print(f" {sys.icon} {sys.title} ({sys.name}) - {sys.tagline}")
|
|
||||||
|
|
||||||
print(f"\nShared Components:")
|
|
||||||
for key, comp in loader.components["shared"].items():
|
|
||||||
print(f" {comp.name} - {comp.description}")
|
|
||||||
20
soleprint/station/tools/modelgen/loader/extract/__init__.py
Normal file
20
soleprint/station/tools/modelgen/loader/extract/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Extractors - Extract model definitions from existing codebases.
|
||||||
|
|
||||||
|
Supported frameworks:
|
||||||
|
- Django: Extract from Django ORM models
|
||||||
|
- SQLAlchemy: Extract from SQLAlchemy models (planned)
|
||||||
|
- Prisma: Extract from Prisma schema (planned)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Type
|
||||||
|
|
||||||
|
from .base import BaseExtractor
|
||||||
|
from .django import DjangoExtractor
|
||||||
|
|
||||||
|
# Registry of available extractors
|
||||||
|
EXTRACTORS: Dict[str, Type[BaseExtractor]] = {
|
||||||
|
"django": DjangoExtractor,
|
||||||
|
}
|
||||||
|
|
||||||
|
__all__ = ["BaseExtractor", "DjangoExtractor", "EXTRACTORS"]
|
||||||
38
soleprint/station/tools/modelgen/loader/extract/base.py
Normal file
38
soleprint/station/tools/modelgen/loader/extract/base.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Base Extractor
|
||||||
|
|
||||||
|
Abstract base class for model extractors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ..schema import EnumDefinition, ModelDefinition
|
||||||
|
|
||||||
|
|
||||||
|
class BaseExtractor(ABC):
|
||||||
|
"""Abstract base for codebase model extractors."""
|
||||||
|
|
||||||
|
def __init__(self, source_path: Path):
|
||||||
|
self.source_path = Path(source_path)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extract(self) -> tuple[List[ModelDefinition], List[EnumDefinition]]:
|
||||||
|
"""
|
||||||
|
Extract model definitions from source codebase.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (models, enums)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def detect(self) -> bool:
|
||||||
|
"""
|
||||||
|
Detect if this extractor can handle the source path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if this extractor can handle the source
|
||||||
|
"""
|
||||||
|
pass
|
||||||
237
soleprint/station/tools/modelgen/loader/extract/django.py
Normal file
237
soleprint/station/tools/modelgen/loader/extract/django.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""
|
||||||
|
Django Extractor
|
||||||
|
|
||||||
|
Extracts model definitions from Django ORM models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from ..schema import EnumDefinition, FieldDefinition, ModelDefinition
|
||||||
|
from .base import BaseExtractor
|
||||||
|
|
||||||
|
# Django field type mappings to Python types
|
||||||
|
DJANGO_FIELD_TYPES = {
|
||||||
|
"CharField": str,
|
||||||
|
"TextField": str,
|
||||||
|
"EmailField": str,
|
||||||
|
"URLField": str,
|
||||||
|
"SlugField": str,
|
||||||
|
"UUIDField": "UUID",
|
||||||
|
"IntegerField": int,
|
||||||
|
"BigIntegerField": "bigint",
|
||||||
|
"SmallIntegerField": int,
|
||||||
|
"PositiveIntegerField": int,
|
||||||
|
"FloatField": float,
|
||||||
|
"DecimalField": float,
|
||||||
|
"BooleanField": bool,
|
||||||
|
"NullBooleanField": bool,
|
||||||
|
"DateField": "datetime",
|
||||||
|
"DateTimeField": "datetime",
|
||||||
|
"TimeField": "datetime",
|
||||||
|
"JSONField": "dict",
|
||||||
|
"ForeignKey": "FK",
|
||||||
|
"OneToOneField": "FK",
|
||||||
|
"ManyToManyField": "M2M",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DjangoExtractor(BaseExtractor):
|
||||||
|
"""Extracts models from Django ORM."""
|
||||||
|
|
||||||
|
def detect(self) -> bool:
|
||||||
|
"""Check if this is a Django project."""
|
||||||
|
# Look for manage.py or settings.py
|
||||||
|
manage_py = self.source_path / "manage.py"
|
||||||
|
settings_py = self.source_path / "settings.py"
|
||||||
|
|
||||||
|
if manage_py.exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for Django imports in any models.py
|
||||||
|
for models_file in self.source_path.rglob("models.py"):
|
||||||
|
content = models_file.read_text()
|
||||||
|
if "from django.db import models" in content:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return settings_py.exists()
|
||||||
|
|
||||||
|
def extract(self) -> tuple[List[ModelDefinition], List[EnumDefinition]]:
|
||||||
|
"""Extract Django models using AST parsing."""
|
||||||
|
models = []
|
||||||
|
enums = []
|
||||||
|
|
||||||
|
# Find all models.py files
|
||||||
|
for models_file in self.source_path.rglob("models.py"):
|
||||||
|
file_models, file_enums = self._extract_from_file(models_file)
|
||||||
|
models.extend(file_models)
|
||||||
|
enums.extend(file_enums)
|
||||||
|
|
||||||
|
return models, enums
|
||||||
|
|
||||||
|
def _extract_from_file(
|
||||||
|
self, file_path: Path
|
||||||
|
) -> tuple[List[ModelDefinition], List[EnumDefinition]]:
|
||||||
|
"""Extract models from a single models.py file."""
|
||||||
|
models = []
|
||||||
|
enums = []
|
||||||
|
|
||||||
|
content = file_path.read_text()
|
||||||
|
tree = ast.parse(content)
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ClassDef):
|
||||||
|
# Check if it inherits from models.Model
|
||||||
|
if self._is_django_model(node):
|
||||||
|
model_def = self._parse_model_class(node)
|
||||||
|
if model_def:
|
||||||
|
models.append(model_def)
|
||||||
|
# Check if it's a TextChoices/IntegerChoices enum
|
||||||
|
elif self._is_django_choices(node):
|
||||||
|
enum_def = self._parse_choices_class(node)
|
||||||
|
if enum_def:
|
||||||
|
enums.append(enum_def)
|
||||||
|
|
||||||
|
return models, enums
|
||||||
|
|
||||||
|
def _is_django_model(self, node: ast.ClassDef) -> bool:
|
||||||
|
"""Check if class inherits from models.Model."""
|
||||||
|
for base in node.bases:
|
||||||
|
if isinstance(base, ast.Attribute):
|
||||||
|
if base.attr == "Model":
|
||||||
|
return True
|
||||||
|
elif isinstance(base, ast.Name):
|
||||||
|
if base.id in ("Model", "AbstractUser", "AbstractBaseUser"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_django_choices(self, node: ast.ClassDef) -> bool:
|
||||||
|
"""Check if class is a Django TextChoices/IntegerChoices."""
|
||||||
|
for base in node.bases:
|
||||||
|
if isinstance(base, ast.Attribute):
|
||||||
|
if base.attr in ("TextChoices", "IntegerChoices"):
|
||||||
|
return True
|
||||||
|
elif isinstance(base, ast.Name):
|
||||||
|
if base.id in ("TextChoices", "IntegerChoices"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _parse_model_class(self, node: ast.ClassDef) -> Optional[ModelDefinition]:
|
||||||
|
"""Parse a Django model class into ModelDefinition."""
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
for item in node.body:
|
||||||
|
if isinstance(item, ast.Assign):
|
||||||
|
field_def = self._parse_field_assignment(item)
|
||||||
|
if field_def:
|
||||||
|
fields.append(field_def)
|
||||||
|
elif isinstance(item, ast.AnnAssign):
|
||||||
|
# Handle annotated assignments (Django 4.0+ style)
|
||||||
|
field_def = self._parse_annotated_field(item)
|
||||||
|
if field_def:
|
||||||
|
fields.append(field_def)
|
||||||
|
|
||||||
|
# Get docstring
|
||||||
|
docstring = ast.get_docstring(node)
|
||||||
|
|
||||||
|
return ModelDefinition(
|
||||||
|
name=node.name,
|
||||||
|
fields=fields,
|
||||||
|
docstring=docstring,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_field_assignment(self, node: ast.Assign) -> Optional[FieldDefinition]:
|
||||||
|
"""Parse a field assignment like: name = models.CharField(...)"""
|
||||||
|
if not node.targets or not isinstance(node.targets[0], ast.Name):
|
||||||
|
return None
|
||||||
|
|
||||||
|
field_name = node.targets[0].id
|
||||||
|
|
||||||
|
# Skip private fields and Meta class
|
||||||
|
if field_name.startswith("_") or field_name == "Meta":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse the field call
|
||||||
|
if isinstance(node.value, ast.Call):
|
||||||
|
return self._parse_field_call(field_name, node.value)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_annotated_field(self, node: ast.AnnAssign) -> Optional[FieldDefinition]:
|
||||||
|
"""Parse an annotated field assignment."""
|
||||||
|
if not isinstance(node.target, ast.Name):
|
||||||
|
return None
|
||||||
|
|
||||||
|
field_name = node.target.id
|
||||||
|
|
||||||
|
if field_name.startswith("_"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if node.value and isinstance(node.value, ast.Call):
|
||||||
|
return self._parse_field_call(field_name, node.value)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_field_call(
|
||||||
|
self, field_name: str, call: ast.Call
|
||||||
|
) -> Optional[FieldDefinition]:
|
||||||
|
"""Parse a Django field call like models.CharField(max_length=100)."""
|
||||||
|
# Get field type name
|
||||||
|
field_type_name = None
|
||||||
|
|
||||||
|
if isinstance(call.func, ast.Attribute):
|
||||||
|
field_type_name = call.func.attr
|
||||||
|
elif isinstance(call.func, ast.Name):
|
||||||
|
field_type_name = call.func.id
|
||||||
|
|
||||||
|
if not field_type_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Map to Python type
|
||||||
|
python_type = DJANGO_FIELD_TYPES.get(field_type_name, str)
|
||||||
|
|
||||||
|
# Check for null=True
|
||||||
|
optional = False
|
||||||
|
default = None
|
||||||
|
|
||||||
|
for keyword in call.keywords:
|
||||||
|
if keyword.arg == "null":
|
||||||
|
if isinstance(keyword.value, ast.Constant):
|
||||||
|
optional = keyword.value.value is True
|
||||||
|
elif keyword.arg == "default":
|
||||||
|
if isinstance(keyword.value, ast.Constant):
|
||||||
|
default = keyword.value.value
|
||||||
|
|
||||||
|
return FieldDefinition(
|
||||||
|
name=field_name,
|
||||||
|
type_hint=python_type,
|
||||||
|
default=default if default is not None else None,
|
||||||
|
optional=optional,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_choices_class(self, node: ast.ClassDef) -> Optional[EnumDefinition]:
|
||||||
|
"""Parse a Django TextChoices/IntegerChoices class."""
|
||||||
|
values = []
|
||||||
|
|
||||||
|
for item in node.body:
|
||||||
|
if isinstance(item, ast.Assign):
|
||||||
|
if item.targets and isinstance(item.targets[0], ast.Name):
|
||||||
|
name = item.targets[0].id
|
||||||
|
if name.isupper(): # Enum values are typically uppercase
|
||||||
|
# Get the value
|
||||||
|
value = name.lower() # Default to lowercase name
|
||||||
|
if isinstance(item.value, ast.Constant):
|
||||||
|
value = str(item.value.value)
|
||||||
|
elif isinstance(item.value, ast.Tuple) and item.value.elts:
|
||||||
|
# TextChoices: NAME = "value", "Label"
|
||||||
|
if isinstance(item.value.elts[0], ast.Constant):
|
||||||
|
value = str(item.value.elts[0].value)
|
||||||
|
|
||||||
|
values.append((name, value))
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return EnumDefinition(name=node.name, values=values)
|
||||||
169
soleprint/station/tools/modelgen/loader/schema.py
Normal file
169
soleprint/station/tools/modelgen/loader/schema.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Schema Loader
|
||||||
|
|
||||||
|
Loads Python dataclasses from a schema/ folder.
|
||||||
|
Expects the folder to have an __init__.py that exports:
|
||||||
|
- DATACLASSES: List of dataclass types to generate
|
||||||
|
- ENUMS: List of Enum types to include
|
||||||
|
- GRPC_MESSAGES: (optional) List of gRPC message types
|
||||||
|
- GRPC_SERVICE: (optional) gRPC service definition dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dataclasses as dc
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Type, get_type_hints
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FieldDefinition:
|
||||||
|
"""Represents a model field."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
type_hint: Any
|
||||||
|
default: Any = dc.MISSING
|
||||||
|
optional: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelDefinition:
|
||||||
|
"""Represents a model/dataclass."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
fields: List[FieldDefinition]
|
||||||
|
docstring: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnumDefinition:
|
||||||
|
"""Represents an enum."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
values: List[tuple[str, str]] # (name, value) pairs
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GrpcServiceDefinition:
|
||||||
|
"""Represents a gRPC service."""
|
||||||
|
|
||||||
|
package: str
|
||||||
|
name: str
|
||||||
|
methods: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaLoader:
|
||||||
|
"""Loads model definitions from Python dataclasses in schema/ folder."""
|
||||||
|
|
||||||
|
def __init__(self, schema_path: Path):
|
||||||
|
self.schema_path = Path(schema_path)
|
||||||
|
self.models: List[ModelDefinition] = []
|
||||||
|
self.enums: List[EnumDefinition] = []
|
||||||
|
self.grpc_messages: List[ModelDefinition] = []
|
||||||
|
self.grpc_service: Optional[GrpcServiceDefinition] = None
|
||||||
|
|
||||||
|
def load(self) -> "SchemaLoader":
|
||||||
|
"""Load schema definitions from the schema folder."""
|
||||||
|
init_path = self.schema_path / "__init__.py"
|
||||||
|
|
||||||
|
if not init_path.exists():
|
||||||
|
raise FileNotFoundError(f"Schema folder must have __init__.py: {init_path}")
|
||||||
|
|
||||||
|
# Import the schema module
|
||||||
|
module = self._import_module(init_path)
|
||||||
|
|
||||||
|
# Extract DATACLASSES
|
||||||
|
dataclasses = getattr(module, "DATACLASSES", [])
|
||||||
|
for cls in dataclasses:
|
||||||
|
self.models.append(self._parse_dataclass(cls))
|
||||||
|
|
||||||
|
# Extract ENUMS
|
||||||
|
enums = getattr(module, "ENUMS", [])
|
||||||
|
for enum_cls in enums:
|
||||||
|
self.enums.append(self._parse_enum(enum_cls))
|
||||||
|
|
||||||
|
# Extract GRPC_MESSAGES (optional)
|
||||||
|
grpc_messages = getattr(module, "GRPC_MESSAGES", [])
|
||||||
|
for cls in grpc_messages:
|
||||||
|
self.grpc_messages.append(self._parse_dataclass(cls))
|
||||||
|
|
||||||
|
# Extract GRPC_SERVICE (optional)
|
||||||
|
grpc_service = getattr(module, "GRPC_SERVICE", None)
|
||||||
|
if grpc_service:
|
||||||
|
self.grpc_service = GrpcServiceDefinition(
|
||||||
|
package=grpc_service.get("package", "service"),
|
||||||
|
name=grpc_service.get("name", "Service"),
|
||||||
|
methods=grpc_service.get("methods", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _import_module(self, path: Path):
|
||||||
|
"""Import a Python module from a file path."""
|
||||||
|
spec = importlib.util.spec_from_file_location("schema", path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Could not load module from {path}")
|
||||||
|
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["schema"] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
def _parse_dataclass(self, cls: Type) -> ModelDefinition:
|
||||||
|
"""Parse a dataclass into a ModelDefinition."""
|
||||||
|
hints = get_type_hints(cls)
|
||||||
|
fields_info = {f.name: f for f in dc.fields(cls)}
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
for name, type_hint in hints.items():
|
||||||
|
if name.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
field_info = fields_info.get(name)
|
||||||
|
default = dc.MISSING
|
||||||
|
if field_info:
|
||||||
|
if field_info.default is not dc.MISSING:
|
||||||
|
default = field_info.default
|
||||||
|
elif field_info.default_factory is not dc.MISSING:
|
||||||
|
default = field_info.default_factory
|
||||||
|
|
||||||
|
# Check if optional (Union with None)
|
||||||
|
optional = self._is_optional(type_hint)
|
||||||
|
|
||||||
|
fields.append(
|
||||||
|
FieldDefinition(
|
||||||
|
name=name,
|
||||||
|
type_hint=type_hint,
|
||||||
|
default=default,
|
||||||
|
optional=optional,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ModelDefinition(
|
||||||
|
name=cls.__name__,
|
||||||
|
fields=fields,
|
||||||
|
docstring=cls.__doc__,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_enum(self, enum_cls: Type[Enum]) -> EnumDefinition:
|
||||||
|
"""Parse an Enum into an EnumDefinition."""
|
||||||
|
values = [(m.name, m.value) for m in enum_cls]
|
||||||
|
return EnumDefinition(name=enum_cls.__name__, values=values)
|
||||||
|
|
||||||
|
def _is_optional(self, type_hint: Any) -> bool:
|
||||||
|
"""Check if a type hint is Optional (Union with None)."""
|
||||||
|
from typing import Union, get_args, get_origin
|
||||||
|
|
||||||
|
origin = get_origin(type_hint)
|
||||||
|
if origin is Union:
|
||||||
|
args = get_args(type_hint)
|
||||||
|
return type(None) in args
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def load_schema(schema_path: str | Path) -> SchemaLoader:
|
||||||
|
"""Load schema definitions from folder."""
|
||||||
|
loader = SchemaLoader(schema_path)
|
||||||
|
return loader.load()
|
||||||
@@ -1,314 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Model Generator
|
Model Generator
|
||||||
|
|
||||||
Generic model generation from configuration files.
|
Orchestrates model generation from various sources to various formats.
|
||||||
Supports multiple output formats and is extensible for bidirectional conversion.
|
Delegates to loaders for input and generators for output.
|
||||||
|
|
||||||
Output formats:
|
|
||||||
- pydantic: Pydantic BaseModel classes
|
|
||||||
- django: Django ORM models (planned)
|
|
||||||
- prisma: Prisma schema (planned)
|
|
||||||
- sqlalchemy: SQLAlchemy models (planned)
|
|
||||||
|
|
||||||
Future: Extract models FROM existing codebases (reverse direction)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Type
|
from typing import Dict, Type
|
||||||
|
|
||||||
from .config_loader import ConfigLoader
|
from .generator import GENERATORS, BaseGenerator
|
||||||
|
from .loader import ConfigLoader
|
||||||
|
|
||||||
class BaseModelWriter(ABC):
|
|
||||||
"""Abstract base for model output writers."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def write(self, config: ConfigLoader, output_path: Path) -> None:
|
|
||||||
"""Write models to the specified path."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def file_extension(self) -> str:
|
|
||||||
"""Return the file extension for this format."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PydanticWriter(BaseModelWriter):
|
|
||||||
"""Generates Pydantic model files."""
|
|
||||||
|
|
||||||
def file_extension(self) -> str:
|
|
||||||
return ".py"
|
|
||||||
|
|
||||||
def write(self, config: ConfigLoader, output_path: Path) -> None:
|
|
||||||
"""Write Pydantic models to output_path."""
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
content = self._generate_content(config)
|
|
||||||
output_path.write_text(content)
|
|
||||||
|
|
||||||
def _generate_content(self, config: ConfigLoader) -> str:
|
|
||||||
"""Generate the Pydantic models file content."""
|
|
||||||
|
|
||||||
# Get component names from config
|
|
||||||
config_comp = config.get_shared_component("config")
|
|
||||||
data_comp = config.get_shared_component("data")
|
|
||||||
|
|
||||||
data_flow_sys = config.get_system("data_flow")
|
|
||||||
doc_sys = config.get_system("documentation")
|
|
||||||
exec_sys = config.get_system("execution")
|
|
||||||
|
|
||||||
connector_comp = config.get_component("data_flow", "connector")
|
|
||||||
pulse_comp = config.get_component("data_flow", "composed")
|
|
||||||
|
|
||||||
pattern_comp = config.get_component("documentation", "pattern")
|
|
||||||
doc_composed = config.get_component("documentation", "composed")
|
|
||||||
|
|
||||||
tool_comp = config.get_component("execution", "utility")
|
|
||||||
monitor_comp = config.get_component("execution", "watcher")
|
|
||||||
cabinet_comp = config.get_component("execution", "container")
|
|
||||||
exec_composed = config.get_component("execution", "composed")
|
|
||||||
|
|
||||||
return f'''"""
|
|
||||||
Pydantic models - Generated from {config.framework.name}.config.json
|
|
||||||
|
|
||||||
DO NOT EDIT MANUALLY - Regenerate from config
|
|
||||||
"""
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import List, Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class Status(str, Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
PLANNED = "planned"
|
|
||||||
BUILDING = "building"
|
|
||||||
DEV = "dev"
|
|
||||||
LIVE = "live"
|
|
||||||
READY = "ready"
|
|
||||||
|
|
||||||
|
|
||||||
class System(str, Enum):
|
|
||||||
{data_flow_sys.name.upper()} = "{data_flow_sys.name}"
|
|
||||||
{doc_sys.name.upper()} = "{doc_sys.name}"
|
|
||||||
{exec_sys.name.upper()} = "{exec_sys.name}"
|
|
||||||
|
|
||||||
|
|
||||||
class ToolType(str, Enum):
|
|
||||||
APP = "app"
|
|
||||||
CLI = "cli"
|
|
||||||
|
|
||||||
|
|
||||||
# === Shared Components ===
|
|
||||||
|
|
||||||
|
|
||||||
class {config_comp.title}(BaseModel):
|
|
||||||
"""{config_comp.description}. Shared across {data_flow_sys.name}, {exec_sys.name}."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
config_path: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class {data_comp.title}(BaseModel):
|
|
||||||
"""{data_comp.description}. Shared across all systems."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
source_template: Optional[str] = None
|
|
||||||
data_path: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# === System-Specific Components ===
|
|
||||||
|
|
||||||
|
|
||||||
class {connector_comp.title}(BaseModel):
|
|
||||||
"""{connector_comp.description} ({data_flow_sys.name})."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
system: Literal["{data_flow_sys.name}"] = "{data_flow_sys.name}"
|
|
||||||
mock: Optional[bool] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class {pattern_comp.title}(BaseModel):
|
|
||||||
"""{pattern_comp.description} ({doc_sys.name})."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
template_path: Optional[str] = None
|
|
||||||
system: Literal["{doc_sys.name}"] = "{doc_sys.name}"
|
|
||||||
|
|
||||||
|
|
||||||
class {tool_comp.title}(BaseModel):
|
|
||||||
"""{tool_comp.description} ({exec_sys.name})."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
|
|
||||||
type: Optional[ToolType] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
path: Optional[str] = None
|
|
||||||
url: Optional[str] = None
|
|
||||||
cli: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class {monitor_comp.title}(BaseModel):
|
|
||||||
"""{monitor_comp.description} ({exec_sys.name})."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
|
|
||||||
|
|
||||||
|
|
||||||
class {cabinet_comp.title}(BaseModel):
|
|
||||||
"""{cabinet_comp.description} ({exec_sys.name})."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
tools: List[{tool_comp.title}] = Field(default_factory=list)
|
|
||||||
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
|
|
||||||
|
|
||||||
|
|
||||||
# === Composed Types ===
|
|
||||||
|
|
||||||
|
|
||||||
class {pulse_comp.title}(BaseModel):
|
|
||||||
"""{pulse_comp.description} ({data_flow_sys.name}). Formula: {pulse_comp.formula}."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
{connector_comp.name}: Optional[{connector_comp.title}] = None
|
|
||||||
{config_comp.name}: Optional[{config_comp.title}] = None
|
|
||||||
{data_comp.name}: Optional[{data_comp.title}] = None
|
|
||||||
system: Literal["{data_flow_sys.name}"] = "{data_flow_sys.name}"
|
|
||||||
|
|
||||||
|
|
||||||
class {doc_composed.title}(BaseModel):
|
|
||||||
"""{doc_composed.description} ({doc_sys.name}). Formula: {doc_composed.formula}."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
template: Optional[{pattern_comp.title}] = None
|
|
||||||
{data_comp.name}: Optional[{data_comp.title}] = None
|
|
||||||
output_{data_comp.name}: Optional[{data_comp.title}] = None
|
|
||||||
system: Literal["{doc_sys.name}"] = "{doc_sys.name}"
|
|
||||||
|
|
||||||
|
|
||||||
class {exec_composed.title}(BaseModel):
|
|
||||||
"""{exec_composed.description} ({exec_sys.name}). Formula: {exec_composed.formula}."""
|
|
||||||
|
|
||||||
name: str # Unique identifier
|
|
||||||
slug: str # URL-friendly identifier
|
|
||||||
title: str # Display title for UI
|
|
||||||
status: Optional[Status] = None
|
|
||||||
cabinet: Optional[{cabinet_comp.title}] = None
|
|
||||||
{config_comp.name}: Optional[{config_comp.title}] = None
|
|
||||||
{data_comp.plural}: List[{data_comp.title}] = Field(default_factory=list)
|
|
||||||
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
|
|
||||||
|
|
||||||
|
|
||||||
# === Collection wrappers for JSON files ===
|
|
||||||
|
|
||||||
|
|
||||||
class {config_comp.title}Collection(BaseModel):
|
|
||||||
items: List[{config_comp.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {data_comp.title}Collection(BaseModel):
|
|
||||||
items: List[{data_comp.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {connector_comp.title}Collection(BaseModel):
|
|
||||||
items: List[{connector_comp.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {pattern_comp.title}Collection(BaseModel):
|
|
||||||
items: List[{pattern_comp.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {tool_comp.title}Collection(BaseModel):
|
|
||||||
items: List[{tool_comp.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {monitor_comp.title}Collection(BaseModel):
|
|
||||||
items: List[{monitor_comp.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {cabinet_comp.title}Collection(BaseModel):
|
|
||||||
items: List[{cabinet_comp.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {pulse_comp.title}Collection(BaseModel):
|
|
||||||
items: List[{pulse_comp.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {doc_composed.title}Collection(BaseModel):
|
|
||||||
items: List[{doc_composed.title}] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class {exec_composed.title}Collection(BaseModel):
|
|
||||||
items: List[{exec_composed.title}] = Field(default_factory=list)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
class DjangoWriter(BaseModelWriter):
|
|
||||||
"""Generates Django model files (placeholder)."""
|
|
||||||
|
|
||||||
def file_extension(self) -> str:
|
|
||||||
return ".py"
|
|
||||||
|
|
||||||
def write(self, config: ConfigLoader, output_path: Path) -> None:
|
|
||||||
raise NotImplementedError("Django model generation not yet implemented")
|
|
||||||
|
|
||||||
|
|
||||||
class PrismaWriter(BaseModelWriter):
|
|
||||||
"""Generates Prisma schema files (placeholder)."""
|
|
||||||
|
|
||||||
def file_extension(self) -> str:
|
|
||||||
return ".prisma"
|
|
||||||
|
|
||||||
def write(self, config: ConfigLoader, output_path: Path) -> None:
|
|
||||||
raise NotImplementedError("Prisma schema generation not yet implemented")
|
|
||||||
|
|
||||||
|
|
||||||
class SQLAlchemyWriter(BaseModelWriter):
|
|
||||||
"""Generates SQLAlchemy model files (placeholder)."""
|
|
||||||
|
|
||||||
def file_extension(self) -> str:
|
|
||||||
return ".py"
|
|
||||||
|
|
||||||
def write(self, config: ConfigLoader, output_path: Path) -> None:
|
|
||||||
raise NotImplementedError("SQLAlchemy model generation not yet implemented")
|
|
||||||
|
|
||||||
|
|
||||||
# Registry of available writers
|
|
||||||
WRITERS: Dict[str, Type[BaseModelWriter]] = {
|
|
||||||
"pydantic": PydanticWriter,
|
|
||||||
"django": DjangoWriter,
|
|
||||||
"prisma": PrismaWriter,
|
|
||||||
"sqlalchemy": SQLAlchemyWriter,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ModelGenerator:
|
class ModelGenerator:
|
||||||
@@ -316,7 +17,7 @@ class ModelGenerator:
|
|||||||
Generates typed models from configuration.
|
Generates typed models from configuration.
|
||||||
|
|
||||||
This is the main entry point for model generation.
|
This is the main entry point for model generation.
|
||||||
Delegates to format-specific writers.
|
Delegates to format-specific generators.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -331,19 +32,19 @@ class ModelGenerator:
|
|||||||
Args:
|
Args:
|
||||||
config: Loaded configuration
|
config: Loaded configuration
|
||||||
output_path: Exact path where to write (file or directory depending on format)
|
output_path: Exact path where to write (file or directory depending on format)
|
||||||
output_format: Output format (pydantic, django, prisma, sqlalchemy)
|
output_format: Output format (pydantic, django, prisma, typescript, protobuf)
|
||||||
"""
|
"""
|
||||||
self.config = config
|
self.config = config
|
||||||
self.output_path = Path(output_path)
|
self.output_path = Path(output_path)
|
||||||
self.output_format = output_format
|
self.output_format = output_format
|
||||||
|
|
||||||
if output_format not in WRITERS:
|
if output_format not in GENERATORS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unknown output format: {output_format}. "
|
f"Unknown output format: {output_format}. "
|
||||||
f"Available: {list(WRITERS.keys())}"
|
f"Available: {list(GENERATORS.keys())}"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.writer = WRITERS[output_format]()
|
self.generator = GENERATORS[output_format]()
|
||||||
|
|
||||||
def generate(self) -> Path:
|
def generate(self) -> Path:
|
||||||
"""
|
"""
|
||||||
@@ -358,13 +59,19 @@ class ModelGenerator:
|
|||||||
output_file = self.output_path
|
output_file = self.output_path
|
||||||
else:
|
else:
|
||||||
# User specified a directory, add default filename
|
# User specified a directory, add default filename
|
||||||
output_file = self.output_path / f"__init__{self.writer.file_extension()}"
|
output_file = (
|
||||||
|
self.output_path / f"__init__{self.generator.file_extension()}"
|
||||||
|
)
|
||||||
|
|
||||||
self.writer.write(self.config, output_file)
|
self.generator.generate(self.config, output_file)
|
||||||
print(f"Generated {self.output_format} models: {output_file}")
|
print(f"Generated {self.output_format} models: {output_file}")
|
||||||
return output_file
|
return output_file
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def available_formats(cls) -> list:
|
def available_formats(cls) -> list:
|
||||||
"""Return list of available output formats."""
|
"""Return list of available output formats."""
|
||||||
return list(WRITERS.keys())
|
return list(GENERATORS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
# Re-export for backwards compatibility
|
||||||
|
WRITERS = GENERATORS
|
||||||
|
|||||||
0
soleprint/station/tools/modelgen/schema/.gitkeep
Normal file
0
soleprint/station/tools/modelgen/schema/.gitkeep
Normal file
139
soleprint/station/tools/modelgen/types.py
Normal file
139
soleprint/station/tools/modelgen/types.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Type Dispatch Tables
|
||||||
|
|
||||||
|
Type mappings for each output format.
|
||||||
|
Used by generators to convert Python types to target framework types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Callable, get_args
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Django Type Mappings
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
DJANGO_TYPES: dict[Any, str] = {
|
||||||
|
str: "models.CharField(max_length={max_length}{opts})",
|
||||||
|
int: "models.IntegerField({opts})",
|
||||||
|
float: "models.FloatField({opts})",
|
||||||
|
bool: "models.BooleanField(default={default})",
|
||||||
|
"UUID": "models.UUIDField({opts})",
|
||||||
|
"datetime": "models.DateTimeField({opts})",
|
||||||
|
"dict": "models.JSONField(default=dict, blank=True)",
|
||||||
|
"list": "models.JSONField(default=list, blank=True)",
|
||||||
|
"text": "models.TextField(blank=True, default='')",
|
||||||
|
"bigint": "models.BigIntegerField({opts})",
|
||||||
|
"enum": "models.CharField(max_length=20, choices=Status.choices{opts})",
|
||||||
|
}
|
||||||
|
|
||||||
|
DJANGO_SPECIAL: dict[str, str] = {
|
||||||
|
"id": "models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)",
|
||||||
|
"created_at": "models.DateTimeField(auto_now_add=True)",
|
||||||
|
"updated_at": "models.DateTimeField(auto_now=True)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Pydantic Type Resolvers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _get_list_inner(type_hint: Any) -> str:
|
||||||
|
"""Get inner type of List[T] for Pydantic."""
|
||||||
|
args = get_args(type_hint)
|
||||||
|
if args and args[0] in (str, int, float, bool):
|
||||||
|
return {str: "str", int: "int", float: "float", bool: "bool"}[args[0]]
|
||||||
|
return "str"
|
||||||
|
|
||||||
|
|
||||||
|
PYDANTIC_RESOLVERS: dict[Any, Callable[[Any], str]] = {
|
||||||
|
str: lambda _: "str",
|
||||||
|
int: lambda _: "int",
|
||||||
|
float: lambda _: "float",
|
||||||
|
bool: lambda _: "bool",
|
||||||
|
"UUID": lambda _: "UUID",
|
||||||
|
"datetime": lambda _: "datetime",
|
||||||
|
"dict": lambda _: "Dict[str, Any]",
|
||||||
|
"list": lambda base: f"List[{_get_list_inner(base)}]",
|
||||||
|
"enum": lambda base: base.__name__,
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TypeScript Type Resolvers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ts_list(base: Any) -> str:
|
||||||
|
"""Resolve TypeScript list type."""
|
||||||
|
args = get_args(base)
|
||||||
|
if args:
|
||||||
|
inner = args[0]
|
||||||
|
if inner is str:
|
||||||
|
return "string[]"
|
||||||
|
elif inner is int or inner is float:
|
||||||
|
return "number[]"
|
||||||
|
elif inner is bool:
|
||||||
|
return "boolean[]"
|
||||||
|
return "string[]"
|
||||||
|
|
||||||
|
|
||||||
|
TS_RESOLVERS: dict[Any, Callable[[Any], str]] = {
|
||||||
|
str: lambda _: "string",
|
||||||
|
int: lambda _: "number",
|
||||||
|
float: lambda _: "number",
|
||||||
|
bool: lambda _: "boolean",
|
||||||
|
"UUID": lambda _: "string",
|
||||||
|
"datetime": lambda _: "string",
|
||||||
|
"dict": lambda _: "Record<string, unknown>",
|
||||||
|
"list": _resolve_ts_list,
|
||||||
|
"enum": lambda base: base.__name__,
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Protobuf Type Resolvers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_proto_list(base: Any) -> str:
|
||||||
|
"""Resolve Protobuf repeated type."""
|
||||||
|
args = get_args(base)
|
||||||
|
if args:
|
||||||
|
inner = args[0]
|
||||||
|
if inner is str:
|
||||||
|
return "repeated string"
|
||||||
|
elif inner is int:
|
||||||
|
return "repeated int32"
|
||||||
|
elif inner is float:
|
||||||
|
return "repeated float"
|
||||||
|
elif inner is bool:
|
||||||
|
return "repeated bool"
|
||||||
|
return "repeated string"
|
||||||
|
|
||||||
|
|
||||||
|
PROTO_RESOLVERS: dict[Any, Callable[[Any], str]] = {
|
||||||
|
str: lambda _: "string",
|
||||||
|
int: lambda _: "int32",
|
||||||
|
float: lambda _: "float",
|
||||||
|
bool: lambda _: "bool",
|
||||||
|
"list": _resolve_proto_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Prisma Type Mappings
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
PRISMA_TYPES: dict[Any, str] = {
|
||||||
|
str: "String",
|
||||||
|
int: "Int",
|
||||||
|
float: "Float",
|
||||||
|
bool: "Boolean",
|
||||||
|
"UUID": "String @default(uuid())",
|
||||||
|
"datetime": "DateTime",
|
||||||
|
"dict": "Json",
|
||||||
|
"list": "Json",
|
||||||
|
"bigint": "BigInt",
|
||||||
|
}
|
||||||
|
|
||||||
|
PRISMA_SPECIAL: dict[str, str] = {
|
||||||
|
"id": "String @id @default(uuid())",
|
||||||
|
"created_at": "DateTime @default(now())",
|
||||||
|
"updated_at": "DateTime @updatedAt",
|
||||||
|
}
|
||||||
7
soleprint/station/tools/modelgen/writer/__init__.py
Normal file
7
soleprint/station/tools/modelgen/writer/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Writer - File writing utilities for modelgen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .file import write_file, write_multiple
|
||||||
|
|
||||||
|
__all__ = ["write_file", "write_multiple"]
|
||||||
30
soleprint/station/tools/modelgen/writer/file.py
Normal file
30
soleprint/station/tools/modelgen/writer/file.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
File Writer
|
||||||
|
|
||||||
|
Utilities for writing generated files to disk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(path: Path, content: str) -> None:
|
||||||
|
"""Write content to file, creating directories as needed."""
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_multiple(directory: Path, files: Dict[str, str]) -> None:
|
||||||
|
"""Write multiple files to a directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Target directory
|
||||||
|
files: Dict mapping filename to content
|
||||||
|
"""
|
||||||
|
directory = Path(directory)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for filename, content in files.items():
|
||||||
|
file_path = directory / filename
|
||||||
|
file_path.write_text(content)
|
||||||
@@ -1,296 +1,238 @@
|
|||||||
/* Pawprint Wrapper - Sidebar Styles */
|
/* Soleprint Sidebar - Injected Styles */
|
||||||
|
/* Matches the original Jinja2 sidebar look */
|
||||||
:root {
|
|
||||||
--sidebar-width: 320px;
|
|
||||||
--sidebar-bg: #1e1e1e;
|
|
||||||
--sidebar-text: #e0e0e0;
|
|
||||||
--sidebar-accent: #007acc;
|
|
||||||
--sidebar-border: #333;
|
|
||||||
--sidebar-shadow: 0 0 20px rgba(0,0,0,0.5);
|
|
||||||
--card-bg: #2a2a2a;
|
|
||||||
--card-hover: #3a3a3a;
|
|
||||||
--success: #4caf50;
|
|
||||||
--error: #f44336;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar Container */
|
/* Sidebar Container */
|
||||||
#pawprint-sidebar {
|
#spr-sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
top: 0;
|
||||||
top: 0;
|
left: 0;
|
||||||
width: var(--sidebar-width);
|
width: 60px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: var(--sidebar-bg);
|
background: #1a1a1a;
|
||||||
color: var(--sidebar-text);
|
border-right: 1px solid #333;
|
||||||
box-shadow: var(--sidebar-shadow);
|
display: flex;
|
||||||
transform: translateX(100%);
|
flex-direction: column;
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
align-items: center;
|
||||||
z-index: 9999;
|
padding: 1rem 0;
|
||||||
overflow-y: auto;
|
z-index: 99999;
|
||||||
overflow-x: hidden;
|
font-family:
|
||||||
display: flex;
|
system-ui,
|
||||||
flex-direction: column;
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pawprint-sidebar.expanded {
|
#spr-sidebar.expanded {
|
||||||
transform: translateX(0);
|
width: 200px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle Button */
|
/* Push page content */
|
||||||
#sidebar-toggle {
|
body.spr-sidebar-active {
|
||||||
position: fixed;
|
margin-left: 60px !important;
|
||||||
right: 0;
|
transition: margin-left 0.2s ease;
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: var(--sidebar-bg);
|
|
||||||
color: var(--sidebar-text);
|
|
||||||
border: 1px solid var(--sidebar-border);
|
|
||||||
border-right: none;
|
|
||||||
border-radius: 8px 0 0 8px;
|
|
||||||
padding: 12px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 10000;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
box-shadow: -2px 0 8px rgba(0,0,0,0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar-toggle:hover {
|
body.spr-sidebar-active.spr-sidebar-expanded {
|
||||||
background: var(--card-hover);
|
margin-left: 200px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar-toggle .icon {
|
/* Sidebar Items (clickable) */
|
||||||
display: block;
|
.spr-sidebar-item {
|
||||||
transition: transform 0.3s;
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #a3a3a3;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pawprint-sidebar.expanded ~ #sidebar-toggle .icon {
|
#spr-sidebar.expanded .spr-sidebar-item {
|
||||||
transform: scaleX(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid var(--sidebar-border);
|
|
||||||
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--sidebar-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header .nest-name {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.7;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.sidebar-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 20px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panel */
|
|
||||||
.panel {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--sidebar-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel h3 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--sidebar-accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Current User Display */
|
|
||||||
.current-user {
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(76, 175, 80, 0.1);
|
|
||||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-user strong {
|
|
||||||
color: var(--success);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-user .logout-btn {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: rgba(244, 67, 54, 0.1);
|
|
||||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
|
||||||
color: var(--error);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-user .logout-btn:hover {
|
|
||||||
background: rgba(244, 67, 54, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User Cards */
|
|
||||||
.user-cards {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--sidebar-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card:hover {
|
|
||||||
background: var(--card-hover);
|
|
||||||
border-color: var(--sidebar-accent);
|
|
||||||
transform: translateX(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card.active {
|
|
||||||
background: rgba(0, 122, 204, 0.2);
|
|
||||||
border-color: var(--sidebar-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card .icon {
|
|
||||||
font-size: 24px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card .info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card .label {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card .role {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.6;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Messages */
|
|
||||||
.status-message {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border: 1px solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-message.success {
|
|
||||||
background: rgba(76, 175, 80, 0.1);
|
|
||||||
border-color: rgba(76, 175, 80, 0.3);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-message.error {
|
|
||||||
background: rgba(244, 67, 54, 0.1);
|
|
||||||
border-color: rgba(244, 67, 54, 0.3);
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-message.info {
|
|
||||||
background: rgba(0, 122, 204, 0.1);
|
|
||||||
border-color: rgba(0, 122, 204, 0.3);
|
|
||||||
color: var(--sidebar-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading Spinner */
|
|
||||||
.loading {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid rgba(255,255,255,0.1);
|
|
||||||
border-top-color: var(--sidebar-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar */
|
|
||||||
#pawprint-sidebar::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pawprint-sidebar::-webkit-scrollbar-track {
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pawprint-sidebar::-webkit-scrollbar-thumb {
|
|
||||||
background: #444;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pawprint-sidebar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.sidebar-footer {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid var(--sidebar-border);
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.5;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#pawprint-sidebar {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
justify-content: flex-start;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-sidebar-item:hover {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-sidebar-item.active {
|
||||||
|
background: #d4a574;
|
||||||
|
color: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-sidebar-item svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label (hidden when collapsed) */
|
||||||
|
.spr-sidebar-item .label {
|
||||||
|
display: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spr-sidebar.expanded .spr-sidebar-item .label {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip (hidden when expanded) */
|
||||||
|
.spr-sidebar-item .tooltip {
|
||||||
|
position: absolute;
|
||||||
|
left: 54px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-sidebar-item:hover .tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spr-sidebar.expanded .spr-sidebar-item .tooltip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle button */
|
||||||
|
.spr-toggle {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
.spr-sidebar-divider {
|
||||||
|
width: 32px;
|
||||||
|
height: 1px;
|
||||||
|
background: #333;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decorative icons (non-clickable) */
|
||||||
|
.spr-sidebar-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-sidebar-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacer to push auth to bottom */
|
||||||
|
.spr-sidebar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vein items under artery icon */
|
||||||
|
.spr-vein-item {
|
||||||
|
width: 36px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-left: 8px;
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-vein-item .label {
|
||||||
|
display: block !important;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spr-sidebar.expanded .spr-vein-item {
|
||||||
|
margin-left: 20px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System links (atlas, station when logged in) */
|
||||||
|
.spr-system-link {
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Locked state (not logged in) */
|
||||||
|
.spr-sidebar-locked {
|
||||||
|
width: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-sidebar-locked .lock-text {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spr-sidebar.expanded .spr-sidebar-locked {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#spr-sidebar.expanded .spr-sidebar-locked .lock-text {
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User/Auth Section */
|
||||||
|
.spr-sidebar-user {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Button */
|
||||||
|
.spr-login-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #a3a3a3;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-login-btn:hover {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spr-login-btn svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,286 +1,223 @@
|
|||||||
// Pawprint Wrapper - Sidebar Logic
|
// Soleprint Sidebar - Self-Injecting Script
|
||||||
|
// Matches the original Jinja2 sidebar look
|
||||||
|
|
||||||
class PawprintSidebar {
|
(function () {
|
||||||
constructor() {
|
"use strict";
|
||||||
this.config = null;
|
|
||||||
this.currentUser = null;
|
|
||||||
this.sidebar = null;
|
|
||||||
this.toggleBtn = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
// Soleprint routes are at root (no prefix)
|
||||||
// Load configuration
|
const SPR_BASE = "";
|
||||||
await this.loadConfig();
|
|
||||||
|
|
||||||
// Create sidebar elements
|
let config = null;
|
||||||
this.createSidebar();
|
let user = null;
|
||||||
this.createToggleButton();
|
|
||||||
|
|
||||||
// Setup event listeners
|
let expanded = localStorage.getItem("spr_sidebar_expanded") === "true";
|
||||||
this.setupEventListeners();
|
|
||||||
|
|
||||||
// Check if user is already logged in
|
// Icons as SVG strings
|
||||||
this.checkCurrentUser();
|
const icons = {
|
||||||
|
toggle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<path d="M9 3v18M3 9h6"/>
|
||||||
|
</svg>`,
|
||||||
|
home: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 12l9-9 9 9M5 10v10a1 1 0 001 1h3m10-11v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1"/>
|
||||||
|
</svg>`,
|
||||||
|
soleprint: `<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<ellipse cx="8" cy="10" rx="3" ry="5" transform="rotate(-10 8 10)"/>
|
||||||
|
<ellipse cx="8" cy="17" rx="2.5" ry="3" transform="rotate(-10 8 17)"/>
|
||||||
|
<ellipse cx="16" cy="8" rx="3" ry="5" transform="rotate(10 16 8)"/>
|
||||||
|
<ellipse cx="16" cy="15" rx="2.5" ry="3" transform="rotate(10 16 15)"/>
|
||||||
|
</svg>`,
|
||||||
|
artery: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2v8M12 10L6 18M12 10l6 8"/>
|
||||||
|
<circle cx="12" cy="10" r="2"/>
|
||||||
|
</svg>`,
|
||||||
|
atlas: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="8"/>
|
||||||
|
<path d="M12 4v16M4 12h16"/>
|
||||||
|
</svg>`,
|
||||||
|
station: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="4" y="6" width="16" height="12" rx="1"/>
|
||||||
|
<circle cx="9" cy="11" r="2"/>
|
||||||
|
<circle cx="15" cy="11" r="2"/>
|
||||||
|
</svg>`,
|
||||||
|
google: `<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>`,
|
||||||
|
user: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="8" r="4"/>
|
||||||
|
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"/>
|
||||||
|
</svg>`,
|
||||||
|
logout: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
|
||||||
|
</svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
// Load saved sidebar state
|
async function init() {
|
||||||
this.loadSidebarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadConfig() {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/wrapper/config.json');
|
// Load config using dynamic base URL
|
||||||
this.config = await response.json();
|
const response = await fetch(`${SPR_BASE}/api/sidebar/config`);
|
||||||
console.log('[Pawprint] Config loaded:', this.config.nest_name);
|
config = await response.json();
|
||||||
|
|
||||||
|
// Check auth status
|
||||||
|
await checkAuth();
|
||||||
|
|
||||||
|
// Create sidebar
|
||||||
|
createSidebar();
|
||||||
|
|
||||||
|
console.log("[Soleprint] Sidebar initialized for room:", config.room);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Pawprint] Failed to load config:', error);
|
console.error("[Soleprint] Failed to initialize sidebar:", error);
|
||||||
// Use default config
|
|
||||||
this.config = {
|
|
||||||
nest_name: 'default',
|
|
||||||
wrapper: {
|
|
||||||
environment: {
|
|
||||||
backend_url: 'http://localhost:8000',
|
|
||||||
frontend_url: 'http://localhost:3000'
|
|
||||||
},
|
|
||||||
users: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createSidebar() {
|
async function checkAuth() {
|
||||||
const sidebar = document.createElement('div');
|
if (!config.auth_enabled) return;
|
||||||
sidebar.id = 'pawprint-sidebar';
|
|
||||||
sidebar.innerHTML = this.getSidebarHTML();
|
|
||||||
document.body.appendChild(sidebar);
|
|
||||||
this.sidebar = sidebar;
|
|
||||||
}
|
|
||||||
|
|
||||||
createToggleButton() {
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.id = 'sidebar-toggle';
|
|
||||||
button.innerHTML = '<span class="icon">◀</span>';
|
|
||||||
button.title = 'Toggle Pawprint Sidebar (Ctrl+Shift+P)';
|
|
||||||
document.body.appendChild(button);
|
|
||||||
this.toggleBtn = button;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSidebarHTML() {
|
|
||||||
const users = this.config.wrapper.users || [];
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<h2>🐾 Pawprint</h2>
|
|
||||||
<div class="nest-name">${this.config.nest_name}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-content">
|
|
||||||
<div id="status-container"></div>
|
|
||||||
|
|
||||||
<!-- Quick Login Panel -->
|
|
||||||
<div class="panel">
|
|
||||||
<h3>👤 Quick Login</h3>
|
|
||||||
|
|
||||||
<div id="current-user-display" style="display: none;">
|
|
||||||
<div class="current-user">
|
|
||||||
Logged in as: <strong id="current-username"></strong>
|
|
||||||
<button class="logout-btn" onclick="pawprintSidebar.logout()">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="user-cards">
|
|
||||||
${users.map(user => `
|
|
||||||
<div class="user-card" data-user-id="${user.id}" onclick="pawprintSidebar.loginAs('${user.id}')">
|
|
||||||
<div class="icon">${user.icon}</div>
|
|
||||||
<div class="info">
|
|
||||||
<span class="label">${user.label}</span>
|
|
||||||
<span class="role">${user.role}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Environment Info Panel -->
|
|
||||||
<div class="panel">
|
|
||||||
<h3>🌍 Environment</h3>
|
|
||||||
<div style="font-size: 12px; opacity: 0.8;">
|
|
||||||
<div style="margin-bottom: 8px;">
|
|
||||||
<strong>Backend:</strong><br>
|
|
||||||
<code style="font-size: 11px;">${this.config.wrapper.environment.backend_url}</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>Frontend:</strong><br>
|
|
||||||
<code style="font-size: 11px;">${this.config.wrapper.environment.frontend_url}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
Pawprint Dev Tools
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners() {
|
|
||||||
// Toggle button
|
|
||||||
this.toggleBtn.addEventListener('click', () => this.toggle());
|
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+Shift+P
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.toggle();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
this.sidebar.classList.toggle('expanded');
|
|
||||||
this.saveSidebarState();
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSidebarState() {
|
|
||||||
const isExpanded = this.sidebar.classList.contains('expanded');
|
|
||||||
localStorage.setItem('pawprint_sidebar_expanded', isExpanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSidebarState() {
|
|
||||||
const isExpanded = localStorage.getItem('pawprint_sidebar_expanded') === 'true';
|
|
||||||
if (isExpanded) {
|
|
||||||
this.sidebar.classList.add('expanded');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showStatus(message, type = 'info') {
|
|
||||||
const container = document.getElementById('status-container');
|
|
||||||
const statusDiv = document.createElement('div');
|
|
||||||
statusDiv.className = `status-message ${type}`;
|
|
||||||
statusDiv.textContent = message;
|
|
||||||
container.innerHTML = '';
|
|
||||||
container.appendChild(statusDiv);
|
|
||||||
|
|
||||||
// Auto-remove after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
statusDiv.remove();
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loginAs(userId) {
|
|
||||||
const user = this.config.wrapper.users.find(u => u.id === userId);
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
this.showStatus(`Logging in as ${user.label}... ⏳`, 'info');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backendUrl = this.config.wrapper.environment.backend_url;
|
const response = await fetch(`${SPR_BASE}/artery/google/oauth/status`, {
|
||||||
const response = await fetch(`${backendUrl}/api/token/`, {
|
credentials: "include",
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: user.username,
|
|
||||||
password: user.password
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Login failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (data.authenticated) {
|
||||||
// Store tokens
|
user = data.user;
|
||||||
localStorage.setItem('access_token', data.access);
|
}
|
||||||
localStorage.setItem('refresh_token', data.refresh);
|
|
||||||
|
|
||||||
// Store user info
|
|
||||||
localStorage.setItem('user_info', JSON.stringify({
|
|
||||||
username: user.username,
|
|
||||||
label: user.label,
|
|
||||||
role: data.details?.role || user.role
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.showStatus(`✓ Logged in as ${user.label}`, 'success');
|
|
||||||
this.currentUser = user;
|
|
||||||
this.updateCurrentUserDisplay();
|
|
||||||
|
|
||||||
// Reload page after short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Pawprint] Login error:', error);
|
console.log("[Soleprint] Auth check failed:", error);
|
||||||
this.showStatus(`✗ Login failed: ${error.message}`, 'error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
function renderSystemSection(systemKey, title) {
|
||||||
localStorage.removeItem('access_token');
|
const veins = config.veins || [];
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user_info');
|
|
||||||
|
|
||||||
this.showStatus('✓ Logged out', 'success');
|
// System link with icon and label (same as soleprint)
|
||||||
this.currentUser = null;
|
let html = `
|
||||||
this.updateCurrentUserDisplay();
|
<a href="${SPR_BASE}/${systemKey}" class="spr-sidebar-item" title="${title}">
|
||||||
|
${icons[systemKey]}
|
||||||
|
<span class="label">${title}</span>
|
||||||
|
<span class="tooltip">${title}</span>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
// Reload page after short delay
|
// Nested elements below - auth-gated
|
||||||
setTimeout(() => {
|
if (user) {
|
||||||
window.location.reload();
|
// Logged in - show vein links under artery
|
||||||
}, 1000);
|
if (systemKey === "artery") {
|
||||||
}
|
for (const vein of veins) {
|
||||||
|
html += `
|
||||||
checkCurrentUser() {
|
<a href="${SPR_BASE}/artery/${vein}" class="spr-sidebar-item spr-vein-item" title="${vein}">
|
||||||
const userInfo = localStorage.getItem('user_info');
|
<span class="label">${vein}</span>
|
||||||
if (userInfo) {
|
<span class="tooltip">${vein}</span>
|
||||||
try {
|
</a>
|
||||||
this.currentUser = JSON.parse(userInfo);
|
`;
|
||||||
this.updateCurrentUserDisplay();
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('[Pawprint] Failed to parse user info:', error);
|
|
||||||
}
|
}
|
||||||
|
} else if (config.auth_enabled) {
|
||||||
|
// Not logged in: show "login to access" below each system icon
|
||||||
|
html += `
|
||||||
|
<div class="spr-sidebar-locked">
|
||||||
|
<span class="lock-text">login to access</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCurrentUserDisplay() {
|
function createSidebar() {
|
||||||
const display = document.getElementById('current-user-display');
|
// Add body class
|
||||||
const username = document.getElementById('current-username');
|
document.body.classList.add("spr-sidebar-active");
|
||||||
|
if (expanded) {
|
||||||
|
document.body.classList.add("spr-sidebar-expanded");
|
||||||
|
}
|
||||||
|
|
||||||
if (this.currentUser) {
|
const sidebar = document.createElement("nav");
|
||||||
display.style.display = 'block';
|
sidebar.id = "spr-sidebar";
|
||||||
username.textContent = this.currentUser.username;
|
if (expanded) sidebar.classList.add("expanded");
|
||||||
|
|
||||||
// Highlight active user card
|
sidebar.innerHTML = `
|
||||||
document.querySelectorAll('.user-card').forEach(card => {
|
<button class="spr-sidebar-item spr-toggle" title="Toggle sidebar">
|
||||||
card.classList.remove('active');
|
${icons.toggle}
|
||||||
});
|
<span class="label">Menu</span>
|
||||||
|
<span class="tooltip">Toggle</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
const activeCard = document.querySelector(`.user-card[data-user-id="${this.getUserIdByUsername(this.currentUser.username)}"]`);
|
<a href="/" class="spr-sidebar-item" title="${config.room}">
|
||||||
if (activeCard) {
|
${icons.home}
|
||||||
activeCard.classList.add('active');
|
<span class="label">${config.room}</span>
|
||||||
}
|
<span class="tooltip">${config.room}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="spr-sidebar-divider"></div>
|
||||||
|
|
||||||
|
<a href="/spr/" class="spr-sidebar-item active" title="Soleprint">
|
||||||
|
${icons.soleprint}
|
||||||
|
<span class="label">Soleprint</span>
|
||||||
|
<span class="tooltip">Soleprint</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="spr-sidebar-divider"></div>
|
||||||
|
|
||||||
|
${renderSystemSection("artery", "Artery")}
|
||||||
|
${renderSystemSection("atlas", "Atlas")}
|
||||||
|
${renderSystemSection("station", "Station")}
|
||||||
|
|
||||||
|
<div class="spr-sidebar-spacer"></div>
|
||||||
|
|
||||||
|
${config.auth_enabled ? renderAuthSection() : ""}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toggle click handler
|
||||||
|
sidebar
|
||||||
|
.querySelector(".spr-toggle")
|
||||||
|
.addEventListener("click", toggleSidebar);
|
||||||
|
|
||||||
|
document.body.appendChild(sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById("spr-sidebar");
|
||||||
|
expanded = !expanded;
|
||||||
|
sidebar.classList.toggle("expanded", expanded);
|
||||||
|
document.body.classList.toggle("spr-sidebar-expanded", expanded);
|
||||||
|
localStorage.setItem("spr_sidebar_expanded", expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthSection() {
|
||||||
|
if (user) {
|
||||||
|
return `
|
||||||
|
<div class="spr-sidebar-user">
|
||||||
|
<a href="${config.auth.logout_url}" class="spr-sidebar-item" title="Logout (${user.email})">
|
||||||
|
${icons.logout}
|
||||||
|
<span class="label">Logout</span>
|
||||||
|
<span class="tooltip">Logout</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
} else {
|
} else {
|
||||||
display.style.display = 'none';
|
// Include current path as redirect target after login (relative, not full URL)
|
||||||
|
const redirectPath = window.location.pathname + window.location.search;
|
||||||
|
const loginUrl = `${config.auth.login_url}?redirect=${encodeURIComponent(redirectPath)}`;
|
||||||
|
return `
|
||||||
|
<div class="spr-sidebar-user">
|
||||||
|
<a href="${loginUrl}" class="spr-sidebar-item" title="Login with Google">
|
||||||
|
${icons.google}
|
||||||
|
<span class="label">Login</span>
|
||||||
|
<span class="tooltip">Login</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserIdByUsername(username) {
|
// Initialize when DOM is ready
|
||||||
const user = this.config.wrapper.users.find(u => u.username === username);
|
if (document.readyState === "loading") {
|
||||||
return user ? user.id : null;
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize sidebar when DOM is ready
|
console.log("[Soleprint] Sidebar script loaded");
|
||||||
const pawprintSidebar = new PawprintSidebar();
|
})();
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => pawprintSidebar.init());
|
|
||||||
} else {
|
|
||||||
pawprintSidebar.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Pawprint] Sidebar script loaded');
|
|
||||||
|
|||||||
Reference in New Issue
Block a user