Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 787575807d | |||
| 0d004c43ff | |||
| 35f46926a9 | |||
| 1d93eded27 | |||
| 70e2072b79 |
@@ -1,10 +1,17 @@
|
||||
"""API Key authentication dependency for FastAPI."""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.auth_service import hash_api_key, utcnow
|
||||
|
||||
_RENEW_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -24,15 +31,25 @@ async def get_current_user(
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.api_key == api_key, User.active == True)
|
||||
select(ApiKey, User)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
row = result.first()
|
||||
now = utcnow()
|
||||
|
||||
if user is None:
|
||||
if row is None or row[0].expires_at <= now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or inactive API key",
|
||||
detail="Invalid or expired API key",
|
||||
)
|
||||
key_row, user = row
|
||||
|
||||
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
|
||||
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
|
||||
key_row.last_used_at = now
|
||||
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
|
||||
await db.flush()
|
||||
|
||||
# Store user_id in request state for access logging middleware
|
||||
request.state.user_id = user.id
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Authentication router - login, logout, profile."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
@@ -34,15 +34,16 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
request: Request,
|
||||
all_sessions: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Logout. The API key is shared across stations logged in as the same
|
||||
user, so by default this does not invalidate it - only this station's
|
||||
session (its Flask session, on the client side) ends. Pass
|
||||
?all_sessions=true to invalidate the key everywhere."""
|
||||
await logout_user(db, user, all_sessions=all_sessions)
|
||||
"""Logout. Deletes this session's API key only; ?all_sessions=true
|
||||
deletes every key of the user."""
|
||||
await logout_user(
|
||||
db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
|
||||
@@ -19,6 +19,7 @@ from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.measurement import (
|
||||
CurrentCycleResponse,
|
||||
MeasurementAuthorisation,
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
@@ -97,6 +98,24 @@ async def get_task_progress(
|
||||
return TaskProgressListResponse(tasks=tasks)
|
||||
|
||||
|
||||
@router.get("/current-cycle", response_model=CurrentCycleResponse)
|
||||
async def get_current_cycle(
|
||||
production_run_id: int = Query(..., gt=0),
|
||||
task_id: int = Query(..., gt=0),
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Quotes of this task already measured in the cycle under way.
|
||||
|
||||
Asked on load, so that a reload mid-cycle does not present quotes again that
|
||||
were taken a moment ago.
|
||||
"""
|
||||
rows = await measurement_service.current_cycle_measurements(
|
||||
db, production_run_id, task_id,
|
||||
)
|
||||
return CurrentCycleResponse(measurements=rows)
|
||||
|
||||
|
||||
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
|
||||
async def authorise_measurement(
|
||||
measurement_id: int,
|
||||
@@ -174,6 +193,7 @@ def _build_measurement_filters(
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
pass_fail: str | None,
|
||||
production_run_id: int | None = None,
|
||||
) -> list:
|
||||
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||
filters = []
|
||||
@@ -199,6 +219,8 @@ def _build_measurement_filters(
|
||||
filters.append(Measurement.measured_at <= date_to)
|
||||
if pass_fail is not None:
|
||||
filters.append(Measurement.pass_fail == pass_fail)
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
return filters
|
||||
|
||||
|
||||
@@ -213,6 +235,7 @@ async def get_measurements(
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
production_run_id: int | None = Query(None, gt=0),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -228,7 +251,7 @@ async def get_measurements(
|
||||
"""
|
||||
filters = _build_measurement_filters(
|
||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||
serial_number, date_from, date_to, pass_fail,
|
||||
serial_number, date_from, date_to, pass_fail, production_run_id,
|
||||
)
|
||||
|
||||
# Build query
|
||||
|
||||
@@ -5,7 +5,6 @@ NOT protected by API Key auth (standalone access).
|
||||
"""
|
||||
import hmac
|
||||
import random
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
@@ -25,7 +24,7 @@ from src.backend.models.orm import (
|
||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||
)
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.services.auth_service import hash_password
|
||||
from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
|
||||
from src.backend.services.measurement_service import calculate_pass_fail
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
@@ -321,8 +320,6 @@ async def create_admin(body: CreateAdminBody):
|
||||
"""Create an admin user with all roles."""
|
||||
_check_password(body.password)
|
||||
|
||||
api_key = secrets.token_hex(32)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
user = User(
|
||||
@@ -332,9 +329,10 @@ async def create_admin(body: CreateAdminBody):
|
||||
display_name=body.admin_display_name,
|
||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||
is_admin=True,
|
||||
api_key=api_key,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
api_key = await issue_api_key(session, user)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -404,7 +402,6 @@ async def seed_demo_data(body: PasswordBody):
|
||||
email=u["email"],
|
||||
roles=u["roles"],
|
||||
is_admin=u["is_admin"],
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
created_users.append(user)
|
||||
@@ -652,7 +649,6 @@ async def manage_user(body: ManageUserBody):
|
||||
email=body.email,
|
||||
roles=body.roles,
|
||||
is_admin=body.is_admin,
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
@@ -707,7 +703,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
|
||||
detail=f"User ID {body.user_id} not found",
|
||||
)
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.api_key = None # Invalidate sessions
|
||||
await revoke_all_keys(session, user.id) # Invalidate sessions
|
||||
|
||||
return {"status": "ok", "message": f"Password changed for '{user.username}'"}
|
||||
|
||||
@@ -730,7 +726,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
|
||||
)
|
||||
user.active = not user.active
|
||||
if not user.active:
|
||||
user.api_key = None # Invalidate sessions on deactivation
|
||||
await revoke_all_keys(session, user.id) # 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}
|
||||
|
||||
@@ -19,7 +19,8 @@ from src.backend.models.api.task import (
|
||||
TaskUpdate,
|
||||
validate_tolerance_order,
|
||||
)
|
||||
from src.backend.services import recipe_service
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
from src.backend.services import recipe_service, vision_service
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
@@ -366,8 +367,24 @@ async def delete_task(
|
||||
|
||||
task = await _task_for_write(db, task, user)
|
||||
|
||||
# The rows go with the task (migration 014 cascades them), the files do not:
|
||||
# collect them now, remove the files only once the commit is durable - same
|
||||
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
|
||||
# file_still_referenced sees the post-delete state even where the database
|
||||
# does not enforce the cascade.
|
||||
references = (await db.execute(
|
||||
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
|
||||
)).scalars().all()
|
||||
for reference in references:
|
||||
await db.delete(reference)
|
||||
|
||||
await db.delete(task)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
# A copy-on-write sibling may still point at the same file (#38.6).
|
||||
for reference in references:
|
||||
if not await vision_service.file_still_referenced(db, reference.path):
|
||||
vision_service.remove_reference_file(reference)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@ from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_admin_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
|
||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@@ -146,7 +146,7 @@ async def change_user_password(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user.password_hash = hash_password(data.password)
|
||||
user.api_key = None
|
||||
await revoke_all_keys(db, user.id)
|
||||
await db.flush()
|
||||
return {"message": f"Password changed for user {user.username}"}
|
||||
|
||||
@@ -168,7 +168,7 @@ async def deactivate_user(
|
||||
detail="Cannot deactivate yourself",
|
||||
)
|
||||
user.active = False
|
||||
user.api_key = None
|
||||
await revoke_all_keys(db, user.id)
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -183,5 +183,5 @@ async def regenerate_user_key(
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
new_key = await regenerate_api_key(db, user_id)
|
||||
new_key = await regenerate_api_key(db, user)
|
||||
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
||||
|
||||
@@ -4,7 +4,10 @@ It does not know, and must not know, whether the server or the station computed
|
||||
the numbers: that is what lets a station's configuration change without touching
|
||||
the frontend or the recipes.
|
||||
"""
|
||||
import io
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -90,9 +93,27 @@ async def _read_image(image: UploadFile) -> bytes:
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="the file's content does not match a supported image format",
|
||||
)
|
||||
_reject_animated(content)
|
||||
return content
|
||||
|
||||
|
||||
def _reject_animated(content: bytes) -> None:
|
||||
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
|
||||
06/10/2026, #43): refuse them instead of silently reading frame 0."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
|
||||
except Exception:
|
||||
# Unreadable here is not this check's business: the magic bytes passed
|
||||
# and the worker decides what it can decode.
|
||||
return
|
||||
if animated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="animated images are not supported",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/execute", response_model=VisionExecuteResponse)
|
||||
async def execute(
|
||||
task_id: int = Form(...),
|
||||
|
||||
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
|
||||
# configured limits to keep the cluster-wide effective limit close to what
|
||||
# was configured instead of multiplying it by N.
|
||||
web_concurrency: int = 1
|
||||
|
||||
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
|
||||
api_key_ttl_hours: int = 24
|
||||
# How many reverse proxies between the client and this API are trusted to
|
||||
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
||||
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""API keys as SHA-256 hashes, one per session (#41)
|
||||
|
||||
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
|
||||
row per login, holding only the SHA-256 hex digest, an expiry and the last
|
||||
use. Existing keys are invalidated on purpose, not migrated - the plaintext
|
||||
is exactly what must stop existing, so everybody logs in once again.
|
||||
|
||||
Revision ID: 015_api_key_hash
|
||||
Revises: 014_vision_fk_ondelete
|
||||
Create Date: 2026-10-06
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '015_api_key_hash'
|
||||
down_revision: Union[str, None] = '014_vision_fk_ondelete'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
batch.drop_column('api_key')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
|
||||
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
|
||||
batch.create_index('ix_users_api_key', ['api_key'])
|
||||
|
||||
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
|
||||
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
|
||||
op.drop_table('api_keys')
|
||||
@@ -91,6 +91,21 @@ class TaskProgressListResponse(BaseModel):
|
||||
tasks: list[TaskProgressResponse]
|
||||
|
||||
|
||||
class CycleMeasurementResponse(BaseModel):
|
||||
"""One quote already measured in the cycle under way."""
|
||||
|
||||
subtask_id: int
|
||||
value: float
|
||||
pass_fail: str
|
||||
measured_at: datetime
|
||||
|
||||
|
||||
class CurrentCycleResponse(BaseModel):
|
||||
"""The quotes to restore on the screen after a reload; empty if the task is closed."""
|
||||
|
||||
measurements: list[CycleMeasurementResponse]
|
||||
|
||||
|
||||
class MeasurementQuery(BaseModel):
|
||||
"""Schema for measurement query filters."""
|
||||
recipe_id: Optional[int] = None
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""SQLAlchemy models for TieMeasureFlow."""
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
@@ -11,6 +12,7 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Recipe",
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""API key sessions: one row per login, stored as a SHA-256 hash."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""One API key per login session.
|
||||
|
||||
Only the SHA-256 hex digest is stored, never the key. Dates are naive UTC.
|
||||
"""
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
@@ -32,7 +32,6 @@ class User(Base):
|
||||
|
||||
# Status
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Authentication service - password hashing, API key management."""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
|
||||
@@ -58,8 +61,8 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
||||
of them because it is the same question every time - who is this, and are they
|
||||
allowed to say yes.
|
||||
|
||||
Uses authenticate_user rather than a full login: logging in would rotate the
|
||||
supervisor's API key and knock out whatever session they have open elsewhere.
|
||||
Uses authenticate_user rather than a full login: logging in would issue a
|
||||
needless API key for the supervisor.
|
||||
"""
|
||||
supervisor = await authenticate_user(db, username, password)
|
||||
if supervisor is None:
|
||||
@@ -75,39 +78,72 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
||||
return supervisor
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate (or reuse) the API key and update last_login for user.
|
||||
def utcnow() -> datetime:
|
||||
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
Reuses an existing key rather than rotating it on every login: the same
|
||||
account logged in from two stations at once (a common shift-account
|
||||
pattern here) must keep both sessions working. Rotating the key would
|
||||
silently log the other station out.
|
||||
"""
|
||||
api_key = user.api_key or generate_api_key()
|
||||
|
||||
def hash_api_key(key: str) -> str:
|
||||
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
|
||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def issue_api_key(db: AsyncSession, user: User) -> str:
|
||||
"""Create a new session key for the user and return it in clear (once)."""
|
||||
key = generate_api_key()
|
||||
now = utcnow()
|
||||
# Housekeeping: drop this user's expired keys so the table does not grow forever.
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
||||
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
|
||||
)
|
||||
db.add(
|
||||
ApiKey(
|
||||
user_id=user.id,
|
||||
key_hash=hash_api_key(key),
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
|
||||
last_used_at=now,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return key
|
||||
|
||||
|
||||
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
|
||||
"""Invalidate every session key of a user."""
|
||||
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Issue a new API key for this login and update last_login.
|
||||
|
||||
One key per login: the same account used from several stations at once
|
||||
(#25) keeps every station working, because no login revokes another's key.
|
||||
"""
|
||||
api_key = await issue_api_key(db, user)
|
||||
await db.execute(
|
||||
update(User).where(User.id == user.id).values(last_login=utcnow())
|
||||
)
|
||||
await db.flush()
|
||||
return api_key
|
||||
|
||||
|
||||
async def logout_user(
|
||||
db: AsyncSession, user: User, all_sessions: bool = False
|
||||
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
|
||||
) -> None:
|
||||
"""Log out. By default a no-op on the shared API key.
|
||||
|
||||
Clearing the key on every logout would kick out any other station still
|
||||
using the same shift account. `all_sessions=True` is the explicit
|
||||
override for a supervisor who wants every session logged out.
|
||||
"""
|
||||
if not all_sessions:
|
||||
"""Log out. Deletes only the key of this session; all_sessions=True
|
||||
deletes every key of the user."""
|
||||
if all_sessions:
|
||||
await revoke_all_keys(db, user.id)
|
||||
return
|
||||
await db.execute(
|
||||
update(User).where(User.id == user.id).values(api_key=None)
|
||||
)
|
||||
await db.flush()
|
||||
if key:
|
||||
await db.execute(
|
||||
delete(ApiKey).where(
|
||||
ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def create_user(
|
||||
@@ -138,11 +174,7 @@ async def create_user(
|
||||
return user
|
||||
|
||||
|
||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
||||
"""Regenerate API key for a user."""
|
||||
new_key = generate_api_key()
|
||||
await db.execute(
|
||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
||||
)
|
||||
await db.flush()
|
||||
return new_key
|
||||
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
|
||||
"""Revoke every key of the user and issue a fresh one."""
|
||||
await revoke_all_keys(db, user.id)
|
||||
return await issue_api_key(db, user)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import (
|
||||
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
|
||||
@@ -241,6 +242,73 @@ async def task_progress(
|
||||
return progress
|
||||
|
||||
|
||||
async def current_cycle_measurements(
|
||||
db: AsyncSession, run_id: int, task_id: int,
|
||||
) -> list[dict]:
|
||||
"""The quotes of a task already measured in the cycle that is under way.
|
||||
|
||||
A reload, a timer redirect or an auto-logout throws away what the page held in
|
||||
memory; the operator would measure the same quotes again and the duplicates
|
||||
would reach the SPC statistics. The cycle needs no column of its own: it starts
|
||||
at the latest event of the run that opens one for this task - the run start, a
|
||||
cycle_completed, or a remeasure of this task.
|
||||
|
||||
If a task_measured of this task follows that boundary the task is already
|
||||
closed in this cycle and there is nothing to restore. Where a quote was measured
|
||||
more than once, the last reading stands.
|
||||
"""
|
||||
boundary = (await db.execute(
|
||||
select(ProductionEvent.id, ProductionEvent.created_at)
|
||||
.where(
|
||||
ProductionEvent.run_id == run_id,
|
||||
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
|
||||
| (
|
||||
(ProductionEvent.event_type == "remeasure")
|
||||
& (ProductionEvent.task_id == task_id)
|
||||
),
|
||||
)
|
||||
.order_by(ProductionEvent.id.desc())
|
||||
.limit(1)
|
||||
)).first()
|
||||
|
||||
event_filter = [ProductionEvent.run_id == run_id]
|
||||
measurement_filter = [Measurement.production_run_id == run_id]
|
||||
if boundary is not None:
|
||||
boundary_id, boundary_at = boundary
|
||||
event_filter.append(ProductionEvent.id > boundary_id)
|
||||
measurement_filter.append(Measurement.measured_at > boundary_at)
|
||||
|
||||
closed = (await db.execute(
|
||||
select(ProductionEvent.id).where(
|
||||
*event_filter,
|
||||
ProductionEvent.event_type == "task_measured",
|
||||
ProductionEvent.task_id == task_id,
|
||||
).limit(1)
|
||||
)).first()
|
||||
if closed is not None:
|
||||
return []
|
||||
|
||||
rows = (await db.execute(
|
||||
select(Measurement)
|
||||
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
|
||||
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
|
||||
.order_by(Measurement.measured_at, Measurement.id)
|
||||
)).scalars().all()
|
||||
|
||||
latest: dict[int, Measurement] = {}
|
||||
for m in rows:
|
||||
latest[m.subtask_id] = m
|
||||
return [
|
||||
{
|
||||
"subtask_id": m.subtask_id,
|
||||
"value": float(m.value),
|
||||
"pass_fail": m.pass_fail,
|
||||
"measured_at": m.measured_at,
|
||||
}
|
||||
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
|
||||
]
|
||||
|
||||
|
||||
async def validate_run_for_version(
|
||||
db: AsyncSession, run_id: int, version_id: int,
|
||||
) -> "ProductionRun":
|
||||
|
||||
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.services.auth_service import hash_password, generate_api_key
|
||||
from src.backend.services.auth_service import hash_password, issue_api_key
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory SQLite engine for tests
|
||||
@@ -142,7 +142,6 @@ async def _create_user(
|
||||
active: bool = True,
|
||||
) -> User:
|
||||
"""Insert a user into the test DB and return it with an API key set."""
|
||||
api_key = generate_api_key()
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
@@ -150,13 +149,14 @@ async def _create_user(
|
||||
roles=roles or [],
|
||||
is_admin=is_admin,
|
||||
active=active,
|
||||
api_key=api_key,
|
||||
language_pref="en",
|
||||
theme_pref="light",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
# Clear key of this test session; the DB only keeps its hash.
|
||||
user.plain_key = await issue_api_key(session, user)
|
||||
return user
|
||||
|
||||
|
||||
@@ -278,4 +278,4 @@ async def create_test_recipe(
|
||||
|
||||
def auth_headers(user: User) -> dict[str, str]:
|
||||
"""Return headers dict with the user's API key for authenticated requests."""
|
||||
return {"X-API-Key": user.api_key}
|
||||
return {"X-API-Key": user.plain_key}
|
||||
|
||||
+151
-46
@@ -78,75 +78,114 @@ class TestLogin:
|
||||
assert len(api_key) >= 32
|
||||
|
||||
|
||||
class TestLoginKeyReuse:
|
||||
"""login_user reuses an existing api_key instead of rotating it (#25)."""
|
||||
class TestLoginKeyPerSession:
|
||||
"""Each login issues its own key; none revokes another (#25, #41)."""
|
||||
|
||||
async def test_two_logins_same_user_share_api_key(
|
||||
async def test_two_logins_same_user_get_distinct_valid_keys(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
"""Two stations logging in as the same shift account both keep working."""
|
||||
await _create_user(db_session, username="twostations", password="TwoStations1")
|
||||
resp1 = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "twostations", "password": "TwoStations1"},
|
||||
)
|
||||
resp2 = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "twostations", "password": "TwoStations1"},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
assert resp2.status_code == 200
|
||||
assert resp1.json()["api_key"] == resp2.json()["api_key"]
|
||||
creds = {"username": "twostations", "password": "TwoStations1"}
|
||||
resp1 = await client.post("/api/auth/login", json=creds)
|
||||
resp2 = await client.post("/api/auth/login", json=creds)
|
||||
k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
|
||||
assert k1 != k2
|
||||
for k in (k1, k2):
|
||||
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_login_generates_key_when_user_has_none(
|
||||
async def test_db_stores_hash_never_clear_key(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
"""A user created without an api_key (e.g. via create_user) still gets one."""
|
||||
from src.backend.services.auth_service import create_user
|
||||
from sqlalchemy import select
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import hash_api_key
|
||||
|
||||
await create_user(
|
||||
db_session, username="brandnew", password="BrandNew1",
|
||||
display_name="Brand New",
|
||||
)
|
||||
await _create_user(db_session, username="hashed", password="HashedPass1")
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "brandnew", "password": "BrandNew1"},
|
||||
"/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
|
||||
)
|
||||
key = resp.json()["api_key"]
|
||||
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||
assert hash_api_key(key) in hashes
|
||||
assert key not in hashes
|
||||
assert all(len(h) == 64 for h in hashes)
|
||||
|
||||
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import update
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import utcnow
|
||||
|
||||
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
|
||||
await db_session.execute(
|
||||
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||
)
|
||||
resp = await client.get("/api/auth/me", headers=auth_headers(user))
|
||||
assert resp.status_code == 401
|
||||
assert "expired" in resp.json()["detail"].lower()
|
||||
|
||||
async def test_use_renews_expiry_only_after_interval(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import select, update
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import utcnow
|
||||
|
||||
user = await _create_user(db_session, username="renew", password="RenewPass1")
|
||||
headers = auth_headers(user)
|
||||
old = utcnow() - timedelta(hours=10)
|
||||
|
||||
# Recent use: no write, expiry untouched.
|
||||
recent = utcnow() - timedelta(minutes=1)
|
||||
await db_session.execute(
|
||||
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
|
||||
)
|
||||
await client.get("/api/auth/me", headers=headers)
|
||||
row = (await db_session.execute(select(ApiKey))).scalar_one()
|
||||
await db_session.refresh(row)
|
||||
assert row.last_used_at == recent
|
||||
|
||||
# Stale last use: expiry is pushed forward.
|
||||
await db_session.execute(
|
||||
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
|
||||
)
|
||||
before = utcnow()
|
||||
resp = await client.get("/api/auth/me", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["api_key"]
|
||||
await db_session.refresh(row)
|
||||
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
|
||||
|
||||
|
||||
class TestLogout:
|
||||
"""POST /api/auth/logout is a no-op on the shared api_key by default (#25)."""
|
||||
"""Logout deletes only this session's key (#41)."""
|
||||
|
||||
async def test_logout_default_keeps_api_key_valid(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
user = await _create_user(
|
||||
db_session, username="shared_logout", password="SharedPass1"
|
||||
)
|
||||
headers = auth_headers(user)
|
||||
resp = await client.post("/api/auth/logout", headers=headers)
|
||||
async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
|
||||
await _create_user(db_session, username="shared_logout", password="SharedPass1")
|
||||
creds = {"username": "shared_logout", "password": "SharedPass1"}
|
||||
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
|
||||
assert resp.status_code == 204
|
||||
|
||||
# The key is still valid: another station on the same account keeps working.
|
||||
resp = await client.get("/api/auth/me", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
|
||||
|
||||
async def test_logout_all_sessions_invalidates_api_key(
|
||||
async def test_logout_all_sessions_removes_every_key(
|
||||
self, client: AsyncClient, db_session
|
||||
):
|
||||
user = await _create_user(
|
||||
db_session, username="shared_logout_all", password="SharedPass1"
|
||||
)
|
||||
headers = auth_headers(user)
|
||||
await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
|
||||
creds = {"username": "shared_logout_all", "password": "SharedPass1"}
|
||||
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
resp = await client.post(
|
||||
"/api/auth/logout", params={"all_sessions": "true"}, headers=headers
|
||||
"/api/auth/logout", params={"all_sessions": "true"},
|
||||
headers={"X-API-Key": k1},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
resp = await client.get("/api/auth/me", headers=headers)
|
||||
assert resp.status_code == 401
|
||||
for k in (k1, k2):
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
|
||||
|
||||
|
||||
class TestAuthenticateUserTiming:
|
||||
@@ -185,3 +224,69 @@ class TestHealthAndAuth:
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
||||
|
||||
|
||||
class TestRevokeAllKeys:
|
||||
"""Password change, deactivation and regenerate invalidate every key (#41)."""
|
||||
|
||||
async def _two_keys(self, client, db_session, name):
|
||||
user = await _create_user(db_session, username=name, password="KeysPass123")
|
||||
creds = {"username": name, "password": "KeysPass123"}
|
||||
keys = [
|
||||
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||
for _ in range(2)
|
||||
]
|
||||
return user, keys
|
||||
|
||||
async def _all_401(self, client, keys):
|
||||
for k in keys:
|
||||
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
|
||||
user, keys = await self._two_keys(client, db_session, "pwkeys")
|
||||
resp = await client.put(
|
||||
f"/api/users/{user.id}/password",
|
||||
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
await self._all_401(client, keys)
|
||||
|
||||
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
|
||||
user, keys = await self._two_keys(client, db_session, "offkeys")
|
||||
resp = await client.delete(
|
||||
f"/api/users/{user.id}", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
await self._all_401(client, keys)
|
||||
|
||||
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
|
||||
user, keys = await self._two_keys(client, db_session, "regenkeys")
|
||||
resp = await client.post(
|
||||
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
await self._all_401(client, keys)
|
||||
new = resp.json()["api_key"]
|
||||
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
|
||||
|
||||
|
||||
class TestExpiredKeyCleanup:
|
||||
async def test_expired_key_removed_at_next_login(self, client, db_session):
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import select, update
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
from src.backend.services.auth_service import hash_api_key, utcnow
|
||||
|
||||
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
|
||||
old_hash = hash_api_key(user.plain_key)
|
||||
await db_session.execute(
|
||||
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||
assert old_hash not in hashes
|
||||
assert len(hashes) == 1
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""#40: after a reload, the quotes already measured in the cycle come back.
|
||||
|
||||
The cycle has no column of its own: it opens at the latest start, cycle_completed
|
||||
or remeasure-of-this-task event of the run, and a task_measured of the task after
|
||||
that boundary means the task is closed in this cycle.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
T0 = datetime(2026, 10, 1, 8, 0, 0)
|
||||
|
||||
|
||||
async def _setup(db, user):
|
||||
recipe = await create_test_recipe(db, user.id)
|
||||
version = (await db.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
task = (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
first = (await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
second = RecipeSubtask(
|
||||
task_id=task.id, marker_number=2, description="Quota 2",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
)
|
||||
db.add(second)
|
||||
station = Station(code="ST-CYC", name="St", active=True, created_by=user.id)
|
||||
db.add(station)
|
||||
await db.commit()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status="running", active_station_id=station.id,
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
return run, version, task, first, second
|
||||
|
||||
|
||||
async def _event(db, run, user, kind, minutes, task_id=None):
|
||||
db.add(ProductionEvent(
|
||||
run_id=run.id, event_type=kind, user_id=user.id, task_id=task_id,
|
||||
created_at=T0 + timedelta(minutes=minutes),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _meas(db, run, version, subtask, user, minutes, value=10.0):
|
||||
db.add(Measurement(
|
||||
subtask_id=subtask.id, version_id=version.id, measured_by=user.id,
|
||||
value=value, pass_fail="pass", production_run_id=run.id,
|
||||
measured_at=T0 + timedelta(minutes=minutes),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _get(client, user, run, task):
|
||||
resp = await client.get(
|
||||
"/api/measurements/current-cycle", headers=auth_headers(user),
|
||||
params={"production_run_id": run.id, "task_id": task.id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["measurements"]
|
||||
|
||||
|
||||
async def test_only_second_cycle_after_cycle_completed(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1, 10.1)
|
||||
await _meas(db_session, run, version, q2, u, 2, 10.2)
|
||||
await _event(db_session, run, u, "cycle_completed", 3)
|
||||
await _meas(db_session, run, version, q1, u, 4, 10.3)
|
||||
|
||||
rows = await _get(client, u, run, task)
|
||||
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.3)]
|
||||
assert rows[0]["pass_fail"] == "pass"
|
||||
|
||||
|
||||
async def test_last_reading_of_a_quote_wins(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1, 10.1)
|
||||
await _meas(db_session, run, version, q1, u, 2, 10.4)
|
||||
|
||||
rows = await _get(client, u, run, task)
|
||||
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.4)]
|
||||
|
||||
|
||||
async def test_remeasure_of_the_task_empties_the_list(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _event(db_session, run, u, "remeasure", 2, task_id=task.id)
|
||||
|
||||
assert await _get(client, u, run, task) == []
|
||||
|
||||
|
||||
async def test_remeasure_of_another_task_does_not_move_the_boundary(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
other = RecipeTask(
|
||||
version_id=version.id, title="Altro", task_type=task.task_type, order_index=9,
|
||||
)
|
||||
db_session.add(other)
|
||||
await db_session.commit()
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _event(db_session, run, u, "remeasure", 2, task_id=other.id)
|
||||
|
||||
assert len(await _get(client, u, run, task)) == 1
|
||||
|
||||
|
||||
async def test_task_measured_after_boundary_means_closed(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _meas(db_session, run, version, q2, u, 2)
|
||||
await _event(db_session, run, u, "task_measured", 3, task_id=task.id)
|
||||
|
||||
assert await _get(client, u, run, task) == []
|
||||
|
||||
|
||||
async def test_task_measured_before_boundary_does_not_close_next_cycle(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _event(db_session, run, u, "task_measured", 1, task_id=task.id)
|
||||
await _event(db_session, run, u, "cycle_completed", 2)
|
||||
await _meas(db_session, run, version, q1, u, 3)
|
||||
|
||||
assert len(await _get(client, u, run, task)) == 1
|
||||
|
||||
|
||||
async def test_permissions(client: AsyncClient, db_session, measurement_tec_user, maker_user):
|
||||
run, _v, task, _q1, _q2 = await _setup(db_session, measurement_tec_user)
|
||||
params = {"production_run_id": run.id, "task_id": task.id}
|
||||
|
||||
no_key = await client.get("/api/measurements/current-cycle", params=params)
|
||||
assert no_key.status_code in (401, 403)
|
||||
|
||||
forbidden = await client.get(
|
||||
"/api/measurements/current-cycle", headers=auth_headers(maker_user), params=params,
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
@@ -107,3 +107,27 @@ class TestBaseMigrationNoopsOnExistingSchema:
|
||||
assert "image_path" in {
|
||||
c["name"] for c in insp.get_columns("recipes")
|
||||
}
|
||||
|
||||
|
||||
class TestApiKeyHashMigration:
|
||||
"""015_api_key_hash: users.api_key -> api_keys table, and back (#41)."""
|
||||
|
||||
def test_015_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
assert {c["name"] for c in insp.get_columns("api_keys")} >= {
|
||||
"user_id", "key_hash", "created_at", "expires_at", "last_used_at",
|
||||
}
|
||||
|
||||
command.downgrade(cfg, "014_vision_fk_ondelete")
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" not in insp.get_table_names()
|
||||
assert "api_key" in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
command.upgrade(cfg, "head") # and up again
|
||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""#43: reference files removed with their task, animated images refused,
|
||||
GET /api/measurements filtered by production run."""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_vision_upload_limits import a_png, recipe_parts
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_uploads(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
async def _upload_reference(client, user, task_id):
|
||||
resp = await client.post(
|
||||
"/api/vision/reference-images", headers=auth_headers(user),
|
||||
files={"image": ("p.png", a_png(), "image/png")},
|
||||
data={"task_id": str(task_id)},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_task_removes_the_reference_file(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||
await db_session.commit()
|
||||
await _upload_reference(client, maker_user, task.id)
|
||||
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||
path = settings.upload_path / ref.path
|
||||
assert path.exists()
|
||||
|
||||
resp = await client.delete(f"/api/tasks/{task.id}", headers=auth_headers(maker_user))
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_task_keeps_a_file_another_task_still_references(
|
||||
client, db_session, maker_user,
|
||||
):
|
||||
r1 = await create_test_recipe(db_session, maker_user.id)
|
||||
r2 = await create_test_recipe(db_session, maker_user.id, code="REC-002")
|
||||
_, t1, _ = await recipe_parts(db_session, r1.id)
|
||||
_, t2, _ = await recipe_parts(db_session, r2.id)
|
||||
await db_session.commit()
|
||||
await _upload_reference(client, maker_user, t1.id)
|
||||
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||
db_session.add(VisionReferenceImage(task_id=t2.id, path=ref.path))
|
||||
await db_session.commit()
|
||||
path = settings.upload_path / ref.path
|
||||
|
||||
resp = await client.delete(f"/api/tasks/{t1.id}", headers=auth_headers(maker_user))
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def _animated(fmt: str, frames: int) -> bytes:
|
||||
imgs = [Image.new("L", (16, 16), color=i * 40) for i in range(frames)]
|
||||
buf = io.BytesIO()
|
||||
imgs[0].save(buf, format=fmt, save_all=True, append_images=imgs[1:], duration=50)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("fmt,mime,frames,expected", [
|
||||
("GIF", "image/gif", 2, 422),
|
||||
("WEBP", "image/webp", 2, 422),
|
||||
("GIF", "image/gif", 1, 201),
|
||||
])
|
||||
async def test_animated_images_are_refused(
|
||||
client, db_session, maker_user, fmt, mime, frames, expected,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/vision/reference-images", headers=auth_headers(maker_user),
|
||||
files={"image": ("a", _animated(fmt, frames), mime)},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
if expected == 422:
|
||||
assert resp.status_code == 422
|
||||
assert "animated" in resp.json()["detail"]
|
||||
else:
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_measurements_filter_by_production_run(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
version, _, subtask = await recipe_parts(db_session, recipe.id)
|
||||
for run in (7, 7, 8, None):
|
||||
db_session.add(Measurement(
|
||||
subtask_id=subtask.id, version_id=version.id, measured_by=maker_user.id,
|
||||
value=1.0, pass_fail="pass", deviation=0.0, input_method="manual",
|
||||
production_run_id=run,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/measurements/", headers=auth_headers(maker_user),
|
||||
params={"production_run_id": 7},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert {m["production_run_id"] for m in body["items"]} == {7}
|
||||
@@ -17,7 +17,7 @@ class TestChangeUserPassword:
|
||||
|
||||
the same as setup.py's change-user-password (setup.py:634).
|
||||
"""
|
||||
old_key = maker_user.api_key
|
||||
old_key = maker_user.plain_key
|
||||
old_username = maker_user.username
|
||||
assert old_key is not None
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestChangeUserPassword:
|
||||
|
||||
# The old key no longer authenticates. Read it before the request
|
||||
# above: the client and app share one session (see conftest), so
|
||||
# `maker_user.api_key` in this process reflects the mutation too.
|
||||
# `maker_user.plain_key` in this process reflects the mutation too.
|
||||
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
|
||||
assert resp2.status_code == 401
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
|
||||
@@ -154,6 +155,26 @@ def create_app() -> Flask:
|
||||
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
|
||||
return Markup(bolded.replace("\n", "<br>"))
|
||||
|
||||
@app.template_filter("local_dt")
|
||||
def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
|
||||
"""Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
|
||||
|
||||
A value without timezone is taken as UTC. None or empty gives "-".
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return "-"
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return value
|
||||
if not isinstance(value, datetime):
|
||||
return str(value)
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
|
||||
return value.astimezone(tz).strftime(fmt)
|
||||
|
||||
@app.template_filter("tojson_attr")
|
||||
def tojson_attr_filter(value):
|
||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||
|
||||
@@ -386,14 +386,12 @@ def task_complete(recipe_id: int):
|
||||
# Load measurements if version_id provided
|
||||
measurements = []
|
||||
if version_id:
|
||||
# Scoped to this measurement session, not the whole version (#30): a
|
||||
# second production run on the same recipe version used to pull in
|
||||
# every other station's/day's readings too. GET /api/measurements has
|
||||
# no production_run_id filter yet (that's lotto A3), so lot/serial +
|
||||
# the current operator is the closest available identity - MANCA:
|
||||
# once the backend accepts production_run_id, prefer it when the
|
||||
# active run is known (see _current_run_id()).
|
||||
# Scoped to this measurement session, not the whole version (#30): the
|
||||
# active production run when known, else lot/serial + current operator.
|
||||
params = {"version_id": version_id, "per_page": 500}
|
||||
run_id = _current_run_id()
|
||||
if run_id:
|
||||
params["production_run_id"] = run_id
|
||||
if lot_number:
|
||||
params["lot_number"] = lot_number
|
||||
if serial_number:
|
||||
@@ -597,6 +595,40 @@ def api_task_progress(task_id: int):
|
||||
return jsonify(row), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: quotes already measured in the current cycle (#40)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/api/current-cycle/<int:task_id>", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_current_cycle(task_id: int):
|
||||
"""Proxy: quotes of this task already measured in the cycle under way.
|
||||
|
||||
The run comes from _current_run_id(), like the rest of the page. Outside a
|
||||
production there is no cycle to restore and the answer is an empty list.
|
||||
"""
|
||||
task_resp = api_client.get(f"/api/tasks/{task_id}")
|
||||
if isinstance(task_resp, dict) and task_resp.get("error"):
|
||||
return jsonify(task_resp), task_resp.get("status_code", 500)
|
||||
|
||||
recipe_id = task_resp.get("recipe_id")
|
||||
if recipe_id and not _recipe_allowed_at_station(recipe_id):
|
||||
abort(403, description=_("Ricetta non assegnata a questa stazione"))
|
||||
|
||||
run_id = _current_run_id()
|
||||
if not run_id:
|
||||
return jsonify({"measurements": []}), 200
|
||||
|
||||
resp = api_client.get(
|
||||
"/api/measurements/current-cycle",
|
||||
params={"production_run_id": run_id, "task_id": task_id},
|
||||
)
|
||||
if not isinstance(resp, dict) or resp.get("error"):
|
||||
# Never fatal: without the restore the screen behaves as it always did.
|
||||
return jsonify({"measurements": []}), 200
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes: Out-of-tolerance authorisation (point 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -56,6 +56,8 @@ class Config:
|
||||
# Babel i18n
|
||||
BABEL_DEFAULT_LOCALE = "it"
|
||||
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
|
||||
# Il DB e le API parlano UTC; si converte solo alla presentazione.
|
||||
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
|
||||
LANGUAGES = {"it": "Italiano", "en": "English"}
|
||||
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ function csvExport() {
|
||||
*/
|
||||
generateFilename(prefix) {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
|
||||
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
|
||||
|
||||
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
||||
<tr>
|
||||
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
||||
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
||||
{{ m.measured_at|local_dt }}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);" class="text-sm">
|
||||
{{ m.task_info.title if m.task_info else '-' }}
|
||||
|
||||
@@ -1100,6 +1100,7 @@ function taskExecute() {
|
||||
this.inputStartedAt = Date.now();
|
||||
// Rejoin whatever production is already open at this station.
|
||||
await this.loadProductionRun();
|
||||
await this.loadCurrentCycle();
|
||||
await this.loadPendingAuthorisation();
|
||||
// A tablet left to sleep drifts (#37.7): catch the countdown back up to
|
||||
// the server's own clock, the same source adoptProductionRun already
|
||||
@@ -1110,6 +1111,42 @@ function taskExecute() {
|
||||
});
|
||||
},
|
||||
|
||||
/* Quotes already measured in the cycle under way (#40).
|
||||
|
||||
A reload, a timer redirect or an auto-logout used to bring every quote back
|
||||
and measuring them again put duplicates in the SPC statistics. Only inside
|
||||
a production: outside one there is no cycle, and the screen starts empty as
|
||||
it always did. */
|
||||
async loadCurrentCycle() {
|
||||
if (!this.isMeasureTask || !this.subtasks.length || !this.productionRun) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'{{ url_for("measure.api_current_cycle", task_id=task.id) }}',
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const known = new Set(this.subtasks.map(s => s.id));
|
||||
const restored = (data.measurements || []).filter(m => known.has(m.subtask_id));
|
||||
if (!restored.length) return;
|
||||
this.measurements = restored.map(m => {
|
||||
const st = this.subtasks.find(s => s.id === m.subtask_id);
|
||||
const nominal = st ? (st.nominal || 0) : 0;
|
||||
return {
|
||||
subtask_id: m.subtask_id,
|
||||
value: m.value,
|
||||
pass_fail: m.pass_fail,
|
||||
deviation: m.value - nominal,
|
||||
};
|
||||
});
|
||||
const next = this.subtasks.findIndex(
|
||||
s => !this.measurements.some(m => m.subtask_id === s.id),
|
||||
);
|
||||
this.currentIndex = next !== -1 ? next : this.subtasks.length - 1;
|
||||
} catch (e) {
|
||||
// Offline: the screen starts empty, as before.
|
||||
}
|
||||
},
|
||||
|
||||
/* Is a quote already waiting for the capoturno?
|
||||
|
||||
Reloading the page was the simplest way past the old gate. It is not one
|
||||
|
||||
@@ -312,8 +312,8 @@ function spcDashboard() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||
|
||||
try {
|
||||
// Parallel fetch of all endpoints
|
||||
@@ -417,8 +417,8 @@ function spcDashboard() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||
|
||||
const url = '/statistics/api/report-' + type + '?' + params.toString();
|
||||
const resp = await fetch(url);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Filtro local_dt: il backend parla UTC, la presentazione e' in Europe/Rome (#42)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_dt(flask_app):
|
||||
return flask_app.jinja_env.filters["local_dt"]
|
||||
|
||||
|
||||
def test_summer_time(local_dt):
|
||||
assert local_dt("2026-10-06T08:00:00+00:00") == "06/10/2026 10:00"
|
||||
|
||||
|
||||
def test_winter_time(local_dt):
|
||||
assert local_dt("2026-01-15T08:00:00+00:00") == "15/01/2026 09:00"
|
||||
|
||||
|
||||
def test_naive_is_utc(local_dt):
|
||||
assert local_dt("2026-10-06T08:00:00") == "06/10/2026 10:00"
|
||||
|
||||
|
||||
def test_datetime_object_and_format(local_dt):
|
||||
dt = datetime(2026, 10, 6, 8, 0, tzinfo=timezone.utc)
|
||||
assert local_dt(dt, "%H:%M") == "10:00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("empty", [None, ""])
|
||||
def test_empty(local_dt, empty):
|
||||
assert local_dt(empty) == "-"
|
||||
@@ -248,6 +248,67 @@ class TestApiTaskProgress:
|
||||
assert resp.status_code in (302, 401)
|
||||
|
||||
|
||||
class TestApiCurrentCycle:
|
||||
"""GET /measure/api/current-cycle/<task_id> (gitea#40)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _station_configured(self, monkeypatch):
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
|
||||
def test_forwards_run_and_task(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
||||
payload = {"measurements": [
|
||||
{"subtask_id": 3, "value": 10.1, "pass_fail": "pass",
|
||||
"measured_at": "2026-10-01T08:01:00"},
|
||||
]}
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
||||
"/api/production-runs/current": {"id": 5},
|
||||
"/api/measurements/current-cycle": payload,
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/api/current-cycle/7")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == payload
|
||||
call = next(
|
||||
c for c in mock_api_client.get.call_args_list
|
||||
if c.args[0] == "/api/measurements/current-cycle"
|
||||
)
|
||||
assert call.kwargs["params"] == {"production_run_id": 5, "task_id": 7}
|
||||
|
||||
def test_no_run_means_empty_and_no_backend_call(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
||||
"/api/production-runs/current": None,
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/api/current-cycle/7")
|
||||
|
||||
assert resp.get_json() == {"measurements": []}
|
||||
assert not any(
|
||||
c.args[0] == "/api/measurements/current-cycle"
|
||||
for c in mock_api_client.get.call_args_list
|
||||
)
|
||||
|
||||
def test_refuses_a_recipe_not_assigned_to_this_station(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
||||
})
|
||||
|
||||
assert logged_in_client.get("/measure/api/current-cycle/7").status_code == 403
|
||||
|
||||
def test_requires_login(self, client):
|
||||
resp = client.get("/measure/api/current-cycle/7")
|
||||
assert resp.status_code in (302, 401)
|
||||
|
||||
|
||||
class TestStartSequence:
|
||||
"""GET /measure/start/<recipe_id> — the operator's way in."""
|
||||
|
||||
@@ -338,6 +399,7 @@ class TestTaskComplete:
|
||||
# The summary serialises the version number, so the recipe carries one.
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
||||
None, # current run
|
||||
{"items": []}, # measurements
|
||||
]
|
||||
|
||||
@@ -345,6 +407,21 @@ class TestTaskComplete:
|
||||
assert resp.status_code == 200
|
||||
assert b"productionClock(" in resp.data
|
||||
|
||||
def test_measured_at_shown_in_local_time(self, logged_in_client, mock_api_client):
|
||||
"""#42: the API sends UTC; the summary shows Europe/Rome."""
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
|
||||
{"id": 1, "marker_number": 1, "description": "d", "nominal": 1.0,
|
||||
"unit": "mm"}]}],
|
||||
None,
|
||||
{"items": [{"id": 1, "subtask_id": 1, "value": 1.0, "pass_fail": "pass",
|
||||
"measured_at": "2026-10-06T08:00:00+00:00"}]},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||
assert resp.status_code == 200
|
||||
assert "06/10/2026 10:00" in resp.get_data(as_text=True)
|
||||
|
||||
def test_measurements_filtered_by_lot_serial_and_operator(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
@@ -357,13 +434,14 @@ class TestTaskComplete:
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
||||
None, # current run
|
||||
{"items": [], "pages": 1},
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||
assert resp.status_code == 200
|
||||
|
||||
endpoint, kwargs = mock_api_client.get.call_args_list[2]
|
||||
endpoint, kwargs = mock_api_client.get.call_args_list[3]
|
||||
assert endpoint[0] == "/api/measurements"
|
||||
params = kwargs["params"]
|
||||
assert params["version_id"] == "5"
|
||||
@@ -371,6 +449,24 @@ class TestTaskComplete:
|
||||
assert params["serial_number"] == "S1"
|
||||
assert params["measured_by"] == 1 # session["user_id"] from the fixture
|
||||
|
||||
def test_measurements_filtered_by_the_active_production_run(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
"""#43: when the station has a run open, the summary asks for that run."""
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
||||
{"id": 77}, # current run
|
||||
{"items": [], "pages": 1},
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||
assert resp.status_code == 200
|
||||
|
||||
endpoint, kwargs = mock_api_client.get.call_args_list[3]
|
||||
assert endpoint[0] == "/api/measurements"
|
||||
assert kwargs["params"]["production_run_id"] == 77
|
||||
|
||||
def test_measurements_without_lot_or_serial_still_scope_to_the_operator(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
@@ -379,13 +475,14 @@ class TestTaskComplete:
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
|
||||
None, # current run
|
||||
{"items": [], "pages": 1},
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||
assert resp.status_code == 200
|
||||
|
||||
endpoint, kwargs = mock_api_client.get.call_args_list[2]
|
||||
endpoint, kwargs = mock_api_client.get.call_args_list[3]
|
||||
params = kwargs["params"]
|
||||
assert "lot_number" not in params
|
||||
assert "serial_number" not in params
|
||||
@@ -404,15 +501,16 @@ class TestTaskComplete:
|
||||
mock_api_client.get.side_effect = [
|
||||
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [subtask]}],
|
||||
None, # current run
|
||||
page1,
|
||||
page2,
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||
assert resp.status_code == 200
|
||||
assert mock_api_client.get.call_count == 4
|
||||
first_page_call = mock_api_client.get.call_args_list[2]
|
||||
second_page_call = mock_api_client.get.call_args_list[3]
|
||||
assert mock_api_client.get.call_count == 5
|
||||
first_page_call = mock_api_client.get.call_args_list[3]
|
||||
second_page_call = mock_api_client.get.call_args_list[4]
|
||||
assert first_page_call.kwargs["params"]["page"] == 1
|
||||
assert second_page_call.kwargs["params"]["page"] == 2
|
||||
|
||||
|
||||
Reference in New Issue
Block a user