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, Flock 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.iot_api import IotPushError, handle_iot_push from apps.operations.services.visibility import dashboard_publish_time, visible_through_date READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates", "cached"}) 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 IotPanelPushView(APIView): """Webhook endpoint for IoT panels to push sensor data.""" permission_classes = [AllowAny] authentication_classes = [] def post(self, request, flock_id: int): try: flock = Flock.objects.get(pk=flock_id) except Flock.DoesNotExist: return Response( {"detail": "Flock not found."}, status=status.HTTP_404_NOT_FOUND, ) try: panel = handle_iot_push(flock, request.data) except IotPushError as exc: code = getattr(exc, "status_code", None) return Response( {"detail": str(exc)}, status=code if code and code >= 400 else status.HTTP_400_BAD_REQUEST, ) except Exception as exc: return Response( {"detail": f"Push failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) return Response({"ok": True, "panel_id": panel.pk}) class AIInsightViewSet(CycleScopedViewSet): queryset = AIInsight.objects.select_related("cycle", "kandang").all() serializer_class = AIInsightSerializer def get_queryset(self): qs = super().get_queryset() params = self.request.query_params if params.get("kandang_id"): qs = qs.filter(kandang_id=params["kandang_id"]) if params.get("topic"): qs = qs.filter(topic=params["topic"]) if params.get("report_type"): qs = qs.filter(report_type=params["report_type"]) if params.get("report_period"): qs = qs.filter(report_period=params["report_period"]) return qs @action(detail=False, methods=["post"], url_path="generate") def generate(self, request): from apps.operations.services.insight_service import generate_insight data = request.data or {} cycle_id = data.get("cycle_id") kandang_id = data.get("kandang_id") topic = data.get("topic") context = data.get("context") or {} report_type = data.get("report_type") or "page" report_period = data.get("report_period") or "current" force_refresh = bool(data.get("force_refresh")) if not cycle_id or not kandang_id or not topic: return Response( {"detail": "cycle_id, kandang_id, and topic are required."}, status=status.HTTP_400_BAD_REQUEST, ) # Reject mixed-farm prompts: context must match selected kandang. ctx_kid = context.get("kandangId") or context.get("kandang_id") if ctx_kid is not None and int(ctx_kid) != int(kandang_id): return Response( {"detail": "context.kandangId must match kandang_id."}, status=status.HTTP_400_BAD_REQUEST, ) # Phase 4: async mode returns 202 + job_id (client polls jobs/{id}/). if bool(data.get("async")): from apps.operations.services import insight_jobs job_id = insight_jobs.create_job( { "cycle_id": int(cycle_id), "kandang_id": int(kandang_id), "topic": str(topic), "context": context if isinstance(context, dict) else {}, "report_type": str(report_type), "report_period": str(report_period), "force_refresh": force_refresh, } ) return Response( {"job_id": job_id, "status": "queued"}, status=status.HTTP_202_ACCEPTED, ) try: result = generate_insight( cycle_id=int(cycle_id), kandang_id=int(kandang_id), topic=str(topic), context=context if isinstance(context, dict) else {}, report_type=str(report_type), report_period=str(report_period), force_refresh=force_refresh, ) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: # noqa: BLE001 return Response( {"detail": f"Insight generate failed: {exc}"}, status=status.HTTP_502_BAD_GATEWAY, ) return Response(result) @action(detail=False, methods=["get"], url_path=r"jobs/(?P[^/.]+)") def job_status(self, request, job_id: str | None = None): from apps.operations.services import insight_jobs job = insight_jobs.get_job(job_id or "") if job is None: return Response({"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND) return Response(job) @action(detail=False, methods=["get"], url_path="cached") def cached(self, request): from apps.operations.services.insight_service import get_cached_insight params = request.query_params cycle_id = params.get("cycle_id") kandang_id = params.get("kandang_id") topic = params.get("topic") if not cycle_id or not kandang_id or not topic: return Response( {"detail": "cycle_id, kandang_id, and topic are required."}, status=status.HTTP_400_BAD_REQUEST, ) result = get_cached_insight( cycle_id=int(cycle_id), kandang_id=int(kandang_id), topic=str(topic), report_type=params.get("report_type") or "page", report_period=params.get("report_period") or "current", ) if result is None: return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND) return Response(result) 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)