feat: FASE 1 - Backend Core (modelli, auth, API)
Implementazione completa del backend FastAPI: - Modelli SQLAlchemy: User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, AccessLog, SystemSetting, RecipeVersionAudit - Schemas Pydantic v2 per tutti i CRUD + statistiche SPC - Middleware: API Key auth (X-API-Key) con role checking + access logging - Router: auth, users, recipes, tasks, measurements, files, settings - Services: auth (bcrypt+secrets), recipe (copy-on-write versioning), measurement (auto pass/fail con UTL/UWL/LWL/LTL) - Alembic env.py con import modelli attivi - Fix architect review: no double-commit, recipe_id subquery filter, user_id in access logs, type annotations corrette Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""Authentication router - login, logout, profile."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user
|
||||
from models.user import User
|
||||
from schemas.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
UserProfileUpdate,
|
||||
UserResponse,
|
||||
)
|
||||
from services.auth_service import authenticate_user, login_user, logout_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Login with username and password, receive API key."""
|
||||
user = await authenticate_user(db, data.username, data.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
api_key = await login_user(db, user)
|
||||
return LoginResponse(
|
||||
user=UserResponse.model_validate(user),
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Logout - invalidate API key."""
|
||||
await logout_user(db, user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_profile(user: User = Depends(get_current_user)):
|
||||
"""Get current user profile."""
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_profile(
|
||||
data: UserProfileUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update own profile (display_name, language, theme)."""
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return UserResponse.model_validate(user)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""File upload/download/delete router for images and PDFs."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
|
||||
from config import settings
|
||||
from middleware.api_key import get_current_user, require_maker
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
# Allowed MIME types
|
||||
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
ALLOWED_PDF_TYPE = "application/pdf"
|
||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
||||
|
||||
|
||||
def validate_file_type(content_type: str) -> bool:
|
||||
"""Validate if file type is allowed."""
|
||||
return content_type in ALLOWED_TYPES
|
||||
|
||||
|
||||
def validate_file_size(file_size: int) -> bool:
|
||||
"""Validate if file size is within limits."""
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
return file_size <= max_bytes
|
||||
|
||||
|
||||
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
|
||||
"""Generate a thumbnail for an image."""
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
img.save(thumbnail_path, quality=85)
|
||||
except Exception as e:
|
||||
# If thumbnail generation fails, we continue without it
|
||||
print(f"Thumbnail generation failed: {e}")
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
recipe_id: int | None = None,
|
||||
version_id: int | None = None,
|
||||
user: User = Depends(require_maker),
|
||||
):
|
||||
"""Upload an image or PDF file.
|
||||
|
||||
Files are stored in uploads/{recipe_id}/{version_id}/
|
||||
Thumbnails are generated for images.
|
||||
"""
|
||||
# Validate file type
|
||||
if not validate_file_type(file.content_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
if not validate_file_size(file_size):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
|
||||
# Determine storage path
|
||||
if recipe_id is not None and version_id is not None:
|
||||
storage_dir = settings.upload_path / str(recipe_id) / str(version_id)
|
||||
else:
|
||||
storage_dir = settings.upload_path / "general"
|
||||
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate unique filename if file already exists
|
||||
file_path = storage_dir / file.filename
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
name_parts = file.filename.rsplit(".", 1)
|
||||
if len(name_parts) == 2:
|
||||
file_path = storage_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
|
||||
else:
|
||||
file_path = storage_dir / f"{file.filename}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# Write file
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Generate thumbnail for images
|
||||
thumbnail_path = None
|
||||
if file.content_type in ALLOWED_IMAGE_TYPES:
|
||||
thumbnail_dir = storage_dir / "thumbnails"
|
||||
thumbnail_dir.mkdir(exist_ok=True)
|
||||
thumbnail_path = thumbnail_dir / file_path.name
|
||||
generate_thumbnail(file_path, thumbnail_path)
|
||||
|
||||
# Return relative path from uploads directory
|
||||
relative_path = file_path.relative_to(settings.upload_path)
|
||||
thumbnail_relative = (
|
||||
thumbnail_path.relative_to(settings.upload_path)
|
||||
if thumbnail_path
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"file_path": str(relative_path).replace("\\", "/"),
|
||||
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
||||
"file_type": file.content_type,
|
||||
"file_size": file_size,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{file_path:path}")
|
||||
async def get_file(
|
||||
file_path: str,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Serve a file from the uploads directory.
|
||||
|
||||
Requires authentication but no specific role.
|
||||
"""
|
||||
# Construct full path
|
||||
full_path = settings.upload_path / file_path
|
||||
|
||||
# Security: ensure path is within uploads directory
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied",
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
return FileResponse(full_path)
|
||||
|
||||
|
||||
@router.delete("/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_file(
|
||||
file_path: str,
|
||||
user: User = Depends(require_maker),
|
||||
):
|
||||
"""Delete a file from the uploads directory.
|
||||
|
||||
Also deletes associated thumbnail if it exists.
|
||||
"""
|
||||
# Construct full path
|
||||
full_path = settings.upload_path / file_path
|
||||
|
||||
# Security: ensure path is within uploads directory
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied",
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
# Delete thumbnail if it exists
|
||||
if full_path.parent.name != "thumbnails":
|
||||
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
||||
if thumbnail_path.exists():
|
||||
thumbnail_path.unlink()
|
||||
|
||||
# Delete file
|
||||
full_path.unlink()
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Measurements router - create, query, export measurements."""
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import (
|
||||
get_current_user,
|
||||
require_measurement_tec,
|
||||
require_metrologist,
|
||||
)
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import RecipeVersion
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
from schemas.measurement import (
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
MeasurementListResponse,
|
||||
MeasurementResponse,
|
||||
)
|
||||
from services.measurement_service import save_measurement
|
||||
|
||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||
|
||||
|
||||
@router.post("/", response_model=MeasurementResponse)
|
||||
async def create_measurement(
|
||||
data: MeasurementCreate,
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a single measurement with auto-calculated pass/fail."""
|
||||
try:
|
||||
measurement = await save_measurement(
|
||||
db=db,
|
||||
subtask_id=data.subtask_id,
|
||||
version_id=data.version_id,
|
||||
measured_by=user.id,
|
||||
value=data.value,
|
||||
lot_number=data.lot_number,
|
||||
serial_number=data.serial_number,
|
||||
input_method=data.input_method,
|
||||
)
|
||||
return MeasurementResponse.model_validate(measurement)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=list[MeasurementResponse])
|
||||
async def create_measurement_batch(
|
||||
data: MeasurementBatchCreate,
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create multiple measurements in a batch."""
|
||||
measurements = []
|
||||
try:
|
||||
for measurement_data in data.measurements:
|
||||
measurement = await save_measurement(
|
||||
db=db,
|
||||
subtask_id=measurement_data.subtask_id,
|
||||
version_id=measurement_data.version_id,
|
||||
measured_by=user.id,
|
||||
value=measurement_data.value,
|
||||
lot_number=measurement_data.lot_number,
|
||||
serial_number=measurement_data.serial_number,
|
||||
input_method=measurement_data.input_method,
|
||||
)
|
||||
measurements.append(measurement)
|
||||
return [MeasurementResponse.model_validate(m) for m in measurements]
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=MeasurementListResponse)
|
||||
async def get_measurements(
|
||||
recipe_id: int | None = Query(None),
|
||||
version_id: int | None = Query(None),
|
||||
subtask_id: int | None = Query(None),
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=500),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Query measurements with filters and pagination."""
|
||||
# Build filter conditions
|
||||
filters = []
|
||||
if recipe_id is not None:
|
||||
# Filter by recipe via subquery on RecipeVersion
|
||||
version_ids = select(RecipeVersion.id).where(
|
||||
RecipeVersion.recipe_id == recipe_id
|
||||
)
|
||||
filters.append(Measurement.version_id.in_(version_ids))
|
||||
if version_id is not None:
|
||||
filters.append(Measurement.version_id == version_id)
|
||||
if subtask_id is not None:
|
||||
filters.append(Measurement.subtask_id == subtask_id)
|
||||
if measured_by is not None:
|
||||
filters.append(Measurement.measured_by == measured_by)
|
||||
if lot_number is not None:
|
||||
filters.append(Measurement.lot_number == lot_number)
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
if date_from is not None:
|
||||
filters.append(Measurement.measured_at >= date_from)
|
||||
if date_to is not None:
|
||||
filters.append(Measurement.measured_at <= date_to)
|
||||
if pass_fail is not None:
|
||||
filters.append(Measurement.pass_fail == pass_fail)
|
||||
|
||||
# Build query
|
||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count()).select_from(Measurement).where(
|
||||
and_(*filters) if filters else True
|
||||
)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(Measurement.measured_at.desc()).limit(per_page).offset(offset)
|
||||
|
||||
result = await db.execute(query)
|
||||
measurements = result.scalars().all()
|
||||
|
||||
pages = (total + per_page - 1) // per_page
|
||||
|
||||
return MeasurementListResponse(
|
||||
items=[MeasurementResponse.model_validate(m) for m in measurements],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/csv")
|
||||
async def export_measurements_csv(
|
||||
recipe_id: int | None = Query(None),
|
||||
version_id: int | None = Query(None),
|
||||
subtask_id: int | None = Query(None),
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Export measurements to CSV with configurable delimiter and decimal separator."""
|
||||
# Check user has MeasurementTec or Metrologist role
|
||||
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="MeasurementTec or Metrologist role required",
|
||||
)
|
||||
|
||||
# Get CSV settings from system_settings
|
||||
delimiter_result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
|
||||
)
|
||||
delimiter_setting = delimiter_result.scalar_one_or_none()
|
||||
delimiter = delimiter_setting.setting_value if delimiter_setting else ","
|
||||
|
||||
decimal_result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == "csv_decimal_separator")
|
||||
)
|
||||
decimal_setting = decimal_result.scalar_one_or_none()
|
||||
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
||||
|
||||
# Build filter conditions (same as get_measurements)
|
||||
filters = []
|
||||
if recipe_id is not None:
|
||||
version_ids = select(RecipeVersion.id).where(
|
||||
RecipeVersion.recipe_id == recipe_id
|
||||
)
|
||||
filters.append(Measurement.version_id.in_(version_ids))
|
||||
if version_id is not None:
|
||||
filters.append(Measurement.version_id == version_id)
|
||||
if subtask_id is not None:
|
||||
filters.append(Measurement.subtask_id == subtask_id)
|
||||
if measured_by is not None:
|
||||
filters.append(Measurement.measured_by == measured_by)
|
||||
if lot_number is not None:
|
||||
filters.append(Measurement.lot_number == lot_number)
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
if date_from is not None:
|
||||
filters.append(Measurement.measured_at >= date_from)
|
||||
if date_to is not None:
|
||||
filters.append(Measurement.measured_at <= date_to)
|
||||
if pass_fail is not None:
|
||||
filters.append(Measurement.pass_fail == pass_fail)
|
||||
|
||||
# Query all matching measurements
|
||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||
query = query.order_by(Measurement.measured_at.desc())
|
||||
result = await db.execute(query)
|
||||
measurements = result.scalars().all()
|
||||
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, delimiter=delimiter)
|
||||
|
||||
# Header
|
||||
writer.writerow([
|
||||
"id",
|
||||
"subtask_id",
|
||||
"version_id",
|
||||
"measured_by",
|
||||
"value",
|
||||
"pass_fail",
|
||||
"deviation",
|
||||
"lot_number",
|
||||
"serial_number",
|
||||
"input_method",
|
||||
"measured_at",
|
||||
"synced_to_csv",
|
||||
])
|
||||
|
||||
# Rows
|
||||
for m in measurements:
|
||||
# Format decimals with configured separator
|
||||
value_str = str(m.value).replace(".", decimal_separator)
|
||||
deviation_str = (
|
||||
str(m.deviation).replace(".", decimal_separator)
|
||||
if m.deviation is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
writer.writerow([
|
||||
m.id,
|
||||
m.subtask_id,
|
||||
m.version_id,
|
||||
m.measured_by,
|
||||
value_str,
|
||||
m.pass_fail,
|
||||
deviation_str,
|
||||
m.lot_number or "",
|
||||
m.serial_number or "",
|
||||
m.input_method,
|
||||
m.measured_at.isoformat(),
|
||||
m.synced_to_csv,
|
||||
])
|
||||
|
||||
# Return as streaming response
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Recipe router - CRUD, versioning, barcode lookup."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||
from models.user import User
|
||||
from schemas.recipe import (
|
||||
RecipeCreate,
|
||||
RecipeListResponse,
|
||||
RecipeResponse,
|
||||
RecipeUpdate,
|
||||
RecipeVersionResponse,
|
||||
)
|
||||
from services import recipe_service
|
||||
|
||||
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: build RecipeResponse with current_version attached
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _recipe_to_response(recipe) -> RecipeResponse:
|
||||
"""Convert a Recipe ORM object to RecipeResponse, injecting current_version."""
|
||||
current = None
|
||||
for v in (recipe.versions or []):
|
||||
if v.is_current:
|
||||
current = v
|
||||
break
|
||||
|
||||
resp = RecipeResponse.model_validate(recipe)
|
||||
if current is not None:
|
||||
resp.current_version = RecipeVersionResponse.model_validate(current)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recipes CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("", response_model=RecipeResponse, status_code=201)
|
||||
async def create_recipe(
|
||||
data: RecipeCreate,
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new recipe with its initial version (v1). Requires Maker role."""
|
||||
recipe = await recipe_service.create_recipe(db, data, user)
|
||||
return _recipe_to_response(recipe)
|
||||
|
||||
|
||||
@router.get("", response_model=RecipeListResponse)
|
||||
async def list_recipes(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None, max_length=200),
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List active recipes with pagination and optional search. All authenticated users."""
|
||||
result = await recipe_service.list_recipes(db, page=page, per_page=per_page, search=search)
|
||||
result["items"] = [_recipe_to_response(r) for r in result["items"]]
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/code/{code}", response_model=RecipeResponse)
|
||||
async def get_recipe_by_code(
|
||||
code: str,
|
||||
_user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Look up a recipe by its barcode/code. Requires MeasurementTec role."""
|
||||
recipe = await recipe_service.get_recipe_by_code(db, code)
|
||||
return _recipe_to_response(recipe)
|
||||
|
||||
|
||||
@router.get("/{recipe_id}", response_model=RecipeResponse)
|
||||
async def get_recipe(
|
||||
recipe_id: int,
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get recipe detail with current version and all tasks/subtasks."""
|
||||
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
|
||||
return _recipe_to_response(recipe)
|
||||
|
||||
|
||||
@router.put("/{recipe_id}", response_model=RecipeResponse)
|
||||
async def update_recipe(
|
||||
recipe_id: int,
|
||||
data: RecipeUpdate,
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a recipe - creates a new version via copy-on-write. Requires Maker role."""
|
||||
new_version = await recipe_service.create_new_version(db, recipe_id, data, user)
|
||||
|
||||
# Reload full recipe for consistent response
|
||||
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
|
||||
return _recipe_to_response(recipe)
|
||||
|
||||
|
||||
@router.delete("/{recipe_id}", response_model=RecipeResponse)
|
||||
async def delete_recipe(
|
||||
recipe_id: int,
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Deactivate a recipe (soft delete). Requires Maker role."""
|
||||
recipe = await recipe_service.deactivate_recipe(db, recipe_id, user)
|
||||
return RecipeResponse.model_validate(recipe)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Versions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{recipe_id}/versions", response_model=list[RecipeVersionResponse])
|
||||
async def list_versions(
|
||||
recipe_id: int,
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all versions of a recipe. Requires Maker or Metrologist role."""
|
||||
versions = await recipe_service.list_versions(db, recipe_id)
|
||||
return [RecipeVersionResponse.model_validate(v) for v in versions]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{recipe_id}/versions/{version_number}",
|
||||
response_model=RecipeVersionResponse,
|
||||
)
|
||||
async def get_version(
|
||||
recipe_id: int,
|
||||
version_number: int,
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a specific version with its tasks. Requires Maker or Metrologist role."""
|
||||
version = await recipe_service.get_version_detail(db, recipe_id, version_number)
|
||||
return RecipeVersionResponse.model_validate(version)
|
||||
|
||||
|
||||
@router.get("/{recipe_id}/versions/{version_number}/measurement-count")
|
||||
async def get_measurement_count(
|
||||
recipe_id: int,
|
||||
version_number: int,
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Count measurements on a specific version. Requires Maker role.
|
||||
|
||||
Useful to warn before creating a new version if the current one has measurements.
|
||||
"""
|
||||
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
||||
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
||||
@@ -0,0 +1,153 @@
|
||||
"""System settings router - read/update settings, upload company logo."""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_admin_user
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_settings(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all system settings as a dictionary.
|
||||
|
||||
Returns key-value pairs of all settings.
|
||||
Available to all authenticated users.
|
||||
"""
|
||||
result = await db.execute(select(SystemSetting))
|
||||
settings_list = result.scalars().all()
|
||||
|
||||
settings_dict = {
|
||||
setting.setting_key: setting.setting_value
|
||||
for setting in settings_list
|
||||
}
|
||||
|
||||
return settings_dict
|
||||
|
||||
|
||||
@router.put("/")
|
||||
async def update_settings(
|
||||
settings_data: dict[str, str],
|
||||
user: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update multiple system settings.
|
||||
|
||||
Expects a JSON object with setting_key: setting_value pairs.
|
||||
Only admins can update settings.
|
||||
"""
|
||||
updated_keys = []
|
||||
|
||||
for key, value in settings_data.items():
|
||||
# Check if setting exists
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
# Create new setting (assume type 'string' for new settings)
|
||||
setting = SystemSetting(
|
||||
setting_key=key,
|
||||
setting_value=value,
|
||||
setting_type="string",
|
||||
updated_by=user.id,
|
||||
)
|
||||
db.add(setting)
|
||||
else:
|
||||
# Update existing setting
|
||||
setting.setting_value = value
|
||||
setting.updated_by = user.id
|
||||
|
||||
updated_keys.append(key)
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"message": f"Updated {len(updated_keys)} settings",
|
||||
"updated_keys": updated_keys,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logo")
|
||||
async def upload_company_logo(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload company logo.
|
||||
|
||||
Saves logo to uploads/logos/ and updates company_logo_path setting.
|
||||
Only admins can upload the logo.
|
||||
"""
|
||||
# Validate file type (must be image)
|
||||
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||
if file.content_type not in allowed_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {file.content_type} not allowed. Must be an image.",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
if file_size > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
|
||||
# Create logos directory
|
||||
logos_dir = settings.upload_path / "logos"
|
||||
logos_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Use a fixed filename for the company logo
|
||||
file_extension = Path(file.filename).suffix
|
||||
logo_filename = f"company_logo{file_extension}"
|
||||
logo_path = logos_dir / logo_filename
|
||||
|
||||
# Write file
|
||||
logo_path.write_bytes(content)
|
||||
|
||||
# Update setting in database
|
||||
relative_path = logo_path.relative_to(settings.upload_path)
|
||||
logo_path_str = str(relative_path).replace("\\", "/")
|
||||
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == "company_logo_path")
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = SystemSetting(
|
||||
setting_key="company_logo_path",
|
||||
setting_value=logo_path_str,
|
||||
setting_type="string",
|
||||
description="Path to company logo file",
|
||||
updated_by=user.id,
|
||||
)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.setting_value = logo_path_str
|
||||
setting.updated_by = user.id
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"message": "Company logo uploaded successfully",
|
||||
"logo_path": logo_path_str,
|
||||
"file_size": file_size,
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Task and Subtask router - CRUD, reorder, copy-on-write integration."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_maker, get_current_user
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from schemas.task import (
|
||||
SubtaskCreate,
|
||||
SubtaskResponse,
|
||||
SubtaskUpdate,
|
||||
TaskCreate,
|
||||
TaskReorderRequest,
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
)
|
||||
from services import recipe_service
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _get_current_version_for_recipe(
|
||||
db: AsyncSession, recipe_id: int
|
||||
) -> RecipeVersion:
|
||||
"""Return the current version of a recipe or raise 404."""
|
||||
result = await db.execute(
|
||||
select(RecipeVersion)
|
||||
.where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
.options(
|
||||
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
version = result.scalar_one_or_none()
|
||||
if version is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No current version found for this recipe",
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
async def _get_task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
||||
"""Return a task with its subtasks or raise 404."""
|
||||
result = await db.execute(
|
||||
select(RecipeTask)
|
||||
.where(RecipeTask.id == task_id)
|
||||
.options(selectinload(RecipeTask.subtasks))
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
async def _get_subtask_or_404(db: AsyncSession, subtask_id: int) -> RecipeSubtask:
|
||||
"""Return a subtask or raise 404."""
|
||||
result = await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||
)
|
||||
subtask = result.scalar_one_or_none()
|
||||
if subtask is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Subtask not found"
|
||||
)
|
||||
return subtask
|
||||
|
||||
|
||||
async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> RecipeVersion:
|
||||
"""Verify a version is the current one (editable). Raise 409 otherwise."""
|
||||
result = await db.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||
)
|
||||
version = result.scalar_one_or_none()
|
||||
if version is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Version not found"
|
||||
)
|
||||
if not version.is_current:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot modify a non-current version. Create a new version first.",
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/recipes/{recipe_id}/tasks", response_model=list[TaskResponse])
|
||||
async def list_tasks(
|
||||
recipe_id: int,
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all tasks for the current version of a recipe. All authenticated users."""
|
||||
version = await _get_current_version_for_recipe(db, recipe_id)
|
||||
tasks = sorted(version.tasks, key=lambda t: t.order_index)
|
||||
return [TaskResponse.model_validate(t) for t in tasks]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/recipes/{recipe_id}/tasks",
|
||||
response_model=TaskResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_task(
|
||||
recipe_id: int,
|
||||
data: TaskCreate,
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a task to the current version of a recipe.
|
||||
|
||||
This creates a NEW version via copy-on-write, then appends the task to
|
||||
the new version. Requires Maker role.
|
||||
"""
|
||||
# Ensure recipe exists and is active
|
||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
recipe = result.scalar_one_or_none()
|
||||
if recipe is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
|
||||
if not recipe.active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot modify an inactive recipe",
|
||||
)
|
||||
|
||||
# Copy-on-write: create new version with existing tasks cloned
|
||||
from schemas.recipe import RecipeUpdate
|
||||
new_version = await recipe_service.create_new_version(
|
||||
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
||||
)
|
||||
|
||||
# Determine order_index (append at end)
|
||||
max_order = max((t.order_index for t in new_version.tasks), default=-1)
|
||||
new_task = RecipeTask(
|
||||
version_id=new_version.id,
|
||||
order_index=max_order + 1,
|
||||
title=data.title,
|
||||
directive=data.directive,
|
||||
description=data.description,
|
||||
file_type=data.file_type,
|
||||
annotations_json=data.annotations_json,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush()
|
||||
|
||||
# Create subtasks if provided
|
||||
for sub_data in data.subtasks:
|
||||
sub = RecipeSubtask(
|
||||
task_id=new_task.id,
|
||||
marker_number=sub_data.marker_number,
|
||||
description=sub_data.description,
|
||||
measurement_type=sub_data.measurement_type,
|
||||
nominal=sub_data.nominal,
|
||||
utl=sub_data.utl,
|
||||
uwl=sub_data.uwl,
|
||||
lwl=sub_data.lwl,
|
||||
ltl=sub_data.ltl,
|
||||
unit=sub_data.unit,
|
||||
)
|
||||
db.add(sub)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_task, attribute_names=["subtasks"])
|
||||
return TaskResponse.model_validate(new_task)
|
||||
|
||||
|
||||
@router.put("/api/tasks/{task_id}", response_model=TaskResponse)
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
data: TaskUpdate,
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a task. The task must belong to the current version. Requires Maker role."""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task, attribute_names=["subtasks"])
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
@router.delete("/api/tasks/{task_id}", status_code=204)
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a task and its subtasks. The task must belong to the current version.
|
||||
|
||||
Requires Maker role.
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
await db.delete(task)
|
||||
await db.flush()
|
||||
|
||||
|
||||
@router.put("/api/tasks/reorder", response_model=list[TaskResponse])
|
||||
async def reorder_tasks(
|
||||
data: TaskReorderRequest,
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reorder tasks by providing ordered task IDs. All tasks must belong to the
|
||||
same current version. Requires Maker role.
|
||||
"""
|
||||
if not data.task_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="task_ids must not be empty",
|
||||
)
|
||||
|
||||
# Fetch all tasks
|
||||
result = await db.execute(
|
||||
select(RecipeTask)
|
||||
.where(RecipeTask.id.in_(data.task_ids))
|
||||
.options(selectinload(RecipeTask.subtasks))
|
||||
)
|
||||
tasks_map = {t.id: t for t in result.scalars().all()}
|
||||
|
||||
# Validate all ids found
|
||||
for tid in data.task_ids:
|
||||
if tid not in tasks_map:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task {tid} not found",
|
||||
)
|
||||
|
||||
# Validate all belong to the same version and version is current
|
||||
version_ids = {t.version_id for t in tasks_map.values()}
|
||||
if len(version_ids) != 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="All tasks must belong to the same version",
|
||||
)
|
||||
await _ensure_version_is_current(db, version_ids.pop())
|
||||
|
||||
# Apply new order
|
||||
for idx, tid in enumerate(data.task_ids):
|
||||
tasks_map[tid].order_index = idx
|
||||
|
||||
await db.flush()
|
||||
|
||||
ordered = [tasks_map[tid] for tid in data.task_ids]
|
||||
return [TaskResponse.model_validate(t) for t in ordered]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subtask endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post(
|
||||
"/api/tasks/{task_id}/subtasks",
|
||||
response_model=SubtaskResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_subtask(
|
||||
task_id: int,
|
||||
data: SubtaskCreate,
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a subtask to a task. The task must belong to the current version.
|
||||
|
||||
Requires Maker role.
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
# Check marker_number uniqueness within the task
|
||||
existing_markers = {s.marker_number for s in task.subtasks}
|
||||
if data.marker_number in existing_markers:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Marker number {data.marker_number} already exists on this task",
|
||||
)
|
||||
|
||||
subtask = RecipeSubtask(
|
||||
task_id=task_id,
|
||||
marker_number=data.marker_number,
|
||||
description=data.description,
|
||||
measurement_type=data.measurement_type,
|
||||
nominal=data.nominal,
|
||||
utl=data.utl,
|
||||
uwl=data.uwl,
|
||||
lwl=data.lwl,
|
||||
ltl=data.ltl,
|
||||
unit=data.unit,
|
||||
)
|
||||
db.add(subtask)
|
||||
await db.flush()
|
||||
await db.refresh(subtask)
|
||||
return SubtaskResponse.model_validate(subtask)
|
||||
|
||||
|
||||
@router.put("/api/subtasks/{subtask_id}", response_model=SubtaskResponse)
|
||||
async def update_subtask(
|
||||
subtask_id: int,
|
||||
data: SubtaskUpdate,
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a subtask. Its parent task must belong to the current version.
|
||||
|
||||
Requires Maker role.
|
||||
"""
|
||||
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||
|
||||
# Validate task belongs to current version
|
||||
task = await _get_task_or_404(db, subtask.task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
return SubtaskResponse.model_validate(subtask)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(subtask, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(subtask)
|
||||
return SubtaskResponse.model_validate(subtask)
|
||||
|
||||
|
||||
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
|
||||
async def delete_subtask(
|
||||
subtask_id: int,
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a subtask. Its parent task must belong to the current version.
|
||||
|
||||
Requires Maker role.
|
||||
"""
|
||||
subtask = await _get_subtask_or_404(db, subtask_id)
|
||||
|
||||
# Validate task belongs to current version
|
||||
task = await _get_task_or_404(db, subtask.task_id)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
await db.delete(subtask)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Users router - CRUD operations (admin only)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_admin_user
|
||||
from models.user import User
|
||||
from schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
from services.auth_service import create_user, hash_password, regenerate_api_key
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all users (admin only)."""
|
||||
result = await db.execute(select(User).order_by(User.username))
|
||||
users = result.scalars().all()
|
||||
return [UserResponse.model_validate(u) for u in users]
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_user(
|
||||
data: UserCreate,
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new user (admin only)."""
|
||||
# Check username uniqueness
|
||||
existing = await db.execute(
|
||||
select(User).where(User.username == data.username)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Username '{data.username}' already exists",
|
||||
)
|
||||
# Validate roles
|
||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
||||
for role in data.roles:
|
||||
if role not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
||||
)
|
||||
user = await create_user(
|
||||
db,
|
||||
username=data.username,
|
||||
password=data.password,
|
||||
display_name=data.display_name,
|
||||
email=data.email,
|
||||
roles=data.roles,
|
||||
is_admin=data.is_admin,
|
||||
language_pref=data.language_pref,
|
||||
theme_pref=data.theme_pref,
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
data: UserUpdate,
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update user (admin only)."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
# Validate roles if provided
|
||||
if "roles" in update_data:
|
||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
||||
for role in update_data["roles"]:
|
||||
if role not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
||||
)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def deactivate_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Soft-delete user (set active=False)."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot deactivate yourself",
|
||||
)
|
||||
user.active = False
|
||||
user.api_key = None
|
||||
await db.flush()
|
||||
|
||||
|
||||
@router.post("/{user_id}/regenerate-key")
|
||||
async def regenerate_user_key(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Regenerate API key for a user (admin only)."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
new_key = await regenerate_api_key(db, user_id)
|
||||
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
||||
Reference in New Issue
Block a user