update api configuration for panel iot and adjust ai insight

This commit is contained in:
Alberto-Audrix committed 2026-09-18 16:06:41 +07:00
1 parent a2b5b10977
commit 20d04acb3e
20 files changed
+814 -177

No files matched your search

@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0019_site_pusat_fields"),
]
operations = [
migrations.AddField(
model_name="flock",
name="iot_api_url",
field=models.CharField(blank=True, default="", max_length=500),
),
]
+1
View File
@@ -80,6 +80,7 @@ class Kandang(models.Model):
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
iot_api_url = models.CharField(max_length=500, blank=True, default="")
kandang = models.ForeignKey(
Kandang,
on_delete=models.CASCADE,
+4 -1
View File
@@ -82,9 +82,12 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
fields = ["id", "flock_name", "iot_api_url", "kandang", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
def validate_iot_api_url(self, value):
return (value or "").strip()
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
+96
View File
@@ -0,0 +1,96 @@
from django.test import Client, TestCase
from apps.accounts.models import User
from apps.farms.models import Flock, Kandang, Site
class FlockIotApiUrlTests(TestCase):
def setUp(self):
self.owner = User.objects.create_user(user_name="owner", password="secret")
self.admin = User.objects.create_user(
user_name="admin",
password="admin123",
status=User.STATUS_SUPERADMIN,
is_superuser=True,
)
self.staff = User.objects.create_user(
user_name="staff",
password="staff123",
status=User.STATUS_ACTIVE,
is_staff=True,
)
self.site = Site.objects.create(site_name="Site A", user=self.owner)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
self.flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
def _csrf_headers(self, client: Client) -> dict[str, str]:
token = client.cookies["csrftoken"].value
return {"HTTP_X_CSRFTOKEN": token}
def _login(self, client: Client, user_name: str, password: str) -> None:
client.get("/api/v1/auth/csrf/")
response = client.post(
"/api/v1/auth/login/",
{"user_name": user_name, "password": password},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
def test_superadmin_can_set_iot_api_url(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "admin", "admin123")
url = "https://example.test/iot/lantai-1"
response = client.patch(
f"/api/v1/flocks/{self.flock.pk}/",
{"iot_api_url": f" {url} "},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["iot_api_url"], url)
self.flock.refresh_from_db()
self.assertEqual(self.flock.iot_api_url, url)
def test_superadmin_can_clear_iot_api_url(self):
self.flock.iot_api_url = "https://example.test/iot/lantai-1"
self.flock.save(update_fields=["iot_api_url"])
client = Client(enforce_csrf_checks=True)
self._login(client, "admin", "admin123")
response = client.patch(
f"/api/v1/flocks/{self.flock.pk}/",
{"iot_api_url": ""},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
self.flock.refresh_from_db()
self.assertEqual(self.flock.iot_api_url, "")
def test_staff_cannot_set_iot_api_url(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "staff", "staff123")
response = client.patch(
f"/api/v1/flocks/{self.flock.pk}/",
{"iot_api_url": "https://example.test/iot/lantai-1"},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 403, response.content)
self.flock.refresh_from_db()
self.assertEqual(self.flock.iot_api_url, "")
def test_create_flock_without_iot_api_url(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "admin", "admin123")
response = client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 2", "kandang": self.kandang.pk},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 201, response.content)
data = response.json()
self.assertEqual(data.get("iot_api_url", ""), "")
flock = Flock.objects.get(pk=data["id"])
self.assertEqual(flock.iot_api_url, "")
+56 -29
View File
@@ -164,12 +164,21 @@ class SyncSummary:
class IotApiClient:
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.IOT_API_BASE_URL).rstrip("/") + "/"
def __init__(self, base_url: str, timeout: float | None = None):
self.base_url = (base_url or "").strip()
if not self.base_url:
raise IotApiError("IoT API URL is empty")
self.timeout = timeout or settings.IOT_API_SYNC_TIMEOUT_SECONDS
def fetch_page(self, page: int) -> dict[str, Any]:
url = f"{self.base_url}?page={page}"
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
parts = urlsplit(self.base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["page"] = str(page)
url = urlunsplit(
(parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)
)
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
@@ -183,32 +192,19 @@ class IotApiClient:
return response.json()
def sync_iot_panels(
def _sync_flock_pages(
flock: Flock,
*,
lookback_minutes: int | None = None,
max_pages: int | None = None,
lookback_minutes: int,
max_pages: int,
cutoff,
) -> SyncSummary:
flock_map: dict[str, int] = getattr(settings, "IOT_FLOCK_ID_MAP", {}) or {}
if not flock_map:
return SyncSummary(upserted=0, skipped=0, pages=0)
lookback = lookback_minutes if lookback_minutes is not None else settings.IOT_SYNC_LOOKBACK_MINUTES
page_limit = max_pages if max_pages is not None else settings.IOT_SYNC_MAX_PAGES
cutoff = timezone.now() - timedelta(minutes=lookback)
django_flocks: dict[str, Flock] = {}
for external_id, django_id in flock_map.items():
try:
django_flocks[str(external_id)] = Flock.objects.select_related("kandang").get(pk=int(django_id))
except Flock.DoesNotExist:
continue
client = IotApiClient()
client = IotApiClient(flock.iot_api_url)
upserted = 0
skipped = 0
pages_read = 0
for page in range(1, page_limit + 1):
for page in range(1, max_pages + 1):
payload = client.fetch_page(page)
pages_read += 1
results = payload.get("results") or []
@@ -217,12 +213,6 @@ def sync_iot_panels(
page_has_recent = False
for record in results:
external_flock_id = str(record.get("flock_id") or "")
flock = django_flocks.get(external_flock_id)
if flock is None:
skipped += 1
continue
fetched_raw = record.get("fetched_at")
payload_data = (record.get("payload") or {}).get("data")
if not fetched_raw or not payload_data:
@@ -262,3 +252,40 @@ def sync_iot_panels(
break
return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read)
def sync_iot_panels(
*,
flock_id: int | None = None,
lookback_minutes: int | None = None,
max_pages: int | None = None,
) -> SyncSummary:
lookback = lookback_minutes if lookback_minutes is not None else settings.IOT_SYNC_LOOKBACK_MINUTES
page_limit = max_pages if max_pages is not None else settings.IOT_SYNC_MAX_PAGES
cutoff = timezone.now() - timedelta(minutes=lookback)
flocks = Flock.objects.select_related("kandang").exclude(iot_api_url="")
if flock_id is not None:
flocks = flocks.filter(pk=flock_id)
flock_list = list(flocks)
if not flock_list:
return SyncSummary(upserted=0, skipped=0, pages=0)
upserted = 0
skipped = 0
pages_read = 0
for flock in flock_list:
if not (flock.iot_api_url or "").strip():
continue
summary = _sync_flock_pages(
flock,
lookback_minutes=lookback,
max_pages=page_limit,
cutoff=cutoff,
)
upserted += summary.upserted
skipped += summary.skipped
pages_read += summary.pages
return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read)
+159
View File
@@ -0,0 +1,159 @@
from datetime import timedelta
from unittest.mock import patch
from django.test import Client, TestCase
from django.utils import timezone
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import IotPanel
from apps.operations.services.iot_api import IotApiClient, sync_iot_panels
SAMPLE_PAYLOAD = {
"results": [
{
"flock_id": "other-external-id",
"fetched_at": timezone.now().isoformat().replace("+00:00", "Z"),
"payload": {
"data": {
"actualTemperature": 300,
"humidity": 700,
"wind": 5,
"water": 12,
"sensors": {},
}
},
}
]
}
class SyncIotPanelsPerFlockTests(TestCase):
def setUp(self):
owner = User.objects.create_user(user_name="owner", password="secret")
self.site = Site.objects.create(site_name="Site A", user=owner)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
self.flock_a = Flock.objects.create(
flock_name="Lantai 1",
kandang=self.kandang,
iot_api_url="https://example.test/iot/a?token=1",
)
self.flock_b = Flock.objects.create(
flock_name="Lantai 2",
kandang=self.kandang,
iot_api_url="",
)
today = timezone.localdate()
Cycle.objects.create(
kandang=self.kandang,
start_date=today - timedelta(days=5),
end_date=today + timedelta(days=30),
doc_in_weight=40,
doc_in_count=10000,
)
@patch.object(IotApiClient, "fetch_page")
def test_upserts_on_flock_with_url_and_ignores_upstream_flock_id(self, mock_fetch):
mock_fetch.return_value = SAMPLE_PAYLOAD
summary = sync_iot_panels(max_pages=1)
self.assertEqual(summary.upserted, 1)
self.assertEqual(IotPanel.objects.filter(flock=self.flock_a).count(), 1)
self.assertEqual(IotPanel.objects.filter(flock=self.flock_b).count(), 0)
mock_fetch.assert_called()
@patch.object(IotApiClient, "fetch_page")
def test_blank_url_flock_never_fetched(self, mock_fetch):
mock_fetch.return_value = SAMPLE_PAYLOAD
summary = sync_iot_panels(flock_id=self.flock_b.pk, max_pages=1)
self.assertEqual(summary.upserted, 0)
self.assertEqual(summary.pages, 0)
mock_fetch.assert_not_called()
@patch.object(IotApiClient, "fetch_page")
def test_flock_id_filter_only_syncs_that_flock(self, mock_fetch):
mock_fetch.return_value = SAMPLE_PAYLOAD
flock_c = Flock.objects.create(
flock_name="Lantai 3",
kandang=self.kandang,
iot_api_url="https://example.test/iot/c",
)
summary = sync_iot_panels(flock_id=flock_c.pk, max_pages=1)
self.assertEqual(summary.upserted, 1)
self.assertEqual(IotPanel.objects.filter(flock=flock_c).count(), 1)
self.assertEqual(IotPanel.objects.filter(flock=self.flock_a).count(), 0)
def test_fetch_page_merges_existing_query_string(self):
client = IotApiClient("https://example.test/iot/a?token=1")
with patch("apps.operations.services.iot_api.httpx.Client") as mock_client_cls:
mock_client = mock_client_cls.return_value.__enter__.return_value
mock_response = mock_client.get.return_value
mock_response.status_code = 200
mock_response.json.return_value = {"results": []}
client.fetch_page(2)
called_url = mock_client.get.call_args.args[0]
self.assertIn("token=1", called_url)
self.assertIn("page=2", called_url)
class PanelIotSyncActionTests(TestCase):
def setUp(self):
self.owner = User.objects.create_user(user_name="owner", password="secret")
self.staff = User.objects.create_user(
user_name="staff",
password="staff123",
status=User.STATUS_ACTIVE,
is_staff=True,
)
self.site = Site.objects.create(site_name="Site A", user=self.owner)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
self.flock = Flock.objects.create(
flock_name="Lantai 1",
kandang=self.kandang,
iot_api_url="https://example.test/iot/a",
)
self.blank = Flock.objects.create(
flock_name="Lantai 2",
kandang=self.kandang,
iot_api_url="",
)
def _csrf_headers(self, client: Client) -> dict[str, str]:
token = client.cookies["csrftoken"].value
return {"HTTP_X_CSRFTOKEN": token}
def _login(self, client: Client) -> None:
client.get("/api/v1/auth/csrf/")
response = client.post(
"/api/v1/auth/login/",
{"user_name": "staff", "password": "staff123"},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
@patch("apps.operations.views.sync_iot_panels")
def test_sync_action_ok_for_staff(self, mock_sync):
mock_sync.return_value = type("S", (), {"upserted": 2, "skipped": 0, "pages": 1})()
client = Client(enforce_csrf_checks=True)
self._login(client)
response = client.post(
"/api/v1/panel-iot/sync/",
{"flock_id": self.flock.pk},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["upserted"], 2)
mock_sync.assert_called_once_with(flock_id=self.flock.pk)
def test_sync_action_rejects_blank_url(self):
client = Client(enforce_csrf_checks=True)
self._login(client)
response = client.post(
"/api/v1/panel-iot/sync/",
{"flock_id": self.blank.pk},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 400, response.content)
+35 -1
View File
@@ -8,7 +8,7 @@ from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.farms.models import Cycle
from apps.farms.models import Cycle, Flock
from apps.operations.models import (
AIInsight,
ChickenCounting,
@@ -33,6 +33,7 @@ from apps.operations.services.chicken_counting_edge import (
sync_chicken_counting_for_cycle,
sync_chicken_weight_for_cycle,
)
from apps.operations.services.iot_api import IotApiError, sync_iot_panels
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates", "cached"})
@@ -250,6 +251,39 @@ class IotPanelViewSet(viewsets.ModelViewSet):
)
return Response(dates)
@action(detail=False, methods=["post"], url_path="sync")
def sync_from_api(self, request):
flock_id = request.data.get("flock_id")
if not flock_id:
return Response(
{"detail": "flock_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
flock = Flock.objects.get(pk=int(flock_id))
except (Flock.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Flock not found."}, status=status.HTTP_404_NOT_FOUND)
if not (flock.iot_api_url or "").strip():
return Response(
{"detail": "Flock has no IoT API URL configured."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
summary = sync_iot_panels(flock_id=flock.pk)
except IotApiError as exc:
code = getattr(exc, "status_code", None)
return Response(
{"detail": str(exc)},
status=code if code and code >= 400 else status.HTTP_502_BAD_GATEWAY,
)
return Response(
{
"upserted": summary.upserted,
"skipped": summary.skipped,
"pages": summary.pages,
}
)
class AIInsightViewSet(CycleScopedViewSet):
queryset = AIInsight.objects.select_related("cycle", "kandang").all()