adjust citysite table name to activesites

This commit is contained in:
Alberto-Audrix committed 2026-09-11 15:51:29 +07:00
1 parent fb0f996776
commit 64b37b6b6d
27 files changed
+382 -221

No files matched your search

+2 -2
View File
@@ -11,12 +11,12 @@ City A/B/C (API+DB) --API key sync--> HQ mirror API+DB --session--> Executive FE
- **Roles** - **Roles**
- `director` / `director_admin`: view all sites (`director_admin` is **not** Django superuser) - `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 - `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 - `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 - `superuser`: break-glass (`bootstrap_admin` / seed `admin`) — director-level ops + Manajemen Akun; hidden from user lists
- `inactive`: locked out - `inactive`: locked out
- No `is_staff` - 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) - **Approve close:** `POST /api/v1/cycles/{id}/approve-close/` (GM only)
## Ports ## Ports
+1 -1
View File
@@ -22,7 +22,7 @@ BOOTSTRAP_ADMIN_PASSWORD=
BOOTSTRAP_STAFF_USER= BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD= 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 KARUNG_WEB_ADMIN_SYNC_ENABLED=false
IOT_SYNC_ENABLED=false IOT_SYNC_ENABLED=false
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=false CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=false
+9 -9
View File
@@ -7,9 +7,9 @@ Main-office **mirror API** for the executive frontend. Domain shapes match site
| Status | Capabilities | Data scope | | Status | Capabilities | Data scope |
|--------|----------------|------------| |--------|----------------|------------|
| `director`, `director_admin` | **View only** (director_admin may repair farm structure writes). Not Django superuser. | **All** sites / GMs | | `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 | | `buh`, `bu_admin` | **View only** | Rollup of managed GMs' registered active sites |
| `gm` | View + register city URLs + sync + **approve cycle close** | Own sites / city 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 / city 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`). | | `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 | | `superuser` | Break-glass: **ops dashboards (director scope) + Manajemen Akun**. Not creatable via UI; hidden from user lists. | All sites / GMs |
| `inactive` | Locked out | — | | `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`. `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. 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`. Active site registry ownership: `ActiveSite.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). 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). 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/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. `GET/POST /api/v1/active-sites/` — register farm-location dashboard APIs.
`POST /api/v1/city-sites/{id}/sync/` and `POST /api/v1/city-sites/sync-all/` — stubs (`501`) until mirror pull is built. `POST /api/v1/active-sites/{id}/sync/` and `POST /api/v1/active-sites/sync-all/` — stubs (`501`) until mirror pull is built.
## Setup ## Setup
+10 -10
View File
@@ -2,7 +2,7 @@
Settled matrix (v1): Settled matrix (v1):
- buh, director (+ their admins until specified otherwise): view only - 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 - 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( SITE_REGISTRY_STATUSES = frozenset(
{ {
User.STATUS_GM, 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( SYNC_STATUSES = frozenset(
{ {
User.STATUS_GM, User.STATUS_GM,
@@ -73,8 +73,8 @@ def can_sync(user) -> bool:
) )
def can_register_city_site(user) -> bool: def can_register_active_site(user) -> bool:
"""GM / GM Admin may add city dashboard URLs to the HQ registry.""" """GM / GM Admin may add site dashboard URLs to the HQ registry."""
return bool( return bool(
user user
and getattr(user, "is_authenticated", False) and getattr(user, "is_authenticated", False)
@@ -105,9 +105,9 @@ def can_manage_users(user) -> bool:
return _status(user) in USER_ADMIN_STATUSES return _status(user) in USER_ADMIN_STATUSES
def can_manage_city_registry(user) -> bool: def can_manage_active_site_registry(user) -> bool:
"""Alias used by city-site write endpoints — same as register permission.""" """Alias used by active-site write endpoints — same as register permission."""
return can_register_city_site(user) or bool(getattr(user, "is_superuser", False)) return can_register_active_site(user) or bool(getattr(user, "is_superuser", False))
class IsExecutive(permissions.BasePermission): class IsExecutive(permissions.BasePermission):
@@ -120,9 +120,9 @@ class IsExecutiveAdmin(permissions.BasePermission):
return can_manage_users(request.user) return can_manage_users(request.user)
class CanRegisterCitySite(permissions.BasePermission): class CanRegisterActiveSite(permissions.BasePermission):
def has_permission(self, request, view): 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) getattr(request.user, "is_superuser", False)
) )
+23 -21
View File
@@ -1,11 +1,11 @@
"""Visibility scoping for HQ executives. """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 - 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 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 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: 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). Returns None when there is no restriction (director / superuser).
""" """
@@ -74,13 +74,13 @@ def visible_owner_ids(user: User) -> list[int] | None:
return [] 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. 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) owners = visible_owner_ids(user)
if owners is None: if owners is None:
@@ -88,7 +88,7 @@ def visible_active_city_site_ids(user: User) -> list[int] | None:
if not owners: if not owners:
return [] return []
return list( return list(
CitySite.objects.filter( ActiveSite.objects.filter(
registered_by_id__in=owners, registered_by_id__in=owners,
is_active=True, is_active=True,
).values_list("pk", flat=True) ).values_list("pk", flat=True)
@@ -120,13 +120,13 @@ def filter_sites_queryset(qs: QuerySet, viewer: User) -> QuerySet:
return qs return qs
if not owners: if not owners:
return qs.none() return qs.none()
city_ids = visible_active_city_site_ids(viewer) active_ids = visible_active_site_ids(viewer)
if not city_ids: if not active_ids:
return qs.none() 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) owners = visible_owner_ids(viewer)
if owners is None: if owners is None:
return qs return qs
@@ -141,10 +141,12 @@ def filter_kandangs_queryset(qs: QuerySet, viewer: User) -> QuerySet:
return qs return qs
if not owners: if not owners:
return qs.none() return qs.none()
city_ids = visible_active_city_site_ids(viewer) active_ids = visible_active_site_ids(viewer)
if not city_ids: if not active_ids:
return qs.none() 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: def filter_flocks_queryset(qs: QuerySet, viewer: User) -> QuerySet:
@@ -153,12 +155,12 @@ def filter_flocks_queryset(qs: QuerySet, viewer: User) -> QuerySet:
return qs return qs
if not owners: if not owners:
return qs.none() return qs.none()
city_ids = visible_active_city_site_ids(viewer) active_ids = visible_active_site_ids(viewer)
if not city_ids: if not active_ids:
return qs.none() return qs.none()
return qs.filter( return qs.filter(
kandang__site__user_id__in=owners, 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 return qs
if not owners: if not owners:
return qs.none() return qs.none()
city_ids = visible_active_city_site_ids(viewer) active_ids = visible_active_site_ids(viewer)
if not city_ids: if not active_ids:
return qs.none() return qs.none()
return qs.filter( return qs.filter(
kandang__site__user_id__in=owners, kandang__site__user_id__in=owners,
kandang__site__city_site_id__in=city_ids, kandang__site__active_site_id__in=active_ids,
) )
+15 -15
View File
@@ -3,7 +3,7 @@ from rest_framework.test import APIClient
from apps.accounts.models import User from apps.accounts.models import User
from apps.farms.models import Kandang, Site from apps.farms.models import Kandang, Site
from apps.sync.models import CitySite from apps.sync.models import ActiveSite
class VisibilityScopeTests(TestCase): class VisibilityScopeTests(TestCase):
@@ -27,23 +27,23 @@ class VisibilityScopeTests(TestCase):
status=User.STATUS_GM, status=User.STATUS_GM,
managed_by=self.buh_other, managed_by=self.buh_other,
) )
self.city_a = CitySite.objects.create( self.active_a = ActiveSite.objects.create(
code="a", code="a",
name="City A", name="Site A",
api_base_url="https://a.example/api/v1", api_base_url="https://a.example/api/v1",
registered_by=self.gm, registered_by=self.gm,
) )
self.city_b = CitySite.objects.create( self.active_b = ActiveSite.objects.create(
code="b", code="b",
name="City B", name="Site B",
api_base_url="https://b.example/api/v1", api_base_url="https://b.example/api/v1",
registered_by=self.gm_other, registered_by=self.gm_other,
) )
self.site = Site.objects.create( 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( 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) 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)
@@ -70,9 +70,9 @@ class VisibilityScopeTests(TestCase):
names = {row["display_name"] for row in response.data["results"]} names = {row["display_name"] for row in response.data["results"]}
self.assertEqual(names, {"gm1"}) 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) 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) self.assertEqual(response.status_code, 200)
codes = {row["code"] for row in response.data["results"]} codes = {row["code"] for row in response.data["results"]}
self.assertEqual(codes, {"a"}) self.assertEqual(codes, {"a"})
@@ -85,16 +85,16 @@ class VisibilityScopeTests(TestCase):
self.assertEqual(names, {"Site A"}) self.assertEqual(names, {"Site A"})
def test_gm_with_empty_registry_sees_no_sites(self): def test_gm_with_empty_registry_sees_no_sites(self):
self.city_a.delete() self.active_a.delete()
self.client.force_authenticate(self.gm) self.client.force_authenticate(self.gm)
response = self.client.get("/api/v1/sites/") response = self.client.get("/api/v1/sites/")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], []) 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) self.client.force_authenticate(self.gm)
response = self.client.post( response = self.client.post(
"/api/v1/city-sites/", "/api/v1/active-sites/",
{ {
"code": "orphan-seed", "code": "orphan-seed",
"name": "Orphan Seed", "name": "Orphan Seed",
@@ -105,14 +105,14 @@ class VisibilityScopeTests(TestCase):
) )
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.orphan.refresh_from_db() 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/") listed = self.client.get("/api/v1/sites/")
names = {row["site_name"] for row in listed.data["results"]} names = {row["site_name"] for row in listed.data["results"]}
self.assertIn("Orphan Seed", names) 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) 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) self.assertEqual(response.status_code, 200)
codes = {row["code"] for row in response.data["results"]} codes = {row["code"] for row in response.data["results"]}
self.assertEqual(codes, {"a", "b"}) self.assertEqual(codes, {"a", "b"})
@@ -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,
),
),
]
+11 -9
View File
@@ -11,9 +11,9 @@ class Site(models.Model):
site_id = models.BigAutoField(primary_key=True) site_id = models.BigAutoField(primary_key=True)
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 city dashboard to their Pengaturan view list. # Linked when GM/GM Admin adds the site dashboard to their Pengaturan view list.
city_site = models.OneToOneField( active_site = models.OneToOneField(
"sync.CitySite", "sync.ActiveSite",
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, 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 max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
) )
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles") kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
city_site = models.ForeignKey( active_site = models.ForeignKey(
"sync.CitySite", "sync.ActiveSite",
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -97,7 +97,7 @@ class Cycle(models.Model):
source_cycle_id = models.BigIntegerField( source_cycle_id = models.BigIntegerField(
null=True, null=True,
blank=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) proposed_end_date = models.DateField(null=True, blank=True)
close_requested_at = models.DateTimeField(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", name="chk_cycles_date_range",
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=["city_site", "source_cycle_id"], fields=["active_site", "source_cycle_id"],
condition=models.Q(city_site__isnull=False, source_cycle_id__isnull=False), condition=models.Q(
name="uniq_cycle_city_source", active_site__isnull=False, source_cycle_id__isnull=False
),
name="uniq_cycle_active_source",
), ),
] ]
+4 -4
View File
@@ -14,11 +14,11 @@ class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
"site_name", "site_name",
"user", "user",
"owner_name", "owner_name",
"city_site", "active_site",
"created_at", "created_at",
"updated_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): class FeedInButtonUrlSerializer(serializers.Serializer):
@@ -104,7 +104,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"feed_initial_balance", "feed_initial_balance",
"feed_initial_balance_date", "feed_initial_balance_date",
"status", "status",
"city_site", "active_site",
"source_cycle_id", "source_cycle_id",
"proposed_end_date", "proposed_end_date",
"close_requested_at", "close_requested_at",
@@ -120,7 +120,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"feed_initial_balance", "feed_initial_balance",
"feed_initial_balance_date", "feed_initial_balance_date",
"status", "status",
"city_site", "active_site",
"source_cycle_id", "source_cycle_id",
"proposed_end_date", "proposed_end_date",
"close_requested_at", "close_requested_at",
@@ -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
)
@@ -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)
+14 -6
View File
@@ -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 from __future__ import annotations
@@ -18,12 +18,17 @@ class SiteClosePushError(Exception):
def push_close_decision(cycle: Cycle, *, approved: bool) -> None: def push_close_decision(cycle: Cycle, *, approved: bool) -> None:
city = cycle.city_site active_site = cycle.active_site
if not city or not city.api_base_url or not city.api_key or not cycle.source_cycle_id: if (
logger.info("No city channel configured for cycle %s; skip push", cycle.pk) 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 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/" url = f"{base}/cycles/{cycle.source_cycle_id}/apply-close-decision/"
payload = {"decision": "approve" if approved else "reject"} payload = {"decision": "approve" if approved else "reject"}
if approved: if approved:
@@ -35,7 +40,10 @@ def push_close_decision(cycle: Cycle, *, approved: bool) -> None:
response = httpx.post( response = httpx.post(
url, url,
json=payload, 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, timeout=30.0,
) )
if response.status_code >= 400: if response.status_code >= 400:
+6 -4
View File
@@ -7,18 +7,20 @@ 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 FeedSacks, ManualInput 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: def _gm_visible_site(*, owner: User, site_name: str, code: str) -> Site:
"""GM scope only sees sites linked to an active CitySite they registered.""" """GM scope only sees sites linked to an ActiveSite they registered."""
city = CitySite.objects.create( active_site = ActiveSite.objects.create(
code=code, code=code,
name=site_name, name=site_name,
api_base_url=f"https://{code}.example/api/v1", api_base_url=f"https://{code}.example/api/v1",
registered_by=owner, 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): class FarmAPITests(TestCase):
+28 -20
View File
@@ -23,8 +23,8 @@ from apps.operations.services.karung_web import (
initial_balance_accuracy, initial_balance_accuracy,
pull_initial_balance_iot, pull_initial_balance_iot,
) )
from apps.farms.services.city_registry import ensure_hq_site_for_city from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site
from apps.sync.models import CitySite from apps.sync.models import ActiveSite
def _validation_detail(exc: ValidationError): def _validation_detail(exc: ValidationError):
@@ -35,7 +35,7 @@ def _validation_detail(exc: ValidationError):
return str(getattr(exc, "message", exc)) 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") source_cycle_id = payload.get("source_cycle_id")
if not source_cycle_id: if not source_cycle_id:
raise ValidationError("source_cycle_id is required.") 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: if start is None:
raise ValidationError("start_date is required.") 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] kandang_name = str(payload.get("kandang_name") or "Kandang")[:30]
owner = city.registered_by owner = active_site.registered_by
if owner is None: if owner is None:
owner = User.objects.filter(status=User.STATUS_GM).first() owner = User.objects.filter(status=User.STATUS_GM).first()
if owner is None: if owner is None:
raise ValidationError("No GM available to own mirrored site.") 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( if site.site_name != site_name and not Site.objects.filter(
user=owner, site_name=site_name user=owner, site_name=site_name
).exclude(pk=site.pk).exists(): ).exclude(pk=site.pk).exists():
@@ -67,11 +67,13 @@ def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
defaults={}, 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: if cycle is None:
cycle = Cycle( cycle = Cycle(
kandang=kandang, kandang=kandang,
city_site=city, active_site=active_site,
source_cycle_id=source_cycle_id, source_cycle_id=source_cycle_id,
start_date=start, start_date=start,
end_date=None, end_date=None,
@@ -97,13 +99,15 @@ def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
@permission_classes([permissions.IsAuthenticated]) @permission_classes([permissions.IsAuthenticated])
def cycle_close_ingest(request): def cycle_close_ingest(request):
"""Site → pusat: register/update a pending close 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: try:
city = CitySite.objects.get(pk=city_site_id, is_active=True) active_site = ActiveSite.objects.get(pk=active_site_id, is_active=True)
except (CitySite.DoesNotExist, TypeError, ValueError): except (ActiveSite.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"detail": "Invalid active_site_id."}, status=status.HTTP_400_BAD_REQUEST
)
try: try:
cycle = _upsert_mirror_cycle_from_ingest(city, request.data) cycle = _upsert_mirror_cycle_from_ingest(active_site, request.data)
except ValidationError as exc: except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data) return Response(CycleSerializer(cycle).data)
@@ -113,13 +117,17 @@ def cycle_close_ingest(request):
@permission_classes([permissions.IsAuthenticated]) @permission_classes([permissions.IsAuthenticated])
def cycle_close_cancel(request): def cycle_close_cancel(request):
"""Site → pusat: cancel a pending close 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") source_cycle_id = request.data.get("source_cycle_id")
try: try:
city = CitySite.objects.get(pk=city_site_id, is_active=True) active_site = ActiveSite.objects.get(pk=active_site_id, is_active=True)
except (CitySite.DoesNotExist, TypeError, ValueError): except (ActiveSite.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST) return Response(
cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first() {"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: if cycle is None:
return Response({"detail": "Mirror cycle not found."}, status=status.HTTP_404_NOT_FOUND) return Response({"detail": "Mirror cycle not found."}, status=status.HTTP_404_NOT_FOUND)
try: try:
@@ -132,7 +140,7 @@ def cycle_close_cancel(request):
class IsDirectorAdminForWrite(permissions.BasePermission): class IsDirectorAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated executive; structure writes for director_admin. """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). create/edit/delete is reserved for director_admin (bootstrap / repair).
""" """
@@ -192,7 +200,7 @@ class FlockViewSet(viewsets.ModelViewSet):
class CycleViewSet(viewsets.ModelViewSet): class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related( queryset = Cycle.objects.select_related(
"kandang", "close_approved_by", "city_site", "kandang__site" "kandang", "close_approved_by", "active_site", "kandang__site"
).all() ).all()
serializer_class = CycleSerializer serializer_class = CycleSerializer
@@ -5,7 +5,7 @@ from django.db import transaction
from apps.accounts.models import ApiKey, User from apps.accounts.models import ApiKey, User
from apps.farms.models import Site 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. # Every executive role + inactive, for local HQ demos.
@@ -108,13 +108,15 @@ class Command(BaseCommand):
if not staff: if not staff:
return return
moved_sites = Site.objects.filter(user=staff).update(user=gm) 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() ApiKey.objects.filter(user=staff).delete()
staff.delete() staff.delete()
self.stdout.write( self.stdout.write(
self.style.WARNING( self.style.WARNING(
f"Removed legacy staff login " 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)"
) )
) )
@@ -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,
),
),
]
+8 -8
View File
@@ -4,33 +4,33 @@ from django.conf import settings
from django.db import models 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 Outbound API key is stored here for local/dev scaffolding. Prefer a secret
manager or encrypted field before production. 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") code = models.SlugField(max_length=64, unique=True, help_text="Stable id, e.g. sukawarna")
name = models.CharField(max_length=120) name = models.CharField(max_length=120)
api_base_url = models.URLField( api_base_url = models.URLField(
max_length=255, 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( api_key = models.CharField(
max_length=255, max_length=255,
blank=True, 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( registered_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="registered_city_sites", related_name="registered_active_sites",
help_text="GM / GM Admin who added this city dashboard URL", help_text="GM / GM Admin who added this site dashboard URL",
) )
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
last_synced_at = models.DateTimeField(null=True, blank=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) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
db_table = "city_sites" db_table = "active_sites"
ordering = ["name"] ordering = ["name"]
def __str__(self) -> str: def __str__(self) -> str:
+7 -7
View File
@@ -1,18 +1,18 @@
from rest_framework import serializers 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) 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() has_api_key = serializers.SerializerMethodField()
registered_by_name = serializers.CharField( registered_by_name = serializers.CharField(
source="registered_by.display_name", read_only=True, allow_null=True source="registered_by.display_name", read_only=True, allow_null=True
) )
class Meta: class Meta:
model = CitySite model = ActiveSite
fields = [ fields = [
"id", "id",
"code", "code",
@@ -40,15 +40,15 @@ class CitySiteSerializer(serializers.ModelSerializer):
"updated_at", "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) return bool(obj.api_key)
class CitySiteWriteSerializer(serializers.ModelSerializer): class ActiveSiteWriteSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="pk", read_only=True) id = serializers.IntegerField(source="pk", read_only=True)
class Meta: class Meta:
model = CitySite model = ActiveSite
fields = [ fields = [
"id", "id",
"code", "code",
+2 -2
View File
@@ -1,10 +1,10 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.sync.views import CitySiteViewSet from apps.sync.views import ActiveSiteViewSet
router = DefaultRouter() router = DefaultRouter()
router.register("city-sites", CitySiteViewSet, basename="city-site") router.register("active-sites", ActiveSiteViewSet, basename="active-site")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
+26 -26
View File
@@ -2,62 +2,62 @@ from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from apps.accounts.permissions import CanRegisterCitySite, IsExecutive, can_sync from apps.accounts.permissions import CanRegisterActiveSite, IsExecutive, can_sync
from apps.accounts.scoping import filter_city_sites_queryset from apps.accounts.scoping import filter_active_sites_queryset
from apps.farms.services.city_registry import ensure_hq_site_for_city from apps.farms.services.active_site_registry import ensure_hq_site_for_active_site
from apps.sync.models import CitySite from apps.sync.models import ActiveSite
from apps.sync.serializers import CitySiteSerializer, CitySiteWriteSerializer 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). List/read: scoped (director all; BUH managed GMs; GM own).
Create/update/delete: GM and GM Admin. Create/update/delete: GM and GM Admin.
Sync: GM and GM Admin only (stubs until mirror pull is implemented). Sync: GM and GM Admin only (stubs until mirror pull is implemented).
Creating 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. appears in the executive view list.
""" """
executive_sync_action = True executive_sync_action = True
def get_queryset(self): def get_queryset(self):
qs = CitySite.objects.select_related("registered_by").all() qs = ActiveSite.objects.select_related("registered_by").all()
return filter_city_sites_queryset(qs, self.request.user) return filter_active_sites_queryset(qs, self.request.user)
def get_permissions(self): def get_permissions(self):
if self.action in {"create", "update", "partial_update", "destroy"}: if self.action in {"create", "update", "partial_update", "destroy"}:
return [CanRegisterCitySite()] return [CanRegisterActiveSite()]
return [IsExecutive()] return [IsExecutive()]
def get_serializer_class(self): def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}: if self.action in {"create", "update", "partial_update"}:
return CitySiteWriteSerializer return ActiveSiteWriteSerializer
return CitySiteSerializer return ActiveSiteSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
city = serializer.save(registered_by=self.request.user) active_site = serializer.save(registered_by=self.request.user)
ensure_hq_site_for_city(city) ensure_hq_site_for_active_site(active_site)
def perform_update(self, serializer): def perform_update(self, serializer):
city = serializer.save() active_site = serializer.save()
if city.is_active: if active_site.is_active:
ensure_hq_site_for_city(city) ensure_hq_site_for_active_site(active_site)
@action(detail=True, methods=["post"], url_path="sync") @action(detail=True, methods=["post"], url_path="sync")
def sync(self, request, pk=None): def sync(self, request, pk=None):
if not can_sync(request.user): if not can_sync(request.user):
return Response({"detail": "Sync not allowed."}, status=status.HTTP_403_FORBIDDEN) return Response({"detail": "Sync not allowed."}, status=status.HTTP_403_FORBIDDEN)
city = self.get_object() active_site = self.get_object()
if not city.is_active: if not active_site.is_active:
return Response( return Response(
{"detail": "City site is inactive."}, {"detail": "Active site is inactive."},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
return Response( return Response(
{ {
"detail": "City sync not implemented yet.", "detail": "Active site sync not implemented yet.",
"city_site": CitySiteSerializer(city).data, "active_site": ActiveSiteSerializer(active_site).data,
}, },
status=status.HTTP_501_NOT_IMPLEMENTED, status=status.HTTP_501_NOT_IMPLEMENTED,
) )
@@ -66,12 +66,12 @@ class CitySiteViewSet(viewsets.ModelViewSet):
def sync_all(self, request): def sync_all(self, request):
if not can_sync(request.user): if not can_sync(request.user):
return Response({"detail": "Sync not allowed."}, status=status.HTTP_403_FORBIDDEN) 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( return Response(
{ {
"detail": "City sync-all not implemented yet.", "detail": "Active site sync-all not implemented yet.",
"city_count": len(cities), "active_site_count": len(sites),
"cities": CitySiteSerializer(cities, many=True).data, "active_sites": ActiveSiteSerializer(sites, many=True).data,
}, },
status=status.HTTP_501_NOT_IMPLEMENTED, status=status.HTTP_501_NOT_IMPLEMENTED,
) )
+1 -1
View File
@@ -182,7 +182,7 @@ KARUNG_WEB_ADMIN_BASE_URL = (
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS = float( KARUNG_WEB_ADMIN_TIMEOUT_SECONDS = float(
env("KARUNG_WEB_ADMIN_TIMEOUT_SECONDS", "30") or "30" 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) 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. # Dashboard publish cutoff (WIB): day D visible from this time on day D.
+14 -14
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts'; import { api, errorMessage } from '../services/apiClient.ts';
import { useAuth } from '../context/AuthContext.tsx'; import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.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'; import { Banner, PageHeader, Spinner } from './ui/Feedback.tsx';
const inputClass = const inputClass =
@@ -38,7 +38,7 @@ const BuhViewList: React.FC = () => {
const isPrimaryBuh = user?.status === 'buh'; const isPrimaryBuh = user?.status === 'buh';
const [gms, setGms] = useState<User[]>([]); const [gms, setGms] = useState<User[]>([]);
const [pending, setPending] = useState<User[]>([]); const [pending, setPending] = useState<User[]>([]);
const [citySites, setCitySites] = useState<CitySite[]>([]); const [activeSites, setActiveSites] = useState<ActiveSite[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null); const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -49,11 +49,11 @@ const BuhViewList: React.FC = () => {
setError(null); setError(null);
try { try {
const nextUsers = await api.users.list(); 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() : []; const nextPending = isPrimaryBuh ? await api.users.listPendingManaged() : [];
setGms(nextUsers.filter((u) => u.status === 'gm')); setGms(nextUsers.filter((u) => u.status === 'gm'));
setPending(nextPending); setPending(nextPending);
setCitySites(nextSites); setActiveSites(nextSites);
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
} finally { } finally {
@@ -66,15 +66,15 @@ const BuhViewList: React.FC = () => {
}, [reload]); }, [reload]);
const sitesByGm = useMemo(() => { const sitesByGm = useMemo(() => {
const map = new Map<number, CitySite[]>(); const map = new Map<number, ActiveSite[]>();
for (const site of citySites) { for (const site of activeSites) {
if (site.registered_by == null) continue; if (site.registered_by == null) continue;
const rows = map.get(site.registered_by) ?? []; const rows = map.get(site.registered_by) ?? [];
rows.push(site); rows.push(site);
map.set(site.registered_by, rows); map.set(site.registered_by, rows);
} }
return map; return map;
}, [citySites]); }, [activeSites]);
const decide = async (gmId: number, decision: 'approve' | 'reject') => { const decide = async (gmId: number, decision: 'approve' | 'reject') => {
setBusyId(gmId); setBusyId(gmId);
@@ -212,7 +212,7 @@ const BuhViewList: React.FC = () => {
const GmViewList: React.FC = () => { const GmViewList: React.FC = () => {
const { user, refreshUser } = useAuth(); const { user, refreshUser } = useAuth();
const { reload: reloadFarm } = useFarm(); const { reload: reloadFarm } = useFarm();
const [citySites, setCitySites] = useState<CitySite[]>([]); const [activeSites, setActiveSites] = useState<ActiveSite[]>([]);
const [buhs, setBuhs] = useState<User[]>([]); const [buhs, setBuhs] = useState<User[]>([]);
const [selectedBuhId, setSelectedBuhId] = useState<number | ''>(''); const [selectedBuhId, setSelectedBuhId] = useState<number | ''>('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -236,12 +236,12 @@ const GmViewList: React.FC = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const sitesPromise = api.citySites.list(); const sitesPromise = api.activeSites.list();
const mePromise = refreshUser(); const mePromise = refreshUser();
const buhsPromise = const buhsPromise =
user?.status === 'gm' ? api.users.listBuhs() : Promise.resolve([] as User[]); user?.status === 'gm' ? api.users.listBuhs() : Promise.resolve([] as User[]);
const [sites, me, nextBuhs] = await Promise.all([sitesPromise, mePromise, buhsPromise]); const [sites, me, nextBuhs] = await Promise.all([sitesPromise, mePromise, buhsPromise]);
setCitySites(sites); setActiveSites(sites);
setBuhs(nextBuhs); setBuhs(nextBuhs);
if (me?.managed_by_pending) { if (me?.managed_by_pending) {
setSelectedBuhId(me.managed_by_pending); setSelectedBuhId(me.managed_by_pending);
@@ -409,7 +409,7 @@ const GmViewList: React.FC = () => {
const resolvedCode = code || slugify(name); const resolvedCode = code || slugify(name);
if (!resolvedCode) return; if (!resolvedCode) return;
void wrap(async () => { void wrap(async () => {
const created = await api.citySites.create({ const created = await api.activeSites.create({
code: resolvedCode, code: resolvedCode,
name: name.trim(), name: name.trim(),
api_base_url: apiBaseUrl.trim(), api_base_url: apiBaseUrl.trim(),
@@ -487,11 +487,11 @@ const GmViewList: React.FC = () => {
</p> </p>
{loading ? ( {loading ? (
<Spinner /> <Spinner />
) : citySites.length === 0 ? ( ) : activeSites.length === 0 ? (
<p className="text-sm text-gray-500">Belum ada site di daftar tampilan.</p> <p className="text-sm text-gray-500">Belum ada site di daftar tampilan.</p>
) : ( ) : (
<ul className="divide-y"> <ul className="divide-y">
{citySites.map((site) => ( {activeSites.map((site) => (
<li key={site.id} className="py-3 flex items-start justify-between gap-3"> <li key={site.id} className="py-3 flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-gray-800 truncate">{site.name}</p> <p className="text-sm font-semibold text-gray-800 truncate">{site.name}</p>
@@ -516,7 +516,7 @@ const GmViewList: React.FC = () => {
disabled={saving} disabled={saving}
onClick={() => onClick={() =>
void wrap(async () => { void wrap(async () => {
await api.citySites.remove(site.id); await api.activeSites.remove(site.id);
flash(`Site ${site.name} dihapus dari daftar tampilan.`); flash(`Site ${site.name} dihapus dari daftar tampilan.`);
}) })
} }
+1 -1
View File
@@ -41,7 +41,7 @@ const {
vi.mock('../../services/apiClient.ts', () => ({ vi.mock('../../services/apiClient.ts', () => ({
api: { api: {
citySites: { activeSites: {
list: listMock, list: listMock,
create: createMock, create: createMock,
remove: removeMock, remove: removeMock,
+2 -2
View File
@@ -24,10 +24,10 @@ BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n
BOOTSTRAP_STAFF_USER= BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD= 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= 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. # Enable only if this box also pulls directly from edge services.
KARUNG_WEB_ADMIN_SYNC_ENABLED=false KARUNG_WEB_ADMIN_SYNC_ENABLED=false
IOT_SYNC_ENABLED=false IOT_SYNC_ENABLED=false
+1 -1
View File
@@ -6,7 +6,7 @@ printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|CHICKEN_COUNTING_|DA
> /etc/environment || true > /etc/environment || true
# Render publish-time cron from DASHBOARD_PUBLISH_* (default 17:00). # 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`. # File lives in /etc/cron.d (requires username field) — do not feed it to `crontab`.
PUBLISH_HOUR="${DASHBOARD_PUBLISH_HOUR:-17}" PUBLISH_HOUR="${DASHBOARD_PUBLISH_HOUR:-17}"
PUBLISH_MINUTE="${DASHBOARD_PUBLISH_MINUTE:-0}" PUBLISH_MINUTE="${DASHBOARD_PUBLISH_MINUTE:-0}"
+9 -8
View File
@@ -2,8 +2,8 @@ import type {
AIInsight, AIInsight,
ChickenCounting, ChickenCounting,
ChickenWeight, ChickenWeight,
CitySite, ActiveSite,
CitySiteWrite, ActiveSiteWrite,
CountingWrite, CountingWrite,
Cycle, Cycle,
CycleFilter, CycleFilter,
@@ -164,12 +164,13 @@ export const api = {
logout: () => post<void>('/auth/logout/'), logout: () => post<void>('/auth/logout/'),
me: () => get<User>('/auth/me/'), me: () => get<User>('/auth/me/'),
}, },
citySites: { activeSites: {
list: async () => unwrapList(await get<Paginated<CitySite> | CitySite[]>('/city-sites/')), list: async () =>
create: (body: CitySiteWrite) => post<CitySite>('/city-sites/', body), unwrapList(await get<Paginated<ActiveSite> | ActiveSite[]>('/active-sites/')),
update: (id: number, body: Partial<CitySiteWrite>) => create: (body: ActiveSiteWrite) => post<ActiveSite>('/active-sites/', body),
patch<CitySite>(`/city-sites/${id}/`, body), update: (id: number, body: Partial<ActiveSiteWrite>) =>
remove: (id: number) => del(`/city-sites/${id}/`), patch<ActiveSite>(`/active-sites/${id}/`, body),
remove: (id: number) => del(`/active-sites/${id}/`),
}, },
users: { users: {
list: async () => unwrapList(await get<Paginated<User> | User[]>('/users/')), list: async () => unwrapList(await get<Paginated<User> | User[]>('/users/')),
+4 -4
View File
@@ -39,7 +39,7 @@ export type UserWrite = {
managed_by?: number | null; managed_by?: number | null;
}; };
export type CitySite = { export type ActiveSite = {
id: number; id: number;
code: string; code: string;
name: string; name: string;
@@ -55,7 +55,7 @@ export type CitySite = {
updated_at: string; updated_at: string;
}; };
export type CitySiteWrite = { export type ActiveSiteWrite = {
code: string; code: string;
name: string; name: string;
api_base_url: string; api_base_url: string;
@@ -68,7 +68,7 @@ export type Site = {
site_name: string; site_name: string;
user: number; user: number;
owner_name?: string; owner_name?: string;
city_site?: number | null; active_site?: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -106,7 +106,7 @@ export type Cycle = {
feed_initial_balance: number; feed_initial_balance: number;
feed_initial_balance_date?: string | null; feed_initial_balance_date?: string | null;
status: CycleStatus; status: CycleStatus;
city_site?: number | null; active_site?: number | null;
source_cycle_id?: number | null; source_cycle_id?: number | null;
proposed_end_date?: string | null; proposed_end_date?: string | null;
close_requested_at?: string | null; close_requested_at?: string | null;