1267a341e3
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.
105 lines
3.5 KiB
Python
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
|