Gate the suite behind a self-contained login (no external IdP): - User model with bcrypt-hashed passwords; admin/user roles - /api/auth endpoints: login, logout, me, change-password, and admin-only user management (list/create/delete/reset/enable) - Stateless JWT session in an HttpOnly, SameSite=Lax, auto-Secure cookie; middleware refuses every /api data route without a session - login.html + auth-guard.js: login page and per-page guard with a top-right "name / Admin / Sign out" pill - Admin Console now gated on admin role (passphrase gate removed) with a User administration card - manage_users.py CLI to bootstrap the first admin - Rebuilt help.js into a searchable, multi-topic help center - Local-dev convenience: app serves html/ so the site + API share one origin under uvicorn (inactive in the prod container) - Docs/env: AUTH_SECRET_KEY, requirements (bcrypt, PyJWT), README Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
550 lines
20 KiB
Python
550 lines
20 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, Request, Response
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
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, auth
|
|
|
|
# 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,..."
|
|
# allow_credentials is required so the browser sends the session cookie.
|
|
_origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
|
if _origins:
|
|
app.add_middleware(
|
|
CORSMiddleware, allow_origins=_origins, allow_credentials=True,
|
|
allow_methods=["*"], allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# ── Authentication gate ────────────────────────────────────────────────────────
|
|
# Every /api/ data route requires a valid session cookie. Login, health, and the
|
|
# docs are exempt (see auth._needs_auth). This is the real security boundary —
|
|
# the static pages are only client-side guarded for UX. OPTIONS (CORS preflight)
|
|
# is always allowed so the browser can negotiate before sending credentials.
|
|
@app.middleware("http")
|
|
async def auth_gate(request: Request, call_next):
|
|
if request.method != "OPTIONS" and auth._needs_auth(request.url.path):
|
|
if not auth.is_request_authenticated(request):
|
|
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
|
return await call_next(request)
|
|
|
|
|
|
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}
|
|
|
|
|
|
# ── Authentication ─────────────────────────────────────────────────────────────
|
|
class LoginIn(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
class NewUserIn(BaseModel):
|
|
username: str
|
|
password: str
|
|
full_name: str = ""
|
|
email: str = ""
|
|
role: str = "user" # 'admin' | 'user'
|
|
|
|
|
|
class PasswordChangeIn(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
class AdminPasswordIn(BaseModel):
|
|
new_password: str
|
|
|
|
|
|
class ActiveIn(BaseModel):
|
|
is_active: bool
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
|
"""Verify credentials and, on success, set the HttpOnly session cookie."""
|
|
user = auth.find_user(db, body.username)
|
|
# Always run a hash comparison to avoid leaking which usernames exist via
|
|
# response timing; verify_password tolerates an empty hash.
|
|
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
|
if not user or not valid:
|
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
if not user.is_active:
|
|
raise HTTPException(status_code=403, detail="Account is disabled")
|
|
user.last_login_at = models.utcnow()
|
|
db.commit()
|
|
token = auth.create_token(user)
|
|
auth.set_session_cookie(response, request, token)
|
|
return {"user": user.to_dict()}
|
|
|
|
|
|
@app.post("/api/auth/logout")
|
|
def logout(response: Response):
|
|
auth.clear_session_cookie(response)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/auth/me")
|
|
def whoami(user: models.User = Depends(auth.get_current_user)):
|
|
"""Who is logged in. The frontend guard calls this on every page load."""
|
|
return {"user": user.to_dict()}
|
|
|
|
|
|
@app.post("/api/auth/password")
|
|
def change_password(body: PasswordChangeIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
if not auth.verify_password(body.current_password, user.password_hash):
|
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
|
if len(body.new_password) < 8:
|
|
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
|
user.password_hash = auth.hash_password(body.new_password)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── User administration (admin only) ────────────────────────────────────────────
|
|
@app.get("/api/auth/users")
|
|
def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
|
rows = db.scalars(select(models.User).order_by(models.User.username)).all()
|
|
return [u.to_dict() for u in rows]
|
|
|
|
|
|
@app.post("/api/auth/users")
|
|
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
|
if len(body.password) < 8:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
|
if body.role not in ("admin", "user"):
|
|
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
|
if auth.find_user(db, body.username):
|
|
raise HTTPException(status_code=409, detail="A user with that username already exists")
|
|
u = models.User(
|
|
id=gen_id("user"),
|
|
username=body.username.strip(),
|
|
email=body.email.strip(),
|
|
full_name=body.full_name.strip(),
|
|
password_hash=auth.hash_password(body.password),
|
|
role=body.role,
|
|
)
|
|
db.add(u)
|
|
db.commit()
|
|
db.refresh(u)
|
|
return u.to_dict()
|
|
|
|
|
|
@app.post("/api/auth/users/{user_id}/password")
|
|
def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
|
u = db.get(models.User, user_id)
|
|
if not u:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if len(body.new_password) < 8:
|
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
|
u.password_hash = auth.hash_password(body.new_password)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/auth/users/{user_id}/active")
|
|
def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
|
u = db.get(models.User, user_id)
|
|
if not u:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if u.id == admin.id and not body.is_active:
|
|
raise HTTPException(status_code=400, detail="You cannot disable your own account")
|
|
u.is_active = body.is_active
|
|
db.commit()
|
|
return u.to_dict()
|
|
|
|
|
|
@app.delete("/api/auth/users/{user_id}")
|
|
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
|
u = db.get(models.User, user_id)
|
|
if not u:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if u.id == admin.id:
|
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
|
db.delete(u)
|
|
db.commit()
|
|
return {"deleted": user_id}
|
|
|
|
|
|
# ── 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]
|
|
|
|
|
|
# ── Local dev convenience: serve the static site from this app ──────────────────
|
|
# In production NGINX serves html/ and only proxies /api/ here, so this app never
|
|
# receives "/" requests, and the api Docker image doesn't even include html/ — so
|
|
# this mount stays inactive there. Locally (plain uvicorn, no NGINX) it lets you
|
|
# open the whole suite at http://localhost:8000/ with the API on the SAME origin,
|
|
# so the session cookie just works (no CORS, no Secure-cookie headache).
|
|
#
|
|
# Mounted LAST so the /api/* routes above always match first.
|
|
_html_dir = os.path.join(os.path.dirname(__file__), "..", "html")
|
|
if os.path.isdir(_html_dir):
|
|
app.mount("/", StaticFiles(directory=_html_dir, html=True), name="site")
|