Files
TieMeasureFlow/src/backend/api/routers/settings.py
T
Adriano ea9da34899 fix(settings): valida csv_delimiter/decimal_separator/auto_logout_minutes (#23)
PUT /api/settings/ non validava nulla: un csv_delimiter vuoto o di piu'
caratteri arrivava intatto nel DB e faceva fallire csv.writer con TypeError
dentro close_run (500, run mai chiuso). Aggiunta validazione per chiave nota
(csv_delimiter 1 carattere fra , ; TAB |, csv_decimal_separator ',' o '.',
auto_logout_minutes vuoto o intero 1-480, company_name/company_logo_path
non vuoti) -> 422 sui valori fuori dominio. Chiavi non note passano senza
validazione (store generico).

Difesa in profondita' in production_export_service._csv_format: un
csv_delimiter gia' sporco nel DB (scritto prima di questa validazione) non
fa piu' fallire close_run, usa ';' e logga un WARNING.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:29:35 +02:00

192 lines
6.2 KiB
Python

"""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 src.backend.config import settings
from src.backend.database import get_db
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/settings", tags=["settings"])
# Domains for the settings keys the backend actually reads. Values outside these
# would only fail later (csv.writer TypeError on close_run, etc) — reject up front.
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
def _validate_setting_value(key: str, value: str) -> None:
"""Raise 422 for a value that would break the code reading this key.
Unrecognised keys pass through unvalidated: the settings store is generic.
"""
if key == "csv_delimiter":
if value not in _VALID_CSV_DELIMITERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="csv_delimiter deve essere un carattere singolo fra ',' ';' TAB '|'",
)
elif key == "csv_decimal_separator":
if value not in _VALID_CSV_DECIMAL_SEPARATORS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="csv_decimal_separator deve essere ',' o '.'",
)
elif key == "auto_logout_minutes":
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 480)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
)
elif key in ("company_name", "company_logo_path"):
if not value.strip() or len(value) > 200:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{key} deve essere una stringa non vuota di massimo 200 caratteri",
)
@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():
_validate_setting_value(key, value)
# 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,
}