diff --git a/DOCKER.md b/DOCKER.md index 92522fd..28a2283 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -58,7 +58,7 @@ docker compose up -d --build docker compose exec api python manage.py bootstrap_admin ``` -Uses `BOOTSTRAP_ADMIN_USER` / `BOOTSTRAP_ADMIN_PASSWORD` from `.env` (default in `docker/.env.example`: `admin` / `Pr04dm1n`). Then log in and create sites, kandang, and cycles in Pengaturan. +Uses `BOOTSTRAP_ADMIN_USER` / `BOOTSTRAP_ADMIN_PASSWORD` from `.env` (default in `docker/.env.example`: `admin` / `Pr04dm1n`). Optionally set `BOOTSTRAP_STAFF_USER` / `BOOTSTRAP_STAFF_PASSWORD` to create a staff login at the same time. Then log in and create sites, kandang, and cycles in Pengaturan. **Dev / demo** (Sukawarna sample cycles): @@ -108,6 +108,8 @@ Important production values: | `KARUNG_WEB_ADMIN_BASE_URL` | External karung service; default `http://host.docker.internal:5000` reaches the host from containers | | `BOOTSTRAP_ADMIN_USER` | Superadmin username for `bootstrap_admin` (default `admin`) | | `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) | +| `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation | +| `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) | | `BOOTSTRAP_API_KEY` | Optional fixed API key for scripts (hashed at rest) | Wagtail admin is proxied at **http://localhost:5002/admin/** (through Nginx → Django). diff --git a/backend/.env.example b/backend/.env.example index 2b14a76..73597ec 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,9 +20,6 @@ DB_NAME=db.sqlite3 # DB_HOST=database # DB_PORT=5432 -WAGTAIL_SITE_NAME=New Dashboard -WAGTAILADMIN_BASE_URL=http://localhost:8000 - API_PAGE_SIZE=50 SESSION_COOKIE_SECURE=False CSRF_COOKIE_SECURE=False @@ -34,6 +31,10 @@ BOOTSTRAP_API_KEY= BOOTSTRAP_ADMIN_USER=admin BOOTSTRAP_ADMIN_PASSWORD= +# Optional staff account (leave BOOTSTRAP_STAFF_USER empty to skip) +BOOTSTRAP_STAFF_USER= +BOOTSTRAP_STAFF_PASSWORD= + # karung-web-admin (external) KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000 KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30 diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py deleted file mode 100644 index fe38d42..0000000 --- a/backend/apps/accounts/admin.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin - -from apps.accounts.models import ApiKey, User - - -@admin.register(User) -class UserAdmin(DjangoUserAdmin): - ordering = ("user_name",) - list_display = ("user_name", "status", "created_at") - search_fields = ("user_name",) - fieldsets = ( - (None, {"fields": ("user_name", "password")}), - ("Permissions", {"fields": ("status", "groups", "user_permissions")}), - ) - add_fieldsets = ( - ( - None, - { - "classes": ("wide",), - "fields": ("user_name", "password1", "password2", "status"), - }, - ), - ) - filter_horizontal = ("groups", "user_permissions") - - -@admin.register(ApiKey) -class ApiKeyAdmin(admin.ModelAdmin): - list_display = ("name", "prefix", "user", "is_active", "last_used_at", "created_at") - list_filter = ("is_active",) - search_fields = ("name", "prefix", "user__user_name") - readonly_fields = ("prefix", "key_hash", "last_used_at", "created_at", "updated_at") diff --git a/backend/apps/farms/admin.py b/backend/apps/farms/admin.py deleted file mode 100644 index ba4f579..0000000 --- a/backend/apps/farms/admin.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib import admin - -from apps.farms.models import Cycle, Flock, Kandang, Site - -admin.site.register(Site) -admin.site.register(Kandang) -admin.site.register(Flock) -admin.site.register(Cycle) diff --git a/backend/apps/farms/migrations/0009_flock_belongs_to_kandang.py b/backend/apps/farms/migrations/0009_flock_belongs_to_kandang.py new file mode 100644 index 0000000..0971efa --- /dev/null +++ b/backend/apps/farms/migrations/0009_flock_belongs_to_kandang.py @@ -0,0 +1,50 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def backfill_flock_kandang(apps, schema_editor): + Flock = apps.get_model("farms", "Flock") + for flock in Flock.objects.select_related("cycle").iterator(): + if flock.cycle_id and flock.kandang_id is None: + flock.kandang_id = flock.cycle.kandang_id + flock.save(update_fields=["kandang_id"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("farms", "0008_cycle_feed_initial_balance"), + ] + + operations = [ + migrations.AddField( + model_name="flock", + name="kandang", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="flocks", + to="farms.kandang", + ), + ), + migrations.RunPython(backfill_flock_kandang, migrations.RunPython.noop), + migrations.AlterField( + model_name="flock", + name="kandang", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="flocks", + to="farms.kandang", + ), + ), + migrations.AlterField( + model_name="flock", + name="cycle", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="flocks", + to="farms.cycle", + ), + ), + ] diff --git a/backend/apps/farms/migrations/0010_restore_flock_cycle_erd.py b/backend/apps/farms/migrations/0010_restore_flock_cycle_erd.py new file mode 100644 index 0000000..4ddd88f --- /dev/null +++ b/backend/apps/farms/migrations/0010_restore_flock_cycle_erd.py @@ -0,0 +1,32 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def remove_orphan_flocks(apps, schema_editor): + Flock = apps.get_model("farms", "Flock") + Flock.objects.filter(cycle_id__isnull=True).delete() + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("farms", "0009_flock_belongs_to_kandang"), + ] + + operations = [ + migrations.RunPython(remove_orphan_flocks, migrations.RunPython.noop), + migrations.RemoveField( + model_name="flock", + name="kandang", + ), + migrations.AlterField( + model_name="flock", + name="cycle", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="flocks", + to="farms.cycle", + ), + ), + ] diff --git a/backend/apps/farms/migrations/0011_flock_belongs_to_kandang.py b/backend/apps/farms/migrations/0011_flock_belongs_to_kandang.py new file mode 100644 index 0000000..567c00c --- /dev/null +++ b/backend/apps/farms/migrations/0011_flock_belongs_to_kandang.py @@ -0,0 +1,45 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def backfill_flock_kandang_from_cycle(apps, schema_editor): + Flock = apps.get_model("farms", "Flock") + for flock in Flock.objects.select_related("cycle").iterator(): + if flock.cycle_id and flock.kandang_id is None: + flock.kandang_id = flock.cycle.kandang_id + flock.save(update_fields=["kandang_id"]) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("farms", "0010_restore_flock_cycle_erd"), + ] + + operations = [ + migrations.AddField( + model_name="flock", + name="kandang", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="flocks", + to="farms.kandang", + ), + ), + migrations.RunPython(backfill_flock_kandang_from_cycle, migrations.RunPython.noop), + migrations.AlterField( + model_name="flock", + name="kandang", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="flocks", + to="farms.kandang", + ), + ), + migrations.RemoveField( + model_name="flock", + name="cycle", + ), + ] diff --git a/backend/apps/farms/models.py b/backend/apps/farms/models.py index 9cc395a..dc14c32 100644 --- a/backend/apps/farms/models.py +++ b/backend/apps/farms/models.py @@ -2,13 +2,10 @@ from datetime import date from django.core.exceptions import ValidationError from django.db import models -from wagtail.admin.panels import FieldPanel -from wagtail.snippets.models import register_snippet from apps.accounts.models import User -@register_snippet class Site(models.Model): site_id = models.BigAutoField(primary_key=True) site_name = models.CharField(max_length=30) @@ -16,11 +13,6 @@ class Site(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [ - FieldPanel("site_name"), - FieldPanel("user"), - ] - class Meta: db_table = "sites" ordering = ["site_name"] @@ -29,7 +21,6 @@ class Site(models.Model): return self.site_name -@register_snippet class Kandang(models.Model): kandang_id = models.BigAutoField(primary_key=True) kandang_name = models.CharField(max_length=30) @@ -37,11 +28,6 @@ class Kandang(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [ - FieldPanel("kandang_name"), - FieldPanel("site"), - ] - class Meta: db_table = "kandang" ordering = ["kandang_name"] @@ -50,23 +36,17 @@ class Kandang(models.Model): return self.kandang_name -@register_snippet class Flock(models.Model): flock_id = models.BigAutoField(primary_key=True) flock_name = models.CharField(max_length=30) - cycle = models.ForeignKey( - "Cycle", + kandang = models.ForeignKey( + Kandang, on_delete=models.CASCADE, related_name="flocks", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [ - FieldPanel("flock_name"), - FieldPanel("cycle"), - ] - class Meta: db_table = "flock" ordering = ["flock_name"] @@ -75,7 +55,6 @@ class Flock(models.Model): return self.flock_name -@register_snippet class Cycle(models.Model): STATUS_ACTIVE = "active" STATUS_CLOSED = "closed" @@ -98,17 +77,6 @@ class Cycle(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [ - FieldPanel("kandang"), - FieldPanel("total_days"), - FieldPanel("start_date"), - FieldPanel("end_date"), - FieldPanel("doc_in_weight"), - FieldPanel("doc_in_count"), - FieldPanel("feed_initial_balance"), - FieldPanel("status"), - ] - class Meta: db_table = "cycles" ordering = ["-start_date"] diff --git a/backend/apps/farms/serializers.py b/backend/apps/farms/serializers.py index 2bc26ad..4d20a4d 100644 --- a/backend/apps/farms/serializers.py +++ b/backend/apps/farms/serializers.py @@ -21,15 +21,13 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer): class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer): class Meta: model = Flock - fields = ["id", "flock_name", "cycle", "created_at", "updated_at"] + fields = ["id", "flock_name", "kandang", "created_at", "updated_at"] read_only_fields = ["id", "created_at", "updated_at"] class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer): kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all()) kandang_name = serializers.CharField(source="kandang.kandang_name", read_only=True) - flock = serializers.SerializerMethodField() - flock_name = serializers.SerializerMethodField() chick_in_weight = serializers.IntegerField(source="doc_in_weight") current_day = serializers.IntegerField(read_only=True) visible_through_date = serializers.DateField(read_only=True) @@ -40,8 +38,6 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer): "id", "kandang", "kandang_name", - "flock", - "flock_name", "total_days", "current_day", "visible_through_date", @@ -57,26 +53,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer): read_only_fields = [ "id", "kandang_name", - "flock", - "flock_name", "feed_initial_balance", "created_at", "updated_at", ] - - def _first_flock(self, obj): - return obj.flocks.order_by("flock_id").first() - - def get_flock(self, obj): - flock = self._first_flock(obj) - return flock.pk if flock is not None else None - - def get_flock_name(self, obj): - flock = self._first_flock(obj) - return flock.flock_name if flock is not None else None - - def create(self, validated_data): - kandang = validated_data["kandang"] - cycle = Cycle.objects.create(**validated_data) - Flock.objects.create(flock_name=kandang.kandang_name[:30], cycle=cycle) - return cycle diff --git a/backend/apps/farms/tests.py b/backend/apps/farms/tests.py index cd0f725..006fd47 100644 --- a/backend/apps/farms/tests.py +++ b/backend/apps/farms/tests.py @@ -10,12 +10,37 @@ from apps.farms.models import Cycle, Flock, Kandang, Site class FarmAPITests(TestCase): def setUp(self): self.user = User.objects.create_user(user_name="tester", password="secret") + self.admin = User.objects.create_user( + user_name="admin", + password="secret", + status=User.STATUS_SUPERADMIN, + ) self.client = APIClient() - self.client.force_authenticate(self.user) self.site = Site.objects.create(site_name="Sukawarna", user=self.user) self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site) - def test_create_cycle_auto_creates_flock_and_returns_erd_fields(self): + def test_create_flock_under_kandang_requires_superadmin(self): + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/flocks/", + {"flock_name": "Lantai 1", "kandang": self.kandang.pk}, + format="json", + ) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(response.data["flock_name"], "Lantai 1") + self.assertEqual(response.data["kandang"], self.kandang.pk) + + def test_staff_cannot_create_flock(self): + self.client.force_authenticate(self.user) + response = self.client.post( + "/api/v1/flocks/", + {"flock_name": "Lantai 1", "kandang": self.kandang.pk}, + format="json", + ) + self.assertEqual(response.status_code, 403, response.data) + + def test_create_cycle_without_flock(self): + self.client.force_authenticate(self.user) start = date.today() response = self.client.post( "/api/v1/cycles/", @@ -31,56 +56,32 @@ class FarmAPITests(TestCase): format="json", ) self.assertEqual(response.status_code, 201, response.data) - self.assertEqual(response.data["chick_in_weight"], 42) - self.assertEqual(response.data["kandang_name"], "Kandang 1") - self.assertEqual(response.data["flock_name"], "Kandang 1") - self.assertIsNotNone(response.data["flock"]) - cycle = Cycle.objects.get(pk=response.data["id"]) - flock = Flock.objects.get(pk=response.data["flock"]) - self.assertEqual(cycle.doc_in_weight, 42) - self.assertEqual(flock.cycle_id, cycle.pk) + self.assertNotIn("flock", response.data) + self.assertNotIn("flock_name", response.data) - def test_list_cycles_by_kandang(self): - cycle = Cycle.objects.create( - kandang=self.kandang, - total_days=35, - start_date=date.today(), - end_date=date.today() + timedelta(days=34), - doc_in_weight=40, - doc_in_count=9000, - status=Cycle.STATUS_ACTIVE, - ) - flock = Flock.objects.create(flock_name="Flock A", cycle=cycle) - response = self.client.get("/api/v1/cycles/", {"kandang_id": self.kandang.pk}) - self.assertEqual(response.status_code, 200) - results = response.data["results"] if isinstance(response.data, dict) else response.data - self.assertEqual(len(results), 1) - self.assertEqual(results[0]["flock"], flock.pk) - self.assertEqual(results[0]["kandang_name"], "Kandang 1") - self.assertEqual(results[0]["flock_name"], "Flock A") + def test_flock_persists_across_cycles(self): + flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) + start = date.today() + for offset in (0, 40): + Cycle.objects.create( + kandang=self.kandang, + total_days=35, + start_date=start + timedelta(days=offset), + end_date=start + timedelta(days=offset + 34), + doc_in_weight=40, + doc_in_count=9000, + status=Cycle.STATUS_ACTIVE, + ) + self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1) + self.assertEqual(flock.kandang_id, self.kandang.pk) - def test_list_flocks_by_cycle(self): - cycle = Cycle.objects.create( - kandang=self.kandang, - total_days=35, - start_date=date.today(), - end_date=date.today() + timedelta(days=34), - doc_in_weight=40, - doc_in_count=9000, - status=Cycle.STATUS_ACTIVE, - ) - flock = Flock.objects.create(flock_name="Flock A", cycle=cycle) - other_cycle = Cycle.objects.create( - kandang=self.kandang, - total_days=35, - start_date=date.today(), - end_date=date.today() + timedelta(days=34), - doc_in_weight=40, - doc_in_count=9000, - status=Cycle.STATUS_ACTIVE, - ) - Flock.objects.create(flock_name="Flock B", cycle=other_cycle) - response = self.client.get("/api/v1/flocks/", {"cycle_id": cycle.pk}) + def test_list_flocks_by_kandang(self): + self.client.force_authenticate(self.user) + Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang) + Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang) + other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site) + Flock.objects.create(flock_name="Other", kandang=other_kandang) + response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.pk}) self.assertEqual(response.status_code, 200, response.data) results = response.data["results"] if isinstance(response.data, dict) else response.data - self.assertEqual([r["id"] for r in results], [flock.pk]) + self.assertEqual(len(results), 2) diff --git a/backend/apps/farms/views.py b/backend/apps/farms/views.py index 027cb3f..fe3e98a 100644 --- a/backend/apps/farms/views.py +++ b/backend/apps/farms/views.py @@ -56,19 +56,20 @@ class KandangViewSet(viewsets.ModelViewSet): class FlockViewSet(viewsets.ModelViewSet): - queryset = Flock.objects.all() + queryset = Flock.objects.select_related("kandang").all() serializer_class = FlockSerializer + permission_classes = [IsSuperAdminForWrite] def get_queryset(self): qs = super().get_queryset() - cycle_id = self.request.query_params.get("cycle_id") - if cycle_id: - qs = qs.filter(cycle_id=cycle_id) + kandang_id = self.request.query_params.get("kandang_id") + if kandang_id: + qs = qs.filter(kandang_id=kandang_id) return qs class CycleViewSet(viewsets.ModelViewSet): - queryset = Cycle.objects.select_related("kandang").prefetch_related("flocks").all() + queryset = Cycle.objects.select_related("kandang").all() serializer_class = CycleSerializer def get_queryset(self): diff --git a/backend/apps/jobs/management/commands/bootstrap_admin.py b/backend/apps/jobs/management/commands/bootstrap_admin.py index b81faf2..182ba40 100644 --- a/backend/apps/jobs/management/commands/bootstrap_admin.py +++ b/backend/apps/jobs/management/commands/bootstrap_admin.py @@ -5,62 +5,119 @@ from django.db import transaction from apps.accounts.models import ApiKey, User +def ensure_bootstrap_user( + *, + user_name: str, + password: str, + status: str, + is_staff: bool, + is_superuser: bool, + force_password: bool, + stdout, +) -> User: + user, created = User.objects.get_or_create( + user_name=user_name, + defaults={ + "status": status, + "is_staff": is_staff, + "is_superuser": is_superuser, + }, + ) + + if not created and user.status != status: + raise CommandError( + f"User {user_name!r} already exists with status {user.status!r}; " + f"cannot bootstrap as {status!r}" + ) + + should_set_password = created or not user.has_usable_password() or force_password + if should_set_password: + user.set_password(password) + user.status = status + user.is_staff = is_staff + user.is_superuser = is_superuser + user.save() + action = "Created" if created else "Updated password for" + stdout.write(f"{action} {status} user {user_name}") + else: + user.status = status + user.is_staff = is_staff + user.is_superuser = is_superuser + user.save(update_fields=["status", "is_staff", "is_superuser", "updated_at"]) + stdout.write(f"User {user_name} already exists (password unchanged)") + + return user + + class Command(BaseCommand): help = ( - "Create production superadmin login only (no demo sites/cycles/data). " - "Uses BOOTSTRAP_ADMIN_USER and BOOTSTRAP_ADMIN_PASSWORD from settings." + "Create production login accounts only (no demo sites/cycles/data). " + "Uses BOOTSTRAP_ADMIN_* and optional BOOTSTRAP_STAFF_* from settings." ) def add_arguments(self, parser): parser.add_argument( "--force-password", action="store_true", - help="Reset password even when the admin user already has one", + help="Reset password even when the user already has one", ) @transaction.atomic def handle(self, *args, **options): - user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip() - password = settings.BOOTSTRAP_ADMIN_PASSWORD or "" - if not password: + force_password = options["force_password"] + + admin_user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip() + admin_password = settings.BOOTSTRAP_ADMIN_PASSWORD or "" + if not admin_password: raise CommandError( "BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin." ) - user, created = User.objects.get_or_create( - user_name=user_name, - defaults={ - "status": User.STATUS_SUPERADMIN, - "is_staff": True, - "is_superuser": True, - }, + admin = ensure_bootstrap_user( + user_name=admin_user_name, + password=admin_password, + status=User.STATUS_SUPERADMIN, + is_staff=True, + is_superuser=True, + force_password=force_password, + stdout=self.stdout, ) - should_set_password = created or not user.has_usable_password() or options["force_password"] - if should_set_password: - user.set_password(password) - user.status = User.STATUS_SUPERADMIN - user.is_staff = True - user.is_superuser = True - user.save() - action = "Created" if created else "Updated password for" - self.stdout.write(f"{action} superadmin {user_name}") - else: - user.status = User.STATUS_SUPERADMIN - user.is_superuser = True - user.save(update_fields=["status", "is_superuser", "updated_at"]) - self.stdout.write(f"Superadmin {user_name} already exists (password unchanged)") + staff_user_name = (settings.BOOTSTRAP_STAFF_USER or "").strip() + staff_password = settings.BOOTSTRAP_STAFF_PASSWORD or "" + if staff_user_name: + if staff_user_name == admin_user_name: + raise CommandError( + "BOOTSTRAP_STAFF_USER must differ from BOOTSTRAP_ADMIN_USER." + ) + if not staff_password: + raise CommandError( + "BOOTSTRAP_STAFF_PASSWORD is required when BOOTSTRAP_STAFF_USER is set." + ) + ensure_bootstrap_user( + user_name=staff_user_name, + password=staff_password, + status=User.STATUS_ACTIVE, + is_staff=True, + is_superuser=False, + force_password=force_password, + stdout=self.stdout, + ) bootstrap_key = settings.BOOTSTRAP_API_KEY if bootstrap_key: - api_key, _ = ApiKey.generate(user, "bootstrap") + api_key, _ = ApiKey.generate(admin, "bootstrap") api_key.prefix = bootstrap_key[:8] api_key.key_hash = ApiKey.hash_key(bootstrap_key) api_key.save(update_fields=["prefix", "key_hash", "updated_at"]) self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})") + accounts = admin_user_name + if staff_user_name: + accounts = f"{admin_user_name}, {staff_user_name}" self.stdout.write( self.style.SUCCESS( - f"Bootstrap OK: login={user_name} (no demo farm data — use Pengaturan to add sites/kandang/cycles)" + f"Bootstrap OK: login={accounts} " + "(no demo farm data — use Pengaturan to add sites/kandang/cycles)" ) ) diff --git a/backend/apps/jobs/management/commands/seed_iot_10min.py b/backend/apps/jobs/management/commands/seed_iot_10min.py index ee2a2dd..a0cdf6c 100644 --- a/backend/apps/jobs/management/commands/seed_iot_10min.py +++ b/backend/apps/jobs/management/commands/seed_iot_10min.py @@ -133,7 +133,7 @@ class Command(BaseCommand): real_by_date = self.load_real_rows(Path(iot_json)) cycles = Cycle.objects.filter( - start_date=cycle_start, flocks__isnull=False + start_date=cycle_start, kandang__flocks__isnull=False ).distinct() if not cycles: raise CommandError(f"No cycles starting {cycle_start} with flocks") @@ -141,7 +141,7 @@ class Command(BaseCommand): seeded = 0 for cycle in cycles: kandang_name = cycle.kandang.kandang_name - for flock in cycle.flocks.order_by("flock_id"): + for flock in cycle.kandang.flocks.order_by("flock_id"): profile = self.profile_for(kandang_name, flock.flock_name) rng = random.Random(options["jitter_seed"] + flock.pk) rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng) diff --git a/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py b/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py index dd9766c..061d662 100644 --- a/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py +++ b/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py @@ -206,8 +206,11 @@ class Command(BaseCommand): ChickenWeight.objects.filter(cycle=existing).delete() FeedSacks.objects.filter(cycle=existing).delete() ManualInput.objects.filter(cycle=existing).delete() - IotPanel.objects.filter(flock__cycle=existing).delete() - Flock.objects.filter(cycle=existing).delete() + IotPanel.objects.filter( + flock__kandang=kandang, + date__gte=existing.start_date, + date__lte=existing.end_date, + ).delete() existing.delete() self.stdout.write("Reset previous Kandang 1 cycle") @@ -232,7 +235,7 @@ class Command(BaseCommand): flocks_by_name: dict[str, Flock] = {} for name in FLOCK_NAMES: - flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle) + flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang) flocks_by_name[name] = flock dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)] @@ -426,7 +429,7 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS( f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} " - f"flocks={Flock.objects.filter(cycle=cycle).count()} " + f"flocks={Flock.objects.filter(kandang=kandang).count()} " f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} " f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} " f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} " diff --git a/backend/apps/jobs/management/commands/seed_sukawarna_kandang5.py b/backend/apps/jobs/management/commands/seed_sukawarna_kandang5.py index 9fea314..ce3a62e 100644 --- a/backend/apps/jobs/management/commands/seed_sukawarna_kandang5.py +++ b/backend/apps/jobs/management/commands/seed_sukawarna_kandang5.py @@ -96,8 +96,11 @@ class Command(BaseCommand): ChickenWeight.objects.filter(cycle=existing).delete() FeedSacks.objects.filter(cycle=existing).delete() ManualInput.objects.filter(cycle=existing).delete() - IotPanel.objects.filter(flock__cycle=existing).delete() - Flock.objects.filter(cycle=existing).delete() + IotPanel.objects.filter( + flock__kandang=kandang, + date__gte=existing.start_date, + date__lte=existing.end_date, + ).delete() existing.delete() self.stdout.write("Reset previous Kandang 2 cycle") @@ -122,7 +125,7 @@ class Command(BaseCommand): flocks_by_name: dict[str, Flock] = {} for name in ("Lantai 1", "Lantai 2"): - flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle) + flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang) flocks_by_name[name] = flock cc_by_date = {} @@ -288,7 +291,7 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS( f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} " - f"flocks={Flock.objects.filter(cycle=cycle).count()} " + f"flocks={Flock.objects.filter(kandang=kandang).count()} " f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} " f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} " f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}" diff --git a/backend/apps/jobs/tests.py b/backend/apps/jobs/tests.py index bddb0cb..4e03d98 100644 --- a/backend/apps/jobs/tests.py +++ b/backend/apps/jobs/tests.py @@ -9,6 +9,8 @@ from django.core.management import call_command @override_settings( BOOTSTRAP_ADMIN_USER="admin", BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n", + BOOTSTRAP_STAFF_USER="", + BOOTSTRAP_STAFF_PASSWORD="", ) class BootstrapAdminTests(TestCase): def test_creates_superadmin_without_demo_data(self): @@ -23,3 +25,31 @@ class BootstrapAdminTests(TestCase): with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""): with self.assertRaises(CommandError): call_command("bootstrap_admin") + + @override_settings( + BOOTSTRAP_STAFF_USER="staff", + BOOTSTRAP_STAFF_PASSWORD="St4ff!", + ) + def test_creates_staff_when_configured(self): + call_command("bootstrap_admin") + staff = User.objects.get(user_name="staff") + self.assertEqual(staff.status, User.STATUS_ACTIVE) + self.assertTrue(staff.is_staff) + self.assertFalse(staff.is_superuser) + self.assertTrue(staff.check_password("St4ff!")) + + @override_settings( + BOOTSTRAP_STAFF_USER="staff", + BOOTSTRAP_STAFF_PASSWORD="", + ) + def test_requires_staff_password_when_user_set(self): + with self.assertRaises(CommandError): + call_command("bootstrap_admin") + + @override_settings( + BOOTSTRAP_STAFF_USER="admin", + BOOTSTRAP_STAFF_PASSWORD="St4ff!", + ) + def test_rejects_staff_same_as_admin(self): + with self.assertRaises(CommandError): + call_command("bootstrap_admin") diff --git a/backend/apps/operations/admin.py b/backend/apps/operations/admin.py deleted file mode 100644 index d614f70..0000000 --- a/backend/apps/operations/admin.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.contrib import admin - -from apps.operations.models import ( - AIInsight, - ChickenCounting, - ChickenWeight, - FeedSacks, - IotPanel, - KPI, - ManualInput, -) - -admin.site.register(KPI) -admin.site.register(ManualInput) -admin.site.register(ChickenCounting) -admin.site.register(ChickenWeight) -admin.site.register(FeedSacks) -admin.site.register(IotPanel) -admin.site.register(AIInsight) diff --git a/backend/apps/operations/models.py b/backend/apps/operations/models.py index 06c15e3..b3362e2 100644 --- a/backend/apps/operations/models.py +++ b/backend/apps/operations/models.py @@ -1,11 +1,8 @@ from django.db import models -from wagtail.admin.panels import FieldPanel -from wagtail.snippets.models import register_snippet from apps.farms.models import Cycle, Flock -@register_snippet class IotPanel(models.Model): panel_id = models.BigAutoField(primary_key=True) date = models.DateField() @@ -19,8 +16,6 @@ class IotPanel(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [FieldPanel("flock"), FieldPanel("date"), FieldPanel("average_temperature")] - class Meta: db_table = "iot_panel" ordering = ["-date"] @@ -31,7 +26,6 @@ class IotPanel(models.Model): ] -@register_snippet class AIInsight(models.Model): date = models.DateField() insight_text = models.TextField() @@ -42,14 +36,11 @@ class AIInsight(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("insight_text"), FieldPanel("alert")] - class Meta: db_table = "ai_insight" ordering = ["-date"] -@register_snippet class FeedSacks(models.Model): fs_id = models.BigAutoField(primary_key=True) date = models.DateField() @@ -63,8 +54,6 @@ class FeedSacks(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("in_today"), FieldPanel("out_today")] - class Meta: db_table = "feed_sacks" ordering = ["-date"] @@ -74,7 +63,6 @@ class FeedSacks(models.Model): ] -@register_snippet class ChickenCounting(models.Model): cc_id = models.BigAutoField(primary_key=True) date = models.DateField() @@ -84,8 +72,6 @@ class ChickenCounting(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("total_count")] - class Meta: db_table = "chicken_counting" ordering = ["-date"] @@ -94,7 +80,6 @@ class ChickenCounting(models.Model): ] -@register_snippet class ChickenWeight(models.Model): cw_id = models.BigAutoField(primary_key=True) date = models.DateField() @@ -108,13 +93,6 @@ class ChickenWeight(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [ - FieldPanel("cycle"), - FieldPanel("date"), - FieldPanel("average_weight"), - FieldPanel("uniformity"), - ] - class Meta: db_table = "chicken_weight" ordering = ["-date"] @@ -123,7 +101,6 @@ class ChickenWeight(models.Model): ] -@register_snippet class ManualInput(models.Model): manual_id = models.BigAutoField(primary_key=True) date = models.DateField() @@ -149,8 +126,6 @@ class ManualInput(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age_manual"), FieldPanel("fcr_manual")] - class Meta: db_table = "manual_input" ordering = ["-date"] @@ -159,7 +134,6 @@ class ManualInput(models.Model): ] -@register_snippet class KPI(models.Model): kpi_id = models.BigAutoField(primary_key=True) date = models.DateField() @@ -187,8 +161,6 @@ class KPI(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age"), FieldPanel("fcr"), FieldPanel("eef")] - class Meta: db_table = "kpi" ordering = ["-date"] diff --git a/backend/apps/operations/serializers.py b/backend/apps/operations/serializers.py index e177fab..66c1edb 100644 --- a/backend/apps/operations/serializers.py +++ b/backend/apps/operations/serializers.py @@ -19,7 +19,7 @@ class CycleContextMixin(serializers.Serializer): flock_id = serializers.SerializerMethodField() def get_flock_id(self, obj): - flock = obj.cycle.flocks.order_by("flock_id").first() + flock = obj.cycle.kandang.flocks.order_by("flock_id").first() return flock.pk if flock is not None else None @@ -124,8 +124,7 @@ class IotPanelSerializer(PkAsIdMixin, serializers.ModelSerializer): read_only_fields = ["id", "created_at", "updated_at"] def get_kandang(self, obj): - cycle = getattr(obj.flock, "cycle", None) - return cycle.kandang_id if cycle is not None else None + return obj.flock.kandang_id class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer): diff --git a/backend/apps/operations/services/iot_api.py b/backend/apps/operations/services/iot_api.py index 36007ea..c9715e3 100644 --- a/backend/apps/operations/services/iot_api.py +++ b/backend/apps/operations/services/iot_api.py @@ -11,7 +11,7 @@ import httpx from django.conf import settings from django.utils import timezone -from apps.farms.models import Flock +from apps.farms.models import Cycle, Flock from apps.operations.models import IotPanel WIB = ZoneInfo("Asia/Jakarta") @@ -199,7 +199,7 @@ def sync_iot_panels( django_flocks: dict[str, Flock] = {} for external_id, django_id in flock_map.items(): try: - django_flocks[str(external_id)] = Flock.objects.select_related("cycle").get(pk=int(django_id)) + django_flocks[str(external_id)] = Flock.objects.select_related("kandang").get(pk=int(django_id)) except Flock.DoesNotExist: continue @@ -236,7 +236,16 @@ def sync_iot_panels( continue snapshot_date = fetched_at.astimezone(WIB).date() - age = cycle_age_for_date(flock.cycle.start_date, snapshot_date) + cycle = ( + Cycle.objects.filter( + kandang_id=flock.kandang_id, + start_date__lte=snapshot_date, + end_date__gte=snapshot_date, + ) + .order_by("-start_date") + .first() + ) + age = cycle_age_for_date(cycle.start_date, snapshot_date) if cycle else 1 fields = panel_fields_from_payload(payload_data, age) IotPanel.objects.update_or_create( diff --git a/backend/apps/operations/tests.py b/backend/apps/operations/tests.py index 1330805..54566a8 100644 --- a/backend/apps/operations/tests.py +++ b/backend/apps/operations/tests.py @@ -26,7 +26,7 @@ class OperationsAPITests(TestCase): doc_in_count=10000, status=Cycle.STATUS_ACTIVE, ) - self.flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle) + self.flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang) def test_karungs_alias_lists_feed_sacks_with_date(self): FeedSacks.objects.create( @@ -65,7 +65,7 @@ class OperationsAPITests(TestCase): self.assertEqual(results[0]["date"], date.today().isoformat()) def test_panel_iot_filters_by_flock(self): - flock_b = Flock.objects.create(flock_name="Flock B", cycle=self.cycle) + flock_b = Flock.objects.create(flock_name="Flock B", kandang=self.kandang) row = IotPanel.objects.create( flock=self.flock, date=date.today(), @@ -379,7 +379,7 @@ class DashboardVisibilityTests(TestCase): self.assertEqual(response.data["date"], "2026-08-28") def test_panel_iot_not_hidden_before_publish_time(self): - flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle) + flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang) IotPanel.objects.create( flock=flock, date=date(2026, 8, 28), diff --git a/backend/apps/operations/views.py b/backend/apps/operations/views.py index 8ed7e4d..4eefa58 100644 --- a/backend/apps/operations/views.py +++ b/backend/apps/operations/views.py @@ -53,7 +53,7 @@ class CycleScopedViewSet(VisibilityFilteredMixin, viewsets.ModelViewSet): if params.get("cycle_id"): qs = qs.filter(cycle_id=params["cycle_id"]) if params.get("flock_id"): - qs = qs.filter(cycle__flocks__flock_id=params["flock_id"]).distinct() + qs = qs.filter(cycle__kandang__flocks=params["flock_id"]).distinct() if params.get("date"): qs = qs.filter(date=params["date"]) return self.filter_visible_through(qs) @@ -143,14 +143,23 @@ class FeedSacksViewSet(CycleScopedViewSet): class IotPanelViewSet(viewsets.ModelViewSet): """IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff.""" - queryset = IotPanel.objects.select_related("flock", "flock__cycle").all() + queryset = IotPanel.objects.select_related("flock", "flock__kandang").all() serializer_class = IotPanelSerializer def get_queryset(self): qs = super().get_queryset() params = self.request.query_params - if params.get("cycle_id"): - qs = qs.filter(flock__cycle_id=params["cycle_id"]) + cycle_id = params.get("cycle_id") + if cycle_id: + try: + cycle = Cycle.objects.get(pk=cycle_id) + except Cycle.DoesNotExist: + return qs.none() + qs = qs.filter( + flock__kandang_id=cycle.kandang_id, + date__gte=cycle.start_date, + date__lte=cycle.end_date, + ) if params.get("flock_id"): qs = qs.filter(flock_id=params["flock_id"]) if params.get("date"): diff --git a/backend/config/settings.py b/backend/config/settings.py index 334af4e..4b67af7 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,4 +1,4 @@ -"""Django settings for New Dashboard (ERD-aligned Wagtail + DRF).""" +"""Django settings for New Dashboard (ERD-aligned DRF API).""" from __future__ import annotations @@ -42,24 +42,6 @@ ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", "localhost,127.0.0.1") CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS", "http://localhost:8000") INSTALLED_APPS = [ - "apps.accounts", - "apps.farms", - "apps.operations", - "apps.jobs", - "wagtail.contrib.forms", - "wagtail.contrib.redirects", - "wagtail.embeds", - "wagtail.sites", - "wagtail.users", - "wagtail.snippets", - "wagtail.documents", - "wagtail.images", - "wagtail.search", - "wagtail.admin", - "wagtail", - "modelcluster", - "taggit", - "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", @@ -68,6 +50,10 @@ INSTALLED_APPS = [ "rest_framework", "corsheaders", "drf_spectacular", + "apps.accounts", + "apps.farms", + "apps.operations", + "apps.jobs", ] MIDDLEWARE = [ @@ -79,7 +65,6 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "wagtail.contrib.redirects.middleware.RedirectMiddleware", ] ROOT_URLCONF = "config.urls" @@ -142,10 +127,6 @@ MEDIA_ROOT = BASE_DIR / "media" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -WAGTAIL_SITE_NAME = env("WAGTAIL_SITE_NAME", "New Dashboard") -WAGTAILADMIN_BASE_URL = env("WAGTAILADMIN_BASE_URL", "http://localhost:8000") -WAGTAIL_APPEND_SLASH = False - CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS", "http://localhost:3000") CORS_ALLOW_CREDENTIALS = env_bool("CORS_ALLOW_CREDENTIALS", True) @@ -177,6 +158,8 @@ BOOTSTRAP_API_KEY = env("BOOTSTRAP_API_KEY", "") or "" BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin" BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or "" +BOOTSTRAP_STAFF_USER = env("BOOTSTRAP_STAFF_USER", "") or "" +BOOTSTRAP_STAFF_PASSWORD = env("BOOTSTRAP_STAFF_PASSWORD", "") or "" KARUNG_WEB_ADMIN_BASE_URL = ( env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or "" diff --git a/backend/config/urls.py b/backend/config/urls.py index 15ff4a5..ad3017a 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -1,16 +1,9 @@ from django.conf import settings from django.conf.urls.static import static -from django.contrib import admin from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView -from wagtail import urls as wagtail_urls -from wagtail.admin import urls as wagtailadmin_urls -from wagtail.documents import urls as wagtaildocs_urls urlpatterns = [ - path("django-admin/", admin.site.urls), - path("admin/", include(wagtailadmin_urls)), - path("documents/", include(wagtaildocs_urls)), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path( "api/docs/", @@ -20,7 +13,6 @@ urlpatterns = [ path("api/v1/", include("apps.accounts.urls")), path("api/v1/", include("apps.farms.urls")), path("api/v1/", include("apps.operations.urls")), - path("", include(wagtail_urls)), ] if settings.DEBUG: diff --git a/backend/requirements.txt b/backend/requirements.txt index f78f572..6754975 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,4 @@ Django>=5.1,<5.3 -wagtail>=6.3,<7.0 djangorestframework>=3.15,<4.0 drf-spectacular>=0.27,<1.0 django-cors-headers>=4.4,<5.0 @@ -7,5 +6,4 @@ python-dotenv>=1.0,<2.0 gunicorn>=23.0,<24.0 psycopg[binary]>=3.2,<4.0 httpx>=0.27,<1.0 -Pillow>=10.0,<12.0 openpyxl>=3.1,<4.0 diff --git a/components/IotPanelPage.tsx b/components/IotPanelPage.tsx index 8f85ce6..61fc430 100644 --- a/components/IotPanelPage.tsx +++ b/components/IotPanelPage.tsx @@ -76,7 +76,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { }, [selectedChartKey]); useEffect(() => { - if (!selectedCycle) { + if (!selectedKandang) { setFlocks([]); setSelectedFlockId(null); setDates([]); @@ -89,7 +89,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { (async () => { setError(null); try { - const data = await api.flocks.list({ cycle_id: selectedCycle.id }); + const data = await api.flocks.list({ kandang_id: selectedKandang.id }); if (cancelled) return; setFlocks(data); setSelectedFlockId((previous) => @@ -102,7 +102,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { return () => { cancelled = true; }; - }, [selectedCycle, refreshToken]); + }, [selectedKandang, refreshToken]); useEffect(() => { if (!selectedCycle || selectedFlockId == null) { diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index f95ef2a..b52f7e7 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { api, errorMessage } from '../services/apiClient.ts'; -import type { CycleStatus } from '../types/api.ts'; import { useAuth } from '../context/AuthContext.tsx'; import { useFarm } from '../context/FarmContext.tsx'; import { Banner, PageHeader } from './ui/Feedback.tsx'; @@ -14,6 +13,7 @@ const SettingsPage: React.FC = () => { const { sites, kandangs, + flocks, cycles, selectedSiteId, selectedKandangId, @@ -27,11 +27,11 @@ const SettingsPage: React.FC = () => { const [saving, setSaving] = useState(false); const [siteName, setSiteName] = useState(''); const [kandangName, setKandangName] = useState(''); + const [flockName, setFlockName] = useState(''); const [startDate, setStartDate] = useState(todayIso()); const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34)); const [docInCount, setDocInCount] = useState(20000); - const [chickInWeight, setChickInWeight] = useState(45); - const [cycleStatus, setCycleStatus] = useState('active'); + const [chickInWeight, setChickInWeight] = useState(35); const flash = (msg: string) => { setNotice(msg); @@ -55,7 +55,10 @@ const SettingsPage: React.FC = () => { return (
- + {error && (
{error} @@ -66,7 +69,7 @@ const SettingsPage: React.FC = () => { {notice}
)} -
+

Sites

{isSuperAdmin ? ( @@ -188,6 +191,69 @@ const SettingsPage: React.FC = () => { ))}
+
+

Flock

+

+ Flock tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus. +

+ {isSuperAdmin ? ( +
{ + e.preventDefault(); + if (!selectedKandangId) return; + void wrap(async () => { + const created = await api.flocks.create({ + flock_name: flockName, + kandang: selectedKandangId, + }); + setFlockName(''); + flash(`Flock ${created.flock_name} dibuat.`); + }); + }} + > + setFlockName(e.target.value)} + required + /> + +
+ ) : ( +

Hanya admin yang dapat membuat flock.

+ )} + {!selectedKandangId ? ( +

Pilih kandang terlebih dahulu.

+ ) : ( +
    + {flocks.map((flock) => ( +
  • + {flock.flock_name} + {isSuperAdmin && ( + + )} +
  • + ))} +
+ )} +

