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:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# Local env / secrets
|
||||
.env
|
||||
|
||||
# Local SQLite dev database
|
||||
*.db
|
||||
wpsuite.db
|
||||
187
DEPLOYMENT.md
187
DEPLOYMENT.md
@@ -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.
|
||||
|
||||
@@ -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
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