Files
cyclone/backend/src/cyclone/api_routers/acks.py
T
Tyler 1267a341e3 refactor(api): trailing newlines + hoist acks.py parser imports to top-level
Self-review nits from the router-split commit:
- All four new files lacked a trailing newline (PEP 8 / POSIX).
- acks.py was lazily importing ParseResult999 / serialize_999 inside
  get_ack_endpoint. Hoist to module-level — there's no import cycle
  (acks.py does not import from cyclone.api), so the imports are safe
  to do once.

No behavior change. Targeted tests (test_acks + test_health + test_api_gets)
still pass 41/41.
2026-06-21 00:50:31 -06:00

105 lines
3.5 KiB
Python

"""``/api/acks`` — list & detail endpoints for the 999 ACK inbox.
These are the persisted acknowledgment rows produced by
``POST /api/parse-999``. The frontend ``useAcks`` hook re-shapes the
list payload to its ``Ack`` interface in ``src/types/index.ts``.
The detail endpoint returns the full ``raw_json`` payload plus the
regenerated ``raw_999_text`` so the UI can show "view source" without a
second round-trip.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
from cyclone.parsers.models_999 import ParseResult999
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.store import store
router = APIRouter()
log = logging.getLogger(__name__)
def _ack_to_ui(row) -> dict:
"""Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
Field names match the rest of the Cyclone API (snake_case). The
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
interface in ``src/types/index.ts``.
"""
return {
"id": row.id,
"source_batch_id": row.source_batch_id,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"received_count": row.received_count,
"ack_code": row.ack_code,
"parsed_at": (
row.parsed_at.isoformat().replace("+00:00", "Z")
if row.parsed_at is not None
else ""
),
}
@router.get("/api/acks")
def list_acks_endpoint(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> Any:
"""Return the list of persisted 999 ACKs, newest first."""
rows = store.list_acks()
items = [_ack_to_ui(r) for r in rows[:limit]]
total = len(rows)
returned = len(items)
has_more = total > returned
if wants_ndjson(request):
return StreamingResponse(
ndjson_stream_list(items, total, returned, has_more),
media_type="application/x-ndjson",
)
return {
"items": items,
"total": total,
"returned": returned,
"has_more": has_more,
}
@router.get("/api/acks/{ack_id}")
def get_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted ACK row with its parsed detail.
Path param is ``ack_id`` (not ``id``) to avoid shadowing FastAPI's
internal ``id`` name and to keep OpenAPI docs self-describing.
Returns 404 when the ACK is missing — never 500.
"""
row = store.get_ack(ack_id)
if row is None:
raise HTTPException(
status_code=404,
detail={"error": "Not found", "detail": f"Ack {ack_id} not found"},
)
body = _ack_to_ui(row)
body["raw_json"] = row.raw_json
# Regenerate the X12 text from raw_json so the operator can download
# the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry
# the regenerated text to keep payloads small; detail does.)
if row.raw_json:
try:
regenerated = ParseResult999.model_validate(row.raw_json)
icn = regenerated.envelope.control_number or "000000001"
body["raw_999_text"] = serialize_999(regenerated, interchange_control_number=icn)
except Exception as exc: # noqa: BLE001 — never 500 on a regen failure
log.warning("Could not regenerate 999 for ack %s: %s", ack_id, exc)
body["raw_999_text"] = None
else:
body["raw_999_text"] = None
return body