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, ) from apps.operations.services.karung_web import ( initial_balance_accuracy, pull_initial_balance_iot, ) 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. Staff accounts may read sites/kandangs (to pick one and create a cycle) but cannot create, edit, or delete them — only superadmins manage structures. """ def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False if request.method in permissions.SAFE_METHODS: return True return bool(request.user.is_superuser or request.user.status == User.STATUS_SUPERADMIN) class SiteViewSet(viewsets.ModelViewSet): queryset = Site.objects.select_related("user").all() serializer_class = SiteSerializer permission_classes = [IsSuperAdminForWrite] def get_queryset(self): qs = super().get_queryset() user_id = self.request.query_params.get("user_id") if user_id: qs = qs.filter(user_id=user_id) return qs class KandangViewSet(viewsets.ModelViewSet): queryset = Kandang.objects.select_related("site").all() serializer_class = KandangSerializer permission_classes = [IsSuperAdminForWrite] def get_queryset(self): qs = super().get_queryset() site_id = self.request.query_params.get("site_id") if site_id: qs = qs.filter(site_id=site_id) return qs class FlockViewSet(viewsets.ModelViewSet): queryset = Flock.objects.select_related("kandang").all() serializer_class = FlockSerializer permission_classes = [IsSuperAdminForWrite] def get_queryset(self): qs = super().get_queryset() kandang_id = self.request.query_params.get("kandang_id") if kandang_id: qs = qs.filter(kandang_id=kandang_id) return qs class CycleViewSet(viewsets.ModelViewSet): queryset = Cycle.objects.select_related("kandang", "kandang__site", "close_requested_by").all() serializer_class = CycleSerializer 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("status"): 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.""" cycle = self.get_object() date_str = request.query_params.get("date") if not date_str: return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST) balance_date = parse_date(str(date_str)) if balance_date is None: return Response( {"detail": "date must be in YYYY-MM-DD format."}, status=status.HTTP_400_BAD_REQUEST, ) manual_raw = request.query_params.get("manual") if manual_raw is None: feed_in_manual = cycle.feed_initial_balance else: try: feed_in_manual = int(manual_raw) except (TypeError, ValueError): return Response( {"detail": "manual must be an integer."}, status=status.HTTP_400_BAD_REQUEST, ) iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date) return Response( { "cycle": cycle.pk, "date": balance_date.isoformat(), "feed_initial_balance": cycle.feed_initial_balance, "feed_initial_balance_date": ( cycle.feed_initial_balance_date.isoformat() if cycle.feed_initial_balance_date else None ), "manual": feed_in_manual, "manual_total": feed_in_manual, "iot": iot_in, "accuracy": initial_balance_accuracy(feed_in_manual, iot_in), "sync_error": sync_error, } ) @action(detail=True, methods=["post"], url_path="initial-balance") def set_initial_balance(self, request, pk=None): """Save the feed-in balance for the cycle (Saldo Awal). Sets ``feed_initial_balance`` on the cycle (the only cycle field this feature writes — never during cycle creation). Opening balance is kept on the cycle for saldo only and is not written into daily FeedSacks rows. Pulls the IoT-detected "in" for that date from karung-web-admin ``/api/combined`` and returns a manual vs IoT accuracy figure. """ cycle = self.get_object() date_str = request.data.get("date") if not date_str: return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST) balance_date = parse_date(str(date_str)) if balance_date is None: return Response( {"detail": "date must be in YYYY-MM-DD format."}, status=status.HTTP_400_BAD_REQUEST, ) try: feed_in_manual = int(request.data.get("feed_in_manual")) except (TypeError, ValueError): return Response( {"detail": "feed_in_manual must be an integer."}, status=status.HTTP_400_BAD_REQUEST, ) if feed_in_manual < 0: return Response( {"detail": "feed_in_manual cannot be negative."}, status=status.HTTP_400_BAD_REQUEST, ) previous_date = cycle.feed_initial_balance_date previous_balance = cycle.feed_initial_balance cycle.feed_initial_balance = feed_in_manual cycle.feed_initial_balance_date = balance_date cycle.save( update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"] ) # Pull IoT "in" at the balance date from karung-web-admin /api/combined. iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date) cleanup_opening_balance_feed_rows( cycle, previous_date=previous_date, previous_balance=previous_balance, balance_date=balance_date, balance_amount=feed_in_manual, ) recompute_feed_sack_in_totals(cycle) accuracy = initial_balance_accuracy(feed_in_manual, iot_in) return Response( { "cycle": cycle.pk, "date": balance_date.isoformat(), "feed_initial_balance": cycle.feed_initial_balance, "feed_initial_balance_date": ( cycle.feed_initial_balance_date.isoformat() if cycle.feed_initial_balance_date else None ), "manual": feed_in_manual, "manual_total": feed_in_manual, "iot": iot_in, "accuracy": accuracy, "sync_error": sync_error, } )