Files
Project-SDE-WP-Suite/server/app.py
n.siegfried 20afb0e565 Add secure username/password login portal
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>
2026-06-25 16:55:54 -05:00

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")