updating cycle close request feature to get approval from national dashboard instead auto from system

This commit is contained in:
Alberto-Audrix committed 2026-09-09 14:18:39 +07:00
1 parent 3a868cf1db
commit e4dbfd0252
31 files changed
+1322 -182

No files matched your search

@@ -0,0 +1,78 @@
# Generated manually for GM cycle-close approval request flow
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def null_end_dates_for_open_cycles(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Cycle.objects.filter(status="active").update(end_date=None)
class Migration(migrations.Migration):
dependencies = [
("farms", "0014_kandang_feed_in_button_urls"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveConstraint(
model_name="cycle",
name="chk_cycles_date_range",
),
migrations.AlterField(
model_name="cycle",
name="end_date",
field=models.DateField(blank=True, null=True),
),
migrations.AlterField(
model_name="cycle",
name="total_days",
field=models.IntegerField(default=1),
),
migrations.AlterField(
model_name="cycle",
name="status",
field=models.CharField(
choices=[
("active", "Active"),
("pending_close", "Pending close"),
("closed", "Closed"),
],
db_index=True,
default="active",
max_length=30,
),
),
migrations.AddField(
model_name="cycle",
name="proposed_end_date",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="close_requested_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="close_requested_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="requested_cycle_closes",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddConstraint(
model_name="cycle",
constraint=models.CheckConstraint(
condition=models.Q(("end_date__isnull", True))
| models.Q(("end_date__gte", models.F("start_date"))),
name="chk_cycles_date_range",
),
),
migrations.RunPython(null_end_dates_for_open_cycles, migrations.RunPython.noop),
]
+141 -15
View File
@@ -2,6 +2,7 @@ from datetime import date
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from apps.accounts.models import User
@@ -58,16 +59,18 @@ class Flock(models.Model):
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_PENDING_CLOSE = "pending_close"
STATUS_CLOSED = "closed"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_PENDING_CLOSE, "Pending close"),
(STATUS_CLOSED, "Closed"),
]
cycle_id = models.BigAutoField(primary_key=True)
total_days = models.IntegerField()
total_days = models.IntegerField(default=1)
start_date = models.DateField()
end_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
@@ -76,6 +79,15 @@ class Cycle(models.Model):
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
proposed_end_date = models.DateField(null=True, blank=True)
close_requested_at = models.DateTimeField(null=True, blank=True)
close_requested_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="requested_cycle_closes",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -84,7 +96,8 @@ class Cycle(models.Model):
ordering = ["-start_date"]
constraints = [
models.CheckConstraint(
check=models.Q(end_date__gte=models.F("start_date")),
check=models.Q(end_date__isnull=True)
| models.Q(end_date__gte=models.F("start_date")),
name="chk_cycles_date_range",
)
]
@@ -95,26 +108,137 @@ class Cycle(models.Model):
def clean(self) -> None:
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError({"end_date": "Must be on or after start_date"})
if (
self.proposed_end_date
and self.start_date
and self.proposed_end_date < self.start_date
):
raise ValidationError(
{"proposed_end_date": "Must be on or after start_date"}
)
def resolved_status(self) -> str:
"""Close the cycle once dashboard data through the end date is visible."""
def soft_end_date(self) -> date:
"""Dashboard-facing upper bound: approved end_date, else publish visible-through."""
if self.end_date:
return self.end_date
from apps.operations.services.visibility import visible_through_date
if visible_through_date() >= self.end_date:
return self.STATUS_CLOSED
return self.STATUS_ACTIVE
return visible_through_date()
def operational_end_date(self, *, today: date | None = None) -> date:
"""Inclusive upper date for edge/IoT history sync — not publish-gated.
Open cycles sync through calendar today so morning edge data is not
skipped until DASHBOARD_PUBLISH_HOUR. Closed cycles stop at end_date.
"""
if today is None:
today = timezone.localdate()
if self.end_date:
return min(today, self.end_date)
return today
def resolved_status(self) -> str:
"""Status is approval-driven — never auto-closed by publish cutoff."""
return self.status
def sync_status(self) -> bool:
resolved = self.resolved_status()
if self.status == resolved:
return False
self.status = resolved
return True
return False
def save(self, *args, **kwargs):
self.sync_status()
# Only refresh provisional total_days on full saves (not partial close-field updates).
if kwargs.get("update_fields") is None:
if self.end_date and self.start_date:
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
elif self.start_date and not self.end_date:
soft = self.soft_end_date()
self.total_days = max((soft - self.start_date).days + 1, 1)
super().save(*args, **kwargs)
def clear_close_request_fields(self) -> None:
self.proposed_end_date = None
self.close_requested_at = None
self.close_requested_by = None
def request_close(self, requester: User, proposed_end_date: date) -> None:
if requester.status != User.STATUS_ACTIVE:
raise ValidationError("Only active users may request cycle close.")
if self.status != self.STATUS_ACTIVE:
raise ValidationError("Only an active cycle can request close.")
if proposed_end_date < self.start_date:
raise ValidationError(
{"proposed_end_date": "Must be on or after start_date"}
)
self.status = self.STATUS_PENDING_CLOSE
self.proposed_end_date = proposed_end_date
self.close_requested_at = timezone.now()
self.close_requested_by = requester
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def cancel_close_request(self, actor: User) -> None:
if actor.status != User.STATUS_ACTIVE:
raise ValidationError("Only active users may cancel a close request.")
if self.status == self.STATUS_ACTIVE and self.proposed_end_date is None:
return
if self.status != self.STATUS_PENDING_CLOSE:
raise ValidationError("Cycle has no pending close request.")
self.status = self.STATUS_ACTIVE
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def apply_close_decision(self, *, approved: bool, end_date: date | None = None) -> None:
if approved:
if end_date is None:
raise ValidationError("end_date is required when approving close.")
if end_date < self.start_date:
raise ValidationError({"end_date": "Must be on or after start_date"})
self.status = self.STATUS_CLOSED
self.end_date = end_date
self.total_days = max((end_date - self.start_date).days + 1, 1)
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"end_date",
"total_days",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
return
if self.status == self.STATUS_CLOSED:
raise ValidationError("Cycle is already closed.")
self.status = self.STATUS_ACTIVE
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
@property
def current_day(self) -> int:
"""0-based cycle age: start_date is day 0."""
@@ -124,8 +248,10 @@ class Cycle(models.Model):
if effective < self.start_date:
return 0
elapsed = (effective - self.start_date).days
max_day = max(self.total_days - 1, 0)
return min(max(elapsed, 0), max_day)
if self.end_date:
max_day = max(self.total_days - 1, 0)
return min(max(elapsed, 0), max_day)
return max(elapsed, 0)
@property
def visible_through_date(self) -> date:
+18 -3
View File
@@ -75,6 +75,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
visible_through_date = serializers.DateField(read_only=True)
soft_end_date = serializers.SerializerMethodField()
class Meta:
model = Cycle
@@ -85,6 +86,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"total_days",
"current_day",
"visible_through_date",
"soft_end_date",
"start_date",
"end_date",
"chick_in_weight",
@@ -92,6 +94,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"feed_initial_balance",
"feed_initial_balance_date",
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"created_at",
"updated_at",
]
@@ -100,20 +105,30 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"kandang_name",
"feed_initial_balance",
"feed_initial_balance_date",
"end_date",
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"created_at",
"updated_at",
"soft_end_date",
]
def get_soft_end_date(self, obj: Cycle):
return obj.soft_end_date()
def validate(self, attrs):
attrs.pop("status", None)
# Defense in depth: end_date is read-only (GM approve only).
attrs.pop("end_date", None)
if self.instance is None:
attrs.setdefault("total_days", 1)
start = attrs.get("start_date", getattr(self.instance, "start_date", None))
end = attrs.get("end_date", getattr(self.instance, "end_date", None))
end = getattr(self.instance, "end_date", None) if self.instance else None
if start and end and end < start:
raise serializers.ValidationError({"end_date": "Must be on or after start_date."})
return attrs
def to_representation(self, instance):
if instance.sync_status():
instance.save(update_fields=["status", "updated_at"])
return super().to_representation(instance)
Whitespace-only changes.
@@ -0,0 +1,74 @@
"""Notify pusat about cycle close request / cancel."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from django.conf import settings
from apps.farms.models import Cycle
logger = logging.getLogger(__name__)
class PusatCloseError(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)
def _configured() -> bool:
return bool(
settings.PUSAT_BASE_URL
and settings.PUSAT_API_KEY
and settings.PUSAT_CITY_SITE_ID
)
def notify_pusat_close_request(cycle: Cycle, *, cancelled: bool = False) -> None:
"""POST ingest/cancel to pusat. Raises PusatCloseError on failure when configured."""
if not _configured():
logger.info("Pusat close channel not configured; skipping notify for cycle %s", cycle.pk)
return
base = settings.PUSAT_BASE_URL.rstrip("/")
headers = {"X-API-Key": settings.PUSAT_API_KEY, "Content-Type": "application/json"}
city_site_id = int(settings.PUSAT_CITY_SITE_ID)
try:
if cancelled:
url = f"{base}/cycle-close/cancel/"
payload: dict[str, Any] = {
"city_site_id": city_site_id,
"source_cycle_id": cycle.pk,
}
else:
url = f"{base}/cycle-close/ingest/"
payload = {
"city_site_id": city_site_id,
"source_cycle_id": cycle.pk,
"kandang_name": cycle.kandang.kandang_name,
"site_name": cycle.kandang.site.site_name,
"start_date": cycle.start_date.isoformat(),
"proposed_end_date": cycle.proposed_end_date.isoformat()
if cycle.proposed_end_date
else None,
"doc_in_weight": cycle.doc_in_weight,
"doc_in_count": cycle.doc_in_count,
"total_days": cycle.total_days,
"requested_at": cycle.close_requested_at.isoformat()
if cycle.close_requested_at
else None,
}
with httpx.Client(timeout=30) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code >= 400:
detail = response.text[:300]
raise PusatCloseError(f"gagal kirim ke pusat — coba lagi ({response.status_code}: {detail})")
except PusatCloseError:
raise
except httpx.HTTPError as exc:
logger.exception("Pusat notify failed for cycle %s", cycle.pk)
raise PusatCloseError("gagal kirim ke pusat — coba lagi") from exc
+111 -7
View File
@@ -64,11 +64,12 @@ class FarmAPITests(TestCase):
def test_update_cycle(self):
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
original_end = start + timedelta(days=34)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
end_date=original_end,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
@@ -86,20 +87,22 @@ class FarmAPITests(TestCase):
)
self.assertEqual(response.status_code, 200, response.data)
cycle.refresh_from_db()
self.assertEqual(cycle.end_date, new_end)
self.assertEqual(cycle.total_days, 40)
# end_date is GM-approval only — client writes are ignored; total_days
# is recomputed from the existing end_date on full save.
self.assertEqual(cycle.end_date, original_end)
self.assertEqual(cycle.total_days, 35)
self.assertEqual(cycle.doc_in_count, 9500)
self.assertEqual(cycle.doc_in_weight, 42)
@patch("apps.operations.services.visibility.visible_through_date", return_value=date(2026, 9, 10))
def test_cycle_status_closes_when_end_date_is_visible(self, _mock_visible):
def test_cycle_stays_active_without_gm_approval(self, _mock_visible):
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),
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
@@ -108,9 +111,110 @@ class FarmAPITests(TestCase):
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_CLOSED)
self.assertEqual(response.data["status"], Cycle.STATUS_ACTIVE)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_CLOSED)
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
def test_active_user_can_request_and_cancel_close(self):
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=1,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
proposed = start + timedelta(days=34)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/request-close/",
{"proposed_end_date": proposed.isoformat()},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
body = response.data.get("cycle", response.data)
self.assertEqual(body["status"], Cycle.STATUS_PENDING_CLOSE)
self.assertEqual(body["proposed_end_date"], proposed.isoformat())
cancel = self.client.post(f"/api/v1/cycles/{cycle.pk}/cancel-close-request/", {}, format="json")
self.assertEqual(cancel.status_code, 200, cancel.data)
cancel_body = cancel.data.get("cycle", cancel.data)
self.assertEqual(cancel_body["status"], Cycle.STATUS_ACTIVE)
self.assertIsNone(cancel_body["proposed_end_date"])
def test_superadmin_cannot_request_close(self):
self.client.force_authenticate(self.admin)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=1,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/request-close/",
{"proposed_end_date": (start + timedelta(days=30)).isoformat()},
format="json",
)
self.assertEqual(response.status_code, 400, response.data)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
def test_session_user_cannot_apply_close_decision(self):
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
end = start + timedelta(days=34)
cycle = Cycle.objects.create(
kandang=self.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=end,
)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/",
{"decision": "approve", "end_date": end.isoformat()},
format="json",
)
self.assertEqual(response.status_code, 403, response.data)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE)
def test_apply_close_decision_approve(self):
from django.test import override_settings
start = date(2026, 8, 1)
end = start + timedelta(days=34)
cycle = Cycle.objects.create(
kandang=self.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=end,
)
with override_settings(PUSAT_API_KEY="pusat-test-key"):
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/",
{"decision": "approve", "end_date": end.isoformat()},
format="json",
HTTP_X_API_KEY="pusat-test-key",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED)
self.assertEqual(response.data["end_date"], end.isoformat())
self.assertEqual(response.data["total_days"], 35)
def test_flock_persists_across_cycles(self):
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
+150 -1
View File
@@ -1,11 +1,16 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
import secrets
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.farms.services.pusat_close import PusatCloseError, notify_pusat_close_request
from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals,
@@ -16,6 +21,69 @@ from apps.operations.services.karung_web import (
)
def _validation_detail(exc: ValidationError) -> str | dict:
if hasattr(exc, "message_dict"):
return exc.message_dict
if hasattr(exc, "messages"):
return "; ".join(str(m) for m in exc.messages)
return str(exc.message) if hasattr(exc, "message") else str(exc)
def _pusat_api_key_from_request(request) -> str | None:
return request.headers.get("X-API-Key") or request.META.get("HTTP_X_API_KEY")
class IsPusatCloseCallback(permissions.BasePermission):
"""Allow only the shared pusat callback key (not session users)."""
message = "Pusat API key required."
def has_permission(self, request, view):
expected = (getattr(settings, "PUSAT_API_KEY", "") or "").strip()
if not expected:
return False
provided = (_pusat_api_key_from_request(request) or "").strip()
if not provided:
return False
return secrets.compare_digest(provided, expected)
def _revert_pending_close(cycle: Cycle) -> None:
cycle.status = Cycle.STATUS_ACTIVE
cycle.clear_close_request_fields()
cycle.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def _restore_pending_close(
cycle: Cycle,
*,
proposed_end_date,
close_requested_at,
close_requested_by_id,
) -> None:
cycle.status = Cycle.STATUS_PENDING_CLOSE
cycle.proposed_end_date = proposed_end_date
cycle.close_requested_at = close_requested_at
cycle.close_requested_by_id = close_requested_by_id
cycle.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
class IsSuperAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated user; mutations only for superadmin.
@@ -71,7 +139,7 @@ class FlockViewSet(viewsets.ModelViewSet):
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related("kandang").all()
queryset = Cycle.objects.select_related("kandang", "kandang__site", "close_requested_by").all()
serializer_class = CycleSerializer
def get_queryset(self):
@@ -83,6 +151,87 @@ class CycleViewSet(viewsets.ModelViewSet):
qs = qs.filter(status=params["status"])
return qs
@action(detail=True, methods=["post"], url_path="request-close")
def request_close(self, request, pk=None):
cycle = self.get_object()
raw = request.data.get("proposed_end_date")
proposed = parse_date(str(raw)) if raw else None
if proposed is None:
return Response(
{"detail": "proposed_end_date is required (YYYY-MM-DD)."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
cycle.request_close(request.user, proposed)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
try:
notify_pusat_close_request(cycle, cancelled=False)
except PusatCloseError as exc:
_revert_pending_close(cycle)
return Response(
{"detail": exc.message},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(CycleSerializer(cycle).data)
@action(detail=True, methods=["post"], url_path="cancel-close-request")
def cancel_close_request(self, request, pk=None):
cycle = self.get_object()
snapshot = {
"proposed_end_date": cycle.proposed_end_date,
"close_requested_at": cycle.close_requested_at,
"close_requested_by_id": cycle.close_requested_by_id,
}
try:
cycle.cancel_close_request(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
try:
notify_pusat_close_request(cycle, cancelled=True)
except PusatCloseError as exc:
_restore_pending_close(cycle, **snapshot)
return Response(
{"detail": exc.message},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(CycleSerializer(cycle).data)
@action(
detail=True,
methods=["post"],
url_path="apply-close-decision",
authentication_classes=[],
permission_classes=[IsPusatCloseCallback],
)
def apply_close_decision(self, request, pk=None):
"""Machine endpoint: pusat pushes approve/reject via shared PUSAT_API_KEY."""
cycle = self.get_object()
decision = str(request.data.get("decision", "")).strip().lower()
if decision not in {"approve", "reject"}:
return Response(
{"detail": "decision must be 'approve' or 'reject'."},
status=status.HTTP_400_BAD_REQUEST,
)
end_date = None
if decision == "approve":
raw = request.data.get("end_date")
end_date = parse_date(str(raw)) if raw else None
if end_date is None:
return Response(
{"detail": "end_date is required when decision is approve."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
cycle.apply_close_decision(approved=(decision == "approve"), end_date=end_date)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data)
@action(detail=True, methods=["get"], url_path="initial-balance-compare")
def compare_initial_balance(self, request, pk=None):
"""Pull IoT masuk for a date and compare against the cycle saldo awal."""
@@ -5,11 +5,12 @@ from apps.farms.models import Cycle
from apps.operations.services.chicken_counting_edge import (
ChickenCountingEdgeError,
sync_chicken_counting_for_cycle,
sync_chicken_weight_for_cycle,
)
class Command(BaseCommand):
help = "Sync chicken counting + mortality IoT from the edge vision API"
help = "Sync chicken counting, mortality, and weight from the edge vision API"
def add_arguments(self, parser):
parser.add_argument("--cycle-id", type=int, default=None)
@@ -17,7 +18,8 @@ class Command(BaseCommand):
def handle(self, *args, **options):
counting_on = settings.CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED
mortality_on = settings.CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED
if not counting_on and not mortality_on:
weight_on = settings.CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED
if not counting_on and not mortality_on and not weight_on:
self.stdout.write(
"CHICKEN_COUNTING_EDGE_*_SYNC_ENABLED=false; skipping"
)
@@ -32,14 +34,27 @@ class Command(BaseCommand):
)
for cycle in cycles:
try:
summary = sync_chicken_counting_for_cycle(cycle)
except ChickenCountingEdgeError as exc:
self.stderr.write(f"cycle={cycle.pk}: {exc}")
continue
self.stdout.write(
self.style.SUCCESS(
f"cycle={cycle.pk} upserted={summary['upserted']} "
f"dates={','.join(d.isoformat() for d in summary['dates']) or '-'}"
)
)
if counting_on or mortality_on:
try:
summary = sync_chicken_counting_for_cycle(cycle)
except ChickenCountingEdgeError as exc:
self.stderr.write(f"cycle={cycle.pk} counting: {exc}")
else:
self.stdout.write(
self.style.SUCCESS(
f"cycle={cycle.pk} counting upserted={summary['upserted']} "
f"dates={','.join(d.isoformat() for d in summary['dates']) or '-'}"
)
)
if weight_on:
try:
w_summary = sync_chicken_weight_for_cycle(cycle)
except ChickenCountingEdgeError as exc:
self.stderr.write(f"cycle={cycle.pk} weight: {exc}")
else:
self.stdout.write(
self.style.SUCCESS(
f"cycle={cycle.pk} weight upserted={w_summary['upserted']} "
f"dates={','.join(d.isoformat() for d in w_summary['dates']) or '-'}"
)
)
@@ -12,7 +12,7 @@ from django.db import transaction
from django.utils import timezone
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting
from apps.operations.models import ChickenCounting, ChickenWeight
class ChickenCountingEdgeError(Exception):
@@ -87,10 +87,47 @@ def aggregate_mortality_by_date(
return by_date
def aggregate_weight_by_date(
rows: list[dict[str, Any]] | None,
*,
kandang_name: str,
) -> dict[date, dict[str, float | int]]:
"""Map date → fused weight metrics for the matching coop.
Prefer ``camera_id=FUSED``. When both floors (e.g. K1-L1 / K1-L2) carry the
same fused prediction, average weight/uniformity and take max sample count
so duplicates are not double-counted.
"""
expected = coop_for_kandang(kandang_name).casefold()
floors_by_date: dict[date, list[dict[str, Any]]] = {}
for row in rows or []:
if str(row.get("camera_id") or "").strip().upper() != "FUSED":
continue
day = _parse_history_date(row.get("date"))
if day is None:
continue
coop = str(row.get("coop") or "").strip()
if coop.casefold() != expected:
continue
floors_by_date.setdefault(day, []).append(row)
by_date: dict[date, dict[str, float | int]] = {}
for day, floors in floors_by_date.items():
weights = [float(f.get("predicted_weight_g") or 0) for f in floors]
unifs = [float(f.get("uniformity") or 0) for f in floors]
samples = [int(f.get("n_valid_samples") or 0) for f in floors]
by_date[day] = {
"average_weight": sum(weights) / len(weights),
"uniformity": sum(unifs) / len(unifs),
"chicken_count": max(samples) if samples else 0,
}
return by_date
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
if today is None:
today = timezone.localdate()
end = min(today, cycle.end_date)
end = cycle.operational_end_date(today=today)
return cycle.start_date, end
@@ -99,6 +136,7 @@ class ChickenCountingEdgeClient:
DB_HISTORY_PATH = "/api/db/history"
MORTALITY_HISTORY_PATH = "/api/mortality/history"
WEIGHT_PREDICTIONS_PATH = "/api/weight/predictions"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (
@@ -136,6 +174,12 @@ class ChickenCountingEdgeClient:
return payload
return list(payload.get("results") or payload.get("history") or [])
def fetch_weight_predictions(self) -> list[dict[str, Any]]:
payload = self._get_json(self.WEIGHT_PREDICTIONS_PATH)
if isinstance(payload, list):
return payload
return list(payload.get("predictions") or payload.get("results") or [])
@transaction.atomic
def sync_chicken_counting_for_cycle(
@@ -212,3 +256,77 @@ def sync_chicken_counting_for_cycle(
upserted.append(day)
return {"dates": upserted, "upserted": len(upserted), "skipped": False}
@transaction.atomic
def sync_chicken_weight_for_cycle(
cycle: Cycle,
*,
predictions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Upsert ChickenWeight from edge weight predictions within the cycle window.
Day 0 (cycle start) always uses DOC weight and 0% uniformity — edge values
for that date are ignored.
"""
if not bool(getattr(settings, "CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED", True)):
return {"dates": [], "upserted": 0, "skipped": True}
client = ChickenCountingEdgeClient()
if predictions is None:
predictions = client.fetch_weight_predictions()
kandang_name = cycle.kandang.kandang_name
weight_by_date = aggregate_weight_by_date(predictions, kandang_name=kandang_name)
start, end = cycle_history_window(cycle)
if start > end:
return {"dates": [], "upserted": 0, "skipped": False}
doc_weight = int(cycle.doc_in_weight)
dates = sorted({d for d in weight_by_date if start <= d <= end} | {start})
prev_weight = float(doc_weight)
# Seed previous-day weight from any row before the first upsert date.
existing_before = (
ChickenWeight.objects.filter(cycle=cycle, date__lt=dates[0])
.order_by("-date")
.first()
)
if existing_before is not None:
prev_weight = float(existing_before.average_weight)
upserted: list[date] = []
for day in dates:
age = (day - start).days
if age == 0:
average_weight = float(doc_weight)
uniformity = 0.0
chicken_count = int(cycle.doc_in_count)
adg = 0.0
else:
metrics = weight_by_date.get(day)
if metrics is None:
continue
average_weight = float(metrics["average_weight"])
uniformity = float(metrics["uniformity"])
chicken_count = int(metrics["chicken_count"])
adg = round(average_weight - prev_weight, 2)
defaults = {
"age": age,
"doc_weight": doc_weight,
"average_weight": average_weight,
"chicken_count": chicken_count,
"uniformity": uniformity,
"average_daily_gain": adg,
}
ChickenWeight.objects.update_or_create(
cycle=cycle,
date=day,
defaults=defaults,
)
upserted.append(day)
prev_weight = average_weight
return {"dates": upserted, "upserted": len(upserted), "skipped": False}
@@ -104,10 +104,10 @@ def daily_totals_from_combined_history(
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
"""Inclusive date window for history upserts."""
"""Inclusive date window for history upserts (calendar today while open)."""
if today is None:
today = timezone.localdate()
end = min(today, cycle.end_date)
end = cycle.operational_end_date(today=today)
return cycle.start_date, end
@@ -11,11 +11,98 @@ from apps.operations.services.chicken_counting_edge import (
ChickenCountingEdgeError,
aggregate_counting_by_date,
aggregate_mortality_by_date,
aggregate_weight_by_date,
coop_for_kandang,
location_prefix_for_kandang,
sync_chicken_counting_for_cycle,
sync_chicken_weight_for_cycle,
)
WEIGHT_PREDICTIONS_SAMPLE = [
{
"date": "2026-09-02",
"camera_id": "FUSED",
"cycle_day": 103,
"predicted_weight_g": 1626.9,
"doc_weight_g": 42.0,
"n_valid_samples": 3215,
"coop": "K1",
"location": "K1-L1",
"uniformity": 85.0,
},
{
"date": "2026-09-02",
"camera_id": "FUSED",
"cycle_day": 103,
"predicted_weight_g": 1626.9,
"doc_weight_g": 42.0,
"n_valid_samples": 3215,
"coop": "K1",
"location": "K1-L2",
"uniformity": 85.0,
},
{
"date": "2026-09-02",
"camera_id": "CC1",
"cycle_day": 103,
"predicted_weight_g": 1600.0,
"n_valid_samples": 100,
"coop": "K1",
"location": "K1-L1",
"uniformity": 80.0,
},
{
"date": "2026-09-03",
"camera_id": "FUSED",
"cycle_day": 1,
"predicted_weight_g": 45.3,
"n_valid_samples": 18580,
"coop": "K1",
"location": "K1-L1",
"uniformity": 18.7,
},
{
"date": "2026-09-03",
"camera_id": "FUSED",
"cycle_day": 1,
"predicted_weight_g": 45.3,
"n_valid_samples": 18580,
"coop": "K1",
"location": "K1-L2",
"uniformity": 18.7,
},
{
"date": "2026-09-03",
"camera_id": "FUSED",
"cycle_day": 1,
"predicted_weight_g": 99.0,
"n_valid_samples": 10,
"coop": "K2",
"location": "K2-L1",
"uniformity": 50.0,
},
{
"date": "2026-09-04",
"camera_id": "FUSED",
"cycle_day": 2,
"predicted_weight_g": 58.2,
"n_valid_samples": 20751,
"coop": "K1",
"location": "K1-L1",
"uniformity": 17.2,
},
{
"date": "2026-08-20",
"camera_id": "FUSED",
"cycle_day": 0,
"predicted_weight_g": 40.0,
"n_valid_samples": 100,
"coop": "K1",
"location": "K1-L1",
"uniformity": 90.0,
},
]
DB_HISTORY_SAMPLE = [
{"date": "2026-09-02", "location": "K1-L1", "total": 14591},
{"date": "2026-09-02", "location": "K1-L2", "total": 14591},
@@ -54,6 +141,20 @@ class AggregationTests(TestCase):
self.assertEqual(by_date[date(2026, 9, 2)], 0)
self.assertEqual(by_date[date(2026, 9, 3)], 29)
def test_weight_uses_fused_coop_rows_and_averages_floors(self):
by_date = aggregate_weight_by_date(
WEIGHT_PREDICTIONS_SAMPLE, kandang_name="Kandang 1"
)
self.assertEqual(by_date[date(2026, 9, 2)]["average_weight"], 1626.9)
self.assertEqual(by_date[date(2026, 9, 2)]["uniformity"], 85.0)
# Identical floor duplicates must not double-count samples.
self.assertEqual(by_date[date(2026, 9, 2)]["chicken_count"], 3215)
self.assertEqual(by_date[date(2026, 9, 3)]["average_weight"], 45.3)
self.assertEqual(by_date[date(2026, 9, 3)]["uniformity"], 18.7)
self.assertNotIn(date(2026, 9, 3), aggregate_weight_by_date([], kandang_name="Kandang 1"))
# K2 row must not leak into K1.
self.assertNotEqual(by_date[date(2026, 9, 3)]["average_weight"], 99.0)
@override_settings(
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
@@ -96,6 +197,42 @@ class SyncChickenCountingTests(TestCase):
ChickenCounting.objects.filter(cycle=self.cycle, date=date(2026, 8, 20)).exists()
)
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
@patch(
"apps.operations.services.visibility.visible_through_date",
return_value=date(2026, 9, 3), # publish cutoff still yesterday
)
def test_open_cycle_syncs_through_today_not_publish_cutoff(
self, _mock_visible, mock_db, mock_mort, mock_today
):
"""Open cycles must ingest today's edge rows before DASHBOARD_PUBLISH_HOUR."""
mock_today.return_value = date(2026, 9, 4)
open_cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=1,
start_date=date(2026, 9, 2),
end_date=None,
doc_in_weight=35,
doc_in_count=50000,
status=Cycle.STATUS_ACTIVE,
)
mock_db.return_value = DB_HISTORY_SAMPLE + [
{"date": "2026-09-04", "location": "K1-L1", "total": 100},
{"date": "2026-09-04", "location": "K1-L2", "total": 100},
]
mock_mort.return_value = MORTALITY_HISTORY_SAMPLE + [
{"date": "2026-09-04", "coop": "K1", "total_mortality_count": 3},
]
summary = sync_chicken_counting_for_cycle(open_cycle)
self.assertIn(date(2026, 9, 4), summary["dates"])
row = ChickenCounting.objects.get(cycle=open_cycle, date=date(2026, 9, 4))
self.assertEqual(row.total_count, 200)
self.assertEqual(row.mortality_count, 3)
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
@@ -211,3 +348,141 @@ class ChickenCountingSyncApiTests(TestCase):
w_results = weight.data["results"] if isinstance(weight.data, dict) else weight.data
self.assertEqual(len(w_results), 1)
self.assertEqual(w_results[0]["date"], "2026-09-04")
@override_settings(
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=True,
)
class SyncChickenWeightTests(TestCase):
def setUp(self):
user = User.objects.create_user(user_name="weight-sync-user", password="secret")
site = Site.objects.create(site_name="Sukawarna", user=user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=33,
start_date=date(2026, 9, 2),
end_date=date(2026, 10, 4),
doc_in_weight=35,
doc_in_count=50000,
status=Cycle.STATUS_ACTIVE,
)
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
@patch(
"apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_weight_predictions"
)
def test_day0_uses_doc_weight_and_zero_uniformity(self, mock_pred, mock_today):
mock_today.return_value = date(2026, 9, 4)
mock_pred.return_value = WEIGHT_PREDICTIONS_SAMPLE
summary = sync_chicken_weight_for_cycle(self.cycle)
self.assertEqual(
sorted(summary["dates"]),
[date(2026, 9, 2), date(2026, 9, 3), date(2026, 9, 4)],
)
day0 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
self.assertEqual(day0.age, 0)
self.assertEqual(day0.doc_weight, 35)
self.assertEqual(day0.average_weight, 35.0)
self.assertEqual(day0.uniformity, 0.0)
self.assertEqual(day0.average_daily_gain, 0.0)
self.assertEqual(day0.chicken_count, 50000)
day1 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 3))
self.assertEqual(day1.age, 1)
self.assertEqual(day1.average_weight, 45.3)
self.assertEqual(day1.uniformity, 18.7)
self.assertAlmostEqual(day1.average_daily_gain, 10.3)
self.assertEqual(day1.chicken_count, 18580)
day2 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 4))
self.assertEqual(day2.average_weight, 58.2)
self.assertAlmostEqual(day2.average_daily_gain, 12.9)
self.assertFalse(
ChickenWeight.objects.filter(cycle=self.cycle, date=date(2026, 8, 20)).exists()
)
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
@patch(
"apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_weight_predictions"
)
def test_creates_day0_even_without_edge_row(self, mock_pred, mock_today):
mock_today.return_value = date(2026, 9, 4)
mock_pred.return_value = [
row
for row in WEIGHT_PREDICTIONS_SAMPLE
if row["date"] != "2026-09-02"
]
sync_chicken_weight_for_cycle(self.cycle)
day0 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
self.assertEqual(day0.average_weight, 35.0)
self.assertEqual(day0.uniformity, 0.0)
@patch(
"apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_weight_predictions"
)
def test_skips_when_weight_sync_disabled(self, mock_pred):
with override_settings(CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=False):
summary = sync_chicken_weight_for_cycle(self.cycle)
self.assertTrue(summary["skipped"])
mock_pred.assert_not_called()
self.assertEqual(ChickenWeight.objects.filter(cycle=self.cycle).count(), 0)
@override_settings(
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=True,
)
class ChickenWeightSyncApiTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="weight-api", password="secret")
self.client = APIClient()
self.client.force_authenticate(self.user)
site = Site.objects.create(site_name="Sukawarna", user=self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=33,
start_date=date(2026, 9, 2),
end_date=date(2026, 10, 4),
doc_in_weight=35,
doc_in_count=50000,
status=Cycle.STATUS_ACTIVE,
)
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
@patch(
"apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_weight_predictions"
)
def test_sync_action_upserts(self, mock_pred, mock_today):
mock_today.return_value = date(2026, 9, 4)
mock_pred.return_value = WEIGHT_PREDICTIONS_SAMPLE
response = self.client.post(
"/api/v1/chicken-weights/sync/",
{"cycle_id": self.cycle.pk},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(ChickenWeight.objects.filter(cycle=self.cycle).count(), 3)
day0 = ChickenWeight.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
self.assertEqual(day0.average_weight, 35.0)
self.assertEqual(day0.uniformity, 0.0)
@patch(
"apps.operations.views.sync_chicken_weight_for_cycle",
side_effect=ChickenCountingEdgeError("edge down"),
)
def test_sync_action_returns_502_on_edge_error(self, _mock_sync):
response = self.client.post(
"/api/v1/chicken-weights/sync/",
{"cycle_id": self.cycle.pk},
format="json",
)
self.assertEqual(response.status_code, 502)
self.assertIn("edge down", response.data["detail"])
+29 -1
View File
@@ -31,6 +31,7 @@ from apps.operations.services.karung_web import KarungWebError, sync_karung_for_
from apps.operations.services.chicken_counting_edge import (
ChickenCountingEdgeError,
sync_chicken_counting_for_cycle,
sync_chicken_weight_for_cycle,
)
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
@@ -119,6 +120,33 @@ class ChickenWeightViewSet(CycleScopedViewSet):
def applies_visibility_filter(self) -> bool:
return False
@action(detail=False, methods=["post"], url_path="sync")
def sync_from_edge(self, request):
cycle_id = request.data.get("cycle_id")
if not cycle_id:
return Response(
{"detail": "cycle_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
cycle = Cycle.objects.select_related("kandang").get(pk=cycle_id)
except Cycle.DoesNotExist:
return Response({"detail": "Cycle not found."}, status=status.HTTP_404_NOT_FOUND)
try:
summary = sync_chicken_weight_for_cycle(cycle)
except (ChickenCountingEdgeError, ValidationError) 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"],
"dates": [d.isoformat() for d in summary["dates"]],
}
)
@action(detail=False, methods=["get"], url_path="latest-avg")
def latest_average(self, request):
cycle_id = request.query_params.get("cycle_id")
@@ -199,7 +227,7 @@ class IotPanelViewSet(viewsets.ModelViewSet):
qs = qs.filter(
flock__kandang_id=cycle.kandang_id,
date__gte=cycle.start_date,
date__lte=cycle.end_date,
date__lte=cycle.operational_end_date(),
)
if params.get("flock_id"):
qs = qs.filter(flock_id=params["flock_id"])