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:
+271
-2
@@ -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."""
|
||||||
|
|||||||
@@ -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 — ' + data.table_count + ' table(s)</div>';
|
+ 'Database initialized — ' + 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>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user