Siklus

{ end_date: endDate, chick_in_weight: chickInWeight, doc_in_count: docInCount, - status: cycleStatus, }); flash('Siklus dibuat.'); }); @@ -247,18 +312,16 @@ const SettingsPage: React.FC = () => { setChickInWeight(Number(e.target.value))} + onChange={(e) => { + const value = Number(e.target.value); + if (Number.isNaN(value)) return; + setChickInWeight(Math.min(51, Math.max(35, value))); + }} /> -
- {/* Read-only banner for closed cycles */} - {isReadOnly && ( + {isClosedCycle && (
@@ -497,9 +382,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }

Mode Lihat Saja (Siklus Selesai)

-

- Untuk mengedit data, pilih siklus yang masih aktif pada dropdown di atas. -

@@ -515,7 +397,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } ) : ( filteredRecords.map((record) => { - const isEditing = !isReadOnly && editingDay === record.day; const cardTone = record.isToday ? 'ring-2 ring-blue-200 bg-blue-50/40' : record.panen > 0 @@ -527,11 +408,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } key={record.day} className={`rounded-2xl border border-gray-200 shadow-sm overflow-hidden ${cardTone}`} > -
+
@@ -557,38 +434,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } {record.dateLabel}
- - {!isReadOnly && ( -
- {isEditing ? ( -
- - -
- ) : ( - - )} -
- )}
@@ -606,92 +451,27 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
Mati
- {isEditing ? ( -
- - setEditValues({ - ...editValues, - mati: parseInt(e.target.value) || 0, - }) - } - className="mt-1 w-full rounded-lg border border-red-200 bg-white px-2 py-2 text-right text-[11px] focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-200 md:px-3 md:text-sm" - /> - {validationErrors.mati && ( -
- {validationErrors.mati} -
- )} -
- ) : ( -
- {formatNumber(record.mati, 0)} -
- )} +
+ {formatNumber(record.mati, 0)} +
Panen
- {isEditing ? ( -
- - setEditValues({ - ...editValues, - panen: parseInt(e.target.value) || 0, - }) - } - className="mt-1 w-full rounded-lg border border-purple-200 bg-white px-2 py-2 text-right text-[11px] focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 md:px-3 md:text-sm" - /> - {validationErrors.panen && ( -
- {validationErrors.panen} -
- )} -
- ) : ( -
- {formatNumber(record.panen, 0)} -
- )} +
+ {formatNumber(record.panen, 0)} +
Berat panen (kg)
- {isEditing ? ( -
- - setEditValues({ - ...editValues, - beratPanen: e.target.value, - }) - } - className="mt-1 w-full rounded-lg border border-purple-200 bg-white px-2 py-2 text-right text-[11px] focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 md:px-3 md:text-sm" - /> - {validationErrors.beratPanen && ( -
- {validationErrors.beratPanen} -
- )} -
- ) : ( -
- {record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg -
- )} +
+ {record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg +
@@ -699,12 +479,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } Stock akhir
- {isEditing - ? Math.max( - 0, - record.stockAwal - editValues.mati - editValues.panen - ).toLocaleString('id-ID') - : formatNumber(record.stockAkhir, 0)} + {formatNumber(record.stockAkhir, 0)}
@@ -712,35 +487,11 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
Chicken Counting
- {isEditing ? ( -
- - setEditValues({ - ...editValues, - chickenCount: e.target.value - ? parseInt(e.target.value) - : undefined, - }) - } - placeholder="0" - className="mt-1 w-full rounded-lg border border-blue-200 bg-white px-2 py-2 text-right text-[11px] focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:px-3 md:text-sm" - /> - {validationErrors.chickenCount && ( -
- {validationErrors.chickenCount} -
- )} -
- ) : ( -
- {record.chickenCount !== undefined - ? formatNumber(record.chickenCount, 0) - : '-'} -
- )} +
+ {record.chickenCount !== undefined + ? formatNumber(record.chickenCount, 0) + : '-'} +
@@ -801,18 +552,10 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } Persentase - {!isReadOnly && ( - - Aksi - - )} {/* Second header row with individual column names */} @@ -834,7 +577,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } {filteredRecords.length === 0 ? ( @@ -843,7 +586,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } ) : ( filteredRecords.map((record, index) => { - const isEditing = !isReadOnly && editingDay === record.day; const rowClass = `hover:bg-gray-100 transition-colors ${ index % 2 === 0 ? 'bg-white' : 'bg-gray-50' } ${record.panen > 0 ? 'border-l-4 border-purple-400' : ''}`; @@ -862,175 +604,50 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } {record.dateLabel} - {/* Stock awal (calculated, read-only) */} + {/* Stock awal */} {formatNumber(record.stockAwal, 0)} - {/* Mati (editable) */} + {/* Mati */} - {isEditing ? ( -
- - setEditValues({ - ...editValues, - mati: parseInt(e.target.value) || 0, - }) - } - className="w-20 px-2 py-1 border border-blue-500 rounded text-right text-[11px] focus:ring-2 focus:ring-blue-500 md:w-24 md:text-sm" - /> - {validationErrors.mati && ( -
- {validationErrors.mati} -
- )} -
- ) : ( - - {formatNumber(record.mati, 0)} - - )} + + {formatNumber(record.mati, 0)} + - {/* JUMLAH PANEN (editable) */} + {/* JUMLAH PANEN */} - {isEditing ? ( -
- - setEditValues({ - ...editValues, - panen: parseInt(e.target.value) || 0, - }) - } - className="w-20 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-24 md:text-sm" - /> - {validationErrors.panen && ( -
- {validationErrors.panen} -
- )} -
- ) : ( - - {formatNumber(record.panen, 0)} - - )} + + {formatNumber(record.panen, 0)} + - {/* BERAT PANEN (editable) */} + {/* BERAT PANEN */} - {isEditing ? ( -
- - setEditValues({ - ...editValues, - beratPanen: e.target.value, - }) - } - className="w-24 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-28 md:text-sm" - /> - {validationErrors.beratPanen && ( -
- {validationErrors.beratPanen} -
- )} -
- ) : ( - - {record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} - - )} + + {record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} + - {/* Stock akhir (calculated, read-only) */} + {/* Stock akhir */} - {isEditing - ? Math.max( - 0, - record.stockAwal - editValues.mati - editValues.panen - ).toLocaleString('id-ID') - : formatNumber(record.stockAkhir, 0)} + {formatNumber(record.stockAkhir, 0)} - {/* Chicken Counting (editable) */} + {/* Chicken Counting */} - {isEditing ? ( -
- - setEditValues({ - ...editValues, - chickenCount: e.target.value - ? parseInt(e.target.value) - : undefined, - }) - } - placeholder="0" - className="w-24 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-28 md:text-sm" - /> - {validationErrors.chickenCount && ( -
- {validationErrors.chickenCount} -
- )} -
- ) : ( - - {record.chickenCount !== undefined - ? formatNumber(record.chickenCount, 0) - : '-'} - - )} + + {record.chickenCount !== undefined + ? formatNumber(record.chickenCount, 0) + : '-'} + - {/* Persentase (calculated, badge) */} - + {/* Persentase */} + {getPersentaseBadge(record.persentase)} - - {/* Aksi */} - {!isReadOnly && ( - - {isEditing ? ( -
- - -
- ) : ( - - )} - - )} ); }) @@ -1079,10 +696,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } Chicken Counting (IOT) = hasil hitung otomatis dari IOT -
  • - Data yang diedit akan - tersimpan sebagai input manual dan ditandai dengan highlight kuning -
  • Baris dengan border ungu menandakan ada panen pada hari tersebut diff --git a/components/counting/__tests__/DataManagementPage.test.tsx b/components/counting/__tests__/DataManagementPage.test.tsx index da049b7..75d51c7 100644 --- a/components/counting/__tests__/DataManagementPage.test.tsx +++ b/components/counting/__tests__/DataManagementPage.test.tsx @@ -68,5 +68,6 @@ describe('DataManagementPage', () => { expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: /Input manual/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Simpan hitungan/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Edit/i })).not.toBeInTheDocument(); }); }); diff --git a/context/FarmContext.tsx b/context/FarmContext.tsx index ca1d1da..47ecb5b 100644 --- a/context/FarmContext.tsx +++ b/context/FarmContext.tsx @@ -1,11 +1,12 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { api, errorMessage } from '../services/apiClient.ts'; -import type { Cycle, Kandang, Site } from '../types/api.ts'; +import type { Cycle, Flock, Kandang, Site } from '../types/api.ts'; import { useAuth } from './AuthContext.tsx'; type FarmContextValue = { sites: Site[]; kandangs: Kandang[]; + flocks: Flock[]; cycles: Cycle[]; selectedSiteId: number | null; selectedKandangId: number | null; @@ -27,6 +28,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children const { user } = useAuth(); const [sites, setSites] = useState([]); const [kandangs, setKandangs] = useState([]); + const [flocks, setFlocks] = useState([]); const [cycles, setCycles] = useState([]); const [selectedSiteId, setSelectedSiteId] = useState(null); const [selectedKandangId, setSelectedKandangId] = useState(null); @@ -43,6 +45,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children if (!user) { setSites([]); setKandangs([]); + setFlocks([]); setCycles([]); return; } @@ -94,6 +97,26 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children }; }, [user, selectedSiteId, reloadToken]); + useEffect(() => { + if (!user || selectedKandangId == null) { + setFlocks([]); + return; + } + let cancelled = false; + (async () => { + try { + const next = await api.flocks.list({ kandang_id: selectedKandangId }); + if (cancelled) return; + setFlocks(next); + } catch (err) { + if (!cancelled) setError(errorMessage(err)); + } + })(); + return () => { + cancelled = true; + }; + }, [user, selectedKandangId, reloadToken]); + useEffect(() => { if (!user || selectedKandangId == null) { setCycles([]); @@ -138,6 +161,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children () => ({ sites, kandangs, + flocks, cycles, selectedSiteId, selectedKandangId, @@ -155,6 +179,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children [ sites, kandangs, + flocks, cycles, selectedSiteId, selectedKandangId, diff --git a/docker-compose.yml b/docker-compose.yml index de6d427..a9c0ac4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,24 @@ +name: dashboard-cpsp + services: database: image: postgres:16-alpine - container_name: newdashboard-database + container_name: dashboard-cpsp-database restart: unless-stopped environment: - POSTGRES_USER: ${DB_USER:-newdashboard} - POSTGRES_PASSWORD: ${DB_PASSWORD:-change_this_password} - POSTGRES_DB: ${DB_NAME:-newdashboard} + POSTGRES_USER: ${DB_USER:-dashboard} + POSTGRES_PASSWORD: ${DB_PASSWORD:-Ket0pr4k} + POSTGRES_DB: ${DB_NAME:-dashboard} PGDATA: /var/lib/postgresql/data/pgdata volumes: - db-data:/var/lib/postgresql/data networks: - - newdashboard-network + - dashboard-cpsp-network healthcheck: test: [ "CMD-SHELL", - "pg_isready -U ${DB_USER:-newdashboard} -d ${DB_NAME:-newdashboard}", + "pg_isready -U ${DB_USER:-dashboard} -d ${DB_NAME:-dashboard}", ] interval: 10s timeout: 5s @@ -28,28 +30,27 @@ services: - "127.0.0.1:15432:5432" api: + image: dashboard-cpsp-api build: context: . dockerfile: docker/Dockerfile.api - container_name: newdashboard-api + container_name: dashboard-cpsp-api restart: unless-stopped ports: - "127.0.0.1:18000:8000" environment: DEBUG: ${DEBUG:-false} - SECRET_KEY: ${SECRET_KEY:-change-me-in-production} + SECRET_KEY: ${SECRET_KEY:-test-secret-key} ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend} CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost:5002,http://127.0.0.1:5002} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5002,http://127.0.0.1:5002} CORS_ALLOW_CREDENTIALS: "true" DB_ENGINE: django.db.backends.postgresql - DB_NAME: ${DB_NAME:-newdashboard} - DB_USER: ${DB_USER:-newdashboard} - DB_PASSWORD: ${DB_PASSWORD:-change_this_password} + DB_NAME: ${DB_NAME:-dashboard} + DB_USER: ${DB_USER:-dashboard} + DB_PASSWORD: ${DB_PASSWORD:-Ket0pr4k} DB_HOST: database DB_PORT: "5432" - WAGTAIL_SITE_NAME: ${WAGTAIL_SITE_NAME:-New Dashboard} - WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://localhost:5002} KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000} KARUNG_WEB_ADMIN_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30} KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false} @@ -60,9 +61,11 @@ services: IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}} IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20} IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10} - BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-} + BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-test-api-key} BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin} - BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n} + BOOTSTRAP_STAFF_USER: ${BOOTSTRAP_STAFF_USER:-skw} + BOOTSTRAP_STAFF_PASSWORD: ${BOOTSTRAP_STAFF_PASSWORD:-skw123} extra_hosts: - "host.docker.internal:host-gateway" volumes: @@ -71,7 +74,7 @@ services: database: condition: service_healthy networks: - - newdashboard-network + - dashboard-cpsp-network healthcheck: test: [ @@ -86,20 +89,21 @@ services: start_period: 60s cron: + image: dashboard-cpsp-cron build: context: . dockerfile: docker/Dockerfile.api - container_name: newdashboard-cron + container_name: dashboard-cpsp-cron restart: unless-stopped entrypoint: ["/app/entrypoint-cron.sh"] environment: DEBUG: ${DEBUG:-false} - SECRET_KEY: ${SECRET_KEY:-change-me-in-production} + SECRET_KEY: ${SECRET_KEY:-test-secret-key} ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend} DB_ENGINE: django.db.backends.postgresql - DB_NAME: ${DB_NAME:-newdashboard} - DB_USER: ${DB_USER:-newdashboard} - DB_PASSWORD: ${DB_PASSWORD:-change_this_password} + DB_NAME: ${DB_NAME:-dashboard} + DB_USER: ${DB_USER:-dashboard} + DB_PASSWORD: ${DB_PASSWORD:-Ket0pr4k} DB_HOST: database DB_PORT: "5432" KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000} @@ -120,13 +124,14 @@ services: api: condition: service_healthy networks: - - newdashboard-network + - dashboard-cpsp-network frontend: + image: dashboard-cpsp-frontend build: context: . dockerfile: docker/Dockerfile.web - container_name: newdashboard-frontend + container_name: dashboard-cpsp-frontend restart: unless-stopped ports: - "5002:80" @@ -134,7 +139,7 @@ services: api: condition: service_healthy networks: - - newdashboard-network + - dashboard-cpsp-network healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"] @@ -144,7 +149,7 @@ services: start_period: 10s networks: - newdashboard-network: + dashboard-cpsp-network: driver: bridge volumes: diff --git a/docker/.env.example b/docker/.env.example index 23cdb01..b681477 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -13,13 +13,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1,api,frontend CSRF_TRUSTED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002 CORS_ALLOWED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002 -WAGTAIL_SITE_NAME=New Dashboard -WAGTAILADMIN_BASE_URL=http://localhost:5002 - # Production admin (bootstrap_admin — no demo data) BOOTSTRAP_ADMIN_USER=admin BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n +# Optional production staff (leave BOOTSTRAP_STAFF_USER empty to skip) +BOOTSTRAP_STAFF_USER=staff +BOOTSTRAP_STAFF_PASSWORD=change_me + # Optional: fixed API key for scripts (hashed at rest) BOOTSTRAP_API_KEY= diff --git a/docker/entrypoint-api.sh b/docker/entrypoint-api.sh index b1065fa..d176fdd 100644 --- a/docker/entrypoint-api.sh +++ b/docker/entrypoint-api.sh @@ -2,5 +2,6 @@ set -e cd /app python manage.py migrate --noinput +python manage.py bootstrap_admin python manage.py collectstatic --noinput exec gunicorn config.wsgi:application -c config/gunicorn.py diff --git a/docker/entrypoint-cron.sh b/docker/entrypoint-cron.sh index afec5fe..fd1d69e 100644 --- a/docker/entrypoint-cron.sh +++ b/docker/entrypoint-cron.sh @@ -2,6 +2,6 @@ set -e cd /app python manage.py migrate --noinput -printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|DASHBOARD_|WAGTAIL_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \ +printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|DASHBOARD_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \ > /etc/environment || true exec cron -f diff --git a/docker/nginx.conf b/docker/nginx.conf index 27148dc..e819b1c 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -37,24 +37,6 @@ server { proxy_send_timeout 1800s; } - location /admin/ { - proxy_pass http://api:8000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /django-admin/ { - proxy_pass http://api:8000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - location /media/ { proxy_pass http://api:8000; proxy_http_version 1.1; diff --git a/services/apiClient.ts b/services/apiClient.ts index f6066e8..483e9bc 100644 --- a/services/apiClient.ts +++ b/services/apiClient.ts @@ -7,6 +7,7 @@ import type { CycleFilter, CycleWrite, Flock, + FlockWrite, InitialBalanceResult, Karung, KarungRequestResult, @@ -163,8 +164,11 @@ export const api = { remove: (id: number) => del(`/kandangs/${id}/`), }, flocks: { - list: async (query?: { cycle_id?: number }) => + list: async (query?: { kandang_id?: number }) => unwrapList(await get | Flock[]>('/flocks/', query)), + create: (body: FlockWrite) => post('/flocks/', body), + update: (id: number, body: Partial) => patch(`/flocks/${id}/`, body), + remove: (id: number) => del(`/flocks/${id}/`), }, cycles: { list: async (query?: CycleFilter) => diff --git a/types/api.ts b/types/api.ts index ed9246d..675bfd9 100644 --- a/types/api.ts +++ b/types/api.ts @@ -34,7 +34,7 @@ export type Kandang = { export type Flock = { id: number; flock_name: string; - cycle: number; + kandang: number; created_at: string; updated_at: string; }; @@ -45,8 +45,6 @@ export type Cycle = { id: number; kandang: number; kandang_name: string; - flock: number; - flock_name: string | null; total_days: number; current_day: number; visible_through_date: string; @@ -221,9 +219,12 @@ export type KandangWrite = { kandang_name: string; site: number; }; +export type FlockWrite = { + flock_name: string; + kandang: number; +}; export type CycleWrite = { kandang: number; - flock?: number; total_days: number; current_day?: number; start_date: string; diff --git a/utils/__tests__/buildInMemoryReport.test.ts b/utils/__tests__/buildInMemoryReport.test.ts index 2479416..a757ba6 100644 --- a/utils/__tests__/buildInMemoryReport.test.ts +++ b/utils/__tests__/buildInMemoryReport.test.ts @@ -5,9 +5,10 @@ import type { ChickenCounting, Cycle, Karung, KPI } from '../../types/api.ts'; const cycle: Cycle = { id: 1, kandang: 9, - flock: 1, + kandang_name: 'Kandang 1', total_days: 35, current_day: 2, + visible_through_date: '2026-05-02', start_date: '2026-05-01', end_date: '2026-06-04', chick_in_weight: 45,