remove wagtail and admin website, remove edit option, from hitung ayam, add create flock to settings

This commit is contained in:
Alberto-Audrix committed 2026-08-31 16:00:44 +07:00
1 parent 74dd3faf60
commit 30a736641f
39 files changed
+634 -819

No files matched your search

+3 -1
View File
@@ -58,7 +58,7 @@ docker compose up -d --build
docker compose exec api python manage.py bootstrap_admin 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): **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 | | `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_USER` | Superadmin username for `bootstrap_admin` (default `admin`) |
| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) | | `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) | | `BOOTSTRAP_API_KEY` | Optional fixed API key for scripts (hashed at rest) |
Wagtail admin is proxied at **http://localhost:5002/admin/** (through Nginx → Django). Wagtail admin is proxied at **http://localhost:5002/admin/** (through Nginx → Django).
+4 -3
View File
@@ -20,9 +20,6 @@ DB_NAME=db.sqlite3
# DB_HOST=database # DB_HOST=database
# DB_PORT=5432 # DB_PORT=5432
WAGTAIL_SITE_NAME=New Dashboard
WAGTAILADMIN_BASE_URL=http://localhost:8000
API_PAGE_SIZE=50 API_PAGE_SIZE=50
SESSION_COOKIE_SECURE=False SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False CSRF_COOKIE_SECURE=False
@@ -34,6 +31,10 @@ BOOTSTRAP_API_KEY=
BOOTSTRAP_ADMIN_USER=admin BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD= 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 (external)
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000 KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30 KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
-33
View File
@@ -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")
-8
View File
@@ -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)
@@ -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",
),
),
]
@@ -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",
),
),
]
@@ -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",
),
]
+2 -34
View File
@@ -2,13 +2,10 @@ from datetime import date
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
from apps.accounts.models import User from apps.accounts.models import User
@register_snippet
class Site(models.Model): class Site(models.Model):
site_id = models.BigAutoField(primary_key=True) site_id = models.BigAutoField(primary_key=True)
site_name = models.CharField(max_length=30) site_name = models.CharField(max_length=30)
@@ -16,11 +13,6 @@ class Site(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("site_name"),
FieldPanel("user"),
]
class Meta: class Meta:
db_table = "sites" db_table = "sites"
ordering = ["site_name"] ordering = ["site_name"]
@@ -29,7 +21,6 @@ class Site(models.Model):
return self.site_name return self.site_name
@register_snippet
class Kandang(models.Model): class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True) kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30) kandang_name = models.CharField(max_length=30)
@@ -37,11 +28,6 @@ class Kandang(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang_name"),
FieldPanel("site"),
]
class Meta: class Meta:
db_table = "kandang" db_table = "kandang"
ordering = ["kandang_name"] ordering = ["kandang_name"]
@@ -50,23 +36,17 @@ class Kandang(models.Model):
return self.kandang_name return self.kandang_name
@register_snippet
class Flock(models.Model): class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True) flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30) flock_name = models.CharField(max_length=30)
cycle = models.ForeignKey( kandang = models.ForeignKey(
"Cycle", Kandang,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="flocks", related_name="flocks",
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("flock_name"),
FieldPanel("cycle"),
]
class Meta: class Meta:
db_table = "flock" db_table = "flock"
ordering = ["flock_name"] ordering = ["flock_name"]
@@ -75,7 +55,6 @@ class Flock(models.Model):
return self.flock_name return self.flock_name
@register_snippet
class Cycle(models.Model): class Cycle(models.Model):
STATUS_ACTIVE = "active" STATUS_ACTIVE = "active"
STATUS_CLOSED = "closed" STATUS_CLOSED = "closed"
@@ -98,17 +77,6 @@ class Cycle(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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: class Meta:
db_table = "cycles" db_table = "cycles"
ordering = ["-start_date"] ordering = ["-start_date"]
+1 -24
View File
@@ -21,15 +21,13 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer): class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta: class Meta:
model = Flock 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"] read_only_fields = ["id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer): class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all()) kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
kandang_name = serializers.CharField(source="kandang.kandang_name", read_only=True) 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") chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True) current_day = serializers.IntegerField(read_only=True)
visible_through_date = serializers.DateField(read_only=True) visible_through_date = serializers.DateField(read_only=True)
@@ -40,8 +38,6 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"id", "id",
"kandang", "kandang",
"kandang_name", "kandang_name",
"flock",
"flock_name",
"total_days", "total_days",
"current_day", "current_day",
"visible_through_date", "visible_through_date",
@@ -57,26 +53,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
read_only_fields = [ read_only_fields = [
"id", "id",
"kandang_name", "kandang_name",
"flock",
"flock_name",
"feed_initial_balance", "feed_initial_balance",
"created_at", "created_at",
"updated_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
+52 -51
View File
@@ -10,12 +10,37 @@ from apps.farms.models import Cycle, Flock, Kandang, Site
class FarmAPITests(TestCase): class FarmAPITests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret") 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 = APIClient()
self.client.force_authenticate(self.user)
self.site = Site.objects.create(site_name="Sukawarna", user=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) 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() start = date.today()
response = self.client.post( response = self.client.post(
"/api/v1/cycles/", "/api/v1/cycles/",
@@ -31,56 +56,32 @@ class FarmAPITests(TestCase):
format="json", format="json",
) )
self.assertEqual(response.status_code, 201, response.data) self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["chick_in_weight"], 42) self.assertNotIn("flock", response.data)
self.assertEqual(response.data["kandang_name"], "Kandang 1") self.assertNotIn("flock_name", response.data)
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)
def test_list_cycles_by_kandang(self): def test_flock_persists_across_cycles(self):
cycle = Cycle.objects.create( flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
kandang=self.kandang, start = date.today()
total_days=35, for offset in (0, 40):
start_date=date.today(), Cycle.objects.create(
end_date=date.today() + timedelta(days=34), kandang=self.kandang,
doc_in_weight=40, total_days=35,
doc_in_count=9000, start_date=start + timedelta(days=offset),
status=Cycle.STATUS_ACTIVE, end_date=start + timedelta(days=offset + 34),
) doc_in_weight=40,
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle) doc_in_count=9000,
response = self.client.get("/api/v1/cycles/", {"kandang_id": self.kandang.pk}) status=Cycle.STATUS_ACTIVE,
self.assertEqual(response.status_code, 200) )
results = response.data["results"] if isinstance(response.data, dict) else response.data self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1)
self.assertEqual(len(results), 1) self.assertEqual(flock.kandang_id, self.kandang.pk)
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_list_flocks_by_cycle(self): def test_list_flocks_by_kandang(self):
cycle = Cycle.objects.create( self.client.force_authenticate(self.user)
kandang=self.kandang, Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
total_days=35, Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang)
start_date=date.today(), other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site)
end_date=date.today() + timedelta(days=34), Flock.objects.create(flock_name="Other", kandang=other_kandang)
doc_in_weight=40, response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.pk})
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})
self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else 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)
+6 -5
View File
@@ -56,19 +56,20 @@ class KandangViewSet(viewsets.ModelViewSet):
class FlockViewSet(viewsets.ModelViewSet): class FlockViewSet(viewsets.ModelViewSet):
queryset = Flock.objects.all() queryset = Flock.objects.select_related("kandang").all()
serializer_class = FlockSerializer serializer_class = FlockSerializer
permission_classes = [IsSuperAdminForWrite]
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
cycle_id = self.request.query_params.get("cycle_id") kandang_id = self.request.query_params.get("kandang_id")
if cycle_id: if kandang_id:
qs = qs.filter(cycle_id=cycle_id) qs = qs.filter(kandang_id=kandang_id)
return qs return qs
class CycleViewSet(viewsets.ModelViewSet): class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related("kandang").prefetch_related("flocks").all() queryset = Cycle.objects.select_related("kandang").all()
serializer_class = CycleSerializer serializer_class = CycleSerializer
def get_queryset(self): def get_queryset(self):
@@ -5,62 +5,119 @@ from django.db import transaction
from apps.accounts.models import ApiKey, User 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): class Command(BaseCommand):
help = ( help = (
"Create production superadmin login only (no demo sites/cycles/data). " "Create production login accounts only (no demo sites/cycles/data). "
"Uses BOOTSTRAP_ADMIN_USER and BOOTSTRAP_ADMIN_PASSWORD from settings." "Uses BOOTSTRAP_ADMIN_* and optional BOOTSTRAP_STAFF_* from settings."
) )
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
"--force-password", "--force-password",
action="store_true", 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 @transaction.atomic
def handle(self, *args, **options): def handle(self, *args, **options):
user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip() force_password = options["force_password"]
password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not password: admin_user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
admin_password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not admin_password:
raise CommandError( raise CommandError(
"BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin." "BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin."
) )
user, created = User.objects.get_or_create( admin = ensure_bootstrap_user(
user_name=user_name, user_name=admin_user_name,
defaults={ password=admin_password,
"status": User.STATUS_SUPERADMIN, status=User.STATUS_SUPERADMIN,
"is_staff": True, is_staff=True,
"is_superuser": 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"] staff_user_name = (settings.BOOTSTRAP_STAFF_USER or "").strip()
if should_set_password: staff_password = settings.BOOTSTRAP_STAFF_PASSWORD or ""
user.set_password(password) if staff_user_name:
user.status = User.STATUS_SUPERADMIN if staff_user_name == admin_user_name:
user.is_staff = True raise CommandError(
user.is_superuser = True "BOOTSTRAP_STAFF_USER must differ from BOOTSTRAP_ADMIN_USER."
user.save() )
action = "Created" if created else "Updated password for" if not staff_password:
self.stdout.write(f"{action} superadmin {user_name}") raise CommandError(
else: "BOOTSTRAP_STAFF_PASSWORD is required when BOOTSTRAP_STAFF_USER is set."
user.status = User.STATUS_SUPERADMIN )
user.is_superuser = True ensure_bootstrap_user(
user.save(update_fields=["status", "is_superuser", "updated_at"]) user_name=staff_user_name,
self.stdout.write(f"Superadmin {user_name} already exists (password unchanged)") 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 bootstrap_key = settings.BOOTSTRAP_API_KEY
if bootstrap_key: if bootstrap_key:
api_key, _ = ApiKey.generate(user, "bootstrap") api_key, _ = ApiKey.generate(admin, "bootstrap")
api_key.prefix = bootstrap_key[:8] api_key.prefix = bootstrap_key[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap_key) api_key.key_hash = ApiKey.hash_key(bootstrap_key)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"]) api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})") 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.stdout.write(
self.style.SUCCESS( 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)"
) )
) )
@@ -133,7 +133,7 @@ class Command(BaseCommand):
real_by_date = self.load_real_rows(Path(iot_json)) real_by_date = self.load_real_rows(Path(iot_json))
cycles = Cycle.objects.filter( cycles = Cycle.objects.filter(
start_date=cycle_start, flocks__isnull=False start_date=cycle_start, kandang__flocks__isnull=False
).distinct() ).distinct()
if not cycles: if not cycles:
raise CommandError(f"No cycles starting {cycle_start} with flocks") raise CommandError(f"No cycles starting {cycle_start} with flocks")
@@ -141,7 +141,7 @@ class Command(BaseCommand):
seeded = 0 seeded = 0
for cycle in cycles: for cycle in cycles:
kandang_name = cycle.kandang.kandang_name 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) profile = self.profile_for(kandang_name, flock.flock_name)
rng = random.Random(options["jitter_seed"] + flock.pk) rng = random.Random(options["jitter_seed"] + flock.pk)
rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng) rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng)
@@ -206,8 +206,11 @@ class Command(BaseCommand):
ChickenWeight.objects.filter(cycle=existing).delete() ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete() FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete() ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(flock__cycle=existing).delete() IotPanel.objects.filter(
Flock.objects.filter(cycle=existing).delete() flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete() existing.delete()
self.stdout.write("Reset previous Kandang 1 cycle") self.stdout.write("Reset previous Kandang 1 cycle")
@@ -232,7 +235,7 @@ class Command(BaseCommand):
flocks_by_name: dict[str, Flock] = {} flocks_by_name: dict[str, Flock] = {}
for name in FLOCK_NAMES: 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 flocks_by_name[name] = flock
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)] dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
@@ -426,7 +429,7 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} " 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"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"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} " f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} "
@@ -96,8 +96,11 @@ class Command(BaseCommand):
ChickenWeight.objects.filter(cycle=existing).delete() ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete() FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete() ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(flock__cycle=existing).delete() IotPanel.objects.filter(
Flock.objects.filter(cycle=existing).delete() flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete() existing.delete()
self.stdout.write("Reset previous Kandang 2 cycle") self.stdout.write("Reset previous Kandang 2 cycle")
@@ -122,7 +125,7 @@ class Command(BaseCommand):
flocks_by_name: dict[str, Flock] = {} flocks_by_name: dict[str, Flock] = {}
for name in ("Lantai 1", "Lantai 2"): 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 flocks_by_name[name] = flock
cc_by_date = {} cc_by_date = {}
@@ -288,7 +291,7 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} " 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"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"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}" f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}"
+30
View File
@@ -9,6 +9,8 @@ from django.core.management import call_command
@override_settings( @override_settings(
BOOTSTRAP_ADMIN_USER="admin", BOOTSTRAP_ADMIN_USER="admin",
BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n", BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n",
BOOTSTRAP_STAFF_USER="",
BOOTSTRAP_STAFF_PASSWORD="",
) )
class BootstrapAdminTests(TestCase): class BootstrapAdminTests(TestCase):
def test_creates_superadmin_without_demo_data(self): def test_creates_superadmin_without_demo_data(self):
@@ -23,3 +25,31 @@ class BootstrapAdminTests(TestCase):
with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""): with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""):
with self.assertRaises(CommandError): with self.assertRaises(CommandError):
call_command("bootstrap_admin") 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")
-19
View File
@@ -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)
-28
View File
@@ -1,11 +1,8 @@
from django.db import models 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 from apps.farms.models import Cycle, Flock
@register_snippet
class IotPanel(models.Model): class IotPanel(models.Model):
panel_id = models.BigAutoField(primary_key=True) panel_id = models.BigAutoField(primary_key=True)
date = models.DateField() date = models.DateField()
@@ -19,8 +16,6 @@ class IotPanel(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("flock"), FieldPanel("date"), FieldPanel("average_temperature")]
class Meta: class Meta:
db_table = "iot_panel" db_table = "iot_panel"
ordering = ["-date"] ordering = ["-date"]
@@ -31,7 +26,6 @@ class IotPanel(models.Model):
] ]
@register_snippet
class AIInsight(models.Model): class AIInsight(models.Model):
date = models.DateField() date = models.DateField()
insight_text = models.TextField() insight_text = models.TextField()
@@ -42,14 +36,11 @@ class AIInsight(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("insight_text"), FieldPanel("alert")]
class Meta: class Meta:
db_table = "ai_insight" db_table = "ai_insight"
ordering = ["-date"] ordering = ["-date"]
@register_snippet
class FeedSacks(models.Model): class FeedSacks(models.Model):
fs_id = models.BigAutoField(primary_key=True) fs_id = models.BigAutoField(primary_key=True)
date = models.DateField() date = models.DateField()
@@ -63,8 +54,6 @@ class FeedSacks(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("in_today"), FieldPanel("out_today")]
class Meta: class Meta:
db_table = "feed_sacks" db_table = "feed_sacks"
ordering = ["-date"] ordering = ["-date"]
@@ -74,7 +63,6 @@ class FeedSacks(models.Model):
] ]
@register_snippet
class ChickenCounting(models.Model): class ChickenCounting(models.Model):
cc_id = models.BigAutoField(primary_key=True) cc_id = models.BigAutoField(primary_key=True)
date = models.DateField() date = models.DateField()
@@ -84,8 +72,6 @@ class ChickenCounting(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("total_count")]
class Meta: class Meta:
db_table = "chicken_counting" db_table = "chicken_counting"
ordering = ["-date"] ordering = ["-date"]
@@ -94,7 +80,6 @@ class ChickenCounting(models.Model):
] ]
@register_snippet
class ChickenWeight(models.Model): class ChickenWeight(models.Model):
cw_id = models.BigAutoField(primary_key=True) cw_id = models.BigAutoField(primary_key=True)
date = models.DateField() date = models.DateField()
@@ -108,13 +93,6 @@ class ChickenWeight(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("cycle"),
FieldPanel("date"),
FieldPanel("average_weight"),
FieldPanel("uniformity"),
]
class Meta: class Meta:
db_table = "chicken_weight" db_table = "chicken_weight"
ordering = ["-date"] ordering = ["-date"]
@@ -123,7 +101,6 @@ class ChickenWeight(models.Model):
] ]
@register_snippet
class ManualInput(models.Model): class ManualInput(models.Model):
manual_id = models.BigAutoField(primary_key=True) manual_id = models.BigAutoField(primary_key=True)
date = models.DateField() date = models.DateField()
@@ -149,8 +126,6 @@ class ManualInput(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age_manual"), FieldPanel("fcr_manual")]
class Meta: class Meta:
db_table = "manual_input" db_table = "manual_input"
ordering = ["-date"] ordering = ["-date"]
@@ -159,7 +134,6 @@ class ManualInput(models.Model):
] ]
@register_snippet
class KPI(models.Model): class KPI(models.Model):
kpi_id = models.BigAutoField(primary_key=True) kpi_id = models.BigAutoField(primary_key=True)
date = models.DateField() date = models.DateField()
@@ -187,8 +161,6 @@ class KPI(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age"), FieldPanel("fcr"), FieldPanel("eef")]
class Meta: class Meta:
db_table = "kpi" db_table = "kpi"
ordering = ["-date"] ordering = ["-date"]
+2 -3
View File
@@ -19,7 +19,7 @@ class CycleContextMixin(serializers.Serializer):
flock_id = serializers.SerializerMethodField() flock_id = serializers.SerializerMethodField()
def get_flock_id(self, obj): 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 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"] read_only_fields = ["id", "created_at", "updated_at"]
def get_kandang(self, obj): def get_kandang(self, obj):
cycle = getattr(obj.flock, "cycle", None) return obj.flock.kandang_id
return cycle.kandang_id if cycle is not None else None
class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer): class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
+12 -3
View File
@@ -11,7 +11,7 @@ import httpx
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from apps.farms.models import Flock from apps.farms.models import Cycle, Flock
from apps.operations.models import IotPanel from apps.operations.models import IotPanel
WIB = ZoneInfo("Asia/Jakarta") WIB = ZoneInfo("Asia/Jakarta")
@@ -199,7 +199,7 @@ def sync_iot_panels(
django_flocks: dict[str, Flock] = {} django_flocks: dict[str, Flock] = {}
for external_id, django_id in flock_map.items(): for external_id, django_id in flock_map.items():
try: 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: except Flock.DoesNotExist:
continue continue
@@ -236,7 +236,16 @@ def sync_iot_panels(
continue continue
snapshot_date = fetched_at.astimezone(WIB).date() 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) fields = panel_fields_from_payload(payload_data, age)
IotPanel.objects.update_or_create( IotPanel.objects.update_or_create(
+3 -3
View File
@@ -26,7 +26,7 @@ class OperationsAPITests(TestCase):
doc_in_count=10000, doc_in_count=10000,
status=Cycle.STATUS_ACTIVE, 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): def test_karungs_alias_lists_feed_sacks_with_date(self):
FeedSacks.objects.create( FeedSacks.objects.create(
@@ -65,7 +65,7 @@ class OperationsAPITests(TestCase):
self.assertEqual(results[0]["date"], date.today().isoformat()) self.assertEqual(results[0]["date"], date.today().isoformat())
def test_panel_iot_filters_by_flock(self): 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( row = IotPanel.objects.create(
flock=self.flock, flock=self.flock,
date=date.today(), date=date.today(),
@@ -379,7 +379,7 @@ class DashboardVisibilityTests(TestCase):
self.assertEqual(response.data["date"], "2026-08-28") self.assertEqual(response.data["date"], "2026-08-28")
def test_panel_iot_not_hidden_before_publish_time(self): 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( IotPanel.objects.create(
flock=flock, flock=flock,
date=date(2026, 8, 28), date=date(2026, 8, 28),
+13 -4
View File
@@ -53,7 +53,7 @@ class CycleScopedViewSet(VisibilityFilteredMixin, viewsets.ModelViewSet):
if params.get("cycle_id"): if params.get("cycle_id"):
qs = qs.filter(cycle_id=params["cycle_id"]) qs = qs.filter(cycle_id=params["cycle_id"])
if params.get("flock_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"): if params.get("date"):
qs = qs.filter(date=params["date"]) qs = qs.filter(date=params["date"])
return self.filter_visible_through(qs) return self.filter_visible_through(qs)
@@ -143,14 +143,23 @@ class FeedSacksViewSet(CycleScopedViewSet):
class IotPanelViewSet(viewsets.ModelViewSet): class IotPanelViewSet(viewsets.ModelViewSet):
"""IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff.""" """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 serializer_class = IotPanelSerializer
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
params = self.request.query_params params = self.request.query_params
if params.get("cycle_id"): cycle_id = params.get("cycle_id")
qs = qs.filter(flock__cycle_id=params["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"): if params.get("flock_id"):
qs = qs.filter(flock_id=params["flock_id"]) qs = qs.filter(flock_id=params["flock_id"])
if params.get("date"): if params.get("date"):
+7 -24
View File
@@ -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 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") CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS", "http://localhost:8000")
INSTALLED_APPS = [ 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.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
@@ -68,6 +50,10 @@ INSTALLED_APPS = [
"rest_framework", "rest_framework",
"corsheaders", "corsheaders",
"drf_spectacular", "drf_spectacular",
"apps.accounts",
"apps.farms",
"apps.operations",
"apps.jobs",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -79,7 +65,6 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
@@ -142,10 +127,6 @@ MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 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_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS", "http://localhost:3000")
CORS_ALLOW_CREDENTIALS = env_bool("CORS_ALLOW_CREDENTIALS", True) 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_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin"
BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or "" 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 = ( KARUNG_WEB_ADMIN_BASE_URL = (
env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or "" env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or ""
-8
View File
@@ -1,16 +1,9 @@
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 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 = [ 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/schema/", SpectacularAPIView.as_view(), name="schema"),
path( path(
"api/docs/", "api/docs/",
@@ -20,7 +13,6 @@ urlpatterns = [
path("api/v1/", include("apps.accounts.urls")), path("api/v1/", include("apps.accounts.urls")),
path("api/v1/", include("apps.farms.urls")), path("api/v1/", include("apps.farms.urls")),
path("api/v1/", include("apps.operations.urls")), path("api/v1/", include("apps.operations.urls")),
path("", include(wagtail_urls)),
] ]
if settings.DEBUG: if settings.DEBUG:
-2
View File
@@ -1,5 +1,4 @@
Django>=5.1,<5.3 Django>=5.1,<5.3
wagtail>=6.3,<7.0
djangorestframework>=3.15,<4.0 djangorestframework>=3.15,<4.0
drf-spectacular>=0.27,<1.0 drf-spectacular>=0.27,<1.0
django-cors-headers>=4.4,<5.0 django-cors-headers>=4.4,<5.0
@@ -7,5 +6,4 @@ python-dotenv>=1.0,<2.0
gunicorn>=23.0,<24.0 gunicorn>=23.0,<24.0
psycopg[binary]>=3.2,<4.0 psycopg[binary]>=3.2,<4.0
httpx>=0.27,<1.0 httpx>=0.27,<1.0
Pillow>=10.0,<12.0
openpyxl>=3.1,<4.0 openpyxl>=3.1,<4.0
+3 -3
View File
@@ -76,7 +76,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
}, [selectedChartKey]); }, [selectedChartKey]);
useEffect(() => { useEffect(() => {
if (!selectedCycle) { if (!selectedKandang) {
setFlocks([]); setFlocks([]);
setSelectedFlockId(null); setSelectedFlockId(null);
setDates([]); setDates([]);
@@ -89,7 +89,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
(async () => { (async () => {
setError(null); setError(null);
try { try {
const data = await api.flocks.list({ cycle_id: selectedCycle.id }); const data = await api.flocks.list({ kandang_id: selectedKandang.id });
if (cancelled) return; if (cancelled) return;
setFlocks(data); setFlocks(data);
setSelectedFlockId((previous) => setSelectedFlockId((previous) =>
@@ -102,7 +102,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [selectedCycle, refreshToken]); }, [selectedKandang, refreshToken]);
useEffect(() => { useEffect(() => {
if (!selectedCycle || selectedFlockId == null) { if (!selectedCycle || selectedFlockId == null) {
+78 -15
View File
@@ -1,6 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts'; import { api, errorMessage } from '../services/apiClient.ts';
import type { CycleStatus } from '../types/api.ts';
import { useAuth } from '../context/AuthContext.tsx'; import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx'; import { useFarm } from '../context/FarmContext.tsx';
import { Banner, PageHeader } from './ui/Feedback.tsx'; import { Banner, PageHeader } from './ui/Feedback.tsx';
@@ -14,6 +13,7 @@ const SettingsPage: React.FC = () => {
const { const {
sites, sites,
kandangs, kandangs,
flocks,
cycles, cycles,
selectedSiteId, selectedSiteId,
selectedKandangId, selectedKandangId,
@@ -27,11 +27,11 @@ const SettingsPage: React.FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [siteName, setSiteName] = useState(''); const [siteName, setSiteName] = useState('');
const [kandangName, setKandangName] = useState(''); const [kandangName, setKandangName] = useState('');
const [flockName, setFlockName] = useState('');
const [startDate, setStartDate] = useState(todayIso()); const [startDate, setStartDate] = useState(todayIso());
const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34)); const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34));
const [docInCount, setDocInCount] = useState(20000); const [docInCount, setDocInCount] = useState(20000);
const [chickInWeight, setChickInWeight] = useState(45); const [chickInWeight, setChickInWeight] = useState(35);
const [cycleStatus, setCycleStatus] = useState<CycleStatus>('active');
const flash = (msg: string) => { const flash = (msg: string) => {
setNotice(msg); setNotice(msg);
@@ -55,7 +55,10 @@ const SettingsPage: React.FC = () => {
return ( return (
<div> <div>
<PageHeader title="Pengaturan" subtitle="Site, kandang, dan siklus produksi" /> <PageHeader
title="Pengaturan"
subtitle="Site, kandang, flock (IoT), dan siklus produksi"
/>
{error && ( {error && (
<div className="mb-4"> <div className="mb-4">
<Banner kind="error">{error}</Banner> <Banner kind="error">{error}</Banner>
@@ -66,7 +69,7 @@ const SettingsPage: React.FC = () => {
<Banner kind="success">{notice}</Banner> <Banner kind="success">{notice}</Banner>
</div> </div>
)} )}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-6">
<section className="bg-white rounded-xl shadow-sm p-5"> <section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Sites</h2> <h2 className="font-semibold text-gray-800 mb-3">Sites</h2>
{isSuperAdmin ? ( {isSuperAdmin ? (
@@ -188,6 +191,69 @@ const SettingsPage: React.FC = () => {
))} ))}
</ul> </ul>
</section> </section>
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Flock</h2>
<p className="mb-3 text-xs text-gray-500">
Flock tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus.
</p>
{isSuperAdmin ? (
<form
className="space-y-2 mb-4"
onSubmit={(e) => {
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.`);
});
}}
>
<input
className={inputClass}
placeholder="Nama flock"
value={flockName}
onChange={(e) => setFlockName(e.target.value)}
required
/>
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving || !selectedKandangId}
>
Tambah flock
</button>
</form>
) : (
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat flock.</p>
)}
{!selectedKandangId ? (
<p className="text-xs text-gray-500">Pilih kandang terlebih dahulu.</p>
) : (
<ul className="divide-y">
{flocks.map((flock) => (
<li key={flock.id} className="py-2 flex items-center justify-between gap-2">
<span className="text-sm text-gray-700">{flock.flock_name}</span>
{isSuperAdmin && (
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.flocks.remove(flock.id);
flash('Flock dihapus.');
})
}
>
Hapus
</button>
)}
</li>
))}
</ul>
)}
</section>
<section className="bg-white rounded-xl shadow-sm p-5"> <section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Siklus</h2> <h2 className="font-semibold text-gray-800 mb-3">Siklus</h2>
<form <form
@@ -204,7 +270,6 @@ const SettingsPage: React.FC = () => {
end_date: endDate, end_date: endDate,
chick_in_weight: chickInWeight, chick_in_weight: chickInWeight,
doc_in_count: docInCount, doc_in_count: docInCount,
status: cycleStatus,
}); });
flash('Siklus dibuat.'); flash('Siklus dibuat.');
}); });
@@ -247,18 +312,16 @@ const SettingsPage: React.FC = () => {
<input <input
type="number" type="number"
className={`${inputClass} mt-1`} className={`${inputClass} mt-1`}
min={35}
max={51}
value={chickInWeight} value={chickInWeight}
onChange={(e) => 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)));
}}
/> />
</label> </label>
<select
className={inputClass}
value={cycleStatus}
onChange={(e) => setCycleStatus(e.target.value as CycleStatus)}
>
<option value="active">active</option>
<option value="closed">closed</option>
</select>
<button <button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold" className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving} disabled={saving}
+70 -24
View File
@@ -2,25 +2,36 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import SettingsPage from '../SettingsPage.tsx'; import SettingsPage from '../SettingsPage.tsx';
const { createKandangMock, createCycleMock, reloadMock, farmState } = vi.hoisted(() => ({ const { createKandangMock, createFlockMock, createCycleMock, reloadMock, farmState } = vi.hoisted(
createKandangMock: vi.fn(), () => ({
createCycleMock: vi.fn(), createKandangMock: vi.fn(),
reloadMock: vi.fn().mockResolvedValue(undefined), createFlockMock: vi.fn(),
farmState: { createCycleMock: vi.fn(),
sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }], reloadMock: vi.fn().mockResolvedValue(undefined),
kandangs: [] as Array<{ farmState: {
id: number; sites: [{ id: 7, site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
kandang_name: string; kandangs: [] as Array<{
site: number; id: number;
created_at: string; kandang_name: string;
updated_at: string; site: number;
}>, created_at: string;
cycles: [] as unknown[], updated_at: string;
selectedSiteId: 7, }>,
selectedKandangId: null as number | null, flocks: [] as Array<{
selectedCycle: null as unknown, id: number;
}, flock_name: string;
})); kandang: number;
created_at: string;
updated_at: string;
}>,
cycles: [] as unknown[],
selectedSiteId: 7,
selectedKandangId: null as number | null,
selectedCycleId: null as number | null,
selectedCycle: null as unknown,
},
})
);
vi.mock('../../services/apiClient.ts', () => ({ vi.mock('../../services/apiClient.ts', () => ({
api: { api: {
@@ -32,6 +43,10 @@ vi.mock('../../services/apiClient.ts', () => ({
create: createKandangMock, create: createKandangMock,
remove: vi.fn(), remove: vi.fn(),
}, },
flocks: {
create: createFlockMock,
remove: vi.fn(),
},
cycles: { cycles: {
create: createCycleMock, create: createCycleMock,
remove: vi.fn(), remove: vi.fn(),
@@ -56,9 +71,11 @@ vi.mock('../../context/FarmContext.tsx', () => ({
useFarm: () => ({ useFarm: () => ({
sites: farmState.sites, sites: farmState.sites,
kandangs: farmState.kandangs, kandangs: farmState.kandangs,
flocks: farmState.flocks,
cycles: farmState.cycles, cycles: farmState.cycles,
selectedSiteId: farmState.selectedSiteId, selectedSiteId: farmState.selectedSiteId,
selectedKandangId: farmState.selectedKandangId, selectedKandangId: farmState.selectedKandangId,
selectedCycleId: farmState.selectedCycleId,
selectedCycle: farmState.selectedCycle, selectedCycle: farmState.selectedCycle,
reload: reloadMock, reload: reloadMock,
setSelectedSiteId: vi.fn(), setSelectedSiteId: vi.fn(),
@@ -70,12 +87,15 @@ vi.mock('../../context/FarmContext.tsx', () => ({
describe('SettingsPage settings forms', () => { describe('SettingsPage settings forms', () => {
beforeEach(() => { beforeEach(() => {
createKandangMock.mockReset(); createKandangMock.mockReset();
createFlockMock.mockReset();
createCycleMock.mockReset(); createCycleMock.mockReset();
reloadMock.mockClear(); reloadMock.mockClear();
farmState.kandangs = []; farmState.kandangs = [];
farmState.flocks = [];
farmState.cycles = []; farmState.cycles = [];
farmState.selectedSiteId = 7; farmState.selectedSiteId = 7;
farmState.selectedKandangId = null; farmState.selectedKandangId = null;
farmState.selectedCycleId = null;
farmState.selectedCycle = null; farmState.selectedCycle = null;
createKandangMock.mockResolvedValue({ createKandangMock.mockResolvedValue({
id: 11, id: 11,
@@ -84,15 +104,21 @@ describe('SettingsPage settings forms', () => {
created_at: '', created_at: '',
updated_at: '', updated_at: '',
}); });
createFlockMock.mockResolvedValue({
id: 3,
flock_name: 'Lantai 1',
kandang: 11,
created_at: '',
updated_at: '',
});
createCycleMock.mockResolvedValue({ createCycleMock.mockResolvedValue({
id: 21, id: 21,
kandang: 11, kandang: 11,
flock: 3,
total_days: 35, total_days: 35,
current_day: 1, current_day: 1,
start_date: '2026-08-18', start_date: '2026-08-18',
end_date: '2026-09-21', end_date: '2026-09-21',
chick_in_weight: 45, chick_in_weight: 35,
doc_in_count: 20000, doc_in_count: 20000,
status: 'active', status: 'active',
created_at: '', created_at: '',
@@ -116,7 +142,7 @@ describe('SettingsPage settings forms', () => {
}); });
}); });
it('submits cycle fields expected by the backend contract', async () => { it('submits flock fields for the selected kandang', async () => {
farmState.kandangs = [ farmState.kandangs = [
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' }, { id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
]; ];
@@ -124,6 +150,27 @@ describe('SettingsPage settings forms', () => {
render(<SettingsPage />); render(<SettingsPage />);
fireEvent.change(screen.getByPlaceholderText('Nama flock'), {
target: { value: 'Lantai 1' },
});
fireEvent.click(screen.getByRole('button', { name: 'Tambah flock' }));
await waitFor(() => expect(createFlockMock).toHaveBeenCalledTimes(1));
expect(createFlockMock).toHaveBeenCalledWith({
flock_name: 'Lantai 1',
kandang: 11,
});
});
it('submits cycle fields without flock assignment', async () => {
farmState.kandangs = [
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
];
farmState.selectedKandangId = 11;
render(<SettingsPage />);
expect(screen.queryByLabelText(/^Siklus$/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Total hari/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/Total hari/i)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Mulai/i), { target: { value: '2026-08-01' } }); fireEvent.change(screen.getByLabelText(/Mulai/i), { target: { value: '2026-08-01' } });
@@ -137,9 +184,8 @@ describe('SettingsPage settings forms', () => {
current_day: 1, current_day: 1,
start_date: '2026-08-01', start_date: '2026-08-01',
end_date: '2026-09-04', end_date: '2026-09-04',
chick_in_weight: 45, chick_in_weight: 35,
doc_in_count: 20000, doc_in_count: 20000,
status: 'active',
}); });
}); });
}); });
+44 -431
View File
@@ -30,13 +30,6 @@ type Row = {
isFuture: boolean; isFuture: boolean;
}; };
type EditValues = {
mati: number;
panen: number;
beratPanen: string;
chickenCount?: number;
};
const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm(); const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const [kpis, setKpis] = useState<KPI[]>([]); const [kpis, setKpis] = useState<KPI[]>([]);
@@ -44,19 +37,11 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
const [manualRows, setManualRows] = useState<ManualInput[]>([]); const [manualRows, setManualRows] = useState<ManualInput[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]); const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editingDay, setEditingDay] = useState<number | null>(null);
const [editValues, setEditValues] = useState<EditValues>({
mati: 0,
panen: 0,
beratPanen: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [searchDay, setSearchDay] = useState<string>(''); const [searchDay, setSearchDay] = useState<string>('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const isReadOnly = !selectedCycle || selectedCycle.status !== 'active'; const isClosedCycle = !selectedCycle || selectedCycle.status !== 'active';
const loadData = async (cycleId: number) => { const loadData = async (cycleId: number) => {
setLoading(true); setLoading(true);
@@ -85,7 +70,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
setCountings([]); setCountings([]);
setManualRows([]); setManualRows([]);
setInsights([]); setInsights([]);
setEditingDay(null);
return; return;
} }
void loadData(selectedCycle.id); void loadData(selectedCycle.id);
@@ -222,104 +206,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
); );
}; };
const handleEdit = (record: Row) => {
setEditingDay(record.day);
setEditValues({
mati: record.mati,
panen: record.panen,
beratPanen: record.beratPanen ? record.beratPanen.toString() : '',
chickenCount: record.chickenCount,
});
setValidationErrors({});
};
const validateEditValues = (record: Row): boolean => {
const errors: Record<string, string> = {};
const beratPanenValue = editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen);
if (editValues.mati < 0) {
errors.mati = 'Mati tidak boleh negatif';
} else if (editValues.mati > record.stockAwal) {
errors.mati = `Mati tidak boleh lebih dari stock awal (${record.stockAwal})`;
}
if (editValues.panen < 0) {
errors.panen = 'Panen tidak boleh negatif';
} else if (editValues.panen > record.stockAwal - editValues.mati) {
errors.panen = `Panen tidak boleh lebih dari sisa (${record.stockAwal - editValues.mati})`;
}
if (editValues.chickenCount !== undefined && editValues.chickenCount < 0) {
errors.chickenCount = 'Chicken counting tidak boleh negatif';
}
if (Number.isNaN(beratPanenValue) || beratPanenValue < 0) {
errors.beratPanen = 'Berat panen tidak boleh negatif';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = async () => {
if (editingDay === null || !selectedCycle) return;
const record = rows.find((r) => r.day === editingDay);
if (!record) return;
if (!validateEditValues(record)) return;
setSaving(true);
try {
const beratPanenValue = editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen);
const manualChanged =
editValues.mati !== record.mati ||
editValues.panen !== record.panen ||
beratPanenValue !== record.beratPanen;
if (manualChanged) {
await api.manualInputs.create({
cycle: selectedCycle.id,
date: record.date,
age_manual: record.day,
mortality_manual: editValues.mati,
harvest_manual: editValues.panen,
// Input pengguna dalam kg, disimpan ke database dalam gram.
harvest_weight_manual: beratPanenValue * 1000,
});
}
if (
editValues.chickenCount !== undefined &&
editValues.chickenCount !== record.chickenCount
) {
const existing = countings.find((c) => c.date === record.date);
if (existing) {
await api.countings.update(existing.id, { total_count: editValues.chickenCount });
} else {
await api.countings.create({
cycle: selectedCycle.id,
date: record.date,
total_count: editValues.chickenCount,
});
}
}
setEditingDay(null);
setValidationErrors({});
await loadData(selectedCycle.id);
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const handleCancel = () => {
setEditingDay(null);
setValidationErrors({});
};
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />; if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return ( return (
@@ -328,7 +214,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<PageHeader <PageHeader
title="Hitung Ayam" title="Hitung Ayam"
icon={<i className="fa-solid fa-calculator" />} icon={<i className="fa-solid fa-calculator" />}
subtitle={`Kelola dan edit data deplesi harian untuk siklus produksi aktif · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`} subtitle={`Lihat data deplesi harian untuk siklus produksi · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
/> />
{/* AI Insight */} {/* AI Insight */}
@@ -488,8 +374,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
</div> </div>
</div> </div>
{/* Read-only banner for closed cycles */} {isClosedCycle && (
{isReadOnly && (
<div className="mb-6 bg-amber-50 border-l-4 border-amber-400 rounded-lg p-4"> <div className="mb-6 bg-amber-50 border-l-4 border-amber-400 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
<i className="fa-solid fa-eye text-amber-500 text-xl mr-4"></i> <i className="fa-solid fa-eye text-amber-500 text-xl mr-4"></i>
@@ -497,9 +382,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<p className="text-sm text-amber-800 font-semibold"> <p className="text-sm text-amber-800 font-semibold">
Mode Lihat Saja (Siklus Selesai) Mode Lihat Saja (Siklus Selesai)
</p> </p>
<p className="text-xs text-amber-700 mt-1">
Untuk mengedit data, pilih siklus yang masih aktif pada dropdown di atas.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -515,7 +397,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
</div> </div>
) : ( ) : (
filteredRecords.map((record) => { filteredRecords.map((record) => {
const isEditing = !isReadOnly && editingDay === record.day;
const cardTone = record.isToday const cardTone = record.isToday
? 'ring-2 ring-blue-200 bg-blue-50/40' ? 'ring-2 ring-blue-200 bg-blue-50/40'
: record.panen > 0 : record.panen > 0
@@ -527,11 +408,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
key={record.day} key={record.day}
className={`rounded-2xl border border-gray-200 shadow-sm overflow-hidden ${cardTone}`} className={`rounded-2xl border border-gray-200 shadow-sm overflow-hidden ${cardTone}`}
> >
<div <div className="flex items-start justify-between gap-3 px-4 py-4 bg-gray-50/80">
className={`flex items-start justify-between gap-3 px-4 py-4 ${
isEditing ? 'bg-blue-50' : 'bg-gray-50/80'
}`}
>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white"> <span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
@@ -557,38 +434,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
{record.dateLabel} {record.dateLabel}
</div> </div>
</div> </div>
{!isReadOnly && (
<div className="flex-shrink-0">
{isEditing ? (
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-green-500 text-white shadow-sm transition-colors hover:bg-green-600 disabled:opacity-60"
aria-label="Simpan"
>
<i className="fa-solid fa-check"></i>
</button>
<button
onClick={handleCancel}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-gray-500 text-white shadow-sm transition-colors hover:bg-gray-600"
aria-label="Batal"
>
<i className="fa-solid fa-times"></i>
</button>
</div>
) : (
<button
onClick={() => handleEdit(record)}
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700"
>
<i className="fa-solid fa-edit"></i>
Edit
</button>
)}
</div>
)}
</div> </div>
<div className="p-4"> <div className="p-4">
@@ -606,92 +451,27 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<div className="text-[11px] font-semibold uppercase tracking-wide text-red-500"> <div className="text-[11px] font-semibold uppercase tracking-wide text-red-500">
Mati Mati
</div> </div>
{isEditing ? ( <div className="text-[11px] font-semibold text-red-600 md:text-sm">
<div> {formatNumber(record.mati, 0)}
<input </div>
type="number"
value={editValues.mati}
onChange={(e) =>
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 && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.mati}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</div>
)}
</div> </div>
<div className="rounded-xl bg-purple-50 p-3"> <div className="rounded-xl bg-purple-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500"> <div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
Panen Panen
</div> </div>
{isEditing ? ( <div className="text-[11px] font-semibold text-purple-600 md:text-sm">
<div> {formatNumber(record.panen, 0)}
<input </div>
type="number"
value={editValues.panen}
onChange={(e) =>
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 && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.panen}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</div>
)}
</div> </div>
<div className="rounded-xl bg-purple-50 p-3"> <div className="rounded-xl bg-purple-50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500"> <div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
Berat panen (kg) Berat panen (kg)
</div> </div>
{isEditing ? ( <div className="text-[11px] font-semibold text-purple-600 md:text-sm">
<div> {record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
<input </div>
type="number"
step="0.01"
min="0"
value={editValues.beratPanen}
onChange={(e) =>
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 && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.beratPanen}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
</div>
)}
</div> </div>
<div className="rounded-xl bg-slate-50 p-3"> <div className="rounded-xl bg-slate-50 p-3">
@@ -699,12 +479,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
Stock akhir Stock akhir
</div> </div>
<div className="text-[11px] font-semibold text-slate-900 md:text-sm"> <div className="text-[11px] font-semibold text-slate-900 md:text-sm">
{isEditing {formatNumber(record.stockAkhir, 0)}
? Math.max(
0,
record.stockAwal - editValues.mati - editValues.panen
).toLocaleString('id-ID')
: formatNumber(record.stockAkhir, 0)}
</div> </div>
</div> </div>
@@ -712,35 +487,11 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<div className="text-[11px] font-semibold uppercase tracking-wide text-blue-500"> <div className="text-[11px] font-semibold uppercase tracking-wide text-blue-500">
Chicken Counting Chicken Counting
</div> </div>
{isEditing ? ( <div className="text-[11px] font-semibold text-blue-600 md:text-sm">
<div> {record.chickenCount !== undefined
<input ? formatNumber(record.chickenCount, 0)
type="number" : '-'}
value={editValues.chickenCount || ''} </div>
onChange={(e) =>
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 && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.chickenCount}
</div>
)}
</div>
) : (
<div className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</div>
)}
</div> </div>
<div className="rounded-xl bg-emerald-50 p-3"> <div className="rounded-xl bg-emerald-50 p-3">
@@ -801,18 +552,10 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
</th> </th>
<th <th
rowSpan={2} rowSpan={2}
className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300" className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
> >
Persentase Persentase
</th> </th>
{!isReadOnly && (
<th
rowSpan={2}
className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
>
Aksi
</th>
)}
</tr> </tr>
{/* Second header row with individual column names */} {/* Second header row with individual column names */}
<tr className="border-b border-gray-200"> <tr className="border-b border-gray-200">
@@ -834,7 +577,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
{filteredRecords.length === 0 ? ( {filteredRecords.length === 0 ? (
<tr> <tr>
<td <td
colSpan={isReadOnly ? 9 : 10} colSpan={9}
className="px-6 py-12 text-center text-gray-500" className="px-6 py-12 text-center text-gray-500"
> >
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i> <i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
@@ -843,7 +586,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
</tr> </tr>
) : ( ) : (
filteredRecords.map((record, index) => { filteredRecords.map((record, index) => {
const isEditing = !isReadOnly && editingDay === record.day;
const rowClass = `hover:bg-gray-100 transition-colors ${ const rowClass = `hover:bg-gray-100 transition-colors ${
index % 2 === 0 ? 'bg-white' : 'bg-gray-50' index % 2 === 0 ? 'bg-white' : 'bg-gray-50'
} ${record.panen > 0 ? 'border-l-4 border-purple-400' : ''}`; } ${record.panen > 0 ? 'border-l-4 border-purple-400' : ''}`;
@@ -862,175 +604,50 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
{record.dateLabel} {record.dateLabel}
</td> </td>
{/* Stock awal (calculated, read-only) */} {/* Stock awal */}
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-500 md:text-sm"> <td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-500 md:text-sm">
{formatNumber(record.stockAwal, 0)} {formatNumber(record.stockAwal, 0)}
</td> </td>
{/* Mati (editable) */} {/* Mati */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200"> <td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? ( <span className="text-[11px] font-semibold text-red-600 md:text-sm">
<div> {formatNumber(record.mati, 0)}
<input </span>
type="number"
value={editValues.mati}
onChange={(e) =>
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 && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.mati}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-red-600 md:text-sm">
{formatNumber(record.mati, 0)}
</span>
)}
</td> </td>
{/* JUMLAH PANEN (editable) */} {/* JUMLAH PANEN */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200"> <td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? ( <span className="text-[11px] font-semibold text-purple-600 md:text-sm">
<div> {formatNumber(record.panen, 0)}
<input </span>
type="number"
value={editValues.panen}
onChange={(e) =>
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 && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.panen}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{formatNumber(record.panen, 0)}
</span>
)}
</td> </td>
{/* BERAT PANEN (editable) */} {/* BERAT PANEN */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200"> <td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? ( <span className="text-[11px] font-semibold text-purple-600 md:text-sm">
<div> {record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
<input </span>
type="number"
step="0.01"
min="0"
value={editValues.beratPanen}
onChange={(e) =>
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 && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.beratPanen}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
</span>
)}
</td> </td>
{/* Stock akhir (calculated, read-only) */} {/* Stock akhir */}
<td className="px-2 py-2 whitespace-nowrap text-center text-[11px] font-bold text-gray-800 border-r border-gray-200 md:text-sm"> <td className="px-2 py-2 whitespace-nowrap text-center text-[11px] font-bold text-gray-800 border-r border-gray-200 md:text-sm">
{isEditing {formatNumber(record.stockAkhir, 0)}
? Math.max(
0,
record.stockAwal - editValues.mati - editValues.panen
).toLocaleString('id-ID')
: formatNumber(record.stockAkhir, 0)}
</td> </td>
{/* Chicken Counting (editable) */} {/* Chicken Counting */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200"> <td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
{isEditing ? ( <span className="text-[11px] font-semibold text-blue-600 md:text-sm">
<div> {record.chickenCount !== undefined
<input ? formatNumber(record.chickenCount, 0)
type="number" : '-'}
value={editValues.chickenCount || ''} </span>
onChange={(e) =>
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 && (
<div className="mt-1 text-[11px] text-red-600">
{validationErrors.chickenCount}
</div>
)}
</div>
) : (
<span className="text-[11px] font-semibold text-blue-600 md:text-sm">
{record.chickenCount !== undefined
? formatNumber(record.chickenCount, 0)
: '-'}
</span>
)}
</td> </td>
{/* Persentase (calculated, badge) */} {/* Persentase */}
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200"> <td className="px-2 py-2 whitespace-nowrap text-center">
{getPersentaseBadge(record.persentase)} {getPersentaseBadge(record.persentase)}
</td> </td>
{/* Aksi */}
{!isReadOnly && (
<td className="px-2 py-2 whitespace-nowrap text-center">
{isEditing ? (
<div className="flex justify-center gap-2">
<button
onClick={handleSave}
disabled={saving}
className="px-2 py-1 bg-green-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-green-600 disabled:opacity-60 md:px-3 md:text-sm"
>
<i className="fa-solid fa-check"></i>
</button>
<button
onClick={handleCancel}
className="px-2 py-1 bg-gray-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-gray-600 md:px-3 md:text-sm"
>
<i className="fa-solid fa-times"></i>
</button>
</div>
) : (
<button
onClick={() => handleEdit(record)}
className="px-2 py-1 bg-blue-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-blue-600 md:px-3 md:text-sm"
>
<i className="fa-solid fa-edit mr-1"></i>
Edit
</button>
)}
</td>
)}
</tr> </tr>
); );
}) })
@@ -1079,10 +696,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<i className="fa-solid fa-circle text-xs mr-2"></i> <i className="fa-solid fa-circle text-xs mr-2"></i>
<strong>Chicken Counting (IOT)</strong> = hasil hitung otomatis dari IOT <strong>Chicken Counting (IOT)</strong> = hasil hitung otomatis dari IOT
</li> </li>
<li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Data yang diedit akan
tersimpan sebagai input manual dan ditandai dengan highlight kuning
</li>
<li> <li>
<i className="fa-solid fa-circle text-xs mr-2"></i>Baris dengan border ungu <i className="fa-solid fa-circle text-xs mr-2"></i>Baris dengan border ungu
menandakan ada panen pada hari tersebut menandakan ada panen pada hari tersebut
@@ -68,5 +68,6 @@ describe('DataManagementPage', () => {
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /Input manual/i })).not.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: /Simpan hitungan/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Edit/i })).not.toBeInTheDocument();
}); });
}); });
+26 -1
View File
@@ -1,11 +1,12 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts'; 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'; import { useAuth } from './AuthContext.tsx';
type FarmContextValue = { type FarmContextValue = {
sites: Site[]; sites: Site[];
kandangs: Kandang[]; kandangs: Kandang[];
flocks: Flock[];
cycles: Cycle[]; cycles: Cycle[];
selectedSiteId: number | null; selectedSiteId: number | null;
selectedKandangId: number | null; selectedKandangId: number | null;
@@ -27,6 +28,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
const { user } = useAuth(); const { user } = useAuth();
const [sites, setSites] = useState<Site[]>([]); const [sites, setSites] = useState<Site[]>([]);
const [kandangs, setKandangs] = useState<Kandang[]>([]); const [kandangs, setKandangs] = useState<Kandang[]>([]);
const [flocks, setFlocks] = useState<Flock[]>([]);
const [cycles, setCycles] = useState<Cycle[]>([]); const [cycles, setCycles] = useState<Cycle[]>([]);
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null); const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null); const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
@@ -43,6 +45,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (!user) { if (!user) {
setSites([]); setSites([]);
setKandangs([]); setKandangs([]);
setFlocks([]);
setCycles([]); setCycles([]);
return; return;
} }
@@ -94,6 +97,26 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
}; };
}, [user, selectedSiteId, reloadToken]); }, [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(() => { useEffect(() => {
if (!user || selectedKandangId == null) { if (!user || selectedKandangId == null) {
setCycles([]); setCycles([]);
@@ -138,6 +161,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
() => ({ () => ({
sites, sites,
kandangs, kandangs,
flocks,
cycles, cycles,
selectedSiteId, selectedSiteId,
selectedKandangId, selectedKandangId,
@@ -155,6 +179,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
[ [
sites, sites,
kandangs, kandangs,
flocks,
cycles, cycles,
selectedSiteId, selectedSiteId,
selectedKandangId, selectedKandangId,
+30 -25
View File
@@ -1,22 +1,24 @@
name: dashboard-cpsp
services: services:
database: database:
image: postgres:16-alpine image: postgres:16-alpine
container_name: newdashboard-database container_name: dashboard-cpsp-database
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${DB_USER:-newdashboard} POSTGRES_USER: ${DB_USER:-dashboard}
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_this_password} POSTGRES_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
POSTGRES_DB: ${DB_NAME:-newdashboard} POSTGRES_DB: ${DB_NAME:-dashboard}
PGDATA: /var/lib/postgresql/data/pgdata PGDATA: /var/lib/postgresql/data/pgdata
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
networks: networks:
- newdashboard-network - dashboard-cpsp-network
healthcheck: healthcheck:
test: test:
[ [
"CMD-SHELL", "CMD-SHELL",
"pg_isready -U ${DB_USER:-newdashboard} -d ${DB_NAME:-newdashboard}", "pg_isready -U ${DB_USER:-dashboard} -d ${DB_NAME:-dashboard}",
] ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
@@ -28,28 +30,27 @@ services:
- "127.0.0.1:15432:5432" - "127.0.0.1:15432:5432"
api: api:
image: dashboard-cpsp-api
build: build:
context: . context: .
dockerfile: docker/Dockerfile.api dockerfile: docker/Dockerfile.api
container_name: newdashboard-api container_name: dashboard-cpsp-api
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:18000:8000" - "127.0.0.1:18000:8000"
environment: environment:
DEBUG: ${DEBUG:-false} 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} 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} 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_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5002,http://127.0.0.1:5002}
CORS_ALLOW_CREDENTIALS: "true" CORS_ALLOW_CREDENTIALS: "true"
DB_ENGINE: django.db.backends.postgresql DB_ENGINE: django.db.backends.postgresql
DB_NAME: ${DB_NAME:-newdashboard} DB_NAME: ${DB_NAME:-dashboard}
DB_USER: ${DB_USER:-newdashboard} DB_USER: ${DB_USER:-dashboard}
DB_PASSWORD: ${DB_PASSWORD:-change_this_password} DB_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
DB_HOST: database DB_HOST: database
DB_PORT: "5432" 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_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_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30}
KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false} 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_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}}
IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20} IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20}
IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10} 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_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: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
volumes: volumes:
@@ -71,7 +74,7 @@ services:
database: database:
condition: service_healthy condition: service_healthy
networks: networks:
- newdashboard-network - dashboard-cpsp-network
healthcheck: healthcheck:
test: test:
[ [
@@ -86,20 +89,21 @@ services:
start_period: 60s start_period: 60s
cron: cron:
image: dashboard-cpsp-cron
build: build:
context: . context: .
dockerfile: docker/Dockerfile.api dockerfile: docker/Dockerfile.api
container_name: newdashboard-cron container_name: dashboard-cpsp-cron
restart: unless-stopped restart: unless-stopped
entrypoint: ["/app/entrypoint-cron.sh"] entrypoint: ["/app/entrypoint-cron.sh"]
environment: environment:
DEBUG: ${DEBUG:-false} 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} ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend}
DB_ENGINE: django.db.backends.postgresql DB_ENGINE: django.db.backends.postgresql
DB_NAME: ${DB_NAME:-newdashboard} DB_NAME: ${DB_NAME:-dashboard}
DB_USER: ${DB_USER:-newdashboard} DB_USER: ${DB_USER:-dashboard}
DB_PASSWORD: ${DB_PASSWORD:-change_this_password} DB_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
DB_HOST: database DB_HOST: database
DB_PORT: "5432" DB_PORT: "5432"
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000} KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
@@ -120,13 +124,14 @@ services:
api: api:
condition: service_healthy condition: service_healthy
networks: networks:
- newdashboard-network - dashboard-cpsp-network
frontend: frontend:
image: dashboard-cpsp-frontend
build: build:
context: . context: .
dockerfile: docker/Dockerfile.web dockerfile: docker/Dockerfile.web
container_name: newdashboard-frontend container_name: dashboard-cpsp-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5002:80" - "5002:80"
@@ -134,7 +139,7 @@ services:
api: api:
condition: service_healthy condition: service_healthy
networks: networks:
- newdashboard-network - dashboard-cpsp-network
healthcheck: healthcheck:
test: test:
["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"] ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
@@ -144,7 +149,7 @@ services:
start_period: 10s start_period: 10s
networks: networks:
newdashboard-network: dashboard-cpsp-network:
driver: bridge driver: bridge
volumes: volumes:
+4 -3
View File
@@ -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 CSRF_TRUSTED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002
CORS_ALLOWED_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) # Production admin (bootstrap_admin — no demo data)
BOOTSTRAP_ADMIN_USER=admin BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n 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) # Optional: fixed API key for scripts (hashed at rest)
BOOTSTRAP_API_KEY= BOOTSTRAP_API_KEY=
+1
View File
@@ -2,5 +2,6 @@
set -e set -e
cd /app cd /app
python manage.py migrate --noinput python manage.py migrate --noinput
python manage.py bootstrap_admin
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
exec gunicorn config.wsgi:application -c config/gunicorn.py exec gunicorn config.wsgi:application -c config/gunicorn.py
+1 -1
View File
@@ -2,6 +2,6 @@
set -e set -e
cd /app cd /app
python manage.py migrate --noinput 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 > /etc/environment || true
exec cron -f exec cron -f
-18
View File
@@ -37,24 +37,6 @@ server {
proxy_send_timeout 1800s; 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/ { location /media/ {
proxy_pass http://api:8000; proxy_pass http://api:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
+5 -1
View File
@@ -7,6 +7,7 @@ import type {
CycleFilter, CycleFilter,
CycleWrite, CycleWrite,
Flock, Flock,
FlockWrite,
InitialBalanceResult, InitialBalanceResult,
Karung, Karung,
KarungRequestResult, KarungRequestResult,
@@ -163,8 +164,11 @@ export const api = {
remove: (id: number) => del(`/kandangs/${id}/`), remove: (id: number) => del(`/kandangs/${id}/`),
}, },
flocks: { flocks: {
list: async (query?: { cycle_id?: number }) => list: async (query?: { kandang_id?: number }) =>
unwrapList(await get<Paginated<Flock> | Flock[]>('/flocks/', query)), unwrapList(await get<Paginated<Flock> | Flock[]>('/flocks/', query)),
create: (body: FlockWrite) => post<Flock>('/flocks/', body),
update: (id: number, body: Partial<FlockWrite>) => patch<Flock>(`/flocks/${id}/`, body),
remove: (id: number) => del(`/flocks/${id}/`),
}, },
cycles: { cycles: {
list: async (query?: CycleFilter) => list: async (query?: CycleFilter) =>
+5 -4
View File
@@ -34,7 +34,7 @@ export type Kandang = {
export type Flock = { export type Flock = {
id: number; id: number;
flock_name: string; flock_name: string;
cycle: number; kandang: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -45,8 +45,6 @@ export type Cycle = {
id: number; id: number;
kandang: number; kandang: number;
kandang_name: string; kandang_name: string;
flock: number;
flock_name: string | null;
total_days: number; total_days: number;
current_day: number; current_day: number;
visible_through_date: string; visible_through_date: string;
@@ -221,9 +219,12 @@ export type KandangWrite = {
kandang_name: string; kandang_name: string;
site: number; site: number;
}; };
export type FlockWrite = {
flock_name: string;
kandang: number;
};
export type CycleWrite = { export type CycleWrite = {
kandang: number; kandang: number;
flock?: number;
total_days: number; total_days: number;
current_day?: number; current_day?: number;
start_date: string; start_date: string;
+2 -1
View File
@@ -5,9 +5,10 @@ import type { ChickenCounting, Cycle, Karung, KPI } from '../../types/api.ts';
const cycle: Cycle = { const cycle: Cycle = {
id: 1, id: 1,
kandang: 9, kandang: 9,
flock: 1, kandang_name: 'Kandang 1',
total_days: 35, total_days: 35,
current_day: 2, current_day: 2,
visible_through_date: '2026-05-02',
start_date: '2026-05-01', start_date: '2026-05-01',
end_date: '2026-06-04', end_date: '2026-06-04',
chick_in_weight: 45, chick_in_weight: 45,