Add source_site_id to ActiveSite model and implement unique constraint. Update serializers and views for site registration and claiming. Enhance tests for site registration and claiming functionality.

This commit is contained in:
Alberto-Audrix committed 2026-09-15 09:45:23 +07:00
1 parent 521f36d22d
commit 61f954a3bd
12 files changed
+684 -172

No files matched your search

+8 -3
View File
@@ -92,17 +92,22 @@ class VisibilityScopeTests(TestCase):
self.assertEqual(response.data["results"], [])
def test_registering_active_site_attaches_matching_seed_site(self):
ActiveSite.objects.create(
code="orphan-seed",
name="Orphan Seed",
api_base_url="https://orphan.example/api/v1",
api_key="k",
managed_by=None,
)
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{
"code": "orphan-seed",
"name": "Orphan Seed",
"is_active": True,
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.status_code, 200, response.data)
self.orphan.refresh_from_db()
self.assertEqual(self.orphan.active_site_id, response.data["id"])
listed = self.client.get("/api/v1/sites/")
@@ -0,0 +1,24 @@
# Generated manually: ActiveSite.source_site_id from city Site.site_id
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sync", "0005_rename_registered_by_to_managed_by"),
]
operations = [
migrations.AddField(
model_name="activesite",
name="source_site_id",
field=models.SlugField(
blank=True,
db_index=True,
default="",
help_text="Farm-location Site.site_id (slug) from the registering dashboard",
max_length=64,
),
),
]
@@ -0,0 +1,21 @@
# Unique non-empty source_site_id so one farm site cannot own two ActiveSites.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sync", "0006_activesite_source_site_id"),
]
operations = [
migrations.AddConstraint(
model_name="activesite",
constraint=models.UniqueConstraint(
condition=~models.Q(source_site_id=""),
fields=("source_site_id",),
name="uniq_active_site_source_site_id",
),
),
]
+14
View File
@@ -15,6 +15,13 @@ class ActiveSite(models.Model):
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)
source_site_id = models.SlugField(
max_length=64,
blank=True,
default="",
db_index=True,
help_text="Farm-location Site.site_id (slug) from the registering dashboard",
)
api_base_url = models.URLField(
max_length=255,
blank=True,
@@ -44,6 +51,13 @@ class ActiveSite(models.Model):
class Meta:
db_table = "active_sites"
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["source_site_id"],
condition=~models.Q(source_site_id=""),
name="uniq_active_site_source_site_id",
),
]
def __str__(self) -> str:
return f"{self.name} ({self.code})"
+50
View File
@@ -1,8 +1,16 @@
from django.utils.text import slugify
from rest_framework import serializers
from apps.sync.models import ActiveSite
def _normalize_code(value: str) -> str:
code = slugify(str(value or "").strip())[:64]
if not code:
raise serializers.ValidationError("Kode tidak valid.")
return code
class ActiveSiteSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="pk", read_only=True)
# Never echo the outbound site API key back to the browser by default.
@@ -17,6 +25,7 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
"id",
"code",
"name",
"source_site_id",
"api_base_url",
"has_api_key",
"managed_by",
@@ -30,6 +39,7 @@ class ActiveSiteSerializer(serializers.ModelSerializer):
]
read_only_fields = [
"id",
"source_site_id",
"has_api_key",
"managed_by",
"managed_by_name",
@@ -58,3 +68,43 @@ class ActiveSiteWriteSerializer(serializers.ModelSerializer):
"is_active",
]
extra_kwargs = {"api_key": {"write_only": True, "required": False}}
class ActiveSiteClaimSerializer(serializers.Serializer):
"""GM claims an ActiveSite that the site dashboard already registered."""
code = serializers.CharField(max_length=64)
def validate_code(self, value: str) -> str:
return _normalize_code(value)
class SiteRegisterSerializer(serializers.Serializer):
"""Payload from a site dashboard registering into the ActiveSite registry."""
code = serializers.CharField(max_length=64)
site_id = serializers.SlugField(max_length=64)
name = serializers.CharField(max_length=120)
api_base_url = serializers.URLField(max_length=255)
api_key = serializers.CharField(max_length=255)
def validate_code(self, value: str) -> str:
return _normalize_code(value)
def validate_site_id(self, value: str) -> str:
site_id = str(value or "").strip()[:64]
if not site_id:
raise serializers.ValidationError("site_id wajib diisi.")
return site_id
def validate_name(self, value: str) -> str:
name = str(value or "").strip()
if not name:
raise serializers.ValidationError("Nama wajib diisi.")
return name[:120]
def validate_api_key(self, value: str) -> str:
key = str(value or "").strip()
if not key:
raise serializers.ValidationError("api_key wajib diisi.")
return key
+206
View File
@@ -0,0 +1,206 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Site
from apps.sync.models import ActiveSite
class RegisterFromSiteTests(TestCase):
def setUp(self):
self.client = APIClient()
self.machine = User.objects.create_user(
user_name="site-bot", password="x", status=User.STATUS_GM
)
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM
)
self.gm_other = User.objects.create_user(
user_name="gm2", password="x", status=User.STATUS_GM
)
def test_register_creates_unclaimed_active_site(self):
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "Sukawarna!",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://sukawarna.example/api/v1",
"api_key": "site-secret",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["code"], "sukawarna")
self.assertEqual(response.data["name"], "Sukawarna")
self.assertEqual(response.data["source_site_id"], "sukawarna-0001")
self.assertEqual(response.data["api_base_url"], "https://sukawarna.example/api/v1")
self.assertTrue(response.data["has_api_key"])
self.assertIsNone(response.data["managed_by"])
row = ActiveSite.objects.get(code="sukawarna")
self.assertEqual(row.api_key, "site-secret")
self.assertEqual(row.source_site_id, "sukawarna-0001")
self.assertIsNone(row.managed_by_id)
def test_re_register_by_site_id_updates_even_if_code_changes(self):
active = ActiveSite.objects.create(
code="old-code",
name="Old",
source_site_id="sukawarna-0001",
api_base_url="https://old.example/api/v1",
api_key="old-key",
managed_by=self.gm,
)
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "new-code",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://new.example/api/v1",
"api_key": "new-key",
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
active.refresh_from_db()
self.assertEqual(active.code, "new-code")
self.assertEqual(active.name, "Sukawarna")
self.assertEqual(active.source_site_id, "sukawarna-0001")
self.assertEqual(active.api_base_url, "https://new.example/api/v1")
self.assertEqual(active.api_key, "new-key")
self.assertEqual(active.managed_by_id, self.gm.pk)
self.assertEqual(ActiveSite.objects.count(), 1)
def test_second_register_same_site_id_never_creates_second_row(self):
self.client.force_authenticate(self.machine)
first = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "code-a",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://a.example/api/v1",
"api_key": "key-a",
},
format="json",
)
self.assertEqual(first.status_code, 201, first.data)
second = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "code-b",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://b.example/api/v1",
"api_key": "key-b",
},
format="json",
)
self.assertEqual(second.status_code, 200, second.data)
self.assertEqual(second.data["id"], first.data["id"])
self.assertEqual(ActiveSite.objects.filter(source_site_id="sukawarna-0001").count(), 1)
self.assertEqual(ActiveSite.objects.count(), 1)
row = ActiveSite.objects.get(source_site_id="sukawarna-0001")
self.assertEqual(row.code, "code-b")
self.assertEqual(row.api_key, "key-b")
def test_re_register_updates_credentials_keeps_claim(self):
active = ActiveSite.objects.create(
code="sukawarna",
name="Old",
source_site_id="sukawarna-0001",
api_base_url="https://old.example/api/v1",
api_key="old-key",
managed_by=self.gm,
)
self.client.force_authenticate(self.machine)
response = self.client.post(
"/api/v1/active-sites/register-from-site/",
{
"code": "sukawarna",
"site_id": "sukawarna-0001",
"name": "Sukawarna",
"api_base_url": "https://new.example/api/v1",
"api_key": "new-key",
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
active.refresh_from_db()
self.assertEqual(active.name, "Sukawarna")
self.assertEqual(active.api_base_url, "https://new.example/api/v1")
self.assertEqual(active.api_key, "new-key")
self.assertEqual(active.managed_by_id, self.gm.pk)
class ClaimActiveSiteTests(TestCase):
def setUp(self):
self.client = APIClient()
self.gm = User.objects.create_user(
user_name="gm1", password="x", status=User.STATUS_GM
)
self.gm_other = User.objects.create_user(
user_name="gm2", password="x", status=User.STATUS_GM
)
self.active = ActiveSite.objects.create(
code="sukawarna",
name="Sukawarna",
api_base_url="https://sukawarna.example/api/v1",
api_key="site-secret",
managed_by=None,
)
def test_gm_claims_registered_site_by_code(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "sukawarna"},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.active.refresh_from_db()
self.assertEqual(self.active.managed_by_id, self.gm.pk)
self.assertTrue(Site.objects.filter(active_site=self.active, user=self.gm).exists())
def test_claim_unknown_code_404(self):
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "missing"},
format="json",
)
self.assertEqual(response.status_code, 404)
def test_claim_owned_by_other_gm_409(self):
self.active.managed_by = self.gm_other
self.active.save(update_fields=["managed_by"])
self.client.force_authenticate(self.gm)
response = self.client.post(
"/api/v1/active-sites/",
{"code": "sukawarna"},
format="json",
)
self.assertEqual(response.status_code, 409)
def test_hapus_unclaims_without_deleting_active_site(self):
self.active.managed_by = self.gm
self.active.source_site_id = "sukawarna-0001"
self.active.save(update_fields=["managed_by", "source_site_id"])
hq_site = Site.objects.create(
site_name="Sukawarna", user=self.gm, active_site=self.active
)
self.client.force_authenticate(self.gm)
response = self.client.delete(f"/api/v1/active-sites/{self.active.pk}/")
self.assertEqual(response.status_code, 204, response.data)
self.assertTrue(ActiveSite.objects.filter(pk=self.active.pk).exists())
self.active.refresh_from_db()
self.assertIsNone(self.active.managed_by_id)
self.assertEqual(self.active.api_key, "site-secret")
self.assertEqual(self.active.source_site_id, "sukawarna-0001")
hq_site.refresh_from_db()
self.assertIsNone(hq_site.active_site_id)
self.assertTrue(Site.objects.filter(pk=hq_site.pk).exists())
+158 -9
View File
@@ -1,49 +1,198 @@
from rest_framework import status, viewsets
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db import IntegrityError
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
from apps.sync.serializers import (
ActiveSiteClaimSerializer,
ActiveSiteSerializer,
ActiveSiteWriteSerializer,
SiteRegisterSerializer,
)
class ActiveSiteViewSet(viewsets.ModelViewSet):
"""
List/read: scoped (director all; BUH managed GMs; GM own).
Create/update/delete: GM and GM Admin.
Create (claim): GM and GM Admin claim a site-registered ActiveSite by code.
Sync: GM and GM Admin only (stubs until mirror pull is implemented).
Creating an active site also attaches (or creates) an HQ Site so farm data
appears in the executive view list.
Site dashboards create/fill ActiveSite via register-from-site (API key auth).
Claiming attaches managed_by and ensures an HQ Site for the view list.
Destroy (Hapus) unclaims only — does not delete the ActiveSite row.
"""
executive_sync_action = True
http_method_names = ["get", "post", "put", "patch", "delete", "head", "options"]
def get_queryset(self):
qs = ActiveSite.objects.select_related("managed_by").all()
return filter_active_sites_queryset(qs, self.request.user)
def get_permissions(self):
if self.action == "register_from_site":
return [permissions.IsAuthenticated()]
if self.action in {"create", "update", "partial_update", "destroy"}:
return [CanRegisterActiveSite()]
return [IsExecutive()]
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
if self.action == "register_from_site":
return SiteRegisterSerializer
if self.action == "create":
return ActiveSiteClaimSerializer
if self.action in {"update", "partial_update"}:
return ActiveSiteWriteSerializer
return ActiveSiteSerializer
def perform_create(self, serializer):
active_site = serializer.save(managed_by=self.request.user)
def create(self, request, *args, **kwargs):
"""GM claim-by-code: site must have registered first."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
active_site = ActiveSite.objects.filter(code=code).first()
if active_site is None:
return Response(
{
"detail": (
"Site belum mendaftar dari dasbor site. "
"Minta staf site mendaftar dulu dengan kode ini."
)
},
status=status.HTTP_404_NOT_FOUND,
)
owner_id = active_site.managed_by_id
if owner_id and owner_id != request.user.pk:
return Response(
{"detail": "Kode sudah diklaim oleh GM lain."},
status=status.HTTP_409_CONFLICT,
)
if owner_id != request.user.pk:
active_site.managed_by = request.user
active_site.save(update_fields=["managed_by", "updated_at"])
ensure_hq_site_for_active_site(active_site)
return Response(
ActiveSiteSerializer(active_site).data,
status=status.HTTP_200_OK,
)
def perform_update(self, serializer):
active_site = serializer.save()
if active_site.is_active:
if active_site.is_active and active_site.managed_by_id:
ensure_hq_site_for_active_site(active_site)
def destroy(self, request, *args, **kwargs):
"""
Hapus from daftar tampilan: unclaim only.
Keeps the ActiveSite registry row (credentials / source_site_id) so the
site dashboard registration is not wiped. Clears managed_by and unlinks
any HQ Site.active_site pointer.
"""
from apps.farms.models import Site
active_site = self.get_object()
Site.objects.filter(active_site=active_site).update(active_site=None)
active_site.managed_by = None
active_site.save(update_fields=["managed_by", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["post"], url_path="register-from-site")
def register_from_site(self, request):
"""
Machine endpoint: site dashboard creates/updates ActiveSite credentials.
Does not set managed_by (GM claim is separate).
One farm site_id maps to at most one ActiveSite. Re-register with a new
code updates that row; it never inserts a second ActiveSite.
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
code = data["code"]
source_site_id = data["site_id"]
active_site = ActiveSite.objects.filter(source_site_id=source_site_id).first()
if active_site is None:
by_code = ActiveSite.objects.filter(code=code).first()
if by_code is not None:
if by_code.source_site_id and by_code.source_site_id != source_site_id:
return Response(
{"detail": "Kode sudah dipakai site lain."},
status=status.HTTP_409_CONFLICT,
)
# Legacy row missing source_site_id: attach this site to it.
active_site = by_code
if active_site is None:
try:
active_site = ActiveSite.objects.create(
code=code,
name=data["name"],
source_site_id=source_site_id,
api_base_url=data["api_base_url"],
api_key=data["api_key"],
managed_by=None,
is_active=True,
)
except IntegrityError:
# Concurrent register for same source_site_id or code.
active_site = ActiveSite.objects.filter(
source_site_id=source_site_id
).first() or ActiveSite.objects.filter(code=code).first()
if active_site is None:
return Response(
{"detail": "Gagal mendaftarkan site — coba lagi."},
status=status.HTTP_409_CONFLICT,
)
else:
return Response(
ActiveSiteSerializer(active_site).data,
status=status.HTTP_201_CREATED,
)
if (
code != active_site.code
and ActiveSite.objects.filter(code=code).exclude(pk=active_site.pk).exists()
):
return Response(
{"detail": "Kode sudah dipakai site lain."},
status=status.HTTP_409_CONFLICT,
)
active_site.code = code
active_site.name = data["name"]
active_site.source_site_id = source_site_id
active_site.api_base_url = data["api_base_url"]
active_site.api_key = data["api_key"]
active_site.is_active = True
try:
active_site.save(
update_fields=[
"code",
"name",
"source_site_id",
"api_base_url",
"api_key",
"is_active",
"updated_at",
]
)
except IntegrityError:
return Response(
{"detail": "Site atau kode bentrok dengan pendaftaran lain."},
status=status.HTTP_409_CONFLICT,
)
return Response(ActiveSiteSerializer(active_site).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="sync")
def sync(self, request, pk=None):
if not can_sync(request.user):