Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email

Brings the Work Package Suite from a browser-local prototype to a
multi-tenant, SQL-backed deployment hardened for customer IP.

Auth & access control
- Local username/password login (bcrypt + JWT in an HttpOnly cookie),
  admin-managed users, per-project membership, and project-scoped API access.
- Admin console: change user roles, view the audit trail, manage settings.

Security hardening
- CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via
  X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping.
- Login lockout, token_version session revocation, stronger password policy,
  fail-closed secret loading, encrypted (AES-256) database backups.

Persistence & schema
- SOPs and Work Packages are now DB-backed and shared across users, written
  through a durable client sync outbox that queues offline edits.
- Alembic migrations applied automatically on container start.

New capabilities
- Phase 2 dashboard (progress, gating, pagination, archive).
- Phase 3 PWA "Field View" with offline caching and auth fallback.
- WP owner assignment with OPTIONAL email notifications, OFF by default and
  toggled from the admin console. SMTP password is read only from the
  SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link,
  never customer IP.

Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:51:15 -07:00
parent dd37f1f551
commit 39b48055ff
48 changed files with 2867 additions and 507 deletions

View File

@@ -9,24 +9,40 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
Interactive docs: http://<host>/api/docs
"""
import os
import re
import uuid
from datetime import timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlparse
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
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 import select, delete, func
from sqlalchemy.orm import Session
from .db import Base, engine, get_db
from . import models, auth
from . import models, auth, notify
# Create tables on startup. (For schema changes later, switch to Alembic.)
Base.metadata.create_all(bind=engine)
# Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run.
# • Production (Postgres) owns its schema through Alembic migrations, which run
# at container start (`alembic upgrade head`, see Dockerfile / DEPLOYMENT.md).
# We must NOT create_all there, or it would race/collide with the migration.
if engine.dialect.name == "sqlite":
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url="/api/openapi.json")
# Interactive docs are handy in dev but hand an attacker the full API map in prod,
# so enable them only on the SQLite dev fallback (production runs on Postgres).
_docs_enabled = engine.dialect.name == "sqlite"
app = FastAPI(
title="Work Package Suite API",
docs_url="/api/docs" if _docs_enabled else None,
redoc_url=None,
openapi_url="/api/openapi.json" if _docs_enabled else None,
)
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
@@ -35,7 +51,7 @@ _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=["*"],
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
)
@@ -44,11 +60,33 @@ if _origins:
# 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.
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
def _csrf_ok(request: Request) -> bool:
"""CSRF defense-in-depth behind SameSite=Lax: when the browser sends an Origin
on a state-changing request, it must be same-origin (or an allowed CORS origin).
Non-browser clients (no Origin header) are unaffected."""
origin = request.headers.get("origin")
if not origin:
return True
if _origins and origin in _origins:
return True
try:
return urlparse(origin).netloc == request.headers.get("host", "")
except Exception:
return False
@app.middleware("http")
async def auth_gate(request: Request, call_next):
if request.method != "OPTIONS" and auth._needs_auth(request.url.path):
path = request.url.path
method = request.method
if method != "OPTIONS" and auth._needs_auth(path):
if not auth.is_request_authenticated(request):
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
return await call_next(request)
@@ -56,6 +94,16 @@ def gen_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}"
# Clients may supply their own resource ids (offline-first). Constrain them to a
# safe charset so an id can never carry HTML/JS that a UI might place in markup.
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
def check_id(v: Optional[str]) -> None:
if v and not _ID_RE.match(v):
raise HTTPException(status_code=400, detail="Invalid id format")
# ── Per-project access control ─────────────────────────────────────────────────
# A non-admin user may only touch projects they're a member of (project_members).
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
@@ -72,8 +120,12 @@ def accessible_project_ids(db: Session, user: "models.User"):
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
if user.role == "admin" or project_id is None:
if user.role == "admin":
return
if not project_id:
# Non-admins may not read/mutate resources with no project assignment
# (orphan/legacy rows); only admins can touch project-less data.
raise HTTPException(status_code=403, detail="This resource is not assigned to a project you can access")
ok = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user.id)
@@ -104,6 +156,54 @@ def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
# ── Audit trail ────────────────────────────────────────────────────────────────
def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str,
project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None:
"""Append an audit-trail row in the CURRENT transaction so it commits
atomically with the change it describes. `actor` may be a User or a username."""
who = actor.username if isinstance(actor, models.User) else (actor or "")
db.add(models.AuditLog(
id=gen_id("ev"), actor=who, action=action, entity_type=entity_type,
entity_id=entity_id or "", project_id=project_id, summary=(summary or "")[:400], detail=detail or {},
))
# ── Assignment ─────────────────────────────────────────────────────────────────
def require_assignable(db: Session, user_id: str, project_id: Optional[str]) -> None:
"""A WP can only be assigned to an active user who can access its project."""
u = db.get(models.User, user_id)
if not u or not u.is_active:
raise HTTPException(status_code=400, detail="Assignee is not a valid user")
if u.role == "admin":
return
ok = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user_id) & (models.ProjectMember.project_id == project_id)
)
)
if not ok:
raise HTTPException(status_code=400, detail="Assignee is not a member of this project")
def wp_link(db: Session, wp: "models.WorkPackage") -> str:
base = (notify.get_settings(db).get("app_base_url") or "").rstrip("/")
path = f"/work-package-suite.html?tab=wp&project={wp.project_id or ''}"
return (base + path) if base else path
def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str:
# Deliberately minimal — a WP number + a link, NOT the package contents (keeps
# customer IP inside the app, behind login).
who = actor.full_name or actor.username
name = assignee.full_name or assignee.username
return (
f"Hi {name},\n\n"
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n"
f"Open the Work Package Suite to view and action it:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite."
)
# ── Request bodies ───────────────────────────────────────────────────────────
class ProjectIn(BaseModel):
id: Optional[str] = None
@@ -136,14 +236,35 @@ class WpIn(BaseModel):
subject: str = ""
type: str = ""
status: str = "Draft"
assignee_id: Optional[str] = None
created_by: str = ""
data: dict[str, Any] = Field(default_factory=dict)
class SettingsIn(BaseModel):
model_config = ConfigDict(extra="ignore")
email_enabled: Optional[bool] = None
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_use_tls: Optional[bool] = None
smtp_username: Optional[str] = None
from_addr: Optional[str] = None
from_name: Optional[str] = None
app_base_url: Optional[str] = None
class TestEmailIn(BaseModel):
to: Optional[str] = None
class StatusIn(BaseModel):
status: str
class ArchiveIn(BaseModel):
archived: bool = True
class CommentIn(BaseModel):
# Tolerate any extra keys the feedback payload includes (timestamp, app, …).
model_config = ConfigDict(extra="allow")
@@ -191,22 +312,49 @@ class ActiveIn(BaseModel):
is_active: bool
class RoleIn(BaseModel):
role: str # 'admin' | 'user'
class ProjectAssignIn(BaseModel):
project_ids: list[str] = Field(default_factory=list)
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
@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."""
"""Verify credentials and, on success, set the HttpOnly session cookie.
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
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.
now = models.utcnow()
# Always run the hash comparison first — even for missing or locked accounts —
# so response timing doesn't leak which usernames exist. verify_password
# tolerates an empty hash.
valid = auth.verify_password(body.password, user.password_hash if user else "")
locked = user.locked_until if user else None
if locked is not None and locked.tzinfo is None:
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
if locked is not None and locked > now:
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
if not user or not valid:
if user:
user.failed_attempts = (user.failed_attempts or 0) + 1
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
user.failed_attempts = 0
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
db.commit()
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()
user.failed_attempts = 0
user.locked_until = None
user.last_login_at = now
db.commit()
token = auth.create_token(user)
auth.set_session_cookie(response, request, token)
@@ -226,13 +374,18 @@ def whoami(user: models.User = Depends(auth.get_current_user)):
@app.post("/api/auth/password")
def change_password(body: PasswordChangeIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
def change_password(body: PasswordChangeIn, request: Request, response: Response, 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")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
db.commit()
db.refresh(user)
# Keep this session logged in by re-issuing a cookie carrying the new version.
auth.set_session_cookie(response, request, auth.create_token(user))
return {"ok": True}
@@ -245,8 +398,9 @@ def list_users(_admin: models.User = Depends(auth.require_admin), db: Session =
@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")
problem = auth.password_problem(body.password, body.username, body.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
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):
@@ -260,6 +414,7 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
role=body.role,
)
db.add(u)
log_event(db, _admin, "user_created", "user", u.id, summary=u.username, detail={"role": u.role})
db.commit()
db.refresh(u)
return u.to_dict()
@@ -270,9 +425,11 @@ def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.Use
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")
problem = auth.password_problem(body.new_password, u.username, u.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
u.password_hash = auth.hash_password(body.new_password)
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
db.commit()
return {"ok": True}
@@ -285,10 +442,43 @@ def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(a
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
log_event(db, admin, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
summary=u.username, detail={"is_active": bool(body.is_active)})
db.commit()
return u.to_dict()
@app.post("/api/auth/users/{user_id}/role")
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Change a user's role (admin ↔ user). Admins can do this at any time.
Guards: you can't change your own role (avoids self-lockout), and the last
remaining admin can't be demoted (keeps the app manageable)."""
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
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 change your own role")
if u.role == "admin" and body.role != "admin":
other_admins = db.scalars(
select(models.User.id).where(
(models.User.role == "admin")
& (models.User.id != u.id)
& (models.User.is_active.is_(True))
)
).all()
if not other_admins:
raise HTTPException(status_code=400, detail="Can't remove the last admin account")
old_role = u.role
u.role = body.role
log_event(db, admin, "role_changed", "user", u.id, summary=u.username,
detail={"from": old_role, "to": body.role})
db.commit()
db.refresh(u)
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)
@@ -296,6 +486,7 @@ def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin),
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")
log_event(db, admin, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
db.delete(u)
db.commit()
return {"deleted": user_id}
@@ -334,6 +525,7 @@ def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User =
# ── Projects ─────────────────────────────────────────────────────────────────
@app.post("/api/projects")
def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
check_id(body.id)
proj = db.get(models.Project, body.id) if body.id else None
is_new = proj is None
if not is_new:
@@ -349,6 +541,8 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
proj.sample = body.sample
proj.created_by = body.created_by or proj.created_by
proj.data = body.data
log_event(db, user, "created" if is_new else "updated", "project", proj.id,
project_id=proj.id, summary=(proj.name or proj.number or proj.id))
db.commit()
# A project created by a non-admin auto-grants its creator access.
if is_new and user.role != "admin":
@@ -388,10 +582,12 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
# ── SOPs ─────────────────────────────────────────────────────────────────────
@app.post("/api/sops")
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
check_id(body.id)
require_project_access(db, user, body.project_id)
sop = db.get(models.Sop, body.id) if body.id else None
if sop is not None:
require_project_access(db, user, sop.project_id)
is_new = sop is None
if sop is None:
sop = models.Sop(id=body.id or gen_id("sop"))
db.add(sop)
@@ -401,6 +597,9 @@ def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user),
sop.complete = body.complete
sop.created_by = body.created_by or sop.created_by
sop.data = body.data
log_event(db, user, "completed" if body.complete else ("created" if is_new else "updated"),
"sop", sop.id, project_id=sop.project_id, summary=(sop.name or sop.number or sop.id),
detail={"complete": bool(body.complete)})
db.commit()
db.refresh(sop)
return sop.to_dict()
@@ -447,6 +646,8 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
summary=(sop.name or sop.number or sop.id))
db.delete(sop)
db.commit()
return {"deleted": sop_id}
@@ -454,11 +655,17 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
# ── Work Packages ────────────────────────────────────────────────────────────
@app.post("/api/wps")
def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
check_id(body.id)
check_id(body.parent_id)
check_id(body.assignee_id)
require_project_access(db, user, body.project_id)
wp = db.get(models.WorkPackage, body.id) if body.id else None
if wp is not None:
require_project_access(db, user, wp.project_id)
is_new = wp is None
old_status = None if is_new else wp.status
old_assignee = None if is_new else wp.assignee_id
if wp is None:
wp = models.WorkPackage(id=body.id or gen_id("wp"))
db.add(wp)
@@ -469,19 +676,52 @@ def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db
wp.subject = body.subject
wp.type = body.type
wp.status = body.status
new_assignee = body.assignee_id or None
if new_assignee:
require_assignable(db, new_assignee, body.project_id)
wp.assignee_id = new_assignee
wp.created_by = body.created_by or wp.created_by
wp.data = body.data
if is_new:
_act, _detail = "created", {"status": wp.status}
elif old_status != wp.status:
_act, _detail = "status_changed", {"from": old_status, "to": wp.status}
else:
_act, _detail = "updated", {"status": wp.status}
log_event(db, user, _act, "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail=_detail)
# Notify a newly-assigned owner (skip self-assignment).
notif = None
if new_assignee and new_assignee != old_assignee and new_assignee != user.id:
assignee = db.get(models.User, new_assignee)
if assignee:
link = wp_link(db, wp)
notif = notify.enqueue(
db, user=assignee, kind="wp_assigned",
subject=f"You were assigned {wp.number or 'a work package'}",
body=assign_body(assignee, wp, user, link),
link=link, wp_id=wp.id, project_id=wp.project_id,
)
log_event(db, user, "assigned", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail={"to": assignee.username})
db.commit()
db.refresh(wp)
if notif is not None:
background_tasks.add_task(notify.deliver, notif.id)
return wp.to_dict()
@app.get("/api/wps")
def list_wps(
response: Response,
project_id: Optional[str] = Query(None),
sop_id: Optional[str] = Query(None),
parent_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
q: Optional[str] = Query(None, description="search number / subject / type"),
archived: str = Query("exclude", description="exclude (default) | only | all"),
limit: Optional[int] = Query(None, ge=1, le=1000),
offset: int = Query(0, ge=0),
full: bool = Query(False),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
@@ -495,8 +735,25 @@ def list_wps(
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
if status:
stmt = stmt.where(models.WorkPackage.status == status)
if archived == "only":
stmt = stmt.where(models.WorkPackage.archived_at.is_not(None))
elif archived != "all":
stmt = stmt.where(models.WorkPackage.archived_at.is_(None)) # default: hide archived
if q and q.strip():
like = f"%{q.strip()}%"
stmt = stmt.where(
models.WorkPackage.number.ilike(like)
| models.WorkPackage.subject.ilike(like)
| models.WorkPackage.type.ilike(like)
)
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
# Report the pre-pagination total so the client can build a pager.
total = db.scalar(select(func.count()).select_from(stmt.subquery()))
response.headers["X-Total-Count"] = str(total or 0)
stmt = stmt.order_by(models.WorkPackage.updated_at.desc()).offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
rows = db.scalars(stmt).all()
# full=true includes the data JSON (full package document) so the creator can
# rehydrate everything in one request; default stays lean for listing.
return [(w.to_dict() if full else w.summary()) for w in rows]
@@ -507,7 +764,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
"""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)
stmt = select(models.WorkPackage).where(models.WorkPackage.archived_at.is_(None))
if project_id:
stmt = stmt.where(models.WorkPackage.project_id == project_id)
if sop_id:
@@ -560,6 +817,8 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id))
db.delete(wp)
db.commit()
return {"deleted": wp_id}
@@ -579,6 +838,8 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db:
raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names})
wp.status = "Issued"
wp.issued_at = models.utcnow()
log_event(db, user, "issued", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail={"to": "Issued"})
db.commit()
db.refresh(wp)
return wp.to_dict()
@@ -590,16 +851,147 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
old_status = wp.status
wp.status = body.status
if body.status == "Issued" and wp.issued_at is None:
wp.issued_at = models.utcnow()
if old_status != body.status:
log_event(db, user, "status_changed", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id), detail={"from": old_status, "to": body.status})
db.commit()
db.refresh(wp)
return wp.to_dict()
@app.post("/api/wps/{wp_id}/archive")
def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Archive (or unarchive) a Work Package — hides it from the default lists and
the dashboard without deleting it. Kept for the record on long-running jobs."""
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
was_archived = wp.archived_at is not None
if body.archived and not was_archived:
wp.archived_at = models.utcnow()
log_event(db, user, "archived", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id))
elif not body.archived and was_archived:
wp.archived_at = None
log_event(db, user, "unarchived", "wp", wp.id, project_id=wp.project_id,
summary=(wp.number or wp.subject or wp.id))
db.commit()
db.refresh(wp)
return wp.to_dict()
# ── Audit trail (history) ──────────────────────────────────────────────────────
@app.get("/api/audit")
def list_audit(
entity_type: Optional[str] = Query(None),
entity_id: Optional[str] = Query(None),
project_id: Optional[str] = Query(None),
action: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
"""History / audit trail. Pass entity_type+entity_id for one item's history,
or project_id for a project activity feed. Scoped to the caller's project
access; admins additionally see project-less events (user management)."""
stmt = select(models.AuditLog)
if entity_type:
stmt = stmt.where(models.AuditLog.entity_type == entity_type)
if entity_id:
stmt = stmt.where(models.AuditLog.entity_id == entity_id)
if action:
stmt = stmt.where(models.AuditLog.action == action)
if project_id:
require_project_access(db, user, project_id)
stmt = stmt.where(models.AuditLog.project_id == project_id)
# Non-admins only ever see events tied to a project they can access.
ids = accessible_project_ids(db, user)
if ids is not None:
stmt = stmt.where(models.AuditLog.project_id.in_(ids))
rows = db.scalars(stmt.order_by(models.AuditLog.at.desc()).limit(limit).offset(offset)).all()
return [e.to_dict() for e in rows]
# ── Settings (admin) ────────────────────────────────────────────────────────────
@app.get("/api/settings")
def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
return notify.public_settings(db)
@app.put("/api/settings")
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
patch = {k: v for k, v in body.model_dump().items() if v is not None}
saved = notify.save_settings(db, patch)
log_event(db, admin, "settings_updated", "settings", "notifications",
summary="notifications", detail={"email_enabled": bool(saved.get("email_enabled"))})
db.commit()
return notify.public_settings(db)
@app.post("/api/settings/test-email")
def send_test_email(body: TestEmailIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
s = notify.get_settings(db)
if not notify.smtp_ready(s):
raise HTTPException(status_code=400, detail="Set the SMTP host and From address first.")
to = (body.to or admin.email or "").strip()
if not to:
raise HTTPException(status_code=400, detail="No recipient — add an email to your account or pass 'to'.")
try:
notify.send_email(s, to, "Work Package Suite — test email",
"This is a test from the Work Package Suite. If you got this, SMTP is working.")
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"Send failed: {e}")
return {"ok": True, "to": to}
# ── Notifications + project members ─────────────────────────────────────────────
@app.get("/api/notifications")
def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500),
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Notification)
if not (all and user.role == "admin"):
stmt = stmt.where(models.Notification.user_id == user.id)
rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all()
return [n.to_dict() for n in rows]
@app.get("/api/projects/{project_id}/members")
def project_members(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Users who can be assigned WPs on this project — its members plus admins."""
require_project_access(db, user, project_id)
member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all())
members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else []
admins = db.scalars(select(models.User).where(models.User.role == "admin")).all()
out, seen = [], set()
for u in list(members) + list(admins):
if u.id in seen or not u.is_active:
continue
seen.add(u.id)
out.append({"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email})
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
return out
# ── Comments / feedback ──────────────────────────────────────────────────────
def _save_comment(body: CommentIn, db: Session) -> dict:
def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict:
# A comment tied to a WP/SOP requires access to that resource's project, so
# a user can't write into another project's review thread.
if body.wp_id:
wp = db.get(models.WorkPackage, body.wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
elif body.sop_id:
sop = db.get(models.Sop, body.sop_id)
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
extra = body.model_extra or {}
c = models.Comment(
id=gen_id("c"),
@@ -607,7 +999,9 @@ def _save_comment(body: CommentIn, db: Session) -> dict:
sop_id=body.sop_id,
wp_id=body.wp_id,
step=body.step,
author=(body.author or body.name or "Anonymous"),
# Attribution comes from the authenticated session, NEVER the client
# payload — otherwise comments could be forged as another user.
author=(user.full_name or user.username),
text=body.text or "",
page=body.page or "",
extra=extra,
@@ -619,14 +1013,14 @@ def _save_comment(body: CommentIn, db: Session) -> dict:
@app.post("/api/comments")
def create_comment(body: CommentIn, db: Session = Depends(get_db)):
return _save_comment(body, db)
def create_comment(body: CommentIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
return _save_comment(body, db, user)
# 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)
def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
return _save_comment(body, db, user)
@app.get("/api/comments")
@@ -635,6 +1029,7 @@ def list_comments(
sop_id: Optional[str] = Query(None),
wp_id: Optional[str] = Query(None),
step: Optional[int] = Query(None),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
stmt = select(models.Comment)
@@ -646,6 +1041,22 @@ def list_comments(
stmt = stmt.where(models.Comment.wp_id == wp_id)
if step is not None:
stmt = stmt.where(models.Comment.step == step)
# Non-admins see general app feedback plus comments on WPs/SOPs in their own
# projects only — never another project's review threads.
ids = accessible_project_ids(db, user)
if ids is not None:
acc_wp = select(models.WorkPackage.id).where(models.WorkPackage.project_id.in_(ids))
acc_sop = select(models.Sop.id).where(models.Sop.project_id.in_(ids))
# The "general feedback" branch is ONLY for comments not tied to any
# WP/SOP — otherwise a project-scoped comment tagged source=home_feedback
# by the client would leak across projects. Project-tied comments are
# visible strictly by project membership.
stmt = stmt.where(
((models.Comment.source == "home_feedback")
& models.Comment.wp_id.is_(None) & models.Comment.sop_id.is_(None))
| (models.Comment.wp_id.in_(acc_wp))
| (models.Comment.sop_id.in_(acc_sop))
)
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
return [c.to_dict() for c in rows]