adjust citysite table name to activesites
This commit is contained in:
1 parent
fb0f996776
commit
64b37b6b6d
27 files changed
+382
-221
No files matched your search
@@ -0,0 +1,70 @@
|
||||
# Generated manually: rename city_site FKs → active_site
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("farms", "0017_site_city_site"),
|
||||
("sync", "0003_rename_citysite_to_activesite"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="cycle",
|
||||
name="uniq_cycle_city_source",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="site",
|
||||
old_name="city_site",
|
||||
new_name="active_site",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="cycle",
|
||||
old_name="city_site",
|
||||
new_name="active_site",
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="cycle",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(
|
||||
("active_site__isnull", False), ("source_cycle_id__isnull", False)
|
||||
),
|
||||
fields=("active_site", "source_cycle_id"),
|
||||
name="uniq_cycle_active_source",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="site",
|
||||
name="active_site",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="hq_site",
|
||||
to="sync.activesite",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cycle",
|
||||
name="active_site",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="mirrored_cycles",
|
||||
to="sync.activesite",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="cycle",
|
||||
name="source_cycle_id",
|
||||
field=models.BigIntegerField(
|
||||
blank=True,
|
||||
help_text="cycle_id on the farm-location dashboard-cpsp deployment",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -11,9 +11,9 @@ class Site(models.Model):
|
||||
site_id = models.BigAutoField(primary_key=True)
|
||||
site_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",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user