From d6508e0ae8b517560fb07ab4ff8581bba52a05a1 Mon Sep 17 00:00:00 2001 From: Adriano Date: Sat, 7 Feb 2026 00:40:50 +0100 Subject: [PATCH] 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 --- server/main.py | 21 ++ server/middleware/__init__.py | 22 ++ server/middleware/api_key.py | 71 ++++ server/middleware/logging.py | 54 +++ server/migrations/env.py | 14 +- server/models/__init__.py | 19 ++ server/models/access_log.py | 32 ++ server/models/measurement.py | 55 +++ server/models/recipe.py | 64 ++++ server/models/setting.py | 60 ++++ server/models/task.py | 76 +++++ server/models/user.py | 48 +++ server/routers/auth.py | 62 ++++ server/routers/files.py | 195 +++++++++++ server/routers/measurements.py | 275 +++++++++++++++ server/routers/recipes.py | 157 +++++++++ server/routers/settings.py | 153 +++++++++ server/routers/tasks.py | 367 ++++++++++++++++++++ server/routers/users.py | 127 +++++++ server/schemas/__init__.py | 81 +++++ server/schemas/measurement.py | 62 ++++ server/schemas/recipe.py | 65 ++++ server/schemas/statistics.py | 94 ++++++ server/schemas/task.py | 85 +++++ server/schemas/user.py | 65 ++++ server/services/auth_service.py | 97 ++++++ server/services/measurement_service.py | 77 +++++ server/services/recipe_service.py | 451 +++++++++++++++++++++++++ 28 files changed, 2942 insertions(+), 7 deletions(-) create mode 100644 server/middleware/api_key.py create mode 100644 server/middleware/logging.py create mode 100644 server/models/access_log.py create mode 100644 server/models/measurement.py create mode 100644 server/models/recipe.py create mode 100644 server/models/setting.py create mode 100644 server/models/task.py create mode 100644 server/models/user.py create mode 100644 server/routers/auth.py create mode 100644 server/routers/files.py create mode 100644 server/routers/measurements.py create mode 100644 server/routers/recipes.py create mode 100644 server/routers/settings.py create mode 100644 server/routers/tasks.py create mode 100644 server/routers/users.py create mode 100644 server/schemas/measurement.py create mode 100644 server/schemas/recipe.py create mode 100644 server/schemas/statistics.py create mode 100644 server/schemas/task.py create mode 100644 server/schemas/user.py create mode 100644 server/services/auth_service.py create mode 100644 server/services/measurement_service.py create mode 100644 server/services/recipe_service.py diff --git a/server/main.py b/server/main.py index 7f7e452..afa2b92 100644 --- a/server/main.py +++ b/server/main.py @@ -7,6 +7,14 @@ from fastapi.middleware.cors import CORSMiddleware from config import settings 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 @@ -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") async def health_check() -> dict: """Health check endpoint.""" diff --git a/server/middleware/__init__.py b/server/middleware/__init__.py index e69de29..db92801 100644 --- a/server/middleware/__init__.py +++ b/server/middleware/__init__.py @@ -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", +] diff --git a/server/middleware/api_key.py b/server/middleware/api_key.py new file mode 100644 index 0000000..0d52045 --- /dev/null +++ b/server/middleware/api_key.py @@ -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() diff --git a/server/middleware/logging.py b/server/middleware/logging.py new file mode 100644 index 0000000..89a369f --- /dev/null +++ b/server/middleware/logging.py @@ -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 diff --git a/server/migrations/env.py b/server/migrations/env.py index 97e944f..df5c76e 100644 --- a/server/migrations/env.py +++ b/server/migrations/env.py @@ -24,13 +24,13 @@ from database import Base # Override alembic.ini URL with .env settings (keep in sync) config.set_main_option("sqlalchemy.url", settings.database_url) -# Import all models here so they register with Base.metadata -# 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 +# Import all models so they register with Base.metadata +from models.user import User # noqa: F401 +from models.recipe import Recipe, RecipeVersion # noqa: F401 +from models.task import RecipeTask, RecipeSubtask # noqa: F401 +from models.measurement import Measurement # noqa: F401 +from models.access_log import AccessLog # noqa: F401 +from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401 target_metadata = Base.metadata diff --git a/server/models/__init__.py b/server/models/__init__.py index e69de29..65a1133 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -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", +] diff --git a/server/models/access_log.py b/server/models/access_log.py new file mode 100644 index 0000000..f61e9e5 --- /dev/null +++ b/server/models/access_log.py @@ -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"" diff --git a/server/models/measurement.py b/server/models/measurement.py new file mode 100644 index 0000000..a5fba93 --- /dev/null +++ b/server/models/measurement.py @@ -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"" diff --git a/server/models/recipe.py b/server/models/recipe.py new file mode 100644 index 0000000..ce1bbe5 --- /dev/null +++ b/server/models/recipe.py @@ -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"" + + +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"" diff --git a/server/models/setting.py b/server/models/setting.py new file mode 100644 index 0000000..f81119e --- /dev/null +++ b/server/models/setting.py @@ -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"" + + +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"" diff --git a/server/models/task.py b/server/models/task.py new file mode 100644 index 0000000..74cc7b6 --- /dev/null +++ b/server/models/task.py @@ -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"" + + +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"" diff --git a/server/models/user.py b/server/models/user.py new file mode 100644 index 0000000..e086991 --- /dev/null +++ b/server/models/user.py @@ -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"" diff --git a/server/routers/auth.py b/server/routers/auth.py new file mode 100644 index 0000000..4ed0bd4 --- /dev/null +++ b/server/routers/auth.py @@ -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) diff --git a/server/routers/files.py b/server/routers/files.py new file mode 100644 index 0000000..4b5bbf1 --- /dev/null +++ b/server/routers/files.py @@ -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() diff --git a/server/routers/measurements.py b/server/routers/measurements.py new file mode 100644 index 0000000..09eb36f --- /dev/null +++ b/server/routers/measurements.py @@ -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" + }, + ) diff --git a/server/routers/recipes.py b/server/routers/recipes.py new file mode 100644 index 0000000..060e254 --- /dev/null +++ b/server/routers/recipes.py @@ -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} diff --git a/server/routers/settings.py b/server/routers/settings.py new file mode 100644 index 0000000..e0066fd --- /dev/null +++ b/server/routers/settings.py @@ -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, + } diff --git a/server/routers/tasks.py b/server/routers/tasks.py new file mode 100644 index 0000000..8f98477 --- /dev/null +++ b/server/routers/tasks.py @@ -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() diff --git a/server/routers/users.py b/server/routers/users.py new file mode 100644 index 0000000..a93b392 --- /dev/null +++ b/server/routers/users.py @@ -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}"} diff --git a/server/schemas/__init__.py b/server/schemas/__init__.py index e69de29..4d4430c 100644 --- a/server/schemas/__init__.py +++ b/server/schemas/__init__.py @@ -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", +] diff --git a/server/schemas/measurement.py b/server/schemas/measurement.py new file mode 100644 index 0000000..7a45a2a --- /dev/null +++ b/server/schemas/measurement.py @@ -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) diff --git a/server/schemas/recipe.py b/server/schemas/recipe.py new file mode 100644 index 0000000..e696cb6 --- /dev/null +++ b/server/schemas/recipe.py @@ -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() diff --git a/server/schemas/statistics.py b/server/schemas/statistics.py new file mode 100644 index 0000000..9bef81a --- /dev/null +++ b/server/schemas/statistics.py @@ -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] = [] diff --git a/server/schemas/task.py b/server/schemas/task.py new file mode 100644 index 0000000..4235299 --- /dev/null +++ b/server/schemas/task.py @@ -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) diff --git a/server/schemas/user.py b/server/schemas/user.py new file mode 100644 index 0000000..6fcff20 --- /dev/null +++ b/server/schemas/user.py @@ -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" diff --git a/server/services/auth_service.py b/server/services/auth_service.py new file mode 100644 index 0000000..df96b13 --- /dev/null +++ b/server/services/auth_service.py @@ -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 diff --git a/server/services/measurement_service.py b/server/services/measurement_service.py new file mode 100644 index 0000000..a854f7f --- /dev/null +++ b/server/services/measurement_service.py @@ -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 diff --git a/server/services/recipe_service.py b/server/services/recipe_service.py new file mode 100644 index 0000000..35898ed --- /dev/null +++ b/server/services/recipe_service.py @@ -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