from datetime import date, timedelta from unittest.mock import patch from django.test import TestCase from rest_framework.test import APIClient from apps.accounts.models import User from apps.farms.models import Cycle, Flock, Kandang, Site from apps.sync.models import ActiveSite def _gm_visible_site(*, owner: User, site_name: str, code: str) -> Site: """GM scope only sees sites linked to an ActiveSite they manage.""" site_id = f"{code}-0001" active_site = ActiveSite.objects.create( code=code, name=site_name, source_site_id=site_id, api_base_url=f"https://{code}.example/api/v1", managed_by=owner, ) return Site.objects.create( site_id=site_id, site_name=site_name, user=owner, active_site=active_site, ) class FarmAPITests(TestCase): def setUp(self): self.user = User.objects.create_user(user_name="tester", password="secret") self.admin = User.objects.create_user( user_name="admin", password="secret", status=User.STATUS_DIRECTOR_ADMIN, ) self.client = APIClient() self.site = _gm_visible_site( owner=self.user, site_name="Sukawarna", code="sukawarna" ) self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site) def test_create_flock_under_kandang_requires_director_admin(self): self.client.force_authenticate(self.admin) response = self.client.post( "/api/v1/flocks/", {"flock_name": "Lantai 1", "kandang": self.kandang.pk}, format="json", ) self.assertEqual(response.status_code, 201, response.data) self.assertEqual(response.data["flock_name"], "Lantai 1") self.assertEqual(response.data["kandang"], self.kandang.pk) def test_staff_cannot_create_flock(self): self.client.force_authenticate(self.user) response = self.client.post( "/api/v1/flocks/", {"flock_name": "Lantai 1", "kandang": self.kandang.pk}, format="json", ) self.assertEqual(response.status_code, 403, response.data) def test_create_cycle_without_flock(self): self.client.force_authenticate(self.user) start = date.today() response = self.client.post( "/api/v1/cycles/", { "kandang": self.kandang.pk, "total_days": 35, "start_date": start.isoformat(), "end_date": (start + timedelta(days=34)).isoformat(), "chick_in_weight": 42, "doc_in_count": 10000, "status": "active", }, format="json", ) self.assertEqual(response.status_code, 201, response.data) self.assertNotIn("flock", response.data) self.assertNotIn("flock_name", response.data) def test_update_cycle(self): self.client.force_authenticate(self.user) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) response = self.client.patch( f"/api/v1/cycles/{cycle.pk}/", { "doc_in_count": 9500, "chick_in_weight": 42, }, format="json", ) self.assertEqual(response.status_code, 200, response.data) cycle.refresh_from_db() # end_date is not writable via PATCH (close flow uses approve-close). self.assertEqual(cycle.end_date, start + timedelta(days=34)) self.assertEqual(cycle.doc_in_count, 9500) self.assertEqual(cycle.doc_in_weight, 42) def test_cycle_stays_active_without_gm_approval(self): """HQ does not auto-close by publish cutoff — GM approve-close is required.""" self.client.force_authenticate(self.user) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) response = self.client.get(f"/api/v1/cycles/{cycle.pk}/") self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["status"], Cycle.STATUS_ACTIVE) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE) def test_gm_can_approve_cycle_close(self): gm = User.objects.create_user(user_name="gm_approver", password="x", status=User.STATUS_GM) site = _gm_visible_site(owner=gm, site_name="GM Site", code="gm-approve") kandang = Kandang.objects.create(kandang_name="Kandang GM", site=site) self.client.force_authenticate(gm) start = date(2026, 8, 1) proposed = start + timedelta(days=34) cycle = Cycle.objects.create( kandang=kandang, total_days=1, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_PENDING_CLOSE, proposed_end_date=proposed, ) response = self.client.post(f"/api/v1/cycles/{cycle.pk}/approve-close/") self.assertEqual(response.status_code, 200, response.data) body = response.data.get("cycle", response.data) self.assertEqual(body["status"], Cycle.STATUS_CLOSED) self.assertEqual(body["end_date"], proposed.isoformat()) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_CLOSED) self.assertEqual(cycle.close_approved_by_id, gm.pk) self.assertIsNotNone(cycle.close_approved_at) self.assertEqual(cycle.end_date, proposed) def test_gm_can_reject_cycle_close(self): gm = User.objects.create_user(user_name="gm_rejector", password="x", status=User.STATUS_GM) site = _gm_visible_site(owner=gm, site_name="GM Reject Site", code="gm-reject") kandang = Kandang.objects.create(kandang_name="Kandang R", site=site) self.client.force_authenticate(gm) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=kandang, total_days=1, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_PENDING_CLOSE, proposed_end_date=start + timedelta(days=20), ) response = self.client.post(f"/api/v1/cycles/{cycle.pk}/reject-close/") self.assertEqual(response.status_code, 200, response.data) body = response.data.get("cycle", response.data) self.assertEqual(body["status"], Cycle.STATUS_ACTIVE) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE) self.assertIsNone(cycle.proposed_end_date) def test_gm_admin_cannot_approve_cycle_close(self): gm_admin = User.objects.create_user( user_name="gm_admin_user", password="x", status=User.STATUS_GM_ADMIN ) site = _gm_visible_site( owner=gm_admin, site_name="GM Admin Site", code="gm-admin-site" ) kandang = Kandang.objects.create(kandang_name="Kandang GMA", site=site) self.client.force_authenticate(gm_admin) start = date(2026, 8, 1) cycle = Cycle.objects.create( kandang=kandang, total_days=1, start_date=start, end_date=None, doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_PENDING_CLOSE, proposed_end_date=start + timedelta(days=34), ) response = self.client.post(f"/api/v1/cycles/{cycle.pk}/approve-close/") self.assertEqual(response.status_code, 403) cycle.refresh_from_db() self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE) def test_flock_persists_across_cycles(self): flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) start = date.today() for offset in (0, 40): Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start + timedelta(days=offset), end_date=start + timedelta(days=offset + 34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1) self.assertEqual(flock.kandang_id, self.kandang.pk) def test_current_day_is_zero_based_from_start_date(self): start = date(2026, 9, 2) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) with patch( "apps.operations.services.visibility.visible_through_date", return_value=start, ): self.assertEqual(cycle.current_day, 0) with patch( "apps.operations.services.visibility.visible_through_date", return_value=start + timedelta(days=1), ): self.assertEqual(cycle.current_day, 1) def test_list_flocks_by_kandang(self): self.client.force_authenticate(self.user) Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang) other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site) Flock.objects.create(flock_name="Other", kandang=other_kandang) response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.pk}) self.assertEqual(response.status_code, 200, response.data) results = response.data["results"] if isinstance(response.data, dict) else response.data self.assertEqual(len(results), 2) def test_initial_balance_write_endpoints_removed(self): start = date.today() cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, status=Cycle.STATUS_ACTIVE, ) self.client.force_authenticate(self.user) post = self.client.post( f"/api/v1/cycles/{cycle.pk}/initial-balance/", {"date": start.isoformat(), "feed_in_manual": 120}, format="json", ) compare = self.client.get( f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/", {"date": start.isoformat(), "manual": 120}, ) self.assertEqual(post.status_code, 404) self.assertEqual(compare.status_code, 404) def test_cycle_api_exposes_mirrored_initial_balance_iot_fields(self): start = date(2026, 8, 26) cycle = Cycle.objects.create( kandang=self.kandang, total_days=35, start_date=start, end_date=start + timedelta(days=34), doc_in_weight=40, doc_in_count=9000, feed_initial_balance=120, feed_initial_balance_date=start, feed_initial_balance_iot=115, feed_initial_balance_accuracy=95.8, feed_initial_balance_sync_error=None, status=Cycle.STATUS_ACTIVE, ) self.client.force_authenticate(self.user) response = self.client.get(f"/api/v1/cycles/{cycle.pk}/") self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["feed_initial_balance"], 120) self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat()) self.assertEqual(response.data["feed_initial_balance_iot"], 115) self.assertEqual(response.data["feed_initial_balance_accuracy"], 95.8) self.assertIsNone(response.data["feed_initial_balance_sync_error"])