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.accounts.scoping import filter_cycles_queryset, filter_flocks_queryset 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, ) 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() # Scope via cycle → kandang → site → owner visible_cycles = filter_cycles_queryset(Cycle.objects.all(), self.request.user) qs = qs.filter(cycle_id__in=visible_cycles.values_list("pk", flat=True)) 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 = filter_cycles_queryset( Cycle.objects.select_related("kandang"), request.user, ).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=["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 = filter_cycles_queryset(Cycle.objects.all(), request.user).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() visible_flocks = filter_flocks_queryset(Flock.objects.all(), self.request.user) qs = qs.filter(flock_id__in=visible_flocks.values_list("pk", flat=True)) params = self.request.query_params cycle_id = params.get("cycle_id") if cycle_id: visible_cycles = filter_cycles_queryset(Cycle.objects.all(), self.request.user) try: cycle = visible_cycles.get(pk=cycle_id) except Cycle.DoesNotExist: return qs.none() # Active cycles have end_date=None; date__lte=None raises ValueError (500). end = cycle.end_date or cycle.soft_end_date() qs = qs.filter( flock__kandang_id=cycle.kandang_id, date__gte=cycle.start_date, date__lte=end, ) 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", "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=["get"], url_path="cached") def cached(self, request): import json 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, ) report_type = params.get("report_type") or "page" report_period = params.get("report_period") or "current" row = ( AIInsight.objects.filter( cycle_id=cycle_id, kandang_id=kandang_id, topic=topic, report_type=report_type, report_period=report_period, ) .order_by("-updated_at") .first() ) if row is None: return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND) raw_cites = row.citations if isinstance(raw_cites, list): cites = raw_cites elif isinstance(raw_cites, str) and raw_cites.strip(): try: cites = json.loads(raw_cites) except Exception: cites = [] else: cites = [] return Response({ "success": True, "insight_text": row.insight_text, "summary": row.summary, "alert": row.alert, "citations": cites, "topic": row.topic, "report_type": row.report_type, "report_period": row.report_period, }) @action(detail=False, methods=["post"], url_path="generate") def generate(self, request): params = request.data or {} 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, ) report_type = params.get("report_type") or "page" report_period = params.get("report_period") or "current" row = ( AIInsight.objects.filter( cycle_id=cycle_id, kandang_id=kandang_id, topic=topic, report_type=report_type, report_period=report_period, ) .order_by("-updated_at") .first() ) if row is not None: import json raw_cites = row.citations try: cites = json.loads(raw_cites) if isinstance(raw_cites, str) else (raw_cites or []) except Exception: cites = [] return Response({ "success": True, "insight_text": row.insight_text, "summary": row.summary, "alert": row.alert, "citations": cites, "topic": row.topic, "report_type": row.report_type, "report_period": row.report_period, }) return Response( {"detail": "Insight belum tersedia pada mirror HQ. Pastikan insight telah di-generate pada site dashboard dan disinkronkan."}, status=status.HTTP_404_NOT_FOUND, ) 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)