updating cycle close request feature to get approval from national dashboard instead auto from system
This commit is contained in:
1 parent
3a868cf1db
commit
e4dbfd0252
31 files changed
+1322
-182
No files matched your search
@@ -0,0 +1,78 @@
|
||||
# Generated manually for GM cycle-close approval request flow
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def null_end_dates_for_open_cycles(apps, schema_editor):
|
||||
Cycle = apps.get_model("farms", "Cycle")
|
||||
Cycle.objects.filter(status="active").update(end_date=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("farms", "0014_kandang_feed_in_button_urls"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="cycle",
|
||||
name="chk_cycles_date_range",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cycle",
|
||||
name="end_date",
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cycle",
|
||||
name="total_days",
|
||||
field=models.IntegerField(default=1),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cycle",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("active", "Active"),
|
||||
("pending_close", "Pending close"),
|
||||
("closed", "Closed"),
|
||||
],
|
||||
db_index=True,
|
||||
default="active",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="proposed_end_date",
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="close_requested_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="close_requested_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="requested_cycle_closes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="cycle",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("end_date__isnull", True))
|
||||
| models.Q(("end_date__gte", models.F("start_date"))),
|
||||
name="chk_cycles_date_range",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(null_end_dates_for_open_cycles, migrations.RunPython.noop),
|
||||
]
|
||||
+141
-15
@@ -2,6 +2,7 @@ from datetime import date
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
@@ -58,16 +59,18 @@ class Flock(models.Model):
|
||||
|
||||
class Cycle(models.Model):
|
||||
STATUS_ACTIVE = "active"
|
||||
STATUS_PENDING_CLOSE = "pending_close"
|
||||
STATUS_CLOSED = "closed"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_ACTIVE, "Active"),
|
||||
(STATUS_PENDING_CLOSE, "Pending close"),
|
||||
(STATUS_CLOSED, "Closed"),
|
||||
]
|
||||
|
||||
cycle_id = models.BigAutoField(primary_key=True)
|
||||
total_days = models.IntegerField()
|
||||
total_days = models.IntegerField(default=1)
|
||||
start_date = models.DateField()
|
||||
end_date = models.DateField()
|
||||
end_date = models.DateField(null=True, blank=True)
|
||||
doc_in_weight = models.IntegerField()
|
||||
doc_in_count = models.IntegerField()
|
||||
feed_initial_balance = models.IntegerField(default=0)
|
||||
@@ -76,6 +79,15 @@ class Cycle(models.Model):
|
||||
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
|
||||
)
|
||||
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
|
||||
proposed_end_date = models.DateField(null=True, blank=True)
|
||||
close_requested_at = models.DateTimeField(null=True, blank=True)
|
||||
close_requested_by = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="requested_cycle_closes",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -84,7 +96,8 @@ class Cycle(models.Model):
|
||||
ordering = ["-start_date"]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=models.Q(end_date__gte=models.F("start_date")),
|
||||
check=models.Q(end_date__isnull=True)
|
||||
| models.Q(end_date__gte=models.F("start_date")),
|
||||
name="chk_cycles_date_range",
|
||||
)
|
||||
]
|
||||
@@ -95,26 +108,137 @@ class Cycle(models.Model):
|
||||
def clean(self) -> None:
|
||||
if self.end_date and self.start_date and self.end_date < self.start_date:
|
||||
raise ValidationError({"end_date": "Must be on or after start_date"})
|
||||
if (
|
||||
self.proposed_end_date
|
||||
and self.start_date
|
||||
and self.proposed_end_date < self.start_date
|
||||
):
|
||||
raise ValidationError(
|
||||
{"proposed_end_date": "Must be on or after start_date"}
|
||||
)
|
||||
|
||||
def resolved_status(self) -> str:
|
||||
"""Close the cycle once dashboard data through the end date is visible."""
|
||||
def soft_end_date(self) -> date:
|
||||
"""Dashboard-facing upper bound: approved end_date, else publish visible-through."""
|
||||
if self.end_date:
|
||||
return self.end_date
|
||||
from apps.operations.services.visibility import visible_through_date
|
||||
|
||||
if visible_through_date() >= self.end_date:
|
||||
return self.STATUS_CLOSED
|
||||
return self.STATUS_ACTIVE
|
||||
return visible_through_date()
|
||||
|
||||
def operational_end_date(self, *, today: date | None = None) -> date:
|
||||
"""Inclusive upper date for edge/IoT history sync — not publish-gated.
|
||||
|
||||
Open cycles sync through calendar today so morning edge data is not
|
||||
skipped until DASHBOARD_PUBLISH_HOUR. Closed cycles stop at end_date.
|
||||
"""
|
||||
if today is None:
|
||||
today = timezone.localdate()
|
||||
if self.end_date:
|
||||
return min(today, self.end_date)
|
||||
return today
|
||||
|
||||
def resolved_status(self) -> str:
|
||||
"""Status is approval-driven — never auto-closed by publish cutoff."""
|
||||
return self.status
|
||||
|
||||
def sync_status(self) -> bool:
|
||||
resolved = self.resolved_status()
|
||||
if self.status == resolved:
|
||||
return False
|
||||
self.status = resolved
|
||||
return True
|
||||
return False
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.sync_status()
|
||||
# Only refresh provisional total_days on full saves (not partial close-field updates).
|
||||
if kwargs.get("update_fields") is None:
|
||||
if self.end_date and self.start_date:
|
||||
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
|
||||
elif self.start_date and not self.end_date:
|
||||
soft = self.soft_end_date()
|
||||
self.total_days = max((soft - self.start_date).days + 1, 1)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clear_close_request_fields(self) -> None:
|
||||
self.proposed_end_date = None
|
||||
self.close_requested_at = None
|
||||
self.close_requested_by = None
|
||||
|
||||
def request_close(self, requester: User, proposed_end_date: date) -> None:
|
||||
if requester.status != User.STATUS_ACTIVE:
|
||||
raise ValidationError("Only active users may request cycle close.")
|
||||
if self.status != self.STATUS_ACTIVE:
|
||||
raise ValidationError("Only an active cycle can request close.")
|
||||
if proposed_end_date < self.start_date:
|
||||
raise ValidationError(
|
||||
{"proposed_end_date": "Must be on or after start_date"}
|
||||
)
|
||||
self.status = self.STATUS_PENDING_CLOSE
|
||||
self.proposed_end_date = proposed_end_date
|
||||
self.close_requested_at = timezone.now()
|
||||
self.close_requested_by = requester
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def cancel_close_request(self, actor: User) -> None:
|
||||
if actor.status != User.STATUS_ACTIVE:
|
||||
raise ValidationError("Only active users may cancel a close request.")
|
||||
if self.status == self.STATUS_ACTIVE and self.proposed_end_date is None:
|
||||
return
|
||||
if self.status != self.STATUS_PENDING_CLOSE:
|
||||
raise ValidationError("Cycle has no pending close request.")
|
||||
self.status = self.STATUS_ACTIVE
|
||||
self.clear_close_request_fields()
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
def apply_close_decision(self, *, approved: bool, end_date: date | None = None) -> None:
|
||||
if approved:
|
||||
if end_date is None:
|
||||
raise ValidationError("end_date is required when approving close.")
|
||||
if end_date < self.start_date:
|
||||
raise ValidationError({"end_date": "Must be on or after start_date"})
|
||||
self.status = self.STATUS_CLOSED
|
||||
self.end_date = end_date
|
||||
self.total_days = max((end_date - self.start_date).days + 1, 1)
|
||||
self.clear_close_request_fields()
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"end_date",
|
||||
"total_days",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return
|
||||
|
||||
if self.status == self.STATUS_CLOSED:
|
||||
raise ValidationError("Cycle is already closed.")
|
||||
self.status = self.STATUS_ACTIVE
|
||||
self.clear_close_request_fields()
|
||||
self.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def current_day(self) -> int:
|
||||
"""0-based cycle age: start_date is day 0."""
|
||||
@@ -124,8 +248,10 @@ class Cycle(models.Model):
|
||||
if effective < self.start_date:
|
||||
return 0
|
||||
elapsed = (effective - self.start_date).days
|
||||
max_day = max(self.total_days - 1, 0)
|
||||
return min(max(elapsed, 0), max_day)
|
||||
if self.end_date:
|
||||
max_day = max(self.total_days - 1, 0)
|
||||
return min(max(elapsed, 0), max_day)
|
||||
return max(elapsed, 0)
|
||||
|
||||
@property
|
||||
def visible_through_date(self) -> date:
|
||||
|
||||
@@ -75,6 +75,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
|
||||
current_day = serializers.IntegerField(read_only=True)
|
||||
visible_through_date = serializers.DateField(read_only=True)
|
||||
soft_end_date = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
@@ -85,6 +86,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"total_days",
|
||||
"current_day",
|
||||
"visible_through_date",
|
||||
"soft_end_date",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"chick_in_weight",
|
||||
@@ -92,6 +94,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"feed_initial_balance",
|
||||
"feed_initial_balance_date",
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -100,20 +105,30 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"kandang_name",
|
||||
"feed_initial_balance",
|
||||
"feed_initial_balance_date",
|
||||
"end_date",
|
||||
"status",
|
||||
"proposed_end_date",
|
||||
"close_requested_at",
|
||||
"close_requested_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"soft_end_date",
|
||||
]
|
||||
|
||||
def get_soft_end_date(self, obj: Cycle):
|
||||
return obj.soft_end_date()
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs.pop("status", None)
|
||||
# Defense in depth: end_date is read-only (GM approve only).
|
||||
attrs.pop("end_date", None)
|
||||
if self.instance is None:
|
||||
attrs.setdefault("total_days", 1)
|
||||
start = attrs.get("start_date", getattr(self.instance, "start_date", None))
|
||||
end = attrs.get("end_date", getattr(self.instance, "end_date", None))
|
||||
end = getattr(self.instance, "end_date", None) if self.instance else None
|
||||
if start and end and end < start:
|
||||
raise serializers.ValidationError({"end_date": "Must be on or after start_date."})
|
||||
return attrs
|
||||
|
||||
def to_representation(self, instance):
|
||||
if instance.sync_status():
|
||||
instance.save(update_fields=["status", "updated_at"])
|
||||
return super().to_representation(instance)
|
||||
Whitespace-only changes.
@@ -0,0 +1,74 @@
|
||||
"""Notify pusat about cycle close request / cancel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
|
||||
from apps.farms.models import Cycle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PusatCloseError(Exception):
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _configured() -> bool:
|
||||
return bool(
|
||||
settings.PUSAT_BASE_URL
|
||||
and settings.PUSAT_API_KEY
|
||||
and settings.PUSAT_CITY_SITE_ID
|
||||
)
|
||||
|
||||
|
||||
def notify_pusat_close_request(cycle: Cycle, *, cancelled: bool = False) -> None:
|
||||
"""POST ingest/cancel to pusat. Raises PusatCloseError on failure when configured."""
|
||||
if not _configured():
|
||||
logger.info("Pusat close channel not configured; skipping notify for cycle %s", cycle.pk)
|
||||
return
|
||||
|
||||
base = settings.PUSAT_BASE_URL.rstrip("/")
|
||||
headers = {"X-API-Key": settings.PUSAT_API_KEY, "Content-Type": "application/json"}
|
||||
city_site_id = int(settings.PUSAT_CITY_SITE_ID)
|
||||
|
||||
try:
|
||||
if cancelled:
|
||||
url = f"{base}/cycle-close/cancel/"
|
||||
payload: dict[str, Any] = {
|
||||
"city_site_id": city_site_id,
|
||||
"source_cycle_id": cycle.pk,
|
||||
}
|
||||
else:
|
||||
url = f"{base}/cycle-close/ingest/"
|
||||
payload = {
|
||||
"city_site_id": city_site_id,
|
||||
"source_cycle_id": cycle.pk,
|
||||
"kandang_name": cycle.kandang.kandang_name,
|
||||
"site_name": cycle.kandang.site.site_name,
|
||||
"start_date": cycle.start_date.isoformat(),
|
||||
"proposed_end_date": cycle.proposed_end_date.isoformat()
|
||||
if cycle.proposed_end_date
|
||||
else None,
|
||||
"doc_in_weight": cycle.doc_in_weight,
|
||||
"doc_in_count": cycle.doc_in_count,
|
||||
"total_days": cycle.total_days,
|
||||
"requested_at": cycle.close_requested_at.isoformat()
|
||||
if cycle.close_requested_at
|
||||
else None,
|
||||
}
|
||||
with httpx.Client(timeout=30) as client:
|
||||
response = client.post(url, json=payload, headers=headers)
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:300]
|
||||
raise PusatCloseError(f"gagal kirim ke pusat — coba lagi ({response.status_code}: {detail})")
|
||||
except PusatCloseError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
logger.exception("Pusat notify failed for cycle %s", cycle.pk)
|
||||
raise PusatCloseError("gagal kirim ke pusat — coba lagi") from exc
|
||||
+111
-7
@@ -64,11 +64,12 @@ class FarmAPITests(TestCase):
|
||||
def test_update_cycle(self):
|
||||
self.client.force_authenticate(self.user)
|
||||
start = date(2026, 8, 1)
|
||||
original_end = start + timedelta(days=34)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=35,
|
||||
start_date=start,
|
||||
end_date=start + timedelta(days=34),
|
||||
end_date=original_end,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
@@ -86,20 +87,22 @@ class FarmAPITests(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.end_date, new_end)
|
||||
self.assertEqual(cycle.total_days, 40)
|
||||
# end_date is GM-approval only — client writes are ignored; total_days
|
||||
# is recomputed from the existing end_date on full save.
|
||||
self.assertEqual(cycle.end_date, original_end)
|
||||
self.assertEqual(cycle.total_days, 35)
|
||||
self.assertEqual(cycle.doc_in_count, 9500)
|
||||
self.assertEqual(cycle.doc_in_weight, 42)
|
||||
|
||||
@patch("apps.operations.services.visibility.visible_through_date", return_value=date(2026, 9, 10))
|
||||
def test_cycle_status_closes_when_end_date_is_visible(self, _mock_visible):
|
||||
def test_cycle_stays_active_without_gm_approval(self, _mock_visible):
|
||||
self.client.force_authenticate(self.user)
|
||||
start = date(2026, 8, 1)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=35,
|
||||
start_date=start,
|
||||
end_date=start + timedelta(days=34),
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
@@ -108,9 +111,110 @@ class FarmAPITests(TestCase):
|
||||
response = self.client.get(f"/api/v1/cycles/{cycle.pk}/")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED)
|
||||
self.assertEqual(response.data["status"], Cycle.STATUS_ACTIVE)
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.status, Cycle.STATUS_CLOSED)
|
||||
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
|
||||
|
||||
def test_active_user_can_request_and_cancel_close(self):
|
||||
self.client.force_authenticate(self.user)
|
||||
start = date(2026, 8, 1)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
proposed = start + timedelta(days=34)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/request-close/",
|
||||
{"proposed_end_date": proposed.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
body = response.data.get("cycle", response.data)
|
||||
self.assertEqual(body["status"], Cycle.STATUS_PENDING_CLOSE)
|
||||
self.assertEqual(body["proposed_end_date"], proposed.isoformat())
|
||||
|
||||
cancel = self.client.post(f"/api/v1/cycles/{cycle.pk}/cancel-close-request/", {}, format="json")
|
||||
self.assertEqual(cancel.status_code, 200, cancel.data)
|
||||
cancel_body = cancel.data.get("cycle", cancel.data)
|
||||
self.assertEqual(cancel_body["status"], Cycle.STATUS_ACTIVE)
|
||||
self.assertIsNone(cancel_body["proposed_end_date"])
|
||||
|
||||
def test_superadmin_cannot_request_close(self):
|
||||
self.client.force_authenticate(self.admin)
|
||||
start = date(2026, 8, 1)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/request-close/",
|
||||
{"proposed_end_date": (start + timedelta(days=30)).isoformat()},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400, response.data)
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
|
||||
|
||||
def test_session_user_cannot_apply_close_decision(self):
|
||||
self.client.force_authenticate(self.user)
|
||||
start = date(2026, 8, 1)
|
||||
end = start + timedelta(days=34)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_PENDING_CLOSE,
|
||||
proposed_end_date=end,
|
||||
)
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/",
|
||||
{"decision": "approve", "end_date": end.isoformat()},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 403, response.data)
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE)
|
||||
|
||||
def test_apply_close_decision_approve(self):
|
||||
from django.test import override_settings
|
||||
|
||||
start = date(2026, 8, 1)
|
||||
end = start + timedelta(days=34)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=1,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_PENDING_CLOSE,
|
||||
proposed_end_date=end,
|
||||
)
|
||||
with override_settings(PUSAT_API_KEY="pusat-test-key"):
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/apply-close-decision/",
|
||||
{"decision": "approve", "end_date": end.isoformat()},
|
||||
format="json",
|
||||
HTTP_X_API_KEY="pusat-test-key",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data["status"], Cycle.STATUS_CLOSED)
|
||||
self.assertEqual(response.data["end_date"], end.isoformat())
|
||||
self.assertEqual(response.data["total_days"], 35)
|
||||
|
||||
def test_flock_persists_across_cycles(self):
|
||||
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
|
||||
|
||||
+150
-1
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user