"""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