migrate site id to varchar instead of int

This commit is contained in:
Alberto-Audrix committed 2026-09-15 11:12:09 +07:00
1 parent 61f954a3bd
commit 36639d8c38
16 files changed
+448 -41

No files matched your search

+14 -3
View File
@@ -30,22 +30,32 @@ class VisibilityScopeTests(TestCase):
self.active_a = ActiveSite.objects.create( self.active_a = ActiveSite.objects.create(
code="a", code="a",
name="Site A", name="Site A",
source_site_id="site-a-0001",
api_base_url="https://a.example/api/v1", api_base_url="https://a.example/api/v1",
managed_by=self.gm, managed_by=self.gm,
) )
self.active_b = ActiveSite.objects.create( self.active_b = ActiveSite.objects.create(
code="b", code="b",
name="Site B", name="Site B",
source_site_id="site-b-0001",
api_base_url="https://b.example/api/v1", api_base_url="https://b.example/api/v1",
managed_by=self.gm_other, managed_by=self.gm_other,
) )
self.site = Site.objects.create( self.site = Site.objects.create(
site_name="Site A", user=self.gm, active_site=self.active_a site_id="site-a-0001",
site_name="Site A",
user=self.gm,
active_site=self.active_a,
) )
self.site_other = Site.objects.create( self.site_other = Site.objects.create(
site_name="Site B", user=self.gm_other, active_site=self.active_b site_id="site-b-0001",
site_name="Site B",
user=self.gm_other,
active_site=self.active_b,
)
self.orphan = Site.objects.create(
site_id="orphan-seed-0001", site_name="Orphan Seed", user=self.gm
) )
self.orphan = Site.objects.create(site_name="Orphan Seed", user=self.gm)
Kandang.objects.create(kandang_name="K1", site=self.site) Kandang.objects.create(kandang_name="K1", site=self.site)
Kandang.objects.create(kandang_name="K2", site=self.site_other) Kandang.objects.create(kandang_name="K2", site=self.site_other)
@@ -95,6 +105,7 @@ class VisibilityScopeTests(TestCase):
ActiveSite.objects.create( ActiveSite.objects.create(
code="orphan-seed", code="orphan-seed",
name="Orphan Seed", name="Orphan Seed",
source_site_id="orphan-seed-0001",
api_base_url="https://orphan.example/api/v1", api_base_url="https://orphan.example/api/v1",
api_key="k", api_key="k",
managed_by=None, managed_by=None,
@@ -0,0 +1,238 @@
# Convert HQ sites.site_id from bigint to farm-matching slug (varchar).
from django.db import migrations, models
import django.db.models.deletion
def _unique_slug(candidate: str, used: set[str]) -> str:
final = candidate[:64]
n = 0
while final in used or not final:
n += 1
suffix = f"-{n}"
final = f"{candidate[: 64 - len(suffix)]}{suffix}"
used.add(final)
return final
def forwards_convert_site_pk(apps, schema_editor):
from django.utils.text import slugify
connection = schema_editor.connection
Site = apps.get_model("farms", "Site")
Kandang = apps.get_model("farms", "Kandang")
ActiveSite = apps.get_model("sync", "ActiveSite")
sites = list(
Site.objects.all().values(
"site_id",
"site_name",
"user_id",
"active_site_id",
"created_at",
"updated_at",
)
)
kandangs = list(
Kandang.objects.all().values(
"kandang_id",
"kandang_name",
"feed_in_button_urls",
"site_id",
"created_at",
"updated_at",
)
)
active_source = {
row.pk: str(row.source_site_id).strip()
for row in ActiveSite.objects.all()
if str(row.source_site_id or "").strip()
}
used: set[str] = set()
mapping: dict[int, str] = {}
for row in sites:
old = row["site_id"]
candidate = active_source.get(row["active_site_id"]) or ""
if not candidate:
base = slugify(row["site_name"] or "")[:40] or "site"
candidate = f"{base}-legacy-{old}"
mapping[old] = _unique_slug(candidate, used)
with connection.cursor() as cursor:
if connection.vendor == "sqlite":
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute(
"""
CREATE TABLE sites_new (
site_id varchar(64) NOT NULL PRIMARY KEY,
site_name varchar(30) NOT NULL,
user_id bigint NOT NULL REFERENCES user_access (user_id)
DEFERRABLE INITIALLY DEFERRED,
active_site_id bigint UNIQUE REFERENCES active_sites (active_site_id)
DEFERRABLE INITIALLY DEFERRED,
created_at datetime NOT NULL,
updated_at datetime NOT NULL
)
"""
)
for row in sites:
cursor.execute(
"""
INSERT INTO sites_new
(site_id, site_name, user_id, active_site_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
[
mapping[row["site_id"]],
row["site_name"],
row["user_id"],
row["active_site_id"],
row["created_at"],
row["updated_at"],
],
)
cursor.execute("DROP TABLE sites")
cursor.execute("ALTER TABLE sites_new RENAME TO sites")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites (user_id)"
)
cursor.execute(
"""
CREATE TABLE kandang_new (
kandang_id integer NOT NULL PRIMARY KEY AUTOINCREMENT,
kandang_name varchar(30) NOT NULL,
feed_in_button_urls text NOT NULL DEFAULT '[]',
site_id varchar(64) NOT NULL REFERENCES sites (site_id)
DEFERRABLE INITIALLY DEFERRED,
created_at datetime NOT NULL,
updated_at datetime NOT NULL
)
"""
)
import json
for row in kandangs:
urls = row["feed_in_button_urls"]
if not isinstance(urls, str):
urls = json.dumps(urls if urls is not None else [])
cursor.execute(
"""
INSERT INTO kandang_new
(kandang_id, kandang_name, feed_in_button_urls, site_id,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
[
row["kandang_id"],
row["kandang_name"],
urls,
mapping[row["site_id"]],
row["created_at"],
row["updated_at"],
],
)
cursor.execute("DROP TABLE kandang")
cursor.execute("ALTER TABLE kandang_new RENAME TO kandang")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id)"
)
cursor.execute("PRAGMA foreign_keys=ON")
elif connection.vendor == "postgresql":
cursor.execute(
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS site_id_new varchar(64)"
)
for old, new in mapping.items():
cursor.execute(
"UPDATE sites SET site_id_new = %s WHERE site_id = %s",
[new, old],
)
cursor.execute(
"ALTER TABLE kandang ADD COLUMN IF NOT EXISTS site_id_new varchar(64)"
)
cursor.execute(
"""
UPDATE kandang AS k
SET site_id_new = s.site_id_new
FROM sites AS s
WHERE k.site_id = s.site_id
"""
)
cursor.execute(
"""
ALTER TABLE kandang DROP CONSTRAINT IF EXISTS kandang_site_id_fkey
"""
)
# Django default name variants
cursor.execute(
"""
DO $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT conname FROM pg_constraint
WHERE conrelid = 'kandang'::regclass
AND contype = 'f'
AND pg_get_constraintdef(oid) ILIKE '%%site_id%%'
LOOP
EXECUTE format('ALTER TABLE kandang DROP CONSTRAINT %I', r.conname);
END LOOP;
END $$;
"""
)
cursor.execute("ALTER TABLE kandang DROP COLUMN site_id")
cursor.execute(
"ALTER TABLE kandang RENAME COLUMN site_id_new TO site_id"
)
cursor.execute("ALTER TABLE sites DROP CONSTRAINT sites_pkey")
cursor.execute("ALTER TABLE sites DROP COLUMN site_id")
cursor.execute("ALTER TABLE sites RENAME COLUMN site_id_new TO site_id")
cursor.execute("ALTER TABLE sites ALTER COLUMN site_id SET NOT NULL")
cursor.execute("ALTER TABLE sites ADD PRIMARY KEY (site_id)")
cursor.execute("ALTER TABLE kandang ALTER COLUMN site_id SET NOT NULL")
cursor.execute(
"""
ALTER TABLE kandang
ADD CONSTRAINT kandang_site_id_fkey
FOREIGN KEY (site_id) REFERENCES sites (site_id) ON DELETE CASCADE
"""
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id)"
)
else:
raise NotImplementedError(
f"sites.site_id slug migration unsupported for {connection.vendor}"
)
class Migration(migrations.Migration):
dependencies = [
("farms", "0018_rename_city_site_to_active_site"),
("sync", "0007_uniq_source_site_id"),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunPython(forwards_convert_site_pk, migrations.RunPython.noop),
],
state_operations=[
migrations.AlterField(
model_name="site",
name="site_id",
field=models.SlugField(max_length=64, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name="kandang",
name="site",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="kandangs",
to="farms.site",
),
),
],
),
]
+34 -1
View File
@@ -1,14 +1,42 @@
from datetime import date from datetime import date
import secrets
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify
from apps.accounts.models import User from apps.accounts.models import User
def site_name_slug(site_name: str) -> str:
"""Slug portion of a site id (no numeric suffix)."""
code = slugify(str(site_name or "").strip())[:55]
if not code:
raise ValidationError({"site_name": "Nama site tidak valid untuk dijadikan id."})
return code
def site_id_from_name(site_name: str, *, numeric_suffix: int | None = None) -> str:
"""Site PK: `{name-slug}-{4-digit number}`, matching farm dashboard identity."""
base = site_name_slug(site_name)
if numeric_suffix is None:
numeric_suffix = secrets.randbelow(10_000)
return f"{base}-{numeric_suffix:04d}"
def allocate_site_id(site_name: str) -> str:
"""Pick an unused site_id for HQ-only creates (seeds / manual)."""
for _ in range(32):
candidate = site_id_from_name(site_name)
if not Site.objects.filter(pk=candidate).exists():
return candidate
raise ValidationError({"site_name": "Gagal membuat id site unik — coba lagi."})
class Site(models.Model): class Site(models.Model):
site_id = models.BigAutoField(primary_key=True) # Same shape as farm dashboard Site.site_id; when linked, equals ActiveSite.source_site_id.
site_id = models.SlugField(primary_key=True, max_length=64)
site_name = models.CharField(max_length=30) site_name = models.CharField(max_length=30)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites")
# Linked when GM/GM Admin adds the site dashboard to their Pengaturan view list. # Linked when GM/GM Admin adds the site dashboard to their Pengaturan view list.
@@ -29,6 +57,11 @@ class Site(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return self.site_name return self.site_name
def save(self, *args, **kwargs):
if not self.site_id:
self.site_id = allocate_site_id(self.site_name)
super().save(*args, **kwargs)
class Kandang(models.Model): class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True) kandang_id = models.BigAutoField(primary_key=True)
+3 -1
View File
@@ -4,7 +4,9 @@ from apps.accounts.serializers import PkAsIdMixin
from apps.farms.models import Cycle, Flock, Kandang, Site from apps.farms.models import Cycle, Flock, Kandang, Site
class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer): class SiteSerializer(serializers.ModelSerializer):
# Slug PK (matches farm site_id); do not use IntegerField PkAsIdMixin.
id = serializers.CharField(source="pk", read_only=True)
owner_name = serializers.CharField(source="user.display_name", read_only=True) owner_name = serializers.CharField(source="user.display_name", read_only=True)
class Meta: class Meta:
@@ -2,27 +2,56 @@
from __future__ import annotations from __future__ import annotations
from apps.farms.models import Site from django.db import transaction
from django.utils.text import slugify
from apps.farms.models import Kandang, Site
from apps.sync.models import ActiveSite from apps.sync.models import ActiveSite
def desired_hq_site_id(active_site: ActiveSite) -> str:
"""HQ Site PK should match the farm site slug when known."""
source = str(active_site.source_site_id or "").strip()
if source:
return source[:64]
code = slugify(str(active_site.code or "").strip())[:64]
if code:
return code
raise ValueError("ActiveSite needs source_site_id or code to create an HQ site.")
@transaction.atomic
def ensure_hq_site_for_active_site(active_site: ActiveSite) -> Site: def ensure_hq_site_for_active_site(active_site: ActiveSite) -> Site:
""" """
Ensure the active-site registry entry has an HQ Site for farm visibility. Ensure the active-site registry entry has an HQ Site for farm visibility.
Prefers an existing linked site, then an unlinked site owned by the Site.site_id matches ActiveSite.source_site_id (farm Site.site_id) when set.
registrar with a matching name (so seeded Sukawarna attaches when Prefers an existing linked site, then a site already keyed by that id, then an
registered), otherwise creates a new Site. unlinked site owned by the registrar with a matching name (re-keyed if needed),
otherwise creates a new Site.
""" """
existing = Site.objects.filter(active_site=active_site).first()
if existing:
return existing
owner = active_site.managed_by owner = active_site.managed_by
if owner is None: if owner is None:
raise ValueError("ActiveSite.managed_by is required to create an HQ site.") raise ValueError("ActiveSite.managed_by is required to create an HQ site.")
desired_id = desired_hq_site_id(active_site)
site_name = (active_site.name or active_site.code or "Site")[:30] site_name = (active_site.name or active_site.code or "Site")[:30]
existing = Site.objects.filter(active_site=active_site).first()
if existing is not None:
if existing.pk == desired_id:
return existing
return _rekey_site(existing, desired_id=desired_id, site_name=site_name, owner=owner)
by_pk = Site.objects.filter(pk=desired_id).first()
if by_pk is not None:
by_pk.active_site = active_site
by_pk.user = owner
if by_pk.site_name != site_name:
by_pk.site_name = site_name
by_pk.save(update_fields=["active_site", "user", "site_name", "updated_at"])
return by_pk
orphan = ( orphan = (
Site.objects.filter( Site.objects.filter(
user=owner, active_site__isnull=True, site_name__iexact=site_name user=owner, active_site__isnull=True, site_name__iexact=site_name
@@ -31,7 +60,6 @@ def ensure_hq_site_for_active_site(active_site: ActiveSite) -> Site:
.first() .first()
) )
if orphan is None and active_site.code: if orphan is None and active_site.code:
# e.g. code "sukawarna" ↔ site_name "Sukawarna"
orphan = ( orphan = (
Site.objects.filter( Site.objects.filter(
user=owner, active_site__isnull=True, site_name__iexact=active_site.code user=owner, active_site__isnull=True, site_name__iexact=active_site.code
@@ -40,10 +68,45 @@ def ensure_hq_site_for_active_site(active_site: ActiveSite) -> Site:
.first() .first()
) )
if orphan is not None: if orphan is not None:
orphan.active_site = active_site if orphan.pk == desired_id:
orphan.save(update_fields=["active_site", "updated_at"]) orphan.active_site = active_site
return orphan orphan.save(update_fields=["active_site", "updated_at"])
return orphan
return _rekey_site(
orphan,
desired_id=desired_id,
site_name=site_name,
owner=owner,
active_site=active_site,
)
return Site.objects.create( return Site.objects.create(
site_name=site_name, user=owner, active_site=active_site site_id=desired_id,
site_name=site_name,
user=owner,
active_site=active_site,
) )
def _rekey_site(
old: Site,
*,
desired_id: str,
site_name: str,
owner,
active_site: ActiveSite | None = None,
) -> Site:
"""Move an HQ site (and its kandangs) onto the farm slug primary key."""
link = active_site if active_site is not None else old.active_site
new_site = Site.objects.create(
site_id=desired_id,
site_name=site_name or old.site_name,
user=owner,
active_site=None,
)
Kandang.objects.filter(site=old).update(site=new_site)
old_active = old.active_site
old.delete()
new_site.active_site = link or old_active
new_site.save(update_fields=["active_site", "updated_at"])
return new_site
+6 -1
View File
@@ -12,14 +12,19 @@ from apps.sync.models import ActiveSite
def _gm_visible_site(*, owner: User, site_name: str, code: str) -> Site: def _gm_visible_site(*, owner: User, site_name: str, code: str) -> Site:
"""GM scope only sees sites linked to an ActiveSite they manage.""" """GM scope only sees sites linked to an ActiveSite they manage."""
site_id = f"{code}-0001"
active_site = ActiveSite.objects.create( active_site = ActiveSite.objects.create(
code=code, code=code,
name=site_name, name=site_name,
source_site_id=site_id,
api_base_url=f"https://{code}.example/api/v1", api_base_url=f"https://{code}.example/api/v1",
managed_by=owner, managed_by=owner,
) )
return Site.objects.create( return Site.objects.create(
site_name=site_name, user=owner, active_site=active_site site_id=site_id,
site_name=site_name,
user=owner,
active_site=active_site,
) )
+21 -2
View File
@@ -7,6 +7,25 @@ from rest_framework.test import APIClient
from apps.accounts.models import User from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import ChickenWeight, FeedSacks, IotPanel from apps.operations.models import ChickenWeight, FeedSacks, IotPanel
from apps.sync.models import ActiveSite
def _linked_site(user: User, *, site_name: str = "Sukawarna", code: str = "sukawarna") -> Site:
"""GM-visible site linked to an ActiveSite (Pengaturan registry)."""
site_id = f"{code}-0001"
active = ActiveSite.objects.create(
code=code,
name=site_name,
source_site_id=site_id,
api_base_url=f"https://{code}.example/api/v1",
managed_by=user,
)
return Site.objects.create(
site_id=site_id,
site_name=site_name,
user=user,
active_site=active,
)
@override_settings(DASHBOARD_PUBLISH_HOUR=0) @override_settings(DASHBOARD_PUBLISH_HOUR=0)
@@ -15,7 +34,7 @@ class OperationsAPITests(TestCase):
self.user = User.objects.create_user(user_name="tester", password="secret") self.user = User.objects.create_user(user_name="tester", password="secret")
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(self.user) self.client.force_authenticate(self.user)
site = Site.objects.create(site_name="Sukawarna", user=self.user) site = _linked_site(self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site) self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.cycle = Cycle.objects.create( self.cycle = Cycle.objects.create(
kandang=self.kandang, kandang=self.kandang,
@@ -314,7 +333,7 @@ class DashboardVisibilityTests(TestCase):
self.user = User.objects.create_user(user_name="tester", password="secret") self.user = User.objects.create_user(user_name="tester", password="secret")
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(self.user) self.client.force_authenticate(self.user)
site = Site.objects.create(site_name="Sukawarna", user=self.user) site = _linked_site(self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site) self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.cycle = Cycle.objects.create( self.cycle = Cycle.objects.create(
kandang=self.kandang, kandang=self.kandang,
@@ -15,6 +15,24 @@ from apps.operations.services.chicken_counting_edge import (
location_prefix_for_kandang, location_prefix_for_kandang,
sync_chicken_counting_for_cycle, sync_chicken_counting_for_cycle,
) )
from apps.sync.models import ActiveSite
def _linked_site(user: User, *, site_name: str = "Sukawarna", code: str = "sukawarna") -> Site:
site_id = f"{code}-0001"
active = ActiveSite.objects.create(
code=code,
name=site_name,
source_site_id=site_id,
api_base_url=f"https://{code}.example/api/v1",
managed_by=user,
)
return Site.objects.create(
site_id=site_id,
site_name=site_name,
user=user,
active_site=active,
)
DB_HISTORY_SAMPLE = [ DB_HISTORY_SAMPLE = [
{"date": "2026-09-02", "location": "K1-L1", "total": 14591}, {"date": "2026-09-02", "location": "K1-L1", "total": 14591},
@@ -133,7 +151,7 @@ class ChickenCountingSyncApiTests(TestCase):
self.user = User.objects.create_user(user_name="tester", password="secret") self.user = User.objects.create_user(user_name="tester", password="secret")
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(self.user) self.client.force_authenticate(self.user)
site = Site.objects.create(site_name="Sukawarna", user=self.user) site = _linked_site(self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site) self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.cycle = Cycle.objects.create( self.cycle = Cycle.objects.create(
kandang=self.kandang, kandang=self.kandang,
+1 -1
View File
@@ -20,7 +20,7 @@ class ActiveSite(models.Model):
blank=True, blank=True,
default="", default="",
db_index=True, db_index=True,
help_text="Farm-location Site.site_id (slug) from the registering dashboard", help_text="Farm-location Site.site_id (slug); equals HQ sites.site_id when claimed",
) )
api_base_url = models.URLField( api_base_url = models.URLField(
max_length=255, max_length=255,
+17 -2
View File
@@ -149,6 +149,7 @@ class ClaimActiveSiteTests(TestCase):
self.active = ActiveSite.objects.create( self.active = ActiveSite.objects.create(
code="sukawarna", code="sukawarna",
name="Sukawarna", name="Sukawarna",
source_site_id="sukawarna-0001",
api_base_url="https://sukawarna.example/api/v1", api_base_url="https://sukawarna.example/api/v1",
api_key="site-secret", api_key="site-secret",
managed_by=None, managed_by=None,
@@ -164,7 +165,18 @@ class ClaimActiveSiteTests(TestCase):
self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.status_code, 200, response.data)
self.active.refresh_from_db() self.active.refresh_from_db()
self.assertEqual(self.active.managed_by_id, self.gm.pk) self.assertEqual(self.active.managed_by_id, self.gm.pk)
self.assertTrue(Site.objects.filter(active_site=self.active, user=self.gm).exists()) hq = Site.objects.get(active_site=self.active, user=self.gm)
self.assertEqual(hq.pk, "sukawarna-0001")
self.assertEqual(hq.site_id, self.active.source_site_id)
def test_claimed_site_api_id_is_farm_slug_string(self):
self.client.force_authenticate(self.gm)
self.client.post("/api/v1/active-sites/", {"code": "sukawarna"}, format="json")
listed = self.client.get("/api/v1/sites/")
self.assertEqual(listed.status_code, 200)
row = next(r for r in listed.data["results"] if r["site_name"] == "Sukawarna")
self.assertEqual(row["id"], "sukawarna-0001")
self.assertIsInstance(row["id"], str)
def test_claim_unknown_code_404(self): def test_claim_unknown_code_404(self):
self.client.force_authenticate(self.gm) self.client.force_authenticate(self.gm)
@@ -191,7 +203,10 @@ class ClaimActiveSiteTests(TestCase):
self.active.source_site_id = "sukawarna-0001" self.active.source_site_id = "sukawarna-0001"
self.active.save(update_fields=["managed_by", "source_site_id"]) self.active.save(update_fields=["managed_by", "source_site_id"])
hq_site = Site.objects.create( hq_site = Site.objects.create(
site_name="Sukawarna", user=self.gm, active_site=self.active site_id="sukawarna-0001",
site_name="Sukawarna",
user=self.gm,
active_site=self.active,
) )
self.client.force_authenticate(self.gm) self.client.force_authenticate(self.gm)
response = self.client.delete(f"/api/v1/active-sites/{self.active.pk}/") response = self.client.delete(f"/api/v1/active-sites/{self.active.pk}/")
+6 -3
View File
@@ -10,6 +10,7 @@
-- is_active true, is_superuser false. Product roles never use is_staff.) -- is_active true, is_superuser false. Product roles never use is_staff.)
-- --
-- user_access → sites → kandang → cycles -- user_access → sites → kandang → cycles
-- (sites.site_id = farm slug = active_sites.source_site_id when linked)
-- kandang → flock → iot_panel -- kandang → flock → iot_panel
-- user_access → api_keys -- user_access → api_keys
-- user_access → active_sites (managed_by) -- user_access → active_sites (managed_by)
@@ -103,10 +104,12 @@ CREATE INDEX IF NOT EXISTS idx_active_sites_managed_by_id ON active_sites (manag
CREATE INDEX IF NOT EXISTS idx_active_sites_source_site_id ON active_sites (source_site_id); CREATE INDEX IF NOT EXISTS idx_active_sites_source_site_id ON active_sites (source_site_id);
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 4. Sites -- 4. Sites (HQ farm tree)
-- site_id is the farm dashboard Site.site_id slug (e.g. sukawarna-4821).
-- When linked, it equals active_sites.source_site_id.
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sites ( CREATE TABLE IF NOT EXISTS sites (
site_id BIGSERIAL PRIMARY KEY, site_id VARCHAR(64) PRIMARY KEY,
site_name VARCHAR(30) NOT NULL, site_name VARCHAR(30) NOT NULL,
user_id BIGINT 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, active_site_id BIGINT UNIQUE REFERENCES active_sites (active_site_id) ON DELETE SET NULL,
@@ -123,7 +126,7 @@ CREATE TABLE IF NOT EXISTS kandang (
kandang_id BIGSERIAL PRIMARY KEY, kandang_id BIGSERIAL PRIMARY KEY,
kandang_name VARCHAR(30) NOT NULL, kandang_name VARCHAR(30) NOT NULL,
feed_in_button_urls JSONB NOT NULL DEFAULT '[]'::jsonb, feed_in_button_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
site_id BIGINT NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE, site_id VARCHAR(64) NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 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
); );
+1 -1
View File
@@ -119,7 +119,7 @@ const Header: React.FC<HeaderProps> = ({
<i className="fa-solid fa-map-marker-alt text-gray-500" /> <i className="fa-solid fa-map-marker-alt text-gray-500" />
<select <select
value={selectedSiteId ?? ''} value={selectedSiteId ?? ''}
onChange={(e) => setSelectedSiteId(e.target.value ? Number(e.target.value) : null)} onChange={(e) => setSelectedSiteId(e.target.value ? e.target.value : null)}
className="px-3 py-1.5 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white text-sm text-gray-900" className="px-3 py-1.5 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 bg-white text-sm text-gray-900"
> >
{visibleSites.length === 0 && <option value="">Tidak ada site</option>} {visibleSites.length === 0 && <option value="">Tidak ada site</option>}
+3 -3
View File
@@ -8,7 +8,7 @@ type FarmContextValue = {
kandangs: Kandang[]; kandangs: Kandang[];
flocks: Flock[]; flocks: Flock[];
cycles: Cycle[]; cycles: Cycle[];
selectedSiteId: number | null; selectedSiteId: string | null;
selectedKandangId: number | null; selectedKandangId: number | null;
selectedCycleId: number | null; selectedCycleId: number | null;
selectedSite: Site | null; selectedSite: Site | null;
@@ -16,7 +16,7 @@ type FarmContextValue = {
selectedCycle: Cycle | null; selectedCycle: Cycle | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
setSelectedSiteId: (id: number | null) => void; setSelectedSiteId: (id: string | null) => void;
setSelectedKandangId: (id: number | null) => void; setSelectedKandangId: (id: number | null) => void;
setSelectedCycleId: (id: number | null) => void; setSelectedCycleId: (id: number | null) => void;
reload: () => Promise<void>; reload: () => Promise<void>;
@@ -31,7 +31,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [kandangs, setKandangs] = useState<Kandang[]>([]); const [kandangs, setKandangs] = useState<Kandang[]>([]);
const [flocks, setFlocks] = useState<Flock[]>([]); const [flocks, setFlocks] = useState<Flock[]>([]);
const [cycles, setCycles] = useState<Cycle[]>([]); const [cycles, setCycles] = useState<Cycle[]>([]);
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null); const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null); const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
const [selectedCycleId, setSelectedCycleId] = useState<number | null>(null); const [selectedCycleId, setSelectedCycleId] = useState<number | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
+3 -3
View File
@@ -191,11 +191,11 @@ export const api = {
list: async (query?: { user_id?: number }) => list: async (query?: { user_id?: number }) =>
unwrapList(await get<Paginated<Site> | Site[]>('/sites/', query)), unwrapList(await get<Paginated<Site> | Site[]>('/sites/', query)),
create: (body: SiteWrite) => post<Site>('/sites/', body), create: (body: SiteWrite) => post<Site>('/sites/', body),
update: (id: number, body: Partial<SiteWrite>) => patch<Site>(`/sites/${id}/`, body), update: (id: string, body: Partial<SiteWrite>) => patch<Site>(`/sites/${id}/`, body),
remove: (id: number) => del(`/sites/${id}/`), remove: (id: string) => del(`/sites/${id}/`),
}, },
kandangs: { kandangs: {
list: async (query?: { site_id?: number }) => list: async (query?: { site_id?: string }) =>
unwrapList(await get<Paginated<Kandang> | Kandang[]>('/kandangs/', query)), unwrapList(await get<Paginated<Kandang> | Kandang[]>('/kandangs/', query)),
create: (body: KandangWrite) => post<Kandang>('/kandangs/', body), create: (body: KandangWrite) => post<Kandang>('/kandangs/', body),
update: (id: number, body: Partial<KandangWrite>) => patch<Kandang>(`/kandangs/${id}/`, body), update: (id: number, body: Partial<KandangWrite>) => patch<Kandang>(`/kandangs/${id}/`, body),
+4 -4
View File
@@ -65,7 +65,7 @@ export type ActiveSiteWrite = {
}; };
export type Site = { export type Site = {
id: number; id: string;
site_name: string; site_name: string;
user: number; user: number;
owner_name?: string; owner_name?: string;
@@ -77,7 +77,7 @@ export type Site = {
export type Kandang = { export type Kandang = {
id: number; id: number;
kandang_name: string; kandang_name: string;
site: number; site: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -268,7 +268,7 @@ export type KarungRequestResult = {
export type CycleFilter = { export type CycleFilter = {
cycle_id?: number; cycle_id?: number;
kandang_id?: number; kandang_id?: number;
site_id?: number; site_id?: string;
flock_id?: number; flock_id?: number;
date?: string; date?: string;
status?: CycleStatus; status?: CycleStatus;
@@ -277,7 +277,7 @@ export type CycleFilter = {
export type SiteWrite = { site_name: string; user: number }; export type SiteWrite = { site_name: string; user: number };
export type KandangWrite = { export type KandangWrite = {
kandang_name: string; kandang_name: string;
site: number; site: string;
}; };
export type FlockWrite = { export type FlockWrite = {
flock_name: string; flock_name: string;
+2 -2
View File
@@ -3,7 +3,7 @@ import type { Cycle, KPI, Site } from '../types/api.ts';
export type KandangRankingRow = { export type KandangRankingRow = {
rank: number; rank: number;
siteId: number; siteId: string;
siteName: string; siteName: string;
kandangId: number; kandangId: number;
kandangName: string; kandangName: string;
@@ -95,7 +95,7 @@ export async function loadKandangRanking({
sites.map((site) => api.kandangs.list({ site_id: site.id })) sites.map((site) => api.kandangs.list({ site_id: site.id }))
); );
type KandangMeta = { siteId: number; siteName: string; kandangName: string }; type KandangMeta = { siteId: string; siteName: string; kandangName: string };
const kandangMeta = new Map<number, KandangMeta>(); const kandangMeta = new Map<number, KandangMeta>();
kandangLists.forEach((list, i) => { kandangLists.forEach((list, i) => {
const site = sites[i]; const site = sites[i];