major restructure
This commit is contained in:
47
soleprint/artery/__init__.py
Normal file
47
soleprint/artery/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Artery - Todo lo vital
|
||||
|
||||
Connectors to external services.
|
||||
|
||||
Hierarchy (simple → complex):
|
||||
|
||||
Vein ──────► Pulse ──────► Plexus
|
||||
│ │ │
|
||||
│ │ └── Full app: backend + frontend + DB
|
||||
│ │ (e.g., WhatsApp with chat UI)
|
||||
│ │
|
||||
│ └── Composed: Vein + Room + Depot
|
||||
│ (e.g., Jira vein configured for specific project)
|
||||
│
|
||||
└── Stateless API connector
|
||||
(e.g., Jira client, Slack client)
|
||||
|
||||
|
||||
Shunt ─── Fake connector for testing
|
||||
(e.g., mercadopago shunt with configurable responses)
|
||||
|
||||
Components:
|
||||
- veins/ Stateless connectors (core/ + api/)
|
||||
- pulses/ Composed: Vein + Room + Depot
|
||||
- plexuses/ Full applications with frontend
|
||||
- shunts/ Fake connectors for testing (configurable responses)
|
||||
- rooms/ Environment configs
|
||||
- depots/ Data storage
|
||||
|
||||
Differences:
|
||||
|
||||
| Aspect | Vein | Pulse | Plexus |
|
||||
|------------|-------------------|-------------------|---------------------------|
|
||||
| State | None (or OAuth) | Vein + config | Full application state |
|
||||
| Frontend | Optional test UI | None (uses vein) | Required full frontend |
|
||||
| Webhooks | No | No | Yes |
|
||||
| Deploy | With soleprint | With soleprint | Self-contained (docker) |
|
||||
|
||||
| Aspect | Shunt |
|
||||
|------------|---------------------------------------------------------------|
|
||||
| Purpose | Fake/mock external service for testing |
|
||||
| Frontend | Config UI to set responses |
|
||||
| Deploy | With soleprint (replaces real vein during testing) |
|
||||
"""
|
||||
|
||||
from . import veins
|
||||
1492
soleprint/artery/index.html
Normal file
1492
soleprint/artery/index.html
Normal file
File diff suppressed because it is too large
Load Diff
79
soleprint/artery/room/README.md
Normal file
79
soleprint/artery/room/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Room - Runtime Environment Configuration
|
||||
|
||||
A **Room** defines connection details for a managed environment (hosts, ports, domains, credentials).
|
||||
|
||||
## Usage
|
||||
|
||||
Rooms are used in composed types:
|
||||
- `Pulse = Vein + Room + Depot` (artery)
|
||||
- `Desk = Cabinet + Room + Depots` (station)
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
artery/room/
|
||||
├── __init__.py # Room model (Pydantic)
|
||||
├── ctrl/ # Base ctrl script templates
|
||||
│ ├── start.sh # Start services
|
||||
│ ├── stop.sh # Stop services
|
||||
│ ├── status.sh # Show status
|
||||
│ ├── logs.sh # View logs
|
||||
│ └── build.sh # Build images
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Room Data
|
||||
|
||||
Room instances are stored in `data/rooms.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "soleprint-local",
|
||||
"slug": "soleprint-local",
|
||||
"title": "Soleprint Local",
|
||||
"status": "dev",
|
||||
"config_path": "mainroom/soleprint"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## ctrl/ Templates
|
||||
|
||||
The scripts in `ctrl/` are templates for room management. Copy them to your room's `ctrl/` folder and customize.
|
||||
|
||||
All scripts:
|
||||
- Auto-detect services (directories with `docker-compose.yml`)
|
||||
- Support targeting specific services: `./start.sh myservice`
|
||||
- Load `.env` from the room root
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Start
|
||||
./ctrl/start.sh # All services (foreground)
|
||||
./ctrl/start.sh -d # Detached
|
||||
./ctrl/start.sh --build # With rebuild
|
||||
|
||||
# Stop
|
||||
./ctrl/stop.sh # All services
|
||||
./ctrl/stop.sh myservice # Specific service
|
||||
|
||||
# Status
|
||||
./ctrl/status.sh
|
||||
|
||||
# Logs
|
||||
./ctrl/logs.sh # All
|
||||
./ctrl/logs.sh -f # Follow
|
||||
./ctrl/logs.sh myservice # Specific service
|
||||
|
||||
# Build
|
||||
./ctrl/build.sh # All
|
||||
./ctrl/build.sh --no-cache # Force rebuild
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
For production deployments, use Woodpecker CI/CD instead of manual ctrl scripts.
|
||||
77
soleprint/artery/room/__init__.py
Normal file
77
soleprint/artery/room/__init__.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Room - Runtime environment configuration.
|
||||
|
||||
A Room defines connection details for a managed environment (hosts, ports, domains, credentials).
|
||||
Used by Pulse (Vein + Room + Depot) and Desk (Cabinet + Room + Depots).
|
||||
|
||||
Room instances are stored in data/rooms.json.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RoomStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
PLANNED = "planned"
|
||||
BUILDING = "building"
|
||||
DEV = "dev"
|
||||
LIVE = "live"
|
||||
READY = "ready"
|
||||
|
||||
|
||||
class RoomConfig(BaseModel):
|
||||
"""Environment-specific configuration for a room."""
|
||||
|
||||
# Network
|
||||
host: Optional[str] = Field(None, description="Primary host/domain")
|
||||
port: Optional[int] = Field(None, description="Primary port")
|
||||
|
||||
# Paths
|
||||
config_path: Optional[str] = Field(None, description="Path to room config folder")
|
||||
deploy_path: Optional[str] = Field(None, description="Deployment target path")
|
||||
|
||||
# Docker
|
||||
network_name: Optional[str] = Field(None, description="Docker network name")
|
||||
deployment_name: Optional[str] = Field(None, description="Container name prefix")
|
||||
|
||||
# Database (when room has DB access)
|
||||
db_host: Optional[str] = None
|
||||
db_port: Optional[int] = Field(None, ge=1, le=65535)
|
||||
db_name: Optional[str] = None
|
||||
db_user: Optional[str] = None
|
||||
# Note: db_password should come from env vars, not stored in config
|
||||
|
||||
|
||||
class Room(BaseModel):
|
||||
"""Runtime environment configuration."""
|
||||
|
||||
name: str = Field(..., description="Unique identifier")
|
||||
slug: str = Field(..., description="URL-friendly identifier")
|
||||
title: str = Field(..., description="Display title for UI")
|
||||
status: RoomStatus = Field(RoomStatus.PENDING, description="Current status")
|
||||
|
||||
# Optional extended config
|
||||
config: Optional[RoomConfig] = Field(None, description="Environment configuration")
|
||||
|
||||
# Legacy field for backwards compatibility
|
||||
config_path: Optional[str] = Field(None, description="Path to room config folder")
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
def load_rooms(data_path: str = "data/rooms.json") -> list[Room]:
|
||||
"""Load rooms from data file."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(data_path)
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
return [Room(**item) for item in data.get("items", [])]
|
||||
44
soleprint/artery/room/ctrl/build.sh
Executable file
44
soleprint/artery/room/ctrl/build.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Build room Docker images
|
||||
#
|
||||
# Usage:
|
||||
# ./build.sh # Build all
|
||||
# ./build.sh <service> # Build specific service
|
||||
# ./build.sh --no-cache # Force rebuild
|
||||
#
|
||||
# This is a TEMPLATE. Copy to your room's ctrl/ and customize.
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
NO_CACHE=""
|
||||
TARGET="all"
|
||||
SERVICE_DIRS=()
|
||||
|
||||
for dir in */; do
|
||||
[ -f "$dir/docker-compose.yml" ] && SERVICE_DIRS+=("${dir%/}")
|
||||
done
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--no-cache) NO_CACHE="--no-cache" ;;
|
||||
*) [[ " ${SERVICE_DIRS[*]} " =~ " ${arg} " ]] && TARGET="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
build_service() {
|
||||
local svc=$1
|
||||
echo "Building $svc..."
|
||||
(cd "$svc" && docker compose build $NO_CACHE)
|
||||
}
|
||||
|
||||
if [ "$TARGET" = "all" ]; then
|
||||
for svc in "${SERVICE_DIRS[@]}"; do
|
||||
build_service "$svc"
|
||||
done
|
||||
else
|
||||
build_service "$TARGET"
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
43
soleprint/artery/room/ctrl/logs.sh
Executable file
43
soleprint/artery/room/ctrl/logs.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# View room service logs
|
||||
#
|
||||
# Usage:
|
||||
# ./logs.sh # All logs
|
||||
# ./logs.sh <service> # Service compose logs
|
||||
# ./logs.sh <container> # Specific container logs
|
||||
# ./logs.sh -f # Follow mode
|
||||
#
|
||||
# This is a TEMPLATE. Copy to your room's ctrl/ and customize.
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
FOLLOW=""
|
||||
TARGET=""
|
||||
SERVICE_DIRS=()
|
||||
|
||||
for dir in */; do
|
||||
[ -f "$dir/docker-compose.yml" ] && SERVICE_DIRS+=("${dir%/}")
|
||||
done
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
-f|--follow) FOLLOW="-f" ;;
|
||||
*) TARGET="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$TARGET" ]; then
|
||||
# Show all logs
|
||||
for svc in "${SERVICE_DIRS[@]}"; do
|
||||
echo "=== $svc ==="
|
||||
(cd "$svc" && docker compose logs --tail=20 $FOLLOW) || true
|
||||
done
|
||||
elif [[ " ${SERVICE_DIRS[*]} " =~ " ${TARGET} " ]]; then
|
||||
# Service compose logs
|
||||
(cd "$TARGET" && docker compose logs $FOLLOW)
|
||||
else
|
||||
# Specific container
|
||||
docker logs $FOLLOW "$TARGET"
|
||||
fi
|
||||
52
soleprint/artery/room/ctrl/start.sh
Executable file
52
soleprint/artery/room/ctrl/start.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Start room services
|
||||
#
|
||||
# Usage:
|
||||
# ./start.sh # Start all (foreground)
|
||||
# ./start.sh -d # Start all (detached)
|
||||
# ./start.sh --build # Start with rebuild
|
||||
# ./start.sh <service> # Start specific service
|
||||
#
|
||||
# This is a TEMPLATE. Copy to your room's ctrl/ and customize.
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Load environment
|
||||
[ -f ".env" ] && set -a && source .env && set +a
|
||||
|
||||
DETACH=""
|
||||
BUILD=""
|
||||
TARGET="all"
|
||||
SERVICE_DIRS=()
|
||||
|
||||
# Auto-detect services (dirs with docker-compose.yml)
|
||||
for dir in */; do
|
||||
[ -f "$dir/docker-compose.yml" ] && SERVICE_DIRS+=("${dir%/}")
|
||||
done
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
-d|--detached) DETACH="-d" ;;
|
||||
--build) BUILD="--build" ;;
|
||||
*) [[ " ${SERVICE_DIRS[*]} " =~ " ${arg} " ]] && TARGET="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
start_service() {
|
||||
local svc=$1
|
||||
echo "Starting $svc..."
|
||||
(cd "$svc" && docker compose up $DETACH $BUILD)
|
||||
[ -n "$DETACH" ] && echo " $svc started"
|
||||
}
|
||||
|
||||
if [ "$TARGET" = "all" ]; then
|
||||
for svc in "${SERVICE_DIRS[@]}"; do
|
||||
start_service "$svc"
|
||||
done
|
||||
else
|
||||
start_service "$TARGET"
|
||||
fi
|
||||
|
||||
[ -n "$DETACH" ] && docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
22
soleprint/artery/room/ctrl/status.sh
Executable file
22
soleprint/artery/room/ctrl/status.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Show room service status
|
||||
#
|
||||
# Usage:
|
||||
# ./status.sh
|
||||
#
|
||||
# This is a TEMPLATE. Copy to your room's ctrl/ and customize.
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[ -f ".env" ] && source .env
|
||||
|
||||
NAME="${DEPLOYMENT_NAME:-room}"
|
||||
|
||||
echo "=== Docker Containers ==="
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "($NAME|NAMES)" || echo "No containers running"
|
||||
|
||||
echo ""
|
||||
echo "=== Networks ==="
|
||||
docker network ls | grep -E "(${NETWORK_NAME:-$NAME}|NETWORK)" || echo "No matching networks"
|
||||
38
soleprint/artery/room/ctrl/stop.sh
Executable file
38
soleprint/artery/room/ctrl/stop.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Stop room services
|
||||
#
|
||||
# Usage:
|
||||
# ./stop.sh # Stop all
|
||||
# ./stop.sh <service> # Stop specific service
|
||||
#
|
||||
# This is a TEMPLATE. Copy to your room's ctrl/ and customize.
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
TARGET="all"
|
||||
SERVICE_DIRS=()
|
||||
|
||||
# Auto-detect services
|
||||
for dir in */; do
|
||||
[ -f "$dir/docker-compose.yml" ] && SERVICE_DIRS+=("${dir%/}")
|
||||
done
|
||||
|
||||
[ -n "$1" ] && [[ " ${SERVICE_DIRS[*]} " =~ " $1 " ]] && TARGET="$1"
|
||||
|
||||
stop_service() {
|
||||
local svc=$1
|
||||
echo "Stopping $svc..."
|
||||
(cd "$svc" && docker compose down)
|
||||
}
|
||||
|
||||
if [ "$TARGET" = "all" ]; then
|
||||
for svc in "${SERVICE_DIRS[@]}"; do
|
||||
stop_service "$svc"
|
||||
done
|
||||
else
|
||||
stop_service "$TARGET"
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
18
soleprint/artery/shunts/__init__.py
Normal file
18
soleprint/artery/shunts/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Shunts - Fake connectors for testing.
|
||||
|
||||
A shunt redirects flow when the real service isn't available.
|
||||
Each shunt mimics a vein's interface with configurable responses.
|
||||
|
||||
Structure:
|
||||
shunts/<service>/
|
||||
├── main.py # FastAPI with config UI
|
||||
├── depot/
|
||||
│ └── responses.json # Configurable fake responses
|
||||
└── README.md
|
||||
|
||||
Usage:
|
||||
1. Start the shunt instead of the real vein
|
||||
2. Configure responses via the UI or responses.json
|
||||
3. Run tests against the shunt
|
||||
"""
|
||||
37
soleprint/artery/shunts/example/README.md
Normal file
37
soleprint/artery/shunts/example/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Example Shunt
|
||||
|
||||
Template for creating fake service connectors for testing.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run the shunt
|
||||
python main.py
|
||||
|
||||
# Or with uvicorn
|
||||
uvicorn main:app --port 8099 --reload
|
||||
```
|
||||
|
||||
## Creating a New Shunt
|
||||
|
||||
1. Copy this directory:
|
||||
```bash
|
||||
cp -r shunts/example shunts/mercadopago
|
||||
```
|
||||
|
||||
2. Edit `depot/responses.json` with fake responses
|
||||
|
||||
3. Update `main.py` to match the real vein's API endpoints
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `depot/responses.json` to configure fake responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /endpoint": {"response": "data"},
|
||||
"POST /endpoint": {"success": true}
|
||||
}
|
||||
```
|
||||
|
||||
The shunt UI at `/` shows current configuration.
|
||||
15
soleprint/artery/shunts/example/depot/responses.json
Normal file
15
soleprint/artery/shunts/example/depot/responses.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"GET /users": [
|
||||
{"id": 1, "name": "Test User", "email": "test@example.com"}
|
||||
],
|
||||
"GET /users/1": {
|
||||
"id": 1,
|
||||
"name": "Test User",
|
||||
"email": "test@example.com"
|
||||
},
|
||||
"POST /users": {
|
||||
"id": 2,
|
||||
"name": "Created User",
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
93
soleprint/artery/shunts/example/main.py
Normal file
93
soleprint/artery/shunts/example/main.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Example Shunt - Template for creating fake service connectors.
|
||||
|
||||
Copy this to create a new shunt:
|
||||
cp -r shunts/example shunts/mercadopago
|
||||
|
||||
Then customize:
|
||||
- Update responses.json with fake responses
|
||||
- Add endpoints matching the real vein's API
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
app = FastAPI(title="Example Shunt", description="Fake service for testing")
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
DEPOT_DIR = Path(os.getenv("SHUNT_DEPOT", str(BASE_DIR / "depot")))
|
||||
|
||||
# Load responses
|
||||
RESPONSES_FILE = DEPOT_DIR / "responses.json"
|
||||
responses = {}
|
||||
if RESPONSES_FILE.exists():
|
||||
responses = json.loads(RESPONSES_FILE.read_text())
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "shunt": "example"}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def config_ui():
|
||||
"""Simple UI to view/edit responses."""
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Example Shunt</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui; background: #111827; color: #f3f4f6; padding: 2rem; }}
|
||||
h1 {{ color: #60a5fa; }}
|
||||
pre {{ background: #1f2937; padding: 1rem; border-radius: 8px; overflow-x: auto; }}
|
||||
.info {{ color: #9ca3af; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Example Shunt</h1>
|
||||
<p class="info">This shunt returns configurable fake responses for testing.</p>
|
||||
<h2>Current Responses</h2>
|
||||
<pre>{json.dumps(responses, indent=2)}</pre>
|
||||
<p class="info">Edit {RESPONSES_FILE} to change responses.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/api/{endpoint:path}")
|
||||
def fake_get(endpoint: str):
|
||||
"""Return configured response for GET requests."""
|
||||
key = f"GET /{endpoint}"
|
||||
if key in responses:
|
||||
return responses[key]
|
||||
return {"error": "not configured", "endpoint": endpoint, "method": "GET"}
|
||||
|
||||
|
||||
@app.post("/api/{endpoint:path}")
|
||||
async def fake_post(endpoint: str, request: Request):
|
||||
"""Return configured response for POST requests."""
|
||||
key = f"POST /{endpoint}"
|
||||
if key in responses:
|
||||
return responses[key]
|
||||
body = (
|
||||
await request.json()
|
||||
if request.headers.get("content-type") == "application/json"
|
||||
else {}
|
||||
)
|
||||
return {
|
||||
"error": "not configured",
|
||||
"endpoint": endpoint,
|
||||
"method": "POST",
|
||||
"received": body,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8099)
|
||||
16
soleprint/artery/shunts/mercadopago/.env.example
Normal file
16
soleprint/artery/shunts/mercadopago/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# MercadoPago (MOCK) Vein Configuration
|
||||
API_PORT=8006
|
||||
|
||||
# Mock data settings
|
||||
MOCK_DATA_PATH=./mock_data
|
||||
|
||||
# Mock behavior
|
||||
ENABLE_RANDOM_DELAYS=true
|
||||
MIN_DELAY_MS=200
|
||||
MAX_DELAY_MS=800
|
||||
|
||||
# Simulate errors
|
||||
ERROR_RATE=0.0 # 0.0 to 1.0 (0% to 100%)
|
||||
|
||||
# Default payment status for testing
|
||||
DEFAULT_PAYMENT_STATUS=approved # approved, pending, rejected
|
||||
263
soleprint/artery/shunts/mercadopago/README.md
Normal file
263
soleprint/artery/shunts/mercadopago/README.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# MercadoPago (MOCK) Vein
|
||||
|
||||
Mock MercadoPago API for testing - simulates payment processing without hitting the real MercadoPago API.
|
||||
|
||||
## Purpose
|
||||
|
||||
Enables testing of MercadoPago integration without:
|
||||
- Creating real payments
|
||||
- Connecting real MercadoPago accounts
|
||||
- Exposing credentials
|
||||
- Consuming API quotas
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Start the mock server
|
||||
python run.py
|
||||
|
||||
# API docs: http://localhost:8006/docs
|
||||
# Health check: http://localhost:8006/health
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and adjust:
|
||||
|
||||
```bash
|
||||
API_PORT=8006 # Server port
|
||||
ENABLE_RANDOM_DELAYS=true # Add realistic delays
|
||||
MIN_DELAY_MS=200 # Minimum delay
|
||||
MAX_DELAY_MS=800 # Maximum delay
|
||||
ERROR_RATE=0.0 # Error rate (0.0 to 1.0)
|
||||
DEFAULT_PAYMENT_STATUS=approved # approved, pending, rejected
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Checkout Pro (Preferences)
|
||||
|
||||
```bash
|
||||
# Create payment link
|
||||
POST /v1/preferences
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"title": "Visita a domicilio",
|
||||
"quantity": 1,
|
||||
"unit_price": 95000,
|
||||
"currency_id": "ARS"
|
||||
}
|
||||
],
|
||||
"external_reference": "SR-12345",
|
||||
"back_urls": {
|
||||
"success": "https://backoffice.amarmascotas.ar/pagos/success/",
|
||||
"pending": "https://backoffice.amarmascotas.ar/pagos/pending/",
|
||||
"failure": "https://backoffice.amarmascotas.ar/pagos/failure/"
|
||||
},
|
||||
"notification_url": "https://backoffice.amarmascotas.ar/payments/mp/webhook/"
|
||||
}
|
||||
|
||||
# Get preference
|
||||
GET /v1/preferences/{preference_id}
|
||||
```
|
||||
|
||||
### Checkout API (Payments)
|
||||
|
||||
```bash
|
||||
# Create payment
|
||||
POST /v1/payments
|
||||
Headers:
|
||||
X-Idempotency-Key: unique-key-123
|
||||
Body:
|
||||
{
|
||||
"transaction_amount": 95000,
|
||||
"description": "Visita a domicilio",
|
||||
"payment_method_id": "visa",
|
||||
"payer": {
|
||||
"email": "test@example.com",
|
||||
"identification": {
|
||||
"type": "DNI",
|
||||
"number": "12345678"
|
||||
}
|
||||
},
|
||||
"application_fee": 45000
|
||||
}
|
||||
|
||||
# Get payment details
|
||||
GET /v1/payments/{payment_id}
|
||||
```
|
||||
|
||||
### OAuth
|
||||
|
||||
```bash
|
||||
# Exchange authorization code for tokens
|
||||
POST /oauth/token
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": "APP_ID",
|
||||
"client_secret": "APP_SECRET",
|
||||
"code": "AUTH_CODE"
|
||||
}
|
||||
|
||||
# Refresh access token
|
||||
POST /oauth/token
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": "APP_ID",
|
||||
"client_secret": "APP_SECRET",
|
||||
"refresh_token": "REFRESH_TOKEN"
|
||||
}
|
||||
```
|
||||
|
||||
### Mock Control
|
||||
|
||||
```bash
|
||||
# Get mock database stats
|
||||
GET /mock/stats
|
||||
|
||||
# Reset mock database
|
||||
GET /mock/reset
|
||||
|
||||
# Update mock configuration
|
||||
POST /mock/config
|
||||
{
|
||||
"default_payment_status": "approved",
|
||||
"error_rate": 0.1
|
||||
}
|
||||
|
||||
# Simulate webhook notification
|
||||
POST /mock/webhook?topic=payment&resource_id=12345
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
All responses include `_mock: "MercadoPago"` to identify mock data:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123456789,
|
||||
"status": "approved",
|
||||
"transaction_amount": 95000,
|
||||
"_mock": "MercadoPago"
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Test Payment Link Creation
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8006"
|
||||
|
||||
# Create preference
|
||||
pref_resp = requests.post(f"{BASE_URL}/v1/preferences", json={
|
||||
"items": [{
|
||||
"title": "Visita a domicilio",
|
||||
"quantity": 1,
|
||||
"unit_price": 95000,
|
||||
"currency_id": "ARS"
|
||||
}],
|
||||
"external_reference": "SR-12345"
|
||||
})
|
||||
pref = pref_resp.json()
|
||||
print(f"Payment link: {pref['init_point']}")
|
||||
```
|
||||
|
||||
### Test Direct Payment with Split
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8006"
|
||||
|
||||
# Create payment with application fee (split)
|
||||
payment_resp = requests.post(
|
||||
f"{BASE_URL}/v1/payments",
|
||||
headers={"X-Idempotency-Key": "unique-123"},
|
||||
json={
|
||||
"transaction_amount": 95000,
|
||||
"description": "Visita a domicilio",
|
||||
"payment_method_id": "visa",
|
||||
"payer": {
|
||||
"email": "test@example.com",
|
||||
"identification": {"type": "DNI", "number": "12345678"}
|
||||
},
|
||||
"application_fee": 45000 # Platform fee
|
||||
}
|
||||
)
|
||||
payment = payment_resp.json()
|
||||
print(f"Payment status: {payment['status']}")
|
||||
print(f"Net amount (for vet): ${payment['net_amount']}")
|
||||
```
|
||||
|
||||
### Test Different Payment Statuses
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8006"
|
||||
|
||||
# Configure mock to return rejected payments
|
||||
requests.post(f"{BASE_URL}/mock/config", json={
|
||||
"default_payment_status": "rejected"
|
||||
})
|
||||
|
||||
# Now all payments will be rejected
|
||||
payment_resp = requests.post(f"{BASE_URL}/v1/payments", json={...})
|
||||
print(payment_resp.json()["status"]) # "rejected"
|
||||
|
||||
# Reset to approved
|
||||
requests.post(f"{BASE_URL}/mock/config", json={
|
||||
"default_payment_status": "approved"
|
||||
})
|
||||
```
|
||||
|
||||
### Test Error Scenarios
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8006"
|
||||
|
||||
# Configure 50% error rate
|
||||
requests.post(f"{BASE_URL}/mock/config", json={
|
||||
"error_rate": 0.5
|
||||
})
|
||||
|
||||
# Half of requests will now fail with 500 error
|
||||
for i in range(10):
|
||||
try:
|
||||
resp = requests.post(f"{BASE_URL}/v1/payments", json={...})
|
||||
print(f"Request {i}: Success")
|
||||
except:
|
||||
print(f"Request {i}: Failed")
|
||||
```
|
||||
|
||||
## Data Generator
|
||||
|
||||
This vein uses the independent `datagen` tool from `ward/tools/datagen/mercadopago.py`.
|
||||
See `ward/tools/datagen/README.md` for data generation details.
|
||||
|
||||
## Integration with Amar Backend
|
||||
|
||||
Point your Amar backend to the mock MercadoPago API:
|
||||
|
||||
```python
|
||||
# settings.py or .env
|
||||
MP_PLATFORM_ACCESS_TOKEN = "mock_token" # Any value works
|
||||
MP_API_BASE_URL = "http://localhost:8006" # Point to mock
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Mock database is in-memory (resets on server restart)
|
||||
- All payment IDs are randomly generated
|
||||
- Payment status can be configured via `/mock/config`
|
||||
- Webhook notifications can be simulated via `/mock/webhook`
|
||||
- OAuth tokens are generated but not validated
|
||||
1
soleprint/artery/shunts/mercadopago/__init__.py
Normal file
1
soleprint/artery/shunts/mercadopago/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""MercadoPago (MOCK) vein - Mock MercadoPago API for testing."""
|
||||
1
soleprint/artery/shunts/mercadopago/api/__init__.py
Normal file
1
soleprint/artery/shunts/mercadopago/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes for MercadoPago mock vein."""
|
||||
281
soleprint/artery/shunts/mercadopago/api/routes.py
Normal file
281
soleprint/artery/shunts/mercadopago/api/routes.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""API routes for MercadoPago (MOCK) vein - Mock MercadoPago API for testing."""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from fastapi import APIRouter, HTTPException, Query, Header
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Import datagen from ward/tools
|
||||
import sys
|
||||
from pathlib import Path
|
||||
ward_tools_path = Path(__file__).parent.parent.parent.parent.parent / "ward" / "tools"
|
||||
sys.path.insert(0, str(ward_tools_path))
|
||||
|
||||
from datagen.mercadopago import MercadoPagoDataGenerator
|
||||
|
||||
from ..core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# In-memory storage for mock data
|
||||
MOCK_DB = {
|
||||
"preferences": {},
|
||||
"payments": {},
|
||||
"merchant_orders": {},
|
||||
"tokens": {},
|
||||
}
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class PreferenceItem(BaseModel):
|
||||
title: str
|
||||
quantity: int
|
||||
unit_price: float
|
||||
currency_id: str = "ARS"
|
||||
|
||||
|
||||
class CreatePreferenceRequest(BaseModel):
|
||||
items: List[PreferenceItem]
|
||||
external_reference: Optional[str] = None
|
||||
back_urls: Optional[Dict[str, str]] = None
|
||||
notification_url: Optional[str] = None
|
||||
auto_return: str = "approved"
|
||||
|
||||
|
||||
class CreatePaymentRequest(BaseModel):
|
||||
transaction_amount: float
|
||||
description: str
|
||||
payment_method_id: str
|
||||
payer: Dict[str, Any]
|
||||
application_fee: Optional[float] = None
|
||||
token: Optional[str] = None
|
||||
installments: Optional[int] = 1
|
||||
issuer_id: Optional[str] = None
|
||||
|
||||
|
||||
async def _mock_delay():
|
||||
"""Add realistic delay if enabled."""
|
||||
if settings.enable_random_delays:
|
||||
delay_ms = random.randint(settings.min_delay_ms, settings.max_delay_ms)
|
||||
await asyncio.sleep(delay_ms / 1000)
|
||||
|
||||
|
||||
def _maybe_error():
|
||||
"""Randomly raise an error based on error_rate."""
|
||||
if random.random() < settings.error_rate:
|
||||
raise HTTPException(500, "Mock error: Simulated MercadoPago failure")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"vein": "MercadoPago\n(MOCK)",
|
||||
"message": "Mock MercadoPago API for testing",
|
||||
"_mock": "MercadoPago",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/v1/preferences")
|
||||
async def create_preference(request: CreatePreferenceRequest):
|
||||
"""Create a Checkout Pro preference (payment link)."""
|
||||
await _mock_delay()
|
||||
_maybe_error()
|
||||
|
||||
# Calculate total from items
|
||||
total = sum(item.unit_price * item.quantity for item in request.items)
|
||||
|
||||
# Generate preference
|
||||
preference = MercadoPagoDataGenerator.preference(
|
||||
description=request.items[0].title if request.items else "Payment",
|
||||
total=total,
|
||||
external_reference=request.external_reference,
|
||||
)
|
||||
|
||||
# Store in mock DB
|
||||
MOCK_DB["preferences"][preference["id"]] = preference
|
||||
|
||||
return preference
|
||||
|
||||
|
||||
@router.get("/v1/preferences/{preference_id}")
|
||||
async def get_preference(preference_id: str):
|
||||
"""Get preference by ID."""
|
||||
await _mock_delay()
|
||||
_maybe_error()
|
||||
|
||||
preference = MOCK_DB["preferences"].get(preference_id)
|
||||
if not preference:
|
||||
raise HTTPException(404, {"message": "Preference not found", "error": "not_found", "status": 404})
|
||||
|
||||
return preference
|
||||
|
||||
|
||||
@router.post("/v1/payments")
|
||||
async def create_payment(
|
||||
request: CreatePaymentRequest,
|
||||
x_idempotency_key: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a payment (Checkout API/Bricks)."""
|
||||
await _mock_delay()
|
||||
_maybe_error()
|
||||
|
||||
# Use configured default status or random
|
||||
status = settings.default_payment_status
|
||||
if status not in ["approved", "pending", "rejected"]:
|
||||
status = random.choice(["approved", "pending", "rejected"])
|
||||
|
||||
# Generate payment
|
||||
payment = MercadoPagoDataGenerator.payment(
|
||||
transaction_amount=request.transaction_amount,
|
||||
description=request.description,
|
||||
status=status,
|
||||
application_fee=request.application_fee,
|
||||
)
|
||||
|
||||
# Override with request payment method
|
||||
payment["payment_method_id"] = request.payment_method_id
|
||||
payment["payer"] = request.payer
|
||||
|
||||
# Store in mock DB
|
||||
MOCK_DB["payments"][payment["id"]] = payment
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
@router.get("/v1/payments/{payment_id}")
|
||||
async def get_payment(payment_id: int):
|
||||
"""Get payment details."""
|
||||
await _mock_delay()
|
||||
_maybe_error()
|
||||
|
||||
payment = MOCK_DB["payments"].get(payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(404, {"message": "Payment not found", "error": "not_found", "status": 404})
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
@router.get("/v1/merchant_orders/{order_id}")
|
||||
async def get_merchant_order(order_id: int):
|
||||
"""Get merchant order details."""
|
||||
await _mock_delay()
|
||||
_maybe_error()
|
||||
|
||||
order = MOCK_DB["merchant_orders"].get(order_id)
|
||||
if not order:
|
||||
# Generate on-the-fly if not found
|
||||
order = MercadoPagoDataGenerator.merchant_order(
|
||||
preference_id=f"pref_{order_id}",
|
||||
total=100000,
|
||||
paid_amount=100000,
|
||||
)
|
||||
order["id"] = order_id
|
||||
MOCK_DB["merchant_orders"][order_id] = order
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/oauth/token")
|
||||
async def oauth_token(
|
||||
grant_type: str = "refresh_token",
|
||||
client_id: str = None,
|
||||
client_secret: str = None,
|
||||
refresh_token: str = None,
|
||||
code: str = None,
|
||||
):
|
||||
"""OAuth token exchange/refresh."""
|
||||
await _mock_delay()
|
||||
_maybe_error()
|
||||
|
||||
if grant_type == "refresh_token":
|
||||
if not refresh_token:
|
||||
raise HTTPException(400, {"error": "invalid_request", "error_description": "refresh_token is required"})
|
||||
|
||||
# Generate new tokens
|
||||
token_data = MercadoPagoDataGenerator.oauth_token()
|
||||
MOCK_DB["tokens"][token_data["access_token"]] = token_data
|
||||
return token_data
|
||||
|
||||
elif grant_type == "authorization_code":
|
||||
if not code:
|
||||
raise HTTPException(400, {"error": "invalid_request", "error_description": "code is required"})
|
||||
|
||||
# Generate tokens from code
|
||||
token_data = MercadoPagoDataGenerator.oauth_token()
|
||||
MOCK_DB["tokens"][token_data["access_token"]] = token_data
|
||||
return token_data
|
||||
|
||||
else:
|
||||
raise HTTPException(400, {"error": "unsupported_grant_type"})
|
||||
|
||||
|
||||
@router.post("/mock/webhook")
|
||||
async def simulate_webhook(
|
||||
topic: str = "payment",
|
||||
resource_id: str = None,
|
||||
):
|
||||
"""Simulate a webhook notification (for testing)."""
|
||||
await _mock_delay()
|
||||
|
||||
if not resource_id:
|
||||
raise HTTPException(400, "resource_id is required")
|
||||
|
||||
notification = MercadoPagoDataGenerator.webhook_notification(
|
||||
topic=topic,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
|
||||
return notification
|
||||
|
||||
|
||||
@router.get("/mock/reset")
|
||||
async def reset_mock_db():
|
||||
"""Reset the mock database."""
|
||||
MOCK_DB["preferences"].clear()
|
||||
MOCK_DB["payments"].clear()
|
||||
MOCK_DB["merchant_orders"].clear()
|
||||
MOCK_DB["tokens"].clear()
|
||||
|
||||
return {
|
||||
"message": "Mock database reset",
|
||||
"_mock": "MercadoPago",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/mock/stats")
|
||||
async def mock_stats():
|
||||
"""Get mock database statistics."""
|
||||
return {
|
||||
"preferences": len(MOCK_DB["preferences"]),
|
||||
"payments": len(MOCK_DB["payments"]),
|
||||
"merchant_orders": len(MOCK_DB["merchant_orders"]),
|
||||
"tokens": len(MOCK_DB["tokens"]),
|
||||
"_mock": "MercadoPago",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mock/config")
|
||||
async def update_mock_config(
|
||||
default_payment_status: Optional[str] = None,
|
||||
error_rate: Optional[float] = None,
|
||||
):
|
||||
"""Update mock configuration (for testing different scenarios)."""
|
||||
if default_payment_status:
|
||||
if default_payment_status not in ["approved", "pending", "rejected", "in_process", "cancelled"]:
|
||||
raise HTTPException(400, "Invalid payment status")
|
||||
settings.default_payment_status = default_payment_status
|
||||
|
||||
if error_rate is not None:
|
||||
if not (0 <= error_rate <= 1):
|
||||
raise HTTPException(400, "error_rate must be between 0 and 1")
|
||||
settings.error_rate = error_rate
|
||||
|
||||
return {
|
||||
"default_payment_status": settings.default_payment_status,
|
||||
"error_rate": settings.error_rate,
|
||||
"_mock": "MercadoPago",
|
||||
}
|
||||
1
soleprint/artery/shunts/mercadopago/core/__init__.py
Normal file
1
soleprint/artery/shunts/mercadopago/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core logic for MercadoPago mock API."""
|
||||
30
soleprint/artery/shunts/mercadopago/core/config.py
Normal file
30
soleprint/artery/shunts/mercadopago/core/config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Configuration for MercadoPago mock vein."""
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||
|
||||
|
||||
class MercadoPagoMockConfig(BaseSettings):
|
||||
"""Configuration for MercadoPago (MOCK) vein."""
|
||||
|
||||
api_port: int = 8006
|
||||
mock_data_path: str = "./mock_data"
|
||||
|
||||
# Mock behavior
|
||||
enable_random_delays: bool = True
|
||||
min_delay_ms: int = 200
|
||||
max_delay_ms: int = 800
|
||||
error_rate: float = 0.0 # 0.0 to 1.0
|
||||
|
||||
# Default payment status
|
||||
default_payment_status: str = "approved" # approved, pending, rejected
|
||||
|
||||
model_config = {
|
||||
"env_file": ENV_FILE if ENV_FILE.exists() else None,
|
||||
"env_file_encoding": "utf-8",
|
||||
}
|
||||
|
||||
|
||||
settings = MercadoPagoMockConfig()
|
||||
40
soleprint/artery/shunts/mercadopago/main.py
Normal file
40
soleprint/artery/shunts/mercadopago/main.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""MercadoPago (MOCK) Vein - FastAPI app."""
|
||||
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from .api.routes import router
|
||||
from .core.config import settings
|
||||
|
||||
app = FastAPI(
|
||||
title="MercadoPago (MOCK)",
|
||||
description="Mock MercadoPago API for testing - simulates payment processing",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Enable CORS for testing from backend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, specify exact origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Templates for configuration UI
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
"""Mock configuration UI."""
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
# Include router at root (matches real MercadoPago API structure)
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)
|
||||
4
soleprint/artery/shunts/mercadopago/requirements.txt
Normal file
4
soleprint/artery/shunts/mercadopago/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
18
soleprint/artery/shunts/mercadopago/run.py
Normal file
18
soleprint/artery/shunts/mercadopago/run.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Standalone runner for MercadoPago mock vein."""
|
||||
|
||||
import logging
|
||||
import uvicorn
|
||||
from main import app
|
||||
from core.config import settings
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info(f"Starting MercadoPago (MOCK) vein on port {settings.api_port}")
|
||||
logger.info(f"API docs: http://localhost:{settings.api_port}/docs")
|
||||
logger.info(f"Health check: http://localhost:{settings.api_port}/health")
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.api_port, reload=True)
|
||||
296
soleprint/artery/shunts/mercadopago/templates/index.html
Normal file
296
soleprint/artery/shunts/mercadopago/templates/index.html
Normal file
@@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MercadoPago API (MOCK) - Configuration</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
header {
|
||||
background: #0071f2;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
|
||||
.subtitle { opacity: 0.9; font-size: 0.875rem; }
|
||||
.mock-badge {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
color: #0071f2;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.section {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #f9fafb;
|
||||
}
|
||||
.endpoint-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.endpoint-card {
|
||||
background: #374151;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.endpoint-card:hover { border-color: #0071f2; background: #4b5563; }
|
||||
.endpoint-card.active { border-color: #0071f2; background: #4b5563; }
|
||||
.endpoint-method {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.method-post { background: #10b981; color: white; }
|
||||
.method-get { background: #3b82f6; color: white; }
|
||||
.endpoint-path { font-family: monospace; font-size: 0.875rem; }
|
||||
.endpoint-desc { font-size: 0.75rem; color: #9ca3af; margin-top: 6px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: #f9fafb;
|
||||
}
|
||||
.form-input, .form-textarea, .form-select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #374151;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.form-textarea { min-height: 200px; font-family: monospace; }
|
||||
.form-input:focus, .form-textarea:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #0071f2;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #0071f2;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { background: #005ac1; }
|
||||
.btn-secondary {
|
||||
background: #4b5563;
|
||||
color: #e5e7eb;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.btn-secondary:hover { background: #6b7280; }
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.status-option {
|
||||
background: #374151;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.status-option:hover { border-color: #0071f2; }
|
||||
.status-option.selected { border-color: #0071f2; background: #4b5563; }
|
||||
.status-name { font-weight: 600; color: #f9fafb; margin-bottom: 4px; }
|
||||
.status-desc { font-size: 0.75rem; color: #9ca3af; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>MercadoPago <span class="mock-badge">MOCK</span></h1>
|
||||
<div class="subtitle">Configure mock payment responses and behavior</div>
|
||||
</header>
|
||||
|
||||
<!-- Payment Status Configuration -->
|
||||
<div class="section">
|
||||
<div class="section-header">Default Payment Status</div>
|
||||
<p style="color: #9ca3af; margin-bottom: 16px;">Choose what status new payments should return:</p>
|
||||
<div class="status-grid">
|
||||
<div class="status-option selected" onclick="selectStatus('approved')">
|
||||
<div class="status-name">Approved</div>
|
||||
<div class="status-desc">Payment successful</div>
|
||||
</div>
|
||||
<div class="status-option" onclick="selectStatus('rejected')">
|
||||
<div class="status-name">Rejected</div>
|
||||
<div class="status-desc">Payment failed</div>
|
||||
</div>
|
||||
<div class="status-option" onclick="selectStatus('pending')">
|
||||
<div class="status-name">Pending</div>
|
||||
<div class="status-desc">Awaiting confirmation</div>
|
||||
</div>
|
||||
<div class="status-option" onclick="selectStatus('in_process')">
|
||||
<div class="status-name">In Process</div>
|
||||
<div class="status-desc">Being processed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoint Configuration -->
|
||||
<div class="section">
|
||||
<div class="section-header">Configure Endpoint Responses</div>
|
||||
<div class="endpoint-list">
|
||||
<div class="endpoint-card" onclick="selectEndpoint('POST', '/checkout/preferences', 'preference')">
|
||||
<div>
|
||||
<span class="endpoint-method method-post">POST</span>
|
||||
<span class="endpoint-path">/checkout/preferences</span>
|
||||
</div>
|
||||
<div class="endpoint-desc">Create payment preference (Checkout Pro)</div>
|
||||
</div>
|
||||
<div class="endpoint-card" onclick="selectEndpoint('POST', '/v1/payments', 'payment')">
|
||||
<div>
|
||||
<span class="endpoint-method method-post">POST</span>
|
||||
<span class="endpoint-path">/v1/payments</span>
|
||||
</div>
|
||||
<div class="endpoint-desc">Create payment (Checkout API)</div>
|
||||
</div>
|
||||
<div class="endpoint-card" onclick="selectEndpoint('GET', '/v1/payments/{id}', 'payment_get')">
|
||||
<div>
|
||||
<span class="endpoint-method method-get">GET</span>
|
||||
<span class="endpoint-path">/v1/payments/{id}</span>
|
||||
</div>
|
||||
<div class="endpoint-desc">Get payment details</div>
|
||||
</div>
|
||||
<div class="endpoint-card" onclick="selectEndpoint('POST', '/oauth/token', 'oauth')">
|
||||
<div>
|
||||
<span class="endpoint-method method-post">POST</span>
|
||||
<span class="endpoint-path">/oauth/token</span>
|
||||
</div>
|
||||
<div class="endpoint-desc">OAuth token exchange/refresh</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Editor -->
|
||||
<div class="section" id="responseEditor" style="display: none;">
|
||||
<div class="section-header">Edit Response</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Endpoint</label>
|
||||
<input class="form-input" id="endpointDisplay" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Mock Response (JSON)</label>
|
||||
<textarea class="form-textarea" id="responseJson" placeholder='{"id": "123456", "status": "approved", "_mock": "MercadoPago"}'></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">HTTP Status Code</label>
|
||||
<input type="number" class="form-input" id="statusCode" value="200">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Delay (ms)</label>
|
||||
<input type="number" class="form-input" id="delay" value="0">
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" onclick="saveResponse()">Save Response</button>
|
||||
<button class="btn btn-secondary" onclick="closeEditor()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Test -->
|
||||
<div class="section">
|
||||
<div class="section-header">Quick Test</div>
|
||||
<p style="color: #9ca3af; margin-bottom: 12px;">Test endpoint URL to hit for configured responses:</p>
|
||||
<div class="form-input" style="background: #374151; user-select: all;">
|
||||
http://localhost:8006/v1/payments
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedEndpoint = null;
|
||||
let selectedPaymentStatus = 'approved';
|
||||
|
||||
function selectStatus(status) {
|
||||
selectedPaymentStatus = status;
|
||||
document.querySelectorAll('.status-option').forEach(opt => opt.classList.remove('selected'));
|
||||
event.currentTarget.classList.add('selected');
|
||||
}
|
||||
|
||||
function selectEndpoint(method, path, type) {
|
||||
selectedEndpoint = {method, path, type};
|
||||
document.querySelectorAll('.endpoint-card').forEach(c => c.classList.remove('active'));
|
||||
event.currentTarget.classList.add('active');
|
||||
document.getElementById('responseEditor').style.display = 'block';
|
||||
document.getElementById('endpointDisplay').value = `${method} ${path}`;
|
||||
document.getElementById('responseJson').value = getDefaultResponse(type);
|
||||
}
|
||||
|
||||
function getDefaultResponse(type) {
|
||||
const defaults = {
|
||||
preference: JSON.stringify({
|
||||
"id": "123456-pref-id",
|
||||
"init_point": "https://www.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456",
|
||||
"sandbox_init_point": "https://sandbox.mercadopago.com.ar/checkout/v1/redirect?pref_id=123456",
|
||||
"_mock": "MercadoPago"
|
||||
}, null, 2),
|
||||
payment: JSON.stringify({
|
||||
"id": 123456,
|
||||
"status": selectedPaymentStatus,
|
||||
"status_detail": selectedPaymentStatus === 'approved' ? 'accredited' : 'cc_rejected_other_reason',
|
||||
"transaction_amount": 1500,
|
||||
"currency_id": "ARS",
|
||||
"_mock": "MercadoPago"
|
||||
}, null, 2),
|
||||
payment_get: JSON.stringify({
|
||||
"id": 123456,
|
||||
"status": "approved",
|
||||
"status_detail": "accredited",
|
||||
"transaction_amount": 1500,
|
||||
"_mock": "MercadoPago"
|
||||
}, null, 2),
|
||||
oauth: JSON.stringify({
|
||||
"access_token": "APP_USR-123456-mock-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 15552000,
|
||||
"refresh_token": "TG-123456-mock-refresh",
|
||||
"_mock": "MercadoPago"
|
||||
}, null, 2)
|
||||
};
|
||||
return defaults[type] || '{}';
|
||||
}
|
||||
|
||||
function saveResponse() {
|
||||
alert('Mock response saved (feature pending implementation)');
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
document.getElementById('responseEditor').style.display = 'none';
|
||||
selectedEndpoint = null;
|
||||
document.querySelectorAll('.endpoint-card').forEach(c => c.classList.remove('active'));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
376
soleprint/artery/veins/PATTERNS.md
Normal file
376
soleprint/artery/veins/PATTERNS.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# Vein Patterns
|
||||
|
||||
This document describes the patterns that emerged from building Jira, Slack, and Google veins side-by-side.
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Core = Isolated API client** - Can run without FastAPI, framework-agnostic
|
||||
**Vein = Corset/wrapper** - Makes the core speak to pawprint ecosystem
|
||||
|
||||
The vein wrapper is not a literal folder - it's following structural conventions and patterns.
|
||||
|
||||
## Directory Structure (Standard)
|
||||
|
||||
```
|
||||
vein/{service}/
|
||||
├── core/ # ISOLATED - can run standalone
|
||||
│ ├── __init__.py
|
||||
│ ├── config.py # Pydantic settings from .env
|
||||
│ ├── auth.py # Auth logic (optional, for complex auth)
|
||||
│ ├── client.py # Main API client
|
||||
│ └── {domain}.py # Additional clients (sheets, drive, etc.)
|
||||
│
|
||||
├── api/ # WRAPPER - FastAPI integration
|
||||
│ ├── __init__.py
|
||||
│ └── routes.py # APIRouter with endpoints
|
||||
│
|
||||
├── models/ # Data models
|
||||
│ ├── __init__.py
|
||||
│ ├── {domain}.py # Pydantic models with from_{service}()
|
||||
│ └── formatter.py # Text formatters for LLM output
|
||||
│
|
||||
├── storage/ # Persistent data (optional, for OAuth tokens)
|
||||
│ └── .gitignore
|
||||
│
|
||||
├── main.py # FastAPI app setup
|
||||
├── run.py # Standalone runner
|
||||
├── requirements.txt # Dependencies
|
||||
├── .env.example # Configuration template
|
||||
└── README.md # Service-specific docs
|
||||
```
|
||||
|
||||
## Base Classes
|
||||
|
||||
### `BaseVein` (vein/base.py)
|
||||
|
||||
Minimal interface for all veins:
|
||||
- `name: str` - Service name
|
||||
- `get_client(creds) -> Client` - Create API client
|
||||
- `health_check(creds) -> dict` - Test connection
|
||||
|
||||
Used for simple token-based auth (Jira, Slack, WhatsApp).
|
||||
|
||||
### `BaseOAuthVein` (vein/oauth.py)
|
||||
|
||||
Extends BaseVein for OAuth2 services:
|
||||
- `get_auth_url(state) -> str` - Generate OAuth URL
|
||||
- `exchange_code(code) -> dict` - Code for tokens
|
||||
- `refresh_token(refresh_token) -> dict` - Refresh expired tokens
|
||||
- `storage: TokenStorage` - Token persistence
|
||||
|
||||
Used for OAuth2 services (Google, GitHub, GitLab).
|
||||
|
||||
### `TokenStorage` (vein/oauth.py)
|
||||
|
||||
File-based token storage (can be overridden for Redis/DB):
|
||||
- `save_tokens(user_id, tokens)` - Persist tokens
|
||||
- `load_tokens(user_id) -> dict` - Retrieve tokens
|
||||
- `is_expired(tokens) -> bool` - Check expiration
|
||||
- `delete_tokens(user_id)` - Logout
|
||||
|
||||
## Core Module Patterns
|
||||
|
||||
### config.py
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||
|
||||
class {Service}Config(BaseSettings):
|
||||
# Service-specific settings
|
||||
api_port: int = 800X # Unique port per vein
|
||||
|
||||
model_config = {
|
||||
"env_file": ENV_FILE,
|
||||
"env_file_encoding": "utf-8",
|
||||
}
|
||||
|
||||
settings = {Service}Config()
|
||||
```
|
||||
|
||||
**Pattern**: Pydantic BaseSettings with .env file at vein root.
|
||||
|
||||
### client.py (Simple Auth - Jira, Slack)
|
||||
|
||||
```python
|
||||
from {sdk} import Client
|
||||
|
||||
def get_client(credentials) -> Client:
|
||||
"""Create authenticated client."""
|
||||
return Client(credentials)
|
||||
```
|
||||
|
||||
**Pattern**: Simple factory function returning SDK client.
|
||||
|
||||
### oauth.py (OAuth2 - Google)
|
||||
|
||||
```python
|
||||
class {Service}OAuth:
|
||||
"""OAuth2 client."""
|
||||
|
||||
def get_authorization_url(self, state=None) -> str:
|
||||
"""Generate auth URL for user redirect."""
|
||||
|
||||
def exchange_code_for_tokens(self, code: str) -> dict:
|
||||
"""Exchange code for tokens."""
|
||||
|
||||
def refresh_access_token(self, refresh_token: str) -> dict:
|
||||
"""Refresh expired token."""
|
||||
|
||||
def get_credentials(self, access_token, refresh_token=None):
|
||||
"""Create SDK credentials from tokens."""
|
||||
```
|
||||
|
||||
**Pattern**: OAuth client handles full flow, separate from API client.
|
||||
|
||||
## API Module Patterns
|
||||
|
||||
### routes.py
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
async def health(creds = Depends(get_credentials)):
|
||||
"""Test connection."""
|
||||
return {"status": "ok", "user": "..."}
|
||||
|
||||
@router.get("/resource")
|
||||
async def get_resource(text: bool = False):
|
||||
"""Get resource data."""
|
||||
# ... fetch data
|
||||
return _maybe_text(data, text, formatter)
|
||||
```
|
||||
|
||||
**Standard endpoints**:
|
||||
- `/health` - Connection test (required)
|
||||
- Service-specific resources
|
||||
- `?text=true` query param for text output
|
||||
|
||||
### Helper Functions
|
||||
|
||||
```python
|
||||
def _maybe_text(data, text: bool, formatter):
|
||||
"""Return text or JSON based on query param."""
|
||||
if not text:
|
||||
return data
|
||||
return PlainTextResponse(formatter(data))
|
||||
```
|
||||
|
||||
**Pattern**: Consistent text/JSON toggle across all veins.
|
||||
|
||||
## Model Patterns
|
||||
|
||||
### Domain Models
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Resource(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
# ... fields
|
||||
|
||||
@classmethod
|
||||
def from_{service}(cls, raw: dict) -> "Resource":
|
||||
"""Parse from service API response."""
|
||||
return cls(
|
||||
id=raw["id"],
|
||||
name=raw["name"],
|
||||
# ... mapping
|
||||
)
|
||||
```
|
||||
|
||||
**Pattern**: Pydantic models with `from_{service}()` factory methods.
|
||||
|
||||
### Formatters
|
||||
|
||||
```python
|
||||
def format_{resource}(resource: Resource) -> str:
|
||||
"""Format resource as text (LLM-friendly)."""
|
||||
return f"{resource.name} (ID: {resource.id})"
|
||||
```
|
||||
|
||||
**Pattern**: Simple functions returning plain text, no fancy tables.
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### Simple Token Auth (Jira, Slack, WhatsApp)
|
||||
|
||||
**Headers or .env fallback**:
|
||||
|
||||
```python
|
||||
async def get_{service}_credentials(
|
||||
x_{service}_token: str | None = Header(None),
|
||||
) -> Credentials:
|
||||
# Use header if provided
|
||||
if x_{service}_token and x_{service}_token.strip():
|
||||
return Credentials(token=x_{service}_token.strip())
|
||||
|
||||
# Fall back to .env
|
||||
if settings.{service}_token:
|
||||
return Credentials(token=settings.{service}_token)
|
||||
|
||||
raise HTTPException(401, "Missing credentials")
|
||||
```
|
||||
|
||||
**Pattern**: Per-request headers for web UI, .env for standalone/API use.
|
||||
|
||||
### OAuth2 (Google, GitHub, GitLab)
|
||||
|
||||
**Three-step flow**:
|
||||
|
||||
1. **Start**: `GET /oauth/start` → Redirect to service
|
||||
2. **Callback**: `GET /oauth/callback?code=...` → Exchange code, save tokens
|
||||
3. **Use**: Load tokens from storage, auto-refresh if expired
|
||||
|
||||
**Pattern**: Stateful (requires token storage), user must complete browser flow.
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
try:
|
||||
client = get_client(creds)
|
||||
data = client.fetch_something()
|
||||
return data
|
||||
except {Service}ClientError as e:
|
||||
raise HTTPException(500, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Unexpected error: {e}")
|
||||
```
|
||||
|
||||
**Pattern**: Catch service-specific errors first, then generic fallback.
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### .env.example
|
||||
|
||||
Include all required settings with placeholder values:
|
||||
|
||||
```dotenv
|
||||
# Service credentials
|
||||
SERVICE_API_KEY=your_key_here
|
||||
SERVICE_URL=https://api.service.com
|
||||
|
||||
# Vein config
|
||||
API_PORT=8001
|
||||
```
|
||||
|
||||
### requirements.txt
|
||||
|
||||
Minimal dependencies:
|
||||
|
||||
```txt
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
{service-specific-sdk}>=X.Y.Z
|
||||
```
|
||||
|
||||
## Main App Pattern
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from api.routes import router
|
||||
from core.config import settings
|
||||
|
||||
app = FastAPI(title="{Service} Vein", version="0.1.0")
|
||||
app.include_router(router)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)
|
||||
```
|
||||
|
||||
**Pattern**: Simple FastAPI app, routes included at root or with prefix.
|
||||
|
||||
## Testing Isolation
|
||||
|
||||
Because `core/` is isolated from FastAPI:
|
||||
|
||||
```python
|
||||
# Can test core directly without HTTP
|
||||
from vein.google.core.sheets import GoogleSheetsClient
|
||||
|
||||
def test_sheets_client():
|
||||
client = GoogleSheetsClient(mock_credentials)
|
||||
data = client.get_sheet_values("sheet_id", "A1:D10")
|
||||
assert len(data) > 0
|
||||
```
|
||||
|
||||
**Pattern**: Core modules are testable without spinning up FastAPI.
|
||||
|
||||
## Port Allocation
|
||||
|
||||
- **8001**: Jira
|
||||
- **8002**: Slack
|
||||
- **8003**: Google
|
||||
- **8004**: WhatsApp (planned)
|
||||
- **8005+**: Future veins
|
||||
|
||||
**Pattern**: Sequential ports starting from 8001.
|
||||
|
||||
## Vein Types
|
||||
|
||||
### Type 1: Simple Token Auth
|
||||
**Examples**: Jira, Slack, WhatsApp
|
||||
**Auth**: Token in headers or .env
|
||||
**Stateless**: No storage needed
|
||||
**Inherits**: BaseVein
|
||||
|
||||
### Type 2: OAuth2
|
||||
**Examples**: Google, GitHub, GitLab
|
||||
**Auth**: OAuth2 flow with callback
|
||||
**Stateful**: Requires token storage
|
||||
**Inherits**: BaseOAuthVein
|
||||
|
||||
### Type 3: Hybrid (Future)
|
||||
**Examples**: Services with webhooks + API
|
||||
**May need**: Database, Redis, webhook endpoints
|
||||
**Consider**: Pulse instead of vein (composed service)
|
||||
|
||||
## When to Use Pulse vs Vein
|
||||
|
||||
**Vein**: Pure connector
|
||||
- Stateless or minimal state (OAuth tokens)
|
||||
- Pull-based (you call the API)
|
||||
- No database required
|
||||
|
||||
**Pulse**: Composed service
|
||||
- Stateful (database, message queue)
|
||||
- Push-based (webhooks, real-time)
|
||||
- Combines vein + storage + processing
|
||||
|
||||
**Example**: WhatsApp webhook receiver = pulse, WhatsApp API client = vein.
|
||||
|
||||
## Standardization Checklist
|
||||
|
||||
When creating a new vein:
|
||||
|
||||
- [ ] Follow directory structure (core/, api/, models/)
|
||||
- [ ] Create .env.example with all settings
|
||||
- [ ] Implement /health endpoint
|
||||
- [ ] Support ?text=true for all data endpoints
|
||||
- [ ] Use from_{service}() factory methods in models
|
||||
- [ ] Create text formatters in models/formatter.py
|
||||
- [ ] Include README.md with setup instructions
|
||||
- [ ] Choose correct base class (BaseVein or BaseOAuthVein)
|
||||
- [ ] Allocate unique port (8001+)
|
||||
- [ ] Keep core/ isolated from FastAPI
|
||||
|
||||
## Evolution
|
||||
|
||||
This document captures patterns as of having 3 veins (Jira, Slack, Google).
|
||||
|
||||
**Do not** enforce these patterns rigidly - they should evolve as we build more veins.
|
||||
**Do** use this as a starting point for consistency.
|
||||
**Do** update this document when patterns change.
|
||||
|
||||
The abstract classes exist to enforce interfaces, not implementations.
|
||||
The patterns exist to reduce cognitive load, not to restrict flexibility.
|
||||
14
soleprint/artery/veins/__init__.py
Normal file
14
soleprint/artery/veins/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Veins - Stateless API connectors.
|
||||
|
||||
Each vein follows the pattern:
|
||||
core/ - Isolated API client (no FastAPI dependency)
|
||||
api/ - FastAPI routes wrapping the core
|
||||
models/ - Pydantic models and formatters
|
||||
ui/ - Simple test form (optional)
|
||||
|
||||
Available veins:
|
||||
- jira - Jira issue tracking
|
||||
- slack - Slack messaging
|
||||
- google - Google APIs (OAuth)
|
||||
"""
|
||||
67
soleprint/artery/veins/base.py
Normal file
67
soleprint/artery/veins/base.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Base class for vein connectors.
|
||||
|
||||
Defines the minimal interface all veins should implement.
|
||||
The core/ module contains isolated API clients.
|
||||
The api/ module wraps them in FastAPI routes.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
TCredentials = TypeVar("TCredentials")
|
||||
TClient = TypeVar("TClient")
|
||||
|
||||
|
||||
class BaseVein(ABC, Generic[TCredentials, TClient]):
|
||||
"""
|
||||
Abstract base for vein connectors.
|
||||
|
||||
Veins are wrappers around API clients that provide:
|
||||
- Standard auth patterns (headers or OAuth)
|
||||
- Health check endpoint
|
||||
- Consistent routing structure
|
||||
|
||||
The core implementation should be isolated and runnable without FastAPI.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Vein name (e.g., 'jira', 'slack', 'google')"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_client(self, creds: TCredentials) -> TClient:
|
||||
"""
|
||||
Create API client with given credentials.
|
||||
|
||||
This should delegate to core/ module which contains
|
||||
the isolated client implementation.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def health_check(self, creds: TCredentials) -> dict:
|
||||
"""
|
||||
Test connection and return status.
|
||||
|
||||
Should return:
|
||||
{
|
||||
"status": "ok",
|
||||
"user": "...", # or other identifying info
|
||||
...
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""
|
||||
Create base router with standard endpoints.
|
||||
|
||||
Subclasses should extend this with additional routes.
|
||||
"""
|
||||
router = APIRouter()
|
||||
return router
|
||||
8
soleprint/artery/veins/google/.env.example
Normal file
8
soleprint/artery/veins/google/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# Google OAuth2 Configuration
|
||||
# Get credentials from: https://console.cloud.google.com/apis/credentials
|
||||
|
||||
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your_client_secret
|
||||
GOOGLE_REDIRECT_URI=https://artery.mcrn.ar/google/oauth/callback
|
||||
GOOGLE_SCOPES=https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly
|
||||
API_PORT=8003
|
||||
90
soleprint/artery/veins/google/README.md
Normal file
90
soleprint/artery/veins/google/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Google Vein
|
||||
|
||||
OAuth2-based connector for Google APIs (Sheets, Drive).
|
||||
|
||||
## Status: DEVELOPMENT
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create Google Cloud project and OAuth2 credentials:
|
||||
- Go to https://console.cloud.google.com/apis/credentials
|
||||
- Create OAuth 2.0 Client ID (Web application)
|
||||
- Add authorized redirect URI: `https://artery.mcrn.ar/google/oauth/callback`
|
||||
- Enable Google Sheets API and Google Drive API
|
||||
|
||||
2. Copy `.env.example` to `.env` and fill in credentials:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your credentials
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Run standalone:
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
## OAuth Flow
|
||||
|
||||
Unlike Jira/Slack (simple token auth), Google uses OAuth2:
|
||||
|
||||
1. **Start**: Visit `/google/oauth/start` - redirects to Google login
|
||||
2. **Callback**: Google redirects to `/google/oauth/callback` with code
|
||||
3. **Exchange**: Code exchanged for access_token + refresh_token
|
||||
4. **Storage**: Tokens saved to `storage/tokens_{user_id}.json`
|
||||
5. **Use**: Subsequent requests use stored tokens
|
||||
6. **Refresh**: Expired tokens auto-refreshed using refresh_token
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Authentication
|
||||
- `GET /google/health` - Check auth status
|
||||
- `GET /google/oauth/start` - Start OAuth flow
|
||||
- `GET /google/oauth/callback` - OAuth callback (called by Google)
|
||||
- `GET /google/oauth/logout` - Clear stored tokens
|
||||
|
||||
### Google Sheets
|
||||
- `GET /google/spreadsheets/{id}` - Get spreadsheet metadata
|
||||
- `GET /google/spreadsheets/{id}/sheets` - List all sheets
|
||||
- `GET /google/spreadsheets/{id}/values?range=Sheet1!A1:D10` - Get cell values
|
||||
|
||||
All endpoints support `?text=true` for LLM-friendly text output.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
core/ # Isolated - can run without FastAPI
|
||||
├── oauth.py # Google OAuth2 client
|
||||
├── sheets.py # Google Sheets API client
|
||||
└── config.py # Settings
|
||||
|
||||
api/ # FastAPI wrapper
|
||||
└── routes.py # Endpoints
|
||||
|
||||
models/ # Data models
|
||||
├── spreadsheet.py # Pydantic models
|
||||
└── formatter.py # Text output
|
||||
|
||||
storage/ # Token persistence (gitignored)
|
||||
```
|
||||
|
||||
## Token Storage
|
||||
|
||||
For development/demo: File-based storage in `storage/`
|
||||
|
||||
For production: Override `TokenStorage` in `vein/oauth.py`:
|
||||
- Redis for scalability
|
||||
- Database for audit trail
|
||||
- Per-user tokens when integrated with auth system
|
||||
|
||||
## Future APIs
|
||||
|
||||
- Google Drive (file listing, download)
|
||||
- Gmail (read messages)
|
||||
- Calendar (events)
|
||||
|
||||
Each API gets its own client in `core/` (e.g., `core/drive.py`).
|
||||
0
soleprint/artery/veins/google/__init__.py
Normal file
0
soleprint/artery/veins/google/__init__.py
Normal file
0
soleprint/artery/veins/google/api/__init__.py
Normal file
0
soleprint/artery/veins/google/api/__init__.py
Normal file
194
soleprint/artery/veins/google/api/routes.py
Normal file
194
soleprint/artery/veins/google/api/routes.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
API routes for Google vein.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse, RedirectResponse
|
||||
from typing import Optional
|
||||
|
||||
from core.oauth import GoogleOAuth
|
||||
from core.sheets import GoogleSheetsClient, GoogleSheetsError
|
||||
from models.spreadsheet import SpreadsheetMetadata, SheetValues
|
||||
from models.formatter import format_spreadsheet_metadata, format_sheet_values
|
||||
|
||||
# Import from parent vein module
|
||||
import sys
|
||||
from pathlib import Path
|
||||
vein_path = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(vein_path))
|
||||
from oauth import TokenStorage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# OAuth client and token storage
|
||||
oauth_client = GoogleOAuth()
|
||||
token_storage = TokenStorage(vein_name="google")
|
||||
|
||||
# For demo/development, use a default user_id
|
||||
# In production, this would come from session/auth
|
||||
DEFAULT_USER_ID = "demo_user"
|
||||
|
||||
|
||||
def _get_sheets_client(user_id: str = DEFAULT_USER_ID) -> GoogleSheetsClient:
|
||||
"""Get authenticated Sheets client for user."""
|
||||
tokens = token_storage.load_tokens(user_id)
|
||||
if not tokens:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated. Visit /google/oauth/start to login.",
|
||||
)
|
||||
|
||||
# Check if expired and refresh if needed
|
||||
if token_storage.is_expired(tokens):
|
||||
if "refresh_token" not in tokens:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Token expired and no refresh token. Re-authenticate at /google/oauth/start",
|
||||
)
|
||||
try:
|
||||
new_tokens = oauth_client.refresh_access_token(tokens["refresh_token"])
|
||||
token_storage.save_tokens(user_id, new_tokens)
|
||||
tokens = new_tokens
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Failed to refresh token: {e}. Re-authenticate at /google/oauth/start",
|
||||
)
|
||||
|
||||
credentials = oauth_client.get_credentials(
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens.get("refresh_token"),
|
||||
)
|
||||
return GoogleSheetsClient(credentials)
|
||||
|
||||
|
||||
def _maybe_text(data, text: bool, formatter):
|
||||
"""Return text or JSON based on query param."""
|
||||
if not text:
|
||||
return data
|
||||
return PlainTextResponse(formatter(data))
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
"""Check if user is authenticated."""
|
||||
try:
|
||||
tokens = token_storage.load_tokens(DEFAULT_USER_ID)
|
||||
if not tokens:
|
||||
return {
|
||||
"status": "not_authenticated",
|
||||
"message": "Visit /google/oauth/start to login",
|
||||
}
|
||||
|
||||
expired = token_storage.is_expired(tokens)
|
||||
return {
|
||||
"status": "ok" if not expired else "token_expired",
|
||||
"has_refresh_token": "refresh_token" in tokens,
|
||||
"user": DEFAULT_USER_ID,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/oauth/start")
|
||||
async def start_oauth(state: Optional[str] = None):
|
||||
"""Start OAuth flow - redirect to Google authorization."""
|
||||
auth_url = oauth_client.get_authorization_url(state=state)
|
||||
return RedirectResponse(auth_url)
|
||||
|
||||
|
||||
@router.get("/oauth/callback")
|
||||
async def oauth_callback(
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
):
|
||||
"""Handle OAuth callback from Google."""
|
||||
if error:
|
||||
raise HTTPException(400, f"OAuth error: {error}")
|
||||
|
||||
if not code:
|
||||
raise HTTPException(400, "Missing authorization code")
|
||||
|
||||
try:
|
||||
tokens = oauth_client.exchange_code_for_tokens(code)
|
||||
token_storage.save_tokens(DEFAULT_USER_ID, tokens)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Successfully authenticated with Google",
|
||||
"user": DEFAULT_USER_ID,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to exchange code: {e}")
|
||||
|
||||
|
||||
@router.get("/oauth/logout")
|
||||
async def logout():
|
||||
"""Clear stored tokens."""
|
||||
token_storage.delete_tokens(DEFAULT_USER_ID)
|
||||
return {"status": "ok", "message": "Logged out"}
|
||||
|
||||
|
||||
@router.get("/spreadsheets/{spreadsheet_id}")
|
||||
async def get_spreadsheet(
|
||||
spreadsheet_id: str,
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get spreadsheet metadata (title, sheets list, etc.)."""
|
||||
try:
|
||||
client = _get_sheets_client()
|
||||
metadata = client.get_spreadsheet_metadata(spreadsheet_id)
|
||||
result = SpreadsheetMetadata.from_google(metadata)
|
||||
return _maybe_text(result, text, format_spreadsheet_metadata)
|
||||
except GoogleSheetsError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/spreadsheets/{spreadsheet_id}/values")
|
||||
async def get_sheet_values(
|
||||
spreadsheet_id: str,
|
||||
range: str = Query(..., description="A1 notation range (e.g., 'Sheet1!A1:D10')"),
|
||||
text: bool = False,
|
||||
max_rows: int = Query(100, ge=1, le=10000),
|
||||
):
|
||||
"""Get values from a sheet range."""
|
||||
try:
|
||||
client = _get_sheets_client()
|
||||
values = client.get_sheet_values(spreadsheet_id, range)
|
||||
result = SheetValues.from_google(spreadsheet_id, range, values)
|
||||
|
||||
if text:
|
||||
return PlainTextResponse(format_sheet_values(result, max_rows=max_rows))
|
||||
return result
|
||||
except GoogleSheetsError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/spreadsheets/{spreadsheet_id}/sheets")
|
||||
async def list_sheets(
|
||||
spreadsheet_id: str,
|
||||
text: bool = False,
|
||||
):
|
||||
"""List all sheets in a spreadsheet."""
|
||||
try:
|
||||
client = _get_sheets_client()
|
||||
sheets = client.get_all_sheets(spreadsheet_id)
|
||||
|
||||
if text:
|
||||
lines = [f"Sheets in {spreadsheet_id}:", ""]
|
||||
for sheet in sheets:
|
||||
lines.append(
|
||||
f" [{sheet['index']}] {sheet['title']} "
|
||||
f"({sheet['row_count']} rows x {sheet['column_count']} cols)"
|
||||
)
|
||||
return PlainTextResponse("\n".join(lines))
|
||||
|
||||
return {"spreadsheet_id": spreadsheet_id, "sheets": sheets}
|
||||
except GoogleSheetsError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
0
soleprint/artery/veins/google/core/__init__.py
Normal file
0
soleprint/artery/veins/google/core/__init__.py
Normal file
24
soleprint/artery/veins/google/core/config.py
Normal file
24
soleprint/artery/veins/google/core/config.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Google OAuth2 configuration loaded from .env file.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||
|
||||
|
||||
class GoogleConfig(BaseSettings):
|
||||
google_client_id: str
|
||||
google_client_secret: str
|
||||
google_redirect_uri: str # e.g., https://artery.mcrn.ar/google/oauth/callback
|
||||
google_scopes: str = "https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly"
|
||||
api_port: int = 8003
|
||||
|
||||
model_config = {
|
||||
"env_file": ENV_FILE,
|
||||
"env_file_encoding": "utf-8",
|
||||
}
|
||||
|
||||
|
||||
settings = GoogleConfig()
|
||||
147
soleprint/artery/veins/google/core/oauth.py
Normal file
147
soleprint/artery/veins/google/core/oauth.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Google OAuth2 flow implementation.
|
||||
|
||||
Isolated OAuth2 client that can run without FastAPI.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from .config import settings
|
||||
|
||||
|
||||
class GoogleOAuth:
|
||||
"""
|
||||
Google OAuth2 client.
|
||||
|
||||
Handles authorization flow, token exchange, and token refresh.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
redirect_uri: Optional[str] = None,
|
||||
scopes: Optional[list[str]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize OAuth client.
|
||||
|
||||
Falls back to settings if parameters not provided.
|
||||
"""
|
||||
self.client_id = client_id or settings.google_client_id
|
||||
self.client_secret = client_secret or settings.google_client_secret
|
||||
self.redirect_uri = redirect_uri or settings.google_redirect_uri
|
||||
self.scopes = scopes or settings.google_scopes.split()
|
||||
|
||||
def _create_flow(self) -> Flow:
|
||||
"""Create OAuth flow object."""
|
||||
client_config = {
|
||||
"web": {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
}
|
||||
flow = Flow.from_client_config(
|
||||
client_config,
|
||||
scopes=self.scopes,
|
||||
redirect_uri=self.redirect_uri,
|
||||
)
|
||||
return flow
|
||||
|
||||
def get_authorization_url(self, state: Optional[str] = None) -> str:
|
||||
"""
|
||||
Generate OAuth2 authorization URL.
|
||||
|
||||
Args:
|
||||
state: Optional state parameter for CSRF protection
|
||||
|
||||
Returns:
|
||||
URL to redirect user for Google authorization
|
||||
"""
|
||||
flow = self._create_flow()
|
||||
auth_url, _ = flow.authorization_url(
|
||||
access_type="offline", # Request refresh token
|
||||
include_granted_scopes="true",
|
||||
state=state,
|
||||
)
|
||||
return auth_url
|
||||
|
||||
def exchange_code_for_tokens(self, code: str) -> dict:
|
||||
"""
|
||||
Exchange authorization code for tokens.
|
||||
|
||||
Args:
|
||||
code: Authorization code from callback
|
||||
|
||||
Returns:
|
||||
Token dict containing:
|
||||
- access_token
|
||||
- refresh_token
|
||||
- expires_in
|
||||
- scope
|
||||
- token_type
|
||||
"""
|
||||
flow = self._create_flow()
|
||||
flow.fetch_token(code=code)
|
||||
|
||||
credentials = flow.credentials
|
||||
return {
|
||||
"access_token": credentials.token,
|
||||
"refresh_token": credentials.refresh_token,
|
||||
"expires_in": 3600, # Google tokens typically 1 hour
|
||||
"scope": " ".join(credentials.scopes or []),
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
def refresh_access_token(self, refresh_token: str) -> dict:
|
||||
"""
|
||||
Refresh an expired access token.
|
||||
|
||||
Args:
|
||||
refresh_token: The refresh token
|
||||
|
||||
Returns:
|
||||
New token dict with fresh access_token
|
||||
"""
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=refresh_token,
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
)
|
||||
|
||||
request = Request()
|
||||
credentials.refresh(request)
|
||||
|
||||
return {
|
||||
"access_token": credentials.token,
|
||||
"refresh_token": refresh_token, # Keep original refresh token
|
||||
"expires_in": 3600,
|
||||
"scope": " ".join(credentials.scopes or []),
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
def get_credentials(self, access_token: str, refresh_token: Optional[str] = None) -> Credentials:
|
||||
"""
|
||||
Create Google Credentials object from tokens.
|
||||
|
||||
Args:
|
||||
access_token: OAuth2 access token
|
||||
refresh_token: Optional refresh token
|
||||
|
||||
Returns:
|
||||
Google Credentials object for API calls
|
||||
"""
|
||||
return Credentials(
|
||||
token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
scopes=self.scopes,
|
||||
)
|
||||
130
soleprint/artery/veins/google/core/sheets.py
Normal file
130
soleprint/artery/veins/google/core/sheets.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Google Sheets API client.
|
||||
|
||||
Isolated client that can run without FastAPI.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
|
||||
class GoogleSheetsError(Exception):
|
||||
"""Sheets API error."""
|
||||
pass
|
||||
|
||||
|
||||
class GoogleSheetsClient:
|
||||
"""
|
||||
Google Sheets API client.
|
||||
|
||||
Provides methods to read spreadsheet data.
|
||||
"""
|
||||
|
||||
def __init__(self, credentials: Credentials):
|
||||
"""
|
||||
Initialize Sheets client.
|
||||
|
||||
Args:
|
||||
credentials: Google OAuth2 credentials
|
||||
"""
|
||||
self.credentials = credentials
|
||||
self.service = build("sheets", "v4", credentials=credentials)
|
||||
|
||||
def get_spreadsheet_metadata(self, spreadsheet_id: str) -> dict:
|
||||
"""
|
||||
Get spreadsheet metadata (title, sheets, etc.).
|
||||
|
||||
Args:
|
||||
spreadsheet_id: The spreadsheet ID
|
||||
|
||||
Returns:
|
||||
Spreadsheet metadata
|
||||
"""
|
||||
try:
|
||||
result = self.service.spreadsheets().get(
|
||||
spreadsheetId=spreadsheet_id
|
||||
).execute()
|
||||
return result
|
||||
except HttpError as e:
|
||||
raise GoogleSheetsError(f"Failed to get spreadsheet: {e}")
|
||||
|
||||
def get_sheet_values(
|
||||
self,
|
||||
spreadsheet_id: str,
|
||||
range_name: str,
|
||||
value_render_option: str = "FORMATTED_VALUE",
|
||||
) -> list[list]:
|
||||
"""
|
||||
Get values from a sheet range.
|
||||
|
||||
Args:
|
||||
spreadsheet_id: The spreadsheet ID
|
||||
range_name: A1 notation range (e.g., 'Sheet1!A1:D10')
|
||||
value_render_option: How values should be rendered
|
||||
- FORMATTED_VALUE: Values formatted as strings (default)
|
||||
- UNFORMATTED_VALUE: Values in calculated format
|
||||
- FORMULA: Formulas
|
||||
|
||||
Returns:
|
||||
List of rows, each row is a list of cell values
|
||||
"""
|
||||
try:
|
||||
result = self.service.spreadsheets().values().get(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
range=range_name,
|
||||
valueRenderOption=value_render_option,
|
||||
).execute()
|
||||
return result.get("values", [])
|
||||
except HttpError as e:
|
||||
raise GoogleSheetsError(f"Failed to get values: {e}")
|
||||
|
||||
def get_all_sheets(self, spreadsheet_id: str) -> list[dict]:
|
||||
"""
|
||||
Get list of all sheets in a spreadsheet.
|
||||
|
||||
Args:
|
||||
spreadsheet_id: The spreadsheet ID
|
||||
|
||||
Returns:
|
||||
List of sheet metadata (title, id, index, etc.)
|
||||
"""
|
||||
metadata = self.get_spreadsheet_metadata(spreadsheet_id)
|
||||
return [
|
||||
{
|
||||
"title": sheet["properties"]["title"],
|
||||
"sheet_id": sheet["properties"]["sheetId"],
|
||||
"index": sheet["properties"]["index"],
|
||||
"row_count": sheet["properties"]["gridProperties"].get("rowCount", 0),
|
||||
"column_count": sheet["properties"]["gridProperties"].get("columnCount", 0),
|
||||
}
|
||||
for sheet in metadata.get("sheets", [])
|
||||
]
|
||||
|
||||
def batch_get_values(
|
||||
self,
|
||||
spreadsheet_id: str,
|
||||
ranges: list[str],
|
||||
value_render_option: str = "FORMATTED_VALUE",
|
||||
) -> dict:
|
||||
"""
|
||||
Get multiple ranges in a single request.
|
||||
|
||||
Args:
|
||||
spreadsheet_id: The spreadsheet ID
|
||||
ranges: List of A1 notation ranges
|
||||
value_render_option: How values should be rendered
|
||||
|
||||
Returns:
|
||||
Dict with spreadsheetId and valueRanges list
|
||||
"""
|
||||
try:
|
||||
result = self.service.spreadsheets().values().batchGet(
|
||||
spreadsheetId=spreadsheet_id,
|
||||
ranges=ranges,
|
||||
valueRenderOption=value_render_option,
|
||||
).execute()
|
||||
return result
|
||||
except HttpError as e:
|
||||
raise GoogleSheetsError(f"Failed to batch get values: {e}")
|
||||
15
soleprint/artery/veins/google/main.py
Normal file
15
soleprint/artery/veins/google/main.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Google Vein - FastAPI app.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from api.routes import router
|
||||
from core.config import settings
|
||||
|
||||
app = FastAPI(title="Google Vein", version="0.1.0")
|
||||
app.include_router(router, prefix="/google")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)
|
||||
0
soleprint/artery/veins/google/models/__init__.py
Normal file
0
soleprint/artery/veins/google/models/__init__.py
Normal file
71
soleprint/artery/veins/google/models/formatter.py
Normal file
71
soleprint/artery/veins/google/models/formatter.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Text formatters for spreadsheet data (LLM-friendly output).
|
||||
"""
|
||||
|
||||
from .spreadsheet import SpreadsheetMetadata, SheetValues
|
||||
|
||||
|
||||
def format_spreadsheet_metadata(metadata: SpreadsheetMetadata) -> str:
|
||||
"""Format spreadsheet metadata as text."""
|
||||
lines = [
|
||||
f"Spreadsheet: {metadata.title}",
|
||||
f"ID: {metadata.spreadsheet_id}",
|
||||
f"Locale: {metadata.locale or 'N/A'}",
|
||||
f"Timezone: {metadata.timezone or 'N/A'}",
|
||||
"",
|
||||
"Sheets:",
|
||||
]
|
||||
|
||||
for sheet in metadata.sheets:
|
||||
lines.append(
|
||||
f" [{sheet.index}] {sheet.title} "
|
||||
f"({sheet.row_count} rows x {sheet.column_count} cols)"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_sheet_values(values: SheetValues, max_rows: int = 100) -> str:
|
||||
"""
|
||||
Format sheet values as text table.
|
||||
|
||||
Args:
|
||||
values: Sheet values
|
||||
max_rows: Maximum rows to display
|
||||
"""
|
||||
lines = [
|
||||
f"Spreadsheet ID: {values.spreadsheet_id}",
|
||||
f"Range: {values.range}",
|
||||
f"Size: {values.row_count} rows x {values.column_count} cols",
|
||||
"",
|
||||
]
|
||||
|
||||
if not values.values:
|
||||
lines.append("(empty)")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Display up to max_rows
|
||||
display_rows = values.values[:max_rows]
|
||||
|
||||
# Calculate column widths (for basic alignment)
|
||||
col_widths = [0] * values.column_count
|
||||
for row in display_rows:
|
||||
for i, cell in enumerate(row):
|
||||
col_widths[i] = max(col_widths[i], len(str(cell)))
|
||||
|
||||
# Format rows
|
||||
for row_idx, row in enumerate(display_rows):
|
||||
cells = []
|
||||
for col_idx, cell in enumerate(row):
|
||||
width = col_widths[col_idx] if col_idx < len(col_widths) else 0
|
||||
cells.append(str(cell).ljust(width))
|
||||
# Pad with empty cells if row is shorter
|
||||
while len(cells) < values.column_count:
|
||||
width = col_widths[len(cells)] if len(cells) < len(col_widths) else 0
|
||||
cells.append("".ljust(width))
|
||||
lines.append(" | ".join(cells))
|
||||
|
||||
if values.row_count > max_rows:
|
||||
lines.append(f"\n... ({values.row_count - max_rows} more rows)")
|
||||
|
||||
return "\n".join(lines)
|
||||
69
soleprint/artery/veins/google/models/spreadsheet.py
Normal file
69
soleprint/artery/veins/google/models/spreadsheet.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Spreadsheet models with self-parsing from Google Sheets API responses.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class SheetInfo(BaseModel):
|
||||
"""Individual sheet within a spreadsheet."""
|
||||
title: str
|
||||
sheet_id: int
|
||||
index: int
|
||||
row_count: int
|
||||
column_count: int
|
||||
|
||||
|
||||
class SpreadsheetMetadata(BaseModel):
|
||||
"""Spreadsheet metadata."""
|
||||
spreadsheet_id: str
|
||||
title: str
|
||||
locale: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
sheets: List[SheetInfo] = []
|
||||
|
||||
@classmethod
|
||||
def from_google(cls, data: dict) -> "SpreadsheetMetadata":
|
||||
"""Parse from Google Sheets API response."""
|
||||
sheets = [
|
||||
SheetInfo(
|
||||
title=sheet["properties"]["title"],
|
||||
sheet_id=sheet["properties"]["sheetId"],
|
||||
index=sheet["properties"]["index"],
|
||||
row_count=sheet["properties"]["gridProperties"].get("rowCount", 0),
|
||||
column_count=sheet["properties"]["gridProperties"].get("columnCount", 0),
|
||||
)
|
||||
for sheet in data.get("sheets", [])
|
||||
]
|
||||
|
||||
return cls(
|
||||
spreadsheet_id=data["spreadsheetId"],
|
||||
title=data["properties"]["title"],
|
||||
locale=data["properties"].get("locale"),
|
||||
timezone=data["properties"].get("timeZone"),
|
||||
sheets=sheets,
|
||||
)
|
||||
|
||||
|
||||
class SheetValues(BaseModel):
|
||||
"""Sheet data values."""
|
||||
spreadsheet_id: str
|
||||
range: str
|
||||
values: List[List[str]] # rows of cells
|
||||
row_count: int
|
||||
column_count: int
|
||||
|
||||
@classmethod
|
||||
def from_google(cls, spreadsheet_id: str, range_name: str, values: List[List]) -> "SheetValues":
|
||||
"""Parse from Google Sheets API values response."""
|
||||
row_count = len(values)
|
||||
column_count = max((len(row) for row in values), default=0)
|
||||
|
||||
return cls(
|
||||
spreadsheet_id=spreadsheet_id,
|
||||
range=range_name,
|
||||
values=values,
|
||||
row_count=row_count,
|
||||
column_count=column_count,
|
||||
)
|
||||
8
soleprint/artery/veins/google/requirements.txt
Normal file
8
soleprint/artery/veins/google/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
google-auth>=2.23.0
|
||||
google-auth-oauthlib>=1.1.0
|
||||
google-auth-httplib2>=0.1.1
|
||||
google-api-python-client>=2.100.0
|
||||
11
soleprint/artery/veins/google/run.py
Normal file
11
soleprint/artery/veins/google/run.py
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone runner for Google vein.
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
from main import app
|
||||
from core.config import settings
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.api_port, reload=True)
|
||||
5
soleprint/artery/veins/google/storage/.gitignore
vendored
Normal file
5
soleprint/artery/veins/google/storage/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Ignore all token files
|
||||
tokens_*.json
|
||||
|
||||
# But keep this directory in git
|
||||
!.gitignore
|
||||
4
soleprint/artery/veins/jira/.env.example
Normal file
4
soleprint/artery/veins/jira/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
JIRA_URL=https://yourcompany.atlassian.net
|
||||
JIRA_EMAIL=your.email@company.com
|
||||
JIRA_API_TOKEN=your_api_token
|
||||
API_PORT=8001
|
||||
37
soleprint/artery/veins/jira/README.md
Normal file
37
soleprint/artery/veins/jira/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Jira Vein
|
||||
|
||||
Jira connector for Pawprint Artery.
|
||||
|
||||
## Authentication
|
||||
|
||||
Two ways to provide Jira credentials:
|
||||
|
||||
### 1. Web UI (Headers)
|
||||
Enter credentials in the web form at https://artery.mcrn.ar
|
||||
- Credentials sent as `X-Jira-Email` and `X-Jira-Token` headers
|
||||
- Use for demos, testing, or when credentials change frequently
|
||||
|
||||
### 2. Local .env file (Fallback)
|
||||
Create `.env` (not committed to git):
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your credentials
|
||||
```
|
||||
|
||||
The system tries headers first, then falls back to `.env`.
|
||||
|
||||
## Getting a Jira API Token
|
||||
|
||||
1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
2. Click "Create API token"
|
||||
3. Copy the token (starts with `ATATT3x`)
|
||||
4. Use in UI or add to `.env`
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /jira/health` - Connection test
|
||||
- `GET /jira/mine` - My assigned tickets
|
||||
- `GET /jira/ticket/{key}` - Ticket details
|
||||
- `POST /jira/search` - Raw JQL search
|
||||
|
||||
Add `?text=true` for LLM-friendly output.
|
||||
1
soleprint/artery/veins/jira/__init__.py
Normal file
1
soleprint/artery/veins/jira/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Jira Vein
|
||||
0
soleprint/artery/veins/jira/api/__init__.py
Normal file
0
soleprint/artery/veins/jira/api/__init__.py
Normal file
299
soleprint/artery/veins/jira/api/routes.py
Normal file
299
soleprint/artery/veins/jira/api/routes.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
API routes for Jira vein.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse, StreamingResponse
|
||||
from typing import Optional, Union
|
||||
from io import BytesIO
|
||||
|
||||
from ..core.auth import get_jira_credentials, JiraCredentials
|
||||
from ..core.client import connect_jira, JiraClientError
|
||||
from ..core.config import settings
|
||||
from ..core.query import JQL, Queries
|
||||
from ..models.ticket import Ticket, TicketDetail, TicketList
|
||||
from ..models.formatter import format_ticket_list, format_ticket_detail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _download_attachments(jira, ticket: TicketDetail) -> TicketDetail:
|
||||
"""Download attachment content and populate base64 field."""
|
||||
for att in ticket.attachments:
|
||||
try:
|
||||
response = jira._session.get(att.url)
|
||||
if response.status_code == 200:
|
||||
att.content_base64 = base64.b64encode(response.content).decode("utf-8")
|
||||
except Exception:
|
||||
pass # Skip failed downloads
|
||||
return ticket
|
||||
|
||||
|
||||
def _search(creds: JiraCredentials, jql: JQL, page: int, page_size: int) -> TicketList:
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
start = (page - 1) * page_size
|
||||
issues = jira.search_issues(jql.build(), startAt=start, maxResults=page_size)
|
||||
tickets = [Ticket.from_jira(i, settings.jira_url) for i in issues]
|
||||
return TicketList(tickets=tickets, total=issues.total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
def _maybe_text(data: Union[TicketList, TicketDetail], text: bool):
|
||||
if not text:
|
||||
return data
|
||||
if isinstance(data, TicketList):
|
||||
return PlainTextResponse(format_ticket_list(data))
|
||||
return PlainTextResponse(format_ticket_detail(data))
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(creds: JiraCredentials = Depends(get_jira_credentials)):
|
||||
try:
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
me = jira.myself()
|
||||
return {"status": "ok", "user": me["displayName"]}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/mine")
|
||||
def my_tickets(
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
project: Optional[str] = None,
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get my assigned open tickets."""
|
||||
try:
|
||||
return _maybe_text(_search(creds, Queries.my_tickets(project), page, page_size), text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/backlog")
|
||||
def backlog(
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
project: str = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get backlog tickets for a project."""
|
||||
try:
|
||||
return _maybe_text(_search(creds, Queries.backlog(project), page, page_size), text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/sprint")
|
||||
def current_sprint(
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
project: str = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get current sprint tickets for a project."""
|
||||
try:
|
||||
return _maybe_text(_search(creds, Queries.current_sprint(project), page, page_size), text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/ticket/{key}")
|
||||
def get_ticket(
|
||||
key: str,
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
text: bool = False,
|
||||
include_attachments: bool = False,
|
||||
include_children: bool = True,
|
||||
):
|
||||
"""Get ticket details with comments, attachments, and child work items."""
|
||||
try:
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
issue = jira.issue(key, expand="comments")
|
||||
ticket = TicketDetail.from_jira(issue, settings.jira_url)
|
||||
if include_attachments and ticket.attachments:
|
||||
ticket = _download_attachments(jira, ticket)
|
||||
|
||||
# Fetch child work items if requested and ticket has subtasks
|
||||
children = []
|
||||
if include_children and ticket.subtasks:
|
||||
# Fetch all children in one query
|
||||
child_jql = f"key in ({','.join(ticket.subtasks)})"
|
||||
child_issues = jira.search_issues(child_jql, maxResults=len(ticket.subtasks))
|
||||
children = [Ticket.from_jira(i, settings.jira_url) for i in child_issues]
|
||||
# Sort children by key
|
||||
children.sort(key=lambda t: t.key)
|
||||
|
||||
# Return as special format that includes children
|
||||
if text:
|
||||
from ..models.formatter import format_ticket_with_children
|
||||
return PlainTextResponse(format_ticket_with_children(ticket, children))
|
||||
|
||||
# For JSON, add children to response
|
||||
result = ticket.model_dump()
|
||||
result["children"] = [c.model_dump() for c in children]
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Return the actual Jira error for debugging
|
||||
raise HTTPException(404, f"Error fetching {key}: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
def search(
|
||||
jql: str,
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Search with raw JQL."""
|
||||
try:
|
||||
return _maybe_text(_search(creds, JQL().raw(jql), page, page_size), text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.post("/epic/{key}/process")
|
||||
def process_epic(
|
||||
key: str,
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
):
|
||||
"""Process epic: fetch epic and all children, save to files."""
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
logger.info(f"EPIC endpoint called: key={key}, email={creds.email}")
|
||||
|
||||
def generate():
|
||||
try:
|
||||
logger.info(f"Starting EPIC process for {key}")
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
logger.info(f"Connected to Jira for {key}")
|
||||
|
||||
# Fetch epic
|
||||
yield json.dumps({"status": "fetching_epic", "completed": 0, "total": 0}) + "\n"
|
||||
logger.info(f"Sent fetching_epic status for {key}")
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.info(f"Fetching issue {key}")
|
||||
epic_issue = jira.issue(key, expand="comments")
|
||||
logger.info(f"Got issue {key}")
|
||||
epic = TicketDetail.from_jira(epic_issue, settings.jira_url)
|
||||
logger.info(f"Parsed epic: {epic.key} with {len(epic.subtasks)} subtasks")
|
||||
|
||||
# Get children keys from subtasks
|
||||
if not epic.subtasks:
|
||||
yield json.dumps({"status": "no_children", "completed": 0, "total": 0}) + "\n"
|
||||
return
|
||||
|
||||
total = len(epic.subtasks)
|
||||
|
||||
# Create storage folder in larder
|
||||
larder_path = Path(__file__).parent.parent.parent.parent / "larder" / "jira_epics" / key
|
||||
larder_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save epic
|
||||
epic_file = larder_path / f"{key}.json"
|
||||
with open(epic_file, "w") as f:
|
||||
json.dump(epic.model_dump(), f, indent=2, default=str)
|
||||
|
||||
yield json.dumps({"status": "processing", "completed": 0, "total": total}) + "\n"
|
||||
|
||||
# Fetch each child
|
||||
children = []
|
||||
for idx, child_key in enumerate(epic.subtasks, 1):
|
||||
time.sleep(0.8) # Human speed
|
||||
|
||||
try:
|
||||
child_issue = jira.issue(child_key, expand="comments")
|
||||
child = TicketDetail.from_jira(child_issue, settings.jira_url)
|
||||
|
||||
# Save child
|
||||
child_file = larder_path / f"{child_key}.json"
|
||||
with open(child_file, "w") as f:
|
||||
json.dump(child.model_dump(), f, indent=2, default=str)
|
||||
|
||||
# Collect children for text formatting
|
||||
children.append(Ticket.from_jira(child_issue, settings.jira_url))
|
||||
|
||||
yield json.dumps({"status": "processing", "completed": idx, "total": total}) + "\n"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
yield json.dumps({
|
||||
"status": "error",
|
||||
"completed": idx,
|
||||
"total": total,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"child_key": child_key,
|
||||
"traceback": traceback.format_exc()
|
||||
}) + "\n"
|
||||
|
||||
# Format as text for display
|
||||
from ..models.formatter import format_ticket_with_children
|
||||
formatted_text = format_ticket_with_children(epic, children)
|
||||
|
||||
yield json.dumps({
|
||||
"status": "complete",
|
||||
"completed": total,
|
||||
"total": total,
|
||||
"path": str(larder_path),
|
||||
"text": formatted_text
|
||||
}) + "\n"
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
yield json.dumps({
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"traceback": traceback.format_exc()
|
||||
}) + "\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@router.get("/epic/{key}/status")
|
||||
def get_epic_status(key: str):
|
||||
"""Check if epic has been processed and files exist."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
larder_path = Path(__file__).parent.parent.parent.parent / "larder" / "jira_epics" / key
|
||||
|
||||
if not larder_path.exists():
|
||||
return {"processed": False}
|
||||
|
||||
files = list(larder_path.glob("*.json"))
|
||||
return {
|
||||
"processed": True,
|
||||
"path": str(larder_path),
|
||||
"files": [f.name for f in files],
|
||||
"count": len(files)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/attachment/{attachment_id}")
|
||||
def get_attachment(
|
||||
attachment_id: str,
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
):
|
||||
"""Stream attachment content directly from Jira."""
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
att_url = f"{settings.jira_url}/rest/api/2/attachment/content/{attachment_id}"
|
||||
response = jira._session.get(att_url, allow_redirects=True)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(404, f"Attachment not found: {attachment_id}")
|
||||
content_type = response.headers.get("Content-Type", "application/octet-stream")
|
||||
return StreamingResponse(
|
||||
BytesIO(response.content),
|
||||
media_type=content_type,
|
||||
)
|
||||
0
soleprint/artery/veins/jira/core/__init__.py
Normal file
0
soleprint/artery/veins/jira/core/__init__.py
Normal file
37
soleprint/artery/veins/jira/core/auth.py
Normal file
37
soleprint/artery/veins/jira/core/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Jira credentials authentication for Jira vein.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fastapi import Header, HTTPException
|
||||
from .config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class JiraCredentials:
|
||||
email: str
|
||||
token: str
|
||||
|
||||
|
||||
async def get_jira_credentials(
|
||||
x_jira_email: str | None = Header(None),
|
||||
x_jira_token: str | None = Header(None),
|
||||
) -> JiraCredentials:
|
||||
"""
|
||||
Dependency that extracts Jira credentials from headers or falls back to config.
|
||||
|
||||
- Headers provided → per-request credentials (web demo)
|
||||
- No headers → use .env credentials (API/standalone)
|
||||
"""
|
||||
# Use headers if provided (check for non-empty strings)
|
||||
if x_jira_email and x_jira_token and x_jira_email.strip() and x_jira_token.strip():
|
||||
return JiraCredentials(email=x_jira_email.strip(), token=x_jira_token.strip())
|
||||
|
||||
# Fall back to config
|
||||
if settings.jira_email and settings.jira_api_token:
|
||||
return JiraCredentials(email=settings.jira_email, token=settings.jira_api_token)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing credentials: provide X-Jira-Email and X-Jira-Token headers, or configure in .env",
|
||||
)
|
||||
19
soleprint/artery/veins/jira/core/client.py
Normal file
19
soleprint/artery/veins/jira/core/client.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Jira connection client.
|
||||
"""
|
||||
|
||||
from jira import JIRA
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class JiraClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def connect_jira(email: str, token: str) -> JIRA:
|
||||
"""Create a Jira connection with the given credentials."""
|
||||
return JIRA(
|
||||
server=settings.jira_url,
|
||||
basic_auth=(email, token),
|
||||
)
|
||||
23
soleprint/artery/veins/jira/core/config.py
Normal file
23
soleprint/artery/veins/jira/core/config.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Jira credentials loaded from .env file.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||
|
||||
|
||||
class JiraConfig(BaseSettings):
|
||||
jira_url: str
|
||||
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
|
||||
api_port: int = 8001
|
||||
|
||||
model_config = {
|
||||
"env_file": ENV_FILE,
|
||||
"env_file_encoding": "utf-8",
|
||||
}
|
||||
|
||||
|
||||
settings = JiraConfig()
|
||||
86
soleprint/artery/veins/jira/core/query.py
Normal file
86
soleprint/artery/veins/jira/core/query.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
JQL query builder.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class JQL:
|
||||
"""Fluent JQL builder."""
|
||||
|
||||
def __init__(self):
|
||||
self._parts: List[str] = []
|
||||
self._order: Optional[str] = None
|
||||
|
||||
def _q(self, val: str) -> str:
|
||||
return f'"{val}"' if " " in val else val
|
||||
|
||||
# Conditions
|
||||
def assigned_to_me(self) -> "JQL":
|
||||
self._parts.append("assignee = currentUser()")
|
||||
return self
|
||||
|
||||
def project(self, key: str) -> "JQL":
|
||||
self._parts.append(f"project = {self._q(key)}")
|
||||
return self
|
||||
|
||||
def sprint_open(self) -> "JQL":
|
||||
self._parts.append("sprint in openSprints()")
|
||||
return self
|
||||
|
||||
def in_backlog(self) -> "JQL":
|
||||
self._parts.append("sprint is EMPTY")
|
||||
return self
|
||||
|
||||
def not_done(self) -> "JQL":
|
||||
self._parts.append("statusCategory != Done")
|
||||
return self
|
||||
|
||||
def status(self, name: str) -> "JQL":
|
||||
self._parts.append(f"status = {self._q(name)}")
|
||||
return self
|
||||
|
||||
def label(self, name: str) -> "JQL":
|
||||
self._parts.append(f"labels = {self._q(name)}")
|
||||
return self
|
||||
|
||||
def text(self, search: str) -> "JQL":
|
||||
self._parts.append(f'text ~ "{search}"')
|
||||
return self
|
||||
|
||||
def issue_type(self, name: str) -> "JQL":
|
||||
self._parts.append(f"issuetype = {self._q(name)}")
|
||||
return self
|
||||
|
||||
def raw(self, jql: str) -> "JQL":
|
||||
self._parts.append(jql)
|
||||
return self
|
||||
|
||||
# Ordering
|
||||
def order_by(self, field: str, desc: bool = True) -> "JQL":
|
||||
self._order = f"ORDER BY {field} {'DESC' if desc else 'ASC'}"
|
||||
return self
|
||||
|
||||
def build(self) -> str:
|
||||
jql = " AND ".join(self._parts)
|
||||
if self._order:
|
||||
jql = f"{jql} {self._order}"
|
||||
return jql.strip()
|
||||
|
||||
|
||||
# Preset queries for main use cases
|
||||
class Queries:
|
||||
@staticmethod
|
||||
def my_tickets(project: Optional[str] = None) -> JQL:
|
||||
q = JQL().assigned_to_me().not_done().order_by("updated")
|
||||
if project:
|
||||
q.project(project)
|
||||
return q
|
||||
|
||||
@staticmethod
|
||||
def backlog(project: str) -> JQL:
|
||||
return JQL().project(project).in_backlog().not_done().order_by("priority")
|
||||
|
||||
@staticmethod
|
||||
def current_sprint(project: str) -> JQL:
|
||||
return JQL().project(project).sprint_open().order_by("priority")
|
||||
15
soleprint/artery/veins/jira/main.py
Normal file
15
soleprint/artery/veins/jira/main.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Jira Vein - FastAPI app.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from .api.routes import router
|
||||
from .core.config import settings
|
||||
|
||||
app = FastAPI(title="Jira Vein", version="0.1.0")
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)
|
||||
0
soleprint/artery/veins/jira/models/__init__.py
Normal file
0
soleprint/artery/veins/jira/models/__init__.py
Normal file
182
soleprint/artery/veins/jira/models/formatter.py
Normal file
182
soleprint/artery/veins/jira/models/formatter.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Text formatters for LLM/human readable output.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ticket import Attachment, Ticket, TicketDetail, TicketList
|
||||
|
||||
|
||||
def _fmt_size(size: int) -> str:
|
||||
"""Format bytes to human readable."""
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size < 1024:
|
||||
return f"{size:.1f}{unit}" if unit != "B" else f"{size}{unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f}TB"
|
||||
|
||||
|
||||
def _fmt_dt(dt) -> str:
|
||||
if not dt:
|
||||
return "-"
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def format_ticket(t: "Ticket") -> str:
|
||||
lines = [
|
||||
f"[{t.key}] {t.summary}",
|
||||
f" Project: {t.project} | Type: {t.issue_type} | Priority: {t.priority or '-'}",
|
||||
f" Status: {t.status} ({t.status_category or '-'})",
|
||||
f" Assignee: {t.assignee or '-'} | Reporter: {t.reporter or '-'}",
|
||||
f" Labels: {', '.join(t.labels) if t.labels else '-'}",
|
||||
f" Created: {_fmt_dt(t.created)} | Updated: {_fmt_dt(t.updated)}",
|
||||
f" URL: {t.url}",
|
||||
]
|
||||
if t.description:
|
||||
lines.append(f" Description: {t.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_ticket_detail(t: "TicketDetail") -> str:
|
||||
lines = [
|
||||
f"# {t.key}: {t.summary}",
|
||||
"",
|
||||
f"Project: {t.project}",
|
||||
f"Type: {t.issue_type}",
|
||||
f"Status: {t.status} ({t.status_category or '-'})",
|
||||
f"Priority: {t.priority or '-'}",
|
||||
f"Assignee: {t.assignee or '-'}",
|
||||
f"Reporter: {t.reporter or '-'}",
|
||||
f"Labels: {', '.join(t.labels) if t.labels else '-'}",
|
||||
f"Created: {_fmt_dt(t.created)}",
|
||||
f"Updated: {_fmt_dt(t.updated)}",
|
||||
f"Parent: {t.parent_key or '-'}",
|
||||
f"Subtasks: {', '.join(t.subtasks) if t.subtasks else '-'}",
|
||||
f"Linked issues: {', '.join(t.linked_issues) if t.linked_issues else '-'}",
|
||||
f"URL: {t.url}",
|
||||
"",
|
||||
"## Description",
|
||||
t.description or "(no description)",
|
||||
"",
|
||||
]
|
||||
|
||||
lines.append(f"## Comments ({len(t.comments)})")
|
||||
if t.comments:
|
||||
for c in t.comments:
|
||||
lines.append(f"### {c.get('author', 'Unknown')} ({c.get('created', '')[:16] if c.get('created') else '-'})")
|
||||
lines.append(c.get("body", ""))
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("(no comments)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"## Attachments ({len(t.attachments)})")
|
||||
if t.attachments:
|
||||
for a in t.attachments:
|
||||
has_content = "[downloaded]" if a.content_base64 else ""
|
||||
lines.append(f"- {a.filename} ({_fmt_size(a.size)}, {a.mimetype}) {has_content}")
|
||||
else:
|
||||
lines.append("(no attachments)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_ticket_with_children(parent: "TicketDetail", children: list) -> str:
|
||||
"""Format a ticket with its children (subtasks/stories)."""
|
||||
lines = [
|
||||
f"# {parent.key}: {parent.summary}",
|
||||
"",
|
||||
f"Project: {parent.project}",
|
||||
f"Type: {parent.issue_type}",
|
||||
f"Status: {parent.status} ({parent.status_category or '-'})",
|
||||
f"Priority: {parent.priority or '-'}",
|
||||
f"Assignee: {parent.assignee or '-'}",
|
||||
f"Reporter: {parent.reporter or '-'}",
|
||||
f"Labels: {', '.join(parent.labels) if parent.labels else '-'}",
|
||||
f"Created: {_fmt_dt(parent.created)}",
|
||||
f"Updated: {_fmt_dt(parent.updated)}",
|
||||
f"URL: {parent.url}",
|
||||
"",
|
||||
"## Description",
|
||||
parent.description or "(no description)",
|
||||
"",
|
||||
]
|
||||
|
||||
# Add children section
|
||||
if children:
|
||||
child_type = "Sub-tasks" if parent.issue_type in ("Story", "Task") else "Stories"
|
||||
lines.append(f"## {child_type} ({len(children)})")
|
||||
lines.append("=" * 60)
|
||||
lines.append("")
|
||||
|
||||
for child in children:
|
||||
lines.append(f"[{child.key}] {child.summary}")
|
||||
lines.append(f" Type: {child.issue_type} | Status: {child.status} | Priority: {child.priority or '-'}")
|
||||
lines.append(f" Assignee: {child.assignee or '-'}")
|
||||
lines.append(f" URL: {child.url}")
|
||||
lines.append("")
|
||||
lines.append("-" * 60)
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"## Comments ({len(parent.comments)})")
|
||||
if parent.comments:
|
||||
for c in parent.comments:
|
||||
lines.append(f"### {c.get('author', 'Unknown')} ({c.get('created', '')[:16] if c.get('created') else '-'})")
|
||||
lines.append(c.get("body", ""))
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("(no comments)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"## Attachments ({len(parent.attachments)})")
|
||||
if parent.attachments:
|
||||
for a in parent.attachments:
|
||||
has_content = "[downloaded]" if a.content_base64 else ""
|
||||
lines.append(f"- {a.filename} ({_fmt_size(a.size)}, {a.mimetype}) {has_content}")
|
||||
else:
|
||||
lines.append("(no attachments)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_ticket_list(tl: "TicketList") -> str:
|
||||
# Sort for text output: stories with subtasks, then bugs
|
||||
stories = []
|
||||
bugs = []
|
||||
subtasks = []
|
||||
|
||||
for t in tl.tickets:
|
||||
if t.parent_key:
|
||||
subtasks.append(t)
|
||||
elif t.issue_type in ("Story", "Epic", "Task"):
|
||||
stories.append(t)
|
||||
elif t.issue_type == "Bug":
|
||||
bugs.append(t)
|
||||
else:
|
||||
stories.append(t) # fallback
|
||||
|
||||
# Build sorted list: parent stories, then their subtasks, then bugs
|
||||
sorted_tickets = []
|
||||
for story in sorted(stories, key=lambda t: t.key):
|
||||
sorted_tickets.append(story)
|
||||
# Add subtasks for this story
|
||||
story_subtasks = [st for st in subtasks if st.parent_key == story.key]
|
||||
sorted_tickets.extend(sorted(story_subtasks, key=lambda t: t.key))
|
||||
|
||||
# Add bugs at the end
|
||||
sorted_tickets.extend(sorted(bugs, key=lambda t: t.key))
|
||||
|
||||
lines = [
|
||||
f"Total: {tl.total} | Page: {tl.page} | Page size: {tl.page_size}",
|
||||
f"Showing: {len(tl.tickets)} tickets",
|
||||
"=" * 60,
|
||||
"",
|
||||
]
|
||||
for i, t in enumerate(sorted_tickets):
|
||||
lines.append(format_ticket(t))
|
||||
if i < len(sorted_tickets) - 1:
|
||||
lines.append("")
|
||||
lines.append("-" * 60)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
135
soleprint/artery/veins/jira/models/ticket.py
Normal file
135
soleprint/artery/veins/jira/models/ticket.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Ticket models with self-parsing from Jira objects.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Attachment(BaseModel):
|
||||
id: str
|
||||
filename: str
|
||||
mimetype: str
|
||||
size: int # bytes
|
||||
url: str
|
||||
content_base64: Optional[str] = None # populated when include_attachments=true
|
||||
|
||||
@classmethod
|
||||
def from_jira(cls, att) -> "Attachment":
|
||||
return cls(
|
||||
id=att.id,
|
||||
filename=att.filename,
|
||||
mimetype=att.mimeType,
|
||||
size=att.size,
|
||||
url=att.content,
|
||||
)
|
||||
|
||||
|
||||
class Ticket(BaseModel):
|
||||
key: str
|
||||
summary: str
|
||||
description: Optional[str] = None
|
||||
status: str
|
||||
status_category: Optional[str] = None
|
||||
issue_type: str
|
||||
priority: Optional[str] = None
|
||||
project: str
|
||||
assignee: Optional[str] = None
|
||||
reporter: Optional[str] = None
|
||||
labels: List[str] = []
|
||||
created: Optional[datetime] = None
|
||||
updated: Optional[datetime] = None
|
||||
url: str
|
||||
parent_key: Optional[str] = None # For subtasks
|
||||
|
||||
@classmethod
|
||||
def from_jira(cls, issue, base_url: str) -> "Ticket":
|
||||
f = issue.fields
|
||||
status_cat = None
|
||||
if hasattr(f.status, "statusCategory"):
|
||||
status_cat = f.status.statusCategory.name
|
||||
|
||||
# Get parent key for subtasks
|
||||
parent = None
|
||||
if hasattr(f, "parent") and f.parent:
|
||||
parent = f.parent.key
|
||||
|
||||
return cls(
|
||||
key=issue.key,
|
||||
summary=f.summary or "",
|
||||
description=f.description,
|
||||
status=f.status.name,
|
||||
status_category=status_cat,
|
||||
issue_type=f.issuetype.name,
|
||||
priority=f.priority.name if f.priority else None,
|
||||
project=f.project.key,
|
||||
assignee=f.assignee.displayName if f.assignee else None,
|
||||
reporter=f.reporter.displayName if f.reporter else None,
|
||||
labels=f.labels or [],
|
||||
created=cls._parse_dt(f.created),
|
||||
updated=cls._parse_dt(f.updated),
|
||||
url=f"{base_url}/browse/{issue.key}",
|
||||
parent_key=parent,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_dt(val: Optional[str]) -> Optional[datetime]:
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class TicketDetail(Ticket):
|
||||
comments: List[dict] = []
|
||||
linked_issues: List[str] = []
|
||||
subtasks: List[str] = []
|
||||
attachments: List[Attachment] = []
|
||||
|
||||
@classmethod
|
||||
def from_jira(cls, issue, base_url: str) -> "TicketDetail":
|
||||
base = Ticket.from_jira(issue, base_url)
|
||||
f = issue.fields
|
||||
|
||||
comments = []
|
||||
if hasattr(f, "comment") and f.comment:
|
||||
for c in f.comment.comments:
|
||||
comments.append({
|
||||
"author": c.author.displayName if hasattr(c, "author") else None,
|
||||
"body": c.body,
|
||||
"created": c.created,
|
||||
})
|
||||
|
||||
linked = []
|
||||
if hasattr(f, "issuelinks") and f.issuelinks:
|
||||
for link in f.issuelinks:
|
||||
if hasattr(link, "outwardIssue"):
|
||||
linked.append(link.outwardIssue.key)
|
||||
if hasattr(link, "inwardIssue"):
|
||||
linked.append(link.inwardIssue.key)
|
||||
|
||||
subtasks = []
|
||||
if hasattr(f, "subtasks") and f.subtasks:
|
||||
subtasks = [st.key for st in f.subtasks]
|
||||
|
||||
attachments = []
|
||||
if hasattr(f, "attachment") and f.attachment:
|
||||
attachments = [Attachment.from_jira(a) for a in f.attachment]
|
||||
|
||||
return cls(
|
||||
**base.model_dump(),
|
||||
comments=comments,
|
||||
linked_issues=linked,
|
||||
subtasks=subtasks,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
|
||||
class TicketList(BaseModel):
|
||||
tickets: List[Ticket]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
5
soleprint/artery/veins/jira/requirements.txt
Normal file
5
soleprint/artery/veins/jira/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
jira>=3.5.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
19
soleprint/artery/veins/jira/run.py
Normal file
19
soleprint/artery/veins/jira/run.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python
|
||||
"""Run the Jira vein API."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import uvicorn
|
||||
from core.config import settings
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=settings.api_port,
|
||||
reload=True,
|
||||
)
|
||||
179
soleprint/artery/veins/oauth.py
Normal file
179
soleprint/artery/veins/oauth.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
OAuth2 utilities and base classes for OAuth-based veins.
|
||||
|
||||
Any vein using OAuth2 (Google, GitHub, GitLab, etc.) can inherit from
|
||||
BaseOAuthVein and use TokenStorage.
|
||||
"""
|
||||
|
||||
import json
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .base import BaseVein, TClient, TCredentials
|
||||
|
||||
|
||||
class TokenStorage:
|
||||
"""
|
||||
File-based token storage for OAuth2 tokens.
|
||||
|
||||
Can be overridden for Redis/database storage in production.
|
||||
Each vein gets its own storage directory.
|
||||
"""
|
||||
|
||||
def __init__(self, vein_name: str, storage_dir: Optional[Path] = None):
|
||||
"""
|
||||
Initialize token storage.
|
||||
|
||||
Args:
|
||||
vein_name: Name of the vein (e.g., 'google', 'github')
|
||||
storage_dir: Base storage directory (defaults to veins/{vein_name}/storage)
|
||||
"""
|
||||
if storage_dir is None:
|
||||
# Default: veins/{vein_name}/storage/
|
||||
storage_dir = Path(__file__).parent / vein_name / "storage"
|
||||
self.storage_dir = storage_dir
|
||||
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_path(self, user_id: str) -> Path:
|
||||
"""Get token file path for user."""
|
||||
return self.storage_dir / f"tokens_{user_id}.json"
|
||||
|
||||
def save_tokens(self, user_id: str, tokens: dict) -> None:
|
||||
"""
|
||||
Save OAuth2 tokens for a user.
|
||||
|
||||
tokens should contain:
|
||||
- access_token
|
||||
- refresh_token (optional)
|
||||
- expires_in (seconds)
|
||||
- scope
|
||||
- token_type
|
||||
"""
|
||||
# Add expiry timestamp
|
||||
if "expires_in" in tokens:
|
||||
expires_at = datetime.now() + timedelta(seconds=tokens["expires_in"])
|
||||
tokens["expires_at"] = expires_at.isoformat()
|
||||
|
||||
path = self._get_path(user_id)
|
||||
with open(path, "w") as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
|
||||
def load_tokens(self, user_id: str) -> Optional[dict]:
|
||||
"""Load OAuth2 tokens for a user. Returns None if not found."""
|
||||
path = self._get_path(user_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def is_expired(self, tokens: dict) -> bool:
|
||||
"""
|
||||
Check if access token is expired.
|
||||
|
||||
Returns True if expired or expiring in less than 5 minutes.
|
||||
"""
|
||||
if "expires_at" not in tokens:
|
||||
return True
|
||||
|
||||
expires_at = datetime.fromisoformat(tokens["expires_at"])
|
||||
# Consider expired if less than 5 minutes remaining
|
||||
return datetime.now() >= expires_at - timedelta(minutes=5)
|
||||
|
||||
def delete_tokens(self, user_id: str) -> None:
|
||||
"""Delete tokens for a user."""
|
||||
path = self._get_path(user_id)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
class BaseOAuthVein(BaseVein[TCredentials, TClient]):
|
||||
"""
|
||||
Base class for OAuth2-based veins.
|
||||
|
||||
Extends BaseVein with OAuth2 flow management:
|
||||
- Authorization URL generation
|
||||
- Code exchange for tokens
|
||||
- Token refresh
|
||||
- Token storage
|
||||
"""
|
||||
|
||||
def __init__(self, storage: Optional[TokenStorage] = None):
|
||||
"""
|
||||
Initialize OAuth vein.
|
||||
|
||||
Args:
|
||||
storage: Token storage instance (creates default if None)
|
||||
"""
|
||||
if storage is None:
|
||||
storage = TokenStorage(vein_name=self.name)
|
||||
self.storage = storage
|
||||
|
||||
@abstractmethod
|
||||
def get_auth_url(self, state: Optional[str] = None) -> str:
|
||||
"""
|
||||
Generate OAuth2 authorization URL.
|
||||
|
||||
Args:
|
||||
state: Optional state parameter for CSRF protection
|
||||
|
||||
Returns:
|
||||
URL to redirect user for authorization
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> dict:
|
||||
"""
|
||||
Exchange authorization code for tokens.
|
||||
|
||||
Args:
|
||||
code: Authorization code from callback
|
||||
|
||||
Returns:
|
||||
Token dict containing access_token, refresh_token, etc.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def refresh_token(self, refresh_token: str) -> dict:
|
||||
"""
|
||||
Refresh an expired access token.
|
||||
|
||||
Args:
|
||||
refresh_token: The refresh token
|
||||
|
||||
Returns:
|
||||
New token dict with fresh access_token
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_valid_tokens(self, user_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Get valid tokens for user, refreshing if needed.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
|
||||
Returns:
|
||||
Valid tokens or None if not authenticated
|
||||
"""
|
||||
tokens = self.storage.load_tokens(user_id)
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
if self.storage.is_expired(tokens) and "refresh_token" in tokens:
|
||||
# Try to refresh
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
new_tokens = asyncio.run(self.refresh_token(tokens["refresh_token"]))
|
||||
self.storage.save_tokens(user_id, new_tokens)
|
||||
return new_tokens
|
||||
except Exception:
|
||||
# Refresh failed, user needs to re-authenticate
|
||||
return None
|
||||
|
||||
return tokens
|
||||
1
soleprint/artery/veins/slack/__init__.py
Normal file
1
soleprint/artery/veins/slack/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Slack Vein
|
||||
1
soleprint/artery/veins/slack/api/__init__.py
Normal file
1
soleprint/artery/veins/slack/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Slack API routes
|
||||
233
soleprint/artery/veins/slack/api/routes.py
Normal file
233
soleprint/artery/veins/slack/api/routes.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
API routes for Slack vein.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from typing import Optional, Union
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.auth import get_slack_credentials, SlackCredentials
|
||||
from ..core.client import get_client, test_auth, SlackClientError
|
||||
from ..models.message import (
|
||||
Channel, ChannelList, Message, MessageList,
|
||||
User, UserList,
|
||||
)
|
||||
from ..models.formatter import (
|
||||
format_channel_list, format_message_list, format_user_list,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PostMessageRequest(BaseModel):
|
||||
channel: str # Channel ID or name
|
||||
text: str
|
||||
thread_ts: Optional[str] = None # Reply to thread
|
||||
|
||||
|
||||
class PostMessageResponse(BaseModel):
|
||||
ok: bool
|
||||
channel: str
|
||||
ts: str
|
||||
message: Optional[Message] = None
|
||||
|
||||
|
||||
def _maybe_text(data, text: bool, formatter):
|
||||
if not text:
|
||||
return data
|
||||
return PlainTextResponse(formatter(data))
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(creds: SlackCredentials = Depends(get_slack_credentials)):
|
||||
"""Test Slack connection."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
info = test_auth(client)
|
||||
return {"status": "ok", **info}
|
||||
except SlackClientError as e:
|
||||
raise HTTPException(500, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Connection failed: {e}")
|
||||
|
||||
|
||||
@router.get("/channels")
|
||||
def list_channels(
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
types: str = Query("public_channel", description="Channel types: public_channel, private_channel (needs groups:read), mpim, im"),
|
||||
text: bool = False,
|
||||
):
|
||||
"""List channels the bot/user has access to."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
response = client.conversations_list(limit=limit, types=types)
|
||||
|
||||
channels = [Channel.from_slack(ch) for ch in response.get("channels", [])]
|
||||
result = ChannelList(channels=channels, total=len(channels))
|
||||
|
||||
return _maybe_text(result, text, format_channel_list)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to list channels: {e}")
|
||||
|
||||
|
||||
@router.get("/channels/{channel_id}/messages")
|
||||
def get_messages(
|
||||
channel_id: str,
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
oldest: Optional[str] = None,
|
||||
latest: Optional[str] = None,
|
||||
text: bool = False,
|
||||
include_users: bool = False,
|
||||
):
|
||||
"""Get messages from a channel."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
|
||||
kwargs = {"channel": channel_id, "limit": limit}
|
||||
if oldest:
|
||||
kwargs["oldest"] = oldest
|
||||
if latest:
|
||||
kwargs["latest"] = latest
|
||||
|
||||
response = client.conversations_history(**kwargs)
|
||||
|
||||
messages = [Message.from_slack(m) for m in response.get("messages", [])]
|
||||
result = MessageList(
|
||||
messages=messages,
|
||||
channel_id=channel_id,
|
||||
has_more=response.get("has_more", False),
|
||||
)
|
||||
|
||||
# Optionally fetch user names for better text output
|
||||
users_map = None
|
||||
if text and include_users:
|
||||
try:
|
||||
users_resp = client.users_list(limit=200)
|
||||
users_map = {
|
||||
u["id"]: u.get("profile", {}).get("display_name") or u.get("real_name") or u.get("name")
|
||||
for u in users_resp.get("members", [])
|
||||
}
|
||||
except Exception:
|
||||
pass # Continue without user names
|
||||
|
||||
if text:
|
||||
return PlainTextResponse(format_message_list(result, users_map))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to get messages: {e}")
|
||||
|
||||
|
||||
@router.get("/channels/{channel_id}/thread/{thread_ts}")
|
||||
def get_thread(
|
||||
channel_id: str,
|
||||
thread_ts: str,
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get replies in a thread."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
response = client.conversations_replies(
|
||||
channel=channel_id,
|
||||
ts=thread_ts,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
messages = [Message.from_slack(m) for m in response.get("messages", [])]
|
||||
result = MessageList(
|
||||
messages=messages,
|
||||
channel_id=channel_id,
|
||||
has_more=response.get("has_more", False),
|
||||
)
|
||||
|
||||
return _maybe_text(result, text, format_message_list)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to get thread: {e}")
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
text: bool = False,
|
||||
):
|
||||
"""List workspace users."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
response = client.users_list(limit=limit)
|
||||
|
||||
users = [User.from_slack(u) for u in response.get("members", [])]
|
||||
result = UserList(users=users, total=len(users))
|
||||
|
||||
return _maybe_text(result, text, format_user_list)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to list users: {e}")
|
||||
|
||||
|
||||
@router.post("/post")
|
||||
def post_message(
|
||||
request: PostMessageRequest,
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
):
|
||||
"""Post a message to a channel."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
|
||||
kwargs = {
|
||||
"channel": request.channel,
|
||||
"text": request.text,
|
||||
}
|
||||
if request.thread_ts:
|
||||
kwargs["thread_ts"] = request.thread_ts
|
||||
|
||||
response = client.chat_postMessage(**kwargs)
|
||||
|
||||
msg = None
|
||||
if response.get("message"):
|
||||
msg = Message.from_slack(response["message"])
|
||||
|
||||
return PostMessageResponse(
|
||||
ok=response.get("ok", False),
|
||||
channel=response.get("channel", request.channel),
|
||||
ts=response.get("ts", ""),
|
||||
message=msg,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to post message: {e}")
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_messages(
|
||||
query: str,
|
||||
creds: SlackCredentials = Depends(get_slack_credentials),
|
||||
count: int = Query(20, ge=1, le=100),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Search messages (requires user token with search:read scope)."""
|
||||
try:
|
||||
client = get_client(creds.token)
|
||||
response = client.search_messages(query=query, count=count)
|
||||
|
||||
messages_data = response.get("messages", {}).get("matches", [])
|
||||
messages = []
|
||||
for m in messages_data:
|
||||
messages.append(Message(
|
||||
ts=m.get("ts", ""),
|
||||
user=m.get("user"),
|
||||
text=m.get("text", ""),
|
||||
thread_ts=m.get("thread_ts"),
|
||||
))
|
||||
|
||||
result = MessageList(
|
||||
messages=messages,
|
||||
channel_id="search",
|
||||
has_more=len(messages) >= count,
|
||||
)
|
||||
|
||||
return _maybe_text(result, text, format_message_list)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Search failed: {e}")
|
||||
1
soleprint/artery/veins/slack/core/__init__.py
Normal file
1
soleprint/artery/veins/slack/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Slack core
|
||||
37
soleprint/artery/veins/slack/core/auth.py
Normal file
37
soleprint/artery/veins/slack/core/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Slack credentials authentication for Slack vein.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from fastapi import Header, HTTPException
|
||||
from .config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackCredentials:
|
||||
token: str
|
||||
|
||||
|
||||
async def get_slack_credentials(
|
||||
x_slack_token: str | None = Header(None),
|
||||
) -> SlackCredentials:
|
||||
"""
|
||||
Dependency that extracts Slack token from headers or falls back to config.
|
||||
|
||||
- Header provided → per-request token (web demo)
|
||||
- No header → use .env token (API/standalone)
|
||||
"""
|
||||
# Use header if provided
|
||||
if x_slack_token and x_slack_token.strip():
|
||||
return SlackCredentials(token=x_slack_token.strip())
|
||||
|
||||
# Fall back to config (prefer bot token, then user token)
|
||||
if settings.slack_bot_token:
|
||||
return SlackCredentials(token=settings.slack_bot_token)
|
||||
if settings.slack_user_token:
|
||||
return SlackCredentials(token=settings.slack_user_token)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing credentials: provide X-Slack-Token header, or configure in .env",
|
||||
)
|
||||
30
soleprint/artery/veins/slack/core/client.py
Normal file
30
soleprint/artery/veins/slack/core/client.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Slack connection client using slack_sdk.
|
||||
"""
|
||||
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
|
||||
class SlackClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_client(token: str) -> WebClient:
|
||||
"""Create a Slack WebClient with the given token."""
|
||||
return WebClient(token=token)
|
||||
|
||||
|
||||
def test_auth(client: WebClient) -> dict:
|
||||
"""Test authentication and return user/bot info."""
|
||||
try:
|
||||
response = client.auth_test()
|
||||
return {
|
||||
"ok": response["ok"],
|
||||
"user": response.get("user"),
|
||||
"user_id": response.get("user_id"),
|
||||
"team": response.get("team"),
|
||||
"team_id": response.get("team_id"),
|
||||
}
|
||||
except SlackApiError as e:
|
||||
raise SlackClientError(f"Auth failed: {e.response['error']}")
|
||||
22
soleprint/artery/veins/slack/core/config.py
Normal file
22
soleprint/artery/veins/slack/core/config.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Slack credentials loaded from .env file.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
ENV_FILE = Path(__file__).parent.parent / ".env"
|
||||
|
||||
|
||||
class SlackConfig(BaseSettings):
|
||||
slack_bot_token: str | None = None # xoxb-... Bot token
|
||||
slack_user_token: str | None = None # xoxp-... User token (optional, for user-level actions)
|
||||
api_port: int = 8002
|
||||
|
||||
model_config = {
|
||||
"env_file": ENV_FILE,
|
||||
"env_file_encoding": "utf-8",
|
||||
}
|
||||
|
||||
|
||||
settings = SlackConfig()
|
||||
15
soleprint/artery/veins/slack/main.py
Normal file
15
soleprint/artery/veins/slack/main.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Slack Vein - FastAPI app.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from .api.routes import router
|
||||
from .core.config import settings
|
||||
|
||||
app = FastAPI(title="Slack Vein", version="0.1.0")
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=settings.api_port)
|
||||
1
soleprint/artery/veins/slack/models/__init__.py
Normal file
1
soleprint/artery/veins/slack/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Slack models
|
||||
70
soleprint/artery/veins/slack/models/formatter.py
Normal file
70
soleprint/artery/veins/slack/models/formatter.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Text formatters for Slack data (LLM-friendly output).
|
||||
"""
|
||||
|
||||
from .message import MessageList, ChannelList, UserList, Message, Channel
|
||||
|
||||
|
||||
def format_message(msg: Message, users: dict[str, str] | None = None) -> str:
|
||||
"""Format a single message."""
|
||||
user_display = msg.user
|
||||
if users and msg.user and msg.user in users:
|
||||
user_display = users[msg.user]
|
||||
|
||||
time_str = msg.timestamp.strftime("%Y-%m-%d %H:%M") if msg.timestamp else msg.ts
|
||||
thread_info = f" [thread: {msg.reply_count} replies]" if msg.reply_count > 0 else ""
|
||||
|
||||
return f"[{time_str}] {user_display}: {msg.text}{thread_info}"
|
||||
|
||||
|
||||
def format_message_list(data: MessageList, users: dict[str, str] | None = None) -> str:
|
||||
"""Format message list for text output."""
|
||||
lines = [f"Channel: {data.channel_id}", f"Messages: {len(data.messages)}", ""]
|
||||
|
||||
for msg in data.messages:
|
||||
lines.append(format_message(msg, users))
|
||||
|
||||
if data.has_more:
|
||||
lines.append("\n[More messages available...]")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_channel(ch: Channel) -> str:
|
||||
"""Format a single channel."""
|
||||
flags = []
|
||||
if ch.is_private:
|
||||
flags.append("private")
|
||||
if ch.is_archived:
|
||||
flags.append("archived")
|
||||
if ch.is_member:
|
||||
flags.append("member")
|
||||
|
||||
flag_str = f" ({', '.join(flags)})" if flags else ""
|
||||
members_str = f" [{ch.num_members} members]" if ch.num_members else ""
|
||||
|
||||
return f"#{ch.name} ({ch.id}){flag_str}{members_str}"
|
||||
|
||||
|
||||
def format_channel_list(data: ChannelList) -> str:
|
||||
"""Format channel list for text output."""
|
||||
lines = [f"Channels: {data.total}", ""]
|
||||
|
||||
for ch in data.channels:
|
||||
lines.append(format_channel(ch))
|
||||
if ch.purpose:
|
||||
lines.append(f" Purpose: {ch.purpose}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_user_list(data: UserList) -> str:
|
||||
"""Format user list for text output."""
|
||||
lines = [f"Users: {data.total}", ""]
|
||||
|
||||
for u in data.users:
|
||||
bot_flag = " [bot]" if u.is_bot else ""
|
||||
display = u.display_name or u.real_name or u.name
|
||||
lines.append(f"@{u.name} ({u.id}) - {display}{bot_flag}")
|
||||
|
||||
return "\n".join(lines)
|
||||
98
soleprint/artery/veins/slack/models/message.py
Normal file
98
soleprint/artery/veins/slack/models/message.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Slack models with self-parsing from Slack API responses.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
real_name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
is_bot: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_slack(cls, user: dict) -> "User":
|
||||
profile = user.get("profile", {})
|
||||
return cls(
|
||||
id=user["id"],
|
||||
name=user.get("name", ""),
|
||||
real_name=profile.get("real_name") or user.get("real_name"),
|
||||
display_name=profile.get("display_name"),
|
||||
is_bot=user.get("is_bot", False),
|
||||
)
|
||||
|
||||
|
||||
class Channel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
is_private: bool = False
|
||||
is_archived: bool = False
|
||||
is_member: bool = False
|
||||
topic: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
num_members: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_slack(cls, channel: dict) -> "Channel":
|
||||
return cls(
|
||||
id=channel["id"],
|
||||
name=channel.get("name", ""),
|
||||
is_private=channel.get("is_private", False),
|
||||
is_archived=channel.get("is_archived", False),
|
||||
is_member=channel.get("is_member", False),
|
||||
topic=channel.get("topic", {}).get("value"),
|
||||
purpose=channel.get("purpose", {}).get("value"),
|
||||
num_members=channel.get("num_members"),
|
||||
)
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
ts: str # Slack timestamp (unique message ID)
|
||||
user: Optional[str] = None
|
||||
text: str
|
||||
thread_ts: Optional[str] = None
|
||||
reply_count: int = 0
|
||||
reactions: List[dict] = []
|
||||
timestamp: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def from_slack(cls, msg: dict) -> "Message":
|
||||
ts = msg.get("ts", "")
|
||||
return cls(
|
||||
ts=ts,
|
||||
user=msg.get("user"),
|
||||
text=msg.get("text", ""),
|
||||
thread_ts=msg.get("thread_ts"),
|
||||
reply_count=msg.get("reply_count", 0),
|
||||
reactions=msg.get("reactions", []),
|
||||
timestamp=cls._ts_to_datetime(ts),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ts_to_datetime(ts: str) -> Optional[datetime]:
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(float(ts))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
class MessageList(BaseModel):
|
||||
messages: List[Message]
|
||||
channel_id: str
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class ChannelList(BaseModel):
|
||||
channels: List[Channel]
|
||||
total: int
|
||||
|
||||
|
||||
class UserList(BaseModel):
|
||||
users: List[User]
|
||||
total: int
|
||||
5
soleprint/artery/veins/slack/requirements.txt
Normal file
5
soleprint/artery/veins/slack/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
slack_sdk>=3.23.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
19
soleprint/artery/veins/slack/run.py
Normal file
19
soleprint/artery/veins/slack/run.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python
|
||||
"""Run the Slack vein API."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import uvicorn
|
||||
from core.config import settings
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=settings.api_port,
|
||||
reload=True,
|
||||
)
|
||||
56
soleprint/atlas/.dockerignore
Normal file
56
soleprint/atlas/.dockerignore
Normal file
@@ -0,0 +1,56 @@
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Data and logs
|
||||
data/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Node (for frontend)
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.next/
|
||||
3
soleprint/atlas/.gitignore
vendored
Normal file
3
soleprint/atlas/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
def
|
||||
__pycache__
|
||||
drive
|
||||
116
soleprint/atlas/CLAUDE.md
Normal file
116
soleprint/atlas/CLAUDE.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# ALBUM: Documentation System
|
||||
|
||||
## Purpose
|
||||
Documentation, templates, and data. Composed into books.
|
||||
|
||||
### Book Types
|
||||
|
||||
**Single-larder book** (template: null)
|
||||
- Book and larder are interchangeable
|
||||
- Just static content served directly
|
||||
- Listed in books.json, NOT in larders.json
|
||||
|
||||
**Templated book** (template: {...})
|
||||
- Template defines the structure
|
||||
- Larder must contain only elements matching that template
|
||||
- Template validates/constrains the larder content
|
||||
- Shows a styled landing page linking to template and larder
|
||||
|
||||
### Larders vs Books
|
||||
|
||||
- **Larders** = buckets that connect to other systems (e.g., drive, external data sources)
|
||||
- **Books** = standalone content, may be single-larder or template+larder
|
||||
- Single-larder books are listed as books, not larders
|
||||
|
||||
### Templated Book Structure
|
||||
|
||||
A templated book must contain a `template/` folder with the template definition inside:
|
||||
|
||||
```
|
||||
book/{book-slug}/
|
||||
├── template/ # REQUIRED for templated books
|
||||
│ └── {template-files}
|
||||
├── {larder-name}/ # The actual data (marked with .larder file)
|
||||
│ └── .larder
|
||||
├── index.html # Larder browser (used at /book/{slug}/larder/)
|
||||
└── detail.html # Detail view template (if needed)
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
book/feature-form-samples/
|
||||
├── template/ # Template definition
|
||||
│ └── plantilla-flujo.md
|
||||
├── feature-form/ # Larder data (constrained by template)
|
||||
│ ├── .larder
|
||||
│ ├── pet-owner/
|
||||
│ ├── veterinarian/
|
||||
│ └── backoffice/
|
||||
├── index.html # Larder browser
|
||||
└── detail.html # Detail renderer
|
||||
```
|
||||
|
||||
Routes for templated books:
|
||||
- `/book/{slug}/` → Landing page (template + larder links)
|
||||
- `/book/{slug}/template/` → Template definition
|
||||
- `/book/{slug}/larder/` → Larder browser
|
||||
|
||||
## Components
|
||||
|
||||
### Template (Patterns)
|
||||
|
||||
**Gherkin/BDD**
|
||||
- Status: Pending
|
||||
- Goal: .feature files, simple templates for non-tech
|
||||
|
||||
**Index Templates**
|
||||
- Status: Pending
|
||||
- Goal: HTML generators for indexes
|
||||
|
||||
### Vault (Data)
|
||||
|
||||
**drive/**
|
||||
- Status: Downloaded
|
||||
- Contents: Company drive (identity, marketing, ops, supply, finance, clients, pitches)
|
||||
|
||||
### Book (Composed Docs)
|
||||
|
||||
**drive-index**
|
||||
- Status: Priority
|
||||
- Goal: Two indexes (internal full, public privacy-conscious)
|
||||
|
||||
**flow-docs**
|
||||
- Status: Pending
|
||||
- Goal: User flow documentation (pet owner, vet, ops)
|
||||
|
||||
## Upward Report
|
||||
```
|
||||
ALBUM: Drive index priority. Template + vault → book composition defined.
|
||||
```
|
||||
|
||||
## Priority
|
||||
1. Drive index book (HTML from vault/drive)
|
||||
2. One gherkin template example
|
||||
3. Flow documentation structure
|
||||
|
||||
## Vault Contents (vault/drive)
|
||||
- 01.Identidad Amar Mascotas
|
||||
- 02. Marketing contenidos
|
||||
- 03. Marketing Growth
|
||||
- 05. ATC - Operaciones
|
||||
- 06. Supply (vetes-labo-clinicas)
|
||||
- 07. Finanzas y contabilidad
|
||||
- Clientes - ventas - devoluciones
|
||||
- Pitch Decks - Presentaciones
|
||||
|
||||
## Deployment
|
||||
|
||||
- **URL**: https://album.mcrn.ar
|
||||
- **Port**: 12002
|
||||
- **Service**: `systemctl status album`
|
||||
|
||||
FastAPI app serving documentation. Currently serves index.html at root, expandable for book browsing.
|
||||
|
||||
## Upstream (for main pawprint thread)
|
||||
|
||||
This is now a separate repo. See pawprint/UPSTREAM.md for merge notes.
|
||||
10
soleprint/atlas/__init__.py
Normal file
10
soleprint/atlas/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Atlas - Documentation System
|
||||
|
||||
Mapeando el recorrido / Mapping the journey
|
||||
|
||||
Components:
|
||||
- templates/ : Documentation patterns (Gherkin, BDD)
|
||||
- books/ : Composed documentation (Template + Depot)
|
||||
- depots/ : Data storage
|
||||
"""
|
||||
118
soleprint/atlas/book-template.html
Normal file
118
soleprint/atlas/book-template.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ book.title }} · Album</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html { background: #0a0a0a; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.6;
|
||||
color: #e5e5e5;
|
||||
background: #15803d;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.logo { width: 64px; height: 64px; color: white; }
|
||||
h1 { font-size: 2rem; margin: 0; color: white; }
|
||||
.tagline {
|
||||
color: rgba(255,255,255,0.85);
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.3);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
section {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 12px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: #15803d;
|
||||
}
|
||||
.composition {
|
||||
background: #f0fdf4;
|
||||
border: 2px solid #15803d;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.composition h3 { margin: 0 0 0.75rem 0; font-size: 1.1rem; color: #15803d; }
|
||||
.components {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
.component {
|
||||
background: white;
|
||||
border: 1px solid #bbf7d0;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: #1a1a1a;
|
||||
transition: all 0.15s;
|
||||
display: block;
|
||||
}
|
||||
.component:hover {
|
||||
border-color: #15803d;
|
||||
box-shadow: 0 4px 12px rgba(21, 128, 61, 0.15);
|
||||
}
|
||||
.component h4 { margin: 0 0 0.5rem 0; font-size: 1rem; color: #15803d; }
|
||||
.component p { margin: 0; font-size: 0.9rem; color: #666; }
|
||||
.component .arrow { float: right; color: #15803d; font-size: 1.2rem; }
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.3);
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
footer a { color: white; text-decoration: none; }
|
||||
footer a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<svg class="logo" viewBox="0 0 48 48" fill="currentColor">
|
||||
<path d="M4 8 C4 8 12 6 24 10 L24 42 C12 38 4 40 4 40 Z" opacity="0.3"/>
|
||||
<path d="M44 8 C44 8 36 6 24 10 L24 42 C36 38 44 40 44 40 Z" opacity="0.5"/>
|
||||
<path d="M4 8 C4 8 12 6 24 10 M44 8 C44 8 36 6 24 10" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M4 40 C4 40 12 38 24 42 M44 40 C44 40 36 38 24 42" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="24" y1="10" x2="24" y2="42" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<h1>{{ book.title }}</h1>
|
||||
</header>
|
||||
<p class="tagline">Templated book</p>
|
||||
|
||||
<section>
|
||||
<div class="composition">
|
||||
<h3>{{ book.title }}</h3>
|
||||
<div class="components">
|
||||
<a href="/book/{{ book.slug }}/template/" class="component">
|
||||
<span class="arrow">→</span>
|
||||
<h4>{{ book.template.title }}</h4>
|
||||
</a>
|
||||
<a href="/book/{{ book.slug }}/larder/" class="component">
|
||||
<span class="arrow">→</span>
|
||||
<h4>{{ book.larder.title }}</h4>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<a href="/">← Album</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
60
soleprint/atlas/books/feature-flow/CLAUDE.md
Normal file
60
soleprint/atlas/books/feature-flow/CLAUDE.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Feature Flow Book
|
||||
|
||||
## Purpose
|
||||
Presentation showing the feature standardization pipeline.
|
||||
|
||||
## The Pipeline
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ OPS TEMPLATES │ -> │ BDD/GHERKIN │ -> │ TESTS │
|
||||
│ │ │ │ │ │
|
||||
│ Non-technical │ │ .feature files │ │ Backend: │
|
||||
│ User flows │ │ Given/When/Then │ │ API contracts │
|
||||
│ From support │ │ Human readable │ │ Workflows │
|
||||
│ │ │ │ │ Frontend: │
|
||||
│ │ │ │ │ Page Objects │
|
||||
│ │ │ │ │ E2E specs │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## Files
|
||||
- `index-en.html` - English slide presentation (8 slides, arrow keys)
|
||||
- `index-es.html` - Spanish slide presentation (8 slides, arrow keys)
|
||||
|
||||
## Slides Structure
|
||||
1. Title
|
||||
2. Pipeline Overview (3 columns)
|
||||
3. Ops Templates
|
||||
4. BDD/Gherkin
|
||||
5. Gherkin File Organization (best practices)
|
||||
6. Backend Tests (amar_django_back structure)
|
||||
7. Frontend Tests (amar_frontend structure)
|
||||
8. Per-Feature Checklist
|
||||
|
||||
## Sources
|
||||
|
||||
### Ops Templates
|
||||
- `album/template/ops-flow/plantilla-flujo.md`
|
||||
- `def/work_plan/21-plantilla-flujos-usuario.md`
|
||||
|
||||
### BDD/Gherkin Examples
|
||||
- `def/work_plan/10-flow-turnero.md` (full gherkin + tests example)
|
||||
|
||||
### Test Structure References
|
||||
- `amar_django_back/tests/contracts/README.md`
|
||||
- `amar_frontend/tests/README.md`
|
||||
|
||||
## Editing
|
||||
Edit `index-en.html` or `index-es.html` directly.
|
||||
Slides are `<section>` elements. Arrow keys to navigate.
|
||||
|
||||
## Flow Checklist (per feature)
|
||||
|
||||
- [ ] Ops template filled by support team
|
||||
- [ ] Convert to .feature file (Gherkin spec)
|
||||
- [ ] Backend: API contract tests per endpoint
|
||||
- [ ] Backend: Workflow test (composition)
|
||||
- [ ] Frontend: Page Object (if new page)
|
||||
- [ ] Frontend: E2E spec (Playwright)
|
||||
- [ ] Wire to CI
|
||||
392
soleprint/atlas/books/feature-flow/index-en.html
Normal file
392
soleprint/atlas/books/feature-flow/index-en.html
Normal file
@@ -0,0 +1,392 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Feature Flow - Standardization Pipeline</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.slide { display: none; min-height: 100vh; padding: 3rem; }
|
||||
.slide.active { display: flex; flex-direction: column; }
|
||||
.nav { position: fixed; bottom: 2rem; right: 2rem; display: flex; gap: 0.5rem; z-index: 100; }
|
||||
.nav button { background: #334155; border: none; color: #e2e8f0; padding: 0.75rem 1.25rem; border-radius: 6px; cursor: pointer; font-size: 1rem; }
|
||||
.nav button:hover { background: #475569; }
|
||||
.nav .counter { background: transparent; padding: 0.75rem 1rem; color: #64748b; }
|
||||
.slide-title { justify-content: center; align-items: center; text-align: center; }
|
||||
.slide-title h1 { font-size: 3.5rem; font-weight: 700; margin-bottom: 1rem; background: linear-gradient(135deg, #6366f1, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.slide-title p { font-size: 1.5rem; color: #94a3b8; }
|
||||
.slide-title .subtitle { margin-top: 3rem; font-size: 1rem; color: #64748b; }
|
||||
.pipeline { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; margin: 2rem 0; }
|
||||
.pipeline-step { background: #1e293b; border-radius: 12px; padding: 2rem; border-left: 4px solid; }
|
||||
.pipeline-step.ops { border-color: #22c55e; }
|
||||
.pipeline-step.bdd { border-color: #6366f1; }
|
||||
.pipeline-step.tests { border-color: #f59e0b; }
|
||||
.pipeline-step h3 { font-size: 1.25rem; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.pipeline-step .num { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.85rem; font-weight: 700; }
|
||||
.pipeline-step.ops .num { background: #22c55e; color: #0f172a; }
|
||||
.pipeline-step.bdd .num { background: #6366f1; color: white; }
|
||||
.pipeline-step.tests .num { background: #f59e0b; color: #0f172a; }
|
||||
.pipeline-step ul { list-style: none; color: #94a3b8; font-size: 0.95rem; }
|
||||
.pipeline-step li { padding: 0.4rem 0; padding-left: 1rem; border-left: 2px solid #334155; margin-bottom: 0.25rem; }
|
||||
.slide h2 { font-size: 2rem; margin-bottom: 2rem; display: flex; align-items: center; gap: 1rem; }
|
||||
.slide h2 .badge { padding: 0.25rem 0.75rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||
.slide h2 .badge.ops { background: #22c55e; color: #0f172a; }
|
||||
.slide h2 .badge.bdd { background: #6366f1; color: white; }
|
||||
.slide h2 .badge.tests { background: #f59e0b; color: #0f172a; }
|
||||
pre { background: #1e293b; padding: 1.5rem; border-radius: 8px; overflow-x: auto; font-size: 0.8rem; line-height: 1.5; margin: 1rem 0; }
|
||||
.keyword { color: #c084fc; }
|
||||
.string { color: #4ade80; }
|
||||
.comment { color: #64748b; }
|
||||
.decorator { color: #f59e0b; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; flex: 1; }
|
||||
.col { background: #1e293b; border-radius: 8px; padding: 1.5rem; }
|
||||
.col h4 { font-size: 0.85rem; color: #94a3b8; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.checklist { list-style: none; font-size: 1.1rem; }
|
||||
.checklist li { padding: 0.75rem 0; display: flex; align-items: center; gap: 0.75rem; border-bottom: 1px solid #334155; }
|
||||
.checklist .check { width: 20px; height: 20px; border: 2px solid #475569; border-radius: 4px; }
|
||||
.flow-list { list-style: none; }
|
||||
.flow-list li { padding: 0.4rem 0; color: #94a3b8; font-size: 0.9rem; }
|
||||
.flow-list li strong { color: #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="slide slide-title active" data-slide="0">
|
||||
<h1>Feature Flow</h1>
|
||||
<p>Standardization Pipeline</p>
|
||||
<div class="subtitle">Ops Templates → BDD/Gherkin → Backend + Frontend Tests</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="1">
|
||||
<h2>Pipeline Overview</h2>
|
||||
<div class="pipeline">
|
||||
<div class="pipeline-step ops">
|
||||
<h3><span class="num">1</span> Ops Templates</h3>
|
||||
<ul>
|
||||
<li>Non-technical language</li>
|
||||
<li>User perspective flows</li>
|
||||
<li>From support/ops team</li>
|
||||
<li>Captures edge cases</li>
|
||||
<li>Documents known problems</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pipeline-step bdd">
|
||||
<h3><span class="num">2</span> BDD/Gherkin</h3>
|
||||
<ul>
|
||||
<li>.feature files</li>
|
||||
<li>Given/When/Then syntax</li>
|
||||
<li>Human readable specs</li>
|
||||
<li>Single source of truth</li>
|
||||
<li>Maps to both test types</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pipeline-step tests">
|
||||
<h3><span class="num">3</span> Tests</h3>
|
||||
<ul>
|
||||
<li><strong>Backend:</strong> API contracts</li>
|
||||
<li><strong>Backend:</strong> Workflows (compositions)</li>
|
||||
<li><strong>Frontend:</strong> Page Objects</li>
|
||||
<li><strong>Frontend:</strong> E2E specs (Playwright)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="2">
|
||||
<h2><span class="badge ops">1</span> Ops Templates</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Template Structure</h4>
|
||||
<pre>
|
||||
<span class="keyword">### [Flow Name]</span>
|
||||
|
||||
<span class="comment">User type:</span> Pet Owner / Vet / Admin
|
||||
<span class="comment">Entry point:</span> Page/button/link
|
||||
<span class="comment">Goal:</span> One sentence
|
||||
|
||||
<span class="keyword">Steps:</span>
|
||||
1. First action
|
||||
2. Second action
|
||||
3. ...
|
||||
|
||||
<span class="keyword">Expected result:</span>
|
||||
- What should happen
|
||||
|
||||
<span class="keyword">Common problems:</span>
|
||||
- Problem 1
|
||||
|
||||
<span class="keyword">Edge cases:</span>
|
||||
- Special case 1
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Source</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>Template:</strong> album/template/ops-flow/</li>
|
||||
<li><strong>Reference:</strong> def/work_plan/21-plantilla</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1.5rem;">Who Fills This</h4>
|
||||
<ul class="flow-list">
|
||||
<li>Support team (daily user contact)</li>
|
||||
<li>Ops team (knows workarounds)</li>
|
||||
<li>Product (requirements)</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1.5rem;">Output</h4>
|
||||
<ul class="flow-list">
|
||||
<li>One .md per flow</li>
|
||||
<li>Organized by user type</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="3">
|
||||
<h2><span class="badge bdd">2</span> BDD/Gherkin</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>.feature File</h4>
|
||||
<pre>
|
||||
<span class="keyword">Feature:</span> Turnero - Book appointment
|
||||
|
||||
<span class="keyword">Scenario:</span> Book vaccination for cat
|
||||
<span class="decorator">Given</span> I am on the turnero page
|
||||
<span class="decorator">When</span> I enter address <span class="string">"Av Santa Fe 1234"</span>
|
||||
<span class="decorator">And</span> I click <span class="string">"Next"</span>
|
||||
<span class="decorator">Then</span> a guest user should be created
|
||||
|
||||
<span class="decorator">When</span> I add pet <span class="string">"Koshka"</span> type <span class="string">"Cat"</span>
|
||||
<span class="decorator">And</span> I select <span class="string">"Vaccination"</span>
|
||||
<span class="decorator">Then</span> <span class="string">"Clinical consult"</span> is auto-added
|
||||
|
||||
<span class="keyword">Scenario:</span> Services filtered by pet type
|
||||
<span class="decorator">Given</span> I added a cat
|
||||
<span class="decorator">Then</span> I see cat vaccines
|
||||
<span class="decorator">And</span> I dont see dog vaccines
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Keywords</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>Feature</strong> = one capability</li>
|
||||
<li><strong>Scenario</strong> = one behavior</li>
|
||||
<li><strong>Given</strong> = precondition</li>
|
||||
<li><strong>When</strong> = action</li>
|
||||
<li><strong>Then</strong> = expected result</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1.5rem;">Reference</h4>
|
||||
<ul class="flow-list">
|
||||
<li>def/work_plan/10-flow-turnero.md</li>
|
||||
<li>Full example with Gherkin, API tests, Page Objects</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="4">
|
||||
<h2><span class="badge bdd">2b</span> Gherkin File Organization</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Correct: One Feature = One File</h4>
|
||||
<pre>
|
||||
<span class="keyword">features/</span>
|
||||
├── pet-owner/
|
||||
│ ├── registro.feature <span class="comment"># 6-8 scenarios</span>
|
||||
│ ├── reservar-turno.feature <span class="comment"># 10-15 scenarios</span>
|
||||
│ ├── gestion-mascotas.feature
|
||||
│ └── pago.feature
|
||||
├── veterinarian/
|
||||
│ └── ...
|
||||
└── backoffice/
|
||||
└── ...
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Anti-pattern: One Scenario = One File</h4>
|
||||
<pre style="border-left: 3px solid #ef4444;">
|
||||
<span class="comment"># DON'T do this</span>
|
||||
features/pet-owner/registro/
|
||||
├── registro-exitoso.feature
|
||||
├── registro-email-invalido.feature
|
||||
├── registro-password-corto.feature
|
||||
└── <span class="comment">... (dozens of tiny files)</span>
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Why Multiple Scenarios per File</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>Feature = Capability</strong> - one file describes one capability with all its behaviors</li>
|
||||
<li><strong>Context stays together</strong> - Background, Rules share context</li>
|
||||
<li><strong>Tooling expects it</strong> - test runners, reports, IDE navigation</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1.5rem;">When to Split</h4>
|
||||
<pre>
|
||||
<span class="comment"># Scenarios per file:</span>
|
||||
5-20 <span class="string">Normal, keep as is</span>
|
||||
20-40 <span class="string">Consider splitting</span>
|
||||
40+ <span class="string">Definitely split</span>
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Folder Depth</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>Good:</strong> 1-2 levels max</li>
|
||||
<li><strong>Avoid:</strong> deep nesting</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="5">
|
||||
<h2><span class="badge tests">3a</span> Backend Tests</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Structure (amar_django_back)</h4>
|
||||
<pre>
|
||||
tests/contracts/
|
||||
├── base.py <span class="comment"># mode switcher</span>
|
||||
├── endpoints.py <span class="comment"># API paths (single source)</span>
|
||||
├── helpers.py <span class="comment"># test data</span>
|
||||
│
|
||||
├── mascotas/ <span class="comment"># app tests</span>
|
||||
│ ├── test_pet_owners.py
|
||||
│ ├── test_pets.py
|
||||
│ └── test_coverage.py
|
||||
├── productos/
|
||||
│ ├── test_services.py
|
||||
│ └── test_cart.py
|
||||
├── solicitudes/
|
||||
│ └── test_service_requests.py
|
||||
│
|
||||
└── <span class="keyword">workflows/</span> <span class="comment"># compositions</span>
|
||||
└── test_turnero_general.py
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Two Test Modes</h4>
|
||||
<pre>
|
||||
<span class="comment"># Fast (Django test client)</span>
|
||||
pytest tests/contracts/
|
||||
|
||||
<span class="comment"># Live (real HTTP)</span>
|
||||
CONTRACT_TEST_MODE=live pytest
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Workflow = Composition</h4>
|
||||
<pre>
|
||||
<span class="comment"># Calls endpoints in sequence:</span>
|
||||
1. Check coverage
|
||||
2. Create pet owner
|
||||
3. Create pet
|
||||
4. Get services
|
||||
5. Create request
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Key Files</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>endpoints.py</strong> - change paths here only</li>
|
||||
<li><strong>helpers.py</strong> - sample data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="6">
|
||||
<h2><span class="badge tests">3b</span> Frontend Tests</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Structure (amar_frontend)</h4>
|
||||
<pre>
|
||||
tests/e2e/
|
||||
├── <span class="keyword">pages/</span> <span class="comment"># Page Objects</span>
|
||||
│ ├── BasePage.ts
|
||||
│ ├── LoginPage.ts
|
||||
│ └── index.ts
|
||||
│
|
||||
└── login.spec.ts <span class="comment"># E2E test</span>
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Page Object Pattern</h4>
|
||||
<pre>
|
||||
<span class="keyword">export class</span> LoginPage <span class="keyword">extends</span> BasePage {
|
||||
<span class="keyword">get</span> emailInput() {
|
||||
<span class="keyword">return</span> this.page.getByLabel(<span class="string">'Email'</span>);
|
||||
}
|
||||
|
||||
<span class="keyword">async</span> login(email, password) {
|
||||
<span class="keyword">await</span> this.emailInput.fill(email);
|
||||
<span class="keyword">await</span> this.passwordInput.fill(password);
|
||||
<span class="keyword">await</span> this.submitButton.click();
|
||||
}
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Running Tests</h4>
|
||||
<pre>
|
||||
<span class="comment"># All tests</span>
|
||||
npx playwright test
|
||||
|
||||
<span class="comment"># With UI</span>
|
||||
npx playwright test --ui
|
||||
|
||||
<span class="comment"># Specific file</span>
|
||||
npx playwright test login.spec.ts
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Locator Priority</h4>
|
||||
<ul class="flow-list">
|
||||
<li>1. getByRole() - buttons, links</li>
|
||||
<li>2. getByLabel() - form fields</li>
|
||||
<li>3. getByText() - visible text</li>
|
||||
<li>4. getByTestId() - data-testid</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1rem;">Avoid</h4>
|
||||
<ul class="flow-list">
|
||||
<li>CSS class selectors</li>
|
||||
<li>Complex XPath</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="7">
|
||||
<h2>Per-Feature Checklist</h2>
|
||||
<ul class="checklist">
|
||||
<li><span class="check"></span> Ops template filled (support team)</li>
|
||||
<li><span class="check"></span> Convert to .feature file (Gherkin spec)</li>
|
||||
<li><span class="check"></span> Backend: API contract tests per endpoint</li>
|
||||
<li><span class="check"></span> Backend: Workflow test (composition)</li>
|
||||
<li><span class="check"></span> Frontend: Page Object (if new page)</li>
|
||||
<li><span class="check"></span> Frontend: E2E spec (Playwright)</li>
|
||||
<li><span class="check"></span> Wire to CI</li>
|
||||
</ul>
|
||||
<div style="margin-top: 2rem; color: #64748b; font-size: 0.9rem;">
|
||||
<p><strong>Full example:</strong> def/work_plan/10-flow-turnero.md</p>
|
||||
<p><strong>Backend README:</strong> amar_django_back/tests/contracts/README.md</p>
|
||||
<p><strong>Frontend README:</strong> amar_frontend/tests/README.md</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="nav">
|
||||
<button onclick="prevSlide()">←</button>
|
||||
<span class="counter"><span id="current">1</span>/<span id="total">8</span></span>
|
||||
<button onclick="nextSlide()">→</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let current = 0;
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
const total = slides.length;
|
||||
document.getElementById('total').textContent = total;
|
||||
function showSlide(n) {
|
||||
slides.forEach(s => s.classList.remove('active'));
|
||||
current = (n + total) % total;
|
||||
slides[current].classList.add('active');
|
||||
document.getElementById('current').textContent = current + 1;
|
||||
}
|
||||
function nextSlide() { showSlide(current + 1); }
|
||||
function prevSlide() { showSlide(current - 1); }
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') nextSlide();
|
||||
if (e.key === 'ArrowLeft') prevSlide();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
470
soleprint/atlas/books/feature-flow/index-es.html
Normal file
470
soleprint/atlas/books/feature-flow/index-es.html
Normal file
@@ -0,0 +1,470 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Feature Flow - Pipeline de Estandarizacion</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.slide {
|
||||
display: none;
|
||||
min-height: 100vh;
|
||||
padding: 3rem;
|
||||
}
|
||||
.slide.active { display: flex; flex-direction: column; }
|
||||
.nav {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
z-index: 100;
|
||||
}
|
||||
.nav button {
|
||||
background: #334155;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.nav button:hover { background: #475569; }
|
||||
.nav .counter {
|
||||
background: transparent;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.slide-title {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.slide-title h1 {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.slide-title p { font-size: 1.5rem; color: #94a3b8; }
|
||||
.slide-title .subtitle { margin-top: 3rem; font-size: 1rem; color: #64748b; }
|
||||
.pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.pipeline-step {
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
.pipeline-step.ops { border-color: #22c55e; }
|
||||
.pipeline-step.bdd { border-color: #6366f1; }
|
||||
.pipeline-step.tests { border-color: #f59e0b; }
|
||||
.pipeline-step h3 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pipeline-step .num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pipeline-step.ops .num { background: #22c55e; color: #0f172a; }
|
||||
.pipeline-step.bdd .num { background: #6366f1; color: white; }
|
||||
.pipeline-step.tests .num { background: #f59e0b; color: #0f172a; }
|
||||
.pipeline-step ul { list-style: none; color: #94a3b8; font-size: 0.95rem; }
|
||||
.pipeline-step li { padding: 0.4rem 0; padding-left: 1rem; border-left: 2px solid #334155; margin-bottom: 0.25rem; }
|
||||
.slide h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.slide h2 .badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.slide h2 .badge.ops { background: #22c55e; color: #0f172a; }
|
||||
.slide h2 .badge.bdd { background: #6366f1; color: white; }
|
||||
.slide h2 .badge.tests { background: #f59e0b; color: #0f172a; }
|
||||
pre {
|
||||
background: #1e293b;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.keyword { color: #c084fc; }
|
||||
.string { color: #4ade80; }
|
||||
.comment { color: #64748b; }
|
||||
.decorator { color: #f59e0b; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; flex: 1; }
|
||||
.col { background: #1e293b; border-radius: 8px; padding: 1.5rem; }
|
||||
.col h4 { font-size: 0.9rem; color: #94a3b8; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.checklist { list-style: none; font-size: 1.1rem; }
|
||||
.checklist li { padding: 0.75rem 0; display: flex; align-items: center; gap: 0.75rem; border-bottom: 1px solid #334155; }
|
||||
.checklist .check { width: 20px; height: 20px; border: 2px solid #475569; border-radius: 4px; }
|
||||
.flow-list { list-style: none; }
|
||||
.flow-list li { padding: 0.5rem 0; color: #94a3b8; }
|
||||
.flow-list li strong { color: #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="slide slide-title active" data-slide="0">
|
||||
<h1>Feature Flow</h1>
|
||||
<p>Pipeline de Estandarizacion</p>
|
||||
<div class="subtitle">Templates Ops → BDD/Gherkin → Tests Backend + Frontend</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="1">
|
||||
<h2>Vision General del Pipeline</h2>
|
||||
<div class="pipeline">
|
||||
<div class="pipeline-step ops">
|
||||
<h3><span class="num">1</span> Templates Ops</h3>
|
||||
<ul>
|
||||
<li>Lenguaje no tecnico</li>
|
||||
<li>Flujos desde el usuario</li>
|
||||
<li>Del equipo de soporte/ops</li>
|
||||
<li>Captura casos borde</li>
|
||||
<li>Documenta problemas conocidos</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pipeline-step bdd">
|
||||
<h3><span class="num">2</span> BDD/Gherkin</h3>
|
||||
<ul>
|
||||
<li>Archivos .feature</li>
|
||||
<li>Sintaxis Given/When/Then</li>
|
||||
<li>Specs legibles</li>
|
||||
<li>Fuente unica de verdad</li>
|
||||
<li>Mapea a ambos tipos de test</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pipeline-step tests">
|
||||
<h3><span class="num">3</span> Tests</h3>
|
||||
<ul>
|
||||
<li><strong>Backend:</strong> Contratos API</li>
|
||||
<li><strong>Backend:</strong> Workflows (composiciones)</li>
|
||||
<li><strong>Frontend:</strong> Page Objects</li>
|
||||
<li><strong>Frontend:</strong> E2E specs (Playwright)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="2">
|
||||
<h2><span class="badge ops">1</span> Templates Ops</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Estructura de la Plantilla</h4>
|
||||
<pre>
|
||||
<span class="keyword">### [Nombre del Flujo]</span>
|
||||
|
||||
<span class="comment">Tipo de usuario:</span> Dueno / Vet / Admin
|
||||
<span class="comment">Donde empieza:</span> Pagina/boton/link
|
||||
<span class="comment">Objetivo:</span> Una oracion
|
||||
|
||||
<span class="keyword">Pasos:</span>
|
||||
1. Primera accion
|
||||
2. Segunda accion
|
||||
3. ...
|
||||
|
||||
<span class="keyword">Que deberia pasar:</span>
|
||||
- Resultado esperado
|
||||
|
||||
<span class="keyword">Problemas comunes:</span>
|
||||
- Problema 1
|
||||
|
||||
<span class="keyword">Casos especiales:</span>
|
||||
- Caso especial 1
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Fuente</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>Plantilla:</strong> album/template/ops-flow/</li>
|
||||
<li><strong>Referencia:</strong> def/work_plan/21-plantilla</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1.5rem;">Quien Completa Esto</h4>
|
||||
<ul class="flow-list">
|
||||
<li>Equipo de soporte (contacto diario)</li>
|
||||
<li>Equipo de ops (conoce workarounds)</li>
|
||||
<li>Producto (requerimientos)</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1.5rem;">Output</h4>
|
||||
<ul class="flow-list">
|
||||
<li>Un .md por flujo</li>
|
||||
<li>Organizado por tipo de usuario</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="3">
|
||||
<h2><span class="badge bdd">2</span> BDD/Gherkin</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Archivo .feature</h4>
|
||||
<pre>
|
||||
<span class="keyword">Feature:</span> Turnero - Reservar turno
|
||||
|
||||
<span class="keyword">Scenario:</span> Reservar vacuna para gato
|
||||
<span class="decorator">Given</span> estoy en la pagina del turnero
|
||||
<span class="decorator">When</span> ingreso direccion <span class="string">"Av Santa Fe 1234"</span>
|
||||
<span class="decorator">And</span> hago click en <span class="string">"Siguiente"</span>
|
||||
<span class="decorator">Then</span> se crea un usuario invitado
|
||||
|
||||
<span class="decorator">When</span> agrego mascota <span class="string">"Koshka"</span> tipo <span class="string">"Gato"</span>
|
||||
<span class="decorator">And</span> selecciono <span class="string">"Vacunacion"</span>
|
||||
<span class="decorator">Then</span> <span class="string">"Consulta clinica"</span> se agrega auto
|
||||
|
||||
<span class="keyword">Scenario:</span> Servicios filtrados por tipo
|
||||
<span class="decorator">Given</span> agregue un gato
|
||||
<span class="decorator">Then</span> veo vacunas felinas
|
||||
<span class="decorator">And</span> no veo vacunas caninas
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Palabras Clave</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>Feature</strong> = una funcionalidad</li>
|
||||
<li><strong>Scenario</strong> = un comportamiento</li>
|
||||
<li><strong>Given</strong> = precondicion</li>
|
||||
<li><strong>When</strong> = accion</li>
|
||||
<li><strong>Then</strong> = resultado esperado</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1.5rem;">Referencia</h4>
|
||||
<ul class="flow-list">
|
||||
<li>def/work_plan/10-flow-turnero.md</li>
|
||||
<li>Ejemplo completo con Gherkin, tests API, Page Objects</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="4">
|
||||
<h2><span class="badge bdd">2b</span> Organizacion de Archivos Gherkin</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Correcto: Una Feature = Un Archivo</h4>
|
||||
<pre>
|
||||
<span class="keyword">features/</span>
|
||||
├── pet-owner/
|
||||
│ ├── registro.feature <span class="comment"># 6-8 escenarios</span>
|
||||
│ ├── reservar-turno.feature <span class="comment"># 10-15 escenarios</span>
|
||||
│ ├── gestion-mascotas.feature
|
||||
│ └── pago.feature
|
||||
├── veterinarian/
|
||||
│ └── ...
|
||||
└── backoffice/
|
||||
└── ...
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Anti-patron: Un Escenario = Un Archivo</h4>
|
||||
<pre style="border-left: 3px solid #ef4444;">
|
||||
<span class="comment"># NO hacer esto</span>
|
||||
features/pet-owner/registro/
|
||||
├── registro-exitoso.feature
|
||||
├── registro-email-invalido.feature
|
||||
├── registro-password-corto.feature
|
||||
└── <span class="comment">... (docenas de archivos pequeños)</span>
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Por Que Multiples Escenarios por Archivo</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>Feature = Capacidad</strong> - un archivo describe una capacidad con todos sus comportamientos</li>
|
||||
<li><strong>Contexto junto</strong> - Background, Rules comparten contexto</li>
|
||||
<li><strong>Tooling lo espera</strong> - test runners, reportes, navegacion IDE</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1.5rem;">Cuando Dividir</h4>
|
||||
<pre>
|
||||
<span class="comment"># Escenarios por archivo:</span>
|
||||
5-20 <span class="string">Normal, mantener</span>
|
||||
20-40 <span class="string">Considerar dividir</span>
|
||||
40+ <span class="string">Definitivamente dividir</span>
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Profundidad de Carpetas</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>Bien:</strong> 1-2 niveles max</li>
|
||||
<li><strong>Evitar:</strong> anidamiento profundo</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="5">
|
||||
<h2><span class="badge tests">3a</span> Tests Backend</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Estructura (amar_django_back)</h4>
|
||||
<pre>
|
||||
tests/contracts/
|
||||
├── base.py <span class="comment"># switcher de modo</span>
|
||||
├── endpoints.py <span class="comment"># paths API (fuente unica)</span>
|
||||
├── helpers.py <span class="comment"># datos de prueba</span>
|
||||
│
|
||||
├── mascotas/ <span class="comment"># tests por app</span>
|
||||
│ ├── test_pet_owners.py
|
||||
│ ├── test_pets.py
|
||||
│ └── test_coverage.py
|
||||
├── productos/
|
||||
│ ├── test_services.py
|
||||
│ └── test_cart.py
|
||||
├── solicitudes/
|
||||
│ └── test_service_requests.py
|
||||
│
|
||||
└── <span class="keyword">workflows/</span> <span class="comment"># composiciones</span>
|
||||
└── test_turnero_general.py
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Dos Modos de Test</h4>
|
||||
<pre>
|
||||
<span class="comment"># Rapido (Django test client)</span>
|
||||
pytest tests/contracts/
|
||||
|
||||
<span class="comment"># Live (HTTP real)</span>
|
||||
CONTRACT_TEST_MODE=live pytest
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Workflow = Composicion</h4>
|
||||
<pre>
|
||||
<span class="comment"># Llama endpoints en secuencia:</span>
|
||||
1. Check cobertura
|
||||
2. Crear pet owner
|
||||
3. Crear mascota
|
||||
4. Obtener servicios
|
||||
5. Crear solicitud
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Archivos Clave</h4>
|
||||
<ul class="flow-list">
|
||||
<li><strong>endpoints.py</strong> - cambiar paths solo aca</li>
|
||||
<li><strong>helpers.py</strong> - datos de ejemplo</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="6">
|
||||
<h2><span class="badge tests">3b</span> Tests Frontend</h2>
|
||||
<div class="two-col">
|
||||
<div class="col">
|
||||
<h4>Estructura (amar_frontend)</h4>
|
||||
<pre>
|
||||
tests/e2e/
|
||||
├── <span class="keyword">pages/</span> <span class="comment"># Page Objects</span>
|
||||
│ ├── BasePage.ts
|
||||
│ ├── LoginPage.ts
|
||||
│ └── index.ts
|
||||
│
|
||||
└── login.spec.ts <span class="comment"># test E2E</span>
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Patron Page Object</h4>
|
||||
<pre>
|
||||
<span class="keyword">export class</span> LoginPage <span class="keyword">extends</span> BasePage {
|
||||
<span class="keyword">get</span> emailInput() {
|
||||
<span class="keyword">return</span> this.page.getByLabel(<span class="string">'Email'</span>);
|
||||
}
|
||||
|
||||
<span class="keyword">async</span> login(email, password) {
|
||||
<span class="keyword">await</span> this.emailInput.fill(email);
|
||||
<span class="keyword">await</span> this.passwordInput.fill(password);
|
||||
<span class="keyword">await</span> this.submitButton.click();
|
||||
}
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>Ejecutar Tests</h4>
|
||||
<pre>
|
||||
<span class="comment"># Todos los tests</span>
|
||||
npx playwright test
|
||||
|
||||
<span class="comment"># Con UI</span>
|
||||
npx playwright test --ui
|
||||
|
||||
<span class="comment"># Archivo especifico</span>
|
||||
npx playwright test login.spec.ts
|
||||
</pre>
|
||||
<h4 style="margin-top: 1rem;">Prioridad de Locators</h4>
|
||||
<ul class="flow-list">
|
||||
<li>1. getByRole() - botones, links</li>
|
||||
<li>2. getByLabel() - campos de form</li>
|
||||
<li>3. getByText() - texto visible</li>
|
||||
<li>4. getByTestId() - data-testid</li>
|
||||
</ul>
|
||||
<h4 style="margin-top: 1rem;">Evitar</h4>
|
||||
<ul class="flow-list">
|
||||
<li>Selectores de clases CSS</li>
|
||||
<li>XPath complejos</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="slide" data-slide="7">
|
||||
<h2>Checklist por Feature</h2>
|
||||
<ul class="checklist">
|
||||
<li><span class="check"></span> Template ops completado (equipo soporte)</li>
|
||||
<li><span class="check"></span> Convertir a archivo .feature (spec Gherkin)</li>
|
||||
<li><span class="check"></span> Backend: Tests de contrato API por endpoint</li>
|
||||
<li><span class="check"></span> Backend: Test workflow (composicion)</li>
|
||||
<li><span class="check"></span> Frontend: Page Object (si es pagina nueva)</li>
|
||||
<li><span class="check"></span> Frontend: E2E spec (Playwright)</li>
|
||||
<li><span class="check"></span> Conectar a CI</li>
|
||||
</ul>
|
||||
<div style="margin-top: 2rem; color: #64748b; font-size: 0.9rem;">
|
||||
<p><strong>Ejemplo completo:</strong> def/work_plan/10-flow-turnero.md</p>
|
||||
<p><strong>README Backend:</strong> amar_django_back/tests/contracts/README.md</p>
|
||||
<p><strong>README Frontend:</strong> amar_frontend/tests/README.md</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="nav">
|
||||
<button onclick="prevSlide()">←</button>
|
||||
<span class="counter"><span id="current">1</span>/<span id="total">8</span></span>
|
||||
<button onclick="nextSlide()">→</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let current = 0;
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
const total = slides.length;
|
||||
document.getElementById('total').textContent = total;
|
||||
function showSlide(n) {
|
||||
slides.forEach(s => s.classList.remove('active'));
|
||||
current = (n + total) % total;
|
||||
slides[current].classList.add('active');
|
||||
document.getElementById('current').textContent = current + 1;
|
||||
}
|
||||
function nextSlide() { showSlide(current + 1); }
|
||||
function prevSlide() { showSlide(current - 1); }
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') nextSlide();
|
||||
if (e.key === 'ArrowLeft') prevSlide();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
269
soleprint/atlas/index.html
Normal file
269
soleprint/atlas/index.html
Normal file
@@ -0,0 +1,269 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ system.title or 'Atlas' }} · 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 48 48' fill='%2315803d'%3E%3Cpath d='M4 8 C4 8 12 6 24 10 L24 42 C12 38 4 40 4 40 Z' opacity='0.3'/%3E%3Cpath d='M44 8 C44 8 36 6 24 10 L24 42 C36 38 44 40 44 40 Z' opacity='0.5'/%3E%3Cpath d='M4 8 C4 8 12 6 24 10 M44 8 C44 8 36 6 24 10' fill='none' stroke='%2315803d' stroke-width='2'/%3E%3Cpath d='M4 40 C4 40 12 38 24 42 M44 40 C44 40 36 38 24 42' fill='none' stroke='%2315803d' stroke-width='2'/%3E%3Cline x1='24' y1='10' x2='24' y2='42' stroke='%2315803d' stroke-width='2'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.6;
|
||||
color: #e5e5e5;
|
||||
background: #163528;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: white;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
.tagline {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
section {
|
||||
background: #000000;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: #86efac;
|
||||
}
|
||||
.composition {
|
||||
background: #000000;
|
||||
border: 2px solid #3e915d;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.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(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.component {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #6b665e;
|
||||
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;
|
||||
}
|
||||
.books {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.books li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #6b665e;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.books li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.books a {
|
||||
color: #86efac;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.books a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.status {
|
||||
display: none;
|
||||
}
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
footer a {
|
||||
color: white;
|
||||
}
|
||||
footer .disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header style="position: relative">
|
||||
<!-- Open book -->
|
||||
<svg class="logo" viewBox="0 0 48 48" fill="currentColor">
|
||||
<path
|
||||
d="M4 8 C4 8 12 6 24 10 L24 42 C12 38 4 40 4 40 Z"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<path
|
||||
d="M44 8 C44 8 36 6 24 10 L24 42 C36 38 44 40 44 40 Z"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M4 8 C4 8 12 6 24 10 M44 8 C44 8 36 6 24 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M4 40 C4 40 12 38 24 42 M44 40 C44 40 36 38 24 42"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<line
|
||||
x1="24"
|
||||
y1="10"
|
||||
x2="24"
|
||||
y2="42"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<h1>{{ system.title or 'Atlas' }}</h1>
|
||||
{% if soleprint_url %}<a
|
||||
href="{{ soleprint_url }}"
|
||||
style="
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.85rem;
|
||||
"
|
||||
>← Soleprint</a
|
||||
>{% endif %}
|
||||
</header>
|
||||
<p class="tagline">
|
||||
{{ system.tagline or 'Actionable documentation' }}
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<div class="composition">
|
||||
<h3>Books</h3>
|
||||
<div class="components">
|
||||
<div class="component"><h4>Template</h4></div>
|
||||
<div class="component"><h4>Depot</h4></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Books</h2>
|
||||
<ul class="books">
|
||||
{% for book in books %}
|
||||
<li>
|
||||
<a href="/book/{{ book.slug }}/">{{ book.title }}</a>
|
||||
<span
|
||||
class="status {% if book.status == 'ready' %}ready{% elif book.status == 'building' %}building{% endif %}"
|
||||
>{{ book.status | capitalize }}</span
|
||||
>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<span style="color: #86efac; font-weight: 500">--</span
|
||||
><span class="status">Pending</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Templates</h2>
|
||||
<ul class="books">
|
||||
{% for template in templates %}
|
||||
<li>
|
||||
<a href="/template/{{ template.slug }}/"
|
||||
>{{ template.title }}</a
|
||||
><span
|
||||
class="status {% if template.status == 'ready' %}ready{% elif template.status == 'building' %}building{% endif %}"
|
||||
>{{ template.status | capitalize }}</span
|
||||
>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<span style="color: #86efac; font-weight: 500">--</span
|
||||
><span class="status">Pending</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Depots</h2>
|
||||
<ul class="books">
|
||||
{% for depot in depots %}
|
||||
<li>
|
||||
<a href="/depot/{{ depot.slug }}/">{{ depot.title }}</a
|
||||
><span
|
||||
class="status {% if depot.status == 'ready' %}ready{% elif depot.status == 'building' %}building{% endif %}"
|
||||
>{{ depot.status | capitalize }}</span
|
||||
>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<span style="color: #86efac; font-weight: 500">--</span
|
||||
><span class="status">Pending</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
|
||||
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
369
soleprint/atlas/main.py
Normal file
369
soleprint/atlas/main.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Album - Documentation system.
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
app = FastAPI(title="Album", version="0.1.0")
|
||||
|
||||
BASE_DIR = Path(__file__).parent.resolve()
|
||||
BOOK_DIR = BASE_DIR / "book"
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
# Create static directory if it doesn't exist
|
||||
STATIC_DIR.mkdir(exist_ok=True)
|
||||
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR))
|
||||
|
||||
# Serve static files
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Pawprint URL for data fetching
|
||||
PAWPRINT_URL = os.getenv("PAWPRINT_URL", "http://localhost:12000")
|
||||
|
||||
|
||||
def get_data():
|
||||
"""Fetch data from pawprint hub."""
|
||||
try:
|
||||
resp = httpx.get(f"{PAWPRINT_URL}/api/data/album", timeout=5.0)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch data from pawprint: {e}")
|
||||
return {"templates": [], "larders": [], "books": []}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "album"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index(request: Request):
|
||||
data = get_data()
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"pawprint_url": os.getenv("PAWPRINT_EXTERNAL_URL", PAWPRINT_URL),
|
||||
**data,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/data")
|
||||
def api_data():
|
||||
"""API endpoint for frontend data (proxied from pawprint)."""
|
||||
return get_data()
|
||||
|
||||
|
||||
# --- Book: Feature Flow (HTML presentations) ---
|
||||
@app.get("/book/feature-flow/", response_class=HTMLResponse)
|
||||
@app.get("/book/feature-flow", response_class=HTMLResponse)
|
||||
def feature_flow_index():
|
||||
"""Redirect to English presentation by default"""
|
||||
return """<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>Feature Flow</title>
|
||||
<style>
|
||||
body { font-family: system-ui; background: #0f172a; color: #e2e8f0; min-height: 100vh;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
h1 { font-size: 2rem; margin-bottom: 2rem; }
|
||||
.links { display: flex; gap: 1rem; }
|
||||
a { background: #334155; color: #e2e8f0; padding: 1rem 2rem; border-radius: 8px;
|
||||
text-decoration: none; font-size: 1.1rem; }
|
||||
a:hover { background: #475569; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>Feature Flow - Standardization Pipeline</h1>
|
||||
<div class="links">
|
||||
<a href="/book/feature-flow/en">English</a>
|
||||
<a href="/book/feature-flow/es">Español</a>
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
@app.get("/book/feature-flow/en", response_class=HTMLResponse)
|
||||
def feature_flow_en():
|
||||
html_file = BOOK_DIR / "feature-flow" / "index-en.html"
|
||||
return HTMLResponse(html_file.read_text())
|
||||
|
||||
|
||||
@app.get("/book/feature-flow/es", response_class=HTMLResponse)
|
||||
def feature_flow_es():
|
||||
html_file = BOOK_DIR / "feature-flow" / "index-es.html"
|
||||
return HTMLResponse(html_file.read_text())
|
||||
|
||||
|
||||
# --- Book: Feature Form Samples (templated book) ---
|
||||
@app.get("/book/feature-form-samples/", response_class=HTMLResponse)
|
||||
@app.get("/book/feature-form-samples", response_class=HTMLResponse)
|
||||
def feature_form_samples_index(request: Request):
|
||||
"""Templated book landing page"""
|
||||
data = get_data()
|
||||
book = next((b for b in data.get("books", []) if b["slug"] == "feature-form-samples"), None)
|
||||
if not book:
|
||||
return HTMLResponse("<h1>Book not found</h1>", status_code=404)
|
||||
return templates.TemplateResponse("book-template.html", {
|
||||
"request": request,
|
||||
"book": book,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/book/feature-form-samples/template/", response_class=HTMLResponse)
|
||||
@app.get("/book/feature-form-samples/template", response_class=HTMLResponse)
|
||||
def feature_form_samples_template():
|
||||
"""View the template - styled like actual feature forms"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Feature Form Template · Album</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
header { margin-bottom: 1.5rem; }
|
||||
.breadcrumb { font-size: 0.9rem; color: #64748b; margin-bottom: 0.5rem; }
|
||||
.breadcrumb a { color: #15803d; text-decoration: none; }
|
||||
.breadcrumb a:hover { text-decoration: underline; }
|
||||
h1 { font-size: 1.5rem; color: #15803d; }
|
||||
.meta { display: flex; gap: 0.5rem; margin-top: 0.5rem; font-size: 0.8rem; }
|
||||
.meta span { background: #f1f5f9; padding: 0.2rem 0.5rem; border-radius: 4px; color: #64748b; }
|
||||
.meta .template { background: #dbeafe; color: #1d4ed8; }
|
||||
|
||||
.form-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.form-header {
|
||||
background: linear-gradient(135deg, #15803d, #22c55e);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
.form-header h2 { font-size: 1.1rem; font-weight: 600; }
|
||||
.form-body { padding: 1.5rem; }
|
||||
|
||||
.field { margin-bottom: 1.25rem; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.field-value {
|
||||
background: #f8fafc;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.field-value.multiline { min-height: 4rem; }
|
||||
|
||||
.field-steps .field-value { padding-left: 0.5rem; }
|
||||
.field-steps ol { list-style: none; padding-left: 0; margin: 0; }
|
||||
.field-steps li {
|
||||
position: relative;
|
||||
padding-left: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.field-steps li::before {
|
||||
content: attr(data-step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: #cbd5e1;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.field-problems .field-value { background: #fef2f2; border-color: #fecaca; }
|
||||
.field-special .field-value { background: #fffbeb; border-color: #fde68a; }
|
||||
.field-technical .field-value { background: #f0fdf4; border-color: #bbf7d0; font-family: monospace; font-size: 0.85rem; }
|
||||
|
||||
footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
footer a { color: #15803d; text-decoration: none; }
|
||||
footer a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Album</a> / <a href="/book/feature-form-samples/">Feature Form Samples</a> / Template
|
||||
</div>
|
||||
<h1>Feature Form Template</h1>
|
||||
<div class="meta">
|
||||
<span class="template">Template</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-card">
|
||||
<div class="form-header">
|
||||
<h2>[Nombre del Flujo]</h2>
|
||||
</div>
|
||||
<div class="form-body">
|
||||
<div class="field">
|
||||
<label class="field-label">Tipo de Usuario</label>
|
||||
<div class="field-value">[Dueno de mascota / Veterinario / Admin]</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Punto de Entrada</label>
|
||||
<div class="field-value">[Que pagina/boton/link]</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Objetivo del Usuario</label>
|
||||
<div class="field-value">[Objetivo en una oracion]</div>
|
||||
</div>
|
||||
<div class="field field-steps">
|
||||
<label class="field-label">Pasos</label>
|
||||
<div class="field-value multiline">
|
||||
<ol>
|
||||
<li data-step="1">[Primera cosa que hace el usuario]</li>
|
||||
<li data-step="2">[Segunda cosa que hace el usuario]</li>
|
||||
<li data-step="3">[etc.]</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Resultado Esperado</label>
|
||||
<div class="field-value">[Resultado esperado cuando todo funciona]</div>
|
||||
</div>
|
||||
<div class="field field-problems">
|
||||
<label class="field-label">Problemas Comunes</label>
|
||||
<div class="field-value multiline">[Problema 1]<br>[Problema 2]</div>
|
||||
</div>
|
||||
<div class="field field-special">
|
||||
<label class="field-label">Casos Especiales</label>
|
||||
<div class="field-value multiline">[Caso especial 1]<br>[Caso especial 2]</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Flujos Relacionados</label>
|
||||
<div class="field-value">[Otros flujos que se conectan con este]</div>
|
||||
</div>
|
||||
<div class="field field-technical">
|
||||
<label class="field-label">Notas Tecnicas</label>
|
||||
<div class="field-value">[Notas para el equipo de desarrollo]</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="/book/feature-form-samples/">← Feature Form Samples</a>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/book/feature-form-samples/larder/", response_class=HTMLResponse)
|
||||
@app.get("/book/feature-form-samples/larder", response_class=HTMLResponse)
|
||||
def feature_form_samples_larder():
|
||||
"""Browse the larder (actual data)"""
|
||||
html_file = BOOK_DIR / "feature-form-samples" / "index.html"
|
||||
if html_file.exists():
|
||||
return HTMLResponse(html_file.read_text())
|
||||
return HTMLResponse("<h1>Larder index not found</h1>", status_code=404)
|
||||
|
||||
|
||||
@app.get("/book/feature-form-samples/larder/{user_type}/{filename}", response_class=HTMLResponse)
|
||||
def feature_form_samples_detail(request: Request, user_type: str, filename: str):
|
||||
"""View a specific feature form"""
|
||||
# Look in the larder subfolder (feature-form)
|
||||
larder_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
|
||||
file_path = larder_dir / user_type / filename
|
||||
if not file_path.exists():
|
||||
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
||||
|
||||
content = file_path.read_text()
|
||||
detail_file = BOOK_DIR / "feature-form-samples" / "detail.html"
|
||||
return templates.TemplateResponse(str(detail_file.relative_to(BASE_DIR)), {
|
||||
"request": request,
|
||||
"user_type": user_type,
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
|
||||
# --- Book: Gherkin Samples ---
|
||||
@app.get("/book/gherkin-samples/", response_class=HTMLResponse)
|
||||
@app.get("/book/gherkin-samples", response_class=HTMLResponse)
|
||||
@app.get("/book/gherkin/", response_class=HTMLResponse) # Alias
|
||||
@app.get("/book/gherkin", response_class=HTMLResponse) # Alias
|
||||
def gherkin_samples_index():
|
||||
"""Browse gherkin samples"""
|
||||
html_file = BOOK_DIR / "gherkin-samples" / "index.html"
|
||||
return HTMLResponse(html_file.read_text())
|
||||
|
||||
|
||||
@app.get("/book/gherkin-samples/{lang}/{user_type}/{filename}", response_class=HTMLResponse)
|
||||
@app.get("/book/gherkin/{lang}/{user_type}/{filename}", response_class=HTMLResponse) # Alias
|
||||
def gherkin_samples_detail(request: Request, lang: str, user_type: str, filename: str):
|
||||
"""View a specific gherkin file"""
|
||||
file_path = BOOK_DIR / "gherkin-samples" / lang / user_type / filename
|
||||
if not file_path.exists() or not file_path.suffix == ".feature":
|
||||
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
||||
|
||||
content = file_path.read_text()
|
||||
detail_file = BOOK_DIR / "gherkin-samples" / "detail.html"
|
||||
return templates.TemplateResponse(str(detail_file.relative_to(BASE_DIR)), {
|
||||
"request": request,
|
||||
"lang": lang,
|
||||
"user_type": user_type,
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
|
||||
# --- Book: Architecture Model (static site) ---
|
||||
app.mount("/book/arch-model", StaticFiles(directory=str(BOOK_DIR / "arch-model"), html=True), name="arch-model")
|
||||
|
||||
|
||||
# --- Book: Drive Index ---
|
||||
@app.get("/book/drive-index/", response_class=HTMLResponse)
|
||||
@app.get("/book/drive-index", response_class=HTMLResponse)
|
||||
def drive_index():
|
||||
"""Browse drive index"""
|
||||
html_file = BOOK_DIR / "drive-index" / "index.html"
|
||||
return HTMLResponse(html_file.read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=int(os.getenv("PORT", "12002")),
|
||||
reload=os.getenv("DEV", "").lower() in ("1", "true"),
|
||||
)
|
||||
4
soleprint/atlas/requirements.txt
Normal file
4
soleprint/atlas/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
jinja2>=3.1.0
|
||||
httpx>=0.25.0
|
||||
1
soleprint/atlas/static/prism/prism-gherkin.min.js
vendored
Normal file
1
soleprint/atlas/static/prism/prism-gherkin.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soleprint/atlas/static/prism/prism-line-numbers.min.css
vendored
Normal file
1
soleprint/atlas/static/prism/prism-line-numbers.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}
|
||||
1
soleprint/atlas/static/prism/prism-line-numbers.min.js
vendored
Normal file
1
soleprint/atlas/static/prism/prism-line-numbers.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!function(){if("undefined"!=typeof Prism&&"undefined"!=typeof document){var e="line-numbers",n=/\n(?!$)/g,t=Prism.plugins.lineNumbers={getLine:function(n,t){if("PRE"===n.tagName&&n.classList.contains(e)){var i=n.querySelector(".line-numbers-rows");if(i){var r=parseInt(n.getAttribute("data-start"),10)||1,s=r+(i.children.length-1);t<r&&(t=r),t>s&&(t=s);var l=t-r;return i.children[l]}}},resize:function(e){r([e])},assumeViewportIndependence:!0},i=void 0;window.addEventListener("resize",(function(){t.assumeViewportIndependence&&i===window.innerWidth||(i=window.innerWidth,r(Array.prototype.slice.call(document.querySelectorAll("pre.line-numbers"))))})),Prism.hooks.add("complete",(function(t){if(t.code){var i=t.element,s=i.parentNode;if(s&&/pre/i.test(s.nodeName)&&!i.querySelector(".line-numbers-rows")&&Prism.util.isActive(i,e)){i.classList.remove(e),s.classList.add(e);var l,o=t.code.match(n),a=o?o.length+1:1,u=new Array(a+1).join("<span></span>");(l=document.createElement("span")).setAttribute("aria-hidden","true"),l.className="line-numbers-rows",l.innerHTML=u,s.hasAttribute("data-start")&&(s.style.counterReset="linenumber "+(parseInt(s.getAttribute("data-start"),10)-1)),t.element.appendChild(l),r([s]),Prism.hooks.run("line-numbers",t)}}})),Prism.hooks.add("line-numbers",(function(e){e.plugins=e.plugins||{},e.plugins.lineNumbers=!0}))}function r(e){if(0!=(e=e.filter((function(e){var n,t=(n=e,n?window.getComputedStyle?getComputedStyle(n):n.currentStyle||null:null)["white-space"];return"pre-wrap"===t||"pre-line"===t}))).length){var t=e.map((function(e){var t=e.querySelector("code"),i=e.querySelector(".line-numbers-rows");if(t&&i){var r=e.querySelector(".line-numbers-sizer"),s=t.textContent.split(n);r||((r=document.createElement("span")).className="line-numbers-sizer",t.appendChild(r)),r.innerHTML="0",r.style.display="block";var l=r.getBoundingClientRect().height;return r.innerHTML="",{element:e,lines:s,lineHeights:[],oneLinerHeight:l,sizer:r}}})).filter(Boolean);t.forEach((function(e){var n=e.sizer,t=e.lines,i=e.lineHeights,r=e.oneLinerHeight;i[t.length-1]=void 0,t.forEach((function(e,t){if(e&&e.length>1){var s=n.appendChild(document.createElement("span"));s.style.display="block",s.textContent=e}else i[t]=r}))})),t.forEach((function(e){for(var n=e.sizer,t=e.lineHeights,i=0,r=0;r<t.length;r++)void 0===t[r]&&(t[r]=n.children[i++].getBoundingClientRect().height)})),t.forEach((function(e){var n=e.sizer,t=e.element.querySelector(".line-numbers-rows");n.style.display="none",n.innerHTML="",e.lineHeights.forEach((function(e,n){t.children[n].style.height=e+"px"}))}))}}}();
|
||||
1
soleprint/atlas/static/prism/prism-tomorrow.min.css
vendored
Normal file
1
soleprint/atlas/static/prism/prism-tomorrow.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}
|
||||
1
soleprint/atlas/static/prism/prism.min.js
vendored
Normal file
1
soleprint/atlas/static/prism/prism.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
512
soleprint/generate.html
Normal file
512
soleprint/generate.html
Normal file
@@ -0,0 +1,512 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Soleprint - Generate Configuration</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--primary: #e94560;
|
||||
--text: #eaeaea;
|
||||
--text-muted: #8892b0;
|
||||
--border: #0f3460;
|
||||
--success: #4ecca3;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
header p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin: 1.5rem 0 0.8rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.checkbox-group input {
|
||||
width: auto;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.7rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #c73e54;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #1a4a7a;
|
||||
}
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.preview {
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
font-size: 0.85rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.preview .folder {
|
||||
color: #82aaff;
|
||||
}
|
||||
.preview .file {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.preview .comment {
|
||||
color: #546e7a;
|
||||
}
|
||||
.status {
|
||||
margin-top: 1rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
.status.success {
|
||||
display: block;
|
||||
background: rgba(78, 204, 163, 0.2);
|
||||
border: 1px solid var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
.status.error {
|
||||
display: block;
|
||||
background: rgba(233, 69, 96, 0.2);
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.help-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Soleprint Configuration Generator</h1>
|
||||
<p>
|
||||
Generate a new room configuration for wrapping a managed
|
||||
application
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Configuration</h2>
|
||||
<form id="configForm">
|
||||
<div class="section-title">Room Settings</div>
|
||||
<div class="form-group">
|
||||
<label for="roomName">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="roomName"
|
||||
placeholder="e.g., myproject"
|
||||
required
|
||||
/>
|
||||
<div class="help-text">
|
||||
Unique identifier for this configuration
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Framework Branding</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="frameworkName"
|
||||
>Framework Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="frameworkName"
|
||||
value="soleprint"
|
||||
placeholder="soleprint"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="frameworkIcon">Icon</label>
|
||||
<input
|
||||
type="text"
|
||||
id="frameworkIcon"
|
||||
value=""
|
||||
placeholder="optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">System Names</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="arteryName">Data Flow System</label>
|
||||
<input
|
||||
type="text"
|
||||
id="arteryName"
|
||||
value="artery"
|
||||
placeholder="artery"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="atlasName"
|
||||
>Documentation System</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="atlasName"
|
||||
value="atlas"
|
||||
placeholder="atlas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stationName">Execution System</label>
|
||||
<input
|
||||
type="text"
|
||||
id="stationName"
|
||||
value="station"
|
||||
placeholder="station"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="section-title">
|
||||
Managed Application (Optional)
|
||||
</div>
|
||||
<div class="checkbox-group form-group">
|
||||
<input type="checkbox" id="hasManaged" />
|
||||
<label for="hasManaged"
|
||||
>Include managed application</label
|
||||
>
|
||||
</div>
|
||||
<div id="managedFields" style="display: none">
|
||||
<div class="form-group">
|
||||
<label for="managedName"
|
||||
>Managed App Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="managedName"
|
||||
placeholder="e.g., myapp"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backendPath"
|
||||
>Backend Repo Path</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="backendPath"
|
||||
placeholder="/path/to/backend"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="frontendPath"
|
||||
>Frontend Repo Path</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="frontendPath"
|
||||
placeholder="/path/to/frontend"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick="generateConfig()"
|
||||
>
|
||||
Generate Config
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="updatePreview()"
|
||||
>
|
||||
Preview Structure
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Preview</h2>
|
||||
<div id="preview" class="preview">
|
||||
<span class="comment"
|
||||
># Enter configuration details and click "Preview
|
||||
Structure"</span
|
||||
>
|
||||
|
||||
<span class="folder">gen/<room>/</span>
|
||||
<span class="folder"><managed>/</span>
|
||||
<span class="comment"># if managed app configured</span>
|
||||
<span class="folder">link/</span>
|
||||
<span class="folder"><soleprint>/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById("configForm");
|
||||
const hasManaged = document.getElementById("hasManaged");
|
||||
const managedFields = document.getElementById("managedFields");
|
||||
const preview = document.getElementById("preview");
|
||||
const status = document.getElementById("status");
|
||||
|
||||
// Toggle managed fields
|
||||
hasManaged.addEventListener("change", () => {
|
||||
managedFields.style.display = hasManaged.checked
|
||||
? "block"
|
||||
: "none";
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// Live preview on input changes
|
||||
form.querySelectorAll("input").forEach((input) => {
|
||||
input.addEventListener("input", debounce(updatePreview, 300));
|
||||
});
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function (...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
function getFormData() {
|
||||
return {
|
||||
room_name:
|
||||
document.getElementById("roomName").value || "myroom",
|
||||
framework: {
|
||||
name:
|
||||
document.getElementById("frameworkName").value ||
|
||||
"soleprint",
|
||||
icon:
|
||||
document.getElementById("frameworkIcon").value ||
|
||||
null,
|
||||
},
|
||||
systems: {
|
||||
artery:
|
||||
document.getElementById("arteryName").value ||
|
||||
"artery",
|
||||
atlas:
|
||||
document.getElementById("atlasName").value ||
|
||||
"atlas",
|
||||
station:
|
||||
document.getElementById("stationName").value ||
|
||||
"station",
|
||||
},
|
||||
managed: hasManaged.checked
|
||||
? {
|
||||
name:
|
||||
document.getElementById("managedName")
|
||||
.value || "",
|
||||
repos: {
|
||||
backend:
|
||||
document.getElementById("backendPath")
|
||||
.value || "",
|
||||
frontend:
|
||||
document.getElementById("frontendPath")
|
||||
.value || "",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePreview() {
|
||||
const data = getFormData();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
preview.innerHTML = result.tree;
|
||||
} else {
|
||||
const err = await response.json();
|
||||
preview.innerHTML = `<span class="comment"># Error: ${err.detail || "Unknown error"}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to client-side preview
|
||||
preview.innerHTML = generateLocalPreview(data);
|
||||
}
|
||||
}
|
||||
|
||||
function generateLocalPreview(data) {
|
||||
const room = data.room_name || "room";
|
||||
const fw = data.framework.name || "soleprint";
|
||||
const managed = data.managed;
|
||||
|
||||
let tree = `<span class="folder">gen/${room}/</span>\n`;
|
||||
|
||||
if (managed && managed.name) {
|
||||
tree += ` <span class="folder">${managed.name}/</span>\n`;
|
||||
tree += ` <span class="folder">link/</span>\n`;
|
||||
}
|
||||
|
||||
tree += ` <span class="folder">${fw}/</span>\n`;
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
async function generateConfig() {
|
||||
const data = getFormData();
|
||||
|
||||
if (!data.room_name) {
|
||||
showStatus("error", "Room name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
// Download config.json
|
||||
const blob = new Blob(
|
||||
[JSON.stringify(result.config, null, 2)],
|
||||
{ type: "application/json" },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `config.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showStatus(
|
||||
"success",
|
||||
`Configuration generated for "${data.room_name}". Save to cfg/${data.room_name}/config.json`,
|
||||
);
|
||||
} else {
|
||||
const err = await response.json();
|
||||
showStatus(
|
||||
"error",
|
||||
err.detail || "Failed to generate config",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus("error", `Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(type, message) {
|
||||
status.className = `status ${type}`;
|
||||
status.textContent = message;
|
||||
}
|
||||
|
||||
// Initial preview
|
||||
updatePreview();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -20,23 +20,65 @@ Routes:
|
||||
/station/* → proxy to station service
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Import data functions
|
||||
from dataloader import get_artery_data, get_atlas_data, get_station_data
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI(title="Soleprint", version="0.1.0")
|
||||
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent)
|
||||
|
||||
|
||||
# === Generation Models ===
|
||||
|
||||
|
||||
class FrameworkConfig(BaseModel):
|
||||
name: str = "soleprint"
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class SystemsConfig(BaseModel):
|
||||
artery: str = "artery"
|
||||
atlas: str = "atlas"
|
||||
station: str = "station"
|
||||
|
||||
|
||||
class ManagedConfig(BaseModel):
|
||||
name: str
|
||||
repos: dict[str, str]
|
||||
|
||||
|
||||
class GenerationRequest(BaseModel):
|
||||
room_name: str
|
||||
framework: FrameworkConfig = FrameworkConfig()
|
||||
systems: SystemsConfig = SystemsConfig()
|
||||
managed: Optional[ManagedConfig] = None
|
||||
|
||||
|
||||
# Load config if available
|
||||
CONFIG_PATH = Path(__file__).parent / "cfg" / "config.json"
|
||||
CONFIG = {}
|
||||
if CONFIG_PATH.exists():
|
||||
CONFIG = json.loads(CONFIG_PATH.read_text())
|
||||
|
||||
# Get ports from config or use defaults
|
||||
HUB_PORT = CONFIG.get("framework", {}).get("hub_port", 12000)
|
||||
SYSTEM_PORTS = {s["key"]: s["port"] for s in CONFIG.get("systems", [])}
|
||||
ARTERY_PORT = SYSTEM_PORTS.get("data_flow", 12001)
|
||||
ATLAS_PORT = SYSTEM_PORTS.get("documentation", 12002)
|
||||
STATION_PORT = SYSTEM_PORTS.get("execution", 12003)
|
||||
|
||||
# Service URLs (internal for API calls)
|
||||
ARTERY_URL = os.getenv("ARTERY_URL", "http://localhost:12001")
|
||||
ATLAS_URL = os.getenv("ATLAS_URL", "http://localhost:12002")
|
||||
STATION_URL = os.getenv("STATION_URL", "http://localhost:12003")
|
||||
ARTERY_URL = os.getenv("ARTERY_URL", f"http://localhost:{ARTERY_PORT}")
|
||||
ATLAS_URL = os.getenv("ATLAS_URL", f"http://localhost:{ATLAS_PORT}")
|
||||
STATION_URL = os.getenv("STATION_URL", f"http://localhost:{STATION_PORT}")
|
||||
|
||||
# External URLs (for frontend links, falls back to internal)
|
||||
ARTERY_EXTERNAL_URL = os.getenv("ARTERY_EXTERNAL_URL", ARTERY_URL)
|
||||
@@ -78,6 +120,137 @@ def api_station_data():
|
||||
return get_station_data()
|
||||
|
||||
|
||||
# === Generation API ===
|
||||
|
||||
|
||||
@app.get("/generate")
|
||||
def generation_ui():
|
||||
"""Serve the generation UI."""
|
||||
return FileResponse(Path(__file__).parent / "generate.html")
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
def generate_config(req: GenerationRequest):
|
||||
"""Generate a config.json for a new room."""
|
||||
config = {
|
||||
"framework": {
|
||||
"name": req.framework.name,
|
||||
"slug": req.framework.name.lower().replace(" ", "-"),
|
||||
"version": "0.1.0",
|
||||
"description": "Development workflow and documentation system",
|
||||
"tagline": "Mapping development footprints",
|
||||
"icon": req.framework.icon or "",
|
||||
"hub_port": HUB_PORT,
|
||||
},
|
||||
"systems": [
|
||||
{
|
||||
"key": "data_flow",
|
||||
"name": req.systems.artery,
|
||||
"slug": req.systems.artery.lower(),
|
||||
"title": req.systems.artery.title(),
|
||||
"tagline": "Todo lo vital",
|
||||
"port": ARTERY_PORT,
|
||||
"icon": "",
|
||||
},
|
||||
{
|
||||
"key": "documentation",
|
||||
"name": req.systems.atlas,
|
||||
"slug": req.systems.atlas.lower(),
|
||||
"title": req.systems.atlas.title(),
|
||||
"tagline": "Documentacion accionable",
|
||||
"port": ATLAS_PORT,
|
||||
"icon": "",
|
||||
},
|
||||
{
|
||||
"key": "execution",
|
||||
"name": req.systems.station,
|
||||
"slug": req.systems.station.lower(),
|
||||
"title": req.systems.station.title(),
|
||||
"tagline": "Monitores, Entornos y Herramientas",
|
||||
"port": STATION_PORT,
|
||||
"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",
|
||||
},
|
||||
},
|
||||
"documentation": {
|
||||
"library": {
|
||||
"name": "book",
|
||||
"title": "Book",
|
||||
"description": "Documentation library",
|
||||
},
|
||||
},
|
||||
"execution": {
|
||||
"utility": {
|
||||
"name": "tool",
|
||||
"title": "Tool",
|
||||
"description": "Execution utility",
|
||||
"plural": "tools",
|
||||
},
|
||||
"watcher": {
|
||||
"name": "monitor",
|
||||
"title": "Monitor",
|
||||
"description": "Service monitor",
|
||||
"plural": "monitors",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if req.managed:
|
||||
config["managed"] = {
|
||||
"name": req.managed.name,
|
||||
"repos": req.managed.repos,
|
||||
}
|
||||
|
||||
return {"config": config, "room_name": req.room_name}
|
||||
|
||||
|
||||
@app.post("/api/generate/preview")
|
||||
def generate_preview(req: GenerationRequest):
|
||||
"""Preview the generated folder structure."""
|
||||
room = req.room_name or "room"
|
||||
fw = req.framework.name or "soleprint"
|
||||
sys = req.systems
|
||||
|
||||
lines = [f'<span class="folder">gen/{room}/</span>']
|
||||
|
||||
if req.managed and req.managed.name:
|
||||
lines.append(f' <span class="folder">{req.managed.name}/</span>')
|
||||
lines.append(' <span class="folder">link/</span>')
|
||||
|
||||
lines.append(f' <span class="folder">{fw}/</span>')
|
||||
|
||||
return {"tree": "\n".join(lines)}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
|
||||
11
soleprint/station/__init__.py
Normal file
11
soleprint/station/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Station - Tools, environments, and execution.
|
||||
|
||||
Centro de control / Control center
|
||||
|
||||
Components:
|
||||
tools/ - Utilities, generators, runners
|
||||
desks/ - Composed: Cabinet + Room + Depots
|
||||
rooms/ - Environment configs
|
||||
depots/ - Data storage
|
||||
"""
|
||||
267
soleprint/station/index.html
Normal file
267
soleprint/station/index.html
Normal file
@@ -0,0 +1,267 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ system.title or 'Station' }} · 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 48 48' fill='%231d4ed8'%3E%3Crect x='4' y='8' width='40' height='28' rx='3' fill='%231d4ed8'/%3E%3Crect x='8' y='12' width='32' height='20' rx='2' fill='%230a0a0a'/%3E%3Crect x='16' y='36' width='16' height='4' fill='%231d4ed8'/%3E%3Crect x='12' y='40' width='24' height='3' rx='1' fill='%231d4ed8'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #0a0a0a;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.6;
|
||||
color: #e5e5e5;
|
||||
background: #1d4ed8;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: white;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
.tagline {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
section {
|
||||
background: #0a0a0a;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: #93c5fd;
|
||||
}
|
||||
.composition {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #1d4ed8;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.composition h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: #93c5fd;
|
||||
}
|
||||
.composition > p {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
.components {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 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: #93c5fd;
|
||||
}
|
||||
.component p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
.tables {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.tables li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #3f3f3f;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.tables li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.tables .name {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
.tables a.name:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.status {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
background: #2a2a2a;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
.health {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3f3f3f;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
.health:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
footer a {
|
||||
color: white;
|
||||
}
|
||||
footer .disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header style="position: relative">
|
||||
<!-- Control station / monitor -->
|
||||
<svg class="logo" viewBox="0 0 48 48" fill="currentColor">
|
||||
<rect x="4" y="8" width="40" height="28" rx="3" />
|
||||
<rect
|
||||
x="8"
|
||||
y="12"
|
||||
width="32"
|
||||
height="20"
|
||||
rx="2"
|
||||
fill="#1d4ed8"
|
||||
/>
|
||||
<rect x="16" y="36" width="16" height="4" />
|
||||
<rect x="12" y="40" width="24" height="3" rx="1" />
|
||||
</svg>
|
||||
<h1>{{ system.title or 'Station' }}</h1>
|
||||
{% if soleprint_url %}<a
|
||||
href="{{ soleprint_url }}"
|
||||
style="
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.85rem;
|
||||
"
|
||||
>← Soleprint</a
|
||||
>{% endif %}
|
||||
</header>
|
||||
<p class="tagline">{{ system.tagline or 'Monitors & Tools' }}</p>
|
||||
|
||||
<section>
|
||||
<div class="composition">
|
||||
<h3>
|
||||
{{ components.composed.title or 'Desk' }} = {{
|
||||
components.composed.formula or 'Tools + Room + Depots' }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ components.composed.description or 'A configured
|
||||
workspace' }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{{ (components.composed.plural or 'desks')|title }}</h2>
|
||||
<ul class="tables">
|
||||
{% for desk in desks %}
|
||||
<li>
|
||||
<span class="name">{{ desk.title }}</span
|
||||
><span class="status">{{ desk.status }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><span class="name">--</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{{ (components.watcher.plural or 'monitors')|title }}</h2>
|
||||
<ul class="tables">
|
||||
{% for monitor in monitors %}
|
||||
<li>
|
||||
<a href="/monitor/{{ monitor.slug }}/" class="name"
|
||||
>{{ monitor.title }}</a
|
||||
><span class="status">{{ monitor.status }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><span class="name">--</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{{ (components.utility.plural or 'tools')|title }}</h2>
|
||||
<ul class="tables">
|
||||
{% for tool in tools %}
|
||||
<li>
|
||||
{% if tool.type == 'app' and tool.url %}
|
||||
<a href="{{ tool.url }}" class="name">{{ tool.title }}</a>
|
||||
{% else %}
|
||||
<span class="name">{{ tool.title }}</span>
|
||||
{% if tool.cli %}<code
|
||||
style="
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-left: 0.5rem;
|
||||
"
|
||||
>{{ tool.cli }}</code
|
||||
>{% endif %} {% endif %}
|
||||
<span class="status">{{ tool.status }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><span class="name">--</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<a href="/health" class="health">/health</a>
|
||||
|
||||
<footer>
|
||||
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
|
||||
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
9
soleprint/station/monitors/__init__.py
Normal file
9
soleprint/station/monitors/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Station Monitors - Dashboards and data visualization tools.
|
||||
|
||||
Monitors are visual tools for exploring and displaying data.
|
||||
Unlike tools (CLIs), monitors provide web-based interfaces.
|
||||
|
||||
Available monitors:
|
||||
- databrowse: Generic SQL data browser with configurable schema
|
||||
"""
|
||||
171
soleprint/station/monitors/databrowse/README.md
Normal file
171
soleprint/station/monitors/databrowse/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Data Browse Monitor
|
||||
|
||||
**Test-oriented data navigation for AMAR** - quickly find which users to log in as for different test scenarios.
|
||||
|
||||
## Purpose
|
||||
|
||||
When working on multiple tickets simultaneously, you need to quickly navigate to users in specific data states:
|
||||
- New user with no data
|
||||
- User with pets but no requests
|
||||
- User with pending payment
|
||||
- Vet with active requests
|
||||
- Admin account
|
||||
|
||||
This monitor provides at-a-glance views of the database grouped by test-relevant states.
|
||||
|
||||
## Architecture
|
||||
|
||||
Follows pawprint **book/larder pattern**:
|
||||
- **larder/** contains all data files (schema, views, scenarios)
|
||||
- **main.py** generates SQL queries from view definitions
|
||||
- Two modes: **SQL** (direct queries) and **API** (Django backend, placeholder)
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Schema** (`larder/schema.json`)
|
||||
- AMAR data model with SQL table mappings
|
||||
- Regular fields (from database columns)
|
||||
- Computed fields (SQL expressions)
|
||||
- Support for multiple graph generators
|
||||
|
||||
**Views** (`larder/views.json`)
|
||||
- Define what to display and how to group it
|
||||
- Each view targets an entity (User, PetOwner, Veterinarian, etc.)
|
||||
- Can group results (e.g., by role, by data state, by availability)
|
||||
- SQL is generated automatically from view configuration
|
||||
|
||||
**Scenarios** (`larder/scenarios.json`)
|
||||
- Test scenarios emerge from actual usage
|
||||
- Format defined, real scenarios added as needed
|
||||
- Links scenarios to specific views with filters
|
||||
|
||||
## Available Views
|
||||
|
||||
1. **users_by_role** - All users grouped by USER/VET/ADMIN for quick login selection
|
||||
2. **petowners_by_state** - Pet owners grouped by data state (has_pets, has_coverage, has_requests, has_turnos)
|
||||
3. **vets_by_availability** - Vets grouped by availability status (available, busy, very busy, no availability)
|
||||
4. **requests_pipeline** - Active service requests grouped by state (similar to turnos monitor)
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
cd /home/mariano/wdir/ama/pawprint/ward/monitor/data_browse
|
||||
python main.py
|
||||
# Opens on http://localhost:12020
|
||||
```
|
||||
|
||||
Or with uvicorn:
|
||||
```bash
|
||||
uvicorn ward.monitor.data_browse.main:app --port 12020 --reload
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Database connection (defaults to local dev)
|
||||
export NEST_NAME=local
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=5433
|
||||
export DB_NAME=amarback
|
||||
export DB_USER=mariano
|
||||
export DB_PASSWORD=""
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```
|
||||
GET / # Landing page
|
||||
GET /view/{view_slug} # View display (HTML)
|
||||
GET /health # Health check
|
||||
GET /api/views # List all views (JSON)
|
||||
GET /api/view/{view_slug} # View data (JSON)
|
||||
GET /api/schema # Data model schema (JSON)
|
||||
GET /api/scenarios # Test scenarios (JSON)
|
||||
```
|
||||
|
||||
## Adding New Views
|
||||
|
||||
Edit `larder/views.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my_new_view",
|
||||
"title": "My New View",
|
||||
"slug": "my-new-view",
|
||||
"description": "Description of what this shows",
|
||||
"mode": "sql",
|
||||
"entity": "PetOwner",
|
||||
"group_by": "some_field",
|
||||
"fields": ["id", "first_name", "last_name", "email"],
|
||||
"display_fields": {
|
||||
"id": {"label": "ID", "width": "60px"},
|
||||
"first_name": {"label": "Name", "width": "120px"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The SQL query is automatically generated from:
|
||||
- Entity definition in `schema.json` (table name, columns)
|
||||
- Fields list (regular + computed fields)
|
||||
- Group by configuration (if specified)
|
||||
- Filters (if specified)
|
||||
|
||||
## Adding Computed Fields
|
||||
|
||||
Edit `larder/schema.json` in the entity definition:
|
||||
|
||||
```json
|
||||
"computed": {
|
||||
"has_pets": {
|
||||
"description": "Has at least one pet",
|
||||
"sql": "EXISTS (SELECT 1 FROM mascotas_pet WHERE petowner_id = mascotas_petowner.id AND deleted = false)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Computed fields can be used in views just like regular fields.
|
||||
|
||||
## Adding Test Scenarios
|
||||
|
||||
As you identify test patterns, add them to `larder/scenarios.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "User with Pending Payment",
|
||||
"slug": "user-pending-payment",
|
||||
"description": "User with accepted request awaiting payment",
|
||||
"role": "USER",
|
||||
"entity": "ServiceRequest",
|
||||
"view": "requests_pipeline",
|
||||
"filters": {
|
||||
"state": ["vet_accepted", "in_progress_pay"],
|
||||
"has_payment": false
|
||||
},
|
||||
"test_cases": [
|
||||
"Payment flow (MercadoPago)",
|
||||
"Payment reminders",
|
||||
"Payment timeout"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
data_browse/
|
||||
├── larder/
|
||||
│ ├── .larder # Larder marker (book pattern)
|
||||
│ ├── schema.json # AMAR data model with SQL mappings
|
||||
│ ├── views.json # View configurations
|
||||
│ └── scenarios.json # Test scenarios
|
||||
├── main.py # FastAPI app
|
||||
├── index.html # Landing page
|
||||
├── view.html # View display template
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
**Current:** SQL mode fully implemented, ready for local testing
|
||||
**Next:** Test with local database, refine views based on usage
|
||||
**Future:** See `workbench/data_browse_roadmap.md`
|
||||
0
soleprint/station/monitors/databrowse/depot/.depot
Normal file
0
soleprint/station/monitors/databrowse/depot/.depot
Normal file
13
soleprint/station/monitors/databrowse/depot/scenarios.json
Normal file
13
soleprint/station/monitors/databrowse/depot/scenarios.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "Example Scenario",
|
||||
"description": "Example test scenario for databrowse",
|
||||
"steps": [
|
||||
"Navigate to users view",
|
||||
"Verify user list is displayed",
|
||||
"Filter by active status"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
27
soleprint/station/monitors/databrowse/depot/schema.json
Normal file
27
soleprint/station/monitors/databrowse/depot/schema.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "example",
|
||||
"description": "Example schema for databrowse. Replace with room-specific schema.",
|
||||
"tables": {
|
||||
"users": {
|
||||
"description": "Example users table",
|
||||
"columns": {
|
||||
"id": { "type": "integer", "primary_key": true },
|
||||
"username": { "type": "string" },
|
||||
"email": { "type": "string" },
|
||||
"is_active": { "type": "boolean" },
|
||||
"created_at": { "type": "datetime" }
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"description": "Example items table",
|
||||
"columns": {
|
||||
"id": { "type": "integer", "primary_key": true },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "text" },
|
||||
"user_id": { "type": "integer", "foreign_key": "users.id" },
|
||||
"status": { "type": "string" },
|
||||
"created_at": { "type": "datetime" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
soleprint/station/monitors/databrowse/depot/views.json
Normal file
38
soleprint/station/monitors/databrowse/depot/views.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"views": [
|
||||
{
|
||||
"slug": "users",
|
||||
"title": "All Users",
|
||||
"description": "List of all users",
|
||||
"table": "users",
|
||||
"fields": ["id", "username", "email", "is_active", "created_at"],
|
||||
"order_by": "-created_at"
|
||||
},
|
||||
{
|
||||
"slug": "active-users",
|
||||
"title": "Active Users",
|
||||
"description": "Users with active status",
|
||||
"table": "users",
|
||||
"fields": ["id", "username", "email", "created_at"],
|
||||
"where": "is_active = true",
|
||||
"order_by": "username"
|
||||
},
|
||||
{
|
||||
"slug": "items",
|
||||
"title": "All Items",
|
||||
"description": "List of all items",
|
||||
"table": "items",
|
||||
"fields": ["id", "name", "status", "user_id", "created_at"],
|
||||
"order_by": "-created_at"
|
||||
},
|
||||
{
|
||||
"slug": "items-by-status",
|
||||
"title": "Items by Status",
|
||||
"description": "Items grouped by status",
|
||||
"table": "items",
|
||||
"fields": ["id", "name", "user_id", "created_at"],
|
||||
"group_by": "status",
|
||||
"order_by": "-created_at"
|
||||
}
|
||||
]
|
||||
}
|
||||
345
soleprint/station/monitors/databrowse/index.html
Normal file
345
soleprint/station/monitors/databrowse/index.html
Normal file
@@ -0,0 +1,345 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Databrowse · {{ room_name }}</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: #111827;
|
||||
color: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0 1.5rem;
|
||||
border-bottom: 1px solid #374151;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.room-badge {
|
||||
font-size: 0.75rem;
|
||||
background: #374151;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Card Grid */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid #374151;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: #252f3f;
|
||||
border-color: #60a5fa;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #f3f4f6;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-mode {
|
||||
font-size: 0.65rem;
|
||||
background: #374151;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-mode.sql {
|
||||
background: #065f46;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.card-mode.api {
|
||||
background: #1e3a8a;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.card-mode.graph {
|
||||
background: #581c87;
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 0.85rem;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.card-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.card-meta-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 3rem;
|
||||
font-size: 0.9rem;
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #374151;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #374151;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #60a5fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Scenario specific styles */
|
||||
.scenario-card {
|
||||
border-left: 3px solid #60a5fa;
|
||||
}
|
||||
|
||||
.scenario-card .card-title {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.card-filters {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
background: #111827;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: inline-block;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>
|
||||
Databrowse
|
||||
<span class="room-badge">{{ room_name }}</span>
|
||||
</h1>
|
||||
<p class="subtitle">
|
||||
Test-oriented data navigation for AMAR - find the right
|
||||
user/scenario
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Views Section -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Views</h2>
|
||||
<span class="count">{{ views|length }} available</span>
|
||||
</div>
|
||||
|
||||
{% if views|length == 0 %}
|
||||
<div class="empty">
|
||||
No views configured. Add views to larder/views.json
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-grid">
|
||||
{% for view in views %}
|
||||
<a href="/view/{{ view.slug }}" class="card">
|
||||
<div class="card-title">
|
||||
{{ view.title }}
|
||||
<span class="card-mode {{ view.mode }}"
|
||||
>{{ view.mode }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-description">
|
||||
{{ view.description }}
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-meta-item">
|
||||
<span class="card-meta-label">Entity:</span>
|
||||
{{ view.entity }}
|
||||
</span>
|
||||
{% if view.group_by %}
|
||||
<span class="card-meta-item">
|
||||
<span class="card-meta-label">Group:</span>
|
||||
{{ view.group_by }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Scenarios Section -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Test Scenarios</h2>
|
||||
<span class="count">{{ scenarios|length }} defined</span>
|
||||
</div>
|
||||
|
||||
{% if scenarios|length == 0 %}
|
||||
<div class="empty">
|
||||
No scenarios defined yet. Scenarios emerge from usage and
|
||||
conversations.
|
||||
<br />Add them to larder/scenarios.json as you identify test
|
||||
patterns.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-grid">
|
||||
{% for scenario in scenarios %} {% if not scenario._example
|
||||
%}
|
||||
<a
|
||||
href="/view/{{ scenario.view }}?scenario={{ scenario.slug }}"
|
||||
class="card scenario-card"
|
||||
>
|
||||
<div class="card-title">{{ scenario.name }}</div>
|
||||
<div class="card-description">
|
||||
{{ scenario.description }}
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-meta-item">
|
||||
<span class="card-meta-label">Role:</span>
|
||||
{{ scenario.role }}
|
||||
</span>
|
||||
{% if scenario.priority %}
|
||||
<span class="card-meta-item">
|
||||
<span class="card-meta-label">Priority:</span>
|
||||
{{ scenario.priority }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if scenario.filters %}
|
||||
<div class="card-filters">
|
||||
{% for key, value in scenario.filters.items() %}
|
||||
<span class="filter-item"
|
||||
>{{ key }}: {{ value }}</span
|
||||
>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %} {% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>Data Browse Monitor v0.1.0</span>
|
||||
<div>
|
||||
<a href="/health">/health</a> ·
|
||||
<a href="/api/schema">/api/schema</a> ·
|
||||
<a href="/api/views">/api/views</a> ·
|
||||
<a href="/api/scenarios">/api/scenarios</a>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user