update link registration site to executive dashboard
This commit is contained in:
1 parent
1e6f6b3b57
commit
d0d1ae7d24
26 files changed
+1044
-55
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 332 KiB |
@@ -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
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<i className="fa-solid fa-map-marker-alt text-gray-500" />
|
||||
<select
|
||||
value={selectedSiteId ?? ''}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? Number(e.target.value) : null)}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? e.target.value : null)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white text-sm text-gray-900"
|
||||
>
|
||||
{sites.length === 0 && <option value="">Tidak ada site</option>}
|
||||
|
||||
+180
-3
@@ -1,14 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import { useAuth } from '../context/AuthContext.tsx';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import type { Cycle, FeedInButtonUrl } from '../types/api.ts';
|
||||
import { Banner, PageHeader } from './ui/Feedback.tsx';
|
||||
import type { Cycle, FeedInButtonUrl, PusatRegistration } from '../types/api.ts';
|
||||
import { Banner, ConfirmDialog, PageHeader } from './ui/Feedback.tsx';
|
||||
import { formatDateId, resolveCycleStatus, todayIso } from '../utils/format.ts';
|
||||
|
||||
const inputClass =
|
||||
'border border-gray-300 rounded-md px-3 py-2 text-sm w-full focus:ring-red-500 focus:border-red-500';
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
type PusatConfirm =
|
||||
| { kind: 'register'; code: string; siteName: string }
|
||||
| { kind: 'change-code'; code: string; previousCode: string };
|
||||
|
||||
const defaultCycleForm = () => ({
|
||||
startDate: todayIso(),
|
||||
docInCount: 20000,
|
||||
@@ -49,6 +62,9 @@ const SettingsPage: React.FC = () => {
|
||||
const [proposedCloseDate, setProposedCloseDate] = useState(todayIso());
|
||||
const [docInCount, setDocInCount] = useState(defaultCycleForm().docInCount);
|
||||
const [chickInWeight, setChickInWeight] = useState(defaultCycleForm().chickInWeight);
|
||||
const [registerCode, setRegisterCode] = useState('');
|
||||
const [pusatRegistration, setPusatRegistration] = useState<PusatRegistration | null>(null);
|
||||
const [pusatConfirm, setPusatConfirm] = useState<PusatConfirm | null>(null);
|
||||
|
||||
const resetCycleForm = () => {
|
||||
const defaults = defaultCycleForm();
|
||||
@@ -82,6 +98,29 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const isSuperAdmin = user?.status === 'superadmin';
|
||||
const canRequestClose = user?.status === 'active';
|
||||
const selectedSite = sites.find((s) => s.id === selectedSiteId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRequestClose || selectedSiteId == null) {
|
||||
setPusatRegistration(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const rows = await api.pusat.registration({ site_id: selectedSiteId });
|
||||
if (!cancelled) {
|
||||
setPusatRegistration(rows[0] ?? null);
|
||||
if (rows[0]?.code) setRegisterCode(rows[0].code);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setPusatRegistration(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canRequestClose, selectedSiteId]);
|
||||
|
||||
const statusLabel = (cycle: Cycle) => {
|
||||
const status = resolveCycleStatus(cycle);
|
||||
@@ -103,10 +142,12 @@ const SettingsPage: React.FC = () => {
|
||||
const wrap = async (fn: () => Promise<void>) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setNotice(null);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -189,6 +230,90 @@ const SettingsPage: React.FC = () => {
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{canRequestClose && (
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-1">Daftar ke pusat</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Pilih site di daftar kiri, lalu masukkan kode. GM di dasbor pusat akan memakai kode
|
||||
yang sama untuk menambahkan site ke daftar tampilan mereka.
|
||||
</p>
|
||||
{!selectedSite ? (
|
||||
<p className="text-sm text-gray-500">Pilih site terlebih dahulu.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-700">
|
||||
Site: <strong>{selectedSite.site_name}</strong>
|
||||
</p>
|
||||
{pusatRegistration && (
|
||||
<p className="text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-2">
|
||||
Sudah terdaftar — kode <strong>{pusatRegistration.code}</strong>
|
||||
{pusatRegistration.registered_at
|
||||
? ` · ${formatDateId(pusatRegistration.registered_at.slice(0, 10))}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
<label className="block text-sm text-gray-700">
|
||||
Kode
|
||||
<input
|
||||
className={`${inputClass} mt-1`}
|
||||
placeholder="sukawarna"
|
||||
value={registerCode}
|
||||
onChange={(e) => setRegisterCode(slugify(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
|
||||
disabled={saving || !registerCode || !!pusatRegistration}
|
||||
onClick={() => {
|
||||
const code = slugify(registerCode);
|
||||
if (!code || !selectedSite || pusatRegistration) return;
|
||||
setPusatConfirm({
|
||||
kind: 'register',
|
||||
code,
|
||||
siteName: selectedSite.site_name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saving && !pusatRegistration ? 'Mendaftarkan...' : 'Daftar ke pusat'}
|
||||
</button>
|
||||
{pusatRegistration && (
|
||||
<button
|
||||
type="button"
|
||||
className="border border-red-600 text-red-600 px-4 py-2 rounded-lg text-sm font-semibold hover:bg-red-50 disabled:opacity-50"
|
||||
disabled={
|
||||
saving ||
|
||||
!registerCode ||
|
||||
slugify(registerCode) === pusatRegistration.code
|
||||
}
|
||||
onClick={() => {
|
||||
const code = slugify(registerCode);
|
||||
if (!code || code === pusatRegistration.code) return;
|
||||
setPusatConfirm({
|
||||
kind: 'change-code',
|
||||
code,
|
||||
previousCode: pusatRegistration.code,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{saving ? 'Mengubah...' : 'Ubah kode'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{pusatRegistration && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Daftar ke pusat terkunci karena site sudah terdaftar. Gunakan Ubah kode untuk
|
||||
mengganti kode.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-3">Kandang</h2>
|
||||
{isSuperAdmin ? (
|
||||
@@ -569,6 +694,58 @@ const SettingsPage: React.FC = () => {
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pusatConfirm != null}
|
||||
title={
|
||||
pusatConfirm?.kind === 'change-code' ? 'Ubah kode pusat?' : 'Daftar ke pusat?'
|
||||
}
|
||||
message={
|
||||
pusatConfirm?.kind === 'change-code' ? (
|
||||
<p>
|
||||
Kode akan diganti dari <strong>{pusatConfirm.previousCode}</strong> menjadi{' '}
|
||||
<strong>{pusatConfirm.code}</strong>. GM yang sudah memakai kode lama perlu
|
||||
menyesuaikan.
|
||||
</p>
|
||||
) : pusatConfirm ? (
|
||||
<p>
|
||||
Site <strong>{pusatConfirm.siteName}</strong> akan didaftarkan ke pusat dengan kode{' '}
|
||||
<strong>{pusatConfirm.code}</strong>. Setelah terdaftar, tombol Daftar terkunci.
|
||||
</p>
|
||||
) : 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);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -14,7 +14,7 @@ const hasConfiguredUrls = (kandang: Kandang): boolean =>
|
||||
const FeedInButtonPanel: React.FC<FeedInButtonPanelProps> = ({ variant = 'default' }) => {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [kandangs, setKandangs] = useState<Kandang[]>([]);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
|
||||
const [selectedKandangId, setSelectedKandangId] = useState<number | 'all'>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -98,7 +98,7 @@ const FeedInButtonPanel: React.FC<FeedInButtonPanelProps> = ({ variant = 'defaul
|
||||
<select
|
||||
id="feed-in-site"
|
||||
value={selectedSiteId ?? ''}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? Number(e.target.value) : null)}
|
||||
onChange={(e) => setSelectedSiteId(e.target.value ? e.target.value : null)}
|
||||
className="border border-gray-300 rounded-md px-3 py-2 text-sm w-full sm:max-w-xs focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
{sites.map((site) => (
|
||||
|
||||
@@ -53,3 +53,74 @@ export const EmptyFarm: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
.
|
||||
</Banner>
|
||||
);
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Lanjutkan',
|
||||
cancelLabel = 'Batal',
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
onClick={() => {
|
||||
if (!busy) onCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl bg-white p-5 shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-600 text-white">
|
||||
<i className="fa-solid fa-triangle-exclamation" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<h3 id="confirm-dialog-title" className="text-lg font-semibold text-gray-800">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-1 text-sm text-gray-600">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{busy ? 'Memproses...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ type FarmContextValue = {
|
||||
kandangs: Kandang[];
|
||||
flocks: Flock[];
|
||||
cycles: Cycle[];
|
||||
selectedSiteId: number | null;
|
||||
selectedSiteId: string | null;
|
||||
selectedKandangId: number | null;
|
||||
selectedCycleId: number | null;
|
||||
selectedSite: Site | null;
|
||||
@@ -16,7 +16,7 @@ type FarmContextValue = {
|
||||
selectedCycle: Cycle | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setSelectedSiteId: (id: number | null) => void;
|
||||
setSelectedSiteId: (id: string | null) => void;
|
||||
setSelectedKandangId: (id: number | null) => void;
|
||||
setSelectedCycleId: (id: number | null) => void;
|
||||
reload: () => Promise<void>;
|
||||
@@ -31,7 +31,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const [kandangs, setKandangs] = useState<Kandang[]>([]);
|
||||
const [flocks, setFlocks] = useState<Flock[]>([]);
|
||||
const [cycles, setCycles] = useState<Cycle[]>([]);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
|
||||
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
|
||||
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
|
||||
const [selectedCycleId, setSelectedCycleId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -70,6 +70,9 @@ services:
|
||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n}
|
||||
BOOTSTRAP_STAFF_USER: ${BOOTSTRAP_STAFF_USER:-skw}
|
||||
BOOTSTRAP_STAFF_PASSWORD: ${BOOTSTRAP_STAFF_PASSWORD:-skw123}
|
||||
PUSAT_BASE_URL: ${PUSAT_BASE_URL:-}
|
||||
PUSAT_API_KEY: ${PUSAT_API_KEY:-}
|
||||
SITE_API_BASE_URL: ${SITE_API_BASE_URL:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
@@ -124,6 +127,9 @@ services:
|
||||
CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS: ${CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS:-30}
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED:-true}
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED: ${CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED:-true}
|
||||
PUSAT_BASE_URL: ${PUSAT_BASE_URL:-}
|
||||
PUSAT_API_KEY: ${PUSAT_API_KEY:-}
|
||||
SITE_API_BASE_URL: ${SITE_API_BASE_URL:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
|
||||
@@ -24,6 +24,11 @@ BOOTSTRAP_STAFF_PASSWORD=change_me
|
||||
# Optional: fixed API key for scripts (hashed at rest)
|
||||
BOOTSTRAP_API_KEY=
|
||||
|
||||
# Pusat (executive) — register + cycle-close
|
||||
PUSAT_BASE_URL=
|
||||
PUSAT_API_KEY=
|
||||
SITE_API_BASE_URL=
|
||||
|
||||
# karung-web-admin on the Docker host
|
||||
KARUNG_WEB_ADMIN_BASE_URL=http://host.docker.internal:5000
|
||||
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
|
||||
|
||||
+12
-3
@@ -19,6 +19,7 @@ import type {
|
||||
ManualInputWrite,
|
||||
Paginated,
|
||||
PanelIoT,
|
||||
PusatRegistration,
|
||||
Site,
|
||||
SiteWrite,
|
||||
User,
|
||||
@@ -165,11 +166,11 @@ export const api = {
|
||||
list: async (query?: { user_id?: number }) =>
|
||||
unwrapList(await get<Paginated<Site> | Site[]>('/sites/', query)),
|
||||
create: (body: SiteWrite) => post<Site>('/sites/', body),
|
||||
update: (id: number, body: Partial<SiteWrite>) => patch<Site>(`/sites/${id}/`, body),
|
||||
remove: (id: number) => del(`/sites/${id}/`),
|
||||
update: (id: string, body: Partial<SiteWrite>) => patch<Site>(`/sites/${id}/`, body),
|
||||
remove: (id: string) => del(`/sites/${id}/`),
|
||||
},
|
||||
kandangs: {
|
||||
list: async (query?: { site_id?: number }) =>
|
||||
list: async (query?: { site_id?: string }) =>
|
||||
unwrapList(await get<Paginated<Kandang> | Kandang[]>('/kandangs/', query)),
|
||||
create: (body: KandangWrite) => post<Kandang>('/kandangs/', body),
|
||||
update: (id: number, body: Partial<KandangWrite>) => patch<Kandang>(`/kandangs/${id}/`, body),
|
||||
@@ -199,6 +200,14 @@ export const api = {
|
||||
cancelCloseRequest: (id: number) =>
|
||||
post<Cycle>(`/cycles/${id}/cancel-close-request/`, {}),
|
||||
},
|
||||
pusat: {
|
||||
register: (body: { code: string; site_id: string }) =>
|
||||
post<PusatRegistration>('/pusat/register/', body),
|
||||
changeCode: (body: { code: string; site_id: string }) =>
|
||||
post<PusatRegistration>('/pusat/change-code/', body),
|
||||
registration: (query?: { site_id?: string }) =>
|
||||
get<PusatRegistration[]>('/pusat/registration/', query),
|
||||
},
|
||||
kpis: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<KPI> | KPI[]>('/kpis/', query)),
|
||||
|
||||
+11
-3
@@ -17,13 +17,21 @@ export type User = {
|
||||
};
|
||||
|
||||
export type Site = {
|
||||
id: number;
|
||||
id: string;
|
||||
site_name: string;
|
||||
user: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PusatRegistration = {
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
code: string;
|
||||
active_site_id: number | null;
|
||||
registered_at: string | null;
|
||||
};
|
||||
|
||||
export type FeedInButtonUrl = {
|
||||
label: string;
|
||||
url: string;
|
||||
@@ -33,7 +41,7 @@ export type Kandang = {
|
||||
id: number;
|
||||
kandang_name: string;
|
||||
feed_in_button_urls: FeedInButtonUrl[];
|
||||
site: number;
|
||||
site: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -229,7 +237,7 @@ export type CycleFilter = {
|
||||
export type SiteWrite = { site_name: string; user: number };
|
||||
export type KandangWrite = {
|
||||
kandang_name: string;
|
||||
site: number;
|
||||
site: string;
|
||||
feed_in_button_urls?: FeedInButtonUrl[];
|
||||
};
|
||||
export type FlockWrite = {
|
||||
|
||||
Reference in new issue
Block a user