updating cycle close request feature to get approval from national dashboard instead auto from system

This commit is contained in:
Alberto-Audrix committed 2026-09-09 14:18:39 +07:00
1 parent 3a868cf1db
commit e4dbfd0252
31 files changed
+1322 -182

No files matched your search

+150 -1
View File
@@ -1,11 +1,16 @@
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,
@@ -16,6 +21,69 @@ from apps.operations.services.karung_web import (
)
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.
@@ -71,7 +139,7 @@ class FlockViewSet(viewsets.ModelViewSet):
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related("kandang").all()
queryset = Cycle.objects.select_related("kandang", "kandang__site", "close_requested_by").all()
serializer_class = CycleSerializer
def get_queryset(self):
@@ -83,6 +151,87 @@ class CycleViewSet(viewsets.ModelViewSet):
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."""