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

{loading ? ( - ) : citySites.length === 0 ? ( + ) : activeSites.length === 0 ? (

Belum ada site di daftar tampilan.

) : (
    - {citySites.map((site) => ( + {activeSites.map((site) => (
  • {site.name}

    @@ -516,7 +516,7 @@ const GmViewList: React.FC = () => { disabled={saving} onClick={() => void wrap(async () => { - await api.citySites.remove(site.id); + await api.activeSites.remove(site.id); flash(`Site ${site.name} dihapus dari daftar tampilan.`); }) } diff --git a/components/__tests__/SettingsPage.test.tsx b/components/__tests__/SettingsPage.test.tsx index a946e80..60769aa 100644 --- a/components/__tests__/SettingsPage.test.tsx +++ b/components/__tests__/SettingsPage.test.tsx @@ -41,7 +41,7 @@ const { vi.mock('../../services/apiClient.ts', () => ({ api: { - citySites: { + activeSites: { list: listMock, create: createMock, remove: removeMock, diff --git a/docker/.env.example b/docker/.env.example index 3a5c64e..49888ab 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -24,10 +24,10 @@ BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n BOOTSTRAP_STAFF_USER= BOOTSTRAP_STAFF_PASSWORD= -# Optional: fixed API key for city-site inbound / scripts (hashed at rest) +# Optional: fixed API key for active-site inbound / scripts (hashed at rest) BOOTSTRAP_API_KEY= -# City-edge syncs OFF at HQ — data should arrive via city-site mirror sync. +# City-edge syncs OFF at HQ — data should arrive via active-site mirror sync. # Enable only if this box also pulls directly from edge services. KARUNG_WEB_ADMIN_SYNC_ENABLED=false IOT_SYNC_ENABLED=false diff --git a/docker/entrypoint-cron.sh b/docker/entrypoint-cron.sh index d72b873..c8f1e32 100644 --- a/docker/entrypoint-cron.sh +++ b/docker/entrypoint-cron.sh @@ -6,7 +6,7 @@ printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|CHICKEN_COUNTING_|DA > /etc/environment || true # Render publish-time cron from DASHBOARD_PUBLISH_* (default 17:00). -# Edge syncs are OFF by default at HQ (mirror via city-sites); commands no-op when disabled. +# Edge syncs are OFF by default at HQ (mirror via active-sites); commands no-op when disabled. # File lives in /etc/cron.d (requires username field) — do not feed it to `crontab`. PUBLISH_HOUR="${DASHBOARD_PUBLISH_HOUR:-17}" PUBLISH_MINUTE="${DASHBOARD_PUBLISH_MINUTE:-0}" diff --git a/services/apiClient.ts b/services/apiClient.ts index 21f80a1..a52689f 100644 --- a/services/apiClient.ts +++ b/services/apiClient.ts @@ -2,8 +2,8 @@ import type { AIInsight, ChickenCounting, ChickenWeight, - CitySite, - CitySiteWrite, + ActiveSite, + ActiveSiteWrite, CountingWrite, Cycle, CycleFilter, @@ -164,12 +164,13 @@ export const api = { logout: () => post('/auth/logout/'), me: () => get('/auth/me/'), }, - citySites: { - list: async () => unwrapList(await get | CitySite[]>('/city-sites/')), - create: (body: CitySiteWrite) => post('/city-sites/', body), - update: (id: number, body: Partial) => - patch(`/city-sites/${id}/`, body), - remove: (id: number) => del(`/city-sites/${id}/`), + activeSites: { + list: async () => + unwrapList(await get | ActiveSite[]>('/active-sites/')), + create: (body: ActiveSiteWrite) => post('/active-sites/', body), + update: (id: number, body: Partial) => + patch(`/active-sites/${id}/`, body), + remove: (id: number) => del(`/active-sites/${id}/`), }, users: { list: async () => unwrapList(await get | User[]>('/users/')), diff --git a/types/api.ts b/types/api.ts index 47c9725..1760dec 100644 --- a/types/api.ts +++ b/types/api.ts @@ -39,7 +39,7 @@ export type UserWrite = { managed_by?: number | null; }; -export type CitySite = { +export type ActiveSite = { id: number; code: string; name: string; @@ -55,7 +55,7 @@ export type CitySite = { updated_at: string; }; -export type CitySiteWrite = { +export type ActiveSiteWrite = { code: string; name: string; api_base_url: string; @@ -68,7 +68,7 @@ export type Site = { site_name: string; user: number; owner_name?: string; - city_site?: number | null; + active_site?: number | null; created_at: string; updated_at: string; }; @@ -106,7 +106,7 @@ export type Cycle = { feed_initial_balance: number; feed_initial_balance_date?: string | null; status: CycleStatus; - city_site?: number | null; + active_site?: number | null; source_cycle_id?: number | null; proposed_end_date?: string | null; close_requested_at?: string | null;