Per-project access control + UI/feedback/admin refinements

Access control:
- project_members table; non-admins only see/operate on assigned
  projects (enforced across projects, SOPs, work packages — 403 else),
  admins bypass. Creating a project auto-grants its creator access.
- Admin API to get/set a user's project assignments, plus a checkbox
  assignment dialog in the Admin Console user list.

UI / workflow:
- Login page: drop the "Prime Controls" wordmark next to the logo.
- SOP tool: remove emoji icons from buttons and nav tabs.
- Rename "Step Comments" to "Feedback"; the author auto-populates
  (read-only) from the signed-in user.
- Move usage-log viewing to the Admin Console; add an admin card that
  lists all feedback/comments (who, what, page + step, when).
- Sample project name -> "Micron FMCS Install (sample)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:38:56 -07:00
parent bdb798efdd
commit 5ad3ffa58e
8 changed files with 322 additions and 38 deletions

View File

@@ -103,6 +103,29 @@
<div id="users-create-msg" class="note"></div>
</div>
<!-- ALL FEEDBACK / COMMENTS -->
<div class="card">
<h2>All feedback &amp; comments</h2>
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page &amp; step) when they commented.</div>
<div class="row">
<button onclick="loadComments()">Refresh comments</button>
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
</div>
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="row">
<button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button>
</div>
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- DB SNAPSHOT -->
<div class="card">
<h2>Database snapshot</h2>

View File

