1a0431366f
Aligns the repo with the python-project-spec-design.md template chosen for V2.0.0. Big move, no logic changes. The 3 pre-existing test failures (test_recipes::test_update_recipe, test_recipes:: test_recipe_versioning, test_tasks::test_reorder_tasks, plus the client test_save_measurement_proxy) survive unchanged. Layout changes - server/ -> src/backend/ - server/middleware/ -> src/backend/api/middleware/ - server/routers/ -> src/backend/api/routers/ - server/models/ -> src/backend/models/orm/ - server/schemas/ -> src/backend/models/api/ - server/uploads/ -> uploads/ (project root, mounted volume) - server/tests/ -> src/backend/tests/ - client/ -> src/frontend/flask_app/ (Flask kept; React deroga is documented in CLAUDE.md, justified by tablet UX, USB caliper/barcode workflow and Fabric.js integration) Tooling - pyproject.toml: monorepo with [project] core deps and optional-dependencies server / client / dev. Replaces both server/requirements.txt and client/requirements.txt. - uv.lock + .python-version (3.11) committed for reproducible builds. - Dockerfile (root, backend) and Dockerfile.frontend rewritten to use uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles preserved as Dockerfile.legacy for reference but excluded from build context via .dockerignore. - docker-compose.dev.yml + docker-compose.yml: build context now ".", dockerfile pointing to the root files. Code adjustments forced by the move - Every "from config|database|models|schemas|services|routers|middleware import ..." rewritten to its src.backend.* equivalent (50+ files including indented inline imports inside test bodies). - src/backend/migrations/env.py: insert project root into sys.path so alembic can resolve src.backend.* imports regardless of cwd. - src/backend/config.py: env_file ../../.env (was ../.env), upload_path resolves project root via parents[2]. - src/backend/tests/conftest.py + tests: import ... from src.backend.* instead of bare names; old per-directory pytest.ini files removed in favor of root pyproject.toml [tool.pytest.ini_options]. - .gitignore: uploads/ at root, src/frontend/flask_app/static/css/ tailwind.css path; .dockerignore tightened. - CLAUDE.md: rewrote sections "Layout del repository", "Comandi di Sviluppo", "Database & Migrations", "Test", "i18n", and all path references throughout the architecture sections. Verified - uv lock resolves 77 packages; uv sync --extra server --extra client --extra dev installs cleanly. - uv run pytest: 171 passed, 4 pre-existing failures. - uv run alembic -c src/backend/migrations/alembic.ini check loads config and metadata (errors only on the absent local MySQL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 lines
9.5 KiB
Python
276 lines
9.5 KiB
Python
"""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 src.backend.database import get_db
|
|
from src.backend.api.middleware.api_key import (
|
|
get_current_user,
|
|
require_measurement_tec,
|
|
require_metrologist,
|
|
)
|
|
from src.backend.models.orm.measurement import Measurement
|
|
from src.backend.models.orm.recipe import RecipeVersion
|
|
from src.backend.models.orm.setting import SystemSetting
|
|
from src.backend.models.orm.user import User
|
|
from src.backend.models.api.measurement import (
|
|
MeasurementBatchCreate,
|
|
MeasurementCreate,
|
|
MeasurementListResponse,
|
|
MeasurementResponse,
|
|
)
|
|
from src.backend.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"
|
|
},
|
|
)
|