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, api_view, permission_classes from rest_framework.response import Response from rest_framework.views import APIView from apps.accounts.authentication import ApiKeyAuthentication 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.farms.services.pusat_export import ( PusatExportError, build_iot_export, build_ops_export, resolve_iot_export_params, resolve_ops_export_params, ) from apps.farms.services.pusat_register import PusatRegisterError, register_site_to_pusat 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) class IsApiKeyAuthenticated(permissions.BasePermission): """Require authentication via hashed site ApiKey (reject session-only users).""" message = "Site API key required." def has_permission(self, request, view): from apps.accounts.models import ApiKey return bool( request.user and request.user.is_authenticated and isinstance(getattr(request, "auth", None), ApiKey) ) 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 IsActiveStaff(permissions.BasePermission): """Staff with status=active only (not superadmin).""" message = "Hanya staf aktif yang dapat mendaftar ke pusat." def has_permission(self, request, view): user = request.user return bool( user and user.is_authenticated and getattr(user, "status", None) == User.STATUS_ACTIVE ) 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) def _pusat_registration_payload(site: Site) -> dict: return { "site_id": site.pk, "site_name": site.site_name, "code": site.pusat_code, "active_site_id": site.pusat_active_site_id, "registered_at": site.pusat_registered_at.isoformat() if site.pusat_registered_at else None, } @api_view(["GET"]) @permission_classes([permissions.IsAuthenticated]) def pusat_registration_status(request): """Return pusat registration status for sites (optional site_id filter).""" site_id = request.query_params.get("site_id") qs = Site.objects.exclude(pusat_active_site_id__isnull=True).exclude(pusat_code="") if site_id: qs = qs.filter(pk=site_id) return Response([_pusat_registration_payload(row) for row in qs]) @api_view(["POST"]) @permission_classes([IsActiveStaff]) def pusat_register(request): """Staff registers the selected farm site to executive ActiveSite registry.""" code = request.data.get("code") site_id = request.data.get("site_id") try: site = Site.objects.get(pk=site_id) except (Site.DoesNotExist, TypeError, ValueError): return Response( {"detail": "Pilih site yang akan didaftarkan."}, status=status.HTTP_400_BAD_REQUEST, ) try: site = register_site_to_pusat(site=site, code=str(code or "")) except PusatRegisterError as exc: return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST) return Response(_pusat_registration_payload(site), status=status.HTTP_200_OK) @api_view(["POST"]) @permission_classes([IsActiveStaff]) def pusat_change_code(request): """Staff changes the pusat registration code for an already-registered site.""" code = request.data.get("code") site_id = request.data.get("site_id") try: site = Site.objects.get(pk=site_id) except (Site.DoesNotExist, TypeError, ValueError): return Response( {"detail": "Pilih site yang akan diubah kodenya."}, status=status.HTTP_400_BAD_REQUEST, ) try: site = register_site_to_pusat( site=site, code=str(code or ""), allow_code_change=True ) except PusatRegisterError as exc: return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST) return Response(_pusat_registration_payload(site), status=status.HTTP_200_OK) 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=[ApiKeyAuthentication], permission_classes=[IsApiKeyAuthenticated], ) def apply_close_decision(self, request, pk=None): """Machine endpoint: pusat pushes approve/reject with the site SITE_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, } ) class PusatOpsExportView(APIView): """Machine pull: sites → kandangs → cycles + ops time-series (no publish cutoff).""" permission_classes = [permissions.IsAuthenticated] def get(self, request): params = request.query_params try: resolved = resolve_ops_export_params( site_id=params.get("site_id"), cycle_id=params.get("cycle_id"), date_from=params.get("from"), date_to=params.get("to"), page=params.get("page"), page_size=params.get("page_size"), ) payload = build_ops_export(**resolved) except PusatExportError as exc: return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST) return Response(payload) class PusatIotExportView(APIView): """Machine pull: sites → kandangs → flocks + iot_panels (no publish cutoff).""" permission_classes = [permissions.IsAuthenticated] def get(self, request): params = request.query_params try: resolved = resolve_iot_export_params( site_id=params.get("site_id"), kandang_id=params.get("kandang_id"), flock_id=params.get("flock_id"), date_from=params.get("from"), date_to=params.get("to"), page=params.get("page"), page_size=params.get("page_size"), ) payload = build_iot_export(**resolved) except PusatExportError as exc: return Response({"detail": exc.message}, status=status.HTTP_400_BAD_REQUEST) return Response(payload)