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

@@ -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
import secrets
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
from apps.accounts.models import User
def site_name_slug(site_name: str) -> str:
"""Slug portion of a site id (no numeric suffix)."""
code = slugify(str(site_name or "").strip())[:55]
if not code:
raise ValidationError({"site_name": "Nama site tidak valid untuk dijadikan id."})
return code
def site_id_from_name(site_name: str, *, numeric_suffix: int | None = None) -> str:
"""Site PK: `{name-slug}-{4-digit number}`, 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):
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)
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.
@@ -29,6 +57,11 @@ class Site(models.Model):
def __str__(self) -> str:
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):
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
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)
class Meta:
@@ -2,27 +2,56 @@
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
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:
"""
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
registrar with a matching name (so seeded Sukawarna attaches when
registered), otherwise creates a new Site.
Site.site_id matches ActiveSite.source_site_id (farm Site.site_id) when set.
Prefers an existing linked site, then a site already keyed by that id, then an
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
if owner is None:
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]
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 = (
Site.objects.filter(
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()
)
if orphan is None and active_site.code:
# e.g. code "sukawarna" ↔ site_name "Sukawarna"
orphan = (
Site.objects.filter(
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()
)
if orphan is not None:
orphan.active_site = active_site
orphan.save(update_fields=["active_site", "updated_at"])
return orphan
if orphan.pk == desired_id:
orphan.active_site = active_site
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(
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:
"""GM scope only sees sites linked to an ActiveSite they manage."""
site_id = f"{code}-0001"
active_site = ActiveSite.objects.create(
code=code,
name=site_name,
source_site_id=site_id,
api_base_url=f"https://{code}.example/api/v1",
managed_by=owner,
)
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,
)