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:
469
server/app.py
469
server/app.py
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user