Add Python/FastAPI + PostgreSQL backend (Phase 1)
- server/: FastAPI app with SQLAlchemy models for sops, work_packages, comments - Endpoints for SOP/WP upsert+list+get+delete and comment create+list; /api/feedback kept as an alias so the existing client keeps working - Portable across engines (PostgreSQL prod, SQLite dev fallback) - requirements.txt, .env.example, and server/README.md (Postgres + systemd) - NGINX now proxies /api/ to the API (replaces the Power Automate hop; comments persist to SQL) - Rewrite DEPLOYMENT.md for the API + database architecture - Add .gitignore for venv/.env/sqlite Phase 2 (wire the client apps to the API) is next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
98
server/models.py
Normal file
98
server/models.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""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 Sop(Base):
|
||||
__tablename__ = "sops"
|
||||
|
||||
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="")
|
||||
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, "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)
|
||||
sop_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("sops.id", ondelete="SET NULL"), 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")
|
||||
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, "sop_id": self.sop_id, "number": self.number,
|
||||
"subject": self.subject, "type": self.type, "status": self.status,
|
||||
"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
|
||||
Reference in New Issue
Block a user