update api export for executive and fixing closing cycle flow

This commit is contained in:
Alberto-Audrix committed 2026-09-15 16:53:25 +07:00
1 parent d0d1ae7d24
commit 07d36081ce
23 files changed
+1378 -101

No files matched your search

+394
View File
@@ -0,0 +1,394 @@
"""Pusat pull exports: ops (cycle metrics) and IoT (flock panels)."""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
from django.db.models import QuerySet
from django.utils import timezone
from django.utils.dateparse import parse_date
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
from apps.operations.serializers import (
AIInsightSerializer,
ChickenCountingSerializer,
ChickenWeightSerializer,
FeedSacksSerializer,
IotPanelSerializer,
KPISerializer,
ManualInputSerializer,
)
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
DEFAULT_PAGE_SIZE = 500
MAX_PAGE_SIZE = 2000
class PusatExportError(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)
def _parse_page(raw_page: str | None, raw_size: str | None) -> tuple[int, int]:
try:
page = int(raw_page or "1")
page_size = int(raw_size or str(DEFAULT_PAGE_SIZE))
except (TypeError, ValueError) as exc:
raise PusatExportError("page dan page_size harus angka.") from exc
if page < 1:
raise PusatExportError("page harus >= 1.")
if page_size < 1 or page_size > MAX_PAGE_SIZE:
raise PusatExportError(f"page_size harus antara 1 dan {MAX_PAGE_SIZE}.")
return page, page_size
def _parse_optional_date(value: str | None, label: str) -> date | None:
if value is None or str(value).strip() == "":
return None
parsed = parse_date(str(value).strip())
if parsed is None:
raise PusatExportError(f"{label} tidak valid (gunakan YYYY-MM-DD).")
return parsed
def _default_window() -> tuple[date, date]:
today = timezone.localdate()
return today - timedelta(days=6), today
def _paginate(qs: QuerySet, *, page: int, page_size: int) -> dict[str, Any]:
total = qs.count()
start = (page - 1) * page_size
end = start + page_size
return {
"count": total,
"page": page,
"page_size": page_size,
"results": list(qs[start:end]),
}
def _enrich_site_id(rows: list[dict], site_id: str) -> list[dict]:
for row in rows:
row["site_id"] = site_id
return rows
def _serialize_page(
qs: QuerySet,
serializer_class,
*,
page: int,
page_size: int,
site_id: str,
) -> dict[str, Any]:
page_data = _paginate(qs, page=page, page_size=page_size)
serialized = serializer_class(page_data["results"], many=True).data
page_data["results"] = _enrich_site_id(list(serialized), site_id)
return page_data
def _site_payload(site: Site) -> dict[str, Any]:
return {
"site_id": site.pk,
"site_name": site.site_name,
"pusat_code": site.pusat_code or "",
"pusat_active_site_id": site.pusat_active_site_id,
"pusat_registered_at": site.pusat_registered_at,
"created_at": site.created_at,
"updated_at": site.updated_at,
}
def _kandang_payload(kandang: Kandang, *, include_feed_urls: bool = True) -> dict[str, Any]:
data: dict[str, Any] = {
"kandang_id": kandang.pk,
"kandang_name": kandang.kandang_name,
"created_at": kandang.created_at,
"updated_at": kandang.updated_at,
}
if include_feed_urls:
data["feed_in_button_urls"] = kandang.feed_in_button_urls or []
return data
def resolve_ops_export_params(
*,
site_id: str | None,
cycle_id: str | None,
date_from: str | None,
date_to: str | None,
page: str | None,
page_size: str | None,
) -> dict[str, Any]:
if not site_id or not str(site_id).strip():
raise PusatExportError("site_id wajib diisi.")
site_id = str(site_id).strip()
try:
site = Site.objects.get(pk=site_id)
except Site.DoesNotExist as exc:
raise PusatExportError("site_id tidak ditemukan.") from exc
cycle: Cycle | None = None
if cycle_id is not None and str(cycle_id).strip() != "":
try:
cycle = Cycle.objects.select_related("kandang").get(pk=int(cycle_id))
except (Cycle.DoesNotExist, TypeError, ValueError) as exc:
raise PusatExportError("cycle_id tidak ditemukan.") from exc
if cycle.kandang.site_id != site.pk:
raise PusatExportError("cycle_id tidak termasuk site_id tersebut.")
parsed_from = _parse_optional_date(date_from, "from")
parsed_to = _parse_optional_date(date_to, "to")
if parsed_from is None and parsed_to is None:
if cycle is not None:
parsed_from = cycle.start_date
parsed_to = cycle.end_date or timezone.localdate()
else:
parsed_from, parsed_to = _default_window()
elif parsed_from is None or parsed_to is None:
raise PusatExportError("from dan to harus diisi berpasangan, atau keduanya dikosongkan.")
if parsed_to < parsed_from:
raise PusatExportError("to harus pada atau setelah from.")
page_n, page_size_n = _parse_page(page, page_size)
return {
"site": site,
"cycle": cycle,
"date_from": parsed_from,
"date_to": parsed_to,
"page": page_n,
"page_size": page_size_n,
}
def _enrich_cycle_initial_balance(cycle: Cycle, row: dict[str, Any]) -> dict[str, Any]:
"""Attach live IoT saldo-awal comparison fields (same source as initial-balance API)."""
balance_date = cycle.feed_initial_balance_date
if balance_date is None:
row["feed_initial_balance_iot"] = None
row["feed_initial_balance_accuracy"] = None
row["feed_initial_balance_sync_error"] = None
return row
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
manual = cycle.feed_initial_balance
row["feed_initial_balance_iot"] = iot_in
row["feed_initial_balance_accuracy"] = initial_balance_accuracy(manual, iot_in)
row["feed_initial_balance_sync_error"] = sync_error
return row
def build_ops_export(
*,
site: Site,
cycle: Cycle | None,
date_from: date,
date_to: date,
page: int,
page_size: int,
) -> dict[str, Any]:
kandangs_qs = Kandang.objects.filter(site=site).order_by("kandang_id")
cycles_qs = Cycle.objects.filter(kandang__site=site).select_related("kandang")
if cycle is not None:
kandangs_qs = kandangs_qs.filter(pk=cycle.kandang_id)
cycles_qs = cycles_qs.filter(pk=cycle.pk)
sites_tree: list[dict[str, Any]] = []
site_node = _site_payload(site)
kandang_nodes = []
cycles_by_kandang: dict[int, list[Cycle]] = {}
for c in cycles_qs.order_by("cycle_id"):
cycles_by_kandang.setdefault(c.kandang_id, []).append(c)
for kandang in kandangs_qs:
node = _kandang_payload(kandang, include_feed_urls=True)
cycle_rows = []
for c in cycles_by_kandang.get(kandang.pk, []):
row = CycleSerializer(c).data
cycle_rows.append(_enrich_cycle_initial_balance(c, dict(row)))
node["cycles"] = cycle_rows
kandang_nodes.append(node)
site_node["kandangs"] = kandang_nodes
sites_tree.append(site_node)
series_filter = {"cycle__kandang__site_id": site.pk, "date__gte": date_from, "date__lte": date_to}
if cycle is not None:
series_filter["cycle_id"] = cycle.pk
def series(model, serializer_class, order_fields=("date", "pk")):
qs = (
model.objects.filter(**series_filter)
.select_related("cycle", "cycle__kandang")
.order_by(*order_fields)
)
return _serialize_page(
qs, serializer_class, page=page, page_size=page_size, site_id=site.pk
)
return {
"exported_at": timezone.now().isoformat(),
"from": date_from.isoformat(),
"to": date_to.isoformat(),
"cycle_id": cycle.pk if cycle else None,
"site_id": site.pk,
"page": page,
"page_size": page_size,
"sites": sites_tree,
"data": {
"kpis": series(KPI, KPISerializer),
"manual_inputs": series(ManualInput, ManualInputSerializer),
"feed_sacks": series(FeedSacks, FeedSacksSerializer),
"chicken_countings": series(ChickenCounting, ChickenCountingSerializer),
"chicken_weights": series(ChickenWeight, ChickenWeightSerializer),
"ai_insights": series(AIInsight, AIInsightSerializer),
},
}
def resolve_iot_export_params(
*,
site_id: str | None,
kandang_id: str | None,
flock_id: str | None,
date_from: str | None,
date_to: str | None,
page: str | None,
page_size: str | None,
) -> dict[str, Any]:
if not site_id or not str(site_id).strip():
raise PusatExportError("site_id wajib diisi.")
site_id = str(site_id).strip()
try:
site = Site.objects.get(pk=site_id)
except Site.DoesNotExist as exc:
raise PusatExportError("site_id tidak ditemukan.") from exc
kandang: Kandang | None = None
if kandang_id is not None and str(kandang_id).strip() != "":
try:
kandang = Kandang.objects.get(pk=int(kandang_id))
except (Kandang.DoesNotExist, TypeError, ValueError) as exc:
raise PusatExportError("kandang_id tidak ditemukan.") from exc
if kandang.site_id != site.pk:
raise PusatExportError("kandang_id tidak termasuk site_id tersebut.")
flock: Flock | None = None
if flock_id is not None and str(flock_id).strip() != "":
try:
flock = Flock.objects.select_related("kandang").get(pk=int(flock_id))
except (Flock.DoesNotExist, TypeError, ValueError) as exc:
raise PusatExportError("flock_id tidak ditemukan.") from exc
if flock.kandang.site_id != site.pk:
raise PusatExportError("flock_id tidak termasuk site_id tersebut.")
if kandang is not None and flock.kandang_id != kandang.pk:
raise PusatExportError("flock_id tidak termasuk kandang_id tersebut.")
parsed_from = _parse_optional_date(date_from, "from")
parsed_to = _parse_optional_date(date_to, "to")
if parsed_from is None and parsed_to is None:
parsed_from, parsed_to = _default_window()
elif parsed_from is None or parsed_to is None:
raise PusatExportError("from dan to harus diisi berpasangan, atau keduanya dikosongkan.")
if parsed_to < parsed_from:
raise PusatExportError("to harus pada atau setelah from.")
page_n, page_size_n = _parse_page(page, page_size)
return {
"site": site,
"kandang": kandang,
"flock": flock,
"date_from": parsed_from,
"date_to": parsed_to,
"page": page_n,
"page_size": page_size_n,
}
def build_iot_export(
*,
site: Site,
kandang: Kandang | None,
flock: Flock | None,
date_from: date,
date_to: date,
page: int,
page_size: int,
) -> dict[str, Any]:
kandangs_qs = Kandang.objects.filter(site=site).order_by("kandang_id")
flocks_qs = Flock.objects.filter(kandang__site=site).select_related("kandang")
if kandang is not None:
kandangs_qs = kandangs_qs.filter(pk=kandang.pk)
flocks_qs = flocks_qs.filter(kandang_id=kandang.pk)
if flock is not None:
kandangs_qs = kandangs_qs.filter(pk=flock.kandang_id)
flocks_qs = flocks_qs.filter(pk=flock.pk)
flocks_by_kandang: dict[int, list[Flock]] = {}
for f in flocks_qs.order_by("flock_id"):
flocks_by_kandang.setdefault(f.kandang_id, []).append(f)
site_node = _site_payload(site)
kandang_nodes = []
for k in kandangs_qs:
node = _kandang_payload(k, include_feed_urls=False)
node["flocks"] = [
{
"flock_id": f.pk,
"flock_name": f.flock_name,
"created_at": f.created_at,
"updated_at": f.updated_at,
}
for f in flocks_by_kandang.get(k.pk, [])
]
kandang_nodes.append(node)
site_node["kandangs"] = kandang_nodes
iot_qs = (
IotPanel.objects.filter(
flock__kandang__site_id=site.pk,
date__gte=date_from,
date__lte=date_to,
)
.select_related("flock", "flock__kandang")
.order_by("timestamp", "panel_id")
)
if kandang is not None:
iot_qs = iot_qs.filter(flock__kandang_id=kandang.pk)
if flock is not None:
iot_qs = iot_qs.filter(flock_id=flock.pk)
iot_page = _serialize_page(
iot_qs, IotPanelSerializer, page=page, page_size=page_size, site_id=site.pk
)
for row in iot_page["results"]:
# Serializer already exposes flock_id / kandang; ensure site_id present.
row.setdefault("site_id", site.pk)
return {
"exported_at": timezone.now().isoformat(),
"from": date_from.isoformat(),
"to": date_to.isoformat(),
"flock_id": flock.pk if flock else None,
"kandang_id": kandang.pk if kandang else None,
"site_id": site.pk,
"page": page,
"page_size": page_size,
"sites": [site_node],
"data": {"iot_panels": iot_page},
}
@@ -45,13 +45,16 @@ def register_site_to_pusat(
Change code after register: allow_code_change=True (requires existing registration).
"""
base = (settings.PUSAT_BASE_URL or "").rstrip("/")
api_key = (settings.PUSAT_API_KEY or "").strip()
pusat_key = (settings.PUSAT_API_KEY or "").strip()
site_key = (settings.SITE_API_KEY or "").strip()
site_api = (settings.SITE_API_BASE_URL or "").rstrip("/")
if not base:
raise PusatRegisterError("PUSAT_BASE_URL belum dikonfigurasi.")
if not api_key:
if not pusat_key:
raise PusatRegisterError("PUSAT_API_KEY belum dikonfigurasi.")
if not site_key:
raise PusatRegisterError("SITE_API_KEY belum dikonfigurasi.")
if not site_api:
raise PusatRegisterError("SITE_API_BASE_URL belum dikonfigurasi.")
@@ -75,9 +78,9 @@ def register_site_to_pusat(
"site_id": site.pk,
"name": site.site_name,
"api_base_url": site_api,
"api_key": api_key,
"api_key": site_key,
}
headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
headers = {"X-API-Key": pusat_key, "Content-Type": "application/json"}
try:
with httpx.Client(timeout=30) as client:
+13 -8
View File
@@ -190,7 +190,7 @@ class FarmAPITests(TestCase):
self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE)
def test_apply_close_decision_approve(self):
from django.test import override_settings
from apps.accounts.models import ApiKey
start = date(2026, 8, 1)
end = start + timedelta(days=34)
@@ -204,13 +204,18 @@ class FarmAPITests(TestCase):
status=Cycle.STATUS_PENDING_CLOSE,
proposed_end_date=end,
)
with override_settings(PUSAT_API_KEY="pusat-test-key"):
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/",
{"decision": "approve", "end_date": end.isoformat()},
format="json",
HTTP_X_API_KEY="pusat-test-key",
)
site_key = "site-close-callback-key"
api_key, _ = ApiKey.generate(self.user, "site")
api_key.prefix = site_key[:8]
api_key.key_hash = ApiKey.hash_key(site_key)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/",
{"decision": "approve", "end_date": end.isoformat()},
format="json",
HTTP_X_API_KEY=site_key,
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED)
self.assertEqual(response.data["end_date"], end.isoformat())
+218
View File
@@ -0,0 +1,218 @@
from datetime import date, timedelta
from unittest.mock import patch
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import ApiKey, User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import AIInsight, ChickenCounting, FeedSacks, IotPanel
SITE_KEY = "site-export-test-key"
@override_settings(DASHBOARD_PUBLISH_HOUR=17, TIME_ZONE="Asia/Jakarta")
class PusatExportTests(TestCase):
def setUp(self):
self.client = APIClient()
self.admin = User.objects.create_user(
user_name="export-admin",
password="x",
status=User.STATUS_SUPERADMIN,
)
api_key, _ = ApiKey.generate(self.admin, "site")
api_key.prefix = SITE_KEY[:8]
api_key.key_hash = ApiKey.hash_key(SITE_KEY)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.site = Site.objects.create(site_name="ExportSite", user=self.admin)
self.other_site = Site.objects.create(site_name="OtherSite", user=self.admin)
self.kandang = Kandang.objects.create(kandang_name="K1", site=self.site)
self.other_kandang = Kandang.objects.create(kandang_name="K2", site=self.other_site)
self.flock = Flock.objects.create(flock_name="F1", kandang=self.kandang)
self.other_flock = Flock.objects.create(flock_name="F2", kandang=self.other_kandang)
today = timezone.localdate()
self.cycle = Cycle.objects.create(
kandang=self.kandang,
start_date=today - timedelta(days=10),
doc_in_weight=40,
doc_in_count=1000,
status=Cycle.STATUS_ACTIVE,
)
self.other_cycle = Cycle.objects.create(
kandang=self.other_kandang,
start_date=today - timedelta(days=10),
doc_in_weight=40,
doc_in_count=1000,
status=Cycle.STATUS_ACTIVE,
)
# "Today" rows would be hidden by publish cutoff on normal list APIs before 17:00.
FeedSacks.objects.create(cycle=self.cycle, date=today, in_today=1)
FeedSacks.objects.create(cycle=self.other_cycle, date=today, in_today=2)
ChickenCounting.objects.create(
cycle=self.cycle, date=today, total_count=100, mortality_count=1
)
AIInsight.objects.create(
cycle=self.cycle,
date=today,
insight_text="ok",
alert="none",
section="fcr",
session="morning",
)
IotPanel.objects.create(
flock=self.flock,
date=today,
timestamp=timezone.now(),
wind_speed=1.0,
humidity=50.0,
water_total=10.0,
average_temperature=28.0,
experience_temperature=27.0,
)
IotPanel.objects.create(
flock=self.other_flock,
date=today,
timestamp=timezone.now(),
wind_speed=1.0,
humidity=50.0,
water_total=10.0,
average_temperature=28.0,
experience_temperature=27.0,
)
def _auth(self):
self.client.credentials(HTTP_X_API_KEY=SITE_KEY)
def test_ops_requires_auth(self):
response = self.client.get("/api/v1/pusat/export/", {"site_id": self.site.pk})
self.assertEqual(response.status_code, 401)
def test_ops_requires_site_id(self):
self._auth()
response = self.client.get("/api/v1/pusat/export/")
self.assertEqual(response.status_code, 400)
def test_ops_unknown_site(self):
self._auth()
response = self.client.get("/api/v1/pusat/export/", {"site_id": "missing-9999"})
self.assertEqual(response.status_code, 400)
def test_ops_cycle_site_mismatch(self):
self._auth()
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "cycle_id": self.other_cycle.pk},
)
self.assertEqual(response.status_code, 400)
def test_ops_export_scopes_and_includes_today(self):
self._auth()
today = timezone.localdate().isoformat()
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "from": today, "to": today},
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["site_id"], self.site.pk)
self.assertEqual(len(response.data["sites"]), 1)
self.assertNotIn("flocks", response.data["sites"][0]["kandangs"][0])
self.assertNotIn("iot_panels", response.data["data"])
self.assertEqual(response.data["data"]["feed_sacks"]["count"], 1)
self.assertEqual(response.data["data"]["chicken_countings"]["count"], 1)
self.assertEqual(response.data["data"]["ai_insights"]["count"], 1)
self.assertEqual(
response.data["data"]["feed_sacks"]["results"][0]["site_id"], self.site.pk
)
def test_ops_cycle_id_defaults_to_cycle_span(self):
self._auth()
closed = Cycle.objects.create(
kandang=self.kandang,
start_date=date(2026, 1, 1),
end_date=date(2026, 2, 10),
doc_in_weight=40,
doc_in_count=1000,
status=Cycle.STATUS_CLOSED,
)
FeedSacks.objects.create(cycle=closed, date=date(2026, 1, 15), in_today=3)
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "cycle_id": closed.pk},
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["from"], "2026-01-01")
self.assertEqual(response.data["to"], "2026-02-10")
self.assertEqual(response.data["data"]["feed_sacks"]["count"], 1)
def test_ops_export_includes_initial_balance_iot(self):
self._auth()
today = timezone.localdate()
self.cycle.feed_initial_balance = 120
self.cycle.feed_initial_balance_date = today
self.cycle.save(
update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"]
)
with patch(
"apps.farms.services.pusat_export.pull_initial_balance_iot",
return_value=(115, None),
) as mock_pull:
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "cycle_id": self.cycle.pk},
)
self.assertEqual(response.status_code, 200, response.data)
cycle_row = response.data["sites"][0]["kandangs"][0]["cycles"][0]
self.assertEqual(cycle_row["feed_initial_balance"], 120)
self.assertEqual(cycle_row["feed_initial_balance_date"], today.isoformat())
self.assertEqual(cycle_row["feed_initial_balance_iot"], 115)
self.assertEqual(cycle_row["feed_initial_balance_accuracy"], 95.8)
self.assertIsNone(cycle_row["feed_initial_balance_sync_error"])
mock_pull.assert_called_once_with(self.cycle, today)
def test_ops_export_skips_iot_pull_without_balance_date(self):
self._auth()
with patch(
"apps.farms.services.pusat_export.pull_initial_balance_iot",
return_value=(99, None),
) as mock_pull:
response = self.client.get(
"/api/v1/pusat/export/",
{"site_id": self.site.pk, "cycle_id": self.cycle.pk},
)
self.assertEqual(response.status_code, 200, response.data)
cycle_row = response.data["sites"][0]["kandangs"][0]["cycles"][0]
self.assertIsNone(cycle_row["feed_initial_balance_iot"])
self.assertIsNone(cycle_row["feed_initial_balance_accuracy"])
self.assertIsNone(cycle_row["feed_initial_balance_sync_error"])
mock_pull.assert_not_called()
def test_iot_flock_site_mismatch(self):
self._auth()
response = self.client.get(
"/api/v1/pusat/export/iot/",
{"site_id": self.site.pk, "flock_id": self.other_flock.pk},
)
self.assertEqual(response.status_code, 400)
def test_iot_export_structure_and_panels(self):
self._auth()
today = timezone.localdate().isoformat()
response = self.client.get(
"/api/v1/pusat/export/iot/",
{"site_id": self.site.pk, "from": today, "to": today},
)
self.assertEqual(response.status_code, 200, response.data)
site_node = response.data["sites"][0]
self.assertEqual(site_node["site_id"], self.site.pk)
self.assertIn("flocks", site_node["kandangs"][0])
self.assertNotIn("cycles", site_node["kandangs"][0])
self.assertEqual(response.data["data"]["iot_panels"]["count"], 1)
self.assertEqual(
response.data["data"]["iot_panels"]["results"][0]["site_id"], self.site.pk
)
@@ -10,6 +10,7 @@ from apps.farms.models import Site
@override_settings(
PUSAT_BASE_URL="http://pusat.example/api/v1",
PUSAT_API_KEY="shared-key",
SITE_API_KEY="site-machine-key",
SITE_API_BASE_URL="https://site.example/api/v1",
)
class PusatRegisterTests(TestCase):
@@ -51,6 +52,7 @@ class PusatRegisterTests(TestCase):
self.assertEqual(kwargs["json"]["api_base_url"], "https://site.example/api/v1")
self.assertEqual(kwargs["json"]["name"], "Sukawarna")
self.assertEqual(kwargs["json"]["site_id"], self.site.pk)
self.assertEqual(kwargs["json"]["api_key"], "site-machine-key")
self.assertEqual(kwargs["headers"]["X-API-Key"], "shared-key")
def test_cannot_register_when_already_registered(self):
+4
View File
@@ -5,6 +5,8 @@ from apps.farms.views import (
CycleViewSet,
FlockViewSet,
KandangViewSet,
PusatIotExportView,
PusatOpsExportView,
SiteViewSet,
pusat_change_code,
pusat_register,
@@ -21,5 +23,7 @@ urlpatterns = [
path("pusat/register/", pusat_register, name="pusat-register"),
path("pusat/change-code/", pusat_change_code, name="pusat-change-code"),
path("pusat/registration/", pusat_registration_status, name="pusat-registration-status"),
path("pusat/export/", PusatOpsExportView.as_view(), name="pusat-export-ops"),
path("pusat/export/iot/", PusatIotExportView.as_view(), name="pusat-export-iot"),
*router.urls,
]
+67 -20
View File
@@ -1,16 +1,22 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from rest_framework.views import APIView
import secrets
from apps.accounts.authentication import ApiKeyAuthentication
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.farms.services.pusat_close import PusatCloseError, notify_pusat_close_request
from apps.farms.services.pusat_export import (
PusatExportError,
build_iot_export,
build_ops_export,
resolve_iot_export_params,
resolve_ops_export_params,
)
from apps.farms.services.pusat_register import PusatRegisterError, register_site_to_pusat
from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows,
@@ -30,23 +36,19 @@ def _validation_detail(exc: ValidationError) -> str | dict:
return str(exc.message) if hasattr(exc, "message") else str(exc)
def _pusat_api_key_from_request(request) -> str | None:
return request.headers.get("X-API-Key") or request.META.get("HTTP_X_API_KEY")
class IsApiKeyAuthenticated(permissions.BasePermission):
"""Require authentication via hashed site ApiKey (reject session-only users)."""
class IsPusatCloseCallback(permissions.BasePermission):
"""Allow only the shared pusat callback key (not session users)."""
message = "Pusat API key required."
message = "Site API key required."
def has_permission(self, request, view):
expected = (getattr(settings, "PUSAT_API_KEY", "") or "").strip()
if not expected:
return False
provided = (_pusat_api_key_from_request(request) or "").strip()
if not provided:
return False
return secrets.compare_digest(provided, expected)
from apps.accounts.models import ApiKey
return bool(
request.user
and request.user.is_authenticated
and isinstance(getattr(request, "auth", None), ApiKey)
)
def _revert_pending_close(cycle: Cycle) -> None:
@@ -286,11 +288,11 @@ class CycleViewSet(viewsets.ModelViewSet):
detail=True,
methods=["post"],
url_path="apply-close-decision",
authentication_classes=[],
permission_classes=[IsPusatCloseCallback],
authentication_classes=[ApiKeyAuthentication],
permission_classes=[IsApiKeyAuthenticated],
)
def apply_close_decision(self, request, pk=None):
"""Machine endpoint: pusat pushes approve/reject via shared PUSAT_API_KEY."""
"""Machine endpoint: pusat pushes approve/reject with the site SITE_API_KEY."""
cycle = self.get_object()
decision = str(request.data.get("decision", "")).strip().lower()
if decision not in {"approve", "reject"}:
@@ -434,3 +436,48 @@ class CycleViewSet(viewsets.ModelViewSet):
"sync_error": sync_error,
}
)
class PusatOpsExportView(APIView):
"""Machine pull: sites → kandangs → cycles + ops time-series (no publish cutoff)."""
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
params = request.query_params
try:
resolved = resolve_ops_export_params(
site_id=params.get("site_id"),
cycle_id=params.get("cycle_id"),
date_from=params.get("from"),
date_to=params.get("to"),
page=params.get("page"),
page_size=params.get("page_size"),
)
payload = build_ops_export(**resolved)
except PusatExportError as exc:
return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST)
return Response(payload)
class PusatIotExportView(APIView):
"""Machine pull: sites → kandangs → flocks + iot_panels (no publish cutoff)."""
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
params = request.query_params
try:
resolved = resolve_iot_export_params(
site_id=params.get("site_id"),
kandang_id=params.get("kandang_id"),
flock_id=params.get("flock_id"),
date_from=params.get("from"),
date_to=params.get("to"),
page=params.get("page"),
page_size=params.get("page_size"),
)
payload = build_iot_export(**resolved)
except PusatExportError as exc:
return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST)
return Response(payload)