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, kandang=self.kandang, date=today, insight_text="ok", alert="healthy", topic="fcr", report_type="page", report_period="Hari 1", ) 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 )