from __future__ import annotations from django.conf import settings from django.core.exceptions import ValidationError from rest_framework import status, viewsets from rest_framework.decorators import action 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.operations.models import ( AIInsight, ChickenCounting, ChickenWeight, FeedSacks, IotPanel, KPI, ManualInput, ) from apps.operations.serializers import ( AIInsightSerializer, ChickenCountingSerializer, ChickenWeightSerializer, FeedSacksSerializer, IotPanelSerializer, KPISerializer, ManualInputSerializer, ) from apps.operations.services.karung_web import KarungWebError, sync_karung_for_cycle 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 READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates"}) class VisibilityFilteredMixin: visibility_date_field = "date" def applies_visibility_filter(self) -> bool: return getattr(self, "action", None) in READ_ACTIONS def filter_visible_through(self, qs): if not self.applies_visibility_filter(): return qs cutoff = visible_through_date() return qs.filter(**{f"{self.visibility_date_field}__lte": cutoff}) class CycleScopedViewSet(VisibilityFilteredMixin, viewsets.ModelViewSet): def get_queryset(self): qs = super().get_queryset() params = self.request.query_params if params.get("cycle_id"): qs = qs.filter(cycle_id=params["cycle_id"]) if params.get("flock_id"): qs = qs.filter(cycle__kandang__flocks=params["flock_id"]).distinct() if params.get("date"): qs = qs.filter(date=params["date"]) return self.filter_visible_through(qs) class KPIViewSet(CycleScopedViewSet): queryset = KPI.objects.select_related("cycle").all() serializer_class = KPISerializer class ManualInputViewSet(CycleScopedViewSet): queryset = ManualInput.objects.select_related("cycle").all() serializer_class = ManualInputSerializer class ChickenCountingViewSet(CycleScopedViewSet): """Chicken counting + mortality IoT — not subject to dashboard publish cutoff.""" queryset = ChickenCounting.objects.select_related("cycle").all() serializer_class = ChickenCountingSerializer 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_counting_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"]], } ) class ChickenWeightViewSet(CycleScopedViewSet): """Chicken weight — not subject to dashboard publish cutoff.""" queryset = ChickenWeight.objects.select_related("cycle").all() serializer_class = ChickenWeightSerializer 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") if not cycle_id: return Response( {"detail": "cycle_id is required."}, status=status.HTTP_400_BAD_REQUEST, ) row = self.get_queryset().filter(cycle_id=cycle_id).order_by("-date", "-pk").first() if row is None: return Response( {"detail": "No chicken weight exists for this cycle."}, status=status.HTTP_404_NOT_FOUND, ) return Response(self.get_serializer(row).data) class FeedSacksViewSet(CycleScopedViewSet): queryset = FeedSacks.objects.select_related("cycle").all() serializer_class = FeedSacksSerializer @action(detail=False, methods=["get"], url_path="latest") def latest(self, request): cycle_id = request.query_params.get("cycle_id") if not cycle_id: return Response( {"detail": "cycle_id is required."}, status=status.HTTP_400_BAD_REQUEST, ) row = self.get_queryset().filter(cycle_id=cycle_id).order_by("-date", "-pk").first() if row is None: return Response( {"detail": "No feed sacks row exists for this cycle."}, status=status.HTTP_404_NOT_FOUND, ) return Response(self.get_serializer(row).data) @action(detail=False, methods=["post"], url_path="request") def request_sync(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.get(pk=cycle_id) except Cycle.DoesNotExist: return Response({"detail": "Cycle not found."}, status=status.HTTP_404_NOT_FOUND) section = request.data.get("section") or "today" try: feed_sack, summary = sync_karung_for_cycle(cycle, section=section) except (KarungWebError, 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( {"karung": self.get_serializer(feed_sack).data, "upstream": summary} ) class IotPanelViewSet(viewsets.ModelViewSet): """IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff.""" queryset = IotPanel.objects.select_related("flock", "flock__kandang").all() serializer_class = IotPanelSerializer def get_queryset(self): qs = super().get_queryset() params = self.request.query_params cycle_id = params.get("cycle_id") if cycle_id: try: cycle = Cycle.objects.get(pk=cycle_id) except Cycle.DoesNotExist: return qs.none() qs = qs.filter( flock__kandang_id=cycle.kandang_id, date__gte=cycle.start_date, date__lte=cycle.operational_end_date(), ) if params.get("flock_id"): qs = qs.filter(flock_id=params["flock_id"]) if params.get("date"): qs = qs.filter(date=params["date"]) return qs def paginate_queryset(self, queryset): if self.request.query_params.get("date"): return None return super().paginate_queryset(queryset) @action(detail=False, methods=["get"], url_path="dates") def dates(self, request): dates = list( self.get_queryset() .order_by("date") .values_list("date", flat=True) .distinct() ) return Response(dates) class AIInsightViewSet(CycleScopedViewSet): queryset = AIInsight.objects.select_related("cycle").all() serializer_class = AIInsightSerializer class HealthView(APIView): permission_classes = [AllowAny] authentication_classes = [] def get(self, request): cutoff = visible_through_date() publish = dashboard_publish_time() payload = { "status": "ok", "service": "newdashboard-api", "karung_web_admin_base_configured": bool(settings.KARUNG_WEB_ADMIN_BASE_URL), "visible_through_date": cutoff.isoformat(), "dashboard_publish_time": publish.strftime("%H:%M"), "dashboard_timezone": settings.TIME_ZONE, } return Response(payload)