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:
2026-06-15 10:58:52 -07:00
parent 3d4402c88c
commit a33b777aca
10 changed files with 563 additions and 155 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# Local env / secrets
.env
# Local SQLite dev database
*.db
wpsuite.db

View File

@@ -1,153 +1,57 @@
# Deployment & Feedback Collection
# Deployment
The Work Package Suite is a **static client-side app** — plain HTML, CSS, and
JavaScript. There is no build step and no database.
The Work Package Suite has two parts:
## Hosting it behind the firewall
- a **static front end** (plain HTML/CSS/JS — no build step), and
- a **Python API** (FastAPI) backed by **PostgreSQL**, which stores the project
SOPs, Work Packages, and comments so they are shared across users instead of
living in each person's browser.
Copy the whole folder to any internal web server and serve it over HTTP(S):
- **IIS / Apache / nginx** — drop the files in the site root. `index.html` is the
entry point.
- **SharePoint / network share** — works too, as long as the files are served
over `http(s)://` (not opened as `file://...`). Serving over HTTP makes
`localStorage` and the embedded Work Package Creator (an `<iframe>`) behave
reliably.
The app makes **no outbound internet calls** — the logo and all scripts are
local, and the previous Google-Fonts dependency has been removed (fonts now fall
back to system UI fonts). So it runs fully air-gapped behind a corporate
firewall.
## Where data lives
By default **everything is stored in each user's own browser** (`localStorage`):
the SOP configuration, the saved Work Packages, the usage logs, and all feedback
/ comments. This means:
- Data is **per-user and per-device** — it is not shared between people, and
clearing browser data erases it.
- Nothing is transmitted anywhere unless you enable central collection (below).
## Feedback collection
There are two layers, and they work together.
### 1. Export / Import (no server required — works today)
Every feedback surface has **Export** and **Import** buttons:
- Home page → *Leave Feedback* panel
- SOP Configuration → *Step Comments* (header button)
- Work Package Creator → *Comments* drawer
A reviewer clicks **Export** to download a JSON file and sends it to you; you
click **Import** on your machine to merge everyone's feedback together (imports
de-duplicate, so re-importing is safe). This needs zero infrastructure and works
behind any firewall.
### 2. Central auto-collection (optional — flip on when hosting is known)
To also gather every submission automatically into one place, set a single value
in [`feedback-config.js`](feedback-config.js):
```js
window.FEEDBACK_ENDPOINT = 'https://your-endpoint-url';
```
browser → NGINX ──serves──> static site (index.html, …)
└─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL
```
When set, each submission is additionally `POST`ed as JSON to that URL (saving
locally still happens, so a failed/disabled endpoint never loses feedback). The
endpoint can be either of:
Everything runs inside your firewall; the app makes **no outbound internet
calls** (the logo and scripts are local and the old Google-Fonts dependency was
removed).
#### Option A — a small backend on your host
Any server that can run code and append the request body to a file you can
download. Example (Node/Express):
## 1. Front end (NGINX)
```js
const express = require('express');
const fs = require('fs');
const app = express();
app.use(express.json());
app.post('/feedback', (req, res) => {
fs.appendFileSync('feedback.jsonl', JSON.stringify(req.body) + '\n');
res.sendStatus(204);
});
app.listen(8080);
```
Copy the project files to a web root and serve them over HTTPS. The provided
[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies
`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and
`root`, then `sudo nginx -t && sudo systemctl reload nginx`.
Each line of `feedback.jsonl` is one submission; download it anytime. (PHP/
Python/ASP.NET equivalents are a few lines too.)
Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package
Creator (`<iframe>`) and any browser-side caching behave reliably.
#### Option B — Internal NGINX reverse proxy → Power Automate (the chosen setup)
## 2. API + database
The browser posts to a **same-origin** path `/api/feedback`; NGINX forwards that
to the Power Automate trigger. This avoids CORS entirely and keeps the secret
trigger URL off the client. `FEEDBACK_ENDPOINT` is already set to
`/api/feedback`, and [`nginx-wp-suite.conf`](nginx-wp-suite.conf) contains the
full server block.
Full setup — PostgreSQL, the systemd service, and the endpoint reference — is in
[`server/README.md`](server/README.md). In short:
**On the NGINX box (one-time, server admin):**
1. Copy the project files to the web root (e.g. `/var/www/wp-suite`).
2. Install [`nginx-wp-suite.conf`](nginx-wp-suite.conf) (e.g. into
`/etc/nginx/conf.d/`), and set `server_name`, the `ssl_certificate` paths
(internal cert), and the `root`.
3. In the `location = /api/feedback` block, replace the `proxy_pass` URL and the
`Host` header with your real trigger URL / region host (keep the full query
string incl. `sig=`).
4. `sudo nginx -t && sudo systemctl reload nginx`.
1. Create the `wpsuite` Postgres database/user.
2. `pip install -r server/requirements.txt` into a venv.
3. Set `DATABASE_URL` and run the API as a systemd service on `127.0.0.1:8000`.
4. Tables are created automatically on first start.
> Two NGINX details that matter: `proxy_ssl_server_name on;` (SNI is required for
> `*.logic.azure.com` or the TLS handshake fails) and the `Host` header set to
> the Azure region host. Both are already in the provided config.
Interactive API docs are at `/api/docs` once it's running.
**In Power Automate:**
1. Create a flow with the **"When an HTTP request is received"** trigger.
2. Set its **Request Body JSON Schema** to:
## 3. Comments / feedback
```json
{
"type": "object",
"properties": {
"app": { "type": "string" },
"page": { "type": "string" },
"submittedAt": { "type": "string" },
"type": { "type": "string" },
"name": { "type": "string" },
"author": { "type": "string" },
"text": { "type": "string" },
"step": { "type": "integer" },
"view": { "type": "string" },
"timestamp": { "type": "string" },
"ts": { "type": "string" },
"id": { "type": "string" },
"clientId": { "type": "string" }
}
}
```
> `name` is used by the home/SOP forms, `author` by the Work Package Creator.
> Map both into one "Submitted by" column with an expression like
> `coalesce(triggerBody()?['name'], triggerBody()?['author'])`.
3. Add an action — **Create item** (SharePoint list) or **Add a row into a
table** (Excel / Dataverse) — mapping the fields above.
4. Save; copy the generated **HTTP POST URL** into `web.config`
(`POWER_AUTOMATE_TRIGGER_URL`).
5. A Power App (or just the list/Excel) reads that store to show live comments.
Every feedback surface (home *Leave Feedback*, SOP *Step Comments*, WP *Comments*)
posts to `/api/feedback`, which the API stores in the `comments` table. The
**Export / Import** buttons remain as an offline fallback — a reviewer can export
a JSON file and someone can import/merge it — but with the API running, comments
are collected centrally with no manual steps.
Chain: `browser → /api/feedback (NGINX proxy) → Power Automate → SharePoint/Dataverse → Power App`.
> The earlier Power Automate route is **no longer needed** — comments go straight
> to Postgres. If you still want a Power App view, point a Power App at the
> Postgres `comments` table via the on-prem data gateway, or have a flow read the
> table; no change to this app is required.
The "downloadable file" is then just the Excel/SharePoint list, viewable live or
exported — all inside your corporate cloud.
### CORS note
If the endpoint is on a **different origin** than the site, it must return CORS
headers allowing the site's origin (e.g.
`Access-Control-Allow-Origin: https://wp-suite.yourcompany.local`). A Power
Automate HTTP trigger and a same-host backend both handle this cleanly; a
same-origin backend needs no CORS at all.
## Feedback payload shape
Each POST body looks like:
### Comment payload shape
```json
{
@@ -161,4 +65,17 @@ Each POST body looks like:
}
```
`type` is one of `home_feedback`, `sop_step_comment`, or `wp_review_comment`.
`type` is one of `home_feedback`, `sop_step_comment`, or `wp_review_comment`. The
API maps `name`/`author` → the comment author and keeps any extra fields in the
row's `extra` JSON column.
## Data model (PostgreSQL)
| Table | Holds | Key columns |
|-------|-------|-------------|
| `sops` | project SOP baselines | `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `sop_id`, `number`, `subject`, `type`, `status`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text` |
The complete client document is stored verbatim in each row's `data` column;
frequently-listed fields are promoted to real columns for filtering.

