from django.core.exceptions import ValidationError from django.utils.dateparse import parse_date, parse_datetime from rest_framework import permissions, status, viewsets from rest_framework.decorators import action, api_view, permission_classes from rest_framework.response import Response from apps.accounts.models import User from apps.accounts.permissions import CanApproveCycleClose, can_approve_cycle_close from apps.accounts.scoping import ( filter_cycles_queryset, filter_flocks_queryset, filter_kandangs_queryset, filter_sites_queryset, ) from apps.farms.models import Cycle, Flock, Kandang, Site from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer from apps.farms.services.site_close import SiteClosePushError, push_close_decision 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, ) from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site from apps.sync.models import ActiveSite def _validation_detail(exc: ValidationError): if hasattr(exc, "message_dict"): return exc.message_dict if hasattr(exc, "messages"): return "; ".join(str(m) for m in exc.messages) return str(getattr(exc, "message", exc)) def _upsert_mirror_cycle_from_ingest(active_site: ActiveSite, payload: dict) -> Cycle: source_cycle_id = payload.get("source_cycle_id") if not source_cycle_id: raise ValidationError("source_cycle_id is required.") proposed = parse_date(str(payload.get("proposed_end_date") or "")) if proposed is None: raise ValidationError("proposed_end_date is required.") start = parse_date(str(payload.get("start_date") or "")) if start is None: raise ValidationError("start_date is required.") site_name = str(payload.get("site_name") or active_site.name)[:30] kandang_name = str(payload.get("kandang_name") or "Kandang")[:30] owner = active_site.managed_by if owner is None: owner = User.objects.filter(status=User.STATUS_GM).first() if owner is None: raise ValidationError("No GM available to own mirrored site.") site = ensure_hq_site_for_active_site(active_site) if site.site_name != site_name and not Site.objects.filter( user=owner, site_name=site_name ).exclude(pk=site.pk).exists(): # Prefer ingest site_name when it does not collide. site.site_name = site_name site.save(update_fields=["site_name", "updated_at"]) kandang, _ = Kandang.objects.get_or_create( site=site, kandang_name=kandang_name, defaults={}, ) cycle = Cycle.objects.filter( active_site=active_site, source_cycle_id=source_cycle_id ).first() if cycle is None: cycle = Cycle( kandang=kandang, active_site=active_site, source_cycle_id=source_cycle_id, start_date=start, end_date=None, doc_in_weight=int(payload.get("doc_in_weight") or 0), doc_in_count=int(payload.get("doc_in_count") or 0), total_days=int(payload.get("total_days") or 1), status=Cycle.STATUS_ACTIVE, ) cycle.save() else: cycle.kandang = kandang cycle.start_date = start cycle.doc_in_weight = int(payload.get("doc_in_weight") or cycle.doc_in_weight) cycle.doc_in_count = int(payload.get("doc_in_count") or cycle.doc_in_count) cycle.save() requested_dt = parse_datetime(str(payload.get("requested_at") or "")) or None cycle.mark_pending_close(proposed, requested_at=requested_dt) return cycle @api_view(["POST"]) @permission_classes([permissions.IsAuthenticated]) def cycle_close_ingest(request): """Site → pusat: register/update a pending close request.""" active_site_id = request.data.get("active_site_id") try: active_site = ActiveSite.objects.get(pk=active_site_id, is_active=True) except (ActiveSite.DoesNotExist, TypeError, ValueError): return Response( {"detail": "Invalid active_site_id."}, status=status.HTTP_400_BAD_REQUEST ) try: cycle = _upsert_mirror_cycle_from_ingest(active_site, request.data) except ValidationError as exc: return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response(CycleSerializer(cycle).data) @api_view(["POST"]) @permission_classes([permissions.IsAuthenticated]) def cycle_close_cancel(request): """Site → pusat: cancel a pending close request.""" active_site_id = request.data.get("active_site_id") source_cycle_id = request.data.get("source_cycle_id") try: active_site = ActiveSite.objects.get(pk=active_site_id, is_active=True) except (ActiveSite.DoesNotExist, TypeError, ValueError): return Response( {"detail": "Invalid active_site_id."}, status=status.HTTP_400_BAD_REQUEST ) cycle = Cycle.objects.filter( active_site=active_site, source_cycle_id=source_cycle_id ).first() if cycle is None: return Response({"detail": "Mirror cycle not found."}, status=status.HTTP_404_NOT_FOUND) try: cycle.cancel_pending_close() except ValidationError as exc: return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response(CycleSerializer(cycle).data) class IsDirectorAdminForWrite(permissions.BasePermission): """Reads for any authenticated executive; structure writes for director_admin. Mirrored farm structures should normally arrive via active-site sync. Manual create/edit/delete is reserved for director_admin (bootstrap / repair). """ 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 getattr(request.user, "status", None) in { User.STATUS_DIRECTOR_ADMIN, User.STATUS_SUPERUSER, } ) class SiteViewSet(viewsets.ModelViewSet): queryset = Site.objects.select_related("user").all() serializer_class = SiteSerializer permission_classes = [IsDirectorAdminForWrite] def get_queryset(self): qs = filter_sites_queryset(super().get_queryset(), self.request.user) 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 = [IsDirectorAdminForWrite] def get_queryset(self): qs = filter_kandangs_queryset(super().get_queryset(), self.request.user) 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 = [IsDirectorAdminForWrite] def get_queryset(self): qs = filter_flocks_queryset(super().get_queryset(), self.request.user) 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", "close_approved_by", "active_site", "kandang__site" ).all() serializer_class = CycleSerializer def get_queryset(self): qs = filter_cycles_queryset(super().get_queryset(), self.request.user) 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="approve-close", permission_classes=[CanApproveCycleClose], ) def approve_close(self, request, pk=None): """GM-only: approve closing a cycle on the pusat mirror.""" if not can_approve_cycle_close(request.user): return Response({"detail": "Only GM may approve cycle close."}, status=status.HTTP_403_FORBIDDEN) cycle = self.get_object() try: cycle.approve_close(request.user) except ValidationError as exc: return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST) site_error = None try: push_close_decision(cycle, approved=True) except SiteClosePushError as exc: site_error = exc.message data = CycleSerializer(cycle).data if site_error: return Response({"cycle": data, "site_error": site_error}) return Response(data) @action( detail=True, methods=["post"], url_path="reject-close", permission_classes=[CanApproveCycleClose], ) def reject_close(self, request, pk=None): """GM-only: reject a pending close request.""" if not can_approve_cycle_close(request.user): return Response({"detail": "Only GM may reject cycle close."}, status=status.HTTP_403_FORBIDDEN) cycle = self.get_object() try: cycle.reject_close(request.user) except ValidationError as exc: return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST) site_error = None try: push_close_decision(cycle, approved=False) except SiteClosePushError as exc: site_error = exc.message data = CycleSerializer(cycle).data if site_error: return Response({"cycle": data, "site_error": site_error}) return Response(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, } )