Compare commits

...

11 Commits

Author SHA1 Message Date
e5c450597a Isolate SOP/WP data per project (namespaced localStorage)
SOP and Work Package localStorage keys are now namespaced by the active
project via ProjectData.key(base) -> base+'__'+<projectId> (SK() in the
suite, wpKey() in the creator), so switching projects shows that project's
own SOP and packages. project-data.js runs a one-time discard of the legacy
un-namespaced keys (guarded by wp_ns_migrated_v1), per the chosen approach.

Active project is resolved before the store loads in both the suite and the
creator so namespaced keys resolve correctly, including standalone deep-links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:46:59 -07:00
3c40b58ff8 Add multi-project support: projects entity, picker home page, project context
Projects become the top-level container; SOPs and Work Packages belong to one.

Backend:
- New projects table + CRUD (/api/projects).
- sops.project_id (FK, cascade) and work_packages.project_id added;
  list/latest/metrics endpoints accept a project_id filter.

Front end (now under html/):
- project-data.js: shared API-first ProjectData adapter with localStorage
  fallback + active-project helpers.
- Home page: removed "About This Suite"; added a Project picker (create /
  use sample / select). Tool cards stay hidden until a project is active and
  carry &project=<id>; hero shows the active project.
- Suite reads ?project, resolves it, shows it in the header, and prefills the
  SOP project fields; passes &project into the WP-creator iframe.
- WP creator stamps projectId onto saved packages.

SOP/WP localStorage is not yet namespaced per project (next step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:39:45 -07:00
4d111d608d Merge branch 'main' into feat/wp-discipline-split-dashboard 2026-06-15 15:28:35 -07:00
a37cf14e89 fix nginx.conf 2026-06-15 15:13:54 -05:00
362aa633ed Build nginx image with static files baked in; remove host bind mounts 2026-06-15 14:57:54 -05:00
fd668f0ea2 Move static files into html/ for Docker bind mount 2026-06-15 14:47:24 -05:00
960b4a4b94 nginx updates 2026-06-15 14:42:25 -05:00
8598606165 fix docker compose 2026-06-15 14:31:56 -05:00
65996c2c0a troubleshoot docker-compose 2026-06-15 14:25:18 -05:00
b0a3d74412 Add docker compose 2026-06-15 14:02:52 -05:00
ffcaa571d1 Add API to production 2026-06-15 13:46:43 -05:00
22 changed files with 795 additions and 82 deletions

3
.gitignore vendored
View File

@@ -11,3 +11,6 @@ venv/
# Local SQLite dev database
*.db
wpsuite.db
# Runtime directories (created by containers)
logs/

8
Dockerfile Normal file
View 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"]

View File

@@ -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
View 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

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -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 &amp; 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)+')':''}
&nbsp;<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 = [];

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

96
html/project-data.js Normal file
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
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);

View File

@@ -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});

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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
View 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/

View 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
View 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;
}

View File

@@ -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
```

View File

@@ -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()

View File

@@ -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),
}