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>
This commit is contained in:
231
server/app.py
Normal file
231
server/app.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user