Predecessors are real references now - data.predecessors holds work-package ids, replacing a free-text SOP phase label that couldn't express "WP04 waits on WP02" and gated nothing. The SOP phase survives beside it as the descriptive "Sequence phase" field. - readiness() has two gates: constraints clear AND every predecessor Closed. The banner, sticky bar, left rail, dashboard Gates column and the ready counters all reflect the second one. - Enforced server-side by enforce_release_gates() on every path that sets a status — the plain upsert included, since that's how the browser and the offline outbox save. /issue and /status would otherwise have been ways around it. - Cycles are refused directly and through a chain, with a message naming the package that already waits on this one. The Creator's picker also hides itself and its own descendants, so a cycle is hard to build in the first place. - A deleted predecessor does not block: it would freeze everything downstream of a package someone removed. - The gate is refusable, on purpose. Planners release ahead of upstream close-out, so an explicit reason (data.gateOverride) allows it, gets a gate_overridden audit event naming what was skipped, and prints on the package. A blank reason is not an override, and changing the predecessor set clears it. The dashboard won't release a blocked package at all — it points at the form where the reason is captured. Critical constraints reopened after release - Reopening a SOP-critical constraint on a released package emails the owner, PM, CM and the package's distribution list (minus whoever did it) and writes a constraint_reopened audit event. - Detected by diffing the incoming constraints against the stored ones inside the normal upsert rather than via a new endpoint: the sync outbox only replays POST /api/wps, so a dedicated route would be lost offline. It fires only on a real cleared→open transition, so re-saving an already-open constraint doesn't re-announce, and never before release or for a non-critical constraint. - Bodies carry the constraint name, WP number and a link — never package contents. Verified: 139 API checks on one fresh database (44 permissions + 22 password reset + 34 search/localization + 39 gates/notifications), including every bypass path, cycle shapes, the deleted-predecessor case, blank-reason overrides, and the four recipients confirmed both in the outbox and on the wire against a local SMTP sink. 27 driven UI checks against the real Creator page in headless Chrome covering the picker, the override prompt (accept and cancel), override invalidation, the cycle exclusions and the dashboard refusal. Screenshots reviewed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1596 lines
71 KiB
Python
1596 lines
71 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 time import monotonic
|
|
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 auth.is_admin(user):
|
|
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 auth.is_admin(user):
|
|
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 require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
|
|
what: str = "this action") -> None:
|
|
"""Destructive / baseline-changing operations: deleting a work package or a
|
|
project, and editing a SOP that has already been completed. Requires project
|
|
access AND the project_admin (or admin) permissions role."""
|
|
require_project_access(db, user, project_id)
|
|
if not auth.is_project_admin(user):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"{what} requires the Project Admin permissions role",
|
|
)
|
|
|
|
|
|
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 auth.is_admin(u):
|
|
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
|
|
bim_enabled: Optional[bool] = None
|
|
default_locale: Optional[str] = None
|
|
default_timezone: 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 = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
|
project_role: str = "" # job function on the project (no permissions)
|
|
|
|
|
|
class ProjectRoleIn(BaseModel):
|
|
project_role: str = ""
|
|
|
|
|
|
class PreferencesIn(BaseModel):
|
|
# Empty string clears the preference (fall back to the app default, then the
|
|
# browser). None means "leave this one alone".
|
|
locale: Optional[str] = None
|
|
timezone: Optional[str] = None
|
|
|
|
|
|
class ForgotPasswordIn(BaseModel):
|
|
username: str = "" # username or email
|
|
|
|
|
|
class ResetPasswordIn(BaseModel):
|
|
token: str
|
|
new_password: str
|
|
|
|
|
|
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 # permissions role — see auth.ROLES
|
|
|
|
|
|
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}
|
|
|
|
|
|
# ── Self-service password reset (needs email switched on) ──────────────────────
|
|
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
|
|
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
|
|
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
|
|
# restart — deliberately simple; the token expiry is the real control.
|
|
_reset_last: dict[str, float] = {}
|
|
|
|
|
|
def _reset_throttled(request: Request, username: str) -> bool:
|
|
now = monotonic()
|
|
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
|
|
prev = _reset_last.get(key)
|
|
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
|
|
return True
|
|
_reset_last[key] = now
|
|
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
|
|
cutoff = now - RESET_COOLDOWN_SECONDS
|
|
for k in [k for k, t in _reset_last.items() if t < cutoff]:
|
|
_reset_last.pop(k, None)
|
|
return False
|
|
|
|
|
|
def reset_body(user: "models.User", link: str, minutes: int) -> str:
|
|
# No account detail beyond the username, and no customer data — same rule as
|
|
# the assignment mail. The link is the only sensitive thing in here.
|
|
who = user.full_name or user.username
|
|
return (
|
|
f"Hi {who},\n\n"
|
|
f"A password reset was requested for your Work Package Suite account "
|
|
f"({user.username}).\n\n"
|
|
f"Set a new password:\n{link}\n\n"
|
|
f"The link expires in {minutes} minutes and can only be used once. "
|
|
f"If you didn't request this, you can ignore this email — your current "
|
|
f"password still works.\n"
|
|
)
|
|
|
|
|
|
@app.get("/api/auth/reset-available")
|
|
def reset_available(db: Session = Depends(get_db)):
|
|
"""Whether the login page should offer 'Forgot password'. Self-service reset
|
|
depends entirely on outbound email, so it's off unless email is enabled AND
|
|
SMTP is configured — otherwise the only route is an admin reset."""
|
|
s = notify.get_settings(db)
|
|
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
|
|
|
|
|
|
@app.post("/api/auth/forgot-password")
|
|
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
|
|
"""Email a reset link. Always returns the same 200 response whether or not the
|
|
account exists — this endpoint is unauthenticated, so it must not become a
|
|
username/email oracle. Failures are recorded in the audit log instead."""
|
|
s = notify.get_settings(db)
|
|
if not (s.get("email_enabled") and notify.smtp_ready(s)):
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
|
|
)
|
|
if _reset_throttled(request, body.username):
|
|
# Same shape as the success response — no oracle, no mail bomb.
|
|
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
|
user = auth.find_user(db, body.username)
|
|
if user and user.is_active and user.email:
|
|
base = (s.get("app_base_url") or "").rstrip("/")
|
|
token = auth.create_reset_token(user)
|
|
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
|
|
sent = notify.send_now(
|
|
db, user.email,
|
|
"Work Package Suite — reset your password",
|
|
reset_body(user, link, auth.RESET_MINUTES),
|
|
)
|
|
log_event(db, user.username, "password_reset_requested", "user", user.id,
|
|
summary=user.username, detail={"emailed": bool(sent)})
|
|
db.commit()
|
|
else:
|
|
# Log the miss for the admin's benefit; the caller can't tell the difference.
|
|
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
|
|
summary=(body.username or "")[:200],
|
|
detail={"reason": "no account, inactive, or no email on file"})
|
|
db.commit()
|
|
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
|
|
|
|
|
@app.post("/api/auth/reset-password")
|
|
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
|
|
"""Complete a reset using the emailed token. The token carries the user's
|
|
token_version, and finishing a reset bumps it — so the link is single-use and
|
|
every existing session for that account is signed out."""
|
|
claims = auth.decode_reset_token(body.token or "")
|
|
if not claims:
|
|
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
|
|
user = db.get(models.User, claims.get("sub"))
|
|
if not user or not user.is_active:
|
|
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
|
|
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
|
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
|
|
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 # burns the link + all sessions
|
|
# A completed reset also clears any login lockout — the person has proven
|
|
# control of the mailbox, so there's nothing left to throttle.
|
|
user.failed_attempts = 0
|
|
user.locked_until = None
|
|
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
|
|
db.commit()
|
|
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()}
|
|
|
|
|
|
# ── Display preferences (self-service) ─────────────────────────────────────────
|
|
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$")
|
|
|
|
|
|
def valid_timezone(tz: str) -> bool:
|
|
"""True if this is a real IANA zone name on this machine."""
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
ZoneInfo(tz)
|
|
return True
|
|
except Exception: # noqa: BLE001 — unknown key, missing tzdata, bad type
|
|
return False
|
|
|
|
|
|
@app.get("/api/timezones")
|
|
def list_timezones(_user: models.User = Depends(auth.get_current_user)):
|
|
"""IANA zone names for the preferences picker, so the list matches what the
|
|
server will actually accept."""
|
|
try:
|
|
from zoneinfo import available_timezones
|
|
return sorted(available_timezones())
|
|
except Exception: # noqa: BLE001 — no tzdata: let the client fall back
|
|
return []
|
|
|
|
|
|
@app.post("/api/auth/preferences")
|
|
def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
"""A user's own locale / timezone. Empty string clears the preference; the app
|
|
default (admin console) applies next, then the browser's own settings."""
|
|
if body.locale is not None:
|
|
loc = body.locale.strip()
|
|
if loc and not _LOCALE_RE.match(loc):
|
|
raise HTTPException(status_code=400, detail="Locale must be a language tag like 'en-US' or 'es'.")
|
|
user.locale = loc[:20]
|
|
if body.timezone is not None:
|
|
tz = body.timezone.strip()
|
|
if tz and not valid_timezone(tz):
|
|
raise HTTPException(status_code=400, detail="Unknown time zone. Pick one from the list.")
|
|
user.timezone = tz[:60]
|
|
db.commit()
|
|
db.refresh(user)
|
|
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 auth.ROLES:
|
|
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
|
|
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,
|
|
project_role=body.project_role.strip()[:120],
|
|
)
|
|
db.add(u)
|
|
log_event(db, _admin, "user_created", "user", u.id, summary=u.username,
|
|
detail={"role": u.role, "project_role": u.project_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 PERMISSIONS role (admin / project_admin / project_user).
|
|
Their job function on the project is separate — see set_user_project_role.
|
|
|
|
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 auth.ROLES:
|
|
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(auth.ROLES)}")
|
|
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 auth.is_admin(u) and body.role != auth.ROLE_ADMIN:
|
|
other_admins = db.scalars(
|
|
select(models.User.id).where(
|
|
(models.User.role == auth.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.post("/api/auth/users/{user_id}/project-role")
|
|
def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
|
"""Set a user's job function on the project (Project Manager, Superintendent,
|
|
…). Purely descriptive — it grants nothing. This is what the SOP team pickers
|
|
and notification routing read, so it's worth keeping accurate."""
|
|
u = db.get(models.User, user_id)
|
|
if not u:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
old = u.project_role or ""
|
|
u.project_role = (body.project_role or "").strip()[:120]
|
|
log_event(db, admin, "project_role_changed", "user", u.id, summary=u.username,
|
|
detail={"from": old, "to": u.project_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 not auth.is_admin(user):
|
|
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")
|
|
# Cascades to every SOP and work package on the project — Project Admin only.
|
|
require_project_admin(db, user, proj.id, "Deleting a project")
|
|
log_event(db, user, "deleted", "project", proj.id, project_id=proj.id,
|
|
summary=(proj.name or proj.number or 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)
|
|
# The SOP is the project's baseline: once it's been completed, changing it
|
|
# is a Project Admin action. Authoring and revising a draft is open to any
|
|
# project member, including marking it complete the first time.
|
|
if sop.complete:
|
|
require_project_admin(db, user, sop.project_id, "Changing a completed SOP")
|
|
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_admin(db, user, sop.project_id, "Deleting a SOP")
|
|
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}
|
|
|
|
|
|
# ── Release gates (constraints + predecessors) ─────────────────────────────────
|
|
# Status ladder, mirrored in the front end (wp-creation-app.js STATUS_ORDER).
|
|
STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"]
|
|
ISSUED_IDX = STATUS_ORDER.index("Issued")
|
|
DONE_STATUS = "Closed"
|
|
|
|
|
|
def _released(status: Optional[str]) -> bool:
|
|
"""Has this package been released to the field (Issued or anything after)?"""
|
|
try:
|
|
return STATUS_ORDER.index(status or "") >= ISSUED_IDX
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def predecessor_ids(data: Optional[dict]) -> list[str]:
|
|
"""Work-package ids this package waits on. Ignores anything malformed rather
|
|
than failing a save — a bad id simply can't block."""
|
|
raw = (data or {}).get("predecessors") or []
|
|
if not isinstance(raw, list):
|
|
return []
|
|
return [p for p in raw if isinstance(p, str) and _ID_RE.match(p)]
|
|
|
|
|
|
def gate_override(data: Optional[dict]) -> Optional[dict]:
|
|
"""A deliberate, reasoned override of the predecessor gate. Planners legitimately
|
|
need to stage packages ahead of the work finishing, so the gate is refusable —
|
|
but only explicitly, and it lands in the audit log."""
|
|
ov = (data or {}).get("gateOverride")
|
|
if isinstance(ov, dict) and str(ov.get("reason") or "").strip():
|
|
return ov
|
|
return None
|
|
|
|
|
|
def blocking_predecessors(db: Session, wp_id: Optional[str], data: Optional[dict]) -> list[dict]:
|
|
"""Predecessors that are not Closed yet. A predecessor that no longer exists is
|
|
NOT blocking — a deleted package must not freeze everything downstream."""
|
|
ids = [p for p in predecessor_ids(data) if p != wp_id]
|
|
if not ids:
|
|
return []
|
|
rows = db.scalars(select(models.WorkPackage).where(models.WorkPackage.id.in_(ids))).all()
|
|
return [
|
|
{"id": r.id, "number": r.number, "subject": r.subject, "status": r.status}
|
|
for r in rows if r.status != DONE_STATUS
|
|
]
|
|
|
|
|
|
def check_predecessor_cycle(db: Session, wp_id: str, data: Optional[dict]) -> None:
|
|
"""Refuse a predecessor set that would make A wait on itself (directly or
|
|
through a chain). Walks the graph from the proposed predecessors; the visited
|
|
set also bounds the walk, so a cycle that already exists elsewhere in the data
|
|
can't spin here."""
|
|
start = [p for p in predecessor_ids(data)]
|
|
if wp_id in start:
|
|
raise HTTPException(status_code=400, detail="A work package cannot be its own predecessor.")
|
|
seen, stack = set(), list(start)
|
|
while stack:
|
|
cur = stack.pop()
|
|
if cur in seen:
|
|
continue
|
|
seen.add(cur)
|
|
row = db.get(models.WorkPackage, cur)
|
|
if row is None:
|
|
continue
|
|
nxt = predecessor_ids(row.data)
|
|
if wp_id in nxt:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"That would create a circular dependency ({row.number or row.id} already waits on this package).",
|
|
)
|
|
stack.extend(n for n in nxt if n not in seen)
|
|
|
|
|
|
def enforce_release_gates(db: Session, wp_id: Optional[str], data: Optional[dict],
|
|
new_status: str, old_status: Optional[str]) -> None:
|
|
"""Refuse a move to Issued (or beyond) while a release gate is unmet. Applied to
|
|
every path that can set a status — the plain upsert included, since that's how
|
|
the browser and the offline outbox save."""
|
|
if not _released(new_status) or _released(old_status):
|
|
return # not a release transition
|
|
constraints = (data or {}).get("constraints") or []
|
|
open_names = [c.get("name") for c in constraints if isinstance(c, dict) and c.get("status") == "open"]
|
|
if open_names:
|
|
raise HTTPException(status_code=409, detail={
|
|
"message": "Open constraints block release", "open": open_names,
|
|
})
|
|
blockers = blocking_predecessors(db, wp_id, data)
|
|
if blockers and not gate_override(data):
|
|
raise HTTPException(status_code=409, detail={
|
|
"message": "Predecessors are not closed yet",
|
|
"blocking": [f"{b['number'] or b['id']} ({b['status']})" for b in blockers],
|
|
})
|
|
|
|
|
|
# ── Critical-constraint notification ───────────────────────────────────────────
|
|
# A constraint flagged CRITICAL on the SOP, reopened after the package was
|
|
# released, is announced by email. We detect it by comparing the incoming
|
|
# constraints against the stored ones during the normal upsert rather than adding a
|
|
# separate endpoint: the browser saves through the sync outbox, which only replays
|
|
# POST /api/wps, so anything hung off another route would be lost offline.
|
|
def reopened_critical(old_data: Optional[dict], new_data: Optional[dict]) -> list[str]:
|
|
old = {}
|
|
for c in (old_data or {}).get("constraints") or []:
|
|
if isinstance(c, dict) and c.get("name"):
|
|
old[c["name"]] = c.get("status")
|
|
out = []
|
|
for c in (new_data or {}).get("constraints") or []:
|
|
if not isinstance(c, dict) or not c.get("critical"):
|
|
continue
|
|
name = c.get("name")
|
|
if not name or c.get("status") != "open":
|
|
continue
|
|
if old.get(name) not in (None, "open"): # was cleared/na, now open
|
|
out.append(name)
|
|
return out
|
|
|
|
|
|
def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
|
|
"""PM + CM user ids from the project's most recent complete SOP."""
|
|
if not project_id:
|
|
return []
|
|
sop = db.scalars(
|
|
select(models.Sop)
|
|
.where((models.Sop.project_id == project_id) & (models.Sop.complete.is_(True)))
|
|
.order_by(models.Sop.updated_at.desc())
|
|
.limit(1)
|
|
).first()
|
|
if not sop:
|
|
return []
|
|
proj = (sop.data or {}).get("project") or {}
|
|
return [i for i in (proj.get("pmId"), proj.get("cmId")) if i]
|
|
|
|
|
|
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
|
|
actor: "models.User", link: str) -> str:
|
|
# Constraint names and a WP number only — no package contents, same rule as the
|
|
# assignment mail.
|
|
who = user.full_name or user.username
|
|
by = actor.full_name or actor.username
|
|
which = ", ".join(names)
|
|
return (
|
|
f"Hi {who},\n\n"
|
|
f"A critical constraint was reopened on {wp.number or 'a work package'} "
|
|
f"after it was released to the field, so the package is on hold.\n\n"
|
|
f"Constraint: {which}\n"
|
|
f"Reopened by: {by}\n\n"
|
|
f"Open the package:\n{link}\n"
|
|
)
|
|
|
|
|
|
def notify_critical_reopen(db: Session, wp: "models.WorkPackage", names: list[str],
|
|
actor: "models.User") -> list["models.Notification"]:
|
|
"""Owner + PM + CM + everyone on the package's distribution list, minus whoever
|
|
did it (they already know) and minus duplicates."""
|
|
ids = []
|
|
if wp.assignee_id:
|
|
ids.append(wp.assignee_id)
|
|
ids.extend(project_sop_team(db, wp.project_id))
|
|
ids.extend([i for i in ((wp.data or {}).get("distributionIds") or []) if isinstance(i, str)])
|
|
seen, out = set(), []
|
|
link = wp_link(db, wp)
|
|
for uid in ids:
|
|
if uid in seen or uid == actor.id:
|
|
continue
|
|
seen.add(uid)
|
|
u = db.get(models.User, uid)
|
|
if not u or not u.is_active:
|
|
continue
|
|
out.append(notify.enqueue(
|
|
db, user=u, kind="wp_constraint_reopened",
|
|
subject=f"On hold: {wp.number or 'work package'} — critical constraint reopened",
|
|
body=hold_body(u, wp, names, actor, link),
|
|
link=link, wp_id=wp.id, project_id=wp.project_id,
|
|
))
|
|
return out
|
|
|
|
|
|
# ── 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
|
|
old_data = None if is_new else (wp.data or {})
|
|
if wp is None:
|
|
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
|
db.add(wp)
|
|
# Predecessors: reject a cycle, and refuse a release while a gate is unmet.
|
|
# Both run before anything is written so a rejected save changes nothing.
|
|
wp_id_for_checks = body.id or wp.id
|
|
check_predecessor_cycle(db, wp_id_for_checks, body.data)
|
|
enforce_release_gates(db, wp_id_for_checks, body.data, body.status, old_status)
|
|
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)
|
|
notifs = []
|
|
# A reasoned override of the predecessor gate is worth its own audit line —
|
|
# "released early, and here's why" is exactly what a reviewer looks for later.
|
|
ov = gate_override(body.data)
|
|
if ov and _released(wp.status) and not _released(old_status):
|
|
blockers = blocking_predecessors(db, wp.id, body.data)
|
|
log_event(db, user, "gate_overridden", "wp", wp.id, project_id=wp.project_id,
|
|
summary=(wp.number or wp.subject or wp.id),
|
|
detail={"reason": str(ov.get("reason"))[:300],
|
|
"blocking": [b["number"] or b["id"] for b in blockers]})
|
|
# A critical constraint reopened after release: log it and tell the people who
|
|
# need to know (owner, PM, CM, distribution).
|
|
if not is_new and _released(old_status):
|
|
reopened = reopened_critical(old_data, wp.data)
|
|
if reopened:
|
|
log_event(db, user, "constraint_reopened", "wp", wp.id, project_id=wp.project_id,
|
|
summary=(wp.number or wp.subject or wp.id),
|
|
detail={"critical": reopened, "status": wp.status})
|
|
notifs.extend(notify_critical_reopen(db, wp, reopened, user))
|
|
# 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:
|
|
notifs.append(notif)
|
|
for n in notifs:
|
|
background_tasks.add_task(notify.deliver, n.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")
|
|
# Deleting a work package is irreversible — Project Admin only. A project_user
|
|
# who wants one out of the way can archive it instead (reversible).
|
|
require_project_admin(db, user, wp.project_id, "Deleting a work package")
|
|
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 while a release gate is unmet:
|
|
any open constraint, or a predecessor package that isn't Closed."""
|
|
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)
|
|
enforce_release_gates(db, wp.id, wp.data, "Issued", wp.status)
|
|
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
|
|
# Same gates as /issue — this route must not be a way around them.
|
|
enforce_release_gates(db, wp.id, wp.data, body.status, old_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]
|
|
|
|
|
|
# ── Global search ──────────────────────────────────────────────────────────────
|
|
def _like_term(q: str) -> str:
|
|
"""Escape LIKE wildcards so a user searching for '100%' or 'a_b' gets what they
|
|
typed rather than a pattern. Paired with escape='\\' on the comparison."""
|
|
return "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_").lower() + "%"
|
|
|
|
|
|
@app.get("/api/search")
|
|
def global_search(
|
|
q: str = Query("", min_length=0, max_length=200),
|
|
limit: int = Query(8, ge=1, le=25),
|
|
user: models.User = Depends(auth.get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Type-ahead across projects, work packages and SOPs, scoped to the projects
|
|
the caller may access. Matches work-package number, subject, type and status,
|
|
project name/number/client/site, and SOP name/number."""
|
|
term = (q or "").strip()
|
|
if len(term) < 2:
|
|
return {"query": term, "projects": [], "wps": [], "sops": []}
|
|
pat = _like_term(term)
|
|
esc = "\\"
|
|
|
|
proj_stmt = select(models.Project).where(
|
|
func.lower(models.Project.name).like(pat, escape=esc)
|
|
| func.lower(models.Project.number).like(pat, escape=esc)
|
|
| func.lower(models.Project.client).like(pat, escape=esc)
|
|
| func.lower(models.Project.site).like(pat, escape=esc)
|
|
)
|
|
proj_stmt = scope_to_access(proj_stmt, models.Project.id, db, user)
|
|
projects = db.scalars(proj_stmt.order_by(models.Project.updated_at.desc()).limit(limit)).all()
|
|
|
|
wp_stmt = select(models.WorkPackage).where(
|
|
(models.WorkPackage.archived_at.is_(None))
|
|
& (
|
|
func.lower(models.WorkPackage.number).like(pat, escape=esc)
|
|
| func.lower(models.WorkPackage.subject).like(pat, escape=esc)
|
|
| func.lower(models.WorkPackage.type).like(pat, escape=esc)
|
|
| func.lower(models.WorkPackage.status).like(pat, escape=esc)
|
|
)
|
|
)
|
|
wp_stmt = scope_to_access(wp_stmt, models.WorkPackage.project_id, db, user)
|
|
wps = db.scalars(wp_stmt.order_by(models.WorkPackage.updated_at.desc()).limit(limit)).all()
|
|
|
|
sop_stmt = select(models.Sop).where(
|
|
func.lower(models.Sop.name).like(pat, escape=esc)
|
|
| func.lower(models.Sop.number).like(pat, escape=esc)
|
|
)
|
|
sop_stmt = scope_to_access(sop_stmt, models.Sop.project_id, db, user)
|
|
sops = db.scalars(sop_stmt.order_by(models.Sop.updated_at.desc()).limit(limit)).all()
|
|
|
|
# Project names for the WP/SOP rows, so a result reads unambiguously when the
|
|
# same WP number exists on two jobs.
|
|
pids = {w.project_id for w in wps} | {s.project_id for s in sops}
|
|
pids.discard(None)
|
|
names = {}
|
|
if pids:
|
|
for p in db.scalars(select(models.Project).where(models.Project.id.in_(pids))).all():
|
|
names[p.id] = p.name or p.number or p.id
|
|
|
|
return {
|
|
"query": term,
|
|
"projects": [{"id": p.id, "name": p.name, "number": p.number, "client": p.client} for p in projects],
|
|
"wps": [{
|
|
"id": w.id, "number": w.number, "subject": w.subject, "type": w.type,
|
|
"status": w.status, "project_id": w.project_id,
|
|
"project_name": names.get(w.project_id, ""),
|
|
} for w in wps],
|
|
"sops": [{
|
|
"id": s.id, "name": s.name, "number": s.number, "complete": s.complete,
|
|
"project_id": s.project_id, "project_name": names.get(s.project_id, ""),
|
|
} for s in sops],
|
|
}
|
|
|
|
|
|
# ── 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.get("/api/app-flags")
|
|
def get_app_flags(_user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
|
"""Feature flags every signed-in page reads (e.g. whether the BIM/VDC tooling
|
|
is switched on). No secrets — safe for any authenticated user."""
|
|
return notify.app_flags(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}
|
|
# Localization defaults are validated the same way a user's own preference is,
|
|
# so a typo can't leave every page formatting dates against a bogus zone.
|
|
loc = (patch.get("default_locale") or "").strip()
|
|
if loc and not _LOCALE_RE.match(loc):
|
|
raise HTTPException(status_code=400, detail="Default locale must be a language tag like 'en-US'.")
|
|
tz = (patch.get("default_timezone") or "").strip()
|
|
if tz and not valid_timezone(tz):
|
|
raise HTTPException(status_code=400, detail="Unknown default time zone.")
|
|
if "default_locale" in patch:
|
|
patch["default_locale"] = loc
|
|
if "default_timezone" in patch:
|
|
patch["default_timezone"] = tz
|
|
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 auth.is_admin(user)):
|
|
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 == auth.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, "project_role": u.project_role or "",
|
|
"role": auth.normalize_role(u.role)})
|
|
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")
|