484 lines
17 KiB
Python
484 lines
17 KiB
Python
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)
|