Files
Project-SDE-WP-Suite/server/models.py
n.siegfried fcba74b584 Fix the WP navigator and the squeezed embedded layout; add per-project permissions
Layout — the reported "skinny scrolling windows"
- .content-area capped the whole suite at 1000px, so on a 1920 screen the embedded
  Work Package Creator ran in a ~930px column with its own scrollbar inside the
  page's. The wizard now caps at 1700px and the Creator/Dashboard tab goes
  full-bleed: the iframe fills the window below the app chrome and owns the only
  scrollbar. Needed `flex: none` on the content area — as a `flex: 1` item its
  flex-basis overrode `height`, leaving the used height indefinite so the child's
  `height: 100%` collapsed the iframe to its 150px default.
- The SOP wizard's fields were one per row; they now flow into ~340px columns.

Navigator — now an auto-hiding drawer
- It was a fixed 262px column that stole width from the form AND was hidden below
  1100px, so embedded (the normal path) it never appeared at all — that's the
  "broken side menu". It's now an overlay drawer behind a slim always-visible edge
  handle: hover or tap to open, move away / Escape / pick a package to close, or pin
  it to keep it open (pinned shifts the form and the page chrome across, and is
  remembered). A gutter keeps the handle off the section-nav chips.

Bugs found while checking the site over
- collectStepData() still read the SOP team fields as text inputs, but wave 1 made
  them account pickers — so it wrote a user ID into state.team.pm where the display
  NAME belongs, and the SOP would print `user_ab12…` as the PM. Now synced properly
  from the pickers.
- loadSampleData() set .value on those selects with fictional names; setting an
  unmatched value on a <select> silently does nothing, so the sample lost its team.
  It now stores them as names without an account, which the picker shows as
  "(no account)".
- My earlier CSS block replacement had deleted the SOP-chip, people-picker and
  critical-tag styles. Restored.

Same picker everywhere the SOP names someone
- Sign-off roles (step 3, required and optional) are account pickers now, storing
  userId alongside the name, so a signature belongs to an account that can be
  notified. Titles stay free text.

Per-project permissions (asked for: "change project permissions for individual users")
- project_members.role overrides the account's role on that project, so a PM on one
  job can be a Project User on another. Empty = inherit; app admin is admin
  everywhere. effective_role() feeds require_project_admin, so WP delete, completed-
  SOP edits and project delete are all judged per project.
- Project access is now its own column in the admin console (it was buried among the
  action buttons, which is why it couldn't be found), showing the project count per
  account; the dialog sets access plus the role on each project.
- The members endpoint reports each person's effective role on that project.

Verified: 157 API checks across five suites on clean databases (44 permissions +
22 password reset + 34 search/localization + 39 gates/notifications + 18 new
per-project permission checks), 16 drawer-behaviour + 4 pinned-mode UI checks driven
in headless Chrome, and probes confirming the team/sign-off pickers populate and no
longer corrupt state.team on step navigation. Screenshots reviewed at 1920x1080.

Service-worker cache bumped to v3 so browsers pick up the new shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:22:19 -07:00

285 lines
15 KiB
Python

"""ORM models for the Work Package Suite.
Three tables:
- sops one row per project SOP (the configuration baseline)
- work_packages one row per IWP, optionally linked to a SOP
- comments feedback / review comments from any page
The full client document for a SOP or WP is kept verbatim in a JSON `data`
column, with the most-queried fields promoted to real columns for listing and
filtering. IDs are short strings (client- or server-generated) so the browser
can upsert without round-tripping a sequence.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Project(Base):
"""A construction project — the top-level container. SOPs and Work Packages
belong to a project so the suite can be used for many jobs at once."""
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(300), default="")
number: Mapped[str] = mapped_column(String(100), default="", index=True)
client: Mapped[str] = mapped_column(String(300), default="")
division: Mapped[str] = mapped_column(String(200), default="")
site: Mapped[str] = mapped_column(String(300), default="")
sample: Mapped[bool] = mapped_column(Boolean, default=False)
data: Mapped[dict] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
def summary(self) -> dict:
return {
"id": self.id, "name": self.name, "number": self.number,
"client": self.client, "division": self.division, "site": self.site,
"sample": self.sample, "created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
}
def to_dict(self) -> dict:
return {**self.summary(), "data": self.data or {}}
class Sop(Base):
__tablename__ = "sops"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
project_id: Mapped[Optional[str]] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
)
name: Mapped[str] = mapped_column(String(300), default="")
number: Mapped[str] = mapped_column(String(100), default="")
complete: Mapped[bool] = mapped_column(Boolean, default=False)
data: Mapped[dict] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
def summary(self) -> dict:
return {
"id": self.id, "project_id": self.project_id, "name": self.name,
"number": self.number, "complete": self.complete, "created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
}
def to_dict(self) -> dict:
return {**self.summary(), "data": self.data or {}}
class WorkPackage(Base):
__tablename__ = "work_packages"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
project_id: Mapped[Optional[str]] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
)
sop_id: Mapped[Optional[str]] = mapped_column(
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
)
# parent_id links a discipline instance (WP01A) back to its master (WP01).
parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
number: Mapped[str] = mapped_column(String(120), default="")
subject: Mapped[str] = mapped_column(String(400), default="")
type: Mapped[str] = mapped_column(String(120), default="")
status: Mapped[str] = mapped_column(String(40), default="Draft")
# The accountable owner (a user id), for "My Work Packages" + assignment
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Archived packages are hidden from the default lists/dashboard but kept for
# the record (years-long projects accumulate hundreds of closed WPs).
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
data: Mapped[dict] = mapped_column(JSON, default=dict)
created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
def summary(self) -> dict:
return {
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
"type": self.type, "status": self.status, "assignee_id": self.assignee_id,
"issued_at": _iso(self.issued_at),
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
"created_by": self.created_by,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
}
def to_dict(self) -> dict:
return {**self.summary(), "data": self.data or {}}
class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt
hash (see server/auth.py). `username` is what people sign in with.
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
• project_role the person's JOB FUNCTION on the project (Project Manager,
Superintendent, QA/QC, …). Carries no permissions; it's what
the SOP team pickers and notification routing read.
"""
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
# Display preferences. Empty means "fall back to the app default, then to the
# browser". A stored value follows the person between devices, which matters on
# shared field tablets where the browser locale isn't theirs.
locale: Mapped[str] = mapped_column(String(20), default="") # BCP47, e.g. en-US
timezone: Mapped[str] = mapped_column(String(60), default="") # IANA, e.g. America/Chicago
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Bumped to invalidate all existing sessions for this user (e.g. on a password
# change). The value is embedded in the JWT and re-checked on every request.
token_version: Mapped[int] = mapped_column(Integer, default=0)
def to_dict(self) -> dict:
"""Public view of a user — NEVER includes the password hash."""
return {
"id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role,
"project_role": self.project_role or "", "is_active": self.is_active,
"locale": self.locale or "", "timezone": self.timezone or "",
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
}
class ProjectMember(Base):
"""Which users may access which projects, and what they may do there. A user
sees/operates on a project only if a row links them to it (admins bypass this
entirely). One row per (user, project) pair.
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
one job and a normal Project User on another. Empty means "inherit the account's
own role" (User.role), which is how every existing row behaves."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Comment(Base):
__tablename__ = "comments"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
source: Mapped[str] = mapped_column(String(40), default="", index=True) # home_feedback | sop_step_comment | wp_review_comment
sop_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
step: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
author: Mapped[str] = mapped_column(String(200), default="")
text: Mapped[str] = mapped_column(Text, default="")
page: Mapped[str] = mapped_column(String(200), default="")
extra: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
def to_dict(self) -> dict:
return {
"id": self.id, "source": self.source, "sop_id": self.sop_id, "wp_id": self.wp_id,
"step": self.step, "author": self.author, "text": self.text, "page": self.page,
"created_at": _iso(self.created_at),
}
class AuditLog(Base):
"""Append-only history: who changed what, when. Rows are written inside the
same transaction as the change they describe (see server/app.py: log_event),
so the trail can't drift from the data. `detail` holds a compact JSON summary
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
Not a ForeignKey to any entity on purpose — the log must survive the deletion
of the thing it describes (you still want "who deleted WP01, and when")."""
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
detail: Mapped[dict] = mapped_column(JSON, default=dict)
def to_dict(self) -> dict:
return {
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
"entity_type": self.entity_type, "entity_id": self.entity_id,
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
}
class AppSetting(Base):
"""Admin-editable application settings (feature flags, SMTP config, …) stored
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
the SMTP password are NOT stored here — they come from the environment."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(80), primary_key=True)
value: Mapped[dict] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class Notification(Base):
"""Outbox for user notifications (an in-app record + an optional email). A row
is written when something notable happens (e.g. a WP assignment); the email
sender processes it only when email notifications are enabled AND SMTP is set —
otherwise it's recorded as 'skipped'. See server/notify.py."""
__tablename__ = "notifications"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
email: Mapped[str] = mapped_column(String(200), default="")
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
subject: Mapped[str] = mapped_column(String(300), default="")
body: Mapped[str] = mapped_column(Text, default="")
link: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
error: Mapped[str] = mapped_column(String(400), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
"status": self.status, "error": self.error,
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
}
def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None