adjust citysite table name to activesites
This commit is contained in:
1 parent
fb0f996776
commit
64b37b6b6d
27 files changed
+382
-221
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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 +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",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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
@@ -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.`);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user