Files
MobilityOps/backend/app/api/routers/workflows.py
T
NuklearRabbitandClaude Sonnet 5 6deb95524d fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
  used identically by the data-quality scanner, a new non-mutating status-recommendation
  preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
  revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
  "maintenance + active booking -> auto rented" shortcut. Frontend
  DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
  why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
  <status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
  after the status-conflict issue now converges on the same final vehicle status,
  proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
  backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
  appeared in locale prose; add a permanent test guarding against a translation file
  ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
  reasons, audit field/actor-type labels, automation last_error, and search
  section/vehicle/booking/issue results all now carry codes the frontend localizes,
  with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
  decision table documenting the evaluator's rules and safe-status principles.

148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:37:34 +02:00

78 lines
2.5 KiB
Python

from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.errors import AppError
from app.models.outbox import OutboxEvent
from app.schemas import AutomationRunOut, CurrentUser
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
def _to_out(event: OutboxEvent) -> AutomationRunOut:
return AutomationRunOut(
event_id=str(event.event_id),
event_type=event.event_type,
aggregate_ref=event.payload_json.get("aggregate_ref", ""),
status=event.delivery_status,
attempts=event.attempts,
last_error=event.last_error,
last_error_code=event.last_error_code,
occurred_at=event.occurred_at,
)
@router.get("", response_model=list[AutomationRunOut])
def list_workflows(
status: str | None = Query(default=None),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
) -> list[AutomationRunOut]:
stmt = select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc())
if status:
stmt = stmt.where(OutboxEvent.delivery_status == status)
events = db.scalars(stmt).all()
return [_to_out(e) for e in events]
@router.post("/{event_id}/retry", response_model=AutomationRunOut)
def retry_workflow(
event_id: str,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> AutomationRunOut:
try:
parsed_id = uuid.UUID(event_id)
except ValueError as exc:
raise AppError("INVALID_EVENT_ID", "event_id must be a UUID.", status_code=422) from exc
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == parsed_id))
if event is None:
raise AppError("EVENT_NOT_FOUND", "Workflow event not found.", status_code=404)
if event.delivery_status != "failed":
raise AppError(
"NOT_RETRYABLE",
f"Event is '{event.delivery_status}', not 'failed'; "
"only failed deliveries can be retried.",
status_code=409,
)
event.delivery_status = "pending"
event.next_attempt_at = None
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="workflow_retry",
entity_type="outbox_event",
metadata={"event_id": event_id, "previous_attempts": event.attempts},
)
db.commit()
return _to_out(event)