Files
dashboard-cpsp-executive/backend/apps/sync/services/farm_export_client.py
T

199 lines
6.7 KiB
Python

"""HTTP client for farm-location pusat export APIs."""
from __future__ import annotations
from typing import Any, Callable, Iterator
from urllib.parse import urljoin
import httpx
from apps.sync.models import ActiveSite
DEFAULT_PAGE_SIZE = 500
DEFAULT_TIMEOUT = 60.0
OPS_SERIES_KEYS = (
"kpis",
"manual_inputs",
"feed_sacks",
"chicken_countings",
"chicken_weights",
"ai_insights",
)
class FarmExportError(Exception):
def __init__(self, message: str, *, status_code: int | None = None):
self.message = message
self.status_code = status_code
super().__init__(message)
class FarmExportClient:
def __init__(
self,
active_site: ActiveSite,
*,
timeout: float = DEFAULT_TIMEOUT,
transport: httpx.BaseTransport | None = None,
):
base = (active_site.api_base_url or "").strip().rstrip("/") + "/"
if not base or base == "/":
raise FarmExportError("ActiveSite.api_base_url is required.")
if not (active_site.api_key or "").strip():
raise FarmExportError("ActiveSite.api_key is required.")
self._base = base
self._headers = {
"X-API-Key": active_site.api_key.strip(),
"Accept": "application/json",
}
self._timeout = timeout
self._transport = transport
def _url(self, path: str) -> str:
return urljoin(self._base, path.lstrip("/"))
def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
cleaned = {
key: value
for key, value in (params or {}).items()
if value is not None and value != ""
}
try:
with httpx.Client(
timeout=self._timeout, transport=self._transport
) as client:
response = client.get(
self._url(path), headers=self._headers, params=cleaned
)
except httpx.HTTPError as exc:
raise FarmExportError(f"Network error calling farm export: {exc}") from exc
if response.status_code == 401:
raise FarmExportError("Farm rejected API key (401).", status_code=401)
if response.status_code == 403:
raise FarmExportError("Farm denied access (403).", status_code=403)
if response.status_code >= 400:
detail = (response.text or "")[:300]
raise FarmExportError(
f"Farm export failed ({response.status_code}): {detail}",
status_code=response.status_code,
)
try:
payload = response.json()
except ValueError as exc:
raise FarmExportError("Farm export returned non-JSON body.") from exc
if not isinstance(payload, dict):
raise FarmExportError("Farm export JSON must be an object.")
return payload
def fetch_ops_export(self, **params: Any) -> dict[str, Any]:
return self._get("pusat/export/", params)
def fetch_iot_export(self, **params: Any) -> dict[str, Any]:
return self._get("pusat/export/iot/", params)
def iter_paginated_list(
self,
fetch: Callable[..., dict[str, Any]],
*,
list_key: str,
page_size: int = DEFAULT_PAGE_SIZE,
**params: Any,
) -> Iterator[list[dict[str, Any]]]:
"""Yield result pages until page * page_size >= count for list_key."""
page = 1
while True:
payload = fetch(page=page, page_size=page_size, **params)
data = payload.get("data") or {}
block = data.get(list_key) or {}
results = block.get("results") or []
if not isinstance(results, list):
raise FarmExportError(f"data.{list_key}.results must be a list.")
yield [row for row in results if isinstance(row, dict)]
count = int(block.get("count") or 0)
size = int(block.get("page_size") or page_size)
current = int(block.get("page") or page)
if size <= 0:
break
if current * size >= count or not results:
break
page = current + 1
def collect_ops_series(
self,
*,
site_id: str,
cycle_id: int | None = None,
date_from: str | None = None,
date_to: str | None = None,
page_size: int = DEFAULT_PAGE_SIZE,
) -> dict[str, list[dict[str, Any]]]:
"""Fetch all pages for every ops series key (shared page cursor)."""
collected = {key: [] for key in OPS_SERIES_KEYS}
page = 1
while True:
params: dict[str, Any] = {
"site_id": site_id,
"page": page,
"page_size": page_size,
}
if cycle_id is not None:
params["cycle_id"] = cycle_id
if date_from is not None:
params["from"] = date_from
if date_to is not None:
params["to"] = date_to
payload = self.fetch_ops_export(**params)
data = payload.get("data") or {}
any_more = False
for key in OPS_SERIES_KEYS:
block = data.get(key) or {}
results = block.get("results") or []
if isinstance(results, list):
collected[key].extend(
row for row in results if isinstance(row, dict)
)
count = int(block.get("count") or 0)
size = int(block.get("page_size") or page_size)
current = int(block.get("page") or page)
if size > 0 and current * size < count and results:
any_more = True
if not any_more:
break
page += 1
return collected
def collect_iot_export(
self,
*,
site_id: str,
date_from: str,
date_to: str,
page_size: int = DEFAULT_PAGE_SIZE,
flock_id: int | None = None,
kandang_id: int | None = None,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Return structure envelope (page 1) plus all iot_panels rows."""
params: dict[str, Any] = {
"site_id": site_id,
"from": date_from,
"to": date_to,
}
if flock_id is not None:
params["flock_id"] = flock_id
if kandang_id is not None:
params["kandang_id"] = kandang_id
first = self.fetch_iot_export(page=1, page_size=page_size, **params)
panels: list[dict[str, Any]] = []
for page_rows in self.iter_paginated_list(
self.fetch_iot_export,
list_key="iot_panels",
page_size=page_size,
**params,
):
panels.extend(page_rows)
return first, panels