update sync data from site dashboard and testing closing cycles
This commit is contained in:
1 parent
36639d8c38
commit
f49e72592c
42 files changed
+1966
-688
No files matched your search
@@ -0,0 +1,198 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user