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>
1075 lines
46 KiB
Python
1075 lines
46 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 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, 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, func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .db import Base, engine, get_db
|
|
from . import models, auth, notify
|
|
|
|
# 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)
|
|
|
|
# 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,..."
|
|
# 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=["*"], expose_headers=["X-Total-Count"],
|
|
)
|
|
|
|
|
|
# ── 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.
|
|
_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):
|
|
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)
|
|
|
|
|
|
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
|
|
# gated. List endpoints are scoped to accessible projects; single-resource and
|
|
# mutating endpoints raise 403 on no access.
|
|
def accessible_project_ids(db: Session, user: "models.User"):
|
|
"""Return the set of project ids the user may access, or None for 'all' (admin)."""
|
|
if user.role == "admin":
|
|
return None
|
|
rows = db.scalars(
|
|
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
|
|
).all()
|
|
return set(rows)
|
|
|
|
|
|
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> 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)
|
|
& (models.ProjectMember.project_id == project_id)
|
|
)
|
|
)
|
|
if not ok:
|
|
raise HTTPException(status_code=403, detail="You don't have access to this project")
|
|
|
|
|
|
def scope_to_access(stmt, column, db: Session, user: "models.User"):
|
|
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
|
|
ids = accessible_project_ids(db, user)
|
|
if ids is None:
|
|
return stmt
|
|
return stmt.where(column.in_(ids))
|
|
|
|
|
|
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
|
|
"""Add a (user, project) membership if it isn't already there."""
|
|
exists = db.scalar(
|
|
select(models.ProjectMember.id).where(
|
|
(models.ProjectMember.user_id == user_id)
|
|
& (models.ProjectMember.project_id == project_id)
|
|
)
|
|
)
|
|
if not exists:
|
|
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
|
|
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"
|
|
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")
|
|
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
|
|
|
|
|
|
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.
|
|
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)
|
|
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.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)
|
|
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, 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")
|
|
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}
|
|
|
|
|
|
# ── 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)):
|
|
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):
|
|
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)
|
|
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()
|
|
|
|
|
|
@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")
|
|
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}
|
|
|
|
|
|
@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
|
|
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)
|
|
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")
|
|
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}
|
|
|
|
|
|
@app.get("/api/auth/users/{user_id}/projects")
|
|
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
|
"""Which projects a user is assigned to, plus the full project list for the
|
|
assignment UI. (Admins implicitly access every project regardless.)"""
|
|
u = db.get(models.User, user_id)
|
|
if not u:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
|
|
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
|
return {
|
|
"user": u.to_dict(),
|
|
"assigned": list(assigned),
|
|
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
|
|
}
|
|
|
|
|
|
@app.put("/api/auth/users/{user_id}/projects")
|
|
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
|
"""Replace a user's project assignments with the given set."""
|
|
u = db.get(models.User, user_id)
|
|
if not u:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
|
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
|
for pid in valid:
|
|
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
|
|
db.commit()
|
|
return {"assigned": sorted(valid)}
|
|
|
|
|
|
# ── 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:
|
|
require_project_access(db, user, proj.id)
|
|
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
|
|
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":
|
|
grant_project_access(db, user.id, proj.id)
|
|
db.commit()
|
|
db.refresh(proj)
|
|
return proj.to_dict()
|
|
|
|
|
|
@app.get("/api/projects")
|
|
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
|
|
rows = db.scalars(stmt).all()
|
|
return [p.summary() for p in rows]
|
|
|
|
|
|
@app.get("/api/projects/{project_id}")
|
|
def get_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
proj = db.get(models.Project, project_id)
|
|
if not proj:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
require_project_access(db, user, proj.id)
|
|
return proj.to_dict()
|
|
|
|
|
|
@app.delete("/api/projects/{project_id}")
|
|
def delete_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
proj = db.get(models.Project, project_id)
|
|
if not proj:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
require_project_access(db, user, proj.id)
|
|
db.delete(proj)
|
|
db.commit()
|
|
return {"deleted": project_id}
|
|
|
|
|
|
# ── 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)
|
|
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
|
|
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()
|
|
|
|
|
|
@app.get("/api/sops")
|
|
def list_sops(project_id: Optional[str] = Query(None), full: bool = Query(False), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
stmt = select(models.Sop)
|
|
if project_id:
|
|
stmt = stmt.where(models.Sop.project_id == project_id)
|
|
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
|
|
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
|
|
# full=true includes the data JSON (the whole SOP document) for hydration;
|
|
# the default summary view stays lean for listing.
|
|
return [(s.to_dict() if full else s.summary()) for s in rows]
|
|
|
|
|
|
@app.get("/api/sops/latest")
|
|
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), 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)
|
|
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
|
|
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, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
sop = db.get(models.Sop, sop_id)
|
|
if not sop:
|
|
raise HTTPException(status_code=404, detail="SOP not found")
|
|
require_project_access(db, user, sop.project_id)
|
|
return sop.to_dict()
|
|
|
|
|
|
@app.delete("/api/sops/{sop_id}")
|
|
def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
sop = db.get(models.Sop, sop_id)
|
|
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}
|
|
|
|
|
|
# ── Work Packages ────────────────────────────────────────────────────────────
|
|
@app.post("/api/wps")
|
|
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)
|
|
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
|
|
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),
|
|
):
|
|
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)
|
|
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)
|
|
# 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]
|
|
|
|
|
|
@app.get("/api/wps/metrics")
|
|
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), 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).where(models.WorkPackage.archived_at.is_(None))
|
|
if project_id:
|
|
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
|
if sop_id:
|
|
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
|
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
|
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, user: models.User = Depends(auth.get_current_user), 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")
|
|
require_project_access(db, user, wp.project_id)
|
|
return wp.to_dict()
|
|
|
|
|
|
@app.delete("/api/wps/{wp_id}")
|
|
def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), 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")
|
|
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}
|
|
|
|
|
|
@app.post("/api/wps/{wp_id}/issue")
|
|
def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), 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")
|
|
require_project_access(db, user, wp.project_id)
|
|
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()
|
|
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()
|
|
|
|
|
|
@app.post("/api/wps/{wp_id}/status")
|
|
def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), 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")
|
|
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, 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"),
|
|
source=body.source or body.type or "",
|
|
sop_id=body.sop_id,
|
|
wp_id=body.wp_id,
|
|
step=body.step,
|
|
# 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,
|
|
)
|
|
db.add(c)
|
|
db.commit()
|
|
db.refresh(c)
|
|
return c.to_dict()
|
|
|
|
|
|
@app.post("/api/comments")
|
|
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, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
return _save_comment(body, db, user)
|
|
|
|
|
|
@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),
|
|
user: models.User = Depends(auth.get_current_user),
|
|
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)
|
|
# 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]
|
|
|
|
|
|
# ── 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")
|