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

@@ -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