"""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:///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 ProjectIn(BaseModel): id: Optional[str] = None name: str = "" number: str = "" client: str = "" division: str = "" site: str = "" sample: bool = False created_by: str = "" data: dict[str, Any] = Field(default_factory=dict) class SopIn(BaseModel): id: Optional[str] = None project_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 project_id: Optional[str] = None sop_id: Optional[str] = None parent_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 StatusIn(BaseModel): status: str 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} # ── Projects ───────────────────────────────────────────────────────────────── @app.post("/api/projects") def upsert_project(body: ProjectIn, db: Session = Depends(get_db)): proj = db.get(models.Project, body.id) if body.id else None if proj is None: proj = models.Project(id=body.id or gen_id("proj")) db.add(proj) proj.name = body.name proj.number = body.number proj.client = body.client proj.division = body.division proj.site = body.site proj.sample = body.sample proj.created_by = body.created_by or proj.created_by proj.data = body.data db.commit() db.refresh(proj) return proj.to_dict() @app.get("/api/projects") def list_projects(db: Session = Depends(get_db)): rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all() return [p.summary() for p in rows] @app.get("/api/projects/{project_id}") def get_project(project_id: str, db: Session = Depends(get_db)): proj = db.get(models.Project, project_id) if not proj: raise HTTPException(status_code=404, detail="Project not found") return proj.to_dict() @app.delete("/api/projects/{project_id}") def delete_project(project_id: str, db: Session = Depends(get_db)): proj = db.get(models.Project, project_id) if not proj: raise HTTPException(status_code=404, detail="Project not found") db.delete(proj) db.commit() return {"deleted": project_id} # ── 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.project_id = body.project_id 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(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)): stmt = select(models.Sop) if project_id: stmt = stmt.where(models.Sop.project_id == project_id) rows = db.scalars(stmt.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, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)): stmt = select(models.Sop) if complete is not None: stmt = stmt.where(models.Sop.complete == complete) if project_id: stmt = stmt.where(models.Sop.project_id == project_id) 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.project_id = body.project_id wp.sop_id = body.sop_id wp.parent_id = body.parent_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( project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), parent_id: Optional[str] = Query(None), status: Optional[str] = Query(None), db: Session = Depends(get_db), ): stmt = select(models.WorkPackage) if project_id: stmt = stmt.where(models.WorkPackage.project_id == project_id) if sop_id: stmt = stmt.where(models.WorkPackage.sop_id == sop_id) if parent_id: stmt = stmt.where(models.WorkPackage.parent_id == parent_id) if status: stmt = stmt.where(models.WorkPackage.status == status) rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all() return [w.summary() for w in rows] @app.get("/api/wps/metrics") def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)): """Aggregates for the dashboard. Masters (data.split == true) are excluded from counts so a split package's hours aren't double-counted with its instances.""" stmt = select(models.WorkPackage) if project_id: stmt = stmt.where(models.WorkPackage.project_id == project_id) if sop_id: stmt = stmt.where(models.WorkPackage.sop_id == sop_id) rows = db.scalars(stmt).all() by_status: dict[str, int] = {} by_discipline: dict[str, int] = {} total = ready = on_hold = est_hours = actual_hours = 0 for w in rows: data = w.data or {} if data.get("split"): continue total += 1 by_status[w.status] = by_status.get(w.status, 0) + 1 if w.status == "Issue": on_hold += 1 constraints = data.get("constraints") or [] open_count = sum(1 for c in constraints if c.get("status") == "open") if open_count == 0 and w.status not in ("Closed", "Issue"): ready += 1 try: est_hours += float(data.get("hours") or 0) actual_hours += float(data.get("actualHrs") or 0) except (TypeError, ValueError): pass for d in (data.get("disciplines") or ["(none)"]): by_discipline[d] = by_discipline.get(d, 0) + 1 return { "total": total, "release_ready": ready, "on_hold": on_hold, "est_hours": round(est_hours), "actual_hours": round(actual_hours), "by_status": by_status, "by_discipline": by_discipline, } @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} @app.post("/api/wps/{wp_id}/issue") def issue_wp(wp_id: str, db: Session = Depends(get_db)): """Release a Work Package to the field. Refuses if any constraint is still open (the AWP release gate).""" wp = db.get(models.WorkPackage, wp_id) if not wp: raise HTTPException(status_code=404, detail="Work Package not found") constraints = (wp.data or {}).get("constraints") or [] open_names = [c.get("name") for c in constraints if c.get("status") == "open"] if open_names: raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names}) wp.status = "Issued" wp.issued_at = models.utcnow() db.commit() db.refresh(wp) return wp.to_dict() @app.post("/api/wps/{wp_id}/status") def set_wp_status(wp_id: str, body: StatusIn, 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") wp.status = body.status if body.status == "Issued" and wp.issued_at is None: wp.issued_at = models.utcnow() db.commit() db.refresh(wp) return wp.to_dict() # ── 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]