feat: FASE 1 - Backend Core (modelli, auth, API)
Implementazione completa del backend FastAPI: - Modelli SQLAlchemy: User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, AccessLog, SystemSetting, RecipeVersionAudit - Schemas Pydantic v2 per tutti i CRUD + statistiche SPC - Middleware: API Key auth (X-API-Key) con role checking + access logging - Router: auth, users, recipes, tasks, measurements, files, settings - Services: auth (bcrypt+secrets), recipe (copy-on-write versioning), measurement (auto pass/fail con UTL/UWL/LWL/LTL) - Alembic env.py con import modelli attivi - Fix architect review: no double-commit, recipe_id subquery filter, user_id in access logs, type annotations corrette Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,14 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from database import init_db
|
from database import init_db
|
||||||
|
from middleware.logging import AccessLogMiddleware
|
||||||
|
from routers.auth import router as auth_router
|
||||||
|
from routers.users import router as users_router
|
||||||
|
from routers.recipes import router as recipes_router
|
||||||
|
from routers.tasks import router as tasks_router
|
||||||
|
from routers.measurements import router as measurements_router
|
||||||
|
from routers.files import router as files_router
|
||||||
|
from routers.settings import router as settings_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -39,6 +47,19 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Access logging middleware
|
||||||
|
app.add_middleware(AccessLogMiddleware)
|
||||||
|
|
||||||
|
# Register routers
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(users_router)
|
||||||
|
app.include_router(recipes_router)
|
||||||
|
app.include_router(tasks_router)
|
||||||
|
app.include_router(measurements_router)
|
||||||
|
app.include_router(files_router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check() -> dict:
|
async def health_check() -> dict:
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""FastAPI middleware for TieMeasureFlow."""
|
||||||
|
from middleware.api_key import (
|
||||||
|
get_current_user,
|
||||||
|
require_role,
|
||||||
|
require_admin,
|
||||||
|
require_maker,
|
||||||
|
require_measurement_tec,
|
||||||
|
require_metrologist,
|
||||||
|
require_admin_user,
|
||||||
|
)
|
||||||
|
from middleware.logging import AccessLogMiddleware
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_current_user",
|
||||||
|
"require_role",
|
||||||
|
"require_admin",
|
||||||
|
"require_maker",
|
||||||
|
"require_measurement_tec",
|
||||||
|
"require_metrologist",
|
||||||
|
"require_admin_user",
|
||||||
|
"AccessLogMiddleware",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""API Key authentication dependency for FastAPI."""
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""Extract API key from header and return the authenticated user.
|
||||||
|
|
||||||
|
The API key is sent in the X-API-Key header on every request.
|
||||||
|
Login endpoint is excluded from this check.
|
||||||
|
"""
|
||||||
|
api_key = request.headers.get("X-API-Key")
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing API key in X-API-Key header",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.api_key == api_key, User.active == True)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or inactive API key",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store user_id in request state for access logging middleware
|
||||||
|
request.state.user_id = user.id
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(role: str):
|
||||||
|
"""Dependency factory that checks if user has a specific role."""
|
||||||
|
async def check_role(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if not user.has_role(role) and not user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Role '{role}' required",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
return check_role
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin():
|
||||||
|
"""Dependency that checks if user is admin."""
|
||||||
|
async def check_admin(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin access required",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
return check_admin
|
||||||
|
|
||||||
|
|
||||||
|
# Pre-built role dependencies for convenience
|
||||||
|
require_maker = require_role("Maker")
|
||||||
|
require_measurement_tec = require_role("MeasurementTec")
|
||||||
|
require_metrologist = require_role("Metrologist")
|
||||||
|
require_admin_user = require_admin()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Access logging middleware for FastAPI."""
|
||||||
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from sqlalchemy import insert
|
||||||
|
|
||||||
|
from database import async_session_factory
|
||||||
|
from models.access_log import AccessLog
|
||||||
|
|
||||||
|
|
||||||
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Middleware that logs every API request to the access_logs table."""
|
||||||
|
|
||||||
|
# Paths to exclude from logging (health checks, static files)
|
||||||
|
EXCLUDED_PATHS = {"/api/health", "/docs", "/openapi.json", "/redoc"}
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
|
# Skip excluded paths
|
||||||
|
if request.url.path in self.EXCLUDED_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
response = await call_next(request)
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
# Extract user info from request state (set by auth middleware)
|
||||||
|
user_id = getattr(request.state, "user_id", None)
|
||||||
|
|
||||||
|
# Log asynchronously (don't block response)
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
await session.execute(
|
||||||
|
insert(AccessLog).values(
|
||||||
|
user_id=user_id,
|
||||||
|
action=f"{request.method} {request.url.path}",
|
||||||
|
details={
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"query": str(request.query_params),
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"duration_ms": round(duration_ms, 2),
|
||||||
|
},
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
user_agent=request.headers.get("user-agent", "")[:500],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
# Don't fail the request if logging fails
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -24,13 +24,13 @@ from database import Base
|
|||||||
# Override alembic.ini URL with .env settings (keep in sync)
|
# Override alembic.ini URL with .env settings (keep in sync)
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
# Import all models here so they register with Base.metadata
|
# Import all models so they register with Base.metadata
|
||||||
# from models.user import User
|
from models.user import User # noqa: F401
|
||||||
# from models.recipe import Recipe, RecipeVersion
|
from models.recipe import Recipe, RecipeVersion # noqa: F401
|
||||||
# from models.task import RecipeTask, RecipeSubtask
|
from models.task import RecipeTask, RecipeSubtask # noqa: F401
|
||||||
# from models.measurement import Measurement
|
from models.measurement import Measurement # noqa: F401
|
||||||
# from models.access_log import AccessLog
|
from models.access_log import AccessLog # noqa: F401
|
||||||
# from models.setting import SystemSetting
|
from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""SQLAlchemy models for TieMeasureFlow."""
|
||||||
|
from models.user import User
|
||||||
|
from models.recipe import Recipe, RecipeVersion
|
||||||
|
from models.task import RecipeTask, RecipeSubtask
|
||||||
|
from models.measurement import Measurement
|
||||||
|
from models.access_log import AccessLog
|
||||||
|
from models.setting import SystemSetting, RecipeVersionAudit
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Recipe",
|
||||||
|
"RecipeVersion",
|
||||||
|
"RecipeTask",
|
||||||
|
"RecipeSubtask",
|
||||||
|
"Measurement",
|
||||||
|
"AccessLog",
|
||||||
|
"SystemSetting",
|
||||||
|
"RecipeVersionAudit",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Access log model for tracking user actions."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AccessLog(Base):
|
||||||
|
__tablename__ = "access_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
details: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_access_log_user_time", "user_id", "created_at"),
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<AccessLog user={self.user_id} action={self.action}>"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Measurement model for storing individual measurements."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, Boolean, DECIMAL, DateTime, Enum, ForeignKey,
|
||||||
|
Integer, String, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Measurement(Base):
|
||||||
|
__tablename__ = "measurements"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
subtask_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_subtasks.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
version_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
measured_by: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Measurement data
|
||||||
|
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False)
|
||||||
|
pass_fail: Mapped[str] = mapped_column(
|
||||||
|
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
||||||
|
)
|
||||||
|
deviation: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
||||||
|
|
||||||
|
# Traceability
|
||||||
|
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Input method
|
||||||
|
input_method: Mapped[str] = mapped_column(
|
||||||
|
Enum("usb_caliper", "manual", name="input_method_enum"),
|
||||||
|
nullable=False,
|
||||||
|
default="manual",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamp
|
||||||
|
measured_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now(), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# CSV sync flag
|
||||||
|
synced_to_csv: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Measurement subtask={self.subtask_id} value={self.value} {self.pass_fail}>"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Recipe and RecipeVersion models with immutable versioning."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from models.task import RecipeTask
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe(Base):
|
||||||
|
__tablename__ = "recipes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||||
|
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Recipe {self.code} '{self.name}'>"
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeVersion(Base):
|
||||||
|
__tablename__ = "recipe_versions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
recipe_id: Mapped[int] = mapped_column(Integer, ForeignKey("recipes.id"), nullable=False)
|
||||||
|
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
change_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
recipe: Mapped["Recipe"] = relationship(back_populates="versions")
|
||||||
|
tasks: Mapped[list["RecipeTask"]] = relationship(
|
||||||
|
back_populates="version", cascade="all, delete-orphan", lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("recipe_id", "version_number", name="uq_recipe_version"),
|
||||||
|
Index("ix_recipe_version_current", "recipe_id", "is_current"),
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<RecipeVersion recipe={self.recipe_id} v{self.version_number}>"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""SystemSetting and RecipeVersionAudit models."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSetting(Base):
|
||||||
|
__tablename__ = "system_settings"
|
||||||
|
|
||||||
|
setting_key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
setting_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
setting_type: Mapped[str] = mapped_column(
|
||||||
|
Enum("string", "number", "boolean", "json", name="setting_type_enum"),
|
||||||
|
nullable=False,
|
||||||
|
default="string",
|
||||||
|
)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
updated_by: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<SystemSetting {self.setting_key}={self.setting_value}>"
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeVersionAudit(Base):
|
||||||
|
__tablename__ = "recipe_version_audit"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
recipe_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipes.id"), nullable=False
|
||||||
|
)
|
||||||
|
old_version_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
new_version_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
changed_by: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
change_type: Mapped[str] = mapped_column(
|
||||||
|
Enum("CREATE", "UPDATE", "ACTIVATE", "RETIRE", name="change_type_enum"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
change_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_recipe_audit_recipe_time", "recipe_id", "created_at"),
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<RecipeVersionAudit recipe={self.recipe_id} {self.change_type}>"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""RecipeTask and RecipeSubtask models."""
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from models.recipe import RecipeVersion
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeTask(Base):
|
||||||
|
__tablename__ = "recipe_tasks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
version_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_versions.id"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
file_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
file_type: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
||||||
|
)
|
||||||
|
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||||
|
subtasks: Mapped[list["RecipeSubtask"]] = relationship(
|
||||||
|
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<RecipeTask #{self.order_index} '{self.title}'>"
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeSubtask(Base):
|
||||||
|
__tablename__ = "recipe_subtasks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
marker_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
measurement_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# Tolerance values
|
||||||
|
nominal: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
||||||
|
utl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Upper Tolerance Limit
|
||||||
|
uwl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Upper Warning Limit
|
||||||
|
lwl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Lower Warning Limit
|
||||||
|
ltl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Lower Tolerance Limit
|
||||||
|
|
||||||
|
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("task_id", "marker_number", name="uq_task_marker"),
|
||||||
|
Index("ix_subtask_task_id", "task_id"),
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<RecipeSubtask #{self.marker_number} '{self.description}'>"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""User model with combinable roles and admin flag."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Roles: combinable JSON array of "Maker", "MeasurementTec", "Metrologist"
|
||||||
|
roles: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||||
|
# Admin flag separate from roles
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# User preferences
|
||||||
|
language_pref: Mapped[str] = mapped_column(
|
||||||
|
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
||||||
|
)
|
||||||
|
theme_pref: Mapped[str] = mapped_column(
|
||||||
|
Enum("light", "dark", name="theme_enum"), nullable=False, default="light"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
def has_role(self, role: str) -> bool:
|
||||||
|
"""Check if user has a specific role."""
|
||||||
|
return role in (self.roles or [])
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.username} roles={self.roles}>"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Authentication router - login, logout, profile."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from middleware.api_key import get_current_user
|
||||||
|
from models.user import User
|
||||||
|
from schemas.user import (
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
UserProfileUpdate,
|
||||||
|
UserResponse,
|
||||||
|
)
|
||||||
|
from services.auth_service import authenticate_user, login_user, logout_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Login with username and password, receive API key."""
|
||||||
|
user = await authenticate_user(db, data.username, data.password)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid username or password",
|
||||||
|
)
|
||||||
|
api_key = await login_user(db, user)
|
||||||
|
return LoginResponse(
|
||||||
|
user=UserResponse.model_validate(user),
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Logout - invalidate API key."""
|
||||||
|
await logout_user(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_profile(user: User = Depends(get_current_user)):
|
||||||
|
"""Get current user profile."""
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me", response_model=UserResponse)
|
||||||
|
async def update_profile(
|
||||||
|
data: UserProfileUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update own profile (display_name, language, theme)."""
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(user, field, value)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""File upload/download/delete router for images and PDFs."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from middleware.api_key import get_current_user, require_maker
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
|
# Allowed MIME types
|
||||||
|
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
|
ALLOWED_PDF_TYPE = "application/pdf"
|
||||||
|
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_type(content_type: str) -> bool:
|
||||||
|
"""Validate if file type is allowed."""
|
||||||
|
return content_type in ALLOWED_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_size(file_size: int) -> bool:
|
||||||
|
"""Validate if file size is within limits."""
|
||||||
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
|
return file_size <= max_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
|
||||||
|
"""Generate a thumbnail for an image."""
|
||||||
|
try:
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||||
|
img.save(thumbnail_path, quality=85)
|
||||||
|
except Exception as e:
|
||||||
|
# If thumbnail generation fails, we continue without it
|
||||||
|
print(f"Thumbnail generation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
recipe_id: int | None = None,
|
||||||
|
version_id: int | None = None,
|
||||||
|
user: User = Depends(require_maker),
|
||||||
|
):
|
||||||
|
"""Upload an image or PDF file.
|
||||||
|
|
||||||
|
Files are stored in uploads/{recipe_id}/{version_id}/
|
||||||
|
Thumbnails are generated for images.
|
||||||
|
"""
|
||||||
|
# Validate file type
|
||||||
|
if not validate_file_type(file.content_type):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
content = await file.read()
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Validate file size
|
||||||
|
if not validate_file_size(file_size):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine storage path
|
||||||
|
if recipe_id is not None and version_id is not None:
|
||||||
|
storage_dir = settings.upload_path / str(recipe_id) / str(version_id)
|
||||||
|
else:
|
||||||
|
storage_dir = settings.upload_path / "general"
|
||||||
|
|
||||||
|
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate unique filename if file already exists
|
||||||
|
file_path = storage_dir / file.filename
|
||||||
|
counter = 1
|
||||||
|
while file_path.exists():
|
||||||
|
name_parts = file.filename.rsplit(".", 1)
|
||||||
|
if len(name_parts) == 2:
|
||||||
|
file_path = storage_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
|
||||||
|
else:
|
||||||
|
file_path = storage_dir / f"{file.filename}_{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Write file
|
||||||
|
file_path.write_bytes(content)
|
||||||
|
|
||||||
|
# Generate thumbnail for images
|
||||||
|
thumbnail_path = None
|
||||||
|
if file.content_type in ALLOWED_IMAGE_TYPES:
|
||||||
|
thumbnail_dir = storage_dir / "thumbnails"
|
||||||
|
thumbnail_dir.mkdir(exist_ok=True)
|
||||||
|
thumbnail_path = thumbnail_dir / file_path.name
|
||||||
|
generate_thumbnail(file_path, thumbnail_path)
|
||||||
|
|
||||||
|
# Return relative path from uploads directory
|
||||||
|
relative_path = file_path.relative_to(settings.upload_path)
|
||||||
|
thumbnail_relative = (
|
||||||
|
thumbnail_path.relative_to(settings.upload_path)
|
||||||
|
if thumbnail_path
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_path": str(relative_path).replace("\\", "/"),
|
||||||
|
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
||||||
|
"file_type": file.content_type,
|
||||||
|
"file_size": file_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{file_path:path}")
|
||||||
|
async def get_file(
|
||||||
|
file_path: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Serve a file from the uploads directory.
|
||||||
|
|
||||||
|
Requires authentication but no specific role.
|
||||||
|
"""
|
||||||
|
# Construct full path
|
||||||
|
full_path = settings.upload_path / file_path
|
||||||
|
|
||||||
|
# Security: ensure path is within uploads directory
|
||||||
|
try:
|
||||||
|
full_path = full_path.resolve()
|
||||||
|
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
if not full_path.exists() or not full_path.is_file():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileResponse(full_path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_file(
|
||||||
|
file_path: str,
|
||||||
|
user: User = Depends(require_maker),
|
||||||
|
):
|
||||||
|
"""Delete a file from the uploads directory.
|
||||||
|
|
||||||
|
Also deletes associated thumbnail if it exists.
|
||||||
|
"""
|
||||||
|
# Construct full path
|
||||||
|
full_path = settings.upload_path / file_path
|
||||||
|
|
||||||
|
# Security: ensure path is within uploads directory
|
||||||
|
try:
|
||||||
|
full_path = full_path.resolve()
|
||||||
|
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
if not full_path.exists() or not full_path.is_file():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete thumbnail if it exists
|
||||||
|
if full_path.parent.name != "thumbnails":
|
||||||
|
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
||||||
|
if thumbnail_path.exists():
|
||||||
|
thumbnail_path.unlink()
|
||||||
|
|
||||||
|
# Delete file
|
||||||
|
full_path.unlink()
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Measurements router - create, query, export measurements."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from middleware.api_key import (
|
||||||
|
get_current_user,
|
||||||
|
require_measurement_tec,
|
||||||
|
require_metrologist,
|
||||||
|
)
|
||||||
|
from models.measurement import Measurement
|
||||||
|
from models.recipe import RecipeVersion
|
||||||
|
from models.setting import SystemSetting
|
||||||
|
from models.user import User
|
||||||
|
from schemas.measurement import (
|
||||||
|
MeasurementBatchCreate,
|
||||||
|
MeasurementCreate,
|
||||||
|
MeasurementListResponse,
|
||||||
|
MeasurementResponse,
|
||||||
|
)
|
||||||
|
from services.measurement_service import save_measurement
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=MeasurementResponse)
|
||||||
|
async def create_measurement(
|
||||||
|
data: MeasurementCreate,
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a single measurement with auto-calculated pass/fail."""
|
||||||
|
try:
|
||||||
|
measurement = await save_measurement(
|
||||||
|
db=db,
|
||||||
|
subtask_id=data.subtask_id,
|
||||||
|
version_id=data.version_id,
|
||||||
|
measured_by=user.id,
|
||||||
|
value=data.value,
|
||||||
|
lot_number=data.lot_number,
|
||||||
|
serial_number=data.serial_number,
|
||||||
|
input_method=data.input_method,
|
||||||
|
)
|
||||||
|
return MeasurementResponse.model_validate(measurement)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=list[MeasurementResponse])
|
||||||
|
async def create_measurement_batch(
|
||||||
|
data: MeasurementBatchCreate,
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create multiple measurements in a batch."""
|
||||||
|
measurements = []
|
||||||
|
try:
|
||||||
|
for measurement_data in data.measurements:
|
||||||
|
measurement = await save_measurement(
|
||||||
|
db=db,
|
||||||
|
subtask_id=measurement_data.subtask_id,
|
||||||
|
version_id=measurement_data.version_id,
|
||||||
|
measured_by=user.id,
|
||||||
|
value=measurement_data.value,
|
||||||
|
lot_number=measurement_data.lot_number,
|
||||||
|
serial_number=measurement_data.serial_number,
|
||||||
|
input_method=measurement_data.input_method,
|
||||||
|
)
|
||||||
|
measurements.append(measurement)
|
||||||
|
return [MeasurementResponse.model_validate(m) for m in measurements]
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=MeasurementListResponse)
|
||||||
|
async def get_measurements(
|
||||||
|
recipe_id: int | None = Query(None),
|
||||||
|
version_id: int | None = Query(None),
|
||||||
|
subtask_id: int | None = Query(None),
|
||||||
|
measured_by: int | None = Query(None),
|
||||||
|
lot_number: str | None = Query(None),
|
||||||
|
serial_number: str | None = Query(None),
|
||||||
|
date_from: datetime | None = Query(None),
|
||||||
|
date_to: datetime | None = Query(None),
|
||||||
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(50, ge=1, le=500),
|
||||||
|
user: User = Depends(require_metrologist),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Query measurements with filters and pagination."""
|
||||||
|
# Build filter conditions
|
||||||
|
filters = []
|
||||||
|
if recipe_id is not None:
|
||||||
|
# Filter by recipe via subquery on RecipeVersion
|
||||||
|
version_ids = select(RecipeVersion.id).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id
|
||||||
|
)
|
||||||
|
filters.append(Measurement.version_id.in_(version_ids))
|
||||||
|
if version_id is not None:
|
||||||
|
filters.append(Measurement.version_id == version_id)
|
||||||
|
if subtask_id is not None:
|
||||||
|
filters.append(Measurement.subtask_id == subtask_id)
|
||||||
|
if measured_by is not None:
|
||||||
|
filters.append(Measurement.measured_by == measured_by)
|
||||||
|
if lot_number is not None:
|
||||||
|
filters.append(Measurement.lot_number == lot_number)
|
||||||
|
if serial_number is not None:
|
||||||
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
if date_from is not None:
|
||||||
|
filters.append(Measurement.measured_at >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
filters.append(Measurement.measured_at <= date_to)
|
||||||
|
if pass_fail is not None:
|
||||||
|
filters.append(Measurement.pass_fail == pass_fail)
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||||
|
|
||||||
|
# Count total
|
||||||
|
count_query = select(func.count()).select_from(Measurement).where(
|
||||||
|
and_(*filters) if filters else True
|
||||||
|
)
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
query = query.order_by(Measurement.measured_at.desc()).limit(per_page).offset(offset)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
measurements = result.scalars().all()
|
||||||
|
|
||||||
|
pages = (total + per_page - 1) // per_page
|
||||||
|
|
||||||
|
return MeasurementListResponse(
|
||||||
|
items=[MeasurementResponse.model_validate(m) for m in measurements],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
pages=pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/csv")
|
||||||
|
async def export_measurements_csv(
|
||||||
|
recipe_id: int | None = Query(None),
|
||||||
|
version_id: int | None = Query(None),
|
||||||
|
subtask_id: int | None = Query(None),
|
||||||
|
measured_by: int | None = Query(None),
|
||||||
|
lot_number: str | None = Query(None),
|
||||||
|
serial_number: str | None = Query(None),
|
||||||
|
date_from: datetime | None = Query(None),
|
||||||
|
date_to: datetime | None = Query(None),
|
||||||
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Export measurements to CSV with configurable delimiter and decimal separator."""
|
||||||
|
# Check user has MeasurementTec or Metrologist role
|
||||||
|
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="MeasurementTec or Metrologist role required",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get CSV settings from system_settings
|
||||||
|
delimiter_result = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
|
||||||
|
)
|
||||||
|
delimiter_setting = delimiter_result.scalar_one_or_none()
|
||||||
|
delimiter = delimiter_setting.setting_value if delimiter_setting else ","
|
||||||
|
|
||||||
|
decimal_result = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.setting_key == "csv_decimal_separator")
|
||||||
|
)
|
||||||
|
decimal_setting = decimal_result.scalar_one_or_none()
|
||||||
|
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
||||||
|
|
||||||
|
# Build filter conditions (same as get_measurements)
|
||||||
|
filters = []
|
||||||
|
if recipe_id is not None:
|
||||||
|
version_ids = select(RecipeVersion.id).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id
|
||||||
|
)
|
||||||
|
filters.append(Measurement.version_id.in_(version_ids))
|
||||||
|
if version_id is not None:
|
||||||
|
filters.append(Measurement.version_id == version_id)
|
||||||
|
if subtask_id is not None:
|
||||||
|
filters.append(Measurement.subtask_id == subtask_id)
|
||||||
|
if measured_by is not None:
|
||||||
|
filters.append(Measurement.measured_by == measured_by)
|
||||||
|
if lot_number is not None:
|
||||||
|
filters.append(Measurement.lot_number == lot_number)
|
||||||
|
if serial_number is not None:
|
||||||
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
if date_from is not None:
|
||||||
|
filters.append(Measurement.measured_at >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
filters.append(Measurement.measured_at <= date_to)
|
||||||
|
if pass_fail is not None:
|
||||||
|
filters.append(Measurement.pass_fail == pass_fail)
|
||||||
|
|
||||||
|
# Query all matching measurements
|
||||||
|
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||||
|
query = query.order_by(Measurement.measured_at.desc())
|
||||||
|
result = await db.execute(query)
|
||||||
|
measurements = result.scalars().all()
|
||||||
|
|
||||||
|
# Generate CSV
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, delimiter=delimiter)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
writer.writerow([
|
||||||
|
"id",
|
||||||
|
"subtask_id",
|
||||||
|
"version_id",
|
||||||
|
"measured_by",
|
||||||
|
"value",
|
||||||
|
"pass_fail",
|
||||||
|
"deviation",
|
||||||
|
"lot_number",
|
||||||
|
"serial_number",
|
||||||
|
"input_method",
|
||||||
|
"measured_at",
|
||||||
|
"synced_to_csv",
|
||||||
|
])
|
||||||
|
|
||||||
|
# Rows
|
||||||
|
for m in measurements:
|
||||||
|
# Format decimals with configured separator
|
||||||
|
value_str = str(m.value).replace(".", decimal_separator)
|
||||||
|
deviation_str = (
|
||||||
|
str(m.deviation).replace(".", decimal_separator)
|
||||||
|
if m.deviation is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
m.id,
|
||||||
|
m.subtask_id,
|
||||||
|
m.version_id,
|
||||||
|
m.measured_by,
|
||||||
|
value_str,
|
||||||
|
m.pass_fail,
|
||||||
|
deviation_str,
|
||||||
|
m.lot_number or "",
|
||||||
|
m.serial_number or "",
|
||||||
|
m.input_method,
|
||||||
|
m.measured_at.isoformat(),
|
||||||
|
m.synced_to_csv,
|
||||||
|
])
|
||||||
|
|
||||||
|
# Return as streaming response
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Recipe router - CRUD, versioning, barcode lookup."""
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||||
|
from models.user import User
|
||||||
|
from schemas.recipe import (
|
||||||
|
RecipeCreate,
|
||||||
|
RecipeListResponse,
|
||||||
|
RecipeResponse,
|
||||||
|
RecipeUpdate,
|
||||||
|
RecipeVersionResponse,
|
||||||
|
)
|
||||||
|
from services import recipe_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: build RecipeResponse with current_version attached
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _recipe_to_response(recipe) -> RecipeResponse:
|
||||||
|
"""Convert a Recipe ORM object to RecipeResponse, injecting current_version."""
|
||||||
|
current = None
|
||||||
|
for v in (recipe.versions or []):
|
||||||
|
if v.is_current:
|
||||||
|
current = v
|
||||||
|
break
|
||||||
|
|
||||||
|
resp = RecipeResponse.model_validate(recipe)
|
||||||
|
if current is not None:
|
||||||
|
resp.current_version = RecipeVersionResponse.model_validate(current)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Recipes CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("", response_model=RecipeResponse, status_code=201)
|
||||||
|
async def create_recipe(
|
||||||
|
data: RecipeCreate,
|
||||||
|
user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new recipe with its initial version (v1). Requires Maker role."""
|
||||||
|
recipe = await recipe_service.create_recipe(db, data, user)
|
||||||
|
return _recipe_to_response(recipe)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=RecipeListResponse)
|
||||||
|
async def list_recipes(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
|
search: str | None = Query(None, max_length=200),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List active recipes with pagination and optional search. All authenticated users."""
|
||||||
|
result = await recipe_service.list_recipes(db, page=page, per_page=per_page, search=search)
|
||||||
|
result["items"] = [_recipe_to_response(r) for r in result["items"]]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/code/{code}", response_model=RecipeResponse)
|
||||||
|
async def get_recipe_by_code(
|
||||||
|
code: str,
|
||||||
|
_user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Look up a recipe by its barcode/code. Requires MeasurementTec role."""
|
||||||
|
recipe = await recipe_service.get_recipe_by_code(db, code)
|
||||||
|
return _recipe_to_response(recipe)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{recipe_id}", response_model=RecipeResponse)
|
||||||
|
async def get_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get recipe detail with current version and all tasks/subtasks."""
|
||||||
|
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
|
||||||
|
return _recipe_to_response(recipe)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{recipe_id}", response_model=RecipeResponse)
|
||||||
|
async def update_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
data: RecipeUpdate,
|
||||||
|
user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a recipe - creates a new version via copy-on-write. Requires Maker role."""
|
||||||
|
new_version = await recipe_service.create_new_version(db, recipe_id, data, user)
|
||||||
|
|
||||||
|
# Reload full recipe for consistent response
|
||||||
|
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
|
||||||
|
return _recipe_to_response(recipe)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{recipe_id}", response_model=RecipeResponse)
|
||||||
|
async def delete_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Deactivate a recipe (soft delete). Requires Maker role."""
|
||||||
|
recipe = await recipe_service.deactivate_recipe(db, recipe_id, user)
|
||||||
|
return RecipeResponse.model_validate(recipe)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Versions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/{recipe_id}/versions", response_model=list[RecipeVersionResponse])
|
||||||
|
async def list_versions(
|
||||||
|
recipe_id: int,
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all versions of a recipe. Requires Maker or Metrologist role."""
|
||||||
|
versions = await recipe_service.list_versions(db, recipe_id)
|
||||||
|
return [RecipeVersionResponse.model_validate(v) for v in versions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{recipe_id}/versions/{version_number}",
|
||||||
|
response_model=RecipeVersionResponse,
|
||||||
|
)
|
||||||
|
async def get_version(
|
||||||
|
recipe_id: int,
|
||||||
|
version_number: int,
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a specific version with its tasks. Requires Maker or Metrologist role."""
|
||||||
|
version = await recipe_service.get_version_detail(db, recipe_id, version_number)
|
||||||
|
return RecipeVersionResponse.model_validate(version)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{recipe_id}/versions/{version_number}/measurement-count")
|
||||||
|
async def get_measurement_count(
|
||||||
|
recipe_id: int,
|
||||||
|
version_number: int,
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Count measurements on a specific version. Requires Maker role.
|
||||||
|
|
||||||
|
Useful to warn before creating a new version if the current one has measurements.
|
||||||
|
"""
|
||||||
|
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
||||||
|
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""System settings router - read/update settings, upload company logo."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import get_db
|
||||||
|
from middleware.api_key import get_current_user, require_admin_user
|
||||||
|
from models.setting import SystemSetting
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_settings(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get all system settings as a dictionary.
|
||||||
|
|
||||||
|
Returns key-value pairs of all settings.
|
||||||
|
Available to all authenticated users.
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(SystemSetting))
|
||||||
|
settings_list = result.scalars().all()
|
||||||
|
|
||||||
|
settings_dict = {
|
||||||
|
setting.setting_key: setting.setting_value
|
||||||
|
for setting in settings_list
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings_dict
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/")
|
||||||
|
async def update_settings(
|
||||||
|
settings_data: dict[str, str],
|
||||||
|
user: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update multiple system settings.
|
||||||
|
|
||||||
|
Expects a JSON object with setting_key: setting_value pairs.
|
||||||
|
Only admins can update settings.
|
||||||
|
"""
|
||||||
|
updated_keys = []
|
||||||
|
|
||||||
|
for key, value in settings_data.items():
|
||||||
|
# Check if setting exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.setting_key == key)
|
||||||
|
)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
# Create new setting (assume type 'string' for new settings)
|
||||||
|
setting = SystemSetting(
|
||||||
|
setting_key=key,
|
||||||
|
setting_value=value,
|
||||||
|
setting_type="string",
|
||||||
|
updated_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
else:
|
||||||
|
# Update existing setting
|
||||||
|
setting.setting_value = value
|
||||||
|
setting.updated_by = user.id
|
||||||
|
|
||||||
|
updated_keys.append(key)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Updated {len(updated_keys)} settings",
|
||||||
|
"updated_keys": updated_keys,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logo")
|
||||||
|
async def upload_company_logo(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Upload company logo.
|
||||||
|
|
||||||
|
Saves logo to uploads/logos/ and updates company_logo_path setting.
|
||||||
|
Only admins can upload the logo.
|
||||||
|
"""
|
||||||
|
# Validate file type (must be image)
|
||||||
|
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||||
|
if file.content_type not in allowed_types:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File type {file.content_type} not allowed. Must be an image.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
content = await file.read()
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Validate file size
|
||||||
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
|
if file_size > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create logos directory
|
||||||
|
logos_dir = settings.upload_path / "logos"
|
||||||
|
logos_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Use a fixed filename for the company logo
|
||||||
|
file_extension = Path(file.filename).suffix
|
||||||
|
logo_filename = f"company_logo{file_extension}"
|
||||||
|
logo_path = logos_dir / logo_filename
|
||||||
|
|
||||||
|
# Write file
|
||||||
|
logo_path.write_bytes(content)
|
||||||
|
|
||||||
|
# Update setting in database
|
||||||
|
relative_path = logo_path.relative_to(settings.upload_path)
|
||||||
|
logo_path_str = str(relative_path).replace("\\", "/")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.setting_key == "company_logo_path")
|
||||||
|
)
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if setting is None:
|
||||||
|
setting = SystemSetting(
|
||||||
|
setting_key="company_logo_path",
|
||||||
|
setting_value=logo_path_str,
|
||||||
|
setting_type="string",
|
||||||
|
description="Path to company logo file",
|
||||||
|
updated_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
else:
|
||||||
|
setting.setting_value = logo_path_str
|
||||||
|
setting.updated_by = user.id
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Company logo uploaded successfully",
|
||||||
|
"logo_path": logo_path_str,
|
||||||
|
"file_size": file_size,
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""Task and Subtask router - CRUD, reorder, copy-on-write integration."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from middleware.api_key import require_maker, get_current_user
|
||||||
|
from models.recipe import Recipe, RecipeVersion
|
||||||
|
from models.task import RecipeSubtask, RecipeTask
|
||||||
|
from models.user import User
|
||||||
|
from schemas.task import (
|
||||||
|
SubtaskCreate,
|
||||||
|
SubtaskResponse,
|
||||||
|
SubtaskUpdate,
|
||||||
|
TaskCreate,
|
||||||
|
TaskReorderRequest,
|
||||||
|
TaskResponse,
|
||||||
|
TaskUpdate,
|
||||||
|
)
|
||||||
|
from services import recipe_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _get_current_version_for_recipe(
|
||||||
|
db: AsyncSession, recipe_id: int
|
||||||
|
) -> RecipeVersion:
|
||||||
|
"""Return the current version of a recipe or raise 404."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeVersion)
|
||||||
|
.where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version = result.scalar_one_or_none()
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No current version found for this recipe",
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||||
|
"""Return a task with its subtasks or raise 404."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeTask)
|
||||||
|
.where(RecipeTask.id == task_id)
|
||||||
|
.options(selectinload(RecipeTask.subtasks))
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_subtask_or_404(db: AsyncSession, subtask_id: int) -> RecipeSubtask:
|
||||||
|
"""Return a subtask or raise 404."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||||
|
)
|
||||||
|
subtask = result.scalar_one_or_none()
|
||||||
|
if subtask is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Subtask not found"
|
||||||
|
)
|
||||||
|
return subtask
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> RecipeVersion:
|
||||||
|
"""Verify a version is the current one (editable). Raise 409 otherwise."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||||
|
)
|
||||||
|
version = result.scalar_one_or_none()
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Version not found"
|
||||||
|
)
|
||||||
|
if not version.is_current:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cannot modify a non-current version. Create a new version first.",
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/recipes/{recipe_id}/tasks", response_model=list[TaskResponse])
|
||||||
|
async def list_tasks(
|
||||||
|
recipe_id: int,
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all tasks for the current version of a recipe. All authenticated users."""
|
||||||
|
version = await _get_current_version_for_recipe(db, recipe_id)
|
||||||
|
tasks = sorted(version.tasks, key=lambda t: t.order_index)
|
||||||
|
return [TaskResponse.model_validate(t) for t in tasks]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/recipes/{recipe_id}/tasks",
|
||||||
|
response_model=TaskResponse,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_task(
|
||||||
|
recipe_id: int,
|
||||||
|
data: TaskCreate,
|
||||||
|
user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Add a task to the current version of a recipe.
|
||||||
|
|
||||||
|
This creates a NEW version via copy-on-write, then appends the task to
|
||||||
|
the new version. Requires Maker role.
|
||||||
|
"""
|
||||||
|
# Ensure recipe exists and is active
|
||||||
|
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||||
|
recipe = result.scalar_one_or_none()
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
|
||||||
|
if not recipe.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cannot modify an inactive recipe",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy-on-write: create new version with existing tasks cloned
|
||||||
|
from schemas.recipe import RecipeUpdate
|
||||||
|
new_version = await recipe_service.create_new_version(
|
||||||
|
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine order_index (append at end)
|
||||||
|
max_order = max((t.order_index for t in new_version.tasks), default=-1)
|
||||||
|
new_task = RecipeTask(
|
||||||
|
version_id=new_version.id,
|
||||||
|
order_index=max_order + 1,
|
||||||
|
title=data.title,
|
||||||
|
directive=data.directive,
|
||||||
|
description=data.description,
|
||||||
|
file_type=data.file_type,
|
||||||
|
annotations_json=data.annotations_json,
|
||||||
|
)
|
||||||
|
db.add(new_task)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create subtasks if provided
|
||||||
|
for sub_data in data.subtasks:
|
||||||
|
sub = RecipeSubtask(
|
||||||
|
task_id=new_task.id,
|
||||||
|
marker_number=sub_data.marker_number,
|
||||||
|
description=sub_data.description,
|
||||||
|
measurement_type=sub_data.measurement_type,
|
||||||
|
nominal=sub_data.nominal,
|
||||||
|
utl=sub_data.utl,
|
||||||
|
uwl=sub_data.uwl,
|
||||||
|
lwl=sub_data.lwl,
|
||||||
|
ltl=sub_data.ltl,
|
||||||
|
unit=sub_data.unit,
|
||||||
|
)
|
||||||
|
db.add(sub)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(new_task, attribute_names=["subtasks"])
|
||||||
|
return TaskResponse.model_validate(new_task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/tasks/{task_id}", response_model=TaskResponse)
|
||||||
|
async def update_task(
|
||||||
|
task_id: int,
|
||||||
|
data: TaskUpdate,
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a task. The task must belong to the current version. Requires Maker role."""
|
||||||
|
task = await _get_task_or_404(db, task_id)
|
||||||
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
if not update_data:
|
||||||
|
return TaskResponse.model_validate(task)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(task, field, value)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(task, attribute_names=["subtasks"])
|
||||||
|
return TaskResponse.model_validate(task)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/tasks/{task_id}", status_code=204)
|
||||||
|
async def delete_task(
|
||||||
|
task_id: int,
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a task and its subtasks. The task must belong to the current version.
|
||||||
|
|
||||||
|
Requires Maker role.
|
||||||
|
"""
|
||||||
|
task = await _get_task_or_404(db, task_id)
|
||||||
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
|
await db.delete(task)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/tasks/reorder", response_model=list[TaskResponse])
|
||||||
|
async def reorder_tasks(
|
||||||
|
data: TaskReorderRequest,
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reorder tasks by providing ordered task IDs. All tasks must belong to the
|
||||||
|
same current version. Requires Maker role.
|
||||||
|
"""
|
||||||
|
if not data.task_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="task_ids must not be empty",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch all tasks
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeTask)
|
||||||
|
.where(RecipeTask.id.in_(data.task_ids))
|
||||||
|
.options(selectinload(RecipeTask.subtasks))
|
||||||
|
)
|
||||||
|
tasks_map = {t.id: t for t in result.scalars().all()}
|
||||||
|
|
||||||
|
# Validate all ids found
|
||||||
|
for tid in data.task_ids:
|
||||||
|
if tid not in tasks_map:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task {tid} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate all belong to the same version and version is current
|
||||||
|
version_ids = {t.version_id for t in tasks_map.values()}
|
||||||
|
if len(version_ids) != 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="All tasks must belong to the same version",
|
||||||
|
)
|
||||||
|
await _ensure_version_is_current(db, version_ids.pop())
|
||||||
|
|
||||||
|
# Apply new order
|
||||||
|
for idx, tid in enumerate(data.task_ids):
|
||||||
|
tasks_map[tid].order_index = idx
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
ordered = [tasks_map[tid] for tid in data.task_ids]
|
||||||
|
return [TaskResponse.model_validate(t) for t in ordered]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Subtask endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/tasks/{task_id}/subtasks",
|
||||||
|
response_model=SubtaskResponse,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_subtask(
|
||||||
|
task_id: int,
|
||||||
|
data: SubtaskCreate,
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Add a subtask to a task. The task must belong to the current version.
|
||||||
|
|
||||||
|
Requires Maker role.
|
||||||
|
"""
|
||||||
|
task = await _get_task_or_404(db, task_id)
|
||||||
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
|
# Check marker_number uniqueness within the task
|
||||||
|
existing_markers = {s.marker_number for s in task.subtasks}
|
||||||
|
if data.marker_number in existing_markers:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Marker number {data.marker_number} already exists on this task",
|
||||||
|
)
|
||||||
|
|
||||||
|
subtask = RecipeSubtask(
|
||||||
|
task_id=task_id,
|
||||||
|
marker_number=data.marker_number,
|
||||||
|
description=data.description,
|
||||||
|
measurement_type=data.measurement_type,
|
||||||
|
nominal=data.nominal,
|
||||||
|
utl=data.utl,
|
||||||
|
uwl=data.uwl,
|
||||||
|
lwl=data.lwl,
|
||||||
|
ltl=data.ltl,
|
||||||
|
unit=data.unit,
|
||||||
|
)
|
||||||
|
db.add(subtask)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(subtask)
|
||||||
|
return SubtaskResponse.model_validate(subtask)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/subtasks/{subtask_id}", response_model=SubtaskResponse)
|
||||||
|
async def update_subtask(
|
||||||
|
subtask_id: int,
|
||||||
|
data: SubtaskUpdate,
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a subtask. Its parent task must belong to the current version.
|
||||||
|
|
||||||
|
Requires Maker role.
|
||||||
|
"""
|
||||||
|
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||||
|
|
||||||
|
# Validate task belongs to current version
|
||||||
|
task = await _get_task_or_404(db, subtask.task_id)
|
||||||
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
if not update_data:
|
||||||
|
return SubtaskResponse.model_validate(subtask)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(subtask, field, value)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(subtask)
|
||||||
|
return SubtaskResponse.model_validate(subtask)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
|
||||||
|
async def delete_subtask(
|
||||||
|
subtask_id: int,
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a subtask. Its parent task must belong to the current version.
|
||||||
|
|
||||||
|
Requires Maker role.
|
||||||
|
"""
|
||||||
|
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||||
|
|
||||||
|
# Validate task belongs to current version
|
||||||
|
task = await _get_task_or_404(db, subtask.task_id)
|
||||||
|
await _ensure_version_is_current(db, task.version_id)
|
||||||
|
|
||||||
|
await db.delete(subtask)
|
||||||
|
await db.flush()
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Users router - CRUD operations (admin only)."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from middleware.api_key import require_admin_user
|
||||||
|
from models.user import User
|
||||||
|
from schemas.user import UserCreate, UserResponse, UserUpdate
|
||||||
|
from services.auth_service import create_user, hash_password, regenerate_api_key
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserResponse])
|
||||||
|
async def list_users(
|
||||||
|
admin: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all users (admin only)."""
|
||||||
|
result = await db.execute(select(User).order_by(User.username))
|
||||||
|
users = result.scalars().all()
|
||||||
|
return [UserResponse.model_validate(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_new_user(
|
||||||
|
data: UserCreate,
|
||||||
|
admin: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new user (admin only)."""
|
||||||
|
# Check username uniqueness
|
||||||
|
existing = await db.execute(
|
||||||
|
select(User).where(User.username == data.username)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Username '{data.username}' already exists",
|
||||||
|
)
|
||||||
|
# Validate roles
|
||||||
|
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
||||||
|
for role in data.roles:
|
||||||
|
if role not in valid_roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
||||||
|
)
|
||||||
|
user = await create_user(
|
||||||
|
db,
|
||||||
|
username=data.username,
|
||||||
|
password=data.password,
|
||||||
|
display_name=data.display_name,
|
||||||
|
email=data.email,
|
||||||
|
roles=data.roles,
|
||||||
|
is_admin=data.is_admin,
|
||||||
|
language_pref=data.language_pref,
|
||||||
|
theme_pref=data.theme_pref,
|
||||||
|
)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=UserResponse)
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
data: UserUpdate,
|
||||||
|
admin: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update user (admin only)."""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
# Validate roles if provided
|
||||||
|
if "roles" in update_data:
|
||||||
|
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
||||||
|
for role in update_data["roles"]:
|
||||||
|
if role not in valid_roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
||||||
|
)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(user, field, value)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def deactivate_user(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Soft-delete user (set active=False)."""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot deactivate yourself",
|
||||||
|
)
|
||||||
|
user.active = False
|
||||||
|
user.api_key = None
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/regenerate-key")
|
||||||
|
async def regenerate_user_key(
|
||||||
|
user_id: int,
|
||||||
|
admin: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Regenerate API key for a user (admin only)."""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
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)
|
||||||
|
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Pydantic schemas for TieMeasureFlow API."""
|
||||||
|
from schemas.measurement import (
|
||||||
|
MeasurementBatchCreate,
|
||||||
|
MeasurementCreate,
|
||||||
|
MeasurementListResponse,
|
||||||
|
MeasurementQuery,
|
||||||
|
MeasurementResponse,
|
||||||
|
)
|
||||||
|
from schemas.recipe import (
|
||||||
|
RecipeCreate,
|
||||||
|
RecipeListResponse,
|
||||||
|
RecipeResponse,
|
||||||
|
RecipeUpdate,
|
||||||
|
RecipeVersionResponse,
|
||||||
|
)
|
||||||
|
from schemas.statistics import (
|
||||||
|
AlertData,
|
||||||
|
CapabilityData,
|
||||||
|
ControlChartData,
|
||||||
|
HistogramData,
|
||||||
|
StatisticsQuery,
|
||||||
|
StatisticsResponse,
|
||||||
|
SummaryData,
|
||||||
|
TrendData,
|
||||||
|
)
|
||||||
|
from schemas.task import (
|
||||||
|
SubtaskCreate,
|
||||||
|
SubtaskResponse,
|
||||||
|
SubtaskUpdate,
|
||||||
|
TaskCreate,
|
||||||
|
TaskReorderRequest,
|
||||||
|
TaskResponse,
|
||||||
|
TaskUpdate,
|
||||||
|
)
|
||||||
|
from schemas.user import (
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
UserCreate,
|
||||||
|
UserProfileUpdate,
|
||||||
|
UserResponse,
|
||||||
|
UserUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# User schemas
|
||||||
|
"UserCreate",
|
||||||
|
"UserUpdate",
|
||||||
|
"UserProfileUpdate",
|
||||||
|
"UserResponse",
|
||||||
|
"LoginRequest",
|
||||||
|
"LoginResponse",
|
||||||
|
# Recipe schemas
|
||||||
|
"RecipeCreate",
|
||||||
|
"RecipeUpdate",
|
||||||
|
"RecipeResponse",
|
||||||
|
"RecipeVersionResponse",
|
||||||
|
"RecipeListResponse",
|
||||||
|
# Task schemas
|
||||||
|
"TaskCreate",
|
||||||
|
"TaskUpdate",
|
||||||
|
"TaskResponse",
|
||||||
|
"TaskReorderRequest",
|
||||||
|
"SubtaskCreate",
|
||||||
|
"SubtaskUpdate",
|
||||||
|
"SubtaskResponse",
|
||||||
|
# Measurement schemas
|
||||||
|
"MeasurementCreate",
|
||||||
|
"MeasurementBatchCreate",
|
||||||
|
"MeasurementResponse",
|
||||||
|
"MeasurementListResponse",
|
||||||
|
"MeasurementQuery",
|
||||||
|
# Statistics schemas
|
||||||
|
"StatisticsQuery",
|
||||||
|
"ControlChartData",
|
||||||
|
"HistogramData",
|
||||||
|
"CapabilityData",
|
||||||
|
"TrendData",
|
||||||
|
"SummaryData",
|
||||||
|
"AlertData",
|
||||||
|
"StatisticsResponse",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Pydantic schemas for Measurement operations."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MeasurementCreate(BaseModel):
|
||||||
|
"""Schema for creating a single measurement."""
|
||||||
|
subtask_id: int
|
||||||
|
version_id: int
|
||||||
|
value: float
|
||||||
|
lot_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
serial_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||||
|
|
||||||
|
|
||||||
|
class MeasurementBatchCreate(BaseModel):
|
||||||
|
"""Schema for creating multiple measurements at once."""
|
||||||
|
measurements: list[MeasurementCreate]
|
||||||
|
|
||||||
|
|
||||||
|
class MeasurementResponse(BaseModel):
|
||||||
|
"""Schema for measurement response."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
subtask_id: int
|
||||||
|
version_id: int
|
||||||
|
measured_by: int
|
||||||
|
value: float
|
||||||
|
pass_fail: str
|
||||||
|
deviation: Optional[float] = None
|
||||||
|
lot_number: Optional[str] = None
|
||||||
|
serial_number: Optional[str] = None
|
||||||
|
input_method: str
|
||||||
|
measured_at: datetime
|
||||||
|
synced_to_csv: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MeasurementListResponse(BaseModel):
|
||||||
|
"""Schema for paginated measurement list."""
|
||||||
|
items: list[MeasurementResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class MeasurementQuery(BaseModel):
|
||||||
|
"""Schema for measurement query filters."""
|
||||||
|
recipe_id: Optional[int] = None
|
||||||
|
version_id: Optional[int] = None
|
||||||
|
subtask_id: Optional[int] = None
|
||||||
|
measured_by: Optional[int] = None
|
||||||
|
lot_number: Optional[str] = None
|
||||||
|
serial_number: Optional[str] = None
|
||||||
|
date_from: Optional[datetime] = None
|
||||||
|
date_to: Optional[datetime] = None
|
||||||
|
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||||
|
page: int = Field(1, ge=1)
|
||||||
|
per_page: int = Field(50, ge=1, le=500)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from schemas.task import TaskResponse
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeCreate(BaseModel):
|
||||||
|
"""Schema for creating a new recipe."""
|
||||||
|
code: str = Field(..., min_length=1, max_length=100)
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeUpdate(BaseModel):
|
||||||
|
"""Schema for updating a recipe (creates new version)."""
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
change_notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeVersionResponse(BaseModel):
|
||||||
|
"""Schema for recipe version response."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
recipe_id: int
|
||||||
|
version_number: int
|
||||||
|
is_current: bool
|
||||||
|
created_by: int
|
||||||
|
created_at: datetime
|
||||||
|
change_notes: Optional[str] = None
|
||||||
|
tasks: list["TaskResponse"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeResponse(BaseModel):
|
||||||
|
"""Schema for recipe response."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
created_by: int
|
||||||
|
created_at: datetime
|
||||||
|
active: bool
|
||||||
|
current_version: Optional[RecipeVersionResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeListResponse(BaseModel):
|
||||||
|
"""Schema for paginated recipe list."""
|
||||||
|
items: list[RecipeResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
# Forward reference imports for model_rebuild
|
||||||
|
from schemas.task import TaskResponse # noqa: E402
|
||||||
|
RecipeVersionResponse.model_rebuild()
|
||||||
|
RecipeResponse.model_rebuild()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Pydantic schemas for SPC statistics responses."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class StatisticsQuery(BaseModel):
|
||||||
|
"""Common query parameters for statistics endpoints."""
|
||||||
|
recipe_id: int
|
||||||
|
version_id: Optional[int] = None
|
||||||
|
subtask_id: Optional[int] = None
|
||||||
|
date_from: Optional[datetime] = None
|
||||||
|
date_to: Optional[datetime] = None
|
||||||
|
operator_id: Optional[int] = None
|
||||||
|
lot_number: Optional[str] = None
|
||||||
|
serial_number: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ControlChartData(BaseModel):
|
||||||
|
"""Data for X-bar/R control chart."""
|
||||||
|
values: list[float]
|
||||||
|
timestamps: list[datetime]
|
||||||
|
mean: float
|
||||||
|
ucl: float # Upper Control Limit
|
||||||
|
lcl: float # Lower Control Limit
|
||||||
|
utl: Optional[float] = None
|
||||||
|
uwl: Optional[float] = None
|
||||||
|
lwl: Optional[float] = None
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
nominal: Optional[float] = None
|
||||||
|
out_of_control: list[int] = [] # Indices of OOC points
|
||||||
|
|
||||||
|
|
||||||
|
class HistogramData(BaseModel):
|
||||||
|
"""Data for histogram with normal curve."""
|
||||||
|
bins: list[float]
|
||||||
|
counts: list[int]
|
||||||
|
normal_x: list[float]
|
||||||
|
normal_y: list[float]
|
||||||
|
mean: float
|
||||||
|
std_dev: float
|
||||||
|
n: int
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityData(BaseModel):
|
||||||
|
"""Capability indices."""
|
||||||
|
cp: Optional[float] = None
|
||||||
|
cpk: Optional[float] = None
|
||||||
|
pp: Optional[float] = None
|
||||||
|
ppk: Optional[float] = None
|
||||||
|
mean: float
|
||||||
|
std_dev: float
|
||||||
|
n: int
|
||||||
|
utl: Optional[float] = None
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
nominal: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TrendData(BaseModel):
|
||||||
|
"""Temporal trend data for capability indices."""
|
||||||
|
dates: list[datetime]
|
||||||
|
cpk_values: list[Optional[float]]
|
||||||
|
ppk_values: list[Optional[float]]
|
||||||
|
n_per_period: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryData(BaseModel):
|
||||||
|
"""Pass/fail/warning summary counts."""
|
||||||
|
total: int
|
||||||
|
pass_count: int
|
||||||
|
warning_count: int
|
||||||
|
fail_count: int
|
||||||
|
pass_rate: float
|
||||||
|
warning_rate: float
|
||||||
|
fail_rate: float
|
||||||
|
|
||||||
|
|
||||||
|
class AlertData(BaseModel):
|
||||||
|
"""Western Electric rules violations."""
|
||||||
|
rule: str
|
||||||
|
description: str
|
||||||
|
points: list[int] # Indices of violating points
|
||||||
|
severity: str = Field(..., pattern="^(info|warning|critical)$")
|
||||||
|
|
||||||
|
|
||||||
|
class StatisticsResponse(BaseModel):
|
||||||
|
"""Combined statistics response."""
|
||||||
|
summary: SummaryData
|
||||||
|
capability: Optional[CapabilityData] = None
|
||||||
|
control_chart: Optional[ControlChartData] = None
|
||||||
|
histogram: Optional[HistogramData] = None
|
||||||
|
trend: Optional[TrendData] = None
|
||||||
|
alerts: list[AlertData] = []
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SubtaskCreate(BaseModel):
|
||||||
|
"""Schema for creating a subtask."""
|
||||||
|
marker_number: int = Field(..., ge=1)
|
||||||
|
description: str = Field(..., min_length=1, max_length=500)
|
||||||
|
measurement_type: Optional[str] = Field(None, max_length=100)
|
||||||
|
nominal: Optional[float] = None
|
||||||
|
utl: Optional[float] = None
|
||||||
|
uwl: Optional[float] = None
|
||||||
|
lwl: Optional[float] = None
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
unit: str = Field("mm", max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class SubtaskUpdate(BaseModel):
|
||||||
|
"""Schema for updating a subtask."""
|
||||||
|
description: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||||
|
measurement_type: Optional[str] = Field(None, max_length=100)
|
||||||
|
nominal: Optional[float] = None
|
||||||
|
utl: Optional[float] = None
|
||||||
|
uwl: Optional[float] = None
|
||||||
|
lwl: Optional[float] = None
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
unit: Optional[str] = Field(None, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class SubtaskResponse(BaseModel):
|
||||||
|
"""Schema for subtask response."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
task_id: int
|
||||||
|
marker_number: int
|
||||||
|
description: str
|
||||||
|
measurement_type: Optional[str] = None
|
||||||
|
nominal: Optional[float] = None
|
||||||
|
utl: Optional[float] = None
|
||||||
|
uwl: Optional[float] = None
|
||||||
|
lwl: Optional[float] = None
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
unit: str
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreate(BaseModel):
|
||||||
|
"""Schema for creating a task."""
|
||||||
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
|
directive: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
subtasks: list[SubtaskCreate] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TaskUpdate(BaseModel):
|
||||||
|
"""Schema for updating a task."""
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
directive: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResponse(BaseModel):
|
||||||
|
"""Schema for task response."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
version_id: int
|
||||||
|
order_index: int
|
||||||
|
title: str
|
||||||
|
directive: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
file_type: Optional[str] = None
|
||||||
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
subtasks: list[SubtaskResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TaskReorderRequest(BaseModel):
|
||||||
|
"""Schema for reordering tasks."""
|
||||||
|
task_ids: list[int] = Field(..., min_length=1)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Pydantic schemas for User operations."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""Schema for creating a new user."""
|
||||||
|
username: str = Field(..., min_length=3, max_length=100)
|
||||||
|
password: str = Field(..., min_length=6, max_length=128)
|
||||||
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
display_name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
roles: list[str] = Field(default_factory=list)
|
||||||
|
is_admin: bool = False
|
||||||
|
language_pref: str = Field("it", pattern="^(it|en)$")
|
||||||
|
theme_pref: str = Field("light", pattern="^(light|dark)$")
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""Schema for updating a user (admin)."""
|
||||||
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
roles: Optional[list[str]] = None
|
||||||
|
is_admin: Optional[bool] = None
|
||||||
|
active: Optional[bool] = None
|
||||||
|
language_pref: Optional[str] = Field(None, pattern="^(it|en)$")
|
||||||
|
theme_pref: Optional[str] = Field(None, pattern="^(light|dark)$")
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileUpdate(BaseModel):
|
||||||
|
"""Schema for user self-update (profile)."""
|
||||||
|
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
language_pref: Optional[str] = Field(None, pattern="^(it|en)$")
|
||||||
|
theme_pref: Optional[str] = Field(None, pattern="^(light|dark)$")
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""Schema for user response."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: Optional[str] = None
|
||||||
|
display_name: str
|
||||||
|
roles: list[str]
|
||||||
|
is_admin: bool
|
||||||
|
language_pref: str
|
||||||
|
theme_pref: str
|
||||||
|
active: bool
|
||||||
|
created_at: datetime
|
||||||
|
last_login: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
"""Schema for login request."""
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
"""Schema for login response."""
|
||||||
|
user: UserResponse
|
||||||
|
api_key: str
|
||||||
|
message: str = "Login successful"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Authentication service - password hashing, API key management."""
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password using bcrypt."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a password against its hash."""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> str:
|
||||||
|
"""Generate a secure 64-character API key."""
|
||||||
|
return secrets.token_urlsafe(48) # 64 chars base64
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(
|
||||||
|
db: AsyncSession, username: str, password: str
|
||||||
|
) -> User | None:
|
||||||
|
"""Authenticate user by username and password."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.username == username, User.active == True)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None or not verify_password(password, user.password_hash):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def login_user(db: AsyncSession, user: User) -> str:
|
||||||
|
"""Generate API key and update last_login for user."""
|
||||||
|
api_key = generate_api_key()
|
||||||
|
await db.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == user.id)
|
||||||
|
.values(api_key=api_key, last_login=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
async def logout_user(db: AsyncSession, user: User) -> None:
|
||||||
|
"""Invalidate user's API key."""
|
||||||
|
await db.execute(
|
||||||
|
update(User).where(User.id == user.id).values(api_key=None)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
display_name: str,
|
||||||
|
email: str | None = None,
|
||||||
|
roles: list[str] | None = None,
|
||||||
|
is_admin: bool = False,
|
||||||
|
language_pref: str = "it",
|
||||||
|
theme_pref: str = "light",
|
||||||
|
) -> User:
|
||||||
|
"""Create a new user with hashed password."""
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
display_name=display_name,
|
||||||
|
email=email,
|
||||||
|
roles=roles or [],
|
||||||
|
is_admin=is_admin,
|
||||||
|
language_pref=language_pref,
|
||||||
|
theme_pref=theme_pref,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(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
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Measurement service - pass/fail calculation, data storage."""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models.measurement import Measurement
|
||||||
|
from models.task import RecipeSubtask
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_pass_fail(
|
||||||
|
value: float, subtask: RecipeSubtask
|
||||||
|
) -> tuple[str, float | None]:
|
||||||
|
"""Calculate pass/fail status and deviation based on tolerances.
|
||||||
|
|
||||||
|
Returns (status, deviation) where status is 'pass', 'warning', or 'fail'.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
- If value is outside UTL/LTL -> 'fail'
|
||||||
|
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
||||||
|
- Otherwise -> 'pass'
|
||||||
|
"""
|
||||||
|
deviation = None
|
||||||
|
if subtask.nominal is not None:
|
||||||
|
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
||||||
|
|
||||||
|
# Check fail (outside tolerance limits)
|
||||||
|
if subtask.utl is not None and value > float(subtask.utl):
|
||||||
|
return "fail", deviation
|
||||||
|
if subtask.ltl is not None and value < float(subtask.ltl):
|
||||||
|
return "fail", deviation
|
||||||
|
|
||||||
|
# Check warning (outside warning limits)
|
||||||
|
if subtask.uwl is not None and value > float(subtask.uwl):
|
||||||
|
return "warning", deviation
|
||||||
|
if subtask.lwl is not None and value < float(subtask.lwl):
|
||||||
|
return "warning", deviation
|
||||||
|
|
||||||
|
return "pass", deviation
|
||||||
|
|
||||||
|
|
||||||
|
async def save_measurement(
|
||||||
|
db: AsyncSession,
|
||||||
|
subtask_id: int,
|
||||||
|
version_id: int,
|
||||||
|
measured_by: int,
|
||||||
|
value: float,
|
||||||
|
lot_number: str | None = None,
|
||||||
|
serial_number: str | None = None,
|
||||||
|
input_method: str = "manual",
|
||||||
|
) -> Measurement:
|
||||||
|
"""Save a single measurement with auto-calculated pass/fail."""
|
||||||
|
# Get subtask for tolerance values
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||||
|
)
|
||||||
|
subtask = result.scalar_one_or_none()
|
||||||
|
if subtask is None:
|
||||||
|
raise ValueError(f"Subtask {subtask_id} not found")
|
||||||
|
|
||||||
|
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||||
|
|
||||||
|
measurement = Measurement(
|
||||||
|
subtask_id=subtask_id,
|
||||||
|
version_id=version_id,
|
||||||
|
measured_by=measured_by,
|
||||||
|
value=value,
|
||||||
|
pass_fail=pass_fail,
|
||||||
|
deviation=deviation,
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
input_method=input_method,
|
||||||
|
)
|
||||||
|
db.add(measurement)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(measurement)
|
||||||
|
return measurement
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""Recipe service - business logic for copy-on-write versioning."""
|
||||||
|
import math
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from models.measurement import Measurement
|
||||||
|
from models.recipe import Recipe, RecipeVersion
|
||||||
|
from models.setting import RecipeVersionAudit
|
||||||
|
from models.task import RecipeSubtask, RecipeTask
|
||||||
|
from models.user import User
|
||||||
|
from schemas.recipe import RecipeCreate, RecipeUpdate
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||||
|
"""Return a recipe or raise 404."""
|
||||||
|
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||||
|
recipe = result.scalar_one_or_none()
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_current_version(
|
||||||
|
db: AsyncSession, recipe_id: int
|
||||||
|
) -> Optional[RecipeVersion]:
|
||||||
|
"""Return the current version for a recipe (with tasks/subtasks loaded)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeVersion)
|
||||||
|
.where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _copy_tasks_to_version(
|
||||||
|
db: AsyncSession,
|
||||||
|
source_version: RecipeVersion,
|
||||||
|
target_version: RecipeVersion,
|
||||||
|
) -> None:
|
||||||
|
"""Deep-copy all tasks and subtasks from *source* to *target* version."""
|
||||||
|
for task in source_version.tasks:
|
||||||
|
new_task = RecipeTask(
|
||||||
|
version_id=target_version.id,
|
||||||
|
order_index=task.order_index,
|
||||||
|
title=task.title,
|
||||||
|
directive=task.directive,
|
||||||
|
description=task.description,
|
||||||
|
file_path=task.file_path,
|
||||||
|
file_type=task.file_type,
|
||||||
|
annotations_json=task.annotations_json,
|
||||||
|
)
|
||||||
|
db.add(new_task)
|
||||||
|
await db.flush() # get new_task.id
|
||||||
|
|
||||||
|
for sub in task.subtasks:
|
||||||
|
new_sub = RecipeSubtask(
|
||||||
|
task_id=new_task.id,
|
||||||
|
marker_number=sub.marker_number,
|
||||||
|
description=sub.description,
|
||||||
|
measurement_type=sub.measurement_type,
|
||||||
|
nominal=sub.nominal,
|
||||||
|
utl=sub.utl,
|
||||||
|
uwl=sub.uwl,
|
||||||
|
lwl=sub.lwl,
|
||||||
|
ltl=sub.ltl,
|
||||||
|
unit=sub.unit,
|
||||||
|
)
|
||||||
|
db.add(new_sub)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_audit(
|
||||||
|
db: AsyncSession,
|
||||||
|
recipe_id: int,
|
||||||
|
old_version_id: Optional[int],
|
||||||
|
new_version_id: int,
|
||||||
|
changed_by: int,
|
||||||
|
change_type: str,
|
||||||
|
change_reason: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist an audit record for a version change."""
|
||||||
|
audit = RecipeVersionAudit(
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
old_version_id=old_version_id,
|
||||||
|
new_version_id=new_version_id,
|
||||||
|
changed_by=changed_by,
|
||||||
|
change_type=change_type,
|
||||||
|
change_reason=change_reason,
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_recipe(
|
||||||
|
db: AsyncSession,
|
||||||
|
data: RecipeCreate,
|
||||||
|
user: User,
|
||||||
|
) -> Recipe:
|
||||||
|
"""Create a recipe together with its first version (v1).
|
||||||
|
|
||||||
|
Returns the newly created Recipe with the current version loaded.
|
||||||
|
"""
|
||||||
|
# Check code uniqueness
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Recipe).where(Recipe.code == data.code)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Recipe code '{data.code}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe = Recipe(
|
||||||
|
code=data.code,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
created_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(recipe)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
version = RecipeVersion(
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
version_number=1,
|
||||||
|
is_current=True,
|
||||||
|
created_by=user.id,
|
||||||
|
change_notes="Initial version",
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
await _write_audit(
|
||||||
|
db,
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
old_version_id=None,
|
||||||
|
new_version_id=version.id,
|
||||||
|
changed_by=user.id,
|
||||||
|
change_type="CREATE",
|
||||||
|
change_reason="Recipe created",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh so relationships are loaded
|
||||||
|
await db.refresh(recipe, attribute_names=["versions"])
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def create_new_version(
|
||||||
|
db: AsyncSession,
|
||||||
|
recipe_id: int,
|
||||||
|
data: RecipeUpdate,
|
||||||
|
user: User,
|
||||||
|
) -> RecipeVersion:
|
||||||
|
"""Copy-on-write: create a new version by cloning current tasks/subtasks.
|
||||||
|
|
||||||
|
1. Fetch current version (with tasks + subtasks)
|
||||||
|
2. Create a new RecipeVersion with incremented version_number
|
||||||
|
3. Deep-copy all tasks + subtasks into the new version
|
||||||
|
4. Apply updates from *data* to the recipe header
|
||||||
|
5. Flip is_current: old -> False, new -> True
|
||||||
|
6. Write audit trail
|
||||||
|
"""
|
||||||
|
recipe = await _get_recipe_or_404(db, recipe_id)
|
||||||
|
if not recipe.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cannot update an inactive recipe",
|
||||||
|
)
|
||||||
|
|
||||||
|
current = await _get_current_version(db, recipe_id)
|
||||||
|
if current is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No current version found for this recipe",
|
||||||
|
)
|
||||||
|
|
||||||
|
old_version_id = current.id
|
||||||
|
new_version_number = current.version_number + 1
|
||||||
|
|
||||||
|
# Mark old version as non-current
|
||||||
|
await db.execute(
|
||||||
|
update(RecipeVersion)
|
||||||
|
.where(RecipeVersion.id == current.id)
|
||||||
|
.values(is_current=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new version
|
||||||
|
new_version = RecipeVersion(
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
version_number=new_version_number,
|
||||||
|
is_current=True,
|
||||||
|
created_by=user.id,
|
||||||
|
change_notes=data.change_notes,
|
||||||
|
)
|
||||||
|
db.add(new_version)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Deep-copy tasks + subtasks
|
||||||
|
await _copy_tasks_to_version(db, source_version=current, target_version=new_version)
|
||||||
|
|
||||||
|
# Apply header updates
|
||||||
|
update_fields: dict = {}
|
||||||
|
if data.name is not None:
|
||||||
|
update_fields["name"] = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
update_fields["description"] = data.description
|
||||||
|
if update_fields:
|
||||||
|
await db.execute(
|
||||||
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
await _write_audit(
|
||||||
|
db,
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
old_version_id=old_version_id,
|
||||||
|
new_version_id=new_version.id,
|
||||||
|
changed_by=user.id,
|
||||||
|
change_type="UPDATE",
|
||||||
|
change_reason=data.change_notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Reload the version with tasks for the response
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeVersion)
|
||||||
|
.where(RecipeVersion.id == new_version.id)
|
||||||
|
.options(
|
||||||
|
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_version(
|
||||||
|
db: AsyncSession, recipe_id: int
|
||||||
|
) -> RecipeVersion:
|
||||||
|
"""Return the current version with tasks and subtasks.
|
||||||
|
|
||||||
|
Raises 404 if the recipe or its current version cannot be found.
|
||||||
|
"""
|
||||||
|
await _get_recipe_or_404(db, recipe_id)
|
||||||
|
version = await _get_current_version(db, recipe_id)
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No current version found for this recipe",
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
async def get_measurement_count(
|
||||||
|
db: AsyncSession, recipe_id: int, version_number: int
|
||||||
|
) -> int:
|
||||||
|
"""Return the number of measurements recorded against a specific version."""
|
||||||
|
# Resolve version_id from recipe_id + version_number
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeVersion.id).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.version_number == version_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version_id = result.scalar_one_or_none()
|
||||||
|
if version_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Version {version_number} not found for recipe {recipe_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count()).select_from(Measurement).where(
|
||||||
|
Measurement.version_id == version_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return count_result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_recipes(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 20,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return a paginated list of active recipes.
|
||||||
|
|
||||||
|
Each recipe includes its current version (with tasks).
|
||||||
|
"""
|
||||||
|
base_filter = [Recipe.active == True] # noqa: E712
|
||||||
|
if search:
|
||||||
|
like_pattern = f"%{search}%"
|
||||||
|
base_filter.append(
|
||||||
|
(Recipe.name.ilike(like_pattern)) | (Recipe.code.ilike(like_pattern))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total count
|
||||||
|
count_stmt = select(func.count()).select_from(Recipe).where(*base_filter)
|
||||||
|
total = (await db.execute(count_stmt)).scalar_one()
|
||||||
|
|
||||||
|
pages = max(1, math.ceil(total / per_page))
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
# Fetch recipes
|
||||||
|
stmt = (
|
||||||
|
select(Recipe)
|
||||||
|
.where(*base_filter)
|
||||||
|
.order_by(Recipe.name.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
recipes = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": recipes,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"pages": pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_recipe_detail(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||||
|
"""Return a single recipe with its current version + tasks loaded."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Recipe)
|
||||||
|
.where(Recipe.id == recipe_id)
|
||||||
|
.options(
|
||||||
|
selectinload(Recipe.versions)
|
||||||
|
.selectinload(RecipeVersion.tasks)
|
||||||
|
.selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recipe = result.scalar_one_or_none()
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found"
|
||||||
|
)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def get_recipe_by_code(db: AsyncSession, code: str) -> Recipe:
|
||||||
|
"""Return a recipe by its barcode/code, with current version loaded."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Recipe)
|
||||||
|
.where(Recipe.code == code, Recipe.active == True) # noqa: E712
|
||||||
|
.options(
|
||||||
|
selectinload(Recipe.versions)
|
||||||
|
.selectinload(RecipeVersion.tasks)
|
||||||
|
.selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recipe = result.scalar_one_or_none()
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No active recipe found with code '{code}'",
|
||||||
|
)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def deactivate_recipe(
|
||||||
|
db: AsyncSession, recipe_id: int, user: User
|
||||||
|
) -> Recipe:
|
||||||
|
"""Soft-delete a recipe by setting active=False."""
|
||||||
|
recipe = await _get_recipe_or_404(db, recipe_id)
|
||||||
|
if not recipe.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Recipe is already inactive",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(Recipe).where(Recipe.id == recipe_id).values(active=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
current = await _get_current_version(db, recipe_id)
|
||||||
|
if current is not None:
|
||||||
|
await _write_audit(
|
||||||
|
db,
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
old_version_id=current.id,
|
||||||
|
new_version_id=current.id,
|
||||||
|
changed_by=user.id,
|
||||||
|
change_type="RETIRE",
|
||||||
|
change_reason="Recipe deactivated",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(recipe)
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def list_versions(
|
||||||
|
db: AsyncSession, recipe_id: int
|
||||||
|
) -> list[RecipeVersion]:
|
||||||
|
"""Return all versions of a recipe, ordered by version_number desc."""
|
||||||
|
await _get_recipe_or_404(db, recipe_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeVersion)
|
||||||
|
.where(RecipeVersion.recipe_id == recipe_id)
|
||||||
|
.order_by(RecipeVersion.version_number.desc())
|
||||||
|
.options(
|
||||||
|
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_version_detail(
|
||||||
|
db: AsyncSession, recipe_id: int, version_number: int
|
||||||
|
) -> RecipeVersion:
|
||||||
|
"""Return a specific version of a recipe with tasks loaded."""
|
||||||
|
await _get_recipe_or_404(db, recipe_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeVersion)
|
||||||
|
.where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.version_number == version_number,
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version = result.scalar_one_or_none()
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Version {version_number} not found for recipe {recipe_id}",
|
||||||
|
)
|
||||||
|
return version
|
||||||
Reference in New Issue
Block a user