View File

@@ -1,17 +1,17 @@
# ─────────────────────────────────────────────────────────────────────────────
# Work Package Suite — NGINX site config
#
# Serves the static site and reverse-proxies feedback to a Power Automate
# "When an HTTP request is received" trigger. The browser only ever sees the
# same-origin path /api/feedback, so there is NO CORS and the secret trigger URL
# (with its sig= token) never reaches client code.
# Serves the static site and reverse-proxies /api/ to the Python API
# (FastAPI on 127.0.0.1:8000), which stores SOPs, Work Packages, and comments
# in PostgreSQL. Same-origin, so there is no CORS.
#
# Install:
# 1. Copy the project files to the web root (e.g. /var/www/wp-suite).
# 2. Put this file at /etc/nginx/conf.d/wp-suite.conf
# 2. Run the API as a service (see server/README.md) listening on :8000.
# 3. Put this file at /etc/nginx/conf.d/wp-suite.conf
# (or /etc/nginx/sites-available/ + symlink into sites-enabled/).
# 3. Replace server_name, the ssl_certificate paths, and the proxy_pass URL.
# 4. sudo nginx -t && sudo systemctl reload nginx
# 4. Replace server_name and the ssl_certificate paths.
# 5. sudo nginx -t && sudo systemctl reload nginx
# ─────────────────────────────────────────────────────────────────────────────
# Redirect plain HTTP to HTTPS
@@ -38,19 +38,15 @@ server {
try_files $uri $uri/ =404;
}
# ── Feedback proxy → Power Automate ──────────────────────────────────────
# Paste your real trigger URL into proxy_pass below, keeping the FULL query
# string (api-version, sp, sv, sig). A small body cap keeps this endpoint safe.
location = /api/feedback {
limit_except POST { deny all; } # only accept POST
client_max_body_size 256k;
proxy_pass https://prod-XX.westus.logic.azure.com/workflows/REPLACE_WORKFLOW_ID/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=REPLACE_SIGNATURE;
# ── API proxy → Python (FastAPI) ─────────────────────────────────────────
# All /api/ calls (SOPs, Work Packages, comments) go to the local API
# service. Keep the /api/ prefix — the API routes are defined under /api.
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_ssl_server_name on; # SNI — required for *.logic.azure.com
proxy_set_header Host prod-XX.westus.logic.azure.com; # <-- match your region host
proxy_set_header Content-Type application/json;
proxy_set_header Cookie ""; # don't leak site cookies upstream
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 5m; # WP documents can be larger
}
}

12
server/.env.example Normal file
View 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
View 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
View File

231
server/app.py Normal file
View 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
View 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
View 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
View 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