diff --git a/backend/.env.example b/backend/.env.example
index 1682c77..4d853fd 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -35,12 +35,13 @@ BOOTSTRAP_ADMIN_PASSWORD=
BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD=
-# Pusat (executive) cycle-close channel
-# Outbound notify uses these; inbound apply-close-decision requires PUSAT_API_KEY
-# in X-API-Key (leave blank to skip notify / reject inbound callbacks).
+# Pusat (executive) cycle-close + register channel
+# Outbound register/notify uses these; inbound apply-close-decision requires
+# PUSAT_API_KEY in X-API-Key. active_site_id is persisted on register.
PUSAT_BASE_URL=
PUSAT_API_KEY=
-PUSAT_CITY_SITE_ID=
+# This site's public API root including /api/v1 (written to executive ActiveSite).
+SITE_API_BASE_URL=
# karung-web-admin (external)
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py
index b9e8d8b..042f20b 100644
--- a/backend/apps/accounts/serializers.py
+++ b/backend/apps/accounts/serializers.py
@@ -12,6 +12,12 @@ class PkAsIdMixin(serializers.Serializer):
id = serializers.IntegerField(source="pk", read_only=True)
+class SlugPkAsIdMixin(serializers.Serializer):
+ """JSON `id` for slug / string primary keys."""
+
+ id = serializers.CharField(source="pk", read_only=True)
+
+
class UserSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = User
diff --git a/backend/apps/farms/migrations/0016_pusat_registration.py b/backend/apps/farms/migrations/0016_pusat_registration.py
new file mode 100644
index 0000000..0f376db
--- /dev/null
+++ b/backend/apps/farms/migrations/0016_pusat_registration.py
@@ -0,0 +1,36 @@
+# Generated manually for PusatRegistration
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("farms", "0015_cycle_close_request"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PusatRegistration",
+ fields=[
+ ("registration_id", models.BigAutoField(primary_key=True, serialize=False)),
+ ("code", models.SlugField(max_length=64)),
+ ("active_site_id", models.BigIntegerField()),
+ ("registered_at", models.DateTimeField(auto_now=True)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "site",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="pusat_registration",
+ to="farms.site",
+ ),
+ ),
+ ],
+ options={
+ "db_table": "pusat_registrations",
+ "ordering": ["-registered_at"],
+ },
+ ),
+ ]
diff --git a/backend/apps/farms/migrations/0017_site_slug_pk.py b/backend/apps/farms/migrations/0017_site_slug_pk.py
new file mode 100644
index 0000000..24874fa
--- /dev/null
+++ b/backend/apps/farms/migrations/0017_site_slug_pk.py
@@ -0,0 +1,121 @@
+# Convert Site.site_id from BigAutoField to name-based SlugField PK with numeric suffix.
+# atomic=False: PostgreSQL cannot ALTER TABLE in the same transaction after PK UPDATEs.
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.utils.text import slugify
+
+
+def _unique_slug(site_name: str, old_pk: str, used: set[str]) -> str:
+ base = slugify(str(site_name or "").strip())[:55] or f"site-{old_pk}"[:55]
+ try:
+ seed = int(old_pk) % 10000
+ except ValueError:
+ seed = sum(ord(c) for c in old_pk) % 10000
+ for i in range(10000):
+ code = f"{base}-{(seed + i) % 10000:04d}"
+ if code not in used:
+ used.add(code)
+ return code
+ raise RuntimeError(f"Could not allocate site id for {old_pk}")
+
+
+def forwards_slug_pks(apps, schema_editor):
+ Site = apps.get_model("farms", "Site")
+ Kandang = apps.get_model("farms", "Kandang")
+ PusatRegistration = apps.get_model("farms", "PusatRegistration")
+
+ used: set[str] = set()
+ mapping: dict[str, str] = {}
+ for site in Site.objects.all().order_by("site_id"):
+ old = str(site.site_id)
+ mapping[old] = _unique_slug(site.site_name, old, used)
+
+ for old_id, new_id in mapping.items():
+ if old_id == new_id:
+ continue
+ Kandang.objects.filter(site_id=old_id).update(site_id=new_id)
+ PusatRegistration.objects.filter(site_id=old_id).update(site_id=new_id)
+ Site.objects.filter(site_id=old_id).update(site_id=new_id)
+
+ # Ensure site names are unique before adding the constraint.
+ seen_names: dict[str, int] = {}
+ for site in Site.objects.all().order_by("site_id"):
+ name = site.site_name
+ count = seen_names.get(name, 0)
+ seen_names[name] = count + 1
+ if count == 0:
+ continue
+ suffix = f"-{count + 1}"
+ site.site_name = f"{name[: 30 - len(suffix)]}{suffix}"
+ site.save(update_fields=["site_name"])
+
+
+def noop_reverse(apps, schema_editor):
+ raise RuntimeError("Cannot reverse site_id slug migration.")
+
+
+class Migration(migrations.Migration):
+
+ atomic = False
+
+ dependencies = [
+ ("farms", "0016_pusat_registration"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="kandang",
+ name="site",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="kandangs",
+ to="farms.site",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="pusatregistration",
+ name="site",
+ field=models.OneToOneField(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="pusat_registration",
+ to="farms.site",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="site",
+ name="site_id",
+ field=models.CharField(max_length=64, primary_key=True, serialize=False),
+ ),
+ migrations.RunPython(forwards_slug_pks, noop_reverse),
+ migrations.AlterField(
+ model_name="site",
+ name="site_id",
+ field=models.SlugField(max_length=64, primary_key=True, serialize=False),
+ ),
+ migrations.AlterField(
+ model_name="site",
+ name="site_name",
+ field=models.CharField(max_length=30, unique=True),
+ ),
+ migrations.AlterField(
+ model_name="kandang",
+ name="site",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="kandangs",
+ to="farms.site",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="pusatregistration",
+ name="site",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="pusat_registration",
+ to="farms.site",
+ ),
+ ),
+ ]
diff --git a/backend/apps/farms/migrations/0018_site_id_numeric_suffix.py b/backend/apps/farms/migrations/0018_site_id_numeric_suffix.py
new file mode 100644
index 0000000..b79d18d
--- /dev/null
+++ b/backend/apps/farms/migrations/0018_site_id_numeric_suffix.py
@@ -0,0 +1,93 @@
+# Append a 4-digit suffix to site ids that are still name-only slugs.
+
+import re
+import secrets
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.utils.text import slugify
+
+_HAS_SUFFIX = re.compile(r"-\d{4}$")
+
+
+def forwards_add_numeric_suffix(apps, schema_editor):
+ Site = apps.get_model("farms", "Site")
+ Kandang = apps.get_model("farms", "Kandang")
+ PusatRegistration = apps.get_model("farms", "PusatRegistration")
+
+ used = set(Site.objects.values_list("site_id", flat=True))
+ mapping: dict[str, str] = {}
+
+ for site in Site.objects.all().order_by("site_id"):
+ old = str(site.site_id)
+ if _HAS_SUFFIX.search(old):
+ continue
+ base = slugify(str(site.site_name or "").strip())[:55] or old[:55]
+ for _ in range(32):
+ candidate = f"{base}-{secrets.randbelow(10_000):04d}"
+ if candidate not in used:
+ used.add(candidate)
+ mapping[old] = candidate
+ break
+ else:
+ raise RuntimeError(f"Could not allocate numeric site id for {old}")
+
+ for old_id, new_id in mapping.items():
+ Kandang.objects.filter(site_id=old_id).update(site_id=new_id)
+ PusatRegistration.objects.filter(site_id=old_id).update(site_id=new_id)
+ Site.objects.filter(site_id=old_id).update(site_id=new_id)
+
+
+def noop_reverse(apps, schema_editor):
+ raise RuntimeError("Cannot reverse site id numeric suffix migration.")
+
+
+class Migration(migrations.Migration):
+
+ atomic = False
+
+ dependencies = [
+ ("farms", "0017_site_slug_pk"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="kandang",
+ name="site",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="kandangs",
+ to="farms.site",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="pusatregistration",
+ name="site",
+ field=models.OneToOneField(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="pusat_registration",
+ to="farms.site",
+ ),
+ ),
+ migrations.RunPython(forwards_add_numeric_suffix, noop_reverse),
+ migrations.AlterField(
+ model_name="kandang",
+ name="site",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="kandangs",
+ to="farms.site",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="pusatregistration",
+ name="site",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="pusat_registration",
+ to="farms.site",
+ ),
+ ),
+ ]
diff --git a/backend/apps/farms/migrations/0019_site_pusat_fields.py b/backend/apps/farms/migrations/0019_site_pusat_fields.py
new file mode 100644
index 0000000..f814ed1
--- /dev/null
+++ b/backend/apps/farms/migrations/0019_site_pusat_fields.py
@@ -0,0 +1,52 @@
+# Move pusat registration fields onto sites; drop pusat_registrations.
+
+from django.db import migrations, models
+
+
+def forwards_copy_registrations(apps, schema_editor):
+ Site = apps.get_model("farms", "Site")
+ PusatRegistration = apps.get_model("farms", "PusatRegistration")
+ for row in PusatRegistration.objects.select_related("site").all():
+ site = row.site
+ site.pusat_code = row.code or ""
+ site.pusat_active_site_id = row.active_site_id
+ site.pusat_registered_at = row.registered_at
+ site.save(
+ update_fields=[
+ "pusat_code",
+ "pusat_active_site_id",
+ "pusat_registered_at",
+ "updated_at",
+ ]
+ )
+
+
+def noop_reverse(apps, schema_editor):
+ raise RuntimeError("Cannot reverse pusat fields onto sites.")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("farms", "0018_site_id_numeric_suffix"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="site",
+ name="pusat_code",
+ field=models.SlugField(blank=True, default="", max_length=64),
+ ),
+ migrations.AddField(
+ model_name="site",
+ name="pusat_active_site_id",
+ field=models.BigIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name="site",
+ name="pusat_registered_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.RunPython(forwards_copy_registrations, noop_reverse),
+ migrations.DeleteModel(name="PusatRegistration"),
+ ]
diff --git a/backend/apps/farms/models.py b/backend/apps/farms/models.py
index d921a08..3ab6768 100644
--- a/backend/apps/farms/models.py
+++ b/backend/apps/farms/models.py
@@ -1,16 +1,46 @@
from datetime import date
+import secrets
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
+from django.utils.text import slugify
from apps.accounts.models import User
+def site_name_slug(site_name: str) -> str:
+ """Slug portion of a site id (no numeric suffix)."""
+ code = slugify(str(site_name or "").strip())[:55]
+ if not code:
+ raise ValidationError({"site_name": "Nama site tidak valid untuk dijadikan id."})
+ return code
+
+
+def site_id_from_name(site_name: str, *, numeric_suffix: int | None = None) -> str:
+ """Site PK: `{name-slug}-{4-digit number}`, e.g. sukawarna-4821."""
+ base = site_name_slug(site_name)
+ if numeric_suffix is None:
+ numeric_suffix = secrets.randbelow(10_000)
+ return f"{base}-{numeric_suffix:04d}"
+
+
+def allocate_site_id(site_name: str) -> str:
+ """Pick an unused site_id for this name."""
+ for _ in range(32):
+ candidate = site_id_from_name(site_name)
+ if not Site.objects.filter(pk=candidate).exists():
+ return candidate
+ raise ValidationError({"site_name": "Gagal membuat id site unik — coba lagi."})
+
+
class Site(models.Model):
- site_id = models.BigAutoField(primary_key=True)
- site_name = models.CharField(max_length=30)
+ site_id = models.SlugField(primary_key=True, max_length=64)
+ site_name = models.CharField(max_length=30, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites")
+ pusat_code = models.SlugField(max_length=64, blank=True, default="")
+ pusat_active_site_id = models.BigIntegerField(null=True, blank=True)
+ pusat_registered_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -21,6 +51,15 @@ class Site(models.Model):
def __str__(self) -> str:
return self.site_name
+ @property
+ def is_registered_to_pusat(self) -> bool:
+ return bool(self.pusat_active_site_id and self.pusat_code)
+
+ def save(self, *args, **kwargs):
+ if not self.site_id:
+ self.site_id = allocate_site_id(self.site_name)
+ super().save(*args, **kwargs)
+
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
diff --git a/backend/apps/farms/serializers.py b/backend/apps/farms/serializers.py
index 6866774..7986bd4 100644
--- a/backend/apps/farms/serializers.py
+++ b/backend/apps/farms/serializers.py
@@ -1,15 +1,31 @@
from rest_framework import serializers
-from apps.accounts.serializers import PkAsIdMixin
-from apps.farms.models import Cycle, Flock, Kandang, Site
+from apps.accounts.serializers import PkAsIdMixin, SlugPkAsIdMixin
+from apps.farms.models import Cycle, Flock, Kandang, Site, allocate_site_id, site_name_slug
-class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
+class SiteSerializer(SlugPkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Site
fields = ["id", "site_name", "user", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
+ def create(self, validated_data):
+ validated_data["site_id"] = allocate_site_id(validated_data["site_name"])
+ return super().create(validated_data)
+
+ def validate_site_name(self, value):
+ name = str(value or "").strip()
+ if not name:
+ raise serializers.ValidationError("Nama site wajib diisi.")
+ site_name_slug(name) # reject names that cannot form a slug
+ qs = Site.objects.filter(site_name__iexact=name)
+ if self.instance:
+ qs = qs.exclude(pk=self.instance.pk)
+ if qs.exists():
+ raise serializers.ValidationError("Nama site sudah dipakai.")
+ return name
+
class FeedInButtonUrlSerializer(serializers.Serializer):
label = serializers.CharField(max_length=50, required=False, allow_blank=True, default="")
@@ -18,6 +34,7 @@ class FeedInButtonUrlSerializer(serializers.Serializer):
class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
feed_in_button_urls = FeedInButtonUrlSerializer(many=True, required=False)
+ site = serializers.PrimaryKeyRelatedField(queryset=Site.objects.all())
class Meta:
model = Kandang
diff --git a/backend/apps/farms/services/pusat_close.py b/backend/apps/farms/services/pusat_close.py
index 5ccb1cd..f0e837c 100644
--- a/backend/apps/farms/services/pusat_close.py
+++ b/backend/apps/farms/services/pusat_close.py
@@ -10,6 +10,7 @@ from django.conf import settings
from apps.farms.models import Cycle
+
logger = logging.getLogger(__name__)
@@ -19,35 +20,38 @@ class PusatCloseError(Exception):
super().__init__(message)
-def _configured() -> bool:
- return bool(
- settings.PUSAT_BASE_URL
- and settings.PUSAT_API_KEY
- and settings.PUSAT_CITY_SITE_ID
- )
+def _active_site_id_for_cycle(cycle: Cycle) -> int | None:
+ site = cycle.kandang.site
+ if site.pusat_active_site_id is not None:
+ return int(site.pusat_active_site_id)
+ return None
+
+
+def _configured(active_site_id: int | None) -> bool:
+ return bool(settings.PUSAT_BASE_URL and settings.PUSAT_API_KEY and active_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():
+ active_site_id = _active_site_id_for_cycle(cycle)
+ if not _configured(active_site_id):
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,
+ "active_site_id": active_site_id,
"source_cycle_id": cycle.pk,
}
else:
url = f"{base}/cycle-close/ingest/"
payload = {
- "city_site_id": city_site_id,
+ "active_site_id": active_site_id,
"source_cycle_id": cycle.pk,
"kandang_name": cycle.kandang.kandang_name,
"site_name": cycle.kandang.site.site_name,
diff --git a/backend/apps/farms/services/pusat_register.py b/backend/apps/farms/services/pusat_register.py
new file mode 100644
index 0000000..e06b3ce
--- /dev/null
+++ b/backend/apps/farms/services/pusat_register.py
@@ -0,0 +1,111 @@
+"""Register this site deployment with executive ActiveSite registry."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import httpx
+from django.conf import settings
+from django.utils import timezone
+from django.utils.text import slugify
+
+from apps.farms.models import Site
+
+logger = logging.getLogger(__name__)
+
+
+class PusatRegisterError(Exception):
+ def __init__(self, message: str):
+ self.message = message
+ super().__init__(message)
+
+
+def _normalize_code(value: str) -> str:
+ code = slugify(str(value or "").strip())[:64]
+ if not code:
+ raise PusatRegisterError("Kode tidak valid.")
+ return code
+
+
+def _site_already_registered(site: Site) -> bool:
+ return bool(site.pusat_active_site_id and site.pusat_code)
+
+
+def register_site_to_pusat(
+ *,
+ site: Site,
+ code: str,
+ allow_code_change: bool = False,
+) -> Site:
+ """
+ POST to executive register-from-site and persist fields on Site.
+
+ First-time register: allow_code_change=False.
+ Change code after register: allow_code_change=True (requires existing registration).
+ """
+ base = (settings.PUSAT_BASE_URL or "").rstrip("/")
+ api_key = (settings.PUSAT_API_KEY or "").strip()
+ site_api = (settings.SITE_API_BASE_URL or "").rstrip("/")
+
+ if not base:
+ raise PusatRegisterError("PUSAT_BASE_URL belum dikonfigurasi.")
+ if not api_key:
+ raise PusatRegisterError("PUSAT_API_KEY belum dikonfigurasi.")
+ if not site_api:
+ raise PusatRegisterError("SITE_API_BASE_URL belum dikonfigurasi.")
+
+ normalized = _normalize_code(code)
+ already = _site_already_registered(site)
+
+ if allow_code_change:
+ if not already:
+ raise PusatRegisterError("Site belum terdaftar ke pusat — daftar dulu.")
+ if site.pusat_code == normalized:
+ raise PusatRegisterError("Kode baru sama dengan kode saat ini.")
+ elif already:
+ raise PusatRegisterError(
+ f"Site sudah terdaftar ke pusat dengan kode '{site.pusat_code}'. "
+ "Gunakan Ubah kode untuk mengganti."
+ )
+
+ url = f"{base}/active-sites/register-from-site/"
+ payload = {
+ "code": normalized,
+ "site_id": site.pk,
+ "name": site.site_name,
+ "api_base_url": site_api,
+ "api_key": api_key,
+ }
+ headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
+
+ try:
+ with httpx.Client(timeout=30) as client:
+ response = client.post(url, json=payload, headers=headers)
+ except httpx.HTTPError as exc:
+ logger.exception("Pusat register failed for site %s", site.pk)
+ raise PusatRegisterError("gagal daftar ke pusat — coba lagi") from exc
+
+ if response.status_code >= 400:
+ detail = response.text[:300]
+ action = "ubah kode" if allow_code_change else "daftar"
+ raise PusatRegisterError(
+ f"gagal {action} ke pusat — coba lagi ({response.status_code}: {detail})"
+ )
+
+ try:
+ data: dict[str, Any] = response.json()
+ except ValueError as exc:
+ raise PusatRegisterError("respons pusat tidak valid") from exc
+
+ active_site_id = data.get("id")
+ if active_site_id is None:
+ raise PusatRegisterError("pusat tidak mengembalikan id active site")
+
+ site.pusat_code = data.get("code") or normalized
+ site.pusat_active_site_id = int(active_site_id)
+ site.pusat_registered_at = timezone.now()
+ site.save(
+ update_fields=["pusat_code", "pusat_active_site_id", "pusat_registered_at", "updated_at"]
+ )
+ return site
diff --git a/backend/apps/farms/tests_pusat_register.py b/backend/apps/farms/tests_pusat_register.py
new file mode 100644
index 0000000..ab4b6aa
--- /dev/null
+++ b/backend/apps/farms/tests_pusat_register.py
@@ -0,0 +1,127 @@
+from unittest.mock import patch
+
+from django.test import TestCase, override_settings
+from rest_framework.test import APIClient
+
+from apps.accounts.models import User
+from apps.farms.models import Site
+
+
+@override_settings(
+ PUSAT_BASE_URL="http://pusat.example/api/v1",
+ PUSAT_API_KEY="shared-key",
+ SITE_API_BASE_URL="https://site.example/api/v1",
+)
+class PusatRegisterTests(TestCase):
+ def setUp(self):
+ self.client = APIClient()
+ self.staff = User.objects.create_user(
+ user_name="staff1", password="x", status=User.STATUS_ACTIVE
+ )
+ self.admin = User.objects.create_user(
+ user_name="admin1", password="x", status=User.STATUS_SUPERADMIN
+ )
+ self.site = Site.objects.create(site_name="Sukawarna", user=self.admin)
+
+ @patch("apps.farms.services.pusat_register.httpx.Client")
+ def test_active_staff_can_register(self, mock_client_cls):
+ mock_client = mock_client_cls.return_value.__enter__.return_value
+ mock_client.post.return_value.status_code = 201
+ mock_client.post.return_value.json.return_value = {
+ "id": 42,
+ "code": "sukawarna",
+ "name": "Sukawarna",
+ }
+
+ self.client.force_authenticate(self.staff)
+ response = self.client.post(
+ "/api/v1/pusat/register/",
+ {"code": "Sukawarna!", "site_id": self.site.pk},
+ format="json",
+ )
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data["active_site_id"], 42)
+ self.assertEqual(response.data["code"], "sukawarna")
+ self.site.refresh_from_db()
+ self.assertEqual(self.site.pusat_active_site_id, 42)
+ self.assertEqual(self.site.pusat_code, "sukawarna")
+ self.assertIsNotNone(self.site.pusat_registered_at)
+
+ kwargs = mock_client.post.call_args.kwargs
+ self.assertEqual(kwargs["json"]["api_base_url"], "https://site.example/api/v1")
+ self.assertEqual(kwargs["json"]["name"], "Sukawarna")
+ self.assertEqual(kwargs["json"]["site_id"], self.site.pk)
+ self.assertEqual(kwargs["headers"]["X-API-Key"], "shared-key")
+
+ def test_cannot_register_when_already_registered(self):
+ self.site.pusat_code = "sukawarna"
+ self.site.pusat_active_site_id = 42
+ self.site.save(
+ update_fields=["pusat_code", "pusat_active_site_id", "updated_at"]
+ )
+ self.client.force_authenticate(self.staff)
+ response = self.client.post(
+ "/api/v1/pusat/register/",
+ {"code": "other-code", "site_id": self.site.pk},
+ format="json",
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("Ubah kode", response.data["detail"])
+
+ @patch("apps.farms.services.pusat_register.httpx.Client")
+ def test_change_code(self, mock_client_cls):
+ self.site.pusat_code = "sukawarna"
+ self.site.pusat_active_site_id = 42
+ self.site.save(
+ update_fields=["pusat_code", "pusat_active_site_id", "updated_at"]
+ )
+ mock_client = mock_client_cls.return_value.__enter__.return_value
+ mock_client.post.return_value.status_code = 200
+ mock_client.post.return_value.json.return_value = {
+ "id": 42,
+ "code": "new-code",
+ "name": "Sukawarna",
+ }
+ self.client.force_authenticate(self.staff)
+ response = self.client.post(
+ "/api/v1/pusat/change-code/",
+ {"code": "new-code", "site_id": self.site.pk},
+ format="json",
+ )
+ self.assertEqual(response.status_code, 200, response.data)
+ self.assertEqual(response.data["code"], "new-code")
+ self.site.refresh_from_db()
+ self.assertEqual(self.site.pusat_code, "new-code")
+ self.assertEqual(self.site.pusat_active_site_id, 42)
+
+ def test_change_code_requires_registration(self):
+ self.client.force_authenticate(self.staff)
+ response = self.client.post(
+ "/api/v1/pusat/change-code/",
+ {"code": "new-code", "site_id": self.site.pk},
+ format="json",
+ )
+ self.assertEqual(response.status_code, 400)
+
+ def test_superadmin_cannot_register(self):
+ self.client.force_authenticate(self.admin)
+ response = self.client.post(
+ "/api/v1/pusat/register/",
+ {"code": "sukawarna", "site_id": self.site.pk},
+ format="json",
+ )
+ self.assertEqual(response.status_code, 403)
+
+ def test_registration_status(self):
+ self.site.pusat_code = "sukawarna"
+ self.site.pusat_active_site_id = 7
+ self.site.save(
+ update_fields=["pusat_code", "pusat_active_site_id", "updated_at"]
+ )
+ self.client.force_authenticate(self.staff)
+ response = self.client.get(
+ "/api/v1/pusat/registration/", {"site_id": self.site.pk}
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(response.data), 1)
+ self.assertEqual(response.data[0]["active_site_id"], 7)
diff --git a/backend/apps/farms/urls.py b/backend/apps/farms/urls.py
index 37dc456..5f593d7 100644
--- a/backend/apps/farms/urls.py
+++ b/backend/apps/farms/urls.py
@@ -1,6 +1,15 @@
+from django.urls import path
from rest_framework.routers import DefaultRouter
-from apps.farms.views import CycleViewSet, FlockViewSet, KandangViewSet, SiteViewSet
+from apps.farms.views import (
+ CycleViewSet,
+ FlockViewSet,
+ KandangViewSet,
+ SiteViewSet,
+ pusat_change_code,
+ pusat_register,
+ pusat_registration_status,
+)
router = DefaultRouter()
router.register("sites", SiteViewSet, basename="site")
@@ -8,4 +17,9 @@ router.register("kandangs", KandangViewSet, basename="kandang")
router.register("flocks", FlockViewSet, basename="flock")
router.register("cycles", CycleViewSet, basename="cycle")
-urlpatterns = router.urls
+urlpatterns = [
+ path("pusat/register/", pusat_register, name="pusat-register"),
+ path("pusat/change-code/", pusat_change_code, name="pusat-change-code"),
+ path("pusat/registration/", pusat_registration_status, name="pusat-registration-status"),
+ *router.urls,
+]
diff --git a/backend/apps/farms/views.py b/backend/apps/farms/views.py
index 51d3cf6..c20bb6b 100644
--- a/backend/apps/farms/views.py
+++ b/backend/apps/farms/views.py
@@ -2,7 +2,7 @@ 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.decorators import action, api_view, permission_classes
from rest_framework.response import Response
import secrets
@@ -11,6 +11,7 @@ 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_register import PusatRegisterError, register_site_to_pusat
from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals,
@@ -84,6 +85,20 @@ def _restore_pending_close(
)
+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.
@@ -99,6 +114,72 @@ class IsSuperAdminForWrite(permissions.BasePermission):
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
diff --git a/backend/config/settings.py b/backend/config/settings.py
index a06c10b..69c42a2 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -183,10 +183,11 @@ KARUNG_WEB_ADMIN_TIMEOUT_SECONDS = float(
)
KARUNG_WEB_ADMIN_SYNC_ENABLED = env_bool("KARUNG_WEB_ADMIN_SYNC_ENABLED", False)
-# Pusat (executive) dedicated cycle-close channel.
+# Pusat (executive) dedicated cycle-close + register channel.
PUSAT_BASE_URL = (env("PUSAT_BASE_URL", "") or "").rstrip("/")
PUSAT_API_KEY = env("PUSAT_API_KEY", "") or ""
-PUSAT_CITY_SITE_ID = env("PUSAT_CITY_SITE_ID", "") or ""
+# This deployment's reachable API root including /api/v1 (sent on register).
+SITE_API_BASE_URL = (env("SITE_API_BASE_URL", "") or "").rstrip("/")
# Dashboard publish cutoff (WIB): day D visible from this time on day D.
DASHBOARD_PUBLISH_HOUR = int(env("DASHBOARD_PUBLISH_HOUR", "17") or "17")
diff --git a/backend/database/ERD_dashboard.png b/backend/database/ERD_dashboard.png
index 17a28c0..002ae2f 100644
Binary files a/backend/database/ERD_dashboard.png and b/backend/database/ERD_dashboard.png differ
diff --git a/backend/database/erd_schema.sql b/backend/database/erd_schema.sql
index d383b85..7b31b0f 100644
--- a/backend/database/erd_schema.sql
+++ b/backend/database/erd_schema.sql
@@ -1,6 +1,6 @@
-- =============================================================================
-- PostgreSQL Schema — CPSP Dashboard ERD
--- Source: ERD_dashboard.png (updated 2026-09-09 for GM cycle-close flow)
+-- Source: ERD_dashboard.png (updated 2026-09-14 for slug site_id + pusat register)
--
-- user_access.status: active | inactive | superadmin
-- user_access.display_name: shown in dashboard UI (login still uses user_name)
@@ -10,6 +10,9 @@
-- left untouched so operators can be marked staff)
-- user_access → sites → kandang → cycles
-- user_access → api_keys (X-API-Key auth; hashed at rest)
+-- sites.site_id: VARCHAR slug PK `{name-slug}-{4-digit}`, e.g. sukawarna-0001
+-- sites.site_name UNIQUE
+-- sites.pusat_*: code + executive active_site_id after register-to-central
-- cycles.status: active | pending_close | closed (GM approves end_date)
-- cycles.close_requested_by_id → user_access (optional)
-- kandang → flock → iot_panel
@@ -74,16 +77,23 @@ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys (prefix);
-- -----------------------------------------------------------------------------
-- 2. Sites
+-- site_id is a stable slug from site_name plus a 4-digit suffix
+-- (e.g. sukawarna-4821), not a serial integer.
+-- pusat_* filled when staff registers the site to executive HQ.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sites (
- site_id SERIAL PRIMARY KEY,
- site_name VARCHAR(30) NOT NULL,
- user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
- created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
+ site_id VARCHAR(64) PRIMARY KEY,
+ site_name VARCHAR(30) NOT NULL UNIQUE,
+ user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
+ pusat_code VARCHAR(64) NOT NULL DEFAULT '',
+ pusat_active_site_id BIGINT,
+ pusat_registered_at TIMESTAMP WITH TIME ZONE,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites (user_id);
+CREATE INDEX IF NOT EXISTS idx_sites_pusat_active_site_id ON sites (pusat_active_site_id);
-- -----------------------------------------------------------------------------
-- 3. Kandang (Coop / Pen)
@@ -91,7 +101,7 @@ CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites (user_id);
CREATE TABLE IF NOT EXISTS kandang (
kandang_id SERIAL PRIMARY KEY,
kandang_name VARCHAR(30) NOT NULL,
- site_id INTEGER NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
+ site_id VARCHAR(64) NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
diff --git a/components/Header.tsx b/components/Header.tsx
index 6df7cd7..d9a1681 100644
--- a/components/Header.tsx
+++ b/components/Header.tsx
@@ -56,7 +56,7 @@ const Header: React.FC
+ Site {pusatConfirm.siteName} akan didaftarkan ke pusat dengan kode{' '} + {pusatConfirm.code}. Setelah terdaftar, tombol Daftar terkunci. +
+ ) : null + } + confirmLabel={ + pusatConfirm?.kind === 'change-code' ? 'Ya, ubah kode' : 'Ya, daftar' + } + busy={saving} + onCancel={() => { + if (!saving) setPusatConfirm(null); + }} + onConfirm={() => { + if (!pusatConfirm || !selectedSiteId || saving) return; + const pending = pusatConfirm; + void wrap(async () => { + if (pending.kind === 'register') { + const reg = await api.pusat.register({ + code: pending.code, + site_id: selectedSiteId, + }); + setPusatRegistration(reg); + setRegisterCode(reg.code); + flash(`Site ${reg.site_name} terdaftar ke pusat (kode ${reg.code}).`); + } else { + const reg = await api.pusat.changeCode({ + code: pending.code, + site_id: selectedSiteId, + }); + setPusatRegistration(reg); + setRegisterCode(reg.code); + flash(`Kode pusat diubah menjadi ${reg.code}.`); + } + setPusatConfirm(null); + }); + }} + /> ); }; diff --git a/components/__tests__/SettingsPage.test.tsx b/components/__tests__/SettingsPage.test.tsx index 61d5e0b..f00c704 100644 --- a/components/__tests__/SettingsPage.test.tsx +++ b/components/__tests__/SettingsPage.test.tsx @@ -10,11 +10,11 @@ const { createKandangMock, createFlockMock, createCycleMock, updateCycleMock, re updateCycleMock: vi.fn(), reloadMock: vi.fn().mockResolvedValue(undefined), farmState: { - sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }], + sites: [{ id: 'site-a', site_name: 'Site A', user: 99, created_at: '', updated_at: '' }], kandangs: [] as Array<{ id: number; kandang_name: string; - site: number; + site: string; created_at: string; updated_at: string; }>, @@ -26,7 +26,7 @@ const { createKandangMock, createFlockMock, createCycleMock, updateCycleMock, re updated_at: string; }>, cycles: [] as unknown[], - selectedSiteId: 7, + selectedSiteId: 'site-a', selectedKandangId: null as number | null, selectedCycleId: null as number | null, selectedCycle: null as unknown, @@ -97,14 +97,14 @@ describe('SettingsPage settings forms', () => { farmState.kandangs = []; farmState.flocks = []; farmState.cycles = []; - farmState.selectedSiteId = 7; + farmState.selectedSiteId = 'site-a'; farmState.selectedKandangId = null; farmState.selectedCycleId = null; farmState.selectedCycle = null; createKandangMock.mockResolvedValue({ id: 11, kandang_name: 'Kandang 1', - site: 7, + site: 'site-a', created_at: '', updated_at: '', }); @@ -155,13 +155,13 @@ describe('SettingsPage settings forms', () => { await waitFor(() => expect(createKandangMock).toHaveBeenCalledTimes(1)); expect(createKandangMock).toHaveBeenCalledWith({ kandang_name: 'Kandang 1', - site: 7, + site: 'site-a', }); }); it('submits flock fields for the selected kandang', async () => { farmState.kandangs = [ - { id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' }, + { id: 11, kandang_name: 'Kandang 1', site: 'site-a', created_at: '', updated_at: '' }, ]; farmState.selectedKandangId = 11; @@ -181,7 +181,7 @@ describe('SettingsPage settings forms', () => { it('submits cycle fields without flock assignment', async () => { farmState.kandangs = [ - { id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' }, + { id: 11, kandang_name: 'Kandang 1', site: 'site-a', created_at: '', updated_at: '' }, ]; farmState.selectedKandangId = 11; @@ -207,7 +207,7 @@ describe('SettingsPage settings forms', () => { it('updates an existing cycle from the edit form', async () => { farmState.kandangs = [ - { id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' }, + { id: 11, kandang_name: 'Kandang 1', site: 'site-a', created_at: '', updated_at: '' }, ]; farmState.selectedKandangId = 11; farmState.cycles = [ diff --git a/components/feedInButton/FeedInButtonPanel.tsx b/components/feedInButton/FeedInButtonPanel.tsx index fa7c046..e88476d 100644 --- a/components/feedInButton/FeedInButtonPanel.tsx +++ b/components/feedInButton/FeedInButtonPanel.tsx @@ -14,7 +14,7 @@ const hasConfiguredUrls = (kandang: Kandang): boolean => const FeedInButtonPanel: React.FC