Projects become the top-level container; SOPs and Work Packages belong to one. Backend: - New projects table + CRUD (/api/projects). - sops.project_id (FK, cascade) and work_packages.project_id added; list/latest/metrics endpoints accept a project_id filter. Front end (now under html/): - project-data.js: shared API-first ProjectData adapter with localStorage fallback + active-project helpers. - Home page: removed "About This Suite"; added a Project picker (create / use sample / select). Tool cards stay hidden until a project is active and carry &project=<id>; hero shows the active project. - Suite reads ?project, resolves it, shows it in the header, and prefills the SOP project fields; passes &project into the WP-creator iframe. - WP creator stamps projectId onto saved packages. SOP/WP localStorage is not yet namespaced per project (next step). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
6.4 KiB
Python
138 lines
6.4 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
|
|
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")
|
|
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=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, "issued_at": _iso(self.issued_at),
|
|
"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 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),
|
|
}
|
|
|
|
|
|
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
|
return dt.isoformat() if dt else None
|