408 lines
16 KiB
Python
408 lines
16 KiB
Python
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.operations.models import FeedSacks, ManualInput
|
|
from apps.sync.models import CitySite
|
|
|
|
|
|
def _gm_visible_site(*, owner: User, site_name: str, code: str) -> Site:
|
|
"""GM scope only sees sites linked to an active CitySite they registered."""
|
|
city = CitySite.objects.create(
|
|
code=code,
|
|
name=site_name,
|
|
api_base_url=f"https://{code}.example/api/v1",
|
|
registered_by=owner,
|
|
)
|
|
return Site.objects.create(site_name=site_name, user=owner, city_site=city)
|
|
|
|
|
|
class FarmAPITests(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_user(user_login="tester", password="secret")
|
|
self.admin = User.objects.create_user(
|
|
user_login="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_login="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_login="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_login="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_set_initial_balance_does_not_create_feed_sack_row(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)
|
|
response = self.client.post(
|
|
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
|
{"date": start.isoformat(), "feed_in_manual": 120},
|
|
format="json",
|
|
)
|
|
self.assertEqual(response.status_code, 200, response.data)
|
|
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
|
|
cycle.refresh_from_db()
|
|
self.assertEqual(cycle.feed_initial_balance, 120)
|
|
self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists())
|
|
self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists())
|
|
|
|
def test_set_initial_balance_does_not_change_existing_feed_sack_totals(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,
|
|
)
|
|
feed_row = FeedSacks.objects.create(
|
|
cycle=cycle,
|
|
date=start + timedelta(days=1),
|
|
in_today=4,
|
|
in_total=4,
|
|
out_today=0,
|
|
out_total=0,
|
|
feed_use_today=0,
|
|
feed_use_total=0,
|
|
)
|
|
|
|
self.client.force_authenticate(self.user)
|
|
response = self.client.post(
|
|
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
|
{"date": start.isoformat(), "feed_in_manual": 120},
|
|
format="json",
|
|
)
|
|
self.assertEqual(response.status_code, 200, response.data)
|
|
|
|
feed_row.refresh_from_db()
|
|
self.assertEqual(feed_row.in_total, 4)
|
|
|
|
def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(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,
|
|
status=Cycle.STATUS_ACTIVE,
|
|
)
|
|
feed_row = FeedSacks.objects.create(
|
|
cycle=cycle,
|
|
date=start + timedelta(days=2),
|
|
in_today=6,
|
|
in_total=6,
|
|
out_today=0,
|
|
out_total=0,
|
|
feed_use_today=0,
|
|
feed_use_total=0,
|
|
)
|
|
|
|
self.client.force_authenticate(self.user)
|
|
first = self.client.post(
|
|
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
|
{"date": start.isoformat(), "feed_in_manual": 120},
|
|
format="json",
|
|
)
|
|
self.assertEqual(first.status_code, 200, first.data)
|
|
second = self.client.post(
|
|
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
|
{"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80},
|
|
format="json",
|
|
)
|
|
self.assertEqual(second.status_code, 200, second.data)
|
|
|
|
cycle.refresh_from_db()
|
|
self.assertEqual(cycle.feed_initial_balance, 80)
|
|
self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1))
|
|
self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1)
|
|
feed_row.refresh_from_db()
|
|
self.assertEqual(feed_row.in_total, 6)
|
|
|
|
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(115, None))
|
|
def test_set_initial_balance_compares_manual_against_combined_api_iot(self, mock_pull):
|
|
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,
|
|
status=Cycle.STATUS_ACTIVE,
|
|
)
|
|
|
|
self.client.force_authenticate(self.user)
|
|
response = self.client.post(
|
|
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
|
{"date": start.isoformat(), "feed_in_manual": 120},
|
|
format="json",
|
|
)
|
|
self.assertEqual(response.status_code, 200, response.data)
|
|
self.assertEqual(response.data["iot"], 115)
|
|
self.assertEqual(response.data["accuracy"], 95.8)
|
|
mock_pull.assert_called_once_with(cycle, start)
|
|
|
|
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(112, None))
|
|
def test_compare_initial_balance_returns_iot_and_accuracy(self, mock_pull):
|
|
start = date(2026, 8, 27)
|
|
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,
|
|
status=Cycle.STATUS_ACTIVE,
|
|
)
|
|
|
|
self.client.force_authenticate(self.user)
|
|
response = self.client.get(
|
|
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
|
|
{"date": start.isoformat(), "manual": 120},
|
|
)
|
|
self.assertEqual(response.status_code, 200, response.data)
|
|
self.assertEqual(response.data["iot"], 112)
|
|
self.assertEqual(response.data["accuracy"], 93.3)
|
|
mock_pull.assert_called_once_with(cycle, start)
|