From 64b37b6b6d4778ee8b9c4c2aae8fc839cf5c4c0f Mon Sep 17 00:00:00 2001
From: Alberto-Audrix
Date: Fri, 11 Sep 2026 15:51:29 +0700
Subject: [PATCH] adjust citysite table name to activesites
---
README.md | 4 +-
backend/.env.example | 2 +-
backend/README.md | 18 ++---
backend/apps/accounts/permissions.py | 20 +++---
backend/apps/accounts/scoping.py | 44 ++++++------
backend/apps/accounts/tests_scoping.py | 30 ++++----
.../0018_rename_city_site_to_active_site.py | 70 +++++++++++++++++++
backend/apps/farms/models.py | 20 +++---
backend/apps/farms/serializers.py | 8 +--
.../farms/services/active_site_registry.py | 49 +++++++++++++
backend/apps/farms/services/city_registry.py | 43 ------------
backend/apps/farms/services/site_close.py | 20 ++++--
backend/apps/farms/tests.py | 10 +--
backend/apps/farms/views.py | 48 +++++++------
.../jobs/management/commands/seed_demo.py | 8 ++-
.../0003_rename_citysite_to_activesite.py | 60 ++++++++++++++++
backend/apps/sync/models.py | 16 ++---
backend/apps/sync/serializers.py | 14 ++--
backend/apps/sync/urls.py | 4 +-
backend/apps/sync/views.py | 52 +++++++-------
backend/config/settings.py | 2 +-
components/SettingsPage.tsx | 28 ++++----
components/__tests__/SettingsPage.test.tsx | 2 +-
docker/.env.example | 4 +-
docker/entrypoint-cron.sh | 2 +-
services/apiClient.ts | 17 ++---
types/api.ts | 8 +--
27 files changed, 382 insertions(+), 221 deletions(-)
create mode 100644 backend/apps/farms/migrations/0018_rename_city_site_to_active_site.py
create mode 100644 backend/apps/farms/services/active_site_registry.py
delete mode 100644 backend/apps/farms/services/city_registry.py
create mode 100644 backend/apps/sync/migrations/0003_rename_citysite_to_activesite.py
diff --git a/README.md b/README.md
index 5973528..73faf8f 100644
--- a/README.md
+++ b/README.md
@@ -11,12 +11,12 @@ City A/B/C (API+DB) --API key sync--> HQ mirror API+DB --session--> Executive FE
- **Roles**
- `director` / `director_admin`: view all sites (`director_admin` is **not** Django superuser)
- `buh` / `bu_admin`: view only GMs they manage (`managed_by`) and those GMs' sites
- - `gm` / `gm_admin`: register city sites + sync; **only `gm`** approves cycle close
+ - `gm` / `gm_admin`: register active sites + sync; **only `gm`** approves cycle close
- `account_admin`: IT Central — User Management only; no ops dashboards
- `superuser`: break-glass (`bootstrap_admin` / seed `admin`) — director-level ops + Manajemen Akun; hidden from user lists
- `inactive`: locked out
- No `is_staff`
-- **City registry:** `/api/v1/city-sites/` (GM / GM Admin)
+- **Active site registry:** `/api/v1/active-sites/` (GM / GM Admin)
- **Approve close:** `POST /api/v1/cycles/{id}/approve-close/` (GM only)
## Ports
diff --git a/backend/.env.example b/backend/.env.example
index 6b21171..da46c0a 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -22,7 +22,7 @@ BOOTSTRAP_ADMIN_PASSWORD=
BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD=
-# City-edge syncs off at HQ (mirror via city-sites instead)
+# City-edge syncs off at HQ (mirror via active-sites instead)
KARUNG_WEB_ADMIN_SYNC_ENABLED=false
IOT_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=false
diff --git a/backend/README.md b/backend/README.md
index 0077386..c665820 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -7,9 +7,9 @@ Main-office **mirror API** for the executive frontend. Domain shapes match site
| Status | Capabilities | Data scope |
|--------|----------------|------------|
| `director`, `director_admin` | **View only** (director_admin may repair farm structure writes). Not Django superuser. | **All** sites / GMs |
-| `buh`, `bu_admin` | **View only** | Rollup of managed GMs' registered city sites |
-| `gm` | View + register city URLs + sync + **approve cycle close** | Own sites / city sites |
-| `gm_admin` | Same as GM **except** no cycle-close approval | Own sites / city sites |
+| `buh`, `bu_admin` | **View only** | Rollup of managed GMs' registered active sites |
+| `gm` | View + register site URLs + sync + **approve cycle close** | Own sites / active sites |
+| `gm_admin` | Same as GM **except** no cycle-close approval | Own sites / active sites |
| `account_admin` | **User Management only** (create / deactivate / reset password). No ops dashboards. Temp passwords shared out-of-band. | Users list (all product users; never lists `superuser`). |
| `superuser` | Break-glass: **ops dashboards (director scope) + Manajemen Akun**. Not creatable via UI; hidden from user lists. | All sites / GMs |
| `inactive` | Locked out | — |
@@ -17,17 +17,17 @@ Main-office **mirror API** for the executive frontend. Domain shapes match site
`bootstrap_admin` / seed `admin` uses status `superuser`. This app does not use `is_staff`.
GM↔BUH link: `User.managed_by` (confirmed). **Only `gm`** may request a **`buh`** (not `bu_admin` / `gm_admin`); **only that `buh`** may approve.
-City registry ownership: `CitySite.registered_by`.
-GM farm visibility follows the Pengaturan city-site list: only HQ `Site` rows linked to an active registered `CitySite` are shown (empty list ⇒ no sites).
+Active site registry ownership: `ActiveSite.registered_by`.
+GM farm visibility follows the Pengaturan active-site list: only HQ `Site` rows linked to an enabled registered `ActiveSite` are shown (empty list ⇒ no sites).
BUH farm visibility is the union of those lists for **confirmed** managed GMs (read-only Pengaturan rollup; optional GM filter in the header).
`POST /api/v1/cycles/{id}/approve-close/` — GM only.
-`POST /api/v1/city-sites/` — GM / GM Admin.
+`POST /api/v1/active-sites/` — GM / GM Admin.
-## City registry
+## Active site registry
-`GET/POST /api/v1/city-sites/` — register city dashboard APIs.
-`POST /api/v1/city-sites/{id}/sync/` and `POST /api/v1/city-sites/sync-all/` — stubs (`501`) until mirror pull is built.
+`GET/POST /api/v1/active-sites/` — register farm-location dashboard APIs.
+`POST /api/v1/active-sites/{id}/sync/` and `POST /api/v1/active-sites/sync-all/` — stubs (`501`) until mirror pull is built.
## Setup
diff --git a/backend/apps/accounts/permissions.py b/backend/apps/accounts/permissions.py
index 863e040..a39347a 100644
--- a/backend/apps/accounts/permissions.py
+++ b/backend/apps/accounts/permissions.py
@@ -2,7 +2,7 @@
Settled matrix (v1):
- buh, director (+ their admins until specified otherwise): view only
-- gm + gm_admin: view + register city sites (URL) + sync from cities
+- gm + gm_admin: view + register active sites (URL) + sync from sites
- gm only (not gm_admin): approve cycle close
"""
@@ -22,7 +22,7 @@ VIEW_ONLY_STATUSES = frozenset(
}
)
-# Can register city dashboard URLs into the HQ mirror registry.
+# Can register site dashboard URLs into the HQ mirror registry.
SITE_REGISTRY_STATUSES = frozenset(
{
User.STATUS_GM,
@@ -30,7 +30,7 @@ SITE_REGISTRY_STATUSES = frozenset(
}
)
-# Can trigger city → HQ mirror sync.
+# Can trigger site → HQ mirror sync.
SYNC_STATUSES = frozenset(
{
User.STATUS_GM,
@@ -73,8 +73,8 @@ def can_sync(user) -> bool:
)
-def can_register_city_site(user) -> bool:
- """GM / GM Admin may add city dashboard URLs to the HQ registry."""
+def can_register_active_site(user) -> bool:
+ """GM / GM Admin may add site dashboard URLs to the HQ registry."""
return bool(
user
and getattr(user, "is_authenticated", False)
@@ -105,9 +105,9 @@ def can_manage_users(user) -> bool:
return _status(user) in USER_ADMIN_STATUSES
-def can_manage_city_registry(user) -> bool:
- """Alias used by city-site write endpoints — same as register permission."""
- return can_register_city_site(user) or bool(getattr(user, "is_superuser", False))
+def can_manage_active_site_registry(user) -> bool:
+ """Alias used by active-site write endpoints — same as register permission."""
+ return can_register_active_site(user) or bool(getattr(user, "is_superuser", False))
class IsExecutive(permissions.BasePermission):
@@ -120,9 +120,9 @@ class IsExecutiveAdmin(permissions.BasePermission):
return can_manage_users(request.user)
-class CanRegisterCitySite(permissions.BasePermission):
+class CanRegisterActiveSite(permissions.BasePermission):
def has_permission(self, request, view):
- return can_register_city_site(request.user) or bool(
+ return can_register_active_site(request.user) or bool(
getattr(request.user, "is_superuser", False)
)
diff --git a/backend/apps/accounts/scoping.py b/backend/apps/accounts/scoping.py
index f116878..c8eb819 100644
--- a/backend/apps/accounts/scoping.py
+++ b/backend/apps/accounts/scoping.py
@@ -1,11 +1,11 @@
"""Visibility scoping for HQ executives.
-- director / director_admin: all sites / city sites / GMs
+- director / director_admin: all sites / active sites / GMs
- buh / bu_admin: only GMs (and gm_admins) they manage, plus those GMs' sites
-- gm / gm_admin: only their own sites / city sites they registered
+- gm / gm_admin: only their own sites / active sites they registered
Farm Site rows are only visible to non-directors when linked to an active
-CitySite in the viewer's Pengaturan registry.
+ActiveSite in the viewer's Pengaturan registry.
"""
from __future__ import annotations
@@ -61,7 +61,7 @@ def managed_gm_ids(buh: User) -> list[int]:
def visible_owner_ids(user: User) -> list[int] | None:
"""
- Site.user / CitySite.registered_by ids this viewer may see.
+ Site.user / ActiveSite.registered_by ids this viewer may see.
Returns None when there is no restriction (director / superuser).
"""
@@ -74,13 +74,13 @@ def visible_owner_ids(user: User) -> list[int] | None:
return []
-def visible_active_city_site_ids(user: User) -> list[int] | None:
+def visible_active_site_ids(user: User) -> list[int] | None:
"""
- Active CitySite pks in this viewer's Pengaturan registry.
+ Enabled ActiveSite pks in this viewer's Pengaturan registry.
Returns None for directors (no restriction). Empty list means no farm sites.
"""
- from apps.sync.models import CitySite
+ from apps.sync.models import ActiveSite
owners = visible_owner_ids(user)
if owners is None:
@@ -88,7 +88,7 @@ def visible_active_city_site_ids(user: User) -> list[int] | None:
if not owners:
return []
return list(
- CitySite.objects.filter(
+ ActiveSite.objects.filter(
registered_by_id__in=owners,
is_active=True,
).values_list("pk", flat=True)
@@ -120,13 +120,13 @@ def filter_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
return qs
if not owners:
return qs.none()
- city_ids = visible_active_city_site_ids(viewer)
- if not city_ids:
+ active_ids = visible_active_site_ids(viewer)
+ if not active_ids:
return qs.none()
- return qs.filter(user_id__in=owners, city_site_id__in=city_ids)
+ return qs.filter(user_id__in=owners, active_site_id__in=active_ids)
-def filter_city_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
+def filter_active_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
owners = visible_owner_ids(viewer)
if owners is None:
return qs
@@ -141,10 +141,12 @@ def filter_kandangs_queryset(qs: QuerySet, viewer: User) -> QuerySet:
return qs
if not owners:
return qs.none()
- city_ids = visible_active_city_site_ids(viewer)
- if not city_ids:
+ active_ids = visible_active_site_ids(viewer)
+ if not active_ids:
return qs.none()
- return qs.filter(site__user_id__in=owners, site__city_site_id__in=city_ids)
+ return qs.filter(
+ site__user_id__in=owners, site__active_site_id__in=active_ids
+ )
def filter_flocks_queryset(qs: QuerySet, viewer: User) -> QuerySet:
@@ -153,12 +155,12 @@ def filter_flocks_queryset(qs: QuerySet, viewer: User) -> QuerySet:
return qs
if not owners:
return qs.none()
- city_ids = visible_active_city_site_ids(viewer)
- if not city_ids:
+ active_ids = visible_active_site_ids(viewer)
+ if not active_ids:
return qs.none()
return qs.filter(
kandang__site__user_id__in=owners,
- kandang__site__city_site_id__in=city_ids,
+ kandang__site__active_site_id__in=active_ids,
)
@@ -168,10 +170,10 @@ def filter_cycles_queryset(qs: QuerySet, viewer: User) -> QuerySet:
return qs
if not owners:
return qs.none()
- city_ids = visible_active_city_site_ids(viewer)
- if not city_ids:
+ active_ids = visible_active_site_ids(viewer)
+ if not active_ids:
return qs.none()
return qs.filter(
kandang__site__user_id__in=owners,
- kandang__site__city_site_id__in=city_ids,
+ kandang__site__active_site_id__in=active_ids,
)
diff --git a/backend/apps/accounts/tests_scoping.py b/backend/apps/accounts/tests_scoping.py
index ea2400e..a38ba3c 100644
--- a/backend/apps/accounts/tests_scoping.py
+++ b/backend/apps/accounts/tests_scoping.py
@@ -3,7 +3,7 @@ from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Kandang, Site
-from apps.sync.models import CitySite
+from apps.sync.models import ActiveSite
class VisibilityScopeTests(TestCase):
@@ -27,23 +27,23 @@ class VisibilityScopeTests(TestCase):
status=User.STATUS_GM,
managed_by=self.buh_other,
)
- self.city_a = CitySite.objects.create(
+ self.active_a = ActiveSite.objects.create(
code="a",
- name="City A",
+ name="Site A",
api_base_url="https://a.example/api/v1",
registered_by=self.gm,
)
- self.city_b = CitySite.objects.create(
+ self.active_b = ActiveSite.objects.create(
code="b",
- name="City B",
+ name="Site B",
api_base_url="https://b.example/api/v1",
registered_by=self.gm_other,
)
self.site = Site.objects.create(
- site_name="Site A", user=self.gm, city_site=self.city_a
+ site_name="Site A", user=self.gm, active_site=self.active_a
)
self.site_other = Site.objects.create(
- site_name="Site B", user=self.gm_other, city_site=self.city_b
+ site_name="Site B", user=self.gm_other, active_site=self.active_b
)
self.orphan = Site.objects.create(site_name="Orphan Seed", user=self.gm)
Kandang.objects.create(kandang_name="K1", site=self.site)
@@ -70,9 +70,9 @@ class VisibilityScopeTests(TestCase):
names = {row["display_name"] for row in response.data["results"]}
self.assertEqual(names, {"gm1"})
- def test_gm_sees_own_city_sites_only(self):
+ def test_gm_sees_own_active_sites_only(self):
self.client.force_authenticate(self.gm)
- response = self.client.get("/api/v1/city-sites/")
+ response = self.client.get("/api/v1/active-sites/")
self.assertEqual(response.status_code, 200)
codes = {row["code"] for row in response.data["results"]}
self.assertEqual(codes, {"a"})
@@ -85,16 +85,16 @@ class VisibilityScopeTests(TestCase):
self.assertEqual(names, {"Site A"})
def test_gm_with_empty_registry_sees_no_sites(self):
- self.city_a.delete()
+ self.active_a.delete()
self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])
- def test_registering_city_site_attaches_matching_seed_site(self):
+ def test_registering_active_site_attaches_matching_seed_site(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
- "/api/v1/city-sites/",
+ "/api/v1/active-sites/",
{
"code": "orphan-seed",
"name": "Orphan Seed",
@@ -105,14 +105,14 @@ class VisibilityScopeTests(TestCase):
)
self.assertEqual(response.status_code, 201)
self.orphan.refresh_from_db()
- self.assertEqual(self.orphan.city_site_id, response.data["id"])
+ self.assertEqual(self.orphan.active_site_id, response.data["id"])
listed = self.client.get("/api/v1/sites/")
names = {row["site_name"] for row in listed.data["results"]}
self.assertIn("Orphan Seed", names)
- def test_director_sees_all_city_sites(self):
+ def test_director_sees_all_active_sites(self):
self.client.force_authenticate(self.director)
- response = self.client.get("/api/v1/city-sites/")
+ response = self.client.get("/api/v1/active-sites/")
self.assertEqual(response.status_code, 200)
codes = {row["code"] for row in response.data["results"]}
self.assertEqual(codes, {"a", "b"})
diff --git a/backend/apps/farms/migrations/0018_rename_city_site_to_active_site.py b/backend/apps/farms/migrations/0018_rename_city_site_to_active_site.py
new file mode 100644
index 0000000..46115b7
--- /dev/null
+++ b/backend/apps/farms/migrations/0018_rename_city_site_to_active_site.py
@@ -0,0 +1,70 @@
+# Generated manually: rename city_site FKs → active_site
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("farms", "0017_site_city_site"),
+ ("sync", "0003_rename_citysite_to_activesite"),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="cycle",
+ name="uniq_cycle_city_source",
+ ),
+ migrations.RenameField(
+ model_name="site",
+ old_name="city_site",
+ new_name="active_site",
+ ),
+ migrations.RenameField(
+ model_name="cycle",
+ old_name="city_site",
+ new_name="active_site",
+ ),
+ migrations.AddConstraint(
+ model_name="cycle",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(
+ ("active_site__isnull", False), ("source_cycle_id__isnull", False)
+ ),
+ fields=("active_site", "source_cycle_id"),
+ name="uniq_cycle_active_source",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="site",
+ name="active_site",
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="hq_site",
+ to="sync.activesite",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="cycle",
+ name="active_site",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="mirrored_cycles",
+ to="sync.activesite",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="cycle",
+ name="source_cycle_id",
+ field=models.BigIntegerField(
+ blank=True,
+ help_text="cycle_id on the farm-location dashboard-cpsp deployment",
+ null=True,
+ ),
+ ),
+ ]
diff --git a/backend/apps/farms/models.py b/backend/apps/farms/models.py
index 118674b..7076e87 100644
--- a/backend/apps/farms/models.py
+++ b/backend/apps/farms/models.py
@@ -11,9 +11,9 @@ class Site(models.Model):
site_id = models.BigAutoField(primary_key=True)
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 city dashboard to their Pengaturan view list.
- city_site = models.OneToOneField(
- "sync.CitySite",
+ # Linked when GM/GM Admin adds the site dashboard to their Pengaturan view list.
+ active_site = models.OneToOneField(
+ "sync.ActiveSite",
null=True,
blank=True,
on_delete=models.SET_NULL,
@@ -87,8 +87,8 @@ class Cycle(models.Model):
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
- city_site = models.ForeignKey(
- "sync.CitySite",
+ active_site = models.ForeignKey(
+ "sync.ActiveSite",
null=True,
blank=True,
on_delete=models.SET_NULL,
@@ -97,7 +97,7 @@ class Cycle(models.Model):
source_cycle_id = models.BigIntegerField(
null=True,
blank=True,
- help_text="cycle_id on the city dashboard-cpsp deployment",
+ help_text="cycle_id on the farm-location dashboard-cpsp deployment",
)
proposed_end_date = models.DateField(null=True, blank=True)
close_requested_at = models.DateTimeField(null=True, blank=True)
@@ -129,9 +129,11 @@ class Cycle(models.Model):
name="chk_cycles_date_range",
),
models.UniqueConstraint(
- fields=["city_site", "source_cycle_id"],
- condition=models.Q(city_site__isnull=False, source_cycle_id__isnull=False),
- name="uniq_cycle_city_source",
+ fields=["active_site", "source_cycle_id"],
+ condition=models.Q(
+ active_site__isnull=False, source_cycle_id__isnull=False
+ ),
+ name="uniq_cycle_active_source",
),
]
diff --git a/backend/apps/farms/serializers.py b/backend/apps/farms/serializers.py
index 72591be..a7d207f 100644
--- a/backend/apps/farms/serializers.py
+++ b/backend/apps/farms/serializers.py
@@ -14,11 +14,11 @@ class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
"site_name",
"user",
"owner_name",
- "city_site",
+ "active_site",
"created_at",
"updated_at",
]
- read_only_fields = ["id", "owner_name", "city_site", "created_at", "updated_at"]
+ read_only_fields = ["id", "owner_name", "active_site", "created_at", "updated_at"]
class FeedInButtonUrlSerializer(serializers.Serializer):
@@ -104,7 +104,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"feed_initial_balance",
"feed_initial_balance_date",
"status",
- "city_site",
+ "active_site",
"source_cycle_id",
"proposed_end_date",
"close_requested_at",
@@ -120,7 +120,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"feed_initial_balance",
"feed_initial_balance_date",
"status",
- "city_site",
+ "active_site",
"source_cycle_id",
"proposed_end_date",
"close_requested_at",
diff --git a/backend/apps/farms/services/active_site_registry.py b/backend/apps/farms/services/active_site_registry.py
new file mode 100644
index 0000000..ab29fa4
--- /dev/null
+++ b/backend/apps/farms/services/active_site_registry.py
@@ -0,0 +1,49 @@
+"""Link HQ Site rows to ActiveSite registry entries (Pengaturan view list)."""
+
+from __future__ import annotations
+
+from apps.farms.models import Site
+from apps.sync.models import ActiveSite
+
+
+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.
+ """
+ existing = Site.objects.filter(active_site=active_site).first()
+ if existing:
+ return existing
+
+ owner = active_site.registered_by
+ if owner is None:
+ raise ValueError("ActiveSite.registered_by is required to create an HQ site.")
+
+ site_name = (active_site.name or active_site.code or "Site")[:30]
+ orphan = (
+ Site.objects.filter(
+ user=owner, active_site__isnull=True, site_name__iexact=site_name
+ )
+ .order_by("pk")
+ .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
+ )
+ .order_by("pk")
+ .first()
+ )
+ if orphan is not None:
+ orphan.active_site = active_site
+ orphan.save(update_fields=["active_site", "updated_at"])
+ return orphan
+
+ return Site.objects.create(
+ site_name=site_name, user=owner, active_site=active_site
+ )
diff --git a/backend/apps/farms/services/city_registry.py b/backend/apps/farms/services/city_registry.py
deleted file mode 100644
index ab8713f..0000000
--- a/backend/apps/farms/services/city_registry.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Link HQ Site rows to CitySite registry entries (Pengaturan view list)."""
-
-from __future__ import annotations
-
-from apps.farms.models import Site
-from apps.sync.models import CitySite
-
-
-def ensure_hq_site_for_city(city: CitySite) -> Site:
- """
- Ensure the city 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.
- """
- existing = Site.objects.filter(city_site=city).first()
- if existing:
- return existing
-
- owner = city.registered_by
- if owner is None:
- raise ValueError("CitySite.registered_by is required to create an HQ site.")
-
- site_name = (city.name or city.code or "Site")[:30]
- orphan = (
- Site.objects.filter(user=owner, city_site__isnull=True, site_name__iexact=site_name)
- .order_by("pk")
- .first()
- )
- if orphan is None and city.code:
- # e.g. code "sukawarna" ↔ site_name "Sukawarna"
- orphan = (
- Site.objects.filter(user=owner, city_site__isnull=True, site_name__iexact=city.code)
- .order_by("pk")
- .first()
- )
- if orphan is not None:
- orphan.city_site = city
- orphan.save(update_fields=["city_site", "updated_at"])
- return orphan
-
- return Site.objects.create(site_name=site_name, user=owner, city_site=city)
diff --git a/backend/apps/farms/services/site_close.py b/backend/apps/farms/services/site_close.py
index 4c41396..7f5bb59 100644
--- a/backend/apps/farms/services/site_close.py
+++ b/backend/apps/farms/services/site_close.py
@@ -1,4 +1,4 @@
-"""Push close decisions from pusat to city dashboard-cpsp."""
+"""Push close decisions from pusat to farm-location dashboard-cpsp."""
from __future__ import annotations
@@ -18,12 +18,17 @@ class SiteClosePushError(Exception):
def push_close_decision(cycle: Cycle, *, approved: bool) -> None:
- city = cycle.city_site
- if not city or not city.api_base_url or not city.api_key or not cycle.source_cycle_id:
- logger.info("No city channel configured for cycle %s; skip push", cycle.pk)
+ active_site = cycle.active_site
+ if (
+ not active_site
+ or not active_site.api_base_url
+ or not active_site.api_key
+ or not cycle.source_cycle_id
+ ):
+ logger.info("No active-site channel configured for cycle %s; skip push", cycle.pk)
return
- base = city.api_base_url.rstrip("/")
+ base = active_site.api_base_url.rstrip("/")
url = f"{base}/cycles/{cycle.source_cycle_id}/apply-close-decision/"
payload = {"decision": "approve" if approved else "reject"}
if approved:
@@ -35,7 +40,10 @@ def push_close_decision(cycle: Cycle, *, approved: bool) -> None:
response = httpx.post(
url,
json=payload,
- headers={"X-API-Key": city.api_key, "Content-Type": "application/json"},
+ headers={
+ "X-API-Key": active_site.api_key,
+ "Content-Type": "application/json",
+ },
timeout=30.0,
)
if response.status_code >= 400:
diff --git a/backend/apps/farms/tests.py b/backend/apps/farms/tests.py
index 95230f0..b3a365c 100644
--- a/backend/apps/farms/tests.py
+++ b/backend/apps/farms/tests.py
@@ -7,18 +7,20 @@ from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import FeedSacks, ManualInput
-from apps.sync.models import CitySite
+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 active CitySite they registered."""
- city = CitySite.objects.create(
+ """GM scope only sees sites linked to an ActiveSite they registered."""
+ active_site = ActiveSite.objects.create(
code=code,
name=site_name,
api_base_url=f"https://{code}.example/api/v1",
registered_by=owner,
)
- return Site.objects.create(site_name=site_name, user=owner, city_site=city)
+ return Site.objects.create(
+ site_name=site_name, user=owner, active_site=active_site
+ )
class FarmAPITests(TestCase):
diff --git a/backend/apps/farms/views.py b/backend/apps/farms/views.py
index e9e8553..51cf653 100644
--- a/backend/apps/farms/views.py
+++ b/backend/apps/farms/views.py
@@ -23,8 +23,8 @@ from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
-from apps.farms.services.city_registry import ensure_hq_site_for_city
-from apps.sync.models import CitySite
+from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site
+from apps.sync.models import ActiveSite
def _validation_detail(exc: ValidationError):
@@ -35,7 +35,7 @@ def _validation_detail(exc: ValidationError):
return str(getattr(exc, "message", exc))
-def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
+def _upsert_mirror_cycle_from_ingest(active_site: ActiveSite, payload: dict) -> Cycle:
source_cycle_id = payload.get("source_cycle_id")
if not source_cycle_id:
raise ValidationError("source_cycle_id is required.")
@@ -46,15 +46,15 @@ def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
if start is None:
raise ValidationError("start_date is required.")
- site_name = str(payload.get("site_name") or city.name)[:30]
+ site_name = str(payload.get("site_name") or active_site.name)[:30]
kandang_name = str(payload.get("kandang_name") or "Kandang")[:30]
- owner = city.registered_by
+ owner = active_site.registered_by
if owner is None:
owner = User.objects.filter(status=User.STATUS_GM).first()
if owner is None:
raise ValidationError("No GM available to own mirrored site.")
- site = ensure_hq_site_for_city(city)
+ site = ensure_hq_site_for_active_site(active_site)
if site.site_name != site_name and not Site.objects.filter(
user=owner, site_name=site_name
).exclude(pk=site.pk).exists():
@@ -67,11 +67,13 @@ def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
defaults={},
)
- cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first()
+ cycle = Cycle.objects.filter(
+ active_site=active_site, source_cycle_id=source_cycle_id
+ ).first()
if cycle is None:
cycle = Cycle(
kandang=kandang,
- city_site=city,
+ active_site=active_site,
source_cycle_id=source_cycle_id,
start_date=start,
end_date=None,
@@ -97,13 +99,15 @@ def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
@permission_classes([permissions.IsAuthenticated])
def cycle_close_ingest(request):
"""Site → pusat: register/update a pending close request."""
- city_site_id = request.data.get("city_site_id")
+ active_site_id = request.data.get("active_site_id")
try:
- city = CitySite.objects.get(pk=city_site_id, is_active=True)
- except (CitySite.DoesNotExist, TypeError, ValueError):
- return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST)
+ active_site = ActiveSite.objects.get(pk=active_site_id, is_active=True)
+ except (ActiveSite.DoesNotExist, TypeError, ValueError):
+ return Response(
+ {"detail": "Invalid active_site_id."}, status=status.HTTP_400_BAD_REQUEST
+ )
try:
- cycle = _upsert_mirror_cycle_from_ingest(city, request.data)
+ cycle = _upsert_mirror_cycle_from_ingest(active_site, request.data)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data)
@@ -113,13 +117,17 @@ def cycle_close_ingest(request):
@permission_classes([permissions.IsAuthenticated])
def cycle_close_cancel(request):
"""Site → pusat: cancel a pending close request."""
- city_site_id = request.data.get("city_site_id")
+ active_site_id = request.data.get("active_site_id")
source_cycle_id = request.data.get("source_cycle_id")
try:
- city = CitySite.objects.get(pk=city_site_id, is_active=True)
- except (CitySite.DoesNotExist, TypeError, ValueError):
- return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST)
- cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first()
+ active_site = ActiveSite.objects.get(pk=active_site_id, is_active=True)
+ except (ActiveSite.DoesNotExist, TypeError, ValueError):
+ return Response(
+ {"detail": "Invalid active_site_id."}, status=status.HTTP_400_BAD_REQUEST
+ )
+ cycle = Cycle.objects.filter(
+ active_site=active_site, source_cycle_id=source_cycle_id
+ ).first()
if cycle is None:
return Response({"detail": "Mirror cycle not found."}, status=status.HTTP_404_NOT_FOUND)
try:
@@ -132,7 +140,7 @@ def cycle_close_cancel(request):
class IsDirectorAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated executive; structure writes for director_admin.
- Mirrored farm structures should normally arrive via city sync. Manual
+ Mirrored farm structures should normally arrive via active-site sync. Manual
create/edit/delete is reserved for director_admin (bootstrap / repair).
"""
@@ -192,7 +200,7 @@ class FlockViewSet(viewsets.ModelViewSet):
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related(
- "kandang", "close_approved_by", "city_site", "kandang__site"
+ "kandang", "close_approved_by", "active_site", "kandang__site"
).all()
serializer_class = CycleSerializer
diff --git a/backend/apps/jobs/management/commands/seed_demo.py b/backend/apps/jobs/management/commands/seed_demo.py
index 54464ae..5b840d4 100644
--- a/backend/apps/jobs/management/commands/seed_demo.py
+++ b/backend/apps/jobs/management/commands/seed_demo.py
@@ -5,7 +5,7 @@ from django.db import transaction
from apps.accounts.models import ApiKey, User
from apps.farms.models import Site
-from apps.sync.models import CitySite
+from apps.sync.models import ActiveSite
# Every executive role + inactive, for local HQ demos.
@@ -108,13 +108,15 @@ class Command(BaseCommand):
if not staff:
return
moved_sites = Site.objects.filter(user=staff).update(user=gm)
- moved_cities = CitySite.objects.filter(registered_by=staff).update(registered_by=gm)
+ moved_active = ActiveSite.objects.filter(registered_by=staff).update(
+ registered_by=gm
+ )
ApiKey.objects.filter(user=staff).delete()
staff.delete()
self.stdout.write(
self.style.WARNING(
f"Removed legacy staff login "
- f"(reassigned sites={moved_sites}, city_sites={moved_cities} to gm)"
+ f"(reassigned sites={moved_sites}, active_sites={moved_active} to gm)"
)
)
diff --git a/backend/apps/sync/migrations/0003_rename_citysite_to_activesite.py b/backend/apps/sync/migrations/0003_rename_citysite_to_activesite.py
new file mode 100644
index 0000000..bf744f8
--- /dev/null
+++ b/backend/apps/sync/migrations/0003_rename_citysite_to_activesite.py
@@ -0,0 +1,60 @@
+# Generated manually: rename CitySite → ActiveSite
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("sync", "0002_citysite_registered_by"),
+ # Farms FKs still reference CitySite; rename only after those exist.
+ ("farms", "0017_site_city_site"),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name="CitySite",
+ new_name="ActiveSite",
+ ),
+ migrations.RenameField(
+ model_name="activesite",
+ old_name="city_site_id",
+ new_name="active_site_id",
+ ),
+ migrations.AlterModelTable(
+ name="activesite",
+ table="active_sites",
+ ),
+ migrations.AlterField(
+ model_name="activesite",
+ name="api_base_url",
+ field=models.URLField(
+ help_text="Site API root including /api/v1, e.g. https://site.example/api/v1",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="activesite",
+ name="api_key",
+ field=models.CharField(
+ blank=True,
+ help_text="X-API-Key for pulling from the site API (keep server-side only)",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="activesite",
+ name="registered_by",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="GM / GM Admin who added this site dashboard URL",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="registered_active_sites",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/backend/apps/sync/models.py b/backend/apps/sync/models.py
index 28398fd..c4e8f0f 100644
--- a/backend/apps/sync/models.py
+++ b/backend/apps/sync/models.py
@@ -4,33 +4,33 @@ from django.conf import settings
from django.db import models
-class CitySite(models.Model):
+class ActiveSite(models.Model):
"""
- Registry of a city dashboard-cpsp deployment that HQ can mirror from.
+ Registry of a farm-location dashboard-cpsp deployment that HQ can mirror from.
Outbound API key is stored here for local/dev scaffolding. Prefer a secret
manager or encrypted field before production.
"""
- city_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")
name = models.CharField(max_length=120)
api_base_url = models.URLField(
max_length=255,
- help_text="City API root including /api/v1, e.g. https://site.example/api/v1",
+ help_text="Site API root including /api/v1, e.g. https://site.example/api/v1",
)
api_key = models.CharField(
max_length=255,
blank=True,
- help_text="X-API-Key for pulling from the city API (keep server-side only)",
+ help_text="X-API-Key for pulling from the site API (keep server-side only)",
)
registered_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
- related_name="registered_city_sites",
- help_text="GM / GM Admin who added this city dashboard URL",
+ related_name="registered_active_sites",
+ help_text="GM / GM Admin who added this site dashboard URL",
)
is_active = models.BooleanField(default=True)
last_synced_at = models.DateTimeField(null=True, blank=True)
@@ -40,7 +40,7 @@ class CitySite(models.Model):
updated_at = models.DateTimeField(auto_now=True)
class Meta:
- db_table = "city_sites"
+ db_table = "active_sites"
ordering = ["name"]
def __str__(self) -> str:
diff --git a/backend/apps/sync/serializers.py b/backend/apps/sync/serializers.py
index 4a2de65..7b3cea5 100644
--- a/backend/apps/sync/serializers.py
+++ b/backend/apps/sync/serializers.py
@@ -1,18 +1,18 @@
from rest_framework import serializers
-from apps.sync.models import CitySite
+from apps.sync.models import ActiveSite
-class CitySiteSerializer(serializers.ModelSerializer):
+class ActiveSiteSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="pk", read_only=True)
- # Never echo the outbound city API key back to the browser by default.
+ # Never echo the outbound site API key back to the browser by default.
has_api_key = serializers.SerializerMethodField()
registered_by_name = serializers.CharField(
source="registered_by.display_name", read_only=True, allow_null=True
)
class Meta:
- model = CitySite
+ model = ActiveSite
fields = [
"id",
"code",
@@ -40,15 +40,15 @@ class CitySiteSerializer(serializers.ModelSerializer):
"updated_at",
]
- def get_has_api_key(self, obj: CitySite) -> bool:
+ def get_has_api_key(self, obj: ActiveSite) -> bool:
return bool(obj.api_key)
-class CitySiteWriteSerializer(serializers.ModelSerializer):
+class ActiveSiteWriteSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="pk", read_only=True)
class Meta:
- model = CitySite
+ model = ActiveSite
fields = [
"id",
"code",
diff --git a/backend/apps/sync/urls.py b/backend/apps/sync/urls.py
index dd8bf5a..abee609 100644
--- a/backend/apps/sync/urls.py
+++ b/backend/apps/sync/urls.py
@@ -1,10 +1,10 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
-from apps.sync.views import CitySiteViewSet
+from apps.sync.views import ActiveSiteViewSet
router = DefaultRouter()
-router.register("city-sites", CitySiteViewSet, basename="city-site")
+router.register("active-sites", ActiveSiteViewSet, basename="active-site")
urlpatterns = [
path("", include(router.urls)),
diff --git a/backend/apps/sync/views.py b/backend/apps/sync/views.py
index 71b61ed..f3a2519 100644
--- a/backend/apps/sync/views.py
+++ b/backend/apps/sync/views.py
@@ -2,62 +2,62 @@ from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
-from apps.accounts.permissions import CanRegisterCitySite, IsExecutive, can_sync
-from apps.accounts.scoping import filter_city_sites_queryset
-from apps.farms.services.city_registry import ensure_hq_site_for_city
-from apps.sync.models import CitySite
-from apps.sync.serializers import CitySiteSerializer, CitySiteWriteSerializer
+from apps.accounts.permissions import CanRegisterActiveSite, IsExecutive, can_sync
+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.sync.models import ActiveSite
+from apps.sync.serializers import ActiveSiteSerializer, ActiveSiteWriteSerializer
-class CitySiteViewSet(viewsets.ModelViewSet):
+class ActiveSiteViewSet(viewsets.ModelViewSet):
"""
List/read: scoped (director all; BUH managed GMs; GM own).
Create/update/delete: GM and GM Admin.
Sync: GM and GM Admin only (stubs until mirror pull is implemented).
- Creating a city site also attaches (or creates) an HQ Site so farm data
+ Creating an active site also attaches (or creates) an HQ Site so farm data
appears in the executive view list.
"""
executive_sync_action = True
def get_queryset(self):
- qs = CitySite.objects.select_related("registered_by").all()
- return filter_city_sites_queryset(qs, self.request.user)
+ qs = ActiveSite.objects.select_related("registered_by").all()
+ return filter_active_sites_queryset(qs, self.request.user)
def get_permissions(self):
if self.action in {"create", "update", "partial_update", "destroy"}:
- return [CanRegisterCitySite()]
+ return [CanRegisterActiveSite()]
return [IsExecutive()]
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
- return CitySiteWriteSerializer
- return CitySiteSerializer
+ return ActiveSiteWriteSerializer
+ return ActiveSiteSerializer
def perform_create(self, serializer):
- city = serializer.save(registered_by=self.request.user)
- ensure_hq_site_for_city(city)
+ active_site = serializer.save(registered_by=self.request.user)
+ ensure_hq_site_for_active_site(active_site)
def perform_update(self, serializer):
- city = serializer.save()
- if city.is_active:
- ensure_hq_site_for_city(city)
+ active_site = serializer.save()
+ if active_site.is_active:
+ ensure_hq_site_for_active_site(active_site)
@action(detail=True, methods=["post"], url_path="sync")
def sync(self, request, pk=None):
if not can_sync(request.user):
return Response({"detail": "Sync not allowed."}, status=status.HTTP_403_FORBIDDEN)
- city = self.get_object()
- if not city.is_active:
+ active_site = self.get_object()
+ if not active_site.is_active:
return Response(
- {"detail": "City site is inactive."},
+ {"detail": "Active site is inactive."},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(
{
- "detail": "City sync not implemented yet.",
- "city_site": CitySiteSerializer(city).data,
+ "detail": "Active site sync not implemented yet.",
+ "active_site": ActiveSiteSerializer(active_site).data,
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
@@ -66,12 +66,12 @@ class CitySiteViewSet(viewsets.ModelViewSet):
def sync_all(self, request):
if not can_sync(request.user):
return Response({"detail": "Sync not allowed."}, status=status.HTTP_403_FORBIDDEN)
- cities = list(self.get_queryset().filter(is_active=True))
+ sites = list(self.get_queryset().filter(is_active=True))
return Response(
{
- "detail": "City sync-all not implemented yet.",
- "city_count": len(cities),
- "cities": CitySiteSerializer(cities, many=True).data,
+ "detail": "Active site sync-all not implemented yet.",
+ "active_site_count": len(sites),
+ "active_sites": ActiveSiteSerializer(sites, many=True).data,
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
diff --git a/backend/config/settings.py b/backend/config/settings.py
index 626b99e..54ef0ea 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -182,7 +182,7 @@ KARUNG_WEB_ADMIN_BASE_URL = (
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS = float(
env("KARUNG_WEB_ADMIN_TIMEOUT_SECONDS", "30") or "30"
)
-# City-edge syncs are OFF by default at HQ — data should arrive via city-site mirror sync.
+# City-edge syncs are OFF by default at HQ — data should arrive via active-site mirror sync.
KARUNG_WEB_ADMIN_SYNC_ENABLED = env_bool("KARUNG_WEB_ADMIN_SYNC_ENABLED", False)
# Dashboard publish cutoff (WIB): day D visible from this time on day D.
diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx
index 477c5db..f609c0a 100644
--- a/components/SettingsPage.tsx
+++ b/components/SettingsPage.tsx
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
-import type { CitySite, User, UserStatus } from '../types/api.ts';
+import type { ActiveSite, User, UserStatus } from '../types/api.ts';
import { Banner, PageHeader, Spinner } from './ui/Feedback.tsx';
const inputClass =
@@ -38,7 +38,7 @@ const BuhViewList: React.FC = () => {
const isPrimaryBuh = user?.status === 'buh';
const [gms, setGms] = useState([]);
const [pending, setPending] = useState([]);
- const [citySites, setCitySites] = useState([]);
+ const [activeSites, setActiveSites] = useState([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
const [error, setError] = useState(null);
@@ -49,11 +49,11 @@ const BuhViewList: React.FC = () => {
setError(null);
try {
const nextUsers = await api.users.list();
- const nextSites = await api.citySites.list();
+ const nextSites = await api.activeSites.list();
const nextPending = isPrimaryBuh ? await api.users.listPendingManaged() : [];
setGms(nextUsers.filter((u) => u.status === 'gm'));
setPending(nextPending);
- setCitySites(nextSites);
+ setActiveSites(nextSites);
} catch (err) {
setError(errorMessage(err));
} finally {
@@ -66,15 +66,15 @@ const BuhViewList: React.FC = () => {
}, [reload]);
const sitesByGm = useMemo(() => {
- const map = new Map();
- for (const site of citySites) {
+ const map = new Map();
+ for (const site of activeSites) {
if (site.registered_by == null) continue;
const rows = map.get(site.registered_by) ?? [];
rows.push(site);
map.set(site.registered_by, rows);
}
return map;
- }, [citySites]);
+ }, [activeSites]);
const decide = async (gmId: number, decision: 'approve' | 'reject') => {
setBusyId(gmId);
@@ -212,7 +212,7 @@ const BuhViewList: React.FC = () => {
const GmViewList: React.FC = () => {
const { user, refreshUser } = useAuth();
const { reload: reloadFarm } = useFarm();
- const [citySites, setCitySites] = useState([]);
+ const [activeSites, setActiveSites] = useState([]);
const [buhs, setBuhs] = useState([]);
const [selectedBuhId, setSelectedBuhId] = useState('');
const [loading, setLoading] = useState(true);
@@ -236,12 +236,12 @@ const GmViewList: React.FC = () => {
setLoading(true);
setError(null);
try {
- const sitesPromise = api.citySites.list();
+ const sitesPromise = api.activeSites.list();
const mePromise = refreshUser();
const buhsPromise =
user?.status === 'gm' ? api.users.listBuhs() : Promise.resolve([] as User[]);
const [sites, me, nextBuhs] = await Promise.all([sitesPromise, mePromise, buhsPromise]);
- setCitySites(sites);
+ setActiveSites(sites);
setBuhs(nextBuhs);
if (me?.managed_by_pending) {
setSelectedBuhId(me.managed_by_pending);
@@ -409,7 +409,7 @@ const GmViewList: React.FC = () => {
const resolvedCode = code || slugify(name);
if (!resolvedCode) return;
void wrap(async () => {
- const created = await api.citySites.create({
+ const created = await api.activeSites.create({
code: resolvedCode,
name: name.trim(),
api_base_url: apiBaseUrl.trim(),
@@ -487,11 +487,11 @@ const GmViewList: React.FC = () => {
{loading ? (
- ) : citySites.length === 0 ? (
+ ) : activeSites.length === 0 ? (
Belum ada site di daftar tampilan.
) : (
- {citySites.map((site) => (
+ {activeSites.map((site) => (
-
{site.name}
@@ -516,7 +516,7 @@ const GmViewList: React.FC = () => {
disabled={saving}
onClick={() =>
void wrap(async () => {
- await api.citySites.remove(site.id);
+ await api.activeSites.remove(site.id);
flash(`Site ${site.name} dihapus dari daftar tampilan.`);
})
}
diff --git a/components/__tests__/SettingsPage.test.tsx b/components/__tests__/SettingsPage.test.tsx
index a946e80..60769aa 100644
--- a/components/__tests__/SettingsPage.test.tsx
+++ b/components/__tests__/SettingsPage.test.tsx
@@ -41,7 +41,7 @@ const {
vi.mock('../../services/apiClient.ts', () => ({
api: {
- citySites: {
+ activeSites: {
list: listMock,
create: createMock,
remove: removeMock,
diff --git a/docker/.env.example b/docker/.env.example
index 3a5c64e..49888ab 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -24,10 +24,10 @@ BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n
BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD=
-# Optional: fixed API key for city-site inbound / scripts (hashed at rest)
+# Optional: fixed API key for active-site inbound / scripts (hashed at rest)
BOOTSTRAP_API_KEY=
-# City-edge syncs OFF at HQ — data should arrive via city-site mirror sync.
+# City-edge syncs OFF at HQ — data should arrive via active-site mirror sync.
# Enable only if this box also pulls directly from edge services.
KARUNG_WEB_ADMIN_SYNC_ENABLED=false
IOT_SYNC_ENABLED=false
diff --git a/docker/entrypoint-cron.sh b/docker/entrypoint-cron.sh
index d72b873..c8f1e32 100644
--- a/docker/entrypoint-cron.sh
+++ b/docker/entrypoint-cron.sh
@@ -6,7 +6,7 @@ printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|CHICKEN_COUNTING_|DA
> /etc/environment || true
# Render publish-time cron from DASHBOARD_PUBLISH_* (default 17:00).
-# Edge syncs are OFF by default at HQ (mirror via city-sites); commands no-op when disabled.
+# Edge syncs are OFF by default at HQ (mirror via active-sites); commands no-op when disabled.
# File lives in /etc/cron.d (requires username field) — do not feed it to `crontab`.
PUBLISH_HOUR="${DASHBOARD_PUBLISH_HOUR:-17}"
PUBLISH_MINUTE="${DASHBOARD_PUBLISH_MINUTE:-0}"
diff --git a/services/apiClient.ts b/services/apiClient.ts
index 21f80a1..a52689f 100644
--- a/services/apiClient.ts
+++ b/services/apiClient.ts
@@ -2,8 +2,8 @@ import type {
AIInsight,
ChickenCounting,
ChickenWeight,
- CitySite,
- CitySiteWrite,
+ ActiveSite,
+ ActiveSiteWrite,
CountingWrite,
Cycle,
CycleFilter,
@@ -164,12 +164,13 @@ export const api = {
logout: () => post
('/auth/logout/'),
me: () => get('/auth/me/'),
},
- citySites: {
- list: async () => unwrapList(await get | CitySite[]>('/city-sites/')),
- create: (body: CitySiteWrite) => post('/city-sites/', body),
- update: (id: number, body: Partial) =>
- patch(`/city-sites/${id}/`, body),
- remove: (id: number) => del(`/city-sites/${id}/`),
+ activeSites: {
+ list: async () =>
+ unwrapList(await get | ActiveSite[]>('/active-sites/')),
+ create: (body: ActiveSiteWrite) => post('/active-sites/', body),
+ update: (id: number, body: Partial) =>
+ patch(`/active-sites/${id}/`, body),
+ remove: (id: number) => del(`/active-sites/${id}/`),
},
users: {
list: async () => unwrapList(await get | User[]>('/users/')),
diff --git a/types/api.ts b/types/api.ts
index 47c9725..1760dec 100644
--- a/types/api.ts
+++ b/types/api.ts
@@ -39,7 +39,7 @@ export type UserWrite = {
managed_by?: number | null;
};
-export type CitySite = {
+export type ActiveSite = {
id: number;
code: string;
name: string;
@@ -55,7 +55,7 @@ export type CitySite = {
updated_at: string;
};
-export type CitySiteWrite = {
+export type ActiveSiteWrite = {
code: string;
name: string;
api_base_url: string;
@@ -68,7 +68,7 @@ export type Site = {
site_name: string;
user: number;
owner_name?: string;
- city_site?: number | null;
+ active_site?: number | null;
created_at: string;
updated_at: string;
};
@@ -106,7 +106,7 @@ export type Cycle = {
feed_initial_balance: number;
feed_initial_balance_date?: string | null;
status: CycleStatus;
- city_site?: number | null;
+ active_site?: number | null;
source_cycle_id?: number | null;
proposed_end_date?: string | null;
close_requested_at?: string | null;