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:
12
server/.env.example
Normal file
12
server/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Copy to .env (dev) or set these in the systemd unit (prod).
|
||||
|
||||
# PostgreSQL connection (production). Format:
|
||||
# postgresql+psycopg://USER:PASSWORD@HOST:5432/DBNAME
|
||||
DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
||||
|
||||
# If DATABASE_URL is omitted entirely, the API falls back to a local SQLite
|
||||
# file (sqlite:///./wpsuite.db) — handy for trying it out without Postgres.
|
||||
|
||||
# Only needed for CROSS-ORIGIN local development (comma-separated). In
|
||||
# production the site is same-origin via NGINX, so leave this unset.
|
||||
# CORS_ORIGINS=http://localhost:5500
|
||||
93
server/README.md
Normal file
93
server/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Work Package Suite API
|
||||
|
||||
A small Python (FastAPI) service that stores project **SOPs**, **Work Packages**,
|
||||
and **comments** in PostgreSQL. NGINX serves the static site and proxies `/api/`
|
||||
to this service.
|
||||
|
||||
```
|
||||
browser → NGINX ──serves──> static site (index.html, …)
|
||||
└─proxy /api/─> this API (uvicorn/gunicorn :8000) → PostgreSQL
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/health` | liveness check |
|
||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||
| GET | `/api/sops` | list SOP summaries |
|
||||
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
||||
| GET | `/api/sops/{id}` | full SOP document |
|
||||
| DELETE | `/api/sops/{id}` | delete a SOP |
|
||||
| POST | `/api/wps` | create/update a Work Package (upsert by `id`) |
|
||||
| GET | `/api/wps?sop_id=…` | list WPs (optionally for one SOP) |
|
||||
| GET | `/api/wps/{id}` | full WP document |
|
||||
| DELETE | `/api/wps/{id}` | delete a WP |
|
||||
| POST | `/api/comments` (and `/api/feedback`) | add a comment |
|
||||
| GET | `/api/comments?source=&sop_id=&wp_id=&step=` | list comments |
|
||||
|
||||
Interactive docs once running: **`/api/docs`**.
|
||||
|
||||
The full client document is stored in each row's `data` (JSON) column; common
|
||||
fields (name, number, status, …) are promoted to columns for listing/filtering.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
# No DATABASE_URL → uses a local sqlite file, so you can start immediately:
|
||||
uvicorn server.app:app --reload --port 8000 # run from the PROJECT ROOT
|
||||
```
|
||||
Then open http://localhost:8000/api/docs.
|
||||
|
||||
> Run uvicorn/gunicorn from the **project root** (the folder that contains the
|
||||
> `server/` directory), because the import path is `server.app:app`.
|
||||
|
||||
## PostgreSQL setup (production)
|
||||
|
||||
```sql
|
||||
CREATE DATABASE wpsuite;
|
||||
CREATE USER wpsuite WITH PASSWORD 'CHANGE_ME';
|
||||
GRANT ALL PRIVILEGES ON DATABASE wpsuite TO wpsuite;
|
||||
```
|
||||
Tables are created automatically on first startup. (For future schema changes,
|
||||
introduce Alembic migrations rather than editing tables by hand.)
|
||||
|
||||
## Run in production (gunicorn + systemd)
|
||||
|
||||
`/etc/systemd/system/wp-suite-api.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Work Package Suite API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/wp-suite
|
||||
Environment="DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite"
|
||||
ExecStart=/opt/wp-suite/.venv/bin/gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now wp-suite-api
|
||||
```
|
||||
|
||||
NGINX already proxies `/api/` to `127.0.0.1:8000` (see `nginx-wp-suite.conf`).
|
||||
|
||||
## Quick test
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/comments \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"type":"home_feedback","name":"Test","text":"hello"}'
|
||||
|
||||
curl http://127.0.0.1:8000/api/comments
|
||||
```
|
||||
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
231
server/app.py
Normal file
231
server/app.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Work Package Suite API.
|
||||
|
||||
A small FastAPI service that stores project SOPs, Work Packages, and comments
|
||||
in SQL (PostgreSQL in production; SQLite for local dev). NGINX serves the static
|
||||
site and proxies /api/ here.
|
||||
|
||||
Run (dev): uvicorn server.app:app --reload --port 8000
|
||||
Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 server.app:app
|
||||
Interactive docs: http://<host>/api/docs
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import Base, engine, get_db
|
||||
from . import models
|
||||
|
||||
# Create tables on startup. (For schema changes later, switch to Alembic.)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
|
||||
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
|
||||
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
|
||||
_origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
||||
if _origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=_origins,
|
||||
allow_methods=["*"], allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def gen_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||
class SopIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str = ""
|
||||
number: str = ""
|
||||
complete: bool = False
|
||||
created_by: str = ""
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WpIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
sop_id: Optional[str] = None
|
||||
number: str = ""
|
||||
subject: str = ""
|
||||
type: str = ""
|
||||
status: str = "Draft"
|
||||
created_by: str = ""
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
# Tolerate any extra keys the feedback payload includes (timestamp, app, …).
|
||||
model_config = ConfigDict(extra="allow")
|
||||
source: Optional[str] = None
|
||||
type: Optional[str] = None # client sends 'type'; treated as source
|
||||
sop_id: Optional[str] = None
|
||||
wp_id: Optional[str] = None
|
||||
step: Optional[int] = None
|
||||
author: Optional[str] = None
|
||||
name: Optional[str] = None # home/SOP forms send 'name'
|
||||
text: Optional[str] = None
|
||||
page: Optional[str] = ""
|
||||
|
||||
|
||||
# ── Health ───────────────────────────────────────────────────────────────────
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/sops")
|
||||
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
sop = db.get(models.Sop, body.id) if body.id else None
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
db.add(sop)
|
||||
sop.name = body.name
|
||||
sop.number = body.number
|
||||
sop.complete = body.complete
|
||||
sop.created_by = body.created_by or sop.created_by
|
||||
sop.data = body.data
|
||||
db.commit()
|
||||
db.refresh(sop)
|
||||
return sop.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/sops")
|
||||
def list_sops(db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.Sop).order_by(models.Sop.updated_at.desc())).all()
|
||||
return [s.summary() for s in rows]
|
||||
|
||||
|
||||
@app.get("/api/sops/latest")
|
||||
def latest_sop(complete: Optional[bool] = None, db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if complete is not None:
|
||||
stmt = stmt.where(models.Sop.complete == complete)
|
||||
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="No SOP found")
|
||||
return sop.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/sops/{sop_id}")
|
||||
def get_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
sop = db.get(models.Sop, sop_id)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
return sop.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/sops/{sop_id}")
|
||||
def delete_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
sop = db.get(models.Sop, sop_id)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
db.delete(sop)
|
||||
db.commit()
|
||||
return {"deleted": sop_id}
|
||||
|
||||
|
||||
# ── Work Packages ────────────────────────────────────────────────────────────
|
||||
@app.post("/api/wps")
|
||||
def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, body.id) if body.id else None
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
wp.sop_id = body.sop_id
|
||||
wp.number = body.number
|
||||
wp.subject = body.subject
|
||||
wp.type = body.type
|
||||
wp.status = body.status
|
||||
wp.created_by = body.created_by or wp.created_by
|
||||
wp.data = body.data
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/wps")
|
||||
def list_wps(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
stmt = select(models.WorkPackage)
|
||||
if sop_id:
|
||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
|
||||
return [w.summary() for w in rows]
|
||||
|
||||
|
||||
@app.get("/api/wps/{wp_id}")
|
||||
def get_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/wps/{wp_id}")
|
||||
def delete_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
db.delete(wp)
|
||||
db.commit()
|
||||
return {"deleted": wp_id}
|
||||
|
||||
|
||||
# ── Comments / feedback ──────────────────────────────────────────────────────
|
||||
def _save_comment(body: CommentIn, db: Session) -> dict:
|
||||
extra = body.model_extra or {}
|
||||
c = models.Comment(
|
||||
id=gen_id("c"),
|
||||
source=body.source or body.type or "",
|
||||
sop_id=body.sop_id,
|
||||
wp_id=body.wp_id,
|
||||
step=body.step,
|
||||
author=(body.author or body.name or "Anonymous"),
|
||||
text=body.text or "",
|
||||
page=body.page or "",
|
||||
extra=extra,
|
||||
)
|
||||
db.add(c)
|
||||
db.commit()
|
||||
db.refresh(c)
|
||||
return c.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/comments")
|
||||
def create_comment(body: CommentIn, db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db)
|
||||
|
||||
|
||||
# Alias so the existing client (which posts to /api/feedback) keeps working.
|
||||
@app.post("/api/feedback")
|
||||
def create_feedback(body: CommentIn, db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db)
|
||||
|
||||
|
||||
@app.get("/api/comments")
|
||||
def list_comments(
|
||||
source: Optional[str] = Query(None),
|
||||
sop_id: Optional[str] = Query(None),
|
||||
wp_id: Optional[str] = Query(None),
|
||||
step: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(models.Comment)
|
||||
if source:
|
||||
stmt = stmt.where(models.Comment.source == source)
|
||||
if sop_id:
|
||||
stmt = stmt.where(models.Comment.sop_id == sop_id)
|
||||
if wp_id:
|
||||
stmt = stmt.where(models.Comment.wp_id == wp_id)
|
||||
if step is not None:
|
||||
stmt = stmt.where(models.Comment.step == step)
|
||||
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
|
||||
return [c.to_dict() for c in rows]
|
||||
41
server/db.py
Normal file
41
server/db.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Database engine and session setup.
|
||||
|
||||
The connection string comes from the DATABASE_URL environment variable, e.g.
|
||||
postgresql+psycopg://wpsuite:secret@db-host:5432/wpsuite
|
||||
|
||||
If unset, it falls back to a local SQLite file so the API can be run and tested
|
||||
on any machine without Postgres. The schema is identical either way (SQLAlchemy
|
||||
handles the dialect differences).
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
# Load a local .env if present (dev convenience). In production the DATABASE_URL
|
||||
# normally comes from the systemd unit's Environment / EnvironmentFile instead.
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./wpsuite.db")
|
||||
|
||||
# SQLite needs this flag to be used from FastAPI's threadpool; Postgres ignores it.
|
||||
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI dependency that yields a session and always closes it."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
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
|
||||
7
server/requirements.txt
Normal file
7
server/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
gunicorn>=21.2
|
||||
sqlalchemy>=2.0
|
||||
psycopg[binary]>=3.1
|
||||
pydantic>=2.6
|
||||
python-dotenv>=1.0
|
||||
Reference in New Issue
Block a user