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:
1 parent
521f36d22d
commit
61f954a3bd
12 files changed
+684
-172
No files matched your search
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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})"
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
+189
-120
@@ -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,120 +22,184 @@
|
|||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 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,
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
status VARCHAR(30) NOT NULL DEFAULT 'gm',
|
||||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
managed_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL,
|
||||||
status VARCHAR(30) NOT 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,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
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,
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
active_site_id BIGINT UNIQUE REFERENCES active_sites (active_site_id) ON DELETE SET NULL,
|
||||||
updated_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
|
||||||
);
|
);
|
||||||
|
|
||||||
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,
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
site_id BIGINT NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
|
||||||
updated_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
|
||||||
);
|
);
|
||||||
|
|
||||||
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',
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
active_site_id BIGINT REFERENCES active_sites (active_site_id) ON DELETE SET NULL,
|
||||||
kandang_id INTEGER NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
|
source_cycle_id BIGINT,
|
||||||
CONSTRAINT chk_cycles_date_range CHECK (end_date >= start_date)
|
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,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_cycles_date_range CHECK (end_date IS NULL OR 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user