199 lines
6.7 KiB
Python
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
|