Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
264 lines
14 KiB
Python
264 lines
14 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; `role` is
|
|
either 'admin' (can manage users) or 'user'."""
|
|
__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="user") # 'admin' | 'user'
|
|
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, "is_active": self.is_active,
|
|
"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
|