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

+4 -3
View File
@@ -20,9 +20,6 @@ DB_NAME=db.sqlite3
# DB_HOST=database
# DB_PORT=5432
WAGTAIL_SITE_NAME=New Dashboard
WAGTAILADMIN_BASE_URL=http://localhost:8000
API_PAGE_SIZE=50
SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False
@@ -34,6 +31,10 @@ BOOTSTRAP_API_KEY=
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=
# Optional staff account (leave BOOTSTRAP_STAFF_USER empty to skip)
BOOTSTRAP_STAFF_USER=
BOOTSTRAP_STAFF_PASSWORD=
# karung-web-admin (external)
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
-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.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
from apps.accounts.models import User
@register_snippet
class Site(models.Model):
site_id = models.BigAutoField(primary_key=True)
site_name = models.CharField(max_length=30)
@@ -16,11 +13,6 @@ class Site(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("site_name"),
FieldPanel("user"),
]
class Meta:
db_table = "sites"
ordering = ["site_name"]
@@ -29,7 +21,6 @@ class Site(models.Model):
return self.site_name
@register_snippet
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
@@ -37,11 +28,6 @@ class Kandang(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang_name"),
FieldPanel("site"),
]
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
@@ -50,23 +36,17 @@ class Kandang(models.Model):
return self.kandang_name
@register_snippet
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
cycle = models.ForeignKey(
"Cycle",
kandang = models.ForeignKey(
Kandang,
on_delete=models.CASCADE,
related_name="flocks",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("flock_name"),
FieldPanel("cycle"),
]
class Meta:
db_table = "flock"
ordering = ["flock_name"]
@@ -75,7 +55,6 @@ class Flock(models.Model):
return self.flock_name
@register_snippet
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_CLOSED = "closed"
@@ -98,17 +77,6 @@ class Cycle(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang"),
FieldPanel("total_days"),
FieldPanel("start_date"),
FieldPanel("end_date"),
FieldPanel("doc_in_weight"),
FieldPanel("doc_in_count"),
FieldPanel("feed_initial_balance"),
FieldPanel("status"),
]
class Meta:
db_table = "cycles"
ordering = ["-start_date"]
+1 -24
View File
@@ -21,15 +21,13 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "cycle", "created_at", "updated_at"]
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
kandang_name = serializers.CharField(source="kandang.kandang_name", read_only=True)
flock = serializers.SerializerMethodField()
flock_name = serializers.SerializerMethodField()
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
visible_through_date = serializers.DateField(read_only=True)
@@ -40,8 +38,6 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"id",
"kandang",
"kandang_name",
"flock",
"flock_name",
"total_days",
"current_day",
"visible_through_date",
@@ -57,26 +53,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
read_only_fields = [
"id",
"kandang_name",
"flock",
"flock_name",
"feed_initial_balance",
"created_at",
"updated_at",
]
def _first_flock(self, obj):
return obj.flocks.order_by("flock_id").first()
def get_flock(self, obj):
flock = self._first_flock(obj)
return flock.pk if flock is not None else None
def get_flock_name(self, obj):
flock = self._first_flock(obj)
return flock.flock_name if flock is not None else None
def create(self, validated_data):
kandang = validated_data["kandang"]
cycle = Cycle.objects.create(**validated_data)
Flock.objects.create(flock_name=kandang.kandang_name[:30], cycle=cycle)
return cycle
+52 -51
View File
@@ -10,12 +10,37 @@ from apps.farms.models import Cycle, Flock, Kandang, Site
class FarmAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
self.admin = User.objects.create_user(
user_name="admin",
password="secret",
status=User.STATUS_SUPERADMIN,
)
self.client = APIClient()
self.client.force_authenticate(self.user)
self.site = Site.objects.create(site_name="Sukawarna", user=self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
def test_create_cycle_auto_creates_flock_and_returns_erd_fields(self):
def test_create_flock_under_kandang_requires_superadmin(self):
self.client.force_authenticate(self.admin)
response = self.client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["flock_name"], "Lantai 1")
self.assertEqual(response.data["kandang"], self.kandang.pk)
def test_staff_cannot_create_flock(self):
self.client.force_authenticate(self.user)
response = self.client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
format="json",
)
self.assertEqual(response.status_code, 403, response.data)
def test_create_cycle_without_flock(self):
self.client.force_authenticate(self.user)
start = date.today()
response = self.client.post(
"/api/v1/cycles/",
@@ -31,56 +56,32 @@ class FarmAPITests(TestCase):
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["chick_in_weight"], 42)
self.assertEqual(response.data["kandang_name"], "Kandang 1")
self.assertEqual(response.data["flock_name"], "Kandang 1")
self.assertIsNotNone(response.data["flock"])
cycle = Cycle.objects.get(pk=response.data["id"])
flock = Flock.objects.get(pk=response.data["flock"])
self.assertEqual(cycle.doc_in_weight, 42)
self.assertEqual(flock.cycle_id, cycle.pk)
self.assertNotIn("flock", response.data)
self.assertNotIn("flock_name", response.data)
def test_list_cycles_by_kandang(self):
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
response = self.client.get("/api/v1/cycles/", {"kandang_id": self.kandang.pk})
self.assertEqual(response.status_code, 200)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["flock"], flock.pk)
self.assertEqual(results[0]["kandang_name"], "Kandang 1")
self.assertEqual(results[0]["flock_name"], "Flock A")
def test_flock_persists_across_cycles(self):
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
start = date.today()
for offset in (0, 40):
Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start + timedelta(days=offset),
end_date=start + timedelta(days=offset + 34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1)
self.assertEqual(flock.kandang_id, self.kandang.pk)
def test_list_flocks_by_cycle(self):
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
other_cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
Flock.objects.create(flock_name="Flock B", cycle=other_cycle)
response = self.client.get("/api/v1/flocks/", {"cycle_id": cycle.pk})
def test_list_flocks_by_kandang(self):
self.client.force_authenticate(self.user)
Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang)
other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site)
Flock.objects.create(flock_name="Other", kandang=other_kandang)
response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual([r["id"] for r in results], [flock.pk])
self.assertEqual(len(results), 2)
+6 -5
View File
@@ -56,19 +56,20 @@ class KandangViewSet(viewsets.ModelViewSet):
class FlockViewSet(viewsets.ModelViewSet):
queryset = Flock.objects.all()
queryset = Flock.objects.select_related("kandang").all()
serializer_class = FlockSerializer
permission_classes = [IsSuperAdminForWrite]
def get_queryset(self):
qs = super().get_queryset()
cycle_id = self.request.query_params.get("cycle_id")
if cycle_id:
qs = qs.filter(cycle_id=cycle_id)
kandang_id = self.request.query_params.get("kandang_id")
if kandang_id:
qs = qs.filter(kandang_id=kandang_id)
return qs
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related("kandang").prefetch_related("flocks").all()
queryset = Cycle.objects.select_related("kandang").all()
serializer_class = CycleSerializer
def get_queryset(self):
@@ -5,62 +5,119 @@ from django.db import transaction
from apps.accounts.models import ApiKey, User
def ensure_bootstrap_user(
*,
user_name: str,
password: str,
status: str,
is_staff: bool,
is_superuser: bool,
force_password: bool,
stdout,
) -> User:
user, created = User.objects.get_or_create(
user_name=user_name,
defaults={
"status": status,
"is_staff": is_staff,
"is_superuser": is_superuser,
},
)
if not created and user.status != status:
raise CommandError(
f"User {user_name!r} already exists with status {user.status!r}; "
f"cannot bootstrap as {status!r}"
)
should_set_password = created or not user.has_usable_password() or force_password
if should_set_password:
user.set_password(password)
user.status = status
user.is_staff = is_staff
user.is_superuser = is_superuser
user.save()
action = "Created" if created else "Updated password for"
stdout.write(f"{action} {status} user {user_name}")
else:
user.status = status
user.is_staff = is_staff
user.is_superuser = is_superuser
user.save(update_fields=["status", "is_staff", "is_superuser", "updated_at"])
stdout.write(f"User {user_name} already exists (password unchanged)")
return user
class Command(BaseCommand):
help = (
"Create production superadmin login only (no demo sites/cycles/data). "
"Uses BOOTSTRAP_ADMIN_USER and BOOTSTRAP_ADMIN_PASSWORD from settings."
"Create production login accounts only (no demo sites/cycles/data). "
"Uses BOOTSTRAP_ADMIN_* and optional BOOTSTRAP_STAFF_* from settings."
)
def add_arguments(self, parser):
parser.add_argument(
"--force-password",
action="store_true",
help="Reset password even when the admin user already has one",
help="Reset password even when the user already has one",
)
@transaction.atomic
def handle(self, *args, **options):
user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not password:
force_password = options["force_password"]
admin_user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
admin_password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not admin_password:
raise CommandError(
"BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin."
)
user, created = User.objects.get_or_create(
user_name=user_name,
defaults={
"status": User.STATUS_SUPERADMIN,
"is_staff": True,
"is_superuser": True,
},
admin = ensure_bootstrap_user(
user_name=admin_user_name,
password=admin_password,
status=User.STATUS_SUPERADMIN,
is_staff=True,
is_superuser=True,
force_password=force_password,
stdout=self.stdout,
)
should_set_password = created or not user.has_usable_password() or options["force_password"]
if should_set_password:
user.set_password(password)
user.status = User.STATUS_SUPERADMIN
user.is_staff = True
user.is_superuser = True
user.save()
action = "Created" if created else "Updated password for"
self.stdout.write(f"{action} superadmin {user_name}")
else:
user.status = User.STATUS_SUPERADMIN
user.is_superuser = True
user.save(update_fields=["status", "is_superuser", "updated_at"])
self.stdout.write(f"Superadmin {user_name} already exists (password unchanged)")
staff_user_name = (settings.BOOTSTRAP_STAFF_USER or "").strip()
staff_password = settings.BOOTSTRAP_STAFF_PASSWORD or ""
if staff_user_name:
if staff_user_name == admin_user_name:
raise CommandError(
"BOOTSTRAP_STAFF_USER must differ from BOOTSTRAP_ADMIN_USER."
)
if not staff_password:
raise CommandError(
"BOOTSTRAP_STAFF_PASSWORD is required when BOOTSTRAP_STAFF_USER is set."
)
ensure_bootstrap_user(
user_name=staff_user_name,
password=staff_password,
status=User.STATUS_ACTIVE,
is_staff=True,
is_superuser=False,
force_password=force_password,
stdout=self.stdout,
)
bootstrap_key = settings.BOOTSTRAP_API_KEY
if bootstrap_key:
api_key, _ = ApiKey.generate(user, "bootstrap")
api_key, _ = ApiKey.generate(admin, "bootstrap")
api_key.prefix = bootstrap_key[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap_key)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
accounts = admin_user_name
if staff_user_name:
accounts = f"{admin_user_name}, {staff_user_name}"
self.stdout.write(
self.style.SUCCESS(
f"Bootstrap OK: login={user_name} (no demo farm data — use Pengaturan to add sites/kandang/cycles)"
f"Bootstrap OK: login={accounts} "
"(no demo farm data — use Pengaturan to add sites/kandang/cycles)"
)
)
@@ -133,7 +133,7 @@ class Command(BaseCommand):
real_by_date = self.load_real_rows(Path(iot_json))
cycles = Cycle.objects.filter(
start_date=cycle_start, flocks__isnull=False
start_date=cycle_start, kandang__flocks__isnull=False
).distinct()
if not cycles:
raise CommandError(f"No cycles starting {cycle_start} with flocks")
@@ -141,7 +141,7 @@ class Command(BaseCommand):
seeded = 0
for cycle in cycles:
kandang_name = cycle.kandang.kandang_name
for flock in cycle.flocks.order_by("flock_id"):
for flock in cycle.kandang.flocks.order_by("flock_id"):
profile = self.profile_for(kandang_name, flock.flock_name)
rng = random.Random(options["jitter_seed"] + flock.pk)
rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng)
@@ -206,8 +206,11 @@ class Command(BaseCommand):
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(flock__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(
flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 1 cycle")
@@ -232,7 +235,7 @@ class Command(BaseCommand):
flocks_by_name: dict[str, Flock] = {}
for name in FLOCK_NAMES:
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
flocks_by_name[name] = flock
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
@@ -426,7 +429,7 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(cycle=cycle).count()} "
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} "
@@ -96,8 +96,11 @@ class Command(BaseCommand):
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(flock__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(
flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 2 cycle")
@@ -122,7 +125,7 @@ class Command(BaseCommand):
flocks_by_name: dict[str, Flock] = {}
for name in ("Lantai 1", "Lantai 2"):
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
flocks_by_name[name] = flock
cc_by_date = {}
@@ -288,7 +291,7 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(cycle=cycle).count()} "
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}"
+30
View File
@@ -9,6 +9,8 @@ from django.core.management import call_command
@override_settings(
BOOTSTRAP_ADMIN_USER="admin",
BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n",
BOOTSTRAP_STAFF_USER="",
BOOTSTRAP_STAFF_PASSWORD="",
)
class BootstrapAdminTests(TestCase):
def test_creates_superadmin_without_demo_data(self):
@@ -23,3 +25,31 @@ class BootstrapAdminTests(TestCase):
with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
@override_settings(
BOOTSTRAP_STAFF_USER="staff",
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
)
def test_creates_staff_when_configured(self):
call_command("bootstrap_admin")
staff = User.objects.get(user_name="staff")
self.assertEqual(staff.status, User.STATUS_ACTIVE)
self.assertTrue(staff.is_staff)
self.assertFalse(staff.is_superuser)
self.assertTrue(staff.check_password("St4ff!"))
@override_settings(
BOOTSTRAP_STAFF_USER="staff",
BOOTSTRAP_STAFF_PASSWORD="",
)
def test_requires_staff_password_when_user_set(self):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
@override_settings(
BOOTSTRAP_STAFF_USER="admin",
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
)
def test_rejects_staff_same_as_admin(self):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
-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 wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
from apps.farms.models import Cycle, Flock
@register_snippet
class IotPanel(models.Model):
panel_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -19,8 +16,6 @@ class IotPanel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("flock"), FieldPanel("date"), FieldPanel("average_temperature")]
class Meta:
db_table = "iot_panel"
ordering = ["-date"]
@@ -31,7 +26,6 @@ class IotPanel(models.Model):
]
@register_snippet
class AIInsight(models.Model):
date = models.DateField()
insight_text = models.TextField()
@@ -42,14 +36,11 @@ class AIInsight(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("insight_text"), FieldPanel("alert")]
class Meta:
db_table = "ai_insight"
ordering = ["-date"]
@register_snippet
class FeedSacks(models.Model):
fs_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -63,8 +54,6 @@ class FeedSacks(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("in_today"), FieldPanel("out_today")]
class Meta:
db_table = "feed_sacks"
ordering = ["-date"]
@@ -74,7 +63,6 @@ class FeedSacks(models.Model):
]
@register_snippet
class ChickenCounting(models.Model):
cc_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -84,8 +72,6 @@ class ChickenCounting(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("total_count")]
class Meta:
db_table = "chicken_counting"
ordering = ["-date"]
@@ -94,7 +80,6 @@ class ChickenCounting(models.Model):
]
@register_snippet
class ChickenWeight(models.Model):
cw_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -108,13 +93,6 @@ class ChickenWeight(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("cycle"),
FieldPanel("date"),
FieldPanel("average_weight"),
FieldPanel("uniformity"),
]
class Meta:
db_table = "chicken_weight"
ordering = ["-date"]
@@ -123,7 +101,6 @@ class ChickenWeight(models.Model):
]
@register_snippet
class ManualInput(models.Model):
manual_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -149,8 +126,6 @@ class ManualInput(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age_manual"), FieldPanel("fcr_manual")]
class Meta:
db_table = "manual_input"
ordering = ["-date"]
@@ -159,7 +134,6 @@ class ManualInput(models.Model):
]
@register_snippet
class KPI(models.Model):
kpi_id = models.BigAutoField(primary_key=True)
date = models.DateField()
@@ -187,8 +161,6 @@ class KPI(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age"), FieldPanel("fcr"), FieldPanel("eef")]
class Meta:
db_table = "kpi"
ordering = ["-date"]
+2 -3
View File
@@ -19,7 +19,7 @@ class CycleContextMixin(serializers.Serializer):
flock_id = serializers.SerializerMethodField()
def get_flock_id(self, obj):
flock = obj.cycle.flocks.order_by("flock_id").first()
flock = obj.cycle.kandang.flocks.order_by("flock_id").first()
return flock.pk if flock is not None else None
@@ -124,8 +124,7 @@ class IotPanelSerializer(PkAsIdMixin, serializers.ModelSerializer):
read_only_fields = ["id", "created_at", "updated_at"]
def get_kandang(self, obj):
cycle = getattr(obj.flock, "cycle", None)
return cycle.kandang_id if cycle is not None else None
return obj.flock.kandang_id
class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
+12 -3
View File
@@ -11,7 +11,7 @@ import httpx
from django.conf import settings
from django.utils import timezone
from apps.farms.models import Flock
from apps.farms.models import Cycle, Flock
from apps.operations.models import IotPanel
WIB = ZoneInfo("Asia/Jakarta")
@@ -199,7 +199,7 @@ def sync_iot_panels(
django_flocks: dict[str, Flock] = {}
for external_id, django_id in flock_map.items():
try:
django_flocks[str(external_id)] = Flock.objects.select_related("cycle").get(pk=int(django_id))
django_flocks[str(external_id)] = Flock.objects.select_related("kandang").get(pk=int(django_id))
except Flock.DoesNotExist:
continue
@@ -236,7 +236,16 @@ def sync_iot_panels(
continue
snapshot_date = fetched_at.astimezone(WIB).date()
age = cycle_age_for_date(flock.cycle.start_date, snapshot_date)
cycle = (
Cycle.objects.filter(
kandang_id=flock.kandang_id,
start_date__lte=snapshot_date,
end_date__gte=snapshot_date,
)
.order_by("-start_date")
.first()
)
age = cycle_age_for_date(cycle.start_date, snapshot_date) if cycle else 1
fields = panel_fields_from_payload(payload_data, age)
IotPanel.objects.update_or_create(
+3 -3
View File
@@ -26,7 +26,7 @@ class OperationsAPITests(TestCase):
doc_in_count=10000,
status=Cycle.STATUS_ACTIVE,
)
self.flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
self.flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang)
def test_karungs_alias_lists_feed_sacks_with_date(self):
FeedSacks.objects.create(
@@ -65,7 +65,7 @@ class OperationsAPITests(TestCase):
self.assertEqual(results[0]["date"], date.today().isoformat())
def test_panel_iot_filters_by_flock(self):
flock_b = Flock.objects.create(flock_name="Flock B", cycle=self.cycle)
flock_b = Flock.objects.create(flock_name="Flock B", kandang=self.kandang)
row = IotPanel.objects.create(
flock=self.flock,
date=date.today(),
@@ -379,7 +379,7 @@ class DashboardVisibilityTests(TestCase):
self.assertEqual(response.data["date"], "2026-08-28")
def test_panel_iot_not_hidden_before_publish_time(self):
flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang)
IotPanel.objects.create(
flock=flock,
date=date(2026, 8, 28),
+13 -4
View File
@@ -53,7 +53,7 @@ class CycleScopedViewSet(VisibilityFilteredMixin, viewsets.ModelViewSet):
if params.get("cycle_id"):
qs = qs.filter(cycle_id=params["cycle_id"])
if params.get("flock_id"):
qs = qs.filter(cycle__flocks__flock_id=params["flock_id"]).distinct()
qs = qs.filter(cycle__kandang__flocks=params["flock_id"]).distinct()
if params.get("date"):
qs = qs.filter(date=params["date"])
return self.filter_visible_through(qs)
@@ -143,14 +143,23 @@ class FeedSacksViewSet(CycleScopedViewSet):
class IotPanelViewSet(viewsets.ModelViewSet):
"""IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff."""
queryset = IotPanel.objects.select_related("flock", "flock__cycle").all()
queryset = IotPanel.objects.select_related("flock", "flock__kandang").all()
serializer_class = IotPanelSerializer
def get_queryset(self):
qs = super().get_queryset()
params = self.request.query_params
if params.get("cycle_id"):
qs = qs.filter(flock__cycle_id=params["cycle_id"])
cycle_id = params.get("cycle_id")
if cycle_id:
try:
cycle = Cycle.objects.get(pk=cycle_id)
except Cycle.DoesNotExist:
return qs.none()
qs = qs.filter(
flock__kandang_id=cycle.kandang_id,
date__gte=cycle.start_date,
date__lte=cycle.end_date,
)
if params.get("flock_id"):
qs = qs.filter(flock_id=params["flock_id"])
if params.get("date"):
+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
@@ -42,24 +42,6 @@ ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS", "http://localhost:8000")
INSTALLED_APPS = [
"apps.accounts",
"apps.farms",
"apps.operations",
"apps.jobs",
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
"wagtail.search",
"wagtail.admin",
"wagtail",
"modelcluster",
"taggit",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
@@ -68,6 +50,10 @@ INSTALLED_APPS = [
"rest_framework",
"corsheaders",
"drf_spectacular",
"apps.accounts",
"apps.farms",
"apps.operations",
"apps.jobs",
]
MIDDLEWARE = [
@@ -79,7 +65,6 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
ROOT_URLCONF = "config.urls"
@@ -142,10 +127,6 @@ MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WAGTAIL_SITE_NAME = env("WAGTAIL_SITE_NAME", "New Dashboard")
WAGTAILADMIN_BASE_URL = env("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
WAGTAIL_APPEND_SLASH = False
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS", "http://localhost:3000")
CORS_ALLOW_CREDENTIALS = env_bool("CORS_ALLOW_CREDENTIALS", True)
@@ -177,6 +158,8 @@ BOOTSTRAP_API_KEY = env("BOOTSTRAP_API_KEY", "") or ""
BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin"
BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or ""
BOOTSTRAP_STAFF_USER = env("BOOTSTRAP_STAFF_USER", "") or ""
BOOTSTRAP_STAFF_PASSWORD = env("BOOTSTRAP_STAFF_PASSWORD", "") or ""
KARUNG_WEB_ADMIN_BASE_URL = (
env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or ""
-8
View File
@@ -1,16 +1,9 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
urlpatterns = [
path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/docs/",
@@ -20,7 +13,6 @@ urlpatterns = [
path("api/v1/", include("apps.accounts.urls")),
path("api/v1/", include("apps.farms.urls")),
path("api/v1/", include("apps.operations.urls")),
path("", include(wagtail_urls)),
]
if settings.DEBUG:
-2
View File
@@ -1,5 +1,4 @@
Django>=5.1,<5.3
wagtail>=6.3,<7.0
djangorestframework>=3.15,<4.0
drf-spectacular>=0.27,<1.0
django-cors-headers>=4.4,<5.0
@@ -7,5 +6,4 @@ python-dotenv>=1.0,<2.0
gunicorn>=23.0,<24.0
psycopg[binary]>=3.2,<4.0
httpx>=0.27,<1.0
Pillow>=10.0,<12.0
openpyxl>=3.1,<4.0