update api configuration for panel iot and adjust ai insight
This commit is contained in:
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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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, "")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user