285 lines
11 KiB
Python
285 lines
11 KiB
Python
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.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"])
|
|
|
|
source_kandang_raw = payload.get("source_kandang_id", payload.get("kandang_id"))
|
|
source_kandang_id = None
|
|
if source_kandang_raw not in (None, ""):
|
|
try:
|
|
source_kandang_id = int(source_kandang_raw)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValidationError("source_kandang_id must be an integer.") from exc
|
|
|
|
kandang = None
|
|
if source_kandang_id is not None:
|
|
kandang = Kandang.objects.filter(
|
|
site=site, source_kandang_id=source_kandang_id
|
|
).first()
|
|
if kandang is None:
|
|
kandang = Kandang.objects.create(
|
|
site=site,
|
|
kandang_name=kandang_name,
|
|
source_kandang_id=source_kandang_id,
|
|
)
|
|
elif kandang.kandang_name != kandang_name:
|
|
kandang.kandang_name = kandang_name
|
|
kandang.save(update_fields=["kandang_name", "updated_at"])
|
|
if kandang is None:
|
|
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)
|