From e07a4e4f897dc20c4f63b6dd554b0e46ef738493 Mon Sep 17 00:00:00 2001 From: Adriano Date: Tue, 24 Feb 2026 14:49:58 +0100 Subject: [PATCH] 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 --- server/routers/setup.py | 273 ++++++++++++++++++++- server/templates/setup/setup.html | 393 +++++++++++++++++++++++++++++- server/tests/test_setup.py | 294 ++++++++++++++++++++++ 3 files changed, 953 insertions(+), 7 deletions(-) create mode 100644 server/tests/test_setup.py diff --git a/server/routers/setup.py b/server/routers/setup.py index ff79539..bb2a257 100644 --- a/server/routers/setup.py +++ b/server/routers/setup.py @@ -3,21 +3,24 @@ Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404. NOT protected by API Key auth (standalone access). """ +import random import secrets +from datetime import datetime, timedelta from pathlib import Path from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel -from sqlalchemy import inspect as sa_inspect +from sqlalchemy import inspect as sa_inspect, select from config import settings from database import Base, engine, async_session_factory from models import ( - User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, + User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, ) from services.auth_service import hash_password +from services.measurement_service import calculate_pass_fail router = APIRouter(prefix="/api/setup", tags=["setup"]) @@ -46,6 +49,28 @@ class ResetDbBody(BaseModel): 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 # --------------------------------------------------------------------------- @@ -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 # --------------------------------------------------------------------------- @@ -312,14 +407,188 @@ async def seed_demo_data(body: PasswordBody): ] 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 { "status": "ok", "message": "Demo data seeded successfully", "users_created": [u["username"] for u in users_data], "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") async def reset_db(body: ResetDbBody): """Drop all tables and recreate them. Requires confirm=true.""" diff --git a/server/templates/setup/setup.html b/server/templates/setup/setup.html index 38342d8..9bee60a 100644 --- a/server/templates/setup/setup.html +++ b/server/templates/setup/setup.html @@ -224,6 +224,123 @@ .mt-4 { margin-top: 16px; } .text-sm { font-size: 0.82rem; } .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; + } @@ -300,14 +417,26 @@

Seed Demo Data

- 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: admin, maker1, tec1, metro1.

- + +
+

User Management

+

+ Create, edit, and manage user accounts. Changes take effect immediately. +

+ +
+

Unlock the setup page to load users.

+
+
+ +

Reset Database

@@ -321,6 +450,58 @@

+ + + + + + + + + + @@ -394,7 +575,7 @@ + 'Database initialized — ' + data.table_count + ' table(s)'; html += '
'; data.tables.forEach(function(t) { - html += '' + t + ''; + html += '' + escHtml(t) + ''; }); html += '
'; } else { @@ -404,7 +585,7 @@ container.innerHTML = html; } catch (e) { container.innerHTML = '
' - + 'Could not fetch status: ' + e.message + '
'; + + 'Could not fetch status: ' + escHtml(e.message) + ''; } } @@ -427,6 +608,7 @@ document.getElementById('login-screen').classList.add('hidden'); document.getElementById('setup-panel').classList.remove('hidden'); await checkStatus(); + await loadUsers(); showToast('Setup panel unlocked', 'success'); } catch (e) { showToast('Connection error: ' + e.message, 'error'); @@ -499,7 +681,11 @@ setLoading('btn-seed', true); try { 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) { showToast(e.message, 'error'); } finally { @@ -541,6 +727,203 @@ 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 '' + escHtml(role) + ' '; + } + + 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 = '

No users found. Use Seed Demo Data or create one.

'; + return; + } + var html = '' + + '' + + '' + + ''; + data.users.forEach(function(u) { + var roles = ''; + (u.roles || []).forEach(function(r) { roles += roleBadge(r); }); + if (u.is_admin) roles += 'Admin '; + var statusBadge = u.active + ? 'Active' + : 'Inactive'; + var toggleLabel = u.active ? 'Deactivate' : 'Activate'; + var toggleClass = u.active ? 'btn-sm-danger' : 'btn-sm-success'; + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
IDUsernameDisplay NameRolesStatusActions
' + u.id + '' + escHtml(u.username) + '' + escHtml(u.display_name) + '' + roles + '' + statusBadge + '
' + + '' + + '' + + '' + + '
'; + container.innerHTML = html; + } catch (e) { + container.innerHTML = '
' + escHtml(e.message) + '
'; + } + } + + /* -- 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'); + } + } diff --git a/server/tests/test_setup.py b/server/tests/test_setup.py new file mode 100644 index 0000000..41452b9 --- /dev/null +++ b/server/tests/test_setup.py @@ -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