Compare commits
11 Commits
savepoint-
...
savepoint-
| Author | SHA1 | Date | |
|---|---|---|---|
| e5c450597a | |||
| 3c40b58ff8 | |||
| 4d111d608d | |||
| a37cf14e89 | |||
| 362aa633ed | |||
| fd668f0ea2 | |||
| 960b4a4b94 | |||
| 8598606165 | |||
| 65996c2c0a | |||
| b0a3d74412 | |||
| ffcaa571d1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,3 +11,6 @@ venv/
|
||||
# Local SQLite dev database
|
||||
*.db
|
||||
wpsuite.db
|
||||
|
||||
# Runtime directories (created by containers)
|
||||
logs/
|
||||
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY server/requirements.txt ./server/
|
||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||
COPY server/ ./server/
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||
@@ -130,6 +130,49 @@ to `fetch('/api/wps…')` in Phase 2 and the UI is unchanged.
|
||||
> real data this is fine; once there is, add Alembic (see open question #2) and
|
||||
> migrate rather than relying on `create_all`.
|
||||
|
||||
## Multi-project support
|
||||
|
||||
> **Note on layout:** the IT admin moved all static files into **`html/`** and
|
||||
> added a Docker/NGINX deployment (`Dockerfile`, `docker-compose.yml`, `nginx/`).
|
||||
> Front-end paths below are under `html/`. `server/` stayed at the repo root.
|
||||
|
||||
The suite is now multi-project. **Projects are the top-level container**; every
|
||||
SOP and Work Package belongs to one.
|
||||
|
||||
- **Backend:** new `projects` table + CRUD (`/api/projects`). `sops` gained
|
||||
`project_id` (FK, cascade) and `work_packages` gained `project_id`; list/latest/
|
||||
metrics endpoints accept a `project_id` filter.
|
||||
- **Project layer:** [html/project-data.js](html/project-data.js) — a shared,
|
||||
**API-first** `ProjectData` adapter (`list/get/save/remove` hit `/api/projects`)
|
||||
that **falls back to a localStorage mirror** (`wp_projects`) when the API is
|
||||
unreachable, plus active-project helpers (`getActive`/`setActive`, stored in
|
||||
`wp_active_project` / `wp_active_project_obj`).
|
||||
- **Home page** ([html/index.html](html/index.html)): "About This Suite" removed;
|
||||
a **Project** picker added. With no projects it offers *Create Project* / *Use
|
||||
Sample Project*; otherwise a dropdown to select. The tool cards stay hidden
|
||||
until a project is active and then carry `&project=<id>`; the hero shows the
|
||||
active project.
|
||||
- **Suite** ([html/work-package-suite-app.js](html/work-package-suite-app.js)):
|
||||
reads `?project=<id>`, resolves it via `ProjectData`, shows it in the header,
|
||||
and **prefills the SOP project fields** (step 1) from the project record when
|
||||
empty. Passes `&project` into the WP-creator iframe.
|
||||
- **WP creator:** stamps `projectId` onto every saved package (for API sync).
|
||||
|
||||
**Per-project isolation (done, local):** SOP/WP localStorage keys are now
|
||||
namespaced per active project via `ProjectData.key(base)` →
|
||||
`base + '__' + <projectId>` (`SK()` in the suite, `wpKey()` in the creator).
|
||||
So each project keeps its own `wp_suite_sop` / `wp_suite_state` /
|
||||
`wp_suite_sop_complete` / `wp_iwp_v1`. On first load after this change,
|
||||
`project-data.js` runs a **one-time discard** of the legacy un-namespaced keys
|
||||
(guarded by `wp_ns_migrated_v1`) — chosen over migrating, since the local data
|
||||
was throwaway demo content.
|
||||
|
||||
**Still ahead (true Phase 2):** move SOP/WP reads+writes to the API filtered by
|
||||
`project_id` (`GET /api/sops/latest?project_id=…`, `GET /api/wps?project_id=…`)
|
||||
so projects are shared across users, not just isolated per browser. The
|
||||
endpoints already accept the `project_id` filter; the front end still reads
|
||||
localStorage.
|
||||
|
||||
**Pending — Phase 2: wire the front end to the API**
|
||||
- SOP: on *SOP Complete*, `POST /api/sops`; on load, `GET /api/sops/latest` to hydrate the Creator (currently uses `localStorage` key `wp_suite_sop`).
|
||||
- WP Creator: save packages via `POST /api/wps`; list/load via `GET /api/wps`
|
||||
|
||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
services:
|
||||
|
||||
webserver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: nginx/Dockerfile
|
||||
container_name: nginx_webserver
|
||||
volumes:
|
||||
- nginx_logs:/var/log/nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
networks:
|
||||
- proxy # external — reachable by your reverse proxy / traefik
|
||||
- internal # needs a path to the api container
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: wp_api
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy # waits for postgres to accept connections
|
||||
networks:
|
||||
- internal
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: wp_db
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
nginx_logs:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
internal:
|
||||
internal: true # no outbound internet access from api/db
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -348,6 +348,22 @@
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
/* PROJECT PICKER */
|
||||
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
||||
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
||||
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
|
||||
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||
border-radius: 6px; padding: 1.25rem; }
|
||||
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
||||
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
|
||||
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
|
||||
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
||||
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
|
||||
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
|
||||
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
.header-content { flex-direction: column; text-align: center; }
|
||||
@@ -379,12 +395,19 @@
|
||||
|
||||
<!-- HERO -->
|
||||
<div class="hero">
|
||||
<h1>Work Package Suite</h1>
|
||||
<p>Standardized approach to Work Package creation for Prime Controls construction projects. Configure project parameters, define constraints, and generate compliant work packages.</p>
|
||||
<h1 id="hero-title">Work Package Suite</h1>
|
||||
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
||||
</div>
|
||||
|
||||
<!-- TOOL CARDS -->
|
||||
<div class="cards-grid" id="overview">
|
||||
<!-- PROJECT SELECTION -->
|
||||
<div class="section" id="project-section">
|
||||
<h2>Project</h2>
|
||||
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
|
||||
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- TOOL CARDS (shown once a project is active) -->
|
||||
<div class="cards-grid" id="overview" style="display:none">
|
||||
|
||||
<!-- SOP CONFIG -->
|
||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||
@@ -443,25 +466,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUPPORT SECTION -->
|
||||
<div class="section">
|
||||
<h2>About This Suite</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
|
||||
<div>
|
||||
<h3>Two-Step Workflow</h3>
|
||||
<p>Configure the project SOP once, then author every Work Package against it. The Creator stays locked until the SOP is complete, so packages always inherit a valid baseline.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Leave Feedback</h3>
|
||||
<p>Use the feedback section on this page or within any tool. All comments are stored locally and can be exported for team review and iteration.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Offline & Collaborative</h3>
|
||||
<p>All tools work entirely in your browser. Export SOP and Work Package data as JSON for sharing, version control, and integration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
@@ -470,13 +474,136 @@
|
||||
</footer>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script>
|
||||
// Reflect SOP completion on the tool cards.
|
||||
(function reflectSOPStatus(){
|
||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||
const esc = ProjectData.esc;
|
||||
let _projects = [];
|
||||
|
||||
function initProjects(){
|
||||
ProjectData.list().then(list => {
|
||||
_projects = list || [];
|
||||
// Reconcile the active project against the list; clear if it's gone.
|
||||
const active = ProjectData.getActive();
|
||||
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
|
||||
renderProjectPicker();
|
||||
applyActiveProject();
|
||||
});
|
||||
}
|
||||
|
||||
function createFormHtml(){
|
||||
return `<div class="proj-form" id="proj-form" style="display:none">
|
||||
<div class="proj-form-grid">
|
||||
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
|
||||
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
||||
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
||||
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
||||
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
||||
</div>
|
||||
<div class="proj-actions">
|
||||
<button class="card-button" onclick="saveNewProject()">Create & Select</button>
|
||||
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderProjectPicker(){
|
||||
const box = document.getElementById('project-picker');
|
||||
const activeId = ProjectData.getActiveId();
|
||||
if(!_projects.length){
|
||||
box.innerHTML = `<div class="proj-empty">
|
||||
<p>No projects yet. Create your first project, or start from a sample.</p>
|
||||
<div class="proj-actions">
|
||||
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
|
||||
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
|
||||
</div>
|
||||
</div>` + createFormHtml();
|
||||
return;
|
||||
}
|
||||
const opts = _projects.map(p =>
|
||||
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
|
||||
).join('');
|
||||
box.innerHTML = `<div class="proj-row">
|
||||
<select id="project-select" onchange="selectProject(this.value)">
|
||||
<option value="">Select a project…</option>${opts}
|
||||
</select>
|
||||
<button class="card-button" onclick="showCreateProject()">+ New</button>
|
||||
<button class="close-btn" onclick="useSampleProject()">Sample</button>
|
||||
</div>
|
||||
<div id="active-project-info"></div>` + createFormHtml();
|
||||
}
|
||||
|
||||
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
|
||||
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
|
||||
|
||||
function saveNewProject(){
|
||||
const v = id => (document.getElementById(id)?.value || '').trim();
|
||||
const name = v('np_name');
|
||||
if(!name){ alert('Project name is required.'); return; }
|
||||
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
|
||||
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
|
||||
function useSampleProject(){
|
||||
const existing = _projects.find(p => p.sample);
|
||||
if(existing){ afterProjectChosen(existing); return; }
|
||||
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
|
||||
function selectProject(id){
|
||||
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
||||
const p = _projects.find(x => x.id === id);
|
||||
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
||||
}
|
||||
|
||||
function afterProjectChosen(p){
|
||||
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
||||
ProjectData.setActive(p);
|
||||
renderProjectPicker();
|
||||
applyActiveProject();
|
||||
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
|
||||
}
|
||||
|
||||
// Show/hide the tool cards and stamp the active project into their links.
|
||||
function applyActiveProject(){
|
||||
const active = ProjectData.getActive();
|
||||
const cards = document.getElementById('overview');
|
||||
const heroTitle = document.getElementById('hero-title');
|
||||
const heroSub = document.getElementById('hero-sub');
|
||||
const info = document.getElementById('active-project-info');
|
||||
|
||||
if(!active){
|
||||
cards.style.display = 'none';
|
||||
heroTitle.textContent = 'Work Package Suite';
|
||||
heroSub.textContent = 'Select a project to begin — or create one.';
|
||||
if(info) info.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const q = '&project=' + encodeURIComponent(active.id);
|
||||
const setHref = (id, base) => { const el=document.getElementById(id); if(el) el.href = base + q; };
|
||||
setHref('card-sop', 'work-package-suite.html?tab=sop');
|
||||
setHref('card-wp', 'work-package-suite.html?tab=wp');
|
||||
setHref('card-dash', 'work-package-suite.html?view=dashboard');
|
||||
|
||||
cards.style.display = '';
|
||||
heroTitle.textContent = active.name || 'Work Package Suite';
|
||||
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
|
||||
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
||||
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
||||
|
||||
reflectSOPStatus(active);
|
||||
}
|
||||
|
||||
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||
|
||||
// Reflect SOP completion on the tool cards (scoped to the active project).
|
||||
function reflectSOPStatus(active){
|
||||
let complete = false, projName = '';
|
||||
try {
|
||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
||||
const sop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
||||
// Storage is namespaced per project, so these already scope to `active`.
|
||||
complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
|
||||
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
|
||||
projName = sop && sop.project && sop.project.name || '';
|
||||
} catch(e){}
|
||||
|
||||
@@ -484,6 +611,12 @@
|
||||
const sopBtn = document.getElementById('card-sop-btn');
|
||||
const wpCard = document.getElementById('card-wp');
|
||||
const wpBtn = document.getElementById('card-wp-btn');
|
||||
if(!sopCard) return;
|
||||
|
||||
// reset (re-render can run multiple times)
|
||||
sopCard.classList.remove('complete');
|
||||
wpCard && wpCard.classList.remove('disabled');
|
||||
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
|
||||
|
||||
if(complete){
|
||||
sopCard.classList.add('complete');
|
||||
@@ -497,7 +630,9 @@
|
||||
if(wpCard) wpCard.classList.add('disabled');
|
||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
initProjects();
|
||||
|
||||
let allComments = [];
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
96
html/project-data.js
Normal file
96
html/project-data.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/* Shared project layer for the Work Package Suite.
|
||||
Projects are the top-level container — every SOP and Work Package belongs to
|
||||
one. Project records live in the SQL database (via /api/projects); this
|
||||
adapter is API-first and falls back to a localStorage mirror so the suite
|
||||
still works in local dev / offline. Included by the home page and the suite. */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var API = '/api';
|
||||
var LS_PROJECTS = 'wp_projects'; // local mirror of the project list
|
||||
var LS_ACTIVE = 'wp_active_project'; // active project id
|
||||
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
||||
|
||||
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
||||
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
||||
function cacheUpsert(p) {
|
||||
var list = readLocal();
|
||||
var ix = list.findIndex(function (x) { return x.id === p.id; });
|
||||
if (ix >= 0) list[ix] = p; else list.unshift(p);
|
||||
writeLocal(list);
|
||||
}
|
||||
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
||||
|
||||
var SAMPLE_PROJECT = {
|
||||
name: 'Micron — INC Construction Work Packages', number: '26-67-008',
|
||||
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
||||
site: 'Boise, ID — Fab', sample: true
|
||||
};
|
||||
|
||||
var ProjectData = {
|
||||
SAMPLE: SAMPLE_PROJECT,
|
||||
esc: esc,
|
||||
|
||||
// Returns the project list. Tries the API; falls back to the local mirror.
|
||||
list: function () {
|
||||
return fetch(API + '/projects', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (rows) { writeLocal(rows); return rows; })
|
||||
.catch(function () { return readLocal(); });
|
||||
},
|
||||
|
||||
get: function (id) {
|
||||
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.catch(function () { return readLocal().find(function (x) { return x.id === id; }) || null; });
|
||||
},
|
||||
|
||||
// Create or update. Assigns an id when new. Mirrors to localStorage either way.
|
||||
save: function (p) {
|
||||
if (!p.id) p.id = uid();
|
||||
return fetch(API + '/projects', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p)
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (saved) { cacheUpsert(saved); return saved; })
|
||||
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
|
||||
},
|
||||
|
||||
remove: function (id) {
|
||||
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||
.then(function () { cacheRemove(id); })
|
||||
.catch(function () { cacheRemove(id); });
|
||||
},
|
||||
|
||||
// ── active project context ────────────────────────────────────────────────
|
||||
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
|
||||
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
||||
setActive: function (p) {
|
||||
try {
|
||||
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
|
||||
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
|
||||
// key('wp_iwp_v1') → 'wp_iwp_v1__proj_ab12'
|
||||
// Falls back to the bare key when no project is active.
|
||||
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
||||
};
|
||||
|
||||
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
||||
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
||||
try {
|
||||
if (!localStorage.getItem('wp_ns_migrated_v1')) {
|
||||
['wp_suite_sop', 'wp_suite_state', 'wp_suite_sop_complete', 'wp_iwp_v1'].forEach(function (k) {
|
||||
try { localStorage.removeItem(k); } catch (e) {}
|
||||
});
|
||||
localStorage.setItem('wp_ns_migrated_v1', '1');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
global.ProjectData = ProjectData;
|
||||
})(window);
|
||||
@@ -4,6 +4,10 @@ let currentStep = 1;
|
||||
let sopComplete = false;
|
||||
let allComments = [];
|
||||
|
||||
// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__<projId>' when a
|
||||
// project is active. Keeps each project's SOP separate in the browser.
|
||||
function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } }
|
||||
|
||||
let state = {
|
||||
project: {name:'', number:'', client:'', division:'', site:''},
|
||||
team: {pm:'', apm:'', cm:'', qm:''},
|
||||
@@ -126,12 +130,15 @@ window.addEventListener('DOMContentLoaded',()=>{
|
||||
renderStandardConstraints();
|
||||
renderSequenceSteps();
|
||||
renderSources();
|
||||
// Resolve the active project FIRST so per-project storage keys are correct
|
||||
// before we restore this project's SOP.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
applyProjectContext(params.get('project'));
|
||||
restoreSavedSOP();
|
||||
updateStepUI();
|
||||
updateProjectDisplay();
|
||||
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page cards.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
|
||||
const tab = params.get('tab');
|
||||
if(params.get('view') === 'dashboard') switchTool('wp');
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||
@@ -202,9 +209,9 @@ function loadSampleData(){
|
||||
function restoreSavedSOP(){
|
||||
let savedState = null, savedSop = null, complete = false;
|
||||
try {
|
||||
complete = localStorage.getItem('wp_suite_sop_complete') === '1';
|
||||
savedState = JSON.parse(localStorage.getItem('wp_suite_state') || 'null');
|
||||
savedSop = JSON.parse(localStorage.getItem('wp_suite_sop') || 'null');
|
||||
complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1';
|
||||
savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null');
|
||||
savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null');
|
||||
} catch(e){}
|
||||
if(!complete || !savedState) return;
|
||||
|
||||
@@ -284,8 +291,11 @@ function renderWPTab(){
|
||||
gate.style.display = 'none';
|
||||
frame.style.display = 'block';
|
||||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
||||
const wantDash = new URLSearchParams(window.location.search).get('view') === 'dashboard';
|
||||
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '') + '&t=' + Date.now();
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const wantDash = sp.get('view') === 'dashboard';
|
||||
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||||
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&view=dashboard' : '')
|
||||
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
||||
}else{
|
||||
gate.style.display = 'block';
|
||||
frame.style.display = 'none';
|
||||
@@ -297,8 +307,43 @@ function onSOPReady(){
|
||||
if(currentTool === 'wp') renderWPTab();
|
||||
}
|
||||
|
||||
// Active project comes from the home page (?project=<id> + ProjectData.getActive()).
|
||||
// When the SOP's project fields are still empty, prefill them from the project
|
||||
// record so the SOP is authored against the chosen project.
|
||||
let activeProject = null;
|
||||
function applyProjectContext(projectId){
|
||||
try {
|
||||
if(typeof ProjectData !== 'undefined'){
|
||||
if(projectId && ProjectData.getActiveId() !== projectId){
|
||||
// Deep-linked to a project that isn't the cached active one. Seed the id
|
||||
// immediately so namespaced storage keys resolve, then fetch the full record.
|
||||
const cached = ProjectData.getActive();
|
||||
ProjectData.setActive(cached && cached.id === projectId ? cached : { id: projectId });
|
||||
ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } });
|
||||
}
|
||||
activeProject = ProjectData.getActive();
|
||||
}
|
||||
} catch(e){}
|
||||
prefillProjectFields();
|
||||
}
|
||||
function prefillProjectFields(){
|
||||
if(!activeProject) return;
|
||||
const set = (id,v)=>{ const el=document.getElementById(id); if(el && !el.value && v) el.value = v; };
|
||||
set('proj_name', activeProject.name);
|
||||
set('proj_number', activeProject.number);
|
||||
set('proj_client', activeProject.client);
|
||||
set('proj_division', activeProject.division);
|
||||
set('proj_site', activeProject.site);
|
||||
if(typeof state !== 'undefined' && state.project){
|
||||
state.project.name = state.project.name || activeProject.name || '';
|
||||
state.project.number = state.project.number || activeProject.number || '';
|
||||
state.project.client = state.project.client || activeProject.client || '';
|
||||
state.project.division = state.project.division || activeProject.division || '';
|
||||
state.project.site = state.project.site || activeProject.site || '';
|
||||
}
|
||||
}
|
||||
function updateProjectDisplay(){
|
||||
const projName = document.getElementById('proj_name')?.value || 'Project';
|
||||
const projName = document.getElementById('proj_name')?.value || (activeProject && activeProject.name) || 'Project';
|
||||
const display = document.getElementById('project-display');
|
||||
if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName;
|
||||
}
|
||||
@@ -677,11 +722,12 @@ function completeSOP(){
|
||||
sopComplete = true;
|
||||
updateProjectDisplay();
|
||||
|
||||
// Persist for the home page (green / "Review") and for the WP Creator tab.
|
||||
// Persist for the home page (green / "Review") and for the WP Creator tab,
|
||||
// namespaced to the active project so each project keeps its own SOP.
|
||||
try {
|
||||
localStorage.setItem('wp_suite_sop', JSON.stringify(sop));
|
||||
localStorage.setItem('wp_suite_state', JSON.stringify(state));
|
||||
localStorage.setItem('wp_suite_sop_complete', '1');
|
||||
localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop));
|
||||
localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state));
|
||||
localStorage.setItem(SK('wp_suite_sop_complete'), '1');
|
||||
} catch(e){}
|
||||
|
||||
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
||||
@@ -369,6 +369,7 @@
|
||||
</div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="work-package-suite-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -43,6 +43,7 @@ const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":
|
||||
|
||||
// ── STATE ────────────────────────────────────────────────────────────────────
|
||||
let SOP=null, editingId=null, numberDirty=false;
|
||||
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[];
|
||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||
@@ -631,6 +632,7 @@ function collectPackage(){
|
||||
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
||||
return {
|
||||
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
||||
projectId: (prev&&prev.projectId) || activeProjectId || '',
|
||||
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
||||
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||||
@@ -756,8 +758,10 @@ function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card')
|
||||
|
||||
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
|
||||
const STORE_KEY='wp_iwp_v1';
|
||||
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(savedPackages)); }catch(e){} }
|
||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(STORE_KEY)); if(Array.isArray(d)) savedPackages=d; }catch(e){} }
|
||||
// Per-project namespaced key so each project keeps its own packages in the browser.
|
||||
function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } }
|
||||
function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} }
|
||||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } }
|
||||
function renderSavedList(){
|
||||
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
||||
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
||||
@@ -1010,6 +1014,15 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
|
||||
}));
|
||||
|
||||
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
||||
// Resolve the active project BEFORE loading the store so namespaced keys resolve.
|
||||
(function seedProject(){
|
||||
const params = new URLSearchParams(location.search);
|
||||
activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){
|
||||
const cached = ProjectData.getActive && ProjectData.getActive();
|
||||
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
|
||||
}
|
||||
})();
|
||||
loadStore();
|
||||
(function bootSOP(){
|
||||
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
||||
@@ -1017,7 +1030,7 @@ loadStore();
|
||||
const params = new URLSearchParams(location.search);
|
||||
if(params.get('embedded')) document.body.classList.add('embedded');
|
||||
try {
|
||||
const raw = localStorage.getItem('wp_suite_sop');
|
||||
const raw = localStorage.getItem(wpKey('wp_suite_sop'));
|
||||
if(raw){
|
||||
const d = JSON.parse(raw);
|
||||
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
||||
@@ -276,6 +276,7 @@
|
||||
</aside>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="wp-creation-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4
nginx/Dockerfile
Normal file
4
nginx/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM nginx:alpine
|
||||
COPY nginx/conf.d/wp-suite.conf /etc/nginx/conf.d/wp-suite.conf
|
||||
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY html/ /usr/share/nginx/html/
|
||||
25
nginx/conf.d/wp-suite.conf
Normal file
25
nginx/conf.d/wp-suite.conf
Normal file
@@ -0,0 +1,25 @@
|
||||
# Work Package Suite — NGINX site config
|
||||
# This container sits behind an external reverse proxy that handles SSL.
|
||||
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Proxy /api/ to the FastAPI container (service name "api" on the internal network)
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
}
|
||||
}
|
||||
17
nginx/nginx.conf
Normal file
17
nginx/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
219
server/README.md
219
server/README.md
@@ -6,7 +6,7 @@ to this service.
|
||||
|
||||
```
|
||||
browser → NGINX ──serves──> static site (index.html, …)
|
||||
└─proxy /api/─> this API (uvicorn/gunicorn :8000) → PostgreSQL
|
||||
└─proxy /api/─> api container (:8000) → db container (postgres)
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
@@ -31,6 +31,8 @@ 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
|
||||
@@ -45,42 +47,193 @@ 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)
|
||||
---
|
||||
|
||||
## Production — Docker Compose
|
||||
|
||||
This is the recommended production setup. Three containers run in an isolated
|
||||
internal network; only NGINX is exposed to the outside via the external `proxy`
|
||||
network.
|
||||
|
||||
```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
|
||||
[external proxy network]
|
||||
│
|
||||
┌────▼────┐ internal network ┌──────────┐ ┌────────┐
|
||||
│ nginx │ ───────────────────> │ api │ → │ db │
|
||||
└─────────┘ └──────────┘ └────────┘
|
||||
```
|
||||
|
||||
### 1. Create the credentials file
|
||||
|
||||
Create `.env` in the **project root** (same directory as `docker-compose.yml`).
|
||||
This file is never committed — add it to `.gitignore`.
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now wp-suite-api
|
||||
# .env — project root
|
||||
POSTGRES_DB=wpsuite
|
||||
POSTGRES_USER=wpsuite
|
||||
POSTGRES_PASSWORD=<strong-random-password>
|
||||
|
||||
# Must match POSTGRES_* above; hostname is the compose service name "db"
|
||||
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
||||
```
|
||||
|
||||
NGINX already proxies `/api/` to `127.0.0.1:8000` (see `nginx-wp-suite.conf`).
|
||||
Generate a strong password:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### 2. Add the Dockerfile
|
||||
|
||||
Create `Dockerfile` in the **project root**:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY server/requirements.txt ./server/
|
||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||
COPY server/ ./server/
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||
```
|
||||
|
||||
### 3. Update the NGINX site config
|
||||
|
||||
The API is no longer at `127.0.0.1:8000` — it is the `api` container.
|
||||
Update the `/api/` proxy block in your nginx conf (e.g. `nginx/conf.d/wp-suite.conf`):
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000; # ← service name, not localhost
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. docker-compose.yml
|
||||
|
||||
Replace your existing `docker-compose.yml` with:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
|
||||
webserver:
|
||||
image: nginx:alpine
|
||||
container_name: nginx_webserver
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./logs:/var/log/nginx
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
networks:
|
||||
- proxy # external — reachable by your reverse proxy / traefik
|
||||
- internal # needs a path to the api container
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: wp_api
|
||||
env_file: .env # loads DATABASE_URL
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy # waits for postgres to accept connections
|
||||
networks:
|
||||
- internal
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: wp_db
|
||||
env_file: .env # loads POSTGRES_DB / USER / PASSWORD
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
name: proxy
|
||||
external: true
|
||||
internal:
|
||||
internal: true # no outbound internet access from api/db
|
||||
```
|
||||
|
||||
### 5. First-time startup
|
||||
|
||||
```bash
|
||||
# Build the api image and start all containers
|
||||
docker compose up -d --build
|
||||
|
||||
# Confirm all three containers are running
|
||||
docker compose ps
|
||||
|
||||
# Tail logs (Ctrl-C to stop following)
|
||||
docker compose logs -f api
|
||||
```
|
||||
|
||||
Tables are created automatically on first API startup — no manual `CREATE TABLE`
|
||||
needed.
|
||||
|
||||
### Authentication notes
|
||||
|
||||
**Postgres → API authentication** is handled entirely through `DATABASE_URL` in
|
||||
`.env`. The `db` container uses `POSTGRES_USER` / `POSTGRES_PASSWORD` to
|
||||
initialise the database on first run; the `api` container uses the matching
|
||||
credentials in `DATABASE_URL` to connect. Neither credential ever appears in the
|
||||
compose file itself.
|
||||
|
||||
**Network isolation**: the `db` container is on the `internal` network only —
|
||||
it has no port exposed to the host and is unreachable from outside the compose
|
||||
stack. Only the `api` container can open a connection to it.
|
||||
|
||||
**Changing the password**: update both `POSTGRES_PASSWORD` and the password
|
||||
in `DATABASE_URL` in `.env`, then:
|
||||
```bash
|
||||
# Stop api first (db must keep running to accept the ALTER USER command)
|
||||
docker compose stop api
|
||||
docker compose exec db psql -U wpsuite -c "ALTER USER wpsuite PASSWORD 'new-password';"
|
||||
docker compose start api
|
||||
```
|
||||
|
||||
### Day-to-day operations
|
||||
|
||||
```bash
|
||||
# Rebuild api after a code change
|
||||
docker compose up -d --build api
|
||||
|
||||
# View postgres data directly
|
||||
docker compose exec db psql -U wpsuite -d wpsuite
|
||||
|
||||
# Take a database backup
|
||||
docker compose exec db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||
|
||||
# Restore from backup
|
||||
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-2025-01-01.sql
|
||||
|
||||
# Stop everything (data volume is preserved)
|
||||
docker compose down
|
||||
|
||||
# Stop everything AND delete all data
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick test
|
||||
|
||||
@@ -91,3 +244,9 @@ curl -X POST http://127.0.0.1:8000/api/comments \
|
||||
|
||||
curl http://127.0.0.1:8000/api/comments
|
||||
```
|
||||
|
||||
Or via the nginx proxy (replace with your hostname):
|
||||
|
||||
```bash
|
||||
curl https://wp-suite.company.local/api/health
|
||||
```
|
||||
|
||||
@@ -41,8 +41,21 @@ def gen_id(prefix: str) -> str:
|
||||
|
||||
|
||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||
class ProjectIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str = ""
|
||||
number: str = ""
|
||||
client: str = ""
|
||||
division: str = ""
|
||||
site: str = ""
|
||||
sample: bool = False
|
||||
created_by: str = ""
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SopIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
name: str = ""
|
||||
number: str = ""
|
||||
complete: bool = False
|
||||
@@ -52,6 +65,7 @@ class SopIn(BaseModel):
|
||||
|
||||
class WpIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
sop_id: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
number: str = ""
|
||||
@@ -86,6 +100,50 @@ def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/projects")
|
||||
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, body.id) if body.id else None
|
||||
if proj is None:
|
||||
proj = models.Project(id=body.id or gen_id("proj"))
|
||||
db.add(proj)
|
||||
proj.name = body.name
|
||||
proj.number = body.number
|
||||
proj.client = body.client
|
||||
proj.division = body.division
|
||||
proj.site = body.site
|
||||
proj.sample = body.sample
|
||||
proj.created_by = body.created_by or proj.created_by
|
||||
proj.data = body.data
|
||||
db.commit()
|
||||
db.refresh(proj)
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def list_projects(db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all()
|
||||
return [p.summary() for p in rows]
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/projects/{project_id}")
|
||||
def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
db.delete(proj)
|
||||
db.commit()
|
||||
return {"deleted": project_id}
|
||||
|
||||
|
||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/sops")
|
||||
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
@@ -93,6 +151,7 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
db.add(sop)
|
||||
sop.project_id = body.project_id
|
||||
sop.name = body.name
|
||||
sop.number = body.number
|
||||
sop.complete = body.complete
|
||||
@@ -104,16 +163,21 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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()
|
||||
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||
rows = db.scalars(stmt.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)):
|
||||
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if complete is not None:
|
||||
stmt = stmt.where(models.Sop.complete == complete)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||
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")
|
||||
@@ -145,6 +209,7 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
wp.project_id = body.project_id
|
||||
wp.sop_id = body.sop_id
|
||||
wp.parent_id = body.parent_id
|
||||
wp.number = body.number
|
||||
@@ -160,12 +225,15 @@ def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
|
||||
@app.get("/api/wps")
|
||||
def list_wps(
|
||||
project_id: Optional[str] = Query(None),
|
||||
sop_id: Optional[str] = Query(None),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(models.WorkPackage)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
if sop_id:
|
||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||
if parent_id:
|
||||
@@ -177,11 +245,13 @@ def list_wps(
|
||||
|
||||
|
||||
@app.get("/api/wps/metrics")
|
||||
def wp_metrics(sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||
from counts so a split package's hours aren't double-counted with its
|
||||
instances."""
|
||||
stmt = select(models.WorkPackage)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
if sop_id:
|
||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||
rows = db.scalars(stmt).all()
|
||||
|
||||
@@ -21,10 +21,42 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""A construction project — the top-level container. SOPs and Work Packages
|
||||
belong to a project so the suite can be used for many jobs at once."""
|
||||
__tablename__ = "projects"
|
||||
|
||||
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="", index=True)
|
||||
client: Mapped[str] = mapped_column(String(300), default="")
|
||||
division: Mapped[str] = mapped_column(String(200), default="")
|
||||
site: Mapped[str] = mapped_column(String(300), default="")
|
||||
sample: 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,
|
||||
"client": self.client, "division": self.division, "site": self.site,
|
||||
"sample": self.sample, "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 Sop(Base):
|
||||
__tablename__ = "sops"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=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)
|
||||
@@ -35,8 +67,8 @@ class Sop(Base):
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "name": self.name, "number": self.number,
|
||||
"complete": self.complete, "created_by": self.created_by,
|
||||
"id": self.id, "project_id": self.project_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),
|
||||
}
|
||||
|
||||
@@ -48,6 +80,9 @@ class WorkPackage(Base):
|
||||
__tablename__ = "work_packages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
sop_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(40), ForeignKey("sops.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
@@ -65,9 +100,9 @@ class WorkPackage(Base):
|
||||
|
||||
def summary(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "sop_id": self.sop_id, "parent_id": self.parent_id,
|
||||
"number": self.number, "subject": self.subject, "type": self.type,
|
||||
"status": self.status, "issued_at": _iso(self.issued_at),
|
||||
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||
"type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
|
||||
"created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user