Add source_site_id to ActiveSite model and implement unique constraint. Update serializers and views for site registration and claiming. Enhance tests for site registration and claiming functionality.

This commit is contained in:
Alberto-Audrix committed 2026-09-15 09:45:23 +07:00
1 parent 521f36d22d
commit 61f954a3bd
12 files changed
+664 -152

No files matched your search

+8 -3
View File
@@ -92,17 +92,22 @@ class VisibilityScopeTests(TestCase):
self.assertEqual(response.data["results"], []) self.assertEqual(response.data["results"], [])
def test_registering_active_site_attaches_matching_seed_site(self): def test_registering_active_site_attaches_matching_seed_site(self):
ActiveSite.objects.create(
code="orphan-seed",
name="Orphan Seed",
api_base_url="https://orphan.example/api/v1",
api_key="k",
managed_by=None,
)
self.client.force_authenticate(self.gm) self.client.force_authenticate(self.gm)
response = self.client.post( response = self.client.post(
"/api/v1/active-sites/", "/api/v1/active-sites/",
{ {
"code": "orphan-seed", "code": "orphan-seed",
"name": "Orphan Seed",
"is_active": True,
}, },
format="json", format="json",
) )
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 200, response.data)
self.orphan.refresh_from_db() self.orphan.refresh_from_db()
self.assertEqual(self.orphan.active_site_id, response.data["id"]) self.assertEqual(self.orphan.active_site_id, response.data["id"])
listed = self.client.get("/api/v1/sites/") listed = self.client.get("/api/v1/sites/")
@@ -0,0 +1,24 @@
# Generated manually: ActiveSite.source_site_id from city Site.site_id
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sync", "0005_rename_registered_by_to_managed_by"),
]
operations = [
migrations.AddField(
model_name="activesite",
name="source_site_id",
field=models.SlugField(
blank=True,
db_index=True,
default="",
help_text="Farm-location Site.site_id (slug) from the registering dashboard",
max_length=64,
),
),
]
@@ -0,0 +1,21 @@
# Unique non-empty source_site_id so one farm site cannot own two ActiveSites.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sync", "0006_activesite_source_site_id"),
]
operations = [
migrations.AddConstraint(
model_name="activesite",
constraint=models.UniqueConstraint(
condition=~models.Q(source_site_id=""),
fields=("source_site_id",),
name="uniq_active_site_source_site_id",
),
),
]
+14
View File
@@ -15,6 +15,13 @@ class ActiveSite(models.Model):
active_site_id = models.BigAutoField(primary_key=True) active_site_id = models.BigAutoField(primary_key=True)
code = models.SlugField(max_length=64, unique=True, help_text="Stable id, e.g. sukawarna") code = models.SlugField(max_length=64, unique=True, help_text="Stable id, e.g. sukawarna")
name = models.CharField(max_length=120) name = models.CharField(max_length=120)
source_site_id = models.SlugField(
max_length=64,
blank=True,
default="",
db_index=True,
help_text="Farm-location Site.site_id (slug) from the registering dashboard",
)
api_base_url = models.URLField( api_base_url = models.URLField(
max_length=255, max_length=255,
blank=True, blank=True,
@@ -44,6 +51,13 @@ class ActiveSite(models.Model):
class Meta: class Meta:
db_table = "active_sites" db_table = "active_sites"
ordering = ["name"] ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["source_site_id"],
condition=~models.Q(source_site_id=""),
name="uniq_active_site_source_site_id",
),
]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.name} ({self.code})" return f"{self.name} ({self.code})"
+50
View File
@@ -1,8 +1,16 @@
from django.utils.text import slugify
from rest_framework import serializers from rest_framework import serializers
from apps.sync.models import ActiveSite from apps.sync.models import ActiveSite
def _normalize_code(value: str) -> str:
code = slugify(str(value or "").strip())[:64]
if not code:
raise serializers.ValidationError("Kode tidak valid.")
return code
class ActiveSiteSerializer(serializers.ModelSerializer): class ActiveSiteSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="pk", read_only=True) id = serializers.IntegerField(source="pk", read_only=True)
# Never echo the outbound site API key back to the browser by default. # Never echo the outbound site API key back to the browser by default.
@@ -17,6 +25,7 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
"id", "id",
"code", "code",
"name", "name",
"source_site_id",
"api_base_url", "api_base_url",
"has_api_key", "has_api_key",
"managed_by", "managed_by",
@@ -30,6 +39,7 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
"source_site_id",
"has_api_key", "has_api_key",
"managed_by", "managed_by",
"managed_by_name", "managed_by_name",
@@ -58,3 +68,43 @@ class ActiveSiteWriteSerializer(serializers.ModelSerializer):
"is_active", "is_active",
] ]
extra_kwargs = {"api_key": {"write_only": True, "required": False}} extra_kwargs = {"api_key": {"write_only": True, "required": False}}
class ActiveSiteClaimSerializer(serializers.Serializer):
"""GM claims an ActiveSite that the site dashboard already registered."""
code = serializers.CharField(max_length=64)
def validate_code(self, value: str) -> str:
return _normalize_code(value)
class SiteRegisterSerializer(serializers.Serializer):
"""Payload from a site dashboard registering into the ActiveSite registry."""
code = serializers.CharField(max_length=64)
site_id = serializers.SlugField(max_length=64)
name = serializers.CharField(max_length=120)
api_base_url = serializers.URLField(max_length=255)
api_key = serializers.CharField(max_length=255)
def validate_code(self, value: str) -> str:
return _normalize_code(value)
def validate_site_id(self, value: str) -> str:
site_id = str(value or "").strip()[:64]
if not site_id:
raise serializers.ValidationError("site_id wajib diisi.")
return site_id
def validate_name(self, value: str) -> str:
name = str(value or "").strip()
if not name:
raise serializers.ValidationError("Nama wajib diisi.")
return name[:120]
def validate_api_key(self, value: str) -> str:
key = str(value or "").strip()
if not key:
raise serializers.ValidationError("api_key wajib diisi.")
return key
+206
View File
@@ -0,0 +1,206 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Site
from apps.sync.models import ActiveSite
class RegisterFromSiteTests(TestCase):
def setUp(self):
self.client = APIClient()
self.machine = User.objects.create_user(
user_name="site-bot", password="x", status=User.STATUS_GM
)
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM
)
self.gm_other = User.objects.create_user(
user_name="gm2", password="x", status=User.STATUS_GM
)
def test_register_creates_unclaimed_active_site(self):
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "Sukawarna!",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://sukawarna.example/api/v1",
"api_key": "site-secret",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["code"], "sukawarna")
self.assertEqual(response.data["name"], "Sukawarna")
self.assertEqual(response.data["source_site_id"], "sukawarna-0001")
self.assertEqual(response.data["api_base_url"], "https://sukawarna.example/api/v1")
self.assertTrue(response.data["has_api_key"])
self.assertIsNone(response.data["managed_by"])
row = ActiveSite.objects.get(code="sukawarna")
self.assertEqual(row.api_key, "site-secret")
self.assertEqual(row.source_site_id, "sukawarna-0001")
self.assertIsNone(row.managed_by_id)
def test_re_register_by_site_id_updates_even_if_code_changes(self):
active = ActiveSite.objects.create(
code="old-code",
name="Old",
source_site_id="sukawarna-0001",
api_base_url="https://old.example/api/v1",
api_key="old-key",
managed_by=self.gm,
)
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "new-code",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://new.example/api/v1",
"api_key": "new-key",
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
active.refresh_from_db()
self.assertEqual(active.code, "new-code")
self.assertEqual(active.name, "Sukawarna")
self.assertEqual(active.source_site_id, "sukawarna-0001")
self.assertEqual(active.api_base_url, "https://new.example/api/v1")
self.assertEqual(active.api_key, "new-key")
self.assertEqual(active.managed_by_id, self.gm.pk)
self.assertEqual(ActiveSite.objects.count(), 1)
def test_second_register_same_site_id_never_creates_second_row(self):
self.client.force_authenticate(self.machine)
first = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "code-a",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://a.example/api/v1",
"api_key": "key-a",
},
format="json",
)
self.assertEqual(first.status_code, 201, first.data)
second = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "code-b",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://b.example/api/v1",
"api_key": "key-b",
},
format="json",
)
self.assertEqual(second.status_code, 200, second.data)
self.assertEqual(second.data["id"], first.data["id"])
self.assertEqual(ActiveSite.objects.filter(source_site_id="sukawarna-0001").count(), 1)
self.assertEqual(ActiveSite.objects.count(), 1)
row = ActiveSite.objects.get(source_site_id="sukawarna-0001")
self.assertEqual(row.code, "code-b")
self.assertEqual(row.api_key, "key-b")
def test_re_register_updates_credentials_keeps_claim(self):
active = ActiveSite.objects.create(
code="sukawarna",
name="Old",
source_site_id="sukawarna-0001",
api_base_url="https://old.example/api/v1",
api_key="old-key",
managed_by=self.gm,
)
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "sukawarna",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://new.example/api/v1",
"api_key": "new-key",
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
active.refresh_from_db()
self.assertEqual(active.name, "Sukawarna")
self.assertEqual(active.api_base_url, "https://new.example/api/v1")
self.assertEqual(active.api_key, "new-key")
self.assertEqual(active.managed_by_id, self.gm.pk)
class ClaimActiveSiteTests(TestCase):
def setUp(self):
self.client = APIClient()
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM
)
self.gm_other = User.objects.create_user(
user_name="gm2", password="x", status=User.STATUS_GM
)
self.active = ActiveSite.objects.create(
code="sukawarna",
name="Sukawarna",
api_base_url="https://sukawarna.example/api/v1",
api_key="site-secret",
managed_by=None,
)
def test_gm_claims_registered_site_by_code(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "sukawarna"},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.active.refresh_from_db()
self.assertEqual(self.active.managed_by_id, self.gm.pk)
self.assertTrue(Site.objects.filter(active_site=self.active, user=self.gm).exists())
def test_claim_unknown_code_404(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "missing"},
format="json",
)
self.assertEqual(response.status_code, 404)
def test_claim_owned_by_other_gm_409(self):
self.active.managed_by = self.gm_other
self.active.save(update_fields=["managed_by"])
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "sukawarna"},
format="json",
)
self.assertEqual(response.status_code, 409)
def test_hapus_unclaims_without_deleting_active_site(self):
self.active.managed_by = self.gm
self.active.source_site_id = "sukawarna-0001"
self.active.save(update_fields=["managed_by", "source_site_id"])
hq_site = Site.objects.create(
site_name="Sukawarna", user=self.gm, active_site=self.active
)
self.client.force_authenticate(self.gm)
response = self.client.delete(f"/api/v1/active-sites/{self.active.pk}/")
self.assertEqual(response.status_code, 204, response.data)
self.assertTrue(ActiveSite.objects.filter(pk=self.active.pk).exists())
self.active.refresh_from_db()
self.assertIsNone(self.active.managed_by_id)
self.assertEqual(self.active.api_key, "site-secret")
self.assertEqual(self.active.source_site_id, "sukawarna-0001")
hq_site.refresh_from_db()
self.assertIsNone(hq_site.active_site_id)
self.assertTrue(Site.objects.filter(pk=hq_site.pk).exists())
+158 -9
View File
@@ -1,49 +1,198 @@
from rest_framework import status, viewsets from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from django.db import IntegrityError
from apps.accounts.permissions import CanRegisterActiveSite, IsExecutive, can_sync from apps.accounts.permissions import CanRegisterActiveSite, IsExecutive, can_sync
from apps.accounts.scoping import filter_active_sites_queryset from apps.accounts.scoping import filter_active_sites_queryset
from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site
from apps.sync.models import ActiveSite from apps.sync.models import ActiveSite
from apps.sync.serializers import ActiveSiteSerializer, ActiveSiteWriteSerializer from apps.sync.serializers import (
ActiveSiteClaimSerializer,
ActiveSiteSerializer,
ActiveSiteWriteSerializer,
SiteRegisterSerializer,
)
class ActiveSiteViewSet(viewsets.ModelViewSet): class ActiveSiteViewSet(viewsets.ModelViewSet):
""" """
List/read: scoped (director all; BUH managed GMs; GM own). List/read: scoped (director all; BUH managed GMs; GM own).
Create/update/delete: GM and GM Admin. Create (claim): GM and GM Admin claim a site-registered ActiveSite by code.
Sync: GM and GM Admin only (stubs until mirror pull is implemented). Sync: GM and GM Admin only (stubs until mirror pull is implemented).
Creating an active site also attaches (or creates) an HQ Site so farm data Site dashboards create/fill ActiveSite via register-from-site (API key auth).
appears in the executive view list. Claiming attaches managed_by and ensures an HQ Site for the view list.
Destroy (Hapus) unclaims only — does not delete the ActiveSite row.
""" """
executive_sync_action = True executive_sync_action = True
http_method_names = ["get", "post", "put", "patch", "delete", "head", "options"]
def get_queryset(self): def get_queryset(self):
qs = ActiveSite.objects.select_related("managed_by").all() qs = ActiveSite.objects.select_related("managed_by").all()
return filter_active_sites_queryset(qs, self.request.user) return filter_active_sites_queryset(qs, self.request.user)
def get_permissions(self): def get_permissions(self):
if self.action == "register_from_site":
return [permissions.IsAuthenticated()]
if self.action in {"create", "update", "partial_update", "destroy"}: if self.action in {"create", "update", "partial_update", "destroy"}:
return [CanRegisterActiveSite()] return [CanRegisterActiveSite()]
return [IsExecutive()] return [IsExecutive()]
def get_serializer_class(self): def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}: if self.action == "register_from_site":
return SiteRegisterSerializer
if self.action == "create":
return ActiveSiteClaimSerializer
if self.action in {"update", "partial_update"}:
return ActiveSiteWriteSerializer return ActiveSiteWriteSerializer
return ActiveSiteSerializer return ActiveSiteSerializer
def perform_create(self, serializer): def create(self, request, *args, **kwargs):
active_site = serializer.save(managed_by=self.request.user) """GM claim-by-code: site must have registered first."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
active_site = ActiveSite.objects.filter(code=code).first()
if active_site is None:
return Response(
{
"detail": (
"Site belum mendaftar dari dasbor site. "
"Minta staf site mendaftar dulu dengan kode ini."
)
},
status=status.HTTP_404_NOT_FOUND,
)
owner_id = active_site.managed_by_id
if owner_id and owner_id != request.user.pk:
return Response(
{"detail": "Kode sudah diklaim oleh GM lain."},
status=status.HTTP_409_CONFLICT,
)
if owner_id != request.user.pk:
active_site.managed_by = request.user
active_site.save(update_fields=["managed_by", "updated_at"])
ensure_hq_site_for_active_site(active_site) ensure_hq_site_for_active_site(active_site)
return Response(
ActiveSiteSerializer(active_site).data,
status=status.HTTP_200_OK,
)
def perform_update(self, serializer): def perform_update(self, serializer):
active_site = serializer.save() active_site = serializer.save()
if active_site.is_active: if active_site.is_active and active_site.managed_by_id:
ensure_hq_site_for_active_site(active_site) ensure_hq_site_for_active_site(active_site)
def destroy(self, request, *args, **kwargs):
"""
Hapus from daftar tampilan: unclaim only.
Keeps the ActiveSite registry row (credentials / source_site_id) so the
site dashboard registration is not wiped. Clears managed_by and unlinks
any HQ Site.active_site pointer.
"""
from apps.farms.models import Site
active_site = self.get_object()
Site.objects.filter(active_site=active_site).update(active_site=None)
active_site.managed_by = None
active_site.save(update_fields=["managed_by", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["post"], url_path="register-from-site")
def register_from_site(self, request):
"""
Machine endpoint: site dashboard creates/updates ActiveSite credentials.
Does not set managed_by (GM claim is separate).
One farm site_id maps to at most one ActiveSite. Re-register with a new
code updates that row; it never inserts a second ActiveSite.
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
code = data["code"]
source_site_id = data["site_id"]
active_site = ActiveSite.objects.filter(source_site_id=source_site_id).first()
if active_site is None:
by_code = ActiveSite.objects.filter(code=code).first()
if by_code is not None:
if by_code.source_site_id and by_code.source_site_id != source_site_id:
return Response(
{"detail": "Kode sudah dipakai site lain."},
status=status.HTTP_409_CONFLICT,
)
# Legacy row missing source_site_id: attach this site to it.
active_site = by_code
if active_site is None:
try:
active_site = ActiveSite.objects.create(
code=code,
name=data["name"],
source_site_id=source_site_id,
api_base_url=data["api_base_url"],
api_key=data["api_key"],
managed_by=None,
is_active=True,
)
except IntegrityError:
# Concurrent register for same source_site_id or code.
active_site = ActiveSite.objects.filter(
source_site_id=source_site_id
).first() or ActiveSite.objects.filter(code=code).first()
if active_site is None:
return Response(
{"detail": "Gagal mendaftarkan site — coba lagi."},
status=status.HTTP_409_CONFLICT,
)
else:
return Response(
ActiveSiteSerializer(active_site).data,
status=status.HTTP_201_CREATED,
)
if (
code != active_site.code
and ActiveSite.objects.filter(code=code).exclude(pk=active_site.pk).exists()
):
return Response(
{"detail": "Kode sudah dipakai site lain."},
status=status.HTTP_409_CONFLICT,
)
active_site.code = code
active_site.name = data["name"]
active_site.source_site_id = source_site_id
active_site.api_base_url = data["api_base_url"]
active_site.api_key = data["api_key"]
active_site.is_active = True
try:
active_site.save(
update_fields=[
"code",
"name",
"source_site_id",
"api_base_url",
"api_key",
"is_active",
"updated_at",
]
)
except IntegrityError:
return Response(
{"detail": "Site atau kode bentrok dengan pendaftaran lain."},
status=status.HTTP_409_CONFLICT,
)
return Response(ActiveSiteSerializer(active_site).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="sync") @action(detail=True, methods=["post"], url_path="sync")
def sync(self, request, pk=None): def sync(self, request, pk=None):
if not can_sync(request.user): if not can_sync(request.user):
+169 -100
View File
@@ -1,15 +1,20 @@
-- ============================================================================= -- =============================================================================
-- PostgreSQL Schema — CPSP Dashboard ERD -- PostgreSQL Schema — CPSP Executive Dashboard ERD
-- Source: ERD_dashboard.png (updated 2026-08-20) -- Source: live DB (dashboard-cpsp-executive) + Django models (updated 2026-09-14)
--
-- user_access.status:
-- inactive | gm | gm_admin | buh | bu_admin | director | director_admin |
-- account_admin | superuser
-- (Django syncs is_active/is_superuser from status on save:
-- inactive => both false; superuser => both true; other executive roles =>
-- is_active true, is_superuser false. Product roles never use is_staff.)
-- --
-- user_access.status: active | inactive | superadmin
-- (Django syncs is_active/is_staff/is_superuser from status on save:
-- inactive => all false; superadmin => is_active/is_superuser true,
-- is_staff false; active => is_active true, is_superuser false, is_staff
-- left untouched so operators can be marked staff)
-- user_access → sites → kandang → cycles -- user_access → sites → kandang → cycles
-- cycles → flock (flock.cycle_id, required; cycles has no flock_id) -- kandang → flock → iot_panel
-- flock → iot_panel -- user_access → api_keys
-- user_access → active_sites (managed_by)
-- sites ↔ active_sites (optional 1:1)
-- cycles → active_sites (mirror source) + close-approval user FKs
-- cycles → ai_insight, feed_sacks, chicken_counting, chicken_weight, -- cycles → ai_insight, feed_sacks, chicken_counting, chicken_weight,
-- manual_input, kpi -- manual_input, kpi
-- kpi → chicken_counting, chicken_weight, feed_sacks, manual_input -- kpi → chicken_counting, chicken_weight, feed_sacks, manual_input
@@ -17,46 +22,94 @@
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 1. User Access -- 1. User Access
-- Django AbstractBaseUser + PermissionsMixin. `status` is the app's role -- Django AbstractBaseUser + PermissionsMixin. `status` is the executive
-- (active | inactive | superadmin); Django auth flags are synced from it on -- role; is_active/is_superuser are derived from it on save.
-- save (is_staff stays untouched for active users, superadmin => is_staff
-- false, is_superuser true).
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS user_access ( CREATE TABLE IF NOT EXISTS user_access (
user_id SERIAL PRIMARY KEY, user_id BIGSERIAL PRIMARY KEY,
user_name VARCHAR(30) NOT NULL UNIQUE, user_name VARCHAR(30) NOT NULL UNIQUE,
display_name VARCHAR(30) NOT NULL UNIQUE, display_name VARCHAR(30) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL, password VARCHAR(128) NOT NULL,
last_login TIMESTAMP WITH TIME ZONE, last_login TIMESTAMP WITH TIME ZONE,
is_superuser BOOLEAN NOT NULL DEFAULT FALSE, is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
status VARCHAR(30) NOT NULL DEFAULT 'gm',
managed_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
managed_by_pending_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
managed_by_requested_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
status VARCHAR(30) NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_user_access_status ON user_access (status); CREATE INDEX IF NOT EXISTS idx_user_access_status ON user_access (status);
CREATE INDEX IF NOT EXISTS idx_user_access_managed_by_id ON user_access (managed_by_id);
CREATE INDEX IF NOT EXISTS idx_user_access_managed_by_pending_id ON user_access (managed_by_pending_id);
-- Django PermissionsMixin many-to-many tables. -- Django PermissionsMixin many-to-many tables.
CREATE TABLE IF NOT EXISTS user_access_groups ( CREATE TABLE IF NOT EXISTS user_access_groups (
id SERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES auth_group (id) ON DELETE CASCADE group_id INTEGER NOT NULL REFERENCES auth_group (id) ON DELETE CASCADE,
UNIQUE (user_id, group_id)
); );
CREATE TABLE IF NOT EXISTS user_access_user_permissions ( CREATE TABLE IF NOT EXISTS user_access_user_permissions (
id SERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
permission_id INTEGER NOT NULL REFERENCES auth_permission (id) ON DELETE CASCADE permission_id INTEGER NOT NULL REFERENCES auth_permission (id) ON DELETE CASCADE,
UNIQUE (user_id, permission_id)
); );
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 2. Sites -- 2. API Keys (inbound X-API-Key auth to HQ)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS api_keys (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
prefix VARCHAR(12) NOT NULL,
key_hash VARCHAR(64) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_used_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_api_keys_user_id ON api_keys (user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys (prefix);
-- -----------------------------------------------------------------------------
-- 3. Active Sites (farm-location dashboard-cpsp registry)
-- source_site_id = farm-location sites.site_id slug (e.g. sukawarna-4821),
-- sent by site dashboard on register-from-site (API field name: site_id).
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS active_sites (
active_site_id BIGSERIAL PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
source_site_id VARCHAR(64) NOT NULL DEFAULT '',
api_base_url VARCHAR(255) NOT NULL DEFAULT '',
api_key VARCHAR(255) NOT NULL DEFAULT '',
managed_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_synced_at TIMESTAMP WITH TIME ZONE,
last_sync_status VARCHAR(32) NOT NULL DEFAULT '',
last_sync_message TEXT NOT NULL DEFAULT '',
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_active_sites_managed_by_id ON active_sites (managed_by_id);
CREATE INDEX IF NOT EXISTS idx_active_sites_source_site_id ON active_sites (source_site_id);
-- -----------------------------------------------------------------------------
-- 4. Sites
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sites ( CREATE TABLE IF NOT EXISTS sites (
site_id SERIAL PRIMARY KEY, site_id BIGSERIAL PRIMARY KEY,
site_name VARCHAR(30) NOT NULL, site_name VARCHAR(30) NOT NULL,
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
active_site_id BIGINT UNIQUE REFERENCES active_sites (active_site_id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -64,12 +117,13 @@ CREATE TABLE IF NOT EXISTS sites (
CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites (user_id); CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites (user_id);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 3. Kandang (Coop / Pen) -- 5. Kandang (Coop / Pen)
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS kandang ( CREATE TABLE IF NOT EXISTS kandang (
kandang_id SERIAL PRIMARY KEY, kandang_id BIGSERIAL PRIMARY KEY,
kandang_name VARCHAR(30) NOT NULL, kandang_name VARCHAR(30) NOT NULL,
site_id INTEGER NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE, feed_in_button_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
site_id BIGINT NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -77,60 +131,75 @@ CREATE TABLE IF NOT EXISTS kandang (
CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id); CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 4. Cycles -- 6. Flock
-- No flock_id — flock belongs to cycle via flock.cycle_id. -- Belongs to kandang (not cycle). IoT readings attach here.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS flock (
flock_id BIGSERIAL PRIMARY KEY,
flock_name VARCHAR(30) NOT NULL,
kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_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
);
CREATE INDEX IF NOT EXISTS idx_flock_kandang_id ON flock (kandang_id);
-- -----------------------------------------------------------------------------
-- 7. Cycles
-- end_date NULL while open; status active | pending_close | closed.
-- Optional active_site + source_cycle_id identify the mirrored farm cycle.
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS cycles ( CREATE TABLE IF NOT EXISTS cycles (
cycle_id SERIAL PRIMARY KEY, cycle_id BIGSERIAL PRIMARY KEY,
total_days INTEGER NOT NULL, total_days INTEGER NOT NULL DEFAULT 1,
start_date DATE NOT NULL, start_date DATE NOT NULL,
end_date DATE NOT NULL, end_date DATE,
doc_in_weight INTEGER NOT NULL, doc_in_weight INTEGER NOT NULL,
doc_in_count INTEGER NOT NULL, doc_in_count INTEGER NOT NULL,
feed_initial_balance INTEGER NOT NULL DEFAULT 0, feed_initial_balance INTEGER NOT NULL DEFAULT 0,
feed_initial_balance_date DATE, feed_initial_balance_date DATE,
status VARCHAR(30) NOT NULL, status VARCHAR(30) NOT NULL DEFAULT 'active',
kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
active_site_id BIGINT REFERENCES active_sites (active_site_id) ON DELETE SET NULL,
source_cycle_id BIGINT,
proposed_end_date DATE,
close_requested_at TIMESTAMP WITH TIME ZONE,
close_requested_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
close_approved_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
close_approved_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
kandang_id INTEGER NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE, CONSTRAINT chk_cycles_date_range CHECK (end_date IS NULL OR end_date >= start_date)
CONSTRAINT chk_cycles_date_range CHECK (end_date >= start_date)
); );
CREATE INDEX IF NOT EXISTS idx_cycles_kandang_id ON cycles (kandang_id); CREATE INDEX IF NOT EXISTS idx_cycles_kandang_id ON cycles (kandang_id);
CREATE INDEX IF NOT EXISTS idx_cycles_status ON cycles (status); CREATE INDEX IF NOT EXISTS idx_cycles_status ON cycles (status);
CREATE INDEX IF NOT EXISTS idx_cycles_start_date ON cycles (start_date); CREATE INDEX IF NOT EXISTS idx_cycles_start_date ON cycles (start_date);
CREATE INDEX IF NOT EXISTS idx_cycles_active_site_id ON cycles (active_site_id);
CREATE INDEX IF NOT EXISTS idx_cycles_close_requested_by_id ON cycles (close_requested_by_id);
CREATE INDEX IF NOT EXISTS idx_cycles_close_approved_by_id ON cycles (close_approved_by_id);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_cycle_active_source
ON cycles (active_site_id, source_cycle_id)
WHERE active_site_id IS NOT NULL AND source_cycle_id IS NOT NULL;
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 5. Flock -- 8. IoT Panel
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS flock (
flock_id SERIAL PRIMARY KEY,
flock_name VARCHAR(30) NOT NULL,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_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
);
CREATE INDEX IF NOT EXISTS idx_flock_cycle_id ON flock (cycle_id);
-- -----------------------------------------------------------------------------
-- 6. IoT Panel
-- One row per sensor reading; the panel polls every 10 minutes, so a cycle -- One row per sensor reading; the panel polls every 10 minutes, so a cycle
-- day holds up to 144 rows. `timestamp` carries the reading time; `date` is -- day holds up to 144 rows. `timestamp` carries the reading time; `date` is
-- kept denormalized for fast daily grouping. -- kept denormalized for fast daily grouping.
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS iot_panel ( CREATE TABLE IF NOT EXISTS iot_panel (
panel_id SERIAL PRIMARY KEY, panel_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL, date DATE NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE, timestamp TIMESTAMP WITH TIME ZONE,
wind_speed FLOAT NOT NULL, wind_speed DOUBLE PRECISION NOT NULL DEFAULT 0,
humidity FLOAT NOT NULL, humidity DOUBLE PRECISION NOT NULL DEFAULT 0,
water_total FLOAT NOT NULL, water_total DOUBLE PRECISION NOT NULL DEFAULT 0,
average_temperature FLOAT NOT NULL, average_temperature DOUBLE PRECISION NOT NULL DEFAULT 0,
experience_temperature FLOAT NOT NULL, experience_temperature DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
flock_id INTEGER NOT NULL REFERENCES flock (flock_id) ON DELETE CASCADE flock_id BIGINT NOT NULL REFERENCES flock (flock_id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_iot_panel_flock_id ON iot_panel (flock_id); CREATE INDEX IF NOT EXISTS idx_iot_panel_flock_id ON iot_panel (flock_id);
@@ -139,10 +208,10 @@ CREATE INDEX IF NOT EXISTS idx_iot_panel_timestamp ON iot_panel (timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_panel_flock_timestamp ON iot_panel (flock_id, timestamp); CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_panel_flock_timestamp ON iot_panel (flock_id, timestamp);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 7. AI Insight -- 9. AI Insight
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ai_insight ( CREATE TABLE IF NOT EXISTS ai_insight (
id SERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL, date DATE NOT NULL,
insight_text TEXT NOT NULL, insight_text TEXT NOT NULL,
alert VARCHAR(100) NOT NULL, alert VARCHAR(100) NOT NULL,
@@ -150,17 +219,17 @@ CREATE TABLE IF NOT EXISTS ai_insight (
session VARCHAR(100) NOT NULL, session VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id); CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id);
CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date); CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 8. Feed Sacks -- 10. Feed Sacks
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS feed_sacks ( CREATE TABLE IF NOT EXISTS feed_sacks (
fs_id SERIAL PRIMARY KEY, fs_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL, date DATE NOT NULL,
in_today INTEGER NOT NULL DEFAULT 0, in_today INTEGER NOT NULL DEFAULT 0,
out_today INTEGER NOT NULL DEFAULT 0, out_today INTEGER NOT NULL DEFAULT 0,
@@ -170,7 +239,7 @@ CREATE TABLE IF NOT EXISTS feed_sacks (
feed_use_total INTEGER NOT NULL DEFAULT 0, feed_use_total INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_feed_sacks_cycle_id ON feed_sacks (cycle_id); CREATE INDEX IF NOT EXISTS idx_feed_sacks_cycle_id ON feed_sacks (cycle_id);
@@ -178,16 +247,16 @@ CREATE INDEX IF NOT EXISTS idx_feed_sacks_date ON feed_sacks (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_feed_sacks_cycle_date ON feed_sacks (cycle_id, date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_feed_sacks_cycle_date ON feed_sacks (cycle_id, date);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 9. Chicken Counting -- 11. Chicken Counting
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chicken_counting ( CREATE TABLE IF NOT EXISTS chicken_counting (
cc_id SERIAL PRIMARY KEY, cc_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL, date DATE NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0, total_count INTEGER NOT NULL DEFAULT 0,
mortality_count INTEGER NOT NULL DEFAULT 0, mortality_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_chicken_counting_cycle_id ON chicken_counting (cycle_id); CREATE INDEX IF NOT EXISTS idx_chicken_counting_cycle_id ON chicken_counting (cycle_id);
@@ -195,31 +264,31 @@ CREATE INDEX IF NOT EXISTS idx_chicken_counting_date ON chicken_counting (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_counting_cycle_date ON chicken_counting (cycle_id, date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_counting_cycle_date ON chicken_counting (cycle_id, date);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 10. Chicken Weight -- 12. Chicken Weight
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chicken_weight ( CREATE TABLE IF NOT EXISTS chicken_weight (
cw_id SERIAL PRIMARY KEY, cw_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL, date DATE NOT NULL,
age INTEGER NOT NULL, age INTEGER NOT NULL,
doc_weight INTEGER NOT NULL, doc_weight INTEGER NOT NULL,
average_weight FLOAT NOT NULL, average_weight DOUBLE PRECISION NOT NULL,
chicken_count INTEGER NOT NULL, chicken_count INTEGER NOT NULL,
uniformity FLOAT NOT NULL, uniformity DOUBLE PRECISION NOT NULL,
average_daily_gain FLOAT NOT NULL, average_daily_gain DOUBLE PRECISION NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_chicken_weight_cycle_id ON chicken_weight (cycle_id); CREATE INDEX IF NOT EXISTS idx_chicken_weight_cycle_id ON chicken_weight (cycle_id);
CREATE INDEX IF NOT EXISTS idx_chicken_weight_date ON chicken_weight (date); CREATE INDEX IF NOT EXISTS idx_chicken_weight_date ON chicken_weight (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_weight_cycle_date ON chicken_weight (cycle_id, date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_cw_cycle_date ON chicken_weight (cycle_id, date);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 11. Manual Input -- 13. Manual Input
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS manual_input ( CREATE TABLE IF NOT EXISTS manual_input (
manual_id SERIAL PRIMARY KEY, manual_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL, date DATE NOT NULL,
age_manual INTEGER NOT NULL DEFAULT 0, age_manual INTEGER NOT NULL DEFAULT 0,
mortality_manual INTEGER NOT NULL DEFAULT 0, mortality_manual INTEGER NOT NULL DEFAULT 0,
@@ -232,16 +301,16 @@ CREATE TABLE IF NOT EXISTS manual_input (
feed_out_manual_total INTEGER NOT NULL DEFAULT 0, feed_out_manual_total INTEGER NOT NULL DEFAULT 0,
harvest_manual INTEGER NOT NULL DEFAULT 0, harvest_manual INTEGER NOT NULL DEFAULT 0,
harvest_manual_total INTEGER NOT NULL DEFAULT 0, harvest_manual_total INTEGER NOT NULL DEFAULT 0,
harvest_weight_manual FLOAT NOT NULL DEFAULT 0, harvest_weight_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
harvest_weight_manual_total FLOAT NOT NULL DEFAULT 0, harvest_weight_manual_total DOUBLE PRECISION NOT NULL DEFAULT 0,
manual_weight FLOAT NOT NULL DEFAULT 0, manual_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
average_harvest_day_manual FLOAT NOT NULL DEFAULT 0, average_harvest_day_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
average_harvest_weight_manual FLOAT NOT NULL DEFAULT 0, average_harvest_weight_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
fcr_manual FLOAT NOT NULL DEFAULT 0, fcr_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
eef_manual FLOAT NOT NULL DEFAULT 0, eef_manual DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_manual_input_cycle_id ON manual_input (cycle_id); CREATE INDEX IF NOT EXISTS idx_manual_input_cycle_id ON manual_input (cycle_id);
@@ -249,10 +318,10 @@ CREATE INDEX IF NOT EXISTS idx_manual_input_date ON manual_input (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_manual_input_cycle_date ON manual_input (cycle_id, date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_manual_input_cycle_date ON manual_input (cycle_id, date);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 12. KPI (aggregates data from cycle + source records) -- 14. KPI (aggregates data from cycle + source records)
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS kpi ( CREATE TABLE IF NOT EXISTS kpi (
kpi_id SERIAL PRIMARY KEY, kpi_id BIGSERIAL PRIMARY KEY,
date DATE NOT NULL, date DATE NOT NULL,
age INTEGER NOT NULL DEFAULT 0, age INTEGER NOT NULL DEFAULT 0,
mortality INTEGER NOT NULL DEFAULT 0, mortality INTEGER NOT NULL DEFAULT 0,
@@ -261,22 +330,22 @@ CREATE TABLE IF NOT EXISTS kpi (
feed_total INTEGER NOT NULL DEFAULT 0, feed_total INTEGER NOT NULL DEFAULT 0,
harvest INTEGER NOT NULL DEFAULT 0, harvest INTEGER NOT NULL DEFAULT 0,
harvest_total INTEGER NOT NULL DEFAULT 0, harvest_total INTEGER NOT NULL DEFAULT 0,
harvest_weight FLOAT NOT NULL DEFAULT 0, harvest_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
harvest_weight_total FLOAT NOT NULL DEFAULT 0, harvest_weight_total DOUBLE PRECISION NOT NULL DEFAULT 0,
chicken_life INTEGER NOT NULL DEFAULT 0, chicken_life INTEGER NOT NULL DEFAULT 0,
chicken_life_percentage FLOAT NOT NULL DEFAULT 0, chicken_life_percentage DOUBLE PRECISION NOT NULL DEFAULT 0,
iot_weight FLOAT NOT NULL DEFAULT 0, iot_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
average_harvest_day FLOAT NOT NULL DEFAULT 0, average_harvest_day DOUBLE PRECISION NOT NULL DEFAULT 0,
average_harvest_weight FLOAT NOT NULL DEFAULT 0, average_harvest_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
fcr FLOAT NOT NULL DEFAULT 0, fcr DOUBLE PRECISION NOT NULL DEFAULT 0,
eef FLOAT NOT NULL DEFAULT 0, eef DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE, cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE,
cc_id INTEGER NOT NULL REFERENCES chicken_counting (cc_id) ON DELETE RESTRICT, cc_id BIGINT NOT NULL REFERENCES chicken_counting (cc_id) ON DELETE RESTRICT,
cw_id INTEGER NOT NULL REFERENCES chicken_weight (cw_id) ON DELETE RESTRICT, cw_id BIGINT NOT NULL REFERENCES chicken_weight (cw_id) ON DELETE RESTRICT,
fs_id INTEGER NOT NULL REFERENCES feed_sacks (fs_id) ON DELETE RESTRICT, fs_id BIGINT NOT NULL REFERENCES feed_sacks (fs_id) ON DELETE RESTRICT,
manual_id INTEGER NOT NULL REFERENCES manual_input (manual_id) ON DELETE RESTRICT manual_id BIGINT NOT NULL REFERENCES manual_input (manual_id) ON DELETE RESTRICT
); );
CREATE INDEX IF NOT EXISTS idx_kpi_cycle_id ON kpi (cycle_id); CREATE INDEX IF NOT EXISTS idx_kpi_cycle_id ON kpi (cycle_id);
@@ -299,8 +368,8 @@ DECLARE
tbl TEXT; tbl TEXT;
BEGIN BEGIN
FOREACH tbl IN ARRAY ARRAY[ FOREACH tbl IN ARRAY ARRAY[
'user_access', 'sites', 'kandang', 'cycles', 'flock', 'user_access', 'api_keys', 'active_sites', 'sites', 'kandang', 'flock',
'iot_panel', 'ai_insight', 'feed_sacks', 'chicken_counting', 'cycles', 'iot_panel', 'ai_insight', 'feed_sacks', 'chicken_counting',
'chicken_weight', 'manual_input', 'kpi' 'chicken_weight', 'manual_input', 'kpi'
] ]
LOOP LOOP
+8 -31
View File
@@ -221,9 +221,7 @@ const GmViewList: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [name, setName] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [codeTouched, setCodeTouched] = useState(false);
const flash = (msg: string) => { const flash = (msg: string) => {
setNotice(msg); setNotice(msg);
@@ -258,9 +256,7 @@ const GmViewList: React.FC = () => {
}, [reload]); }, [reload]);
const resetForm = () => { const resetForm = () => {
setName('');
setCode(''); setCode('');
setCodeTouched(false);
}; };
const wrap = async (fn: () => Promise<void>) => { const wrap = async (fn: () => Promise<void>) => {
@@ -310,7 +306,7 @@ const GmViewList: React.FC = () => {
<div> <div>
<PageHeader <PageHeader
title="Pengaturan" title="Pengaturan"
subtitle="Pilih BUH Anda, lalu daftarkan site dengan nama dan kode" subtitle="Pilih BUH Anda, lalu klaim site dengan kode dari dasbor site"
/> />
{error && ( {error && (
<div className="mb-4"> <div className="mb-4">
@@ -396,57 +392,38 @@ const GmViewList: React.FC = () => {
<section className="bg-white rounded-xl shadow-sm p-5"> <section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-1">Tambah site</h2> <h2 className="font-semibold text-gray-800 mb-1">Tambah site</h2>
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-gray-500 mb-4">
Masukkan nama dan kode yang sama dengan yang diisi manajer di dasbor site, agar site Masukkan kode yang sama dengan yang dipakai staf saat daftar ke pusat di dasbor site.
terhubung ke daftar tampilan Anda. Site harus sudah mendaftar dulu sebelum bisa masuk daftar tampilan Anda.
</p> </p>
<form <form
className="space-y-3" className="space-y-3"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const resolvedCode = code || slugify(name); const resolvedCode = slugify(code);
if (!resolvedCode) return; if (!resolvedCode) return;
void wrap(async () => { void wrap(async () => {
const created = await api.activeSites.create({ const claimed = await api.activeSites.create({
code: resolvedCode, code: resolvedCode,
name: name.trim(),
is_active: true,
}); });
resetForm(); resetForm();
flash(`Site ${created.name} ditambahkan ke daftar tampilan.`); flash(`Site ${claimed.name} ditambahkan ke daftar tampilan.`);
}); });
}} }}
> >
<label className="block text-sm text-gray-700">
Nama site
<input
className={`${inputClass} mt-1`}
placeholder="Contoh: Sukawarna"
value={name}
onChange={(e) => {
const next = e.target.value;
setName(next);
if (!codeTouched) setCode(slugify(next));
}}
required
/>
</label>
<label className="block text-sm text-gray-700"> <label className="block text-sm text-gray-700">
Kode Kode
<input <input
className={`${inputClass} mt-1`} className={`${inputClass} mt-1`}
placeholder="sukawarna" placeholder="sukawarna"
value={code} value={code}
onChange={(e) => { onChange={(e) => setCode(slugify(e.target.value))}
setCodeTouched(true);
setCode(slugify(e.target.value));
}}
required required
/> />
</label> </label>
<button <button
type="submit" type="submit"
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50" className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-semibold disabled:opacity-50"
disabled={saving} disabled={saving || !code}
> >
{saving ? 'Menyimpan...' : 'Tambah ke daftar'} {saving ? 'Menyimpan...' : 'Tambah ke daftar'}
</button> </button>
+2 -2
View File
@@ -87,7 +87,7 @@ const UserManagementPage: React.FC = () => {
password, password,
}); });
setNotice( setNotice(
`Akun "${userName.trim()}" dibuat. Bagikan password sementara secara aman (di luar sistem).` `Akun "${userName.trim()}" dibuat. Jangan lupa ganti password setelah login pertama kali.`
); );
setUserLogin(''); setUserLogin('');
setUserName(''); setUserName('');
@@ -133,7 +133,7 @@ const UserManagementPage: React.FC = () => {
setBusyId(id); setBusyId(id);
try { try {
await api.users.setPassword(id, resetPassword); await api.users.setPassword(id, resetPassword);
setNotice('Password direset. Bagikan password baru secara aman (di luar sistem).'); setNotice('Password direset. Bagikan password baru secara aman.');
setResetId(null); setResetId(null);
setResetPassword(''); setResetPassword('');
await reload(); await reload();
+2 -6
View File
@@ -156,7 +156,7 @@ describe('SettingsPage city site registry', () => {
render(<SettingsPage />); render(<SettingsPage />);
await screen.findByText('Sukawarna'); await screen.findByText('Sukawarna');
fireEvent.change(screen.getByPlaceholderText('Contoh: Sukawarna'), { fireEvent.change(screen.getByPlaceholderText('sukawarna'), {
target: { value: 'Cikupa' }, target: { value: 'Cikupa' },
}); });
fireEvent.click(screen.getByRole('button', { name: 'Tambah ke daftar' })); fireEvent.click(screen.getByRole('button', { name: 'Tambah ke daftar' }));
@@ -164,12 +164,8 @@ describe('SettingsPage city site registry', () => {
await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1)); await waitFor(() => expect(createMock).toHaveBeenCalledTimes(1));
expect(createMock).toHaveBeenCalledWith({ expect(createMock).toHaveBeenCalledWith({
code: 'cikupa', code: 'cikupa',
name: 'Cikupa',
is_active: true,
}); });
}); }); it('lets GM request a BUH', async () => {
it('lets GM request a BUH', async () => {
requestMock.mockResolvedValue({ requestMock.mockResolvedValue({
...authState.user, ...authState.user,
managed_by_pending: 2, managed_by_pending: 2,
+2 -1
View File
@@ -43,6 +43,7 @@ export type ActiveSite = {
id: number; id: number;
code: string; code: string;
name: string; name: string;
source_site_id: string;
api_base_url: string; api_base_url: string;
has_api_key: boolean; has_api_key: boolean;
managed_by?: number | null; managed_by?: number | null;
@@ -57,7 +58,7 @@ export type ActiveSite = {
export type ActiveSiteWrite = { export type ActiveSiteWrite = {
code: string; code: string;
name: string; name?: string;
api_base_url?: string; api_base_url?: string;
api_key?: string; api_key?: string;
is_active?: boolean; is_active?: boolean;