Projects become the top-level container; SOPs and Work Packages belong to one. Backend: - New projects table + CRUD (/api/projects). - sops.project_id (FK, cascade) and work_packages.project_id added; list/latest/metrics endpoints accept a project_id filter. Front end (now under html/): - project-data.js: shared API-first ProjectData adapter with localStorage fallback + active-project helpers. - Home page: removed "About This Suite"; added a Project picker (create / use sample / select). Tool cards stay hidden until a project is active and carry &project=<id>; hero shows the active project. - Suite reads ?project, resolves it, shows it in the header, and prefills the SOP project fields; passes &project into the WP-creator iframe. - WP creator stamps projectId onto saved packages. SOP/WP localStorage is not yet namespaced per project (next step). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
388 lines
13 KiB
Python
388 lines
13 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 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]
|