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,398 @@
|
||||
import httpx
|
||||
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 AIInsight, FeedSacks, IotPanel, KPI
|
||||
from apps.sync.models import ActiveSite
|
||||
from apps.sync.services.farm_export_client import FarmExportClient
|
||||
from apps.sync.services.mirror_pull import sync_active_site_safe
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _empty_series(page=1, page_size=500):
|
||||
block = {"count": 0, "page": page, "page_size": page_size, "results": []}
|
||||
return {
|
||||
"kpis": dict(block),
|
||||
"manual_inputs": dict(block),
|
||||
"feed_sacks": dict(block),
|
||||
"chicken_countings": dict(block),
|
||||
"chicken_weights": dict(block),
|
||||
"ai_insights": dict(block),
|
||||
}
|
||||
|
||||
|
||||
class MirrorPullTests(TestCase):
|
||||
def setUp(self):
|
||||
self.gm = User.objects.create_user(
|
||||
user_name="gm-mirror", password="x", status=User.STATUS_GM
|
||||
)
|
||||
self.active = ActiveSite.objects.create(
|
||||
code="sukawarna",
|
||||
name="Sukawarna",
|
||||
source_site_id="sukawarna-0002",
|
||||
api_base_url="https://farm.example/api/v1",
|
||||
api_key="site-secret",
|
||||
managed_by=self.gm,
|
||||
is_active=True,
|
||||
)
|
||||
self.start = date(2026, 9, 1)
|
||||
|
||||
def _handler(self):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
params = request.url.params
|
||||
if path.endswith("/pusat/export/iot/"):
|
||||
body = {
|
||||
"sites": [
|
||||
{
|
||||
"site_id": "sukawarna-0002",
|
||||
"site_name": "Sukawarna",
|
||||
"kandangs": [
|
||||
{
|
||||
"kandang_id": 101,
|
||||
"kandang_name": "K1",
|
||||
"flocks": [
|
||||
{"flock_id": 201, "flock_name": "Lantai 1"}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"iot_panels": {
|
||||
"count": 1,
|
||||
"page": 1,
|
||||
"page_size": 500,
|
||||
"results": [
|
||||
{
|
||||
"flock_id": 201,
|
||||
"date": "2026-09-02",
|
||||
"timestamp": "2026-09-02T10:00:00+07:00",
|
||||
"wind_speed": 1.2,
|
||||
"humidity": 70.0,
|
||||
"water_total": 10.0,
|
||||
"average_temperature": 28.0,
|
||||
"experience_temperature": 27.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
cycle_id = params.get("cycle_id")
|
||||
if cycle_id:
|
||||
series = _empty_series()
|
||||
series["feed_sacks"] = {
|
||||
"count": 1,
|
||||
"page": 1,
|
||||
"page_size": 500,
|
||||
"results": [
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"in_today": 5,
|
||||
"out_today": 0,
|
||||
"in_total": 5,
|
||||
"out_total": 0,
|
||||
"feed_use_today": 2,
|
||||
"feed_use_total": 2,
|
||||
"cycle": int(cycle_id),
|
||||
}
|
||||
],
|
||||
}
|
||||
series["chicken_countings"] = {
|
||||
"count": 1,
|
||||
"page": 1,
|
||||
"page_size": 500,
|
||||
"results": [
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"total_count": 9900,
|
||||
"mortality_count": 10,
|
||||
"cycle": int(cycle_id),
|
||||
}
|
||||
],
|
||||
}
|
||||
series["chicken_weights"] = {
|
||||
"count": 1,
|
||||
"page": 1,
|
||||
"page_size": 500,
|
||||
"results": [
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"age": 1,
|
||||
"doc_weight": 42,
|
||||
"average_weight": 50.0,
|
||||
"chicken_count": 100,
|
||||
"uniformity": 90.0,
|
||||
"average_daily_gain": 8.0,
|
||||
"cycle": int(cycle_id),
|
||||
}
|
||||
],
|
||||
}
|
||||
series["manual_inputs"] = {
|
||||
"count": 1,
|
||||
"page": 1,
|
||||
"page_size": 500,
|
||||
"results": [
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"age_manual": 1,
|
||||
"mortality_manual": 0,
|
||||
"cycle": int(cycle_id),
|
||||
}
|
||||
],
|
||||
}
|
||||
series["ai_insights"] = {
|
||||
"count": 1,
|
||||
"page": 1,
|
||||
"page_size": 500,
|
||||
"results": [
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"section": "health",
|
||||
"session": "am",
|
||||
"insight_text": "ok",
|
||||
"alert": "none",
|
||||
"cycle": int(cycle_id),
|
||||
}
|
||||
],
|
||||
}
|
||||
series["kpis"] = {
|
||||
"count": 1,
|
||||
"page": 1,
|
||||
"page_size": 500,
|
||||
"results": [
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"age": 1,
|
||||
"fcr": 1.2,
|
||||
"eef": 300.0,
|
||||
"chicken_life": 9900,
|
||||
"cycle": int(cycle_id),
|
||||
}
|
||||
],
|
||||
}
|
||||
body = {
|
||||
"site_id": "sukawarna-0002",
|
||||
"cycle_id": int(cycle_id),
|
||||
"sites": [],
|
||||
"data": series,
|
||||
}
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
# Structure-only call
|
||||
body = {
|
||||
"site_id": "sukawarna-0002",
|
||||
"sites": [
|
||||
{
|
||||
"site_id": "sukawarna-0002",
|
||||
"site_name": "Sukawarna",
|
||||
"kandangs": [
|
||||
{
|
||||
"kandang_id": 101,
|
||||
"kandang_name": "K1",
|
||||
"feed_in_button_urls": [],
|
||||
"cycles": [
|
||||
{
|
||||
"id": 55,
|
||||
"kandang": 101,
|
||||
"status": "active",
|
||||
"start_date": self.start.isoformat(),
|
||||
"end_date": None,
|
||||
"chick_in_weight": 42,
|
||||
"doc_in_count": 10000,
|
||||
"total_days": 1,
|
||||
"feed_initial_balance": 120,
|
||||
"feed_initial_balance_date": self.start.isoformat(),
|
||||
"feed_initial_balance_iot": 115,
|
||||
"feed_initial_balance_accuracy": 95.8,
|
||||
"feed_initial_balance_sync_error": None,
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"kandang": 101,
|
||||
"status": "closed",
|
||||
"start_date": "2026-01-01",
|
||||
"end_date": "2026-02-01",
|
||||
"chick_in_weight": 40,
|
||||
"doc_in_count": 9000,
|
||||
"total_days": 32,
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"kandang": 101,
|
||||
"status": "closed",
|
||||
"start_date": "2025-10-01",
|
||||
"end_date": "2025-11-01",
|
||||
"chick_in_weight": 40,
|
||||
"doc_in_count": 9000,
|
||||
"total_days": 32,
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"kandang": 101,
|
||||
"status": "closed",
|
||||
"start_date": "2025-07-01",
|
||||
"end_date": "2025-08-01",
|
||||
"chick_in_weight": 40,
|
||||
"doc_in_count": 9000,
|
||||
"total_days": 32,
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"kandang": 101,
|
||||
"status": "closed",
|
||||
"start_date": "2025-04-01",
|
||||
"end_date": "2025-05-01",
|
||||
"chick_in_weight": 40,
|
||||
"doc_in_count": 9000,
|
||||
"total_days": 32,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"data": _empty_series(),
|
||||
}
|
||||
return httpx.Response(200, json=body)
|
||||
|
||||
return handler
|
||||
|
||||
def test_mirror_upsert_idempotent_and_selects_closed_limit(self):
|
||||
transport = httpx.MockTransport(self._handler())
|
||||
client = FarmExportClient(self.active, transport=transport)
|
||||
summary = sync_active_site_safe(
|
||||
self.active, client=client, closed_limit=3, page_size=500
|
||||
)
|
||||
self.assertEqual(summary.status, "ok", summary.message)
|
||||
self.assertEqual(Cycle.objects.filter(active_site=self.active).count(), 5)
|
||||
# active + last 3 closed = 4 selected (47 dropped)
|
||||
self.assertEqual(summary.selected_cycles, 4)
|
||||
self.assertFalse(
|
||||
Cycle.objects.filter(active_site=self.active, source_cycle_id=47).exists()
|
||||
and Cycle.objects.get(
|
||||
active_site=self.active, source_cycle_id=47
|
||||
).feed_sacks.exists()
|
||||
)
|
||||
# Oldest closed is still created from structure but not series-synced
|
||||
self.assertTrue(
|
||||
Cycle.objects.filter(active_site=self.active, source_cycle_id=47).exists()
|
||||
)
|
||||
active_cycle = Cycle.objects.get(active_site=self.active, source_cycle_id=55)
|
||||
self.assertEqual(active_cycle.feed_initial_balance, 120)
|
||||
self.assertEqual(active_cycle.feed_initial_balance_date, self.start)
|
||||
self.assertEqual(active_cycle.feed_initial_balance_iot, 115)
|
||||
self.assertEqual(active_cycle.feed_initial_balance_accuracy, 95.8)
|
||||
self.assertIsNone(active_cycle.feed_initial_balance_sync_error)
|
||||
self.assertEqual(FeedSacks.objects.filter(cycle=active_cycle).count(), 1)
|
||||
self.assertEqual(KPI.objects.filter(cycle=active_cycle).count(), 1)
|
||||
self.assertEqual(AIInsight.objects.filter(cycle=active_cycle).count(), 1)
|
||||
self.assertEqual(Flock.objects.filter(source_flock_id=201).count(), 1)
|
||||
self.assertEqual(IotPanel.objects.count(), 1)
|
||||
|
||||
# Second sync is idempotent
|
||||
summary2 = sync_active_site_safe(
|
||||
self.active, client=FarmExportClient(self.active, transport=transport)
|
||||
)
|
||||
self.assertEqual(summary2.status, "ok", summary2.message)
|
||||
self.assertEqual(FeedSacks.objects.filter(cycle=active_cycle).count(), 1)
|
||||
self.assertEqual(IotPanel.objects.count(), 1)
|
||||
self.active.refresh_from_db()
|
||||
self.assertEqual(self.active.last_sync_status, "ok")
|
||||
|
||||
def test_sync_api_endpoint(self):
|
||||
transport = httpx.MockTransport(self._handler())
|
||||
api = APIClient()
|
||||
api.force_authenticate(self.gm)
|
||||
with patch(
|
||||
"apps.sync.services.mirror_pull.FarmExportClient",
|
||||
side_effect=lambda active_site, **kwargs: FarmExportClient(
|
||||
active_site, transport=transport
|
||||
),
|
||||
):
|
||||
response = api.post(f"/api/v1/active-sites/{self.active.pk}/sync/")
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data["summary"]["status"], "ok")
|
||||
self.active.refresh_from_db()
|
||||
self.assertEqual(self.active.last_sync_status, "ok")
|
||||
|
||||
|
||||
class CloseIngestSourceKandangTests(TestCase):
|
||||
def setUp(self):
|
||||
self.gm = User.objects.create_user(
|
||||
user_name="gm-ingest", password="x", status=User.STATUS_GM
|
||||
)
|
||||
self.machine = User.objects.create_user(
|
||||
user_name="bot", password="x", status=User.STATUS_GM
|
||||
)
|
||||
self.active = ActiveSite.objects.create(
|
||||
code="site-a",
|
||||
name="Site A",
|
||||
source_site_id="site-a-0001",
|
||||
api_base_url="https://a.example/api/v1",
|
||||
api_key="k",
|
||||
managed_by=self.gm,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.machine)
|
||||
|
||||
def test_ingest_prefers_source_kandang_id(self):
|
||||
response = self.client.post(
|
||||
"/api/v1/cycle-close/ingest/",
|
||||
{
|
||||
"active_site_id": self.active.pk,
|
||||
"source_cycle_id": 99,
|
||||
"source_kandang_id": 777,
|
||||
"kandang_name": "Kandang Baru",
|
||||
"site_name": "Site A",
|
||||
"start_date": "2026-09-01",
|
||||
"proposed_end_date": "2026-09-15",
|
||||
"doc_in_weight": 40,
|
||||
"doc_in_count": 1000,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
kandang = Kandang.objects.get(source_kandang_id=777)
|
||||
self.assertEqual(kandang.kandang_name, "Kandang Baru")
|
||||
cycle = Cycle.objects.get(active_site=self.active, source_cycle_id=99)
|
||||
self.assertEqual(cycle.kandang_id, kandang.pk)
|
||||
|
||||
|
||||
class AIInsightUniqueTests(TestCase):
|
||||
def setUp(self):
|
||||
owner = User.objects.create_user(user_name="owner", password="x")
|
||||
site = Site.objects.create(site_id="s-1", site_name="S", user=owner)
|
||||
kandang = Kandang.objects.create(site=site, kandang_name="K")
|
||||
self.cycle = Cycle.objects.create(
|
||||
kandang=kandang,
|
||||
start_date=date.today(),
|
||||
doc_in_weight=40,
|
||||
doc_in_count=100,
|
||||
)
|
||||
|
||||
def test_unique_constraint_allows_upsert_key(self):
|
||||
AIInsight.objects.create(
|
||||
cycle=self.cycle,
|
||||
date=date.today(),
|
||||
section="a",
|
||||
session="am",
|
||||
insight_text="one",
|
||||
alert="",
|
||||
)
|
||||
AIInsight.objects.update_or_create(
|
||||
cycle=self.cycle,
|
||||
date=date.today(),
|
||||
section="a",
|
||||
session="am",
|
||||
defaults={"insight_text": "two", "alert": "x"},
|
||||
)
|
||||
self.assertEqual(AIInsight.objects.filter(cycle=self.cycle).count(), 1)
|
||||
self.assertEqual(
|
||||
AIInsight.objects.get(cycle=self.cycle).insight_text, "two"
|
||||
)
|
||||
Reference in new issue
Block a user