@@ -11,6 +11,8 @@ function reveal(){
document.getElementById('admin-main').style.display='';
checkHealth();
loadUsers();
loadComments();
loadUsage();
}
function showDenied(){
document.getElementById('admin-denied').style.display='';
@@ -195,6 +197,7 @@ function renderUsers(list, meId){
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
disableBtn+delBtn+
'</div></td>'+
@@ -248,6 +251,125 @@ async function deleteUser(id, username){
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
}
// ── project access assignment ───────────────────────────────────────────────────
async function manageProjects(id, username){
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
}
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
function openProjectModal(userId, username, projects, assigned, userObj){
closeProjectModal();
const isAdmin = userObj && userObj.role==='admin';
const items = projects.length ? projects.map(p =>
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
const modal = document.createElement('div');
modal.id = 'proj-modal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
modal.innerHTML =
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
'<div style="padding:14px 18px;overflow:auto;">'+
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
'<div id="proj-list">'+items+'</div>'+
'</div>'+
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
'<button onclick="closeProjectModal()">Cancel</button>'+
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
'</div>'+
'</div>';
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
document.body.appendChild(modal);
const saveBtn = document.getElementById('proj-save');
if(saveBtn) saveBtn.onclick = async () => {
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
if(status===200) closeProjectModal();
else alert('Save failed (HTTP '+status+').');
};
}
// ── all feedback / comments ─────────────────────────────────────────────────────
let _comments = [];
async function loadComments(){
const box = document.getElementById('comments-admin');
box.textContent = 'Loading…';
const { status, json } = await api('GET','/api/comments');
if(status!==200 || !Array.isArray(json)){
box.innerHTML = '<div class="banner bad">Could not load comments (HTTP '+status+').</div>'; return;
}
_comments = json;
const sel = document.getElementById('cmt-filter'); const cur = sel.value;
const sources = [...new Set(json.map(c=>c.source).filter(Boolean))].sort();
sel.innerHTML = '<option value="">All sources</option>' + sources.map(s=>'<option value="'+uesc(s)+'">'+uesc(s)+'</option>').join('');
sel.value = cur;
renderComments();
}
function renderComments(){
const box = document.getElementById('comments-admin');
const src = document.getElementById('cmt-filter').value;
const q = (document.getElementById('cmt-search').value||'').toLowerCase();
let rows = _comments.filter(c => (!src || c.source===src) &&
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const where = c => {
const bits = [];
if(c.page) bits.push(uesc(c.page));
if(c.step!=null) bits.push('step '+c.step);
if(c.sop_id) bits.push('SOP '+uesc(c.sop_id));
if(c.wp_id) bits.push('WP '+uesc(c.wp_id));
return bits.join(' · ') || '—';
};
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Source</th><th>Where</th><th>Comment</th></tr></thead><tbody>'+
rows.map(c => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(c.created_at)+'</td>'+
'<td><strong>'+uesc(c.author||'Anonymous')+'</strong></td>'+
'<td>'+uesc(c.source||'—')+'</td>'+
'<td style="color:var(--muted)">'+where(c)+'</td>'+
'<td>'+uesc(c.text||'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1';
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
function loadUsage(){
const box = document.getElementById('usage-admin');
const evs = (usageLoad().events) || [];
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
const byEvent = {}, byStep = {}, sessions = new Set();
let first = evs[0].ts, last = evs[0].ts;
evs.forEach(e => {
byEvent[e.event] = (byEvent[e.event]||0)+1;
if(e.session) sessions.add(e.session);
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
});
const fmt = s => s ? new Date(s).toLocaleString() : '—';
let html = '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
html += '</tbody></table>';
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>';
box.innerHTML = html;
}
function downloadUsage(){
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
}
// ── access control: admins only ─────────────────────────────────────────────────
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
// Show the console for admins; otherwise show the "Admins only" notice.

View File

@@ -76,7 +76,6 @@
<main class="card">
<div class="brand">
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
<span class="name">Prime Controls</span>
</div>
<h1>Sign in</h1>
<p class="sub">Work Package Suite</p>

View File

@@ -25,7 +25,7 @@
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',
name: 'Micron FMCS Install (sample)', number: '26-67-008',
client: 'Micron Technology, Inc.', division: 'Semiconductor',
site: 'Boise, ID — Fab', sample: true
};

View File

@@ -159,6 +159,12 @@ window.addEventListener('DOMContentLoaded',()=>{
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
track('app_open');
// Feedback author auto-populates from the signed-in user (auth-guard sets
// window.WP_USER and fires 'wp-auth-ready'); the field is read-only.
setCommenterName();
document.addEventListener('wp-auth-ready', setCommenterName);
let _fieldTimer;
document.addEventListener('input', e=>{
const t = e.target;
@@ -853,8 +859,17 @@ function toggleComments(){
if(panel.style.display === 'block') loadStepComments();
}
function currentUserName(){
const u = window.WP_USER;
return (u && (u.full_name || u.username)) || '';
}
function setCommenterName(){
const el = document.getElementById('commenter-name');
if(el) el.value = currentUserName();
}
function submitComment(){
const name = document.getElementById('commenter-name').value || 'Anonymous';
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
const text = document.getElementById('comment-text').value.trim();
if(!text){ alert('Please enter a comment.'); return; }

View File

@@ -23,10 +23,9 @@
</div>
</div>
<div class="header-right">
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button>
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">❔ Help</button>
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button>
<button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</button>
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button>
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
</div>
</div>
@@ -34,13 +33,13 @@
<!-- MAIN NAVIGATION -->
<div class="main-nav">
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
<span class="tab-icon">⚙️</span> SOP Configuration
SOP Configuration
</button>
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
<span class="tab-icon">📋</span> Work Package Creation
Work Package Creation
</button>
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
<span class="tab-icon">📊</span> Dashboard
Dashboard
</button>
</div>
@@ -298,7 +297,7 @@
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button>
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button>
</div>
</div>
@@ -329,9 +328,9 @@
<!-- SOP NAVIGATION -->
<div class="step-navigation">
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button>
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button>
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button>
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button>
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
</div>
</div>
@@ -342,9 +341,9 @@
<div id="tool-wp" class="tool">
<!-- Shown until the SOP is complete -->
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
<h2>📋 Work Package Creation</h2>
<h2>Work Package Creation</h2>
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
</div>
<!-- The real Work Package Creator, embedded once the SOP is complete -->
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
@@ -357,12 +356,12 @@
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
<div id="comments-panel" class="comments-dropdown" style="display: none;">
<div class="comments-dropdown-header">
<strong>💬 Step Comments</strong>
<strong>Feedback</strong>
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close"></button>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
<input type="text" id="commenter-name" placeholder="e.g., your name" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
<label style="font-weight: 600; font-size: 13px;">Your Name</label>
<input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
@@ -370,8 +369,8 @@
</div>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button>
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button>
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button>
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button>
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
</div>
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>

View File

@@ -56,6 +56,54 @@ def gen_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}"
# ── Per-project access control ─────────────────────────────────────────────────
# A non-admin user may only touch projects they're a member of (project_members).
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
# gated. List endpoints are scoped to accessible projects; single-resource and
# mutating endpoints raise 403 on no access.
def accessible_project_ids(db: Session, user: "models.User"):
"""Return the set of project ids the user may access, or None for 'all' (admin)."""
if user.role == "admin":
return None
rows = db.scalars(
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
).all()
return set(rows)
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
if user.role == "admin" or project_id is None:
return
ok = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user.id)
& (models.ProjectMember.project_id == project_id)
)
)
if not ok:
raise HTTPException(status_code=403, detail="You don't have access to this project")
def scope_to_access(stmt, column, db: Session, user: "models.User"):
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
ids = accessible_project_ids(db, user)
if ids is None:
return stmt
return stmt.where(column.in_(ids))
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
"""Add a (user, project) membership if it isn't already there."""
exists = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user_id)
& (models.ProjectMember.project_id == project_id)
)
)
if not exists:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
# ── Request bodies ───────────────────────────────────────────────────────────
class ProjectIn(BaseModel):
id: Optional[str] = None
@@ -143,6 +191,10 @@ class ActiveIn(BaseModel):
is_active: bool
class ProjectAssignIn(BaseModel):
project_ids: list[str] = Field(default_factory=list)
@app.post("/api/auth/login")
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
"""Verify credentials and, on success, set the HttpOnly session cookie."""
@@ -249,10 +301,43 @@ def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin),
return {"deleted": user_id}
@app.get("/api/auth/users/{user_id}/projects")
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Which projects a user is assigned to, plus the full project list for the
assignment UI. (Admins implicitly access every project regardless.)"""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
return {
"user": u.to_dict(),
"assigned": list(assigned),
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
}
@app.put("/api/auth/users/{user_id}/projects")
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Replace a user's project assignments with the given set."""
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
for pid in valid:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
db.commit()
return {"assigned": sorted(valid)}
# ── Projects ─────────────────────────────────────────────────────────────────
@app.post("/api/projects")
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, body.id) if body.id else None
is_new = proj is None
if not is_new:
require_project_access(db, user, proj.id)
if proj is None:
proj = models.Project(id=body.id or gen_id("proj"))
db.add(proj)
@@ -265,29 +350,36 @@ def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
proj.created_by = body.created_by or proj.created_by
proj.data = body.data
db.commit()
# A project created by a non-admin auto-grants its creator access.
if is_new and user.role != "admin":
grant_project_access(db, user.id, proj.id)
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()
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
rows = db.scalars(stmt).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)):
def get_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, proj.id)
return proj.to_dict()
@app.delete("/api/projects/{project_id}")
def delete_project(project_id: str, db: Session = Depends(get_db)):
def delete_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, proj.id)
db.delete(proj)
db.commit()
return {"deleted": project_id}
@@ -295,8 +387,11 @@ def delete_project(project_id: str, db: Session = Depends(get_db)):
# ── SOPs ─────────────────────────────────────────────────────────────────────
@app.post("/api/sops")
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
require_project_access(db, user, body.project_id)
sop = db.get(models.Sop, body.id) if body.id else None
if sop is not None:
require_project_access(db, user, sop.project_id)
if sop is None:
sop = models.Sop(id=body.id or gen_id("sop"))
db.add(sop)
@@ -312,21 +407,23 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
@app.get("/api/sops")
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
def list_sops(project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Sop)
if project_id:
stmt = stmt.where(models.Sop.project_id == project_id)
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
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, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), 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)
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
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")
@@ -334,18 +431,20 @@ def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Quer
@app.get("/api/sops/{sop_id}")
def get_sop(sop_id: str, db: Session = Depends(get_db)):
def get_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
sop = db.get(models.Sop, sop_id)
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
return sop.to_dict()
@app.delete("/api/sops/{sop_id}")
def delete_sop(sop_id: str, db: Session = Depends(get_db)):
def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
sop = db.get(models.Sop, sop_id)
if not sop:
raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
db.delete(sop)
db.commit()
return {"deleted": sop_id}
@@ -353,8 +452,11 @@ def delete_sop(sop_id: str, db: Session = Depends(get_db)):
# ── Work Packages ────────────────────────────────────────────────────────────
@app.post("/api/wps")
def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
require_project_access(db, user, body.project_id)
wp = db.get(models.WorkPackage, body.id) if body.id else None
if wp is not None:
require_project_access(db, user, wp.project_id)
if wp is None:
wp = models.WorkPackage(id=body.id or gen_id("wp"))
db.add(wp)
@@ -378,6 +480,7 @@ def list_wps(
sop_id: Optional[str] = Query(None),
parent_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
stmt = select(models.WorkPackage)
@@ -389,12 +492,13 @@ def list_wps(
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
if status:
stmt = stmt.where(models.WorkPackage.status == status)
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
return [w.summary() for w in rows]
@app.get("/api/wps/metrics")
def wp_metrics(project_id: Optional[str] = Query(None), 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), user: models.User = Depends(auth.get_current_user), 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."""
@@ -403,6 +507,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
stmt = stmt.where(models.WorkPackage.project_id == project_id)
if sop_id:
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
rows = db.scalars(stmt).all()
by_status: dict[str, int] = {}
@@ -436,30 +541,33 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
@app.get("/api/wps/{wp_id}")
def get_wp(wp_id: str, db: Session = Depends(get_db)):
def get_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
return wp.to_dict()
@app.delete("/api/wps/{wp_id}")
def delete_wp(wp_id: str, db: Session = Depends(get_db)):
def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
db.delete(wp)
db.commit()
return {"deleted": wp_id}
@app.post("/api/wps/{wp_id}/issue")
def issue_wp(wp_id: str, db: Session = Depends(get_db)):
def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Release a Work Package to the field. Refuses if any constraint is still
open (the AWP release gate)."""
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
constraints = (wp.data or {}).get("constraints") or []
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
if open_names:
@@ -472,10 +580,11 @@ def issue_wp(wp_id: str, db: Session = Depends(get_db)):
@app.post("/api/wps/{wp_id}/status")
def set_wp_status(wp_id: str, body: StatusIn, db: Session = Depends(get_db)):
def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
wp.status = body.status
if body.status == "Issued" and wp.issued_at is None:
wp.issued_at = models.utcnow()

View File

@@ -12,7 +12,7 @@ can upsert without round-tripping a sequence.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
@@ -137,6 +137,23 @@ class User(Base):
}
class ProjectMember(Base):
"""Which users may access which projects. A user sees/operates on a project
only if a row links them to it (admins bypass this entirely). One row per
(user, project) pair."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Comment(Base):
__tablename__ = "comments"