feat: add demo measurements to seed and user management to setup page

Seed now generates 180 realistic measurements (30 per subtask) with
Gaussian distribution for SPC testing. Setup page gains full user CRUD
with list, create/edit, password change, and activate/deactivate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-24 14:49:58 +01:00
parent 272685e554
commit e07a4e4f89
3 changed files with 953 additions and 7 deletions
+271 -2
View File
@@ -3,21 +3,24 @@
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404. Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access). NOT protected by API Key auth (standalone access).
""" """
import random
import secrets import secrets
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import inspect as sa_inspect from sqlalchemy import inspect as sa_inspect, select
from config import settings from config import settings
from database import Base, engine, async_session_factory from database import Base, engine, async_session_factory
from models import ( from models import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
) )
from services.auth_service import hash_password from services.auth_service import hash_password
from services.measurement_service import calculate_pass_fail
router = APIRouter(prefix="/api/setup", tags=["setup"]) router = APIRouter(prefix="/api/setup", tags=["setup"])
@@ -46,6 +49,28 @@ class ResetDbBody(BaseModel):
confirm: bool confirm: bool
class ManageUserBody(BaseModel):
password: str # setup password
user_id: int | None = None # None = create, int = update
username: str
user_password: str | None = None # Required for create, optional for update
display_name: str
email: str | None = None
roles: list[str] = []
is_admin: bool = False
class ChangeUserPasswordBody(BaseModel):
password: str # setup password
user_id: int
new_password: str
class ToggleUserActiveBody(BaseModel):
password: str # setup password
user_id: int
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -66,6 +91,76 @@ def _check_password(password: str) -> None:
) )
def _generate_measurements_for_subtask(
subtask: RecipeSubtask,
version_id: int,
measured_by: int,
lot_numbers: list[str],
serial_numbers: list[str],
base_date: datetime,
count: int = 30,
) -> list[Measurement]:
"""Generate realistic demo measurements with Gaussian distribution."""
nominal = float(subtask.nominal)
uwl = float(subtask.uwl)
ltl = float(subtask.ltl)
utl = float(subtask.utl)
lwl = float(subtask.lwl)
sigma = (uwl - nominal) / 2.5
measurements = []
for i in range(count):
# ~80% pass, ~13% warning, ~7% fail
r = random.random()
if r < 0.07:
# Fail: outside tolerance limits
if random.random() < 0.5:
value = random.uniform(utl, utl + (utl - nominal))
else:
value = random.uniform(ltl - (nominal - ltl), ltl)
elif r < 0.20:
# Warning: between warning and tolerance
if random.random() < 0.5:
value = random.uniform(uwl, utl)
else:
value = random.uniform(ltl, lwl)
else:
# Pass: Gaussian around nominal
value = random.gauss(nominal, sigma * 0.7)
# Clamp to within warning limits
value = max(lwl, min(uwl, value))
value = round(value, 6)
pass_fail, deviation = calculate_pass_fail(value, subtask)
# Distribute across time, lots, serials
days_offset = random.uniform(0, 30)
hours_offset = random.uniform(8, 17) # working hours
measured_at = base_date - timedelta(days=days_offset) + timedelta(hours=hours_offset)
lot = lot_numbers[i % len(lot_numbers)]
serial = serial_numbers[i % len(serial_numbers)]
input_method = "usb_caliper" if random.random() < 0.7 else "manual"
m = Measurement(
subtask_id=subtask.id,
version_id=version_id,
measured_by=measured_by,
value=value,
pass_fail=pass_fail,
deviation=deviation,
lot_number=lot,
serial_number=serial,
input_method=input_method,
measured_at=measured_at,
)
measurements.append(m)
return measurements
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Endpoints # Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -312,14 +407,188 @@ async def seed_demo_data(body: PasswordBody):
] ]
session.add_all(subtasks_2) session.add_all(subtasks_2)
# ---- Measurements -------------------------------------------------
await session.flush() # Get subtask IDs
random.seed(42) # Reproducible demo data
tec_user = created_users[2] # tec1
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
base_date = datetime.now()
all_subtasks = subtasks_1 + subtasks_2
measurements_created = 0
for st in all_subtasks:
ms = _generate_measurements_for_subtask(
subtask=st,
version_id=version.id,
measured_by=tec_user.id,
lot_numbers=lot_numbers,
serial_numbers=serial_numbers,
base_date=base_date,
count=30,
)
session.add_all(ms)
measurements_created += len(ms)
return { return {
"status": "ok", "status": "ok",
"message": "Demo data seeded successfully", "message": "Demo data seeded successfully",
"users_created": [u["username"] for u in users_data], "users_created": [u["username"] for u in users_data],
"recipe_created": "DEMO-001", "recipe_created": "DEMO-001",
"measurements_created": measurements_created,
} }
@router.post("/list-users")
async def list_users(body: PasswordBody):
"""List all users."""
_check_password(body.password)
async with async_session_factory() as session:
result = await session.execute(select(User).order_by(User.id))
users = result.scalars().all()
return {
"status": "ok",
"users": [
{
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"email": u.email,
"roles": u.roles or [],
"is_admin": u.is_admin,
"active": u.active,
"created_at": u.created_at.isoformat() if u.created_at else None,
"last_login": u.last_login.isoformat() if u.last_login else None,
}
for u in users
],
}
@router.post("/manage-user")
async def manage_user(body: ManageUserBody):
"""Create or update a user. user_id=null creates new, user_id=int updates."""
_check_password(body.password)
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
invalid = set(body.roles) - VALID_ROLES
if invalid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid roles: {', '.join(invalid)}",
)
async with async_session_factory() as session:
async with session.begin():
if body.user_id is None:
# Create
if not body.user_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password is required when creating a new user",
)
# Check username uniqueness
existing = await session.execute(
select(User).where(User.username == body.username)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username '{body.username}' already exists",
)
user = User(
username=body.username,
password_hash=hash_password(body.user_password),
display_name=body.display_name,
email=body.email,
roles=body.roles,
is_admin=body.is_admin,
api_key=secrets.token_hex(32),
)
session.add(user)
await session.flush()
return {"status": "ok", "message": f"User '{body.username}' created", "user_id": user.id}
else:
# Update
result = await session.execute(
select(User).where(User.id == body.user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User ID {body.user_id} not found",
)
# Check username uniqueness (exclude self)
existing = await session.execute(
select(User).where(
User.username == body.username, User.id != body.user_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username '{body.username}' already taken",
)
user.username = body.username
user.display_name = body.display_name
user.email = body.email
user.roles = body.roles
user.is_admin = body.is_admin
if body.user_password:
user.password_hash = hash_password(body.user_password)
return {"status": "ok", "message": f"User '{body.username}' updated", "user_id": user.id}
@router.post("/change-user-password")
async def change_user_password(body: ChangeUserPasswordBody):
"""Change a user's password and invalidate their API key."""
_check_password(body.password)
async with async_session_factory() as session:
async with session.begin():
result = await session.execute(
select(User).where(User.id == body.user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User ID {body.user_id} not found",
)
user.password_hash = hash_password(body.new_password)
user.api_key = None # Invalidate sessions
return {"status": "ok", "message": f"Password changed for '{user.username}'"}
@router.post("/toggle-user-active")
async def toggle_user_active(body: ToggleUserActiveBody):
"""Toggle a user's active status. Invalidates API key when deactivated."""
_check_password(body.password)
async with async_session_factory() as session:
async with session.begin():
result = await session.execute(
select(User).where(User.id == body.user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User ID {body.user_id} not found",
)
user.active = not user.active
if not user.active:
user.api_key = None # Invalidate sessions on deactivation
new_status = "active" if user.active else "inactive"
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
@router.post("/reset-db") @router.post("/reset-db")
async def reset_db(body: ResetDbBody): async def reset_db(body: ResetDbBody):
"""Drop all tables and recreate them. Requires confirm=true.""" """Drop all tables and recreate them. Requires confirm=true."""
+388 -5
View File
@@ -224,6 +224,123 @@
.mt-4 { margin-top: 16px; } .mt-4 { margin-top: 16px; }
.text-sm { font-size: 0.82rem; } .text-sm { font-size: 0.82rem; }
.text-muted { color: #94a3b8; } .text-muted { color: #94a3b8; }
/* --- User Management ----------------------------------------------- */
.user-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-top: 12px;
}
.user-table th {
text-align: left;
padding: 8px 10px;
background: #f8fafc;
color: #475569;
font-weight: 600;
border-bottom: 2px solid #e2e8f0;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.user-table td {
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.user-table tr:hover td { background: #f8fafc; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.badge-maker { background: #dbeafe; color: #1d4ed8; }
.badge-measurement { background: #dcfce7; color: #166534; }
.badge-metrologist { background: #fef3c7; color: #92400e; }
.badge-admin { background: #fce7f3; color: #9d174d; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-inactive { background: #fee2e2; color: #991b1b; }
.btn-sm {
padding: 4px 10px;
font-size: 0.75rem;
border-radius: 6px;
border: none;
cursor: pointer;
font-weight: 500;
font-family: inherit;
transition: background .15s;
}
.btn-sm-primary { background: #2563eb; color: #fff; }
.btn-sm-primary:hover { background: #1d4ed8; }
.btn-sm-warning { background: #f59e0b; color: #fff; }
.btn-sm-warning:hover { background: #d97706; }
.btn-sm-danger { background: #ef4444; color: #fff; }
.btn-sm-danger:hover { background: #dc2626; }
.btn-sm-success { background: #10b981; color: #fff; }
.btn-sm-success:hover { background: #059669; }
.action-btns { display: flex; gap: 4px; flex-wrap: wrap; }
/* --- Modal --------------------------------------------------------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeInOverlay .2s ease-out;
}
@keyframes fadeInOverlay { from { opacity: 0; } to { opacity: 1; } }
.modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,.2);
padding: 24px;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: scaleIn .2s ease-out;
}
@keyframes scaleIn { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.modal h2 { margin-bottom: 16px; }
.modal .btn { margin-top: 8px; }
.modal-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.modal-actions .btn { flex: 1; }
/* --- Checkbox group ------------------------------------------------ */
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
margin-bottom: 0;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 16px; height: 16px;
accent-color: #2563eb;
}
</style> </style>
</head> </head>
<body> <body>
@@ -300,14 +417,26 @@
<div class="card"> <div class="card">
<h2>Seed Demo Data</h2> <h2>Seed Demo Data</h2>
<p class="text-sm text-muted" style="margin-bottom:14px;"> <p class="text-sm text-muted" style="margin-bottom:14px;">
Populate the database with sample users and a demo recipe. Populate the database with sample users, a demo recipe, and 180 measurements for SPC testing.
Creates users: <span class="mono">admin</span>, <span class="mono">maker1</span>, Creates users: <span class="mono">admin</span>, <span class="mono">maker1</span>,
<span class="mono">tec1</span>, <span class="mono">metro1</span>. <span class="mono">tec1</span>, <span class="mono">metro1</span>.
</p> </p>
<button class="btn btn-success" id="btn-seed" onclick="seedData()">Load Demo Data</button> <button class="btn btn-success" id="btn-seed" onclick="seedData()">Load Demo Data</button>
</div> </div>
<!-- 5. Reset DB ---------------------------------------------------- --> <!-- 5. User Management ------------------------------------------------ -->
<div class="card" id="card-users">
<h2>User Management</h2>
<p class="text-sm text-muted" style="margin-bottom:14px;">
Create, edit, and manage user accounts. Changes take effect immediately.
</p>
<button class="btn btn-primary" style="width:auto; margin-bottom:14px;" onclick="openCreateUserModal()">+ New User</button>
<div id="users-table-container">
<p class="text-sm text-muted">Unlock the setup page to load users.</p>
</div>
</div>
<!-- 6. Reset DB ---------------------------------------------------- -->
<div class="card danger"> <div class="card danger">
<h2 style="color:#dc2626;">Reset Database</h2> <h2 style="color:#dc2626;">Reset Database</h2>
<p class="text-sm" style="color:#991b1b; margin-bottom:8px;"> <p class="text-sm" style="color:#991b1b; margin-bottom:8px;">
@@ -321,6 +450,58 @@
</div> </div>
</div> </div>
<!-- ================================================================== -->
<!-- MODALS -->
<!-- ================================================================== -->
<!-- Create / Edit User Modal -->
<div class="modal-overlay hidden" id="modal-user">
<div class="modal">
<h2 id="modal-user-title">New User</h2>
<input type="hidden" id="mu-user-id">
<label for="mu-username">Username</label>
<input type="text" id="mu-username" placeholder="username">
<label for="mu-password">Password <span id="mu-pwd-hint" class="text-muted text-sm">(required)</span></label>
<input type="password" id="mu-password" placeholder="Password">
<label for="mu-displayname">Display Name</label>
<input type="text" id="mu-displayname" placeholder="Full Name">
<label for="mu-email">Email</label>
<input type="email" id="mu-email" placeholder="user@example.com">
<label>Roles</label>
<div class="checkbox-group">
<label><input type="checkbox" id="mu-role-maker" value="Maker"> Maker</label>
<label><input type="checkbox" id="mu-role-tec" value="MeasurementTec"> MeasurementTec</label>
<label><input type="checkbox" id="mu-role-metro" value="Metrologist"> Metrologist</label>
</div>
<div class="checkbox-group">
<label><input type="checkbox" id="mu-admin"> Administrator</label>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('modal-user')">Cancel</button>
<button class="btn btn-primary" id="btn-save-user" onclick="saveUser()">Save User</button>
</div>
</div>
</div>
<!-- Change Password Modal -->
<div class="modal-overlay hidden" id="modal-password">
<div class="modal">
<h2>Change Password</h2>
<input type="hidden" id="mp-user-id">
<p class="text-sm text-muted" style="margin-bottom:14px;">
Changing password for: <strong id="mp-username"></strong>
</p>
<label for="mp-new-password">New Password</label>
<input type="password" id="mp-new-password" placeholder="New password">
<label for="mp-confirm-password">Confirm Password</label>
<input type="password" id="mp-confirm-password" placeholder="Confirm password">
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('modal-password')">Cancel</button>
<button class="btn btn-primary" onclick="changePassword()">Change Password</button>
</div>
</div>
</div>
<!-- ================================================================== --> <!-- ================================================================== -->
<!-- JAVASCRIPT --> <!-- JAVASCRIPT -->
<!-- ================================================================== --> <!-- ================================================================== -->
@@ -394,7 +575,7 @@
+ 'Database initialized &mdash; ' + data.table_count + ' table(s)</div>'; + 'Database initialized &mdash; ' + data.table_count + ' table(s)</div>';
html += '<div class="table-list">'; html += '<div class="table-list">';
data.tables.forEach(function(t) { data.tables.forEach(function(t) {
html += '<span class="table-chip">' + t + '</span>'; html += '<span class="table-chip">' + escHtml(t) + '</span>';
}); });
html += '</div>'; html += '</div>';
} else { } else {
@@ -404,7 +585,7 @@
container.innerHTML = html; container.innerHTML = html;
} catch (e) { } catch (e) {
container.innerHTML = '<div class="status-bar err"><span class="dot red"></span>' container.innerHTML = '<div class="status-bar err"><span class="dot red"></span>'
+ 'Could not fetch status: ' + e.message + '</div>'; + 'Could not fetch status: ' + escHtml(e.message) + '</div>';
} }
} }
@@ -427,6 +608,7 @@
document.getElementById('login-screen').classList.add('hidden'); document.getElementById('login-screen').classList.add('hidden');
document.getElementById('setup-panel').classList.remove('hidden'); document.getElementById('setup-panel').classList.remove('hidden');
await checkStatus(); await checkStatus();
await loadUsers();
showToast('Setup panel unlocked', 'success'); showToast('Setup panel unlocked', 'success');
} catch (e) { } catch (e) {
showToast('Connection error: ' + e.message, 'error'); showToast('Connection error: ' + e.message, 'error');
@@ -499,7 +681,11 @@
setLoading('btn-seed', true); setLoading('btn-seed', true);
try { try {
const data = await apiPost('/seed', { password: setupPassword }); const data = await apiPost('/seed', { password: setupPassword });
showToast(data.message + ' (users: ' + data.users_created.join(', ') + ')', 'success'); var msg = data.message + ' (users: ' + data.users_created.join(', ');
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
msg += ')';
showToast(msg, 'success');
await loadUsers();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
} finally { } finally {
@@ -541,6 +727,203 @@
setLoading('btn-reset', false); setLoading('btn-reset', false);
} }
} }
/* ================================================================== */
/* USER MANAGEMENT */
/* ================================================================== */
function escHtml(str) {
if (!str) return '';
var d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function roleBadge(role) {
var cls = 'badge ';
if (role === 'Maker') cls += 'badge-maker';
else if (role === 'MeasurementTec') cls += 'badge-measurement';
else if (role === 'Metrologist') cls += 'badge-metrologist';
return '<span class="' + cls + '">' + escHtml(role) + '</span> ';
}
async function loadUsers() {
var container = document.getElementById('users-table-container');
try {
var data = await apiPost('/list-users', { password: setupPassword });
if (!data.users || data.users.length === 0) {
container.innerHTML = '<p class="text-sm text-muted">No users found. Use Seed Demo Data or create one.</p>';
return;
}
var html = '<table class="user-table"><thead><tr>'
+ '<th>ID</th><th>Username</th><th>Display Name</th>'
+ '<th>Roles</th><th>Status</th><th>Actions</th>'
+ '</tr></thead><tbody>';
data.users.forEach(function(u) {
var roles = '';
(u.roles || []).forEach(function(r) { roles += roleBadge(r); });
if (u.is_admin) roles += '<span class="badge badge-admin">Admin</span> ';
var statusBadge = u.active
? '<span class="badge badge-active">Active</span>'
: '<span class="badge badge-inactive">Inactive</span>';
var toggleLabel = u.active ? 'Deactivate' : 'Activate';
var toggleClass = u.active ? 'btn-sm-danger' : 'btn-sm-success';
html += '<tr>'
+ '<td class="mono">' + u.id + '</td>'
+ '<td class="mono">' + escHtml(u.username) + '</td>'
+ '<td>' + escHtml(u.display_name) + '</td>'
+ '<td>' + roles + '</td>'
+ '<td>' + statusBadge + '</td>'
+ '<td><div class="action-btns">'
+ '<button class="btn-sm btn-sm-primary" onclick="openEditUserModal(' + u.id + ')">Edit</button>'
+ '<button class="btn-sm btn-sm-warning" onclick="openPasswordModal(' + u.id + ', \'' + escHtml(u.username).replace(/'/g, "\\'") + '\')">Password</button>'
+ '<button class="btn-sm ' + toggleClass + '" onclick="toggleActive(' + u.id + ', \'' + escHtml(u.username).replace(/'/g, "\\'") + '\', ' + u.active + ')">' + toggleLabel + '</button>'
+ '</div></td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="status-bar err"><span class="dot red"></span>' + escHtml(e.message) + '</div>';
}
}
/* -- Modal helpers ------------------------------------------------- */
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
}
function openCreateUserModal() {
document.getElementById('modal-user-title').textContent = 'New User';
document.getElementById('mu-user-id').value = '';
document.getElementById('mu-username').value = '';
document.getElementById('mu-password').value = '';
document.getElementById('mu-displayname').value = '';
document.getElementById('mu-email').value = '';
document.getElementById('mu-role-maker').checked = false;
document.getElementById('mu-role-tec').checked = false;
document.getElementById('mu-role-metro').checked = false;
document.getElementById('mu-admin').checked = false;
document.getElementById('mu-pwd-hint').textContent = '(required)';
document.getElementById('modal-user').classList.remove('hidden');
}
/* Store user data for edit lookup */
var _usersCache = [];
async function openEditUserModal(userId) {
/* Reload users to get fresh data */
try {
var data = await apiPost('/list-users', { password: setupPassword });
_usersCache = data.users || [];
} catch (e) {
showToast('Failed to load user: ' + e.message, 'error');
return;
}
var user = _usersCache.find(function(u) { return u.id === userId; });
if (!user) { showToast('User not found', 'error'); return; }
document.getElementById('modal-user-title').textContent = 'Edit User';
document.getElementById('mu-user-id').value = user.id;
document.getElementById('mu-username').value = user.username;
document.getElementById('mu-password').value = '';
document.getElementById('mu-displayname').value = user.display_name;
document.getElementById('mu-email').value = user.email || '';
document.getElementById('mu-role-maker').checked = (user.roles || []).indexOf('Maker') !== -1;
document.getElementById('mu-role-tec').checked = (user.roles || []).indexOf('MeasurementTec') !== -1;
document.getElementById('mu-role-metro').checked = (user.roles || []).indexOf('Metrologist') !== -1;
document.getElementById('mu-admin').checked = user.is_admin;
document.getElementById('mu-pwd-hint').textContent = '(leave blank to keep current)';
document.getElementById('modal-user').classList.remove('hidden');
}
async function saveUser() {
var userId = document.getElementById('mu-user-id').value;
var username = document.getElementById('mu-username').value.trim();
var pwd = document.getElementById('mu-password').value;
var displayName = document.getElementById('mu-displayname').value.trim();
var email = document.getElementById('mu-email').value.trim();
if (!username || !displayName) {
showToast('Username and Display Name are required', 'error');
return;
}
if (!userId && !pwd) {
showToast('Password is required for new users', 'error');
return;
}
var roles = [];
if (document.getElementById('mu-role-maker').checked) roles.push('Maker');
if (document.getElementById('mu-role-tec').checked) roles.push('MeasurementTec');
if (document.getElementById('mu-role-metro').checked) roles.push('Metrologist');
setLoading('btn-save-user', true);
try {
var body = {
password: setupPassword,
user_id: userId ? parseInt(userId) : null,
username: username,
display_name: displayName,
email: email || null,
roles: roles,
is_admin: document.getElementById('mu-admin').checked,
};
if (pwd) body.user_password = pwd;
var data = await apiPost('/manage-user', body);
showToast(data.message, 'success');
closeModal('modal-user');
await loadUsers();
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading('btn-save-user', false);
}
}
function openPasswordModal(userId, username) {
document.getElementById('mp-user-id').value = userId;
document.getElementById('mp-username').textContent = username;
document.getElementById('mp-new-password').value = '';
document.getElementById('mp-confirm-password').value = '';
document.getElementById('modal-password').classList.remove('hidden');
}
async function changePassword() {
var userId = document.getElementById('mp-user-id').value;
var newPwd = document.getElementById('mp-new-password').value;
var confirmPwd = document.getElementById('mp-confirm-password').value;
if (!newPwd) { showToast('Password cannot be empty', 'error'); return; }
if (newPwd !== confirmPwd) { showToast('Passwords do not match', 'error'); return; }
try {
var data = await apiPost('/change-user-password', {
password: setupPassword,
user_id: parseInt(userId),
new_password: newPwd,
});
showToast(data.message, 'success');
closeModal('modal-password');
} catch (e) {
showToast(e.message, 'error');
}
}
async function toggleActive(userId, username, currentlyActive) {
var action = currentlyActive ? 'deactivate' : 'activate';
if (!confirm('Are you sure you want to ' + action + ' user "' + username + '"?')) return;
try {
var data = await apiPost('/toggle-user-active', {
password: setupPassword,
user_id: userId,
});
showToast(data.message, 'success');
await loadUsers();
} catch (e) {
showToast(e.message, 'error');
}
}
</script> </script>
</body> </body>
</html> </html>
+294
View File
@@ -0,0 +1,294 @@
"""Tests for setup router - demo measurements and user management."""
import pytest
from httpx import AsyncClient
from config import settings
from tests.conftest import test_engine, TestSessionFactory
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
SETUP_PWD = "test-setup-password"
@pytest.fixture(autouse=True)
def enable_setup(monkeypatch):
"""Enable setup endpoints and redirect engine/session to test DB."""
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
# setup.py imports engine and async_session_factory directly from database
# and uses them instead of get_db dependency injection. Patch them to use
# the test in-memory SQLite engine/session instead of real MySQL.
import routers.setup as setup_mod
monkeypatch.setattr(setup_mod, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
async def _seed(client: AsyncClient) -> dict:
"""Run seed and return response data."""
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
assert resp.status_code == 200
return resp.json()
# ---------------------------------------------------------------------------
# Feature 1: Demo Measurements in Seed
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_seed_creates_measurements(client: AsyncClient):
"""Seed should create 180 measurements (30 per subtask x 6 subtasks)."""
data = await _seed(client)
assert data["measurements_created"] == 180
@pytest.mark.asyncio
async def test_seed_measurements_have_correct_distribution(client: AsyncClient, db_session):
"""Measurements should have a mix of pass, warning, and fail."""
await _seed(client)
from sqlalchemy import select, func
from models.measurement import Measurement
result = await db_session.execute(
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail)
)
counts = dict(result.all())
# Should have all three statuses
assert "pass" in counts
assert "warning" in counts
assert "fail" in counts
# Pass should be the majority
assert counts["pass"] > counts["warning"]
assert counts["pass"] > counts["fail"]
@pytest.mark.asyncio
async def test_seed_measurements_have_lot_and_serial(client: AsyncClient, db_session):
"""All measurements should have lot and serial numbers."""
await _seed(client)
from sqlalchemy import select, func
from models.measurement import Measurement
result = await db_session.execute(
select(func.count()).where(Measurement.lot_number.is_(None))
)
null_lots = result.scalar()
assert null_lots == 0
@pytest.mark.asyncio
async def test_seed_measurements_input_methods(client: AsyncClient, db_session):
"""Measurements should have mixed input methods."""
await _seed(client)
from sqlalchemy import select, func
from models.measurement import Measurement
result = await db_session.execute(
select(Measurement.input_method, func.count()).group_by(Measurement.input_method)
)
methods = dict(result.all())
assert "usb_caliper" in methods
assert "manual" in methods
@pytest.mark.asyncio
async def test_seed_is_reproducible(client: AsyncClient):
"""Seed uses random.seed(42), so results should be deterministic."""
data = await _seed(client)
assert data["measurements_created"] == 180
assert data["recipe_created"] == "DEMO-001"
# ---------------------------------------------------------------------------
# Feature 2: User Management - List
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_users_after_seed(client: AsyncClient):
"""After seed, list-users should return 4 users."""
await _seed(client)
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
assert resp.status_code == 200
data = resp.json()
assert len(data["users"]) == 4
@pytest.mark.asyncio
async def test_list_users_wrong_password(client: AsyncClient):
"""list-users with wrong password should return 403."""
resp = await client.post("/api/setup/list-users", json={"password": "wrong"})
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Feature 2: User Management - Create / Update
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_user(client: AsyncClient):
"""Create a new user via manage-user endpoint."""
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200
resp = await client.post("/api/setup/manage-user", json={
"password": SETUP_PWD,
"user_id": None,
"username": "newuser",
"user_password": "pass123",
"display_name": "New User",
"email": "new@test.com",
"roles": ["Maker"],
"is_admin": False,
})
assert resp.status_code == 200
data = resp.json()
assert data["user_id"] is not None
assert "created" in data["message"]
@pytest.mark.asyncio
async def test_create_user_duplicate_username(client: AsyncClient):
"""Creating a user with a duplicate username should return 409."""
await _seed(client)
resp = await client.post("/api/setup/manage-user", json={
"password": SETUP_PWD,
"user_id": None,
"username": "admin",
"user_password": "pass123",
"display_name": "Duplicate",
"roles": [],
})
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_create_user_without_password(client: AsyncClient):
"""Creating a user without a password should return 400."""
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
resp = await client.post("/api/setup/manage-user", json={
"password": SETUP_PWD,
"user_id": None,
"username": "nopassuser",
"display_name": "No Pass",
"roles": [],
})
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_update_user(client: AsyncClient):
"""Update an existing user via manage-user endpoint."""
await _seed(client)
# Get users to find the ID
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
users = resp.json()["users"]
maker = next(u for u in users if u["username"] == "maker1")
resp = await client.post("/api/setup/manage-user", json={
"password": SETUP_PWD,
"user_id": maker["id"],
"username": "maker1",
"display_name": "Updated Name",
"email": "updated@test.com",
"roles": ["Maker", "MeasurementTec"],
"is_admin": False,
})
assert resp.status_code == 200
assert "updated" in resp.json()["message"]
@pytest.mark.asyncio
async def test_create_user_invalid_role(client: AsyncClient):
"""Creating a user with an invalid role should return 400."""
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
resp = await client.post("/api/setup/manage-user", json={
"password": SETUP_PWD,
"user_id": None,
"username": "badrole",
"user_password": "pass123",
"display_name": "Bad Role",
"roles": ["InvalidRole"],
})
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# Feature 2: User Management - Password Change
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_change_password(client: AsyncClient):
"""Changing password should succeed and invalidate api_key."""
await _seed(client)
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
users = resp.json()["users"]
tec = next(u for u in users if u["username"] == "tec1")
resp = await client.post("/api/setup/change-user-password", json={
"password": SETUP_PWD,
"user_id": tec["id"],
"new_password": "newpass123",
})
assert resp.status_code == 200
assert "changed" in resp.json()["message"].lower()
@pytest.mark.asyncio
async def test_change_password_user_not_found(client: AsyncClient):
"""Changing password for non-existent user should return 404."""
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
resp = await client.post("/api/setup/change-user-password", json={
"password": SETUP_PWD,
"user_id": 99999,
"new_password": "newpass123",
})
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Feature 2: User Management - Toggle Active
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_toggle_user_active(client: AsyncClient):
"""Toggle active should flip the user's active status."""
await _seed(client)
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
users = resp.json()["users"]
maker = next(u for u in users if u["username"] == "maker1")
assert maker["active"] is True
# Deactivate
resp = await client.post("/api/setup/toggle-user-active", json={
"password": SETUP_PWD,
"user_id": maker["id"],
})
assert resp.status_code == 200
assert resp.json()["active"] is False
# Re-activate
resp = await client.post("/api/setup/toggle-user-active", json={
"password": SETUP_PWD,
"user_id": maker["id"],
})
assert resp.status_code == 200
assert resp.json()["active"] is True