adjust citysite table name to activesites

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

No files matched your search

+10 -10
View File
@@ -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)
)
+23 -21
View File
@@ -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,
)
+15 -15
View File
@@ -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"})
@@ -0,0 +1,70 @@
# Generated manually: rename city_site FKs → active_site
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0017_site_city_site"),
("sync", "0003_rename_citysite_to_activesite"),
]
operations = [
migrations.RemoveConstraint(
model_name="cycle",
name="uniq_cycle_city_source",
),
migrations.RenameField(
model_name="site",
old_name="city_site",
new_name="active_site",
),
migrations.RenameField(
model_name="cycle",
old_name="city_site",
new_name="active_site",
),
migrations.AddConstraint(
model_name="cycle",
constraint=models.UniqueConstraint(
condition=models.Q(
("active_site__isnull", False), ("source_cycle_id__isnull", False)
),
fields=("active_site", "source_cycle_id"),
name="uniq_cycle_active_source",
),
),
migrations.AlterField(
model_name="site",
name="active_site",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="hq_site",
to="sync.activesite",
),
),
migrations.AlterField(
model_name="cycle",
name="active_site",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="mirrored_cycles",
to="sync.activesite",
),
),
migrations.AlterField(
model_name="cycle",
name="source_cycle_id",
field=models.BigIntegerField(
blank=True,
help_text="cycle_id on the farm-location dashboard-cpsp deployment",
null=True,
),
),
]
+11 -9
View File
@@ -11,9 +11,9 @@ class Site(models.Model):
site_id = models.BigAutoField(primary_key=True)
site_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",
),
]
+4 -4
View File
@@ -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",
@@ -0,0 +1,49 @@
"""Link HQ Site rows to ActiveSite registry entries (Pengaturan view list)."""
from __future__ import annotations
from apps.farms.models import Site
from apps.sync.models import ActiveSite
def ensure_hq_site_for_active_site(active_site: ActiveSite) -> Site:
"""
Ensure the active-site registry entry has an HQ Site for farm visibility.
Prefers an existing linked site, then an unlinked site owned by the
registrar with a matching name (so seeded Sukawarna attaches when
registered), otherwise creates a new Site.
"""
existing = Site.objects.filter(active_site=active_site).first()
if existing:
return existing
owner = active_site.registered_by
if owner is None:
raise ValueError("ActiveSite.registered_by is required to create an HQ site.")
site_name = (active_site.name or active_site.code or "Site")[:30]
orphan = (
Site.objects.filter(
user=owner, active_site__isnull=True, site_name__iexact=site_name
)
.order_by("pk")
.first()
)
if orphan is None and active_site.code:
# e.g. code "sukawarna" ↔ site_name "Sukawarna"
orphan = (
Site.objects.filter(
user=owner, active_site__isnull=True, site_name__iexact=active_site.code
)
.order_by("pk")
.first()
)
if orphan is not None:
orphan.active_site = active_site
orphan.save(update_fields=["active_site", "updated_at"])
return orphan
return Site.objects.create(
site_name=site_name, user=owner, active_site=active_site
)
@@ -1,43 +0,0 @@
"""Link HQ Site rows to CitySite registry entries (Pengaturan view list)."""
from __future__ import annotations
from apps.farms.models import Site
from apps.sync.models import CitySite
def ensure_hq_site_for_city(city: CitySite) -> Site:
"""
Ensure the city registry entry has an HQ Site for farm visibility.
Prefers an existing linked site, then an unlinked site owned by the
registrar with a matching name (so seeded Sukawarna attaches when
registered), otherwise creates a new Site.
"""
existing = Site.objects.filter(city_site=city).first()
if existing:
return existing
owner = city.registered_by
if owner is None:
raise ValueError("CitySite.registered_by is required to create an HQ site.")
site_name = (city.name or city.code or "Site")[:30]
orphan = (
Site.objects.filter(user=owner, city_site__isnull=True, site_name__iexact=site_name)
.order_by("pk")
.first()
)
if orphan is None and city.code:
# e.g. code "sukawarna" ↔ site_name "Sukawarna"
orphan = (
Site.objects.filter(user=owner, city_site__isnull=True, site_name__iexact=city.code)
.order_by("pk")
.first()
)
if orphan is not None:
orphan.city_site = city
orphan.save(update_fields=["city_site", "updated_at"])
return orphan
return Site.objects.create(site_name=site_name, user=owner, city_site=city)
+14 -6
View File
@@ -1,4 +1,4 @@
"""Push close decisions from pusat to city dashboard-cpsp."""
"""Push close decisions from pusat to farm-location dashboard-cpsp."""
from __future__ import annotations
@@ -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:
+6 -4
View File
@@ -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):
+28 -20
View File
@@ -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
@@ -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)"
)
)
@@ -0,0 +1,60 @@
# Generated manually: rename CitySite → ActiveSite
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("sync", "0002_citysite_registered_by"),
# Farms FKs still reference CitySite; rename only after those exist.
("farms", "0017_site_city_site"),
]
operations = [
migrations.RenameModel(
old_name="CitySite",
new_name="ActiveSite",
),
migrations.RenameField(
model_name="activesite",
old_name="city_site_id",
new_name="active_site_id",
),
migrations.AlterModelTable(
name="activesite",
table="active_sites",
),
migrations.AlterField(
model_name="activesite",
name="api_base_url",
field=models.URLField(
help_text="Site API root including /api/v1, e.g. https://site.example/api/v1",
max_length=255,
),
),
migrations.AlterField(
model_name="activesite",
name="api_key",
field=models.CharField(
blank=True,
help_text="X-API-Key for pulling from the site API (keep server-side only)",
max_length=255,
),
),
migrations.AlterField(
model_name="activesite",
name="registered_by",
field=models.ForeignKey(
blank=True,
help_text="GM / GM Admin who added this site dashboard URL",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="registered_active_sites",
to=settings.AUTH_USER_MODEL,
),
),
]
+8 -8
View File
@@ -4,33 +4,33 @@ from django.conf import settings
from django.db import models
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:
+7 -7
View File
@@ -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",
+2 -2
View File
@@ -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)),
+26 -26
View File
@@ -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,
)