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 = ({ setRegisterCode(slugify(e.target.value))} + required + /> + +
+ + {pusatRegistration && ( + + )} +
+ {pusatRegistration && ( +

+ Daftar ke pusat terkunci karena site sudah terdaftar. Gunakan Ubah kode untuk + mengganti kode. +

+ )} + + )} + + )} +

Kandang

{isSuperAdmin ? ( @@ -569,6 +694,58 @@ const SettingsPage: React.FC = () => {
+ + + Kode akan diganti dari {pusatConfirm.previousCode} menjadi{' '} + {pusatConfirm.code}. GM yang sudah memakai kode lama perlu + menyesuaikan. +

+ ) : pusatConfirm ? ( +

+ 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 = ({ variant = 'default' }) => { const [sites, setSites] = useState([]); const [kandangs, setKandangs] = useState([]); - const [selectedSiteId, setSelectedSiteId] = useState(null); + const [selectedSiteId, setSelectedSiteId] = useState(null); const [selectedKandangId, setSelectedKandangId] = useState('all'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -98,7 +98,7 @@ const FeedInButtonPanel: React.FC = ({ variant = 'defaul