Site comments (8/3) - BIM card: LOD removed, IFF # added next to the coordination status, and required once that status is "Signed off (IFF)" — an unnumbered sign-off isn't traceable. A LOD already stored on a package is preserved and shown as legacy, not blanked. - The blue "from SOP types" subtext under a field is now a SOP chip on the label with the detail in a tooltip. The chip stays visible rather than hover-only: field tablets have no hover, and "this came from the SOP" is the part that matters. The hint elements stay in the DOM (hidden) so the code writing to them keeps working; an observer mirrors their text into the tooltip. - Specification Section is no longer typed per package. Each WP type carries a spec section on the SOP; the field is read-only in the Creator and follows the type, with the SOP's spec folder linked underneath. This reads both spec comments as one intent — stop typing it, derive it. - Assignees and Distribution are multi-selects over the SOP project team, showing each person's job function, with the CM pre-added to Distribution (removable per package) and a free-text option for people with no account. The stored display strings are unchanged so print/export/dashboard keep working; account ids ride alongside for the notification work in wave 3. Localization + time - Per-user locale/timezone (Language & time in the user menu), an app-wide default in the admin console, then the browser. Timezones are validated against the server's zoneinfo and the picker is fed from it. Calendar dates are formatted from their parts so a due date never reads a day early in another zone. - Every displayed timestamp now goes through the shared helpers. Top-bar chrome - Project switcher beside the logo and a centered global search, injected into either generation of top bar; skipped in an iframe so the embedded Creator doesn't get a second one. Ctrl/Cmd-K focuses search. - GET /api/search covers work packages, projects and SOPs, scoped to the caller's projects, hiding archived packages, with LIKE wildcards escaped. Fixed along the way: showForm() cleared every card's inline display, which undid applyKind() — so the Package Type and BIM cards reappeared on an install-only project. Split out applyKindVisibility() and re-apply it there. Verified: 100 API checks on a fresh database (44 permissions + 22 password reset + 34 search/localization), 24 driven UI checks against the real Creator page in headless Chrome (SOP chips, both people pickers, spec auto-fill, critical tags, BIM suppression), and the chrome harness on both bar styles. Screenshots reviewed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
15 KiB
Python
280 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. 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."""
|
|
__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
|
|
)
|
|
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
|