Files
Project-SDE-WP-Suite/server/app.py
n.siegfried a33b777aca Add Python/FastAPI + PostgreSQL backend (Phase 1)
- server/: FastAPI app with SQLAlchemy models for sops, work_packages, comments
- Endpoints for SOP/WP upsert+list+get+delete and comment create+list;
  /api/feedback kept as an alias so the existing client keeps working
- Portable across engines (PostgreSQL prod, SQLite dev fallback)
- requirements.txt, .env.example, and server/README.md (Postgres + systemd)
- NGINX now proxies /api/ to the API (replaces the Power Automate hop;
  comments persist to SQL)
- Rewrite DEPLOYMENT.md for the API + database architecture
- Add .gitignore for venv/.env/sqlite

Phase 2 (wire the client apps to the API) is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:58:52 -07:00

232 lines
7.8 KiB
Python

"""Work Package Suite API.
A small FastAPI service that stores project SOPs, Work Packages, and comments
in SQL (PostgreSQL in production; SQLite for local dev). NGINX serves the static
site and proxies /api/ here.
Run (dev): uvicorn server.app:app --reload --port 8000
Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
Interactive docs: http://<host>/api/docs
"""
import os
import uuid
from typing import Any, Optional
from fastapi import FastAPI, Depends, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, delete
from sqlalchemy.orm import Session
from .db import Base, engine, get_db
from . import models
# Create tables on startup. (For schema changes later, switch to Alembic.)
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url="/api/openapi.json")
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
_origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
if _origins:
app.add_middleware(
CORSMiddleware, allow_origins=_origins,
allow_methods=["*"], allow_headers=["*"],
)
def gen_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}"
# ── Request bodies ───────────────────────────────────────────────────────────
class SopIn(BaseModel):
id: Optional[str] = None
name: str = ""
number: str = ""
complete: bool = False
created_by: str = ""
data: dict[str, Any] = Field(default_factory=dict)
class WpIn(BaseModel):
id: Optional[str] = None
sop_id: Optional[str] = None
number: str = ""
subject: str = ""
type: str = ""
status: str = "Draft"
created_by: str = ""
data: dict[str, Any] = Field(default_factory=dict)
class CommentIn(BaseModel):
# Tolerate any extra keys the feedback payload includes (timestamp, app, …).
model_config = ConfigDict(extra="allow")
source: Optional[str] = None
type: Optional[str] = None # client sends 'type'; treated as source
sop_id: Optional[str] = None
wp_id: Optional[str] = None
step: Optional[int] = None
author: Optional[str] = None
name: Optional[str] = None # home/SOP forms send 'name'
text: Optional[str] = None
page: Optional[str] = ""
# ── Health ───────────────────────────────────────────────────────────────────
@app.get("/api/health")
def health():
return {"ok": True}
# ── SOPs ─────────────────────────────────────────────────────────────────────
@app.post("/api/sops")
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
sop = db.get(models.Sop, body.id) if body.id else None
if sop is None:
sop = models.Sop(id=body.id or gen_id("sop"))
db.add(sop)
sop.name = body.name
sop.number = body.number
sop.complete = body.complete
sop.created_by = body.created_by or sop.created_by
sop.data = body.data
db.commit()
db.refresh(sop)
return sop.to_dict()
@app.get("/api/sops")
def list_sops(db: Session = Depends(get_db)):
rows = db.scalars(select(models.Sop).order_by(models.Sop.updated_at.desc())).all()
return [s.summary() for s in rows]
@app.get("/api/sops/latest")
def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)):
stmt = select(models.Sop)
if complete is not None:
stmt = stmt.where(models.Sop.complete == complete)
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
if not sop:
raise HTTPException(status_code=404, detail="No SOP found")
return sop.to_dict()
@app.get("/api/sops/{sop_id}")
def get_sop(sop_id: str, db: Session = Depends(get_db)):
sop = db.get(models.Sop, sop_id)
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
return sop.to_dict()
@app.delete("/api/sops/{sop_id}")
def delete_sop(sop_id: str, db: Session = Depends(get_db)):
sop = db.get(models.Sop, sop_id)
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
db.delete(sop)
db.commit()
return {"deleted": sop_id}
# ── Work Packages ────────────────────────────────────────────────────────────
@app.post("/api/wps")
def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, body.id) if body.id else None
if wp is None:
wp = models.WorkPackage(id=body.id or gen_id("wp"))
db.add(wp)
wp.sop_id = body.sop_id
wp.number = body.number
wp.subject = body.subject
wp.type = body.type
wp.status = body.status
wp.created_by = body.created_by or wp.created_by
wp.data = body.data
db.commit()
db.refresh(wp)
return wp.to_dict()
@app.get("/api/wps")
def list_wps(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
stmt = select(models.WorkPackage)
if sop_id:
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
return [w.summary() for w in rows]
@app.get("/api/wps/{wp_id}")
def get_wp(wp_id: str, db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
return wp.to_dict()
@app.delete("/api/wps/{wp_id}")
def delete_wp(wp_id: str, db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
db.delete(wp)
db.commit()
return {"deleted": wp_id}
# ── Comments / feedback ──────────────────────────────────────────────────────
def _save_comment(body: CommentIn, db: Session) -> dict:
extra = body.model_extra or {}
c = models.Comment(
id=gen_id("c"),
source=body.source or body.type or "",
sop_id=body.sop_id,
wp_id=body.wp_id,
step=body.step,
author=(body.author or body.name or "Anonymous"),
text=body.text or "",
page=body.page or "",
extra=extra,
)
db.add(c)
db.commit()
db.refresh(c)
return c.to_dict()
@app.post("/api/comments")
def create_comment(body: CommentIn, db: Session = Depends(get_db)):
return _save_comment(body, db)
# Alias so the existing client (which posts to /api/feedback) keeps working.
@app.post("/api/feedback")
def create_feedback(body: CommentIn, db: Session = Depends(get_db)):
return _save_comment(body, db)
@app.get("/api/comments")
def list_comments(
source: Optional[str] = Query(None),
sop_id: Optional[str] = Query(None),
wp_id: Optional[str] = Query(None),
step: Optional[int] = Query(None),
db: Session = Depends(get_db),
):
stmt = select(models.Comment)
if source:
stmt = stmt.where(models.Comment.source == source)
if sop_id:
stmt = stmt.where(models.Comment.sop_id == sop_id)
if wp_id:
stmt = stmt.where(models.Comment.wp_id == wp_id)
if step is not None:
stmt = stmt.where(models.Comment.step == step)
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
return [c.to_dict() for c in rows]