major restructure

This commit is contained in:
buenosairesam
2026-01-20 05:31:26 -03:00
parent 27b32deba4
commit e4052374db
328 changed files with 1018 additions and 10018 deletions

View 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

File diff suppressed because it is too large Load Diff

View 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.

View 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", [])]

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

View 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

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

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

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

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

View 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.

View 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
}
}

View 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)

View 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

View 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

View File

@@ -0,0 +1 @@
"""MercadoPago (MOCK) vein - Mock MercadoPago API for testing."""

View File

@@ -0,0 +1 @@
"""API routes for MercadoPago mock vein."""

View 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",
}

View File

@@ -0,0 +1 @@
"""Core logic for MercadoPago mock API."""

View 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()

View 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)

View File

@@ -0,0 +1,4 @@
fastapi>=0.104.0
uvicorn>=0.24.0
pydantic>=2.0.0
pydantic-settings>=2.0.0

View 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)

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

View 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.

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

View 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

View 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

View 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`).

View 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))

View 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()

View 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,
)

View 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}")

View 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)

View 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)

View 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,
)

View 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

View 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)

View File

@@ -0,0 +1,5 @@
# Ignore all token files
tokens_*.json
# But keep this directory in git
!.gitignore

View 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

View 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.

View File

@@ -0,0 +1 @@
# Jira Vein

View 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,
)

View 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",
)

View 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),
)

View 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()

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

View 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)

View 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)

View 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

View 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

View 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,
)

View 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

View File

@@ -0,0 +1 @@
# Slack Vein

View File

@@ -0,0 +1 @@
# Slack API routes

View 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}")

View File

@@ -0,0 +1 @@
# Slack core

View 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",
)

View 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']}")

View 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()

View 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)

View File

@@ -0,0 +1 @@
# Slack models

View 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)

View 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

View 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

View 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,
)

View 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
View File

@@ -0,0 +1,3 @@
def
__pycache__
drive

116
soleprint/atlas/CLAUDE.md Normal file
View 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.

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

View 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">&rarr;</span>
<h4>{{ book.template.title }}</h4>
</a>
<a href="/book/{{ book.slug }}/larder/" class="component">
<span class="arrow">&rarr;</span>
<h4>{{ book.larder.title }}</h4>
</a>
</div>
</div>
</section>
<footer>
<a href="/">&larr; Album</a>
</footer>
</body>
</html>

View 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

View 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 &rarr; BDD/Gherkin &rarr; 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()">&#8592;</button>
<span class="counter"><span id="current">1</span>/<span id="total">8</span></span>
<button onclick="nextSlide()">&#8594;</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>

View 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 &rarr; BDD/Gherkin &rarr; 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
View 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
View 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/">&larr; 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"),
)

View File

@@ -0,0 +1,4 @@
fastapi>=0.104.0
uvicorn>=0.24.0
jinja2>=3.1.0
httpx>=0.25.0

File diff suppressed because one or more lines are too long

View 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}

View 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"}))}))}}}();

View 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}

File diff suppressed because one or more lines are too long

512
soleprint/generate.html Normal file
View 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/&lt;room&gt;/</span>
<span class="folder">&lt;managed&gt;/</span>
<span class="comment"># if managed app configured</span>
<span class="folder">link/</span>
<span class="folder">&lt;soleprint&gt;/</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>

View File

@@ -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(

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

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

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

View 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`

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

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

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

View 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