"""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 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) 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 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