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
|
The Work Package Suite has two parts:
|
||||||
JavaScript. There is no build step and no database.
|
|
||||||
|
|
||||||
## 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):
|
```
|
||||||
|
browser → NGINX ──serves──> static site (index.html, …)
|
||||||
- **IIS / Apache / nginx** — drop the files in the site root. `index.html` is the
|
└─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL
|
||||||
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';
|
|
||||||
```
|
```
|
||||||
|
|
||||||
When set, each submission is additionally `POST`ed as JSON to that URL (saving
|
Everything runs inside your firewall; the app makes **no outbound internet
|
||||||
locally still happens, so a failed/disabled endpoint never loses feedback). The
|
calls** (the logo and scripts are local and the old Google-Fonts dependency was
|
||||||
endpoint can be either of:
|
removed).
|
||||||
|
|
||||||
#### Option A — a small backend on your host
|
## 1. Front end (NGINX)
|
||||||
Any server that can run code and append the request body to a file you can
|
|
||||||
download. Example (Node/Express):
|
|
||||||
|
|
||||||
```js
|
Copy the project files to a web root and serve them over HTTPS. The provided
|
||||||
const express = require('express');
|
[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies
|
||||||
const fs = require('fs');
|
`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and
|
||||||
const app = express();
|
`root`, then `sudo nginx -t && sudo systemctl reload nginx`.
|
||||||
app.use(express.json());
|
|
||||||
app.post('/feedback', (req, res) => {
|
|
||||||
fs.appendFileSync('feedback.jsonl', JSON.stringify(req.body) + '\n');
|
|
||||||
res.sendStatus(204);
|
|
||||||
});
|
|
||||||
app.listen(8080);
|
|
||||||
```
|
|
||||||
|
|
||||||
Each line of `feedback.jsonl` is one submission; download it anytime. (PHP/
|
Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package
|
||||||
Python/ASP.NET equivalents are a few lines too.)
|
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
|
Full setup — PostgreSQL, the systemd service, and the endpoint reference — is in
|
||||||
to the Power Automate trigger. This avoids CORS entirely and keeps the secret
|
[`server/README.md`](server/README.md). In short:
|
||||||
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.
|
|
||||||
|
|
||||||
**On the NGINX box (one-time, server admin):**
|
1. Create the `wpsuite` Postgres database/user.
|
||||||
1. Copy the project files to the web root (e.g. `/var/www/wp-suite`).
|
2. `pip install -r server/requirements.txt` into a venv.
|
||||||
2. Install [`nginx-wp-suite.conf`](nginx-wp-suite.conf) (e.g. into
|
3. Set `DATABASE_URL` and run the API as a systemd service on `127.0.0.1:8000`.
|
||||||
`/etc/nginx/conf.d/`), and set `server_name`, the `ssl_certificate` paths
|
4. Tables are created automatically on first start.
|
||||||
(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`.
|
|
||||||
|
|
||||||
> Two NGINX details that matter: `proxy_ssl_server_name on;` (SNI is required for
|
Interactive API docs are at `/api/docs` once it's running.
|
||||||
> `*.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.
|
|
||||||
|
|
||||||
**In Power Automate:**
|
## 3. Comments / feedback
|
||||||
1. Create a flow with the **"When an HTTP request is received"** trigger.
|
|
||||||
2. Set its **Request Body JSON Schema** to:
|
|
||||||
|
|
||||||
```json
|
Every feedback surface (home *Leave Feedback*, SOP *Step Comments*, WP *Comments*)
|
||||||
{
|
posts to `/api/feedback`, which the API stores in the `comments` table. The
|
||||||
"type": "object",
|
**Export / Import** buttons remain as an offline fallback — a reviewer can export
|
||||||
"properties": {
|
a JSON file and someone can import/merge it — but with the API running, comments
|
||||||
"app": { "type": "string" },
|
are collected centrally with no manual steps.
|
||||||
"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.
|
|
||||||
|
|
||||||
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
|
### Comment payload shape
|
||||||
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:
|
|
||||||
|
|
||||||
```json
|
```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
|
# Work Package Suite — NGINX site config
|
||||||
#
|
#
|
||||||
# Serves the static site and reverse-proxies feedback to a Power Automate
|
# Serves the static site and reverse-proxies /api/ to the Python API
|
||||||
# "When an HTTP request is received" trigger. The browser only ever sees the
|
# (FastAPI on 127.0.0.1:8000), which stores SOPs, Work Packages, and comments
|
||||||
# same-origin path /api/feedback, so there is NO CORS and the secret trigger URL
|
# in PostgreSQL. Same-origin, so there is no CORS.
|
||||||
# (with its sig= token) never reaches client code.
|
|
||||||
#
|
#
|
||||||
# Install:
|
# Install:
|
||||||
# 1. Copy the project files to the web root (e.g. /var/www/wp-suite).
|
# 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/).
|
# (or /etc/nginx/sites-available/ + symlink into sites-enabled/).
|
||||||
# 3. Replace server_name, the ssl_certificate paths, and the proxy_pass URL.
|
# 4. Replace server_name and the ssl_certificate paths.
|
||||||
# 4. sudo nginx -t && sudo systemctl reload nginx
|
# 5. sudo nginx -t && sudo systemctl reload nginx
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Redirect plain HTTP to HTTPS
|
# Redirect plain HTTP to HTTPS
|
||||||
@@ -38,19 +38,15 @@ server {
|
|||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Feedback proxy → Power Automate ──────────────────────────────────────
|
# ── API proxy → Python (FastAPI) ─────────────────────────────────────────
|
||||||
# Paste your real trigger URL into proxy_pass below, keeping the FULL query
|
# All /api/ calls (SOPs, Work Packages, comments) go to the local API
|
||||||
# string (api-version, sp, sv, sig). A small body cap keeps this endpoint safe.
|
# service. Keep the /api/ prefix — the API routes are defined under /api.
|
||||||
location = /api/feedback {
|
location /api/ {
|
||||||
limit_except POST { deny all; } # only accept POST
|
proxy_pass http://127.0.0.1:8000;
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_ssl_server_name on; # SNI — required for *.logic.azure.com
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Host prod-XX.westus.logic.azure.com; # <-- match your region host
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
proxy_set_header Content-Type application/json;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header Cookie ""; # don't leak site cookies upstream
|
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