Three things asked for together, plus the migration they share (a7c31f9e5b02 —
additive, with database defaults for existing rows, so unlike the users.role
rewrite it is safe under a code-only rollback).
ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and
freezes read-only, without losing anything. Hiding is free: GET /api/projects
defaults to archived=exclude, so the home picker and the app-bar switcher drop it
without either of them changing. Freezing is require_project_writable(), which
every write that lands on a project now goes through — SOP and WP upserts (both
ends, so a package can be moved neither into nor out of an archived job), deletes,
issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403:
nobody lacks a permission, the project's state is the objection, and the browser
outbox in project-data.js retires 4xx ops instead of retrying them against a job
that will never accept them. Unarchive and delete stay allowed on purpose —
unarchive is the one write an archived project must take, and archive-then-delete
is a normal sequence.
DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the
people who belong on every job, so an admin says it once instead of remembering it
at each project creation. It runs on the is_new branch of upsert_project, which is
the single road into project creation, so the home page, the sample project and the
demo seeder are all covered and an update never re-runs it. Note the interaction
with the existing creator-grant: that row commits first and add_default_members
never overwrites an existing membership, so the creator grant now carries the
creator's own auto_add_role — otherwise someone flagged "Project Admin on every
job" would land as a plain member on the one job they started themselves.
ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in
an 860px card meant every cell wrapped, so one user occupied a ~100px band, the
action buttons stacked, and the table spilled outside its own white card. Now
1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls
sideways, and one spacing/control scale across all twelve cards. Truncation hangs
off a span inside the cell rather than max-width on the td, which table-layout:auto
treats as advisory — the usual reason cell ellipsis works in the stylesheet and not
on the page.
Found in review and fixed here rather than later:
- Stored XSS in the new Projects card, reachable by any signed-in user, landing in
an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used
in eight places escapes in the wrong order — uesc leaves backslashes alone, so a
stored name containing \' closes the JS string literal and the rest executes.
jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go
through it. The same bug, unescaped entirely, was in the SOP builder's custom
constraint names (escHandlerArg there). Three of seven test payloads escaped the
literal under the old idiom — one of them a plain name ending in a backslash, so
it was breaking buttons for innocent input too.
- _save_comment resolved wp_id and sop_id with if/elif but stored both, so a
payload naming a WP you may touch and a SOP you may not was authorised on the WP
alone and still wrote into the other project's thread. Both are checked now.
- Promoting an account to admin left its default-member flag set but invisible,
ready to take effect again on demotion — cleared, as set_user_auto_add already
does for the role.
smoketest.py and the console's own smoke test both assert the archive round trip:
out of the default list, present with archived=all, writes refused with 409, and
all of it undone by unarchiving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
301 lines
16 KiB
Python
301 lines
16 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)
|
|
# Archived projects are hidden from every picker, switcher and search but kept
|
|
# for the record — a finished job still has to be readable years later. Unlike
|
|
# an archived work package they are also FROZEN read-only: the API refuses any
|
|
# write to the project or to anything under it until an admin unarchives it.
|
|
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, "name": self.name, "number": self.number,
|
|
"client": self.client, "division": self.division, "site": self.site,
|
|
"sample": self.sample,
|
|
"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 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="")
|
|
# A PM or QA lead who belongs on every job shouldn't have to be ticked into each
|
|
# new project by hand, so flagged accounts get a ProjectMember row the moment a
|
|
# project is created. `auto_add_role` is the role they land with and shares
|
|
# ProjectMember.role's value space: '' = inherit the account's own role,
|
|
# otherwise 'project_admin' | 'project_user'.
|
|
auto_add_projects: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
auto_add_role: Mapped[str] = mapped_column(String(20), 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,
|
|
"auto_add_projects": bool(self.auto_add_projects),
|
|
"auto_add_role": self.auto_add_role or "",
|
|
"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
|