first commit

This commit is contained in:
Alberto-Audrix committed 2026-08-26 13:46:05 +07:00
commit 367533c02b
171 files changed
+101175

No files matched your search

View File
Whitespace-only changes.
View File
Whitespace-only changes.
+33
View File
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
label = "accounts"
verbose_name = "Accounts"
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from django.utils.translation import gettext_lazy as _
from rest_framework import authentication, exceptions
from apps.accounts.models import ApiKey
class ApiKeyAuthentication(authentication.BaseAuthentication):
"""Authenticate via X-API-Key header (hashed lookup)."""
keyword = "X-API-Key"
def authenticate(self, request):
raw_key = request.headers.get(self.keyword) or request.META.get("HTTP_X_API_KEY")
if not raw_key:
return None
if len(raw_key) < 8:
raise exceptions.AuthenticationFailed(_("Invalid API key"))
prefix = raw_key[:8]
candidates = ApiKey.objects.select_related("user").filter(
prefix=prefix, is_active=True, user__is_active=True
)
for api_key in candidates:
if api_key.verify(raw_key):
api_key.touch()
return (api_key.user, api_key)
raise exceptions.AuthenticationFailed(_("Invalid API key"))
def authenticate_header(self, request):
return self.keyword
@@ -0,0 +1,55 @@
# Generated by Django 5.2.17 on 2026-08-10 08:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('user_name', models.CharField(max_length=30, unique=True)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'db_table': 'user_access',
'ordering': ['user_name'],
},
),
migrations.CreateModel(
name='ApiKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('prefix', models.CharField(db_index=True, max_length=12)),
('key_hash', models.CharField(max_length=64)),
('is_active', models.BooleanField(default=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'api_keys',
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name="user",
old_name="id",
new_name="user_id",
),
]
@@ -0,0 +1,39 @@
from django.db import migrations, models
def backfill_user_status(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.is_superuser:
status = "superadmin"
flags = {"is_active": True, "is_staff": True, "is_superuser": True}
elif not user.is_active:
status = "inactive"
flags = {"is_active": False, "is_staff": False, "is_superuser": False}
else:
status = "active"
flags = {"is_active": True, "is_staff": False, "is_superuser": False}
User.objects.filter(pk=user.pk).update(status=status, **flags)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_erd_named_primary_keys"),
]
operations = [
migrations.AddField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("active", "Active"),
("inactive", "Inactive"),
("superadmin", "Super Admin"),
],
default="active",
max_length=30,
),
),
migrations.RunPython(backfill_user_status, migrations.RunPython.noop),
]
@@ -0,0 +1,29 @@
from django.db import migrations
def sync_status_flags(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.status == "inactive":
user.is_active = False
user.is_staff = False
user.is_superuser = False
elif user.status == "superadmin":
user.is_active = True
user.is_staff = False
user.is_superuser = True
else:
user.is_active = True
user.is_superuser = False
user.save(update_fields=["is_active", "is_staff", "is_superuser", "updated_at"])
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_user_status"),
]
operations = [
migrations.RunPython(sync_status_flags, migrations.RunPython.noop),
]
Whitespace-only changes.
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import hashlib
import secrets
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone
class UserManager(BaseUserManager):
def create_user(self, user_name: str, password: str | None = None, **extra_fields):
if not user_name:
raise ValueError("user_name is required")
extra_fields.setdefault("status", User.STATUS_ACTIVE)
user = self.model(user_name=user_name, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, user_name: str, password: str | None = None, **extra_fields):
extra_fields["status"] = User.STATUS_SUPERADMIN
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
return self.create_user(user_name, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
"""ERD User Access — username field is user_name."""
STATUS_ACTIVE = "active"
STATUS_INACTIVE = "inactive"
STATUS_SUPERADMIN = "superadmin"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_INACTIVE, "Inactive"),
(STATUS_SUPERADMIN, "Super Admin"),
]
user_id = models.BigAutoField(primary_key=True)
user_name = models.CharField(max_length=30, unique=True)
status = models.CharField(max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
USERNAME_FIELD = "user_name"
REQUIRED_FIELDS: list[str] = []
class Meta:
db_table = "user_access"
ordering = ["user_name"]
def __str__(self) -> str:
return self.user_name
def save(self, *args, **kwargs):
if self.status == self.STATUS_INACTIVE:
self.is_active = False
self.is_staff = False
self.is_superuser = False
elif self.status == self.STATUS_SUPERADMIN:
self.is_active = True
self.is_staff = False
self.is_superuser = True
else:
self.is_active = True
self.is_superuser = False
# is_staff is left untouched for active users, so an operator can be
# explicitly flagged as staff without being a superadmin.
super().save(*args, **kwargs)
class ApiKey(models.Model):
"""Hashed API keys for X-API-Key authentication."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
name = models.CharField(max_length=100)
prefix = models.CharField(max_length=12, db_index=True)
key_hash = models.CharField(max_length=64)
is_active = models.BooleanField(default=True)
last_used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "api_keys"
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.name} ({self.prefix}…)"
@staticmethod
def hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
@classmethod
def generate(cls, user: User, name: str) -> tuple[ApiKey, str]:
raw = secrets.token_urlsafe(32)
prefix = raw[:8]
instance = cls.objects.create(
user=user,
name=name,
prefix=prefix,
key_hash=cls.hash_key(raw),
)
return instance, raw
def verify(self, raw_key: str) -> bool:
return secrets.compare_digest(self.key_hash, self.hash_key(raw_key))
def touch(self) -> None:
self.last_used_at = timezone.now()
self.save(update_fields=["last_used_at", "updated_at"])
+45
View File
@@ -0,0 +1,45 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from apps.accounts.models import ApiKey
User = get_user_model()
class PkAsIdMixin(serializers.Serializer):
"""Keep JSON `id` stable after ERD-named primary keys."""
id = serializers.IntegerField(source="pk", read_only=True)
class UserSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "user_name", "status", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class LoginSerializer(serializers.Serializer):
user_name = serializers.CharField(max_length=30)
password = serializers.CharField(write_only=True)
class ApiKeySerializer(serializers.ModelSerializer):
class Meta:
model = ApiKey
fields = [
"id",
"name",
"prefix",
"is_active",
"last_used_at",
"created_at",
"updated_at",
"user",
]
read_only_fields = fields
class ApiKeyCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100)
user_id = serializers.IntegerField(required=False)
+23
View File
@@ -0,0 +1,23 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.accounts.views import (
ApiKeyViewSet,
CsrfView,
LoginView,
LogoutView,
MeView,
UserViewSet,
)
router = DefaultRouter()
router.register("users", UserViewSet, basename="user")
router.register("api-keys", ApiKeyViewSet, basename="api-key")
urlpatterns = [
path("auth/csrf/", CsrfView.as_view(), name="auth-csrf"),
path("auth/login/", LoginView.as_view(), name="auth-login"),
path("auth/logout/", LogoutView.as_view(), name="auth-logout"),
path("auth/me/", MeView.as_view(), name="auth-me"),
*router.urls,
]
+117
View File
@@ -0,0 +1,117 @@
from django.contrib.auth import authenticate, login, logout
from django.middleware.csrf import get_token
from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils.decorators import method_decorator
from rest_framework import permissions, status, viewsets
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.models import ApiKey, User
from apps.accounts.serializers import (
ApiKeyCreateSerializer,
ApiKeySerializer,
LoginSerializer,
UserSerializer,
)
class IsOwnerOrStaff(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.user.is_staff or request.user.is_superuser:
return True
return getattr(obj, "user_id", None) == request.user.pk or obj == request.user
class IsAdminUser(permissions.IsAdminUser):
"""Admin access for staff OR superadmin (superadmins may have is_staff=False)."""
def has_permission(self, request, view):
return bool(request.user and (request.user.is_staff or request.user.is_superuser))
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by("user_name")
serializer_class = UserSerializer
permission_classes = [IsAdminUser]
class ApiKeyViewSet(viewsets.ModelViewSet):
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
qs = ApiKey.objects.select_related("user").order_by("-created_at")
if self.request.user.is_staff or self.request.user.is_superuser:
return qs
return qs.filter(user=self.request.user)
def get_serializer_class(self):
if self.action == "create":
return ApiKeyCreateSerializer
return ApiKeySerializer
def get_permissions(self):
return [permissions.IsAuthenticated()]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
name = serializer.validated_data["name"]
user = request.user
if (request.user.is_staff or request.user.is_superuser) and serializer.validated_data.get(
"user_id"
):
user = User.objects.get(pk=serializer.validated_data["user_id"])
api_key, raw = ApiKey.generate(user=user, name=name)
data = ApiKeySerializer(api_key).data
data["raw_key"] = raw
return Response(data, status=status.HTTP_201_CREATED)
def perform_destroy(self, instance):
if (
not self.request.user.is_staff
and not self.request.user.is_superuser
and instance.user_id != self.request.user.pk
):
raise permissions.PermissionDenied()
instance.delete()
@method_decorator(ensure_csrf_cookie, name="dispatch")
class CsrfView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
def get(self, request):
return Response({"csrfToken": get_token(request)})
class LoginView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
def post(self, request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["user_name"],
password=serializer.validated_data["password"],
)
if user is None:
return Response(
{"detail": "Invalid credentials"},
status=status.HTTP_401_UNAUTHORIZED,
)
login(request, user)
return Response(UserSerializer(user).data)
class LogoutView(APIView):
def post(self, request):
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
class MeView(APIView):
def get(self, request):
return Response(UserSerializer(request.user).data)
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
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)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class FarmsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.farms"
label = "farms"
verbose_name = "Farms"
@@ -0,0 +1,91 @@
# Generated by Django 5.2.17 on 2026-08-18 08:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('total_days', models.IntegerField()),
('start_date', models.DateField()),
('end_date', models.DateField()),
('doc_in_weight', models.IntegerField()),
('doc_in_count', models.IntegerField()),
('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed')], default='active', max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'cycles',
'ordering': ['-start_date'],
},
),
migrations.CreateModel(
name='Flock',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('flock_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='flock_set', to='farms.cycle')),
],
options={
'db_table': 'flock',
'ordering': ['flock_name'],
},
),
migrations.AddField(
model_name='cycle',
name='flock',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cycles', to='farms.flock'),
),
migrations.CreateModel(
name='Site',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('site_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sites', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'sites',
'ordering': ['site_name'],
},
),
migrations.CreateModel(
name='Kandang',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kandang_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kandangs', to='farms.site')),
],
options={
'db_table': 'kandang',
'ordering': ['kandang_name'],
},
),
migrations.AddField(
model_name='cycle',
name='kandang',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycles', to='farms.kandang'),
),
migrations.AddConstraint(
model_name='cycle',
constraint=models.CheckConstraint(condition=models.Q(('end_date__gte', models.F('start_date'))), name='chk_cycles_date_range'),
),
]
@@ -0,0 +1,81 @@
# Generated by Django 5.2.17 on 2026-08-19
import django.db.models.deletion
from django.db import migrations, models
def _column_names(connection, table):
with connection.cursor() as cursor:
if connection.vendor == "sqlite":
cursor.execute(f"PRAGMA table_info({table})")
return {row[1] for row in cursor.fetchall()}
cursor.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s
""",
[table],
)
return {row[0] for row in cursor.fetchall()}
def ensure_cycle_kandang_id(apps, schema_editor):
connection = schema_editor.connection
if "kandang_id" in _column_names(connection, "cycles"):
return
with connection.cursor() as cursor:
cursor.execute(
"ALTER TABLE cycles ADD COLUMN kandang_id bigint NULL REFERENCES kandang(id)"
)
def backfill_cycle_flocks(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Flock = apps.get_model("farms", "Flock")
for cycle_id in Cycle.objects.filter(flock_id__isnull=True).values_list("pk", flat=True):
flock = Flock.objects.create(flock_name=f"Cycle {cycle_id}")
Cycle.objects.filter(pk=cycle_id).update(flock_id=flock.pk)
Flock.objects.filter(pk=flock.pk).update(cycle_id=cycle_id)
class Migration(migrations.Migration):
dependencies = [
("farms", "0001_initial"),
]
operations = [
migrations.RunPython(ensure_cycle_kandang_id, migrations.RunPython.noop),
migrations.RunPython(backfill_cycle_flocks, migrations.RunPython.noop),
migrations.AlterField(
model_name="cycle",
name="flock",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cycles",
to="farms.flock",
),
),
migrations.AlterField(
model_name="cycle",
name="status",
field=models.CharField(
choices=[("active", "Active"), ("closed", "Closed")],
db_index=True,
default="active",
max_length=30,
),
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flock_set",
to="farms.cycle",
),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 5.2.17 on 2026-08-19
from django.db import migrations
def _sqlite_flock_cycle_notnull(connection):
with connection.cursor() as cursor:
cursor.execute("PRAGMA table_info(flock)")
cols = {row[1]: row for row in cursor.fetchall()}
cycle_col = cols.get("cycle_id")
return bool(cycle_col and cycle_col[3] == 1)
def make_flock_cycle_nullable(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
if not _sqlite_flock_cycle_notnull(connection):
return
with connection.cursor() as cursor:
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("ALTER TABLE flock RENAME TO flock_old")
cursor.execute(
"""
CREATE TABLE flock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flock_name varchar(30) NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
cycle_id bigint NULL REFERENCES cycles(id)
)
"""
)
cursor.execute(
"""
INSERT INTO flock (id, flock_name, created_at, updated_at, cycle_id)
SELECT id, flock_name, created_at, updated_at, cycle_id FROM flock_old
"""
)
cursor.execute("DROP TABLE flock_old")
cursor.execute("PRAGMA foreign_keys=ON")
class Migration(migrations.Migration):
dependencies = [
("farms", "0002_cycle_requires_flock"),
]
operations = [
migrations.RunPython(make_flock_cycle_nullable, migrations.RunPython.noop),
]
@@ -0,0 +1,59 @@
# Generated by Django 5.2.17 on 2026-08-19
from django.db import migrations
def retarget_cycles_flock_fk(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
with connection.cursor() as cursor:
cursor.execute("SELECT sql FROM sqlite_master WHERE name='cycles'")
row = cursor.fetchone()
if not row or "flock_old" not in (row[0] or ""):
return
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("ALTER TABLE cycles RENAME TO cycles_old")
cursor.execute(
"""
CREATE TABLE cycles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
total_days integer NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
doc_in_weight integer NOT NULL,
doc_in_count integer NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
flock_id bigint NOT NULL REFERENCES flock(id) DEFERRABLE INITIALLY DEFERRED,
kandang_id bigint NOT NULL REFERENCES kandang(id) DEFERRABLE INITIALLY DEFERRED,
status varchar(30) NOT NULL,
CONSTRAINT chk_cycles_date_range CHECK (end_date >= start_date)
)
"""
)
cursor.execute(
"""
INSERT INTO cycles (
id, total_days, start_date, end_date, doc_in_weight, doc_in_count,
created_at, updated_at, flock_id, kandang_id, status
)
SELECT
id, total_days, start_date, end_date, doc_in_weight, doc_in_count,
created_at, updated_at, flock_id, kandang_id, status
FROM cycles_old
"""
)
cursor.execute("DROP TABLE cycles_old")
cursor.execute("PRAGMA foreign_keys=ON")
class Migration(migrations.Migration):
dependencies = [
("farms", "0003_flock_cycle_nullable"),
]
operations = [
migrations.RunPython(retarget_cycles_flock_fk, migrations.RunPython.noop),
]
@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_erd_named_primary_keys"),
("farms", "0004_retarget_cycles_flock_fk"),
]
operations = [
migrations.RenameField(model_name="site", old_name="id", new_name="site_id"),
migrations.RenameField(model_name="kandang", old_name="id", new_name="kandang_id"),
migrations.RenameField(model_name="flock", old_name="id", new_name="flock_id"),
migrations.RenameField(model_name="cycle", old_name="id", new_name="cycle_id"),
]
@@ -0,0 +1,40 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_cycle_from_cycles(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Flock = apps.get_model("farms", "Flock")
for cycle in Cycle.objects.exclude(flock_id__isnull=True).iterator():
Flock.objects.filter(pk=cycle.flock_id).update(cycle_id=cycle.pk)
Flock.objects.filter(cycle_id__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("farms", "0005_erd_named_primary_keys"),
]
operations = [
migrations.RunPython(backfill_flock_cycle_from_cycles, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.cycle",
),
),
migrations.RemoveField(
model_name="cycle",
name="flock",
),
migrations.AlterField(
model_name="cycle",
name="doc_in_weight",
field=models.FloatField(),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0006_erd_flock_belongs_to_cycle"),
]
operations = [
migrations.AlterField(
model_name="cycle",
name="doc_in_weight",
field=models.IntegerField(),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0007_cycle_doc_in_weight_int"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance",
field=models.IntegerField(default=0),
),
]
Whitespace-only changes.
+135
View File
@@ -0,0 +1,135 @@
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)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites")
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"]
def __str__(self) -> str:
return self.site_name
@register_snippet
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
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"]
def __str__(self) -> str:
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",
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"]
def __str__(self) -> str:
return self.flock_name
@register_snippet
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_CLOSED = "closed"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_CLOSED, "Closed"),
]
cycle_id = models.BigAutoField(primary_key=True)
total_days = models.IntegerField()
start_date = models.DateField()
end_date = models.DateField()
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
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"]
constraints = [
models.CheckConstraint(
check=models.Q(end_date__gte=models.F("start_date")),
name="chk_cycles_date_range",
)
]
def __str__(self) -> str:
return f"Cycle #{self.pk} ({self.status})"
def clean(self) -> None:
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError({"end_date": "Must be on or after start_date"})
@property
def current_day(self) -> int:
from django.utils import timezone
today = timezone.localdate()
if today <= self.start_date:
return 1
elapsed = (today - self.start_date).days + 1
return min(max(elapsed, 1), self.total_days)
+61
View File
@@ -0,0 +1,61 @@
from rest_framework import serializers
from apps.accounts.serializers import PkAsIdMixin
from apps.farms.models import Cycle, Flock, Kandang, Site
class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Site
fields = ["id", "site_name", "user", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Kandang
fields = ["id", "kandang_name", "site", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "cycle", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
flock = serializers.SerializerMethodField()
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
class Meta:
model = Cycle
fields = [
"id",
"kandang",
"flock",
"total_days",
"current_day",
"start_date",
"end_date",
"chick_in_weight",
"doc_in_count",
"feed_initial_balance",
"status",
"created_at",
"updated_at",
]
read_only_fields = ["id", "flock", "feed_initial_balance", "created_at", "updated_at"]
def get_flock(self, obj):
flock = obj.flocks.order_by("flock_id").first()
return flock.pk 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
+82
View File
@@ -0,0 +1,82 @@
from datetime import date, timedelta
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
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.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):
start = date.today()
response = self.client.post(
"/api/v1/cycles/",
{
"kandang": self.kandang.pk,
"total_days": 35,
"start_date": start.isoformat(),
"end_date": (start + timedelta(days=34)).isoformat(),
"chick_in_weight": 42,
"doc_in_count": 10000,
"status": "active",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["chick_in_weight"], 42)
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):
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)
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})
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])
+11
View File
@@ -0,0 +1,11 @@
from rest_framework.routers import DefaultRouter
from apps.farms.views import CycleViewSet, FlockViewSet, KandangViewSet, SiteViewSet
router = DefaultRouter()
router.register("sites", SiteViewSet, basename="site")
router.register("kandangs", KandangViewSet, basename="kandang")
router.register("flocks", FlockViewSet, basename="flock")
router.register("cycles", CycleViewSet, basename="cycle")
urlpatterns = router.urls
+172
View File
@@ -0,0 +1,172 @@
from datetime import date, timedelta
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.operations.models import FeedSacks, ManualInput
from apps.operations.services.karung_web import KarungWebError, request_karung
from apps.operations.services.manual_input import recompute_manual_totals
class IsSuperAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated user; mutations only for superadmin.
Staff accounts may read sites/kandangs (to pick one and create a cycle) but
cannot create, edit, or delete them — only superadmins manage structures.
"""
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if request.method in permissions.SAFE_METHODS:
return True
return bool(request.user.is_superuser or request.user.status == User.STATUS_SUPERADMIN)
class SiteViewSet(viewsets.ModelViewSet):
queryset = Site.objects.select_related("user").all()
serializer_class = SiteSerializer
permission_classes = [IsSuperAdminForWrite]
def get_queryset(self):
qs = super().get_queryset()
user_id = self.request.query_params.get("user_id")
if user_id:
qs = qs.filter(user_id=user_id)
return qs
class KandangViewSet(viewsets.ModelViewSet):
queryset = Kandang.objects.select_related("site").all()
serializer_class = KandangSerializer
permission_classes = [IsSuperAdminForWrite]
def get_queryset(self):
qs = super().get_queryset()
site_id = self.request.query_params.get("site_id")
if site_id:
qs = qs.filter(site_id=site_id)
return qs
class FlockViewSet(viewsets.ModelViewSet):
queryset = Flock.objects.all()
serializer_class = FlockSerializer
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)
return qs
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related("kandang").prefetch_related("flocks").all()
serializer_class = CycleSerializer
def get_queryset(self):
qs = super().get_queryset()
params = self.request.query_params
if params.get("kandang_id"):
qs = qs.filter(kandang_id=params["kandang_id"])
if params.get("status"):
qs = qs.filter(status=params["status"])
return qs
@action(detail=True, methods=["post"], url_path="initial-balance")
def set_initial_balance(self, request, pk=None):
"""Save the feed-in balance for the cycle (Saldo Awal).
Sets ``feed_initial_balance`` on the cycle (the only cycle field this
feature writes — never during cycle creation), records the manual feed-in
for the balance date, then pulls the IoT-detected "in" for that date from
the karung-web-admin feed and returns a manual vs IoT accuracy figure.
"""
cycle = self.get_object()
date_str = request.data.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
feed_in_manual = int(request.data.get("feed_in_manual"))
except (TypeError, ValueError):
return Response(
{"detail": "feed_in_manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
if feed_in_manual < 0:
return Response(
{"detail": "feed_in_manual cannot be negative."},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.feed_initial_balance = feed_in_manual
cycle.save(update_fields=["feed_initial_balance", "updated_at"])
# Pull IoT "in" at the balance date (karung-web-admin syncs today/yesterday).
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
sync_error = None
if feed_sack is None:
try:
today = date.today()
section = None
if balance_date == today:
section = "today"
elif balance_date == today - timedelta(days=1):
section = "yesterday_history"
if section:
request_karung(cycle, section=section)
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
except (KarungWebError, ValidationError) as exc:
sync_error = str(exc)
iot_in = feed_sack.in_today if feed_sack is not None else None
# Record the manual feed-in for the balance date.
ManualInput.objects.update_or_create(
cycle=cycle,
date=balance_date,
defaults={"feed_in_manual": feed_in_manual},
)
recompute_manual_totals(cycle)
# FeedSacks in_total always includes the initial balance as its base.
running = cycle.feed_initial_balance
for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"):
running += fs_row.in_today
if fs_row.in_total != running:
fs_row.in_total = running
fs_row.save(update_fields=["in_total", "updated_at"])
accuracy = None
if feed_in_manual > 0 and iot_in is not None and iot_in > 0:
accuracy = round(min(feed_in_manual, iot_in) / max(feed_in_manual, iot_in) * 1000) / 10
elif feed_in_manual == 0 and iot_in == 0:
accuracy = 100.0
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"manual": feed_in_manual,
"manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual,
"iot": iot_in,
"accuracy": accuracy,
"sync_error": sync_error,
}
)
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class JobsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.jobs"
label = "jobs"
verbose_name = "Jobs"
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,11 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
class Command(BaseCommand):
help = "Placeholder — advance_cycle_days no longer applicable (current_day removed from ERD)"
def handle(self, *args, **options):
self.stdout.write("current_day field removed; this command is a no-op now.")
@@ -0,0 +1,53 @@
from django.core.management.base import BaseCommand
from django.db.models import Sum
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting, FeedSacks, KPI
class Command(BaseCommand):
help = "Recompute simple KPI rollups from ops tables for active cycles"
def handle(self, *args, **options):
count = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
counting = ChickenCounting.objects.filter(cycle=cycle).aggregate(
mortality=Sum("mortality_count"),
life=Sum("total_count"),
)
feed_sack = FeedSacks.objects.filter(cycle=cycle).order_by("-date", "-pk").first()
latest_cc = ChickenCounting.objects.filter(cycle=cycle).order_by("-date").first()
latest_cw = None
latest_fs = feed_sack
latest_mi = None
from apps.operations.models import ChickenWeight, ManualInput
latest_cw = ChickenWeight.objects.filter(cycle=cycle).order_by("-date").first()
latest_mi = ManualInput.objects.filter(cycle=cycle).order_by("-date").first()
if not all([latest_cc, latest_cw, latest_fs, latest_mi]):
self.stdout.write(f"cycle={cycle.pk} skipped (missing source records)")
continue
kpi, _ = KPI.objects.update_or_create(
cycle=cycle,
date=latest_cc.date,
defaults={
"cc": latest_cc,
"cw": latest_cw,
"fs": latest_fs,
"manual": latest_mi,
"mortality_total": counting["mortality"] or 0,
"chicken_life": counting["life"] or cycle.doc_in_count,
"feed_total": feed_sack.feed_use_total if feed_sack else 0,
"chicken_life_percentage": (
((counting["life"] or cycle.doc_in_count) / cycle.doc_in_count * 100.0)
if cycle.doc_in_count
else 0.0
),
},
)
count += 1
self.stdout.write(f"KPI {kpi.pk} for cycle {cycle.pk}")
self.stdout.write(self.style.SUCCESS(f"Recomputed {count} KPI row(s)"))
@@ -0,0 +1,48 @@
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.accounts.models import ApiKey, User
class Command(BaseCommand):
help = (
"Seed demo superadmin login + API key, then Sukawarna Kandang 4 "
"and Kandang 5 as the default closed cycles"
)
@transaction.atomic
def handle(self, *args, **options):
user, created = User.objects.get_or_create(
user_name="admin",
defaults={"status": User.STATUS_SUPERADMIN, "is_staff": True, "is_superuser": True},
)
if created or not user.has_usable_password():
user.set_password("admin123")
user.status = User.STATUS_SUPERADMIN
user.save()
self.stdout.write("Created/updated user admin / admin123")
raw_key = None
bootstrap = settings.BOOTSTRAP_API_KEY
if bootstrap:
api_key, raw_key = ApiKey.generate(user, "bootstrap")
api_key.prefix = bootstrap[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
raw_key = bootstrap
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
elif not ApiKey.objects.filter(user=user).exists():
api_key, raw_key = ApiKey.generate(user, "seed")
self.stdout.write(self.style.WARNING(f"Demo API key (save now): {raw_key}"))
call_command("seed_sukawarna_kandang4")
call_command("seed_sukawarna_kandang5")
call_command("seed_iot_10min")
self.stdout.write(
self.style.SUCCESS(
"Seed OK: Sukawarna Kandang 4 & Kandang 5 closed cycles + 10-minute IoT panels"
)
)
@@ -0,0 +1,239 @@
"""Seed 10-minute IoT panel readings (144/day) for every flock.
Kandang 5 Lantai 1 is seeded from real sensor snapshots pulled from
``dashboard.cpsp.id/api/iot/flocks/`` (cached in ``scripts/iot_panel_10min_extract.json``);
every other flock/floor is generated from that data with a deterministic per-house
jitter so each cycle day holds a full 00:00..23:50 run of 144 readings.
Run ``scripts/extract_iot_10min.py`` to refresh the cache against the live API.
"""
from __future__ import annotations
import json
import random
from datetime import date as date_cls
from datetime import datetime, time, timedelta
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from apps.farms.models import Cycle, Flock
SLOTS_PER_DAY = 144 # 24h * 6
CHILL_FACTOR_POINTS = (
(0, 8.0),
(1, 8.0),
(7, 7.0),
(14, 6.0),
(21, 4.5),
(28, 3.5),
(35, 3.5),
(42, 3.0),
)
def chicken_chill_factor(age: float) -> float:
if age <= CHILL_FACTOR_POINTS[0][0]:
return CHILL_FACTOR_POINTS[0][1]
for idx in range(1, len(CHILL_FACTOR_POINTS)):
prev_age, prev_factor = CHILL_FACTOR_POINTS[idx - 1]
next_age, next_factor = CHILL_FACTOR_POINTS[idx]
if age > next_age:
continue
span = next_age - prev_age
if span <= 0:
return next_factor
progress = (age - prev_age) / span
return prev_factor + (next_factor - prev_factor) * progress
return CHILL_FACTOR_POINTS[-1][1]
def experience_temperature(
*, average_temperature: float, wind_speed: float, humidity_pct: float, age_days: float
) -> float:
chill_factor = chicken_chill_factor(age_days)
rh_adjustment = (humidity_pct - 70.0) / 5.0
chill_effect = wind_speed * chill_factor - rh_adjustment
return average_temperature - chill_effect
def lerp(a: float, b: float, frac: float) -> float:
return a + (b - a) * frac
def slot_timestamp(day: date_cls, slot: int) -> datetime:
naive = datetime.combine(day, time(0, 0)) + timedelta(minutes=10 * slot)
return timezone.make_aware(naive, timezone.get_current_timezone())
def gap_fill_day(day_rows: dict[int, dict]) -> dict[int, dict]:
"""Return 144 slots for a day, interpolating any missing 10-minute windows."""
present = sorted(day_rows.items())
filled: dict[int, dict] = {}
for slot in range(SLOTS_PER_DAY):
if slot in day_rows:
filled[slot] = dict(day_rows[slot])
continue
prev = next = None
for s, row in present:
if s < slot:
prev = (s, row)
elif s > slot and next is None:
next = (s, row)
break
if prev is None and next is not None:
filled[slot] = dict(next[1])
elif next is None and prev is not None:
filled[slot] = dict(prev[1])
elif prev is not None and next is not None:
ps, prow = prev
ns, nrow = next
frac = (slot - ps) / (ns - ps)
filled[slot] = {
"wind_speed": lerp(prow["wind_speed"], nrow["wind_speed"], frac),
"humidity": lerp(prow["humidity"], nrow["humidity"], frac),
"water_total": lerp(prow["water_total"], nrow["water_total"], frac),
"average_temperature": lerp(
prow["average_temperature"], nrow["average_temperature"], frac
),
}
return filled
class Command(BaseCommand):
help = "Seed 10-minute IoT panel readings (144/day) for all flocks"
def add_arguments(self, parser):
parser.add_argument("--iot-json", default="")
parser.add_argument("--cycle-start", default="2026-05-22")
parser.add_argument("--no-reset", action="store_true")
parser.add_argument("--jitter-seed", type=int, default=7)
def load_real_rows(self, path: Path) -> dict[str, dict[int, dict]]:
if not path.is_file():
raise CommandError(f"IoT 10-min extract not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
by_date: dict[str, dict[int, dict]] = {}
for row in data["rows"]:
ts = datetime.fromisoformat(row["timestamp"])
slot = (ts.hour * 60 + ts.minute) // 10
by_date.setdefault(row["date"], {})[slot] = row
return by_date
@transaction.atomic
def handle(self, *args, **options):
iot_json = options["iot_json"] or str(
Path(settings.BASE_DIR).resolve().parent / "scripts" / "iot_panel_10min_extract.json"
)
cycle_start = date_cls.fromisoformat(options["cycle_start"])
real_by_date = self.load_real_rows(Path(iot_json))
cycles = Cycle.objects.filter(
start_date=cycle_start, flocks__isnull=False
).distinct()
if not cycles:
raise CommandError(f"No cycles starting {cycle_start} with flocks")
seeded = 0
for cycle in cycles:
kandang_name = cycle.kandang.kandang_name
for flock in cycle.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)
if not options["no_reset"]:
flock.iot_panels.all().delete()
for chunk in range(0, len(rows), 500):
flock.iot_panels.bulk_create(rows[chunk : chunk + 500])
seeded += len(rows)
self.stdout.write(
f" {kandang_name} / {flock.flock_name}: {len(rows)} readings"
)
self.stdout.write(self.style.SUCCESS(f"Seeded {seeded} IoT panel readings"))
def profile_for(self, kandang_name: str, flock_name: str) -> dict:
"""Per-house transform applied to the real Kandang 5 Lantai 1 readings."""
is_lantai1 = flock_name == "Lantai 1"
if kandang_name == "Kandang 5":
if is_lantai1:
return {"real": True}
return {
"wind_delta": -0.05,
"humidity_delta": 1.2,
"temperature_delta": -0.3,
"water_factor": 0.96,
}
# Kandang 4: deterministic jitter (same approach as seed_sukawarna_kandang4).
return {
"temperature_jitter": (-0.4, 0.4),
"humidity_jitter": (-3.0, 3.0),
"wind_jitter": (-0.1, 0.1),
"water_factor": 0.9,
"water_jitter_abs": (-120.0, 120.0),
"humidity_delta": 1.2 if not is_lantai1 else 0.0,
"temperature_delta": -0.3 if not is_lantai1 else 0.0,
}
def build_flock_rows(self, flock: Flock, cycle: Cycle, real: dict, profile: dict, rng: random.Random) -> list:
from apps.operations.models import IotPanel
day = cycle.start_date
rows: list[IotPanel] = []
while day <= cycle.end_date:
iso = day.isoformat()
age_days = (day - cycle.start_date).days + 1
if profile.get("real"):
source = gap_fill_day(real.get(iso, {}))
else:
source = gap_fill_day(real.get(iso, {}))
if not source:
day += timedelta(days=1)
continue
real_total = source.get(SLOTS_PER_DAY - 1, {}).get("water_total", 0.0) or 0.0
target_total = real_total * profile.get("water_factor", 1.0)
if profile.get("water_jitter_abs"):
target_total += rng.uniform(*profile["water_jitter_abs"])
target_total = max(0.0, target_total)
water_scale = target_total / real_total if real_total else 0.0
for slot in range(SLOTS_PER_DAY):
base = source[slot]
avg_temp = base.get("average_temperature", 0.0) + profile.get("temperature_delta", 0.0)
if profile.get("temperature_jitter"):
avg_temp += rng.uniform(*profile["temperature_jitter"])
humidity = base.get("humidity", 0.0) + profile.get("humidity_delta", 0.0)
if profile.get("humidity_jitter"):
humidity += rng.uniform(*profile["humidity_jitter"])
humidity = min(100.0, max(0.0, humidity))
wind = base.get("wind_speed", 0.0) + profile.get("wind_delta", 0.0)
if profile.get("wind_jitter"):
wind += rng.uniform(*profile["wind_jitter"])
wind = max(0.0, wind)
water = base.get("water_total", 0.0) * water_scale
exp_temp = experience_temperature(
average_temperature=avg_temp,
wind_speed=wind,
humidity_pct=humidity,
age_days=age_days,
)
rows.append(
IotPanel(
flock=flock,
date=day,
timestamp=slot_timestamp(day, slot),
wind_speed=round(wind, 2),
humidity=round(humidity, 1),
water_total=round(water, 1),
average_temperature=round(avg_temp, 2),
experience_temperature=round(exp_temp, 2),
)
)
day += timedelta(days=1)
return rows
@@ -0,0 +1,435 @@
"""Seed Sukawarna / Kandang 4 closed cycle.
Kandang 4 is a generated dataset modeled on the Kandang 5 seed
(``seed_sukawarna_kandang5``). The two houses run the same 49-day window at
the same site, but Kandang 4 is stocked with 45,000 DOC and performs slightly
better (lower mortality, marginally better FCR / EEF).
Every daily series is hand-tuned so the numbers stay internally consistent:
* ``chicken_counting`` – daily mortality sums to the cumulative total; camera
count tracks live population (DOC - mortality - harvest) with coverage noise.
* ``chicken_weight`` – smooth growth curve; weighing continues through
close-out (day 38-48), flock average declines as heavy birds are harvested.
* ``feed_sacks`` – daily use ramps up to ~98 sacks and falls off during
close-out; total use (1,941 sacks) matches close-out FCR ≈ 1.71.
* ``harvest`` – 20 partial harvests day 29-48, 40,700 birds,
57.47 t live weight, avg ~1,412 g/bird at ~33.1 days; tail harvests leave
fewer than 50 birds in the house at close-out.
* ``kpi`` / ``manual`` – FCR/EEF/lot-weight follow the same formulas as the
Kandang 5 preview:
FCR = feed_total_kg / (harvest_weight_total_g + lot_weight_g)
EEF = life_pct * (avg_harvest_weight/1000) / (FCR * avg_harvest_day) * 100
lot = chicken_life * average_weight
* ``iot_panel`` – derived from the real Kandang 5 sensor extract with
deterministic per-house jitter (same site, same season).
"""
from __future__ import annotations
import json
import math
import random
from datetime import date, timedelta
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.jobs.sukawarna_preview import IOT_EXTRACT_JSON
from apps.operations.models import (
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
DEFAULT_PASSWORD = "staff123"
START_DATE = date(2026, 5, 22)
END_DATE = date(2026, 7, 9)
TOTAL_DAYS = 49
DOC_IN_WEIGHT = 35
DOC_IN_COUNT = 45_000
FLOCK_NAMES = ("Lantai 1", "Lantai 2")
# --- Daily mortality per age 0..48 (sums to 4,269 ≈ 9.49 % deplesi) ---------
MORTALITY = [
42, 58, 74, 78, 68, 62, 60,
52, 54, 47, 50, 44, 46, 48,
42, 45, 47, 50, 52, 48, 46,
44, 47, 42, 50, 58, 65, 72,
95, 120, 150, 175, 200, 185, 215,
240, 255, 235, 220, 140, 115, 100,
85, 60, 65, 50, 45, 28, 0,
]
# --- Flock average weight (g) per age 0..48 ---------------------------------
# Weighing continues through close-out (day 38-48). As heavy birds are
# harvested first, the remaining flock average weight declines.
AVG_WEIGHT = [
35, 47, 62, 80, 100, 123, 148,
175, 204, 235, 268, 303, 340, 379,
420, 463, 508, 555, 604, 655, 708,
763, 820, 879, 940, 1003, 1068, 1135,
1204, 1275, 1348, 1423, 1500, 1570, 1530,
1470, 1420, 1360, 1310, 1270, 1230, 1220,
1200, 1180, 1160, 1140, 1120, 1100, 1080,
]
# --- Weigh-sample size per age 0..48 (declines with live flock during close-out) ---
SAMPLE_COUNT = [
229, 214, 250, 230, 260, 240, 220,
280, 210, 250, 230, 260, 240, 220,
270, 250, 230, 260, 240, 220, 280,
250, 230, 270, 240, 220, 260, 240,
280, 250, 230, 270, 250, 230, 260,
240, 220, 250, 180, 150, 120, 100,
80, 60, 50, 40, 30, 20, 15,
]
# --- Uniformity (%) per age 0..48 --------------------------------------------
UNIFORMITY = [
0, 45, 62, 70, 66, 74, 71,
78, 69, 73, 81, 68, 75, 72,
70, 77, 74, 69, 72, 76, 71,
73, 68, 74, 70, 72, 66, 69,
73, 67, 71, 69, 72, 68, 65,
70, 67, 63, 62, 61, 60, 59,
58, 57, 56, 55, 54, 53, 52,
]
# --- Feed used (sacks, 50 kg each) per age 0..48; sums to 1,941 --------------
FEED_TODAY = [
24, 11, 14, 22, 24, 27, 31,
34, 38, 40, 42, 36, 38, 41,
44, 48, 59, 66, 74, 85, 89,
90, 57, 98, 92, 84, 72, 79,
80, 71, 71, 65, 57, 24, 29,
14, 13, 12, 8, 3, 3, 5, 8, 0, 8, 2, 4, 4, 1,
]
# --- Feed delivered (sacks) on delivery days (age -> sacks) ------------------
# Cumulative intake (1,910 sacks) ends just below usage, a small stock draw.
FEED_IN = {
1: 120, 4: 130, 7: 140, 10: 150, 13: 160, 16: 170, 19: 150,
22: 140, 25: 130, 28: 120, 31: 110, 34: 100, 37: 90, 40: 80, 43: 120,
}
# --- Karung keluar (out) per age 0..48: only during the final week, small ---
# "Out" is NOT the same as usage (tuang). It only happens at the very end of the
# cycle and totals ~20 sacks per house, with a handful of sacks per day.
OUT_TODAY = [0] * 42 + [3, 4, 5, 3, 2, 2, 1]
# --- Harvest plan: age -> (birds, avg live weight g/bird) --------------------
# 20 partial harvests day 29-48; the tail harvests clear out the house so the
# leftover flock (chicken_life) at close-out stays under 50 birds.
HARVEST = {
29: (4_500, 1_310),
30: (5_000, 1_360),
31: (5_000, 1_430),
32: (5_000, 1_490),
33: (5_000, 1_540),
34: (4_500, 1_500),
35: (3_500, 1_440),
36: (2_500, 1_390),
37: (2_000, 1_330),
38: (1_500, 1_280),
39: (1_000, 1_240),
40: (500, 1_200),
41: (120, 1_190),
42: (100, 1_170),
43: (90, 1_150),
44: (80, 1_130),
45: (90, 1_110),
46: (80, 1_090),
47: (80, 1_070),
48: (60, 1_050),
}
def _round(value: float, ndigits: int = 2) -> float:
if math.isclose(value, 0.0, abs_tol=1e-12):
return 0.0
return round(value, ndigits)
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 4 closed cycle (generated, modeled on Kandang 5)"
def add_arguments(self, parser):
parser.add_argument("--password", default=DEFAULT_PASSWORD)
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
parser.add_argument("--no-reset", action="store_true")
parser.add_argument("--jitter-seed", type=int, default=7)
@transaction.atomic
def handle(self, *args, **options):
iot_path = Path(options["iot_json"])
if not iot_path.is_file():
raise CommandError(f"IoT extract not found: {iot_path}")
iot = json.loads(iot_path.read_text(encoding="utf-8"))
password = options["password"]
rng = random.Random(options["jitter_seed"])
user, created = User.objects.get_or_create(
user_name="staff",
defaults={"status": User.STATUS_ACTIVE, "is_staff": True},
)
user.status = User.STATUS_ACTIVE
user.is_staff = True
user.set_password(password)
user.save()
if created:
self.stdout.write(f"Created user staff / {password}")
else:
self.stdout.write(f"Updated password for staff / {password}")
site, _ = Site.objects.get_or_create(site_name="Sukawarna", defaults={"user": user})
if site.user_id != user.pk:
site.user = user
site.save(update_fields=["user", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 4",
site=site,
)
existing = Cycle.objects.filter(kandang=kandang, start_date=START_DATE).first()
if existing and not options["no_reset"]:
KPI.objects.filter(cycle=existing).delete()
ChickenCounting.objects.filter(cycle=existing).delete()
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()
existing.delete()
self.stdout.write("Reset previous Kandang 4 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
start_date=START_DATE,
defaults={
"end_date": END_DATE,
"total_days": TOTAL_DAYS,
"doc_in_weight": DOC_IN_WEIGHT,
"doc_in_count": DOC_IN_COUNT,
"status": Cycle.STATUS_CLOSED,
},
)
if not cycle_created:
cycle.end_date = END_DATE
cycle.total_days = TOTAL_DAYS
cycle.doc_in_weight = DOC_IN_WEIGHT
cycle.doc_in_count = DOC_IN_COUNT
cycle.status = Cycle.STATUS_CLOSED
cycle.save()
flocks_by_name: dict[str, Flock] = {}
for name in FLOCK_NAMES:
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
flocks_by_name[name] = flock
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
# --- cumulative series -------------------------------------------------
mort_total = 0
harvest_total = 0
harvest_weight_total = 0.0
feed_total = 0
feed_in_total = 0
out_total = 0
cc_by_date: dict[date, ChickenCounting] = {}
cw_by_date: dict[date, ChickenWeight] = {}
fs_by_date: dict[date, FeedSacks] = {}
manual_by_date: dict[date, ManualInput] = {}
harvest_day_accum = 0.0 # Σ (harvest × age)
harvest_wt_accum = 0.0 # Σ (harvest × batch avg weight)
for i, day in enumerate(dates):
age = i
live = DOC_IN_COUNT - mort_total - harvest_total
# --- chicken_counting -----------------------------------------
quality = "ok" if age < 34 else "atas_only"
coverage = 0.82 + 0.16 * math.sin(i / 1.9) - 0.011 * min(i, 36)
camera_count = max(1, int(round(live * max(coverage, 0.3))))
cc, _ = ChickenCounting.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"total_count": camera_count,
"mortality_count": MORTALITY[i],
},
)
cc_by_date[day] = cc
# --- chicken_weight --------------------------------------------
weight = AVG_WEIGHT[i]
prev_weight = AVG_WEIGHT[i - 1] if i > 0 else DOC_IN_WEIGHT
adg = _round(weight - prev_weight, 2) if weight else 0.0
cw, _ = ChickenWeight.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"doc_weight": DOC_IN_WEIGHT,
"average_weight": weight,
"chicken_count": SAMPLE_COUNT[i],
"uniformity": UNIFORMITY[i],
"average_daily_gain": adg,
},
)
cw_by_date[day] = cw
# --- feed_sacks ------------------------------------------------
feed_today = FEED_TODAY[i]
in_today = FEED_IN.get(age, 0)
out_today = OUT_TODAY[i]
feed_total += feed_today
feed_in_total += in_today
out_total += out_today
fs, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": feed_in_total,
"out_total": out_total,
"feed_use_today": feed_today,
"feed_use_total": feed_total,
},
)
fs_by_date[day] = fs
# --- harvest (manual) ------------------------------------------
harvest_today, harvest_wt_today = HARVEST.get(age, (0, 0))
if harvest_today:
harvest_total += harvest_today
harvest_weight_total += harvest_today * harvest_wt_today
harvest_day_accum += harvest_today * age
harvest_wt_accum += harvest_today * harvest_wt_today
# --- manual input ----------------------------------------------
# Running averages: flock weight before the first harvest, then the
# harvest-weighted running mean carried forward on idle days.
if harvest_today:
avg_hday = harvest_day_accum / harvest_total
avg_hwt = harvest_wt_accum / harvest_total
elif harvest_total == 0:
avg_hday = float(age)
avg_hwt = float(weight) if weight else 0.0
else:
avg_hday = harvest_day_accum / harvest_total
avg_hwt = harvest_wt_accum / harvest_total
life = DOC_IN_COUNT - mort_total - harvest_total
lot_weight_g = life * weight if weight else 0.0
denom = harvest_weight_total + lot_weight_g
fcr = (feed_total * 50_000) / denom if denom > 0 else 0.0
life_pct = (DOC_IN_COUNT - mort_total) / DOC_IN_COUNT * 100.0
if avg_hday > 0 and fcr > 0:
eef = life_pct * (avg_hwt / 1000.0) / (fcr * avg_hday) * 100.0
else:
eef = 0.0
manual, _ = ManualInput.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age_manual": age,
"mortality_manual": MORTALITY[i],
"mortality_manual_total": mort_total,
"feed_in_manual": in_today,
"feed_in_manual_total": feed_in_total,
"feed_use_manual": feed_today,
"feed_use_manual_total": feed_total,
"feed_out_manual": out_today,
"feed_out_manual_total": out_total,
"harvest_manual": harvest_today,
"harvest_manual_total": harvest_total,
"harvest_weight_manual": float(harvest_today * harvest_wt_today),
"harvest_weight_manual_total": harvest_weight_total,
"manual_weight": weight,
"average_harvest_day_manual": _round(avg_hday, 4),
"average_harvest_weight_manual": _round(avg_hwt, 4),
"fcr_manual": _round(fcr, 4),
"eef_manual": _round(eef, 4),
},
)
manual_by_date[day] = manual
# --- KPI ---------------------------------------------------------
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"mortality": MORTALITY[i],
"mortality_total": mort_total,
"feed": feed_today,
"feed_total": feed_total,
"harvest": harvest_today,
"harvest_total": harvest_total,
"harvest_weight": float(harvest_today * harvest_wt_today),
"harvest_weight_total": harvest_weight_total,
"chicken_life": life,
"chicken_life_percentage": _round(life_pct, 3),
"iot_weight": _round(lot_weight_g, 2),
"average_harvest_day": _round(avg_hday, 4),
"average_harvest_weight": _round(avg_hwt, 4),
"fcr": _round(fcr, 4),
"eef": _round(eef, 4),
"cc": cc,
"cw": cw,
"fs": fs,
"manual": manual,
},
)
mort_total += MORTALITY[i]
# --- IoT panel: Kandang 5 sensors with per-house jitter -------------
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
for row in flock_block["rows"]:
day = date.fromisoformat(row["date"])
avg_temp = float(row["average_temperature"]) + rng.uniform(-0.4, 0.4)
humidity = min(max(float(row["humidity"]) + rng.uniform(-3.0, 3.0), 0.0), 100.0)
wind = max(float(row["wind_speed"]) + rng.uniform(-0.1, 0.1), 0.0)
water = max(float(row["water_total"]) * 0.9 + rng.uniform(-120.0, 120.0), 0.0)
chill = float(row["chill_factor"])
exp_temp = avg_temp - (70.0 - humidity) / 5.0 - wind * chill
IotPanel.objects.update_or_create(
flock=flock,
date=day,
defaults={
"wind_speed": _round(wind, 2),
"humidity": _round(humidity, 1),
"water_total": _round(water, 1),
"average_temperature": _round(avg_temp, 2),
"experience_temperature": _round(exp_temp, 4),
},
)
panel_count += 1
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
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"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} "
f"closeout_eef={last_kpi.eef if last_kpi else None}"
)
)
@@ -0,0 +1,296 @@
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.jobs.sukawarna_preview import (
FEED_REFERENCE_XLSX,
IOT_EXTRACT_JSON,
PREVIEW_XLSX,
as_number,
load_feed_reference,
load_preview,
)
from apps.operations.models import (
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
DEFAULT_PASSWORD = "staff123"
# --- Karung keluar (out) per cycle day 0..48: only during the final week ----
# "Out" is NOT the same as usage (tuang). It only happens at the very end of the
# cycle and totals ~20 sacks per house, with a handful of sacks per day.
OUT_TODAY = [0] * 42 + [4, 5, 4, 3, 2, 1, 1]
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 5 closed cycle from preview xlsx + IoT extract"
def add_arguments(self, parser):
parser.add_argument("--password", default=DEFAULT_PASSWORD)
parser.add_argument("--xlsx", default=str(PREVIEW_XLSX))
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
parser.add_argument("--feed-reference", default=str(FEED_REFERENCE_XLSX))
parser.add_argument("--no-reset", action="store_true")
@transaction.atomic
def handle(self, *args, **options):
xlsx = Path(options["xlsx"])
iot_path = Path(options["iot_json"])
feed_reference_path = Path(options["feed_reference"])
if not xlsx.is_file():
raise CommandError(f"Preview not found: {xlsx}")
if not iot_path.is_file():
raise CommandError(f"IoT extract not found: {iot_path}")
sheets = load_preview(xlsx)
iot = json.loads(iot_path.read_text(encoding="utf-8"))
feed_reference = {}
if feed_reference_path.is_file():
feed_reference = load_feed_reference(feed_reference_path)
else:
self.stderr.write(f"Feed reference not found, continuing without it: {feed_reference_path}")
password = options["password"]
user, created = User.objects.get_or_create(
user_name="staff",
defaults={"status": User.STATUS_ACTIVE, "is_staff": True},
)
user.status = User.STATUS_ACTIVE
user.is_staff = True
user.set_password(password)
user.save()
if created:
self.stdout.write(f"Created user staff / {password}")
else:
self.stdout.write(f"Updated password for staff / {password}")
site, _ = Site.objects.get_or_create(site_name="Sukawarna", defaults={"user": user})
if site.user_id != user.pk:
site.user = user
site.save(update_fields=["user", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 5",
site=site,
)
start_date = date(2026, 5, 22)
end_date = date(2026, 7, 9)
existing = Cycle.objects.filter(kandang=kandang, start_date=start_date).first()
if existing and not options["no_reset"]:
KPI.objects.filter(cycle=existing).delete()
ChickenCounting.objects.filter(cycle=existing).delete()
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()
existing.delete()
self.stdout.write("Reset previous Kandang 5 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
start_date=start_date,
defaults={
"end_date": end_date,
"total_days": 49,
"doc_in_weight": 35,
"doc_in_count": 50000,
"status": Cycle.STATUS_CLOSED,
},
)
if not cycle_created:
cycle.end_date = end_date
cycle.total_days = 49
cycle.doc_in_weight = 35
cycle.doc_in_count = 50000
cycle.status = Cycle.STATUS_CLOSED
cycle.save()
flocks_by_name: dict[str, Flock] = {}
for name in ("Lantai 1", "Lantai 2"):
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
flocks_by_name[name] = flock
cc_by_date = {}
for row in sheets["chicken_counting"]:
obj, _ = ChickenCounting.objects.update_or_create(
cycle=cycle,
date=row["date"],
defaults={
"total_count": int(as_number(row.get("total_count"))),
"mortality_count": int(as_number(row.get("mortality_count"))),
},
)
cc_by_date[row["date"]] = obj
cw_by_date = {}
for row in sheets["chicken_weight"]:
obj, _ = ChickenWeight.objects.update_or_create(
cycle=cycle,
date=row["date"],
defaults={
"age": int(as_number(row.get("age"))),
"doc_weight": int(as_number(row.get("doc_weight"))),
"average_weight": float(as_number(row.get("average_weight"))),
"chicken_count": int(as_number(row.get("chicken_count"))),
"uniformity": float(as_number(row.get("uniformity"))),
"average_daily_gain": float(as_number(row.get("average_daily_gain"))),
},
)
cw_by_date[row["date"]] = obj
fs_by_date = {}
fs_in_total = 0
fs_use_total = 0
fs_out_total = 0
for row in sheets["feed_sacks"]:
day = row["date"]
ref = feed_reference.get(day.isoformat())
in_today = int(ref["iot_in"]) if ref else int(as_number(row.get("in_today")))
feed_use_today = (
int(ref["iot_use"]) if ref else int(as_number(row.get("out_today")))
)
out_today = OUT_TODAY[(day - start_date).days]
fs_in_total += in_today
fs_use_total += feed_use_today
fs_out_total += out_today
obj, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": fs_in_total,
"out_total": fs_out_total,
"feed_use_today": feed_use_today,
"feed_use_total": fs_use_total,
},
)
fs_by_date[day] = obj
manual_by_date = {}
manual_in_total = 0
manual_use_total = 0
manual_out_total = 0
for row in sheets["manual_input"]:
day = row["date"]
ref = feed_reference.get(day.isoformat())
feed_in_manual = int(ref["manual_in"]) if ref else (
int(fs_row.in_today) if (fs_row := fs_by_date.get(day)) else 0
)
feed_use_manual = int(ref["manual_use"]) if ref else int(as_number(row.get("feed_manual")))
feed_out_manual = OUT_TODAY[(day - start_date).days]
manual_in_total += feed_in_manual
manual_use_total += feed_use_manual
manual_out_total += feed_out_manual
obj, _ = ManualInput.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age_manual": int(as_number(row.get("age_manual"))),
"mortality_manual": int(as_number(row.get("mortality_manual"))),
"mortality_manual_total": int(as_number(row.get("mortality_manual_total"))),
"feed_in_manual": feed_in_manual,
"feed_in_manual_total": manual_in_total,
"feed_use_manual": feed_use_manual,
"feed_use_manual_total": manual_use_total,
"feed_out_manual": feed_out_manual,
"feed_out_manual_total": manual_out_total,
"harvest_manual": int(as_number(row.get("harvest_manual"))),
"harvest_manual_total": int(as_number(row.get("harvest_manual_total"))),
"harvest_weight_manual": float(as_number(row.get("harvest_weight_manual"))),
"harvest_weight_manual_total": float(
as_number(row.get("harvest_weight_manual_total"))
),
"manual_weight": float(
as_number(row.get("manual_weight_float") or row.get("manual_weight"))
),
"average_harvest_day_manual": float(
as_number(row.get("average_harvest_day_manual"))
),
"average_harvest_weight_manual": float(
as_number(row.get("average_harvest_weight_manual"))
),
"fcr_manual": float(as_number(row.get("fcr_manual"))),
"eef_manual": float(as_number(row.get("eef_manual"))),
},
)
manual_by_date[day] = obj
missing = []
for row in sheets["kpi_preview"]:
day = row["date"]
try:
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": int(as_number(row.get("age"))),
"mortality": int(as_number(row.get("deplesi"))),
"mortality_total": int(as_number(row.get("deplesi_total"))),
"feed": int(as_number(row.get("feed_use_manual"))),
"feed_total": int(as_number(row.get("feed_use_total_manual"))),
"harvest": int(as_number(row.get("panen"))),
"harvest_total": int(as_number(row.get("panen_total"))),
"harvest_weight": float(as_number(row.get("harvest_weight_g"))),
"harvest_weight_total": float(as_number(row.get("harvest_weight_total_g"))),
"chicken_life": int(as_number(row.get("chicken_life"))),
"chicken_life_percentage": float(as_number(row.get("chicken_life_pct"))),
"iot_weight": float(as_number(row.get("lot_weight_g"))),
"average_harvest_day": float(as_number(row.get("avg_harvest_day"))),
"average_harvest_weight": float(as_number(row.get("avg_harvest_weight_g"))),
"fcr": float(as_number(row.get("fcr"))),
"eef": float(as_number(row.get("eef"))),
"cc": cc_by_date[day],
"cw": cw_by_date[day],
"fs": fs_by_date[day],
"manual": manual_by_date[day],
},
)
except KeyError:
missing.append(str(day))
if missing:
raise CommandError(f"KPI dates missing source rows: {missing}")
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
for row in flock_block["rows"]:
day = date.fromisoformat(row["date"])
IotPanel.objects.update_or_create(
flock=flock,
date=day,
defaults={
"wind_speed": float(row["wind_speed"]),
"humidity": float(row["humidity"]),
"water_total": float(row["water_total"]),
"average_temperature": float(row["average_temperature"]),
"experience_temperature": float(row["experience_temperature"]),
},
)
panel_count += 1
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
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"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}"
)
)
@@ -0,0 +1,28 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
from apps.operations.services.karung_web import KarungWebError, request_karung
class Command(BaseCommand):
help = "Sync FeedSacks rows from karung-web-admin active sections"
def handle(self, *args, **options):
if not settings.KARUNG_WEB_ADMIN_SYNC_ENABLED:
self.stdout.write("KARUNG_WEB_ADMIN_SYNC_ENABLED=false; skipping")
return
synced = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
try:
feed_sack, summary = request_karung(cycle, section="today")
synced += 1
self.stdout.write(
f"cycle={cycle.pk} in_today={feed_sack.in_today} "
f"out_today={feed_sack.out_today} date={summary.get('snapshot_date')}"
)
except KarungWebError as exc:
self.stderr.write(f"cycle={cycle.pk} error={exc}")
except Exception as exc: # noqa: BLE001
self.stderr.write(f"cycle={cycle.pk} unexpected={exc}")
self.stdout.write(self.style.SUCCESS(f"Synced {synced} feed sack row(s)"))
+104
View File
@@ -0,0 +1,104 @@
"""Load Sukawarna / Kandang 5 preview sheets into ORM-ready row dicts."""
from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
REPO_ROOT = Path(__file__).resolve().parents[3]
PREVIEW_XLSX = REPO_ROOT / "backup" / "kandang5_sukawarna_preview.xlsx"
IOT_EXTRACT_JSON = REPO_ROOT / "scripts" / "iot_panel_cycle_extract.json"
# Final-cycle report (reference for manual feed use on Kandang 5).
FEED_REFERENCE_XLSX = (
REPO_ROOT
/ "backup"
/ "Laporan_Akhir-Siklus_Sukawarna_Kandang-Atas_Akhir-Siklus_2026-05-22_sd_2026-07-09.xlsx"
)
def as_date(value: Any) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
if len(text) >= 10 and text[0].isdigit() and text[4] == "-":
return date.fromisoformat(text[:10])
return None
def as_number(value: Any, default: float | int = 0):
if value is None or value == "":
return default
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value
try:
if isinstance(value, str) and "." not in value:
return int(value)
return float(value)
except (TypeError, ValueError):
return default
def dated_rows(ws) -> list[dict[str, Any]]:
headers = [cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))]
rows: list[dict[str, Any]] = []
for raw in ws.iter_rows(min_row=2, values_only=True):
mapped = {str(headers[i]): raw[i] for i in range(len(headers))}
parsed = as_date(mapped.get("date"))
if parsed is None:
continue
mapped["date"] = parsed
rows.append(mapped)
return rows
def load_preview(path: Path | None = None) -> dict[str, list[dict[str, Any]]]:
workbook = load_workbook(path or PREVIEW_XLSX, data_only=True)
return {
"chicken_counting": dated_rows(workbook["chicken_counting"]),
"chicken_weight": dated_rows(workbook["chicken_weight"]),
"feed_sacks": dated_rows(workbook["feed_sacks"]),
"manual_input": dated_rows(workbook["manual_input"]),
"kpi_preview": dated_rows(workbook["kpi_preview"]),
}
def load_feed_reference(path: Path | None = None) -> dict[str, dict[str, int]]:
"""Load the final-cycle report's Feed In / Feed Use sheets.
Returns ``{date: {manual_in, iot_in, manual_use, iot_use}}``. These values are
the authoritative reference for the karung "Manual" / "IOT" columns.
"""
workbook = load_workbook(path or FEED_REFERENCE_XLSX, data_only=True)
feed_in: dict[str, dict[str, int]] = {}
for raw in workbook["Feed In"].iter_rows(min_row=3, values_only=True):
parsed = as_date(raw[0])
if parsed is None:
continue
feed_in[parsed.isoformat()] = {
"manual_in": int(as_number(raw[1])),
"iot_in": int(as_number(raw[2])),
}
feed_use: dict[str, dict[str, int]] = {}
for raw in workbook["Feed Use"].iter_rows(min_row=3, values_only=True):
parsed = as_date(raw[0])
if parsed is None:
continue
feed_use[parsed.isoformat()] = {
"manual_use": int(as_number(raw[2])),
"iot_use": int(as_number(raw[3])),
}
merged: dict[str, dict[str, int]] = {}
for key in feed_in:
if key in feed_use:
merged[key] = {**feed_in[key], **feed_use[key]}
return merged
+32
View File
@@ -0,0 +1,32 @@
from datetime import date, datetime
from io import BytesIO
from django.test import SimpleTestCase
from openpyxl import Workbook
from apps.jobs.sukawarna_preview import as_date, as_number, dated_rows
class SukawarnaPreviewHelpersTests(SimpleTestCase):
def test_as_date_accepts_iso_and_datetime(self):
self.assertEqual(as_date("2026-05-22"), date(2026, 5, 22))
self.assertEqual(as_date(datetime(2026, 7, 9, 12, 0)), date(2026, 7, 9))
self.assertIsNone(as_date("grams"))
self.assertIsNone(as_date("karung tuang Jul 4-8 updated"))
def test_dated_rows_skips_note_rows(self):
wb = Workbook()
ws = wb.active
ws.append(["date", "total_count"])
ws.append(["grams", "grams"])
ws.append(["2026-05-22", 13980])
bio = BytesIO()
wb.save(bio)
bio.seek(0)
from openpyxl import load_workbook
loaded = load_workbook(bio, data_only=True).active
rows = dated_rows(loaded)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["date"], date(2026, 5, 22))
self.assertEqual(as_number(rows[0]["total_count"]), 13980)
View File
Whitespace-only changes.
+19
View File
@@ -0,0 +1,19 @@
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)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class OperationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.operations"
label = "operations"
verbose_name = "Operations"
@@ -0,0 +1,199 @@
# Generated by Django 5.2.17 on 2026-08-18 08:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('farms', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AIInsight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('insight_text', models.TextField()),
('alert', models.CharField(max_length=100)),
('section', models.CharField(max_length=100)),
('session', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_insights', to='farms.cycle')),
],
options={
'db_table': 'ai_insight',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ChickenCounting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('total_count', models.IntegerField(default=0)),
('mortality_count', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chicken_countings', to='farms.cycle')),
],
options={
'db_table': 'chicken_counting',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ChickenWeight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age', models.IntegerField()),
('doc_weight', models.IntegerField()),
('average_weight', models.FloatField()),
('chicken_count', models.IntegerField()),
('uniformity', models.FloatField()),
('average_daily_gain', models.FloatField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chicken_weights', to='farms.cycle')),
],
options={
'db_table': 'chicken_weight',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='FeedSacks',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('in_today', models.IntegerField(default=0)),
('out_today', models.IntegerField(default=0)),
('in_total', models.IntegerField(default=0)),
('out_total', models.IntegerField(default=0)),
('feed_use_today', models.IntegerField(default=0)),
('feed_use_total', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_sacks', to='farms.cycle')),
],
options={
'verbose_name_plural': 'Feed sacks',
'db_table': 'feed_sacks',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='IotPanel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('wind_speed', models.FloatField(default=0.0)),
('humidity', models.FloatField(default=0.0)),
('water_total', models.FloatField(default=0.0)),
('average_temperature', models.FloatField(default=0.0)),
('experience_temperature', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('flock', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='iot_panels', to='farms.flock')),
],
options={
'verbose_name': 'IoT Panel',
'verbose_name_plural': 'IoT Panels',
'db_table': 'iot_panel',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ManualInput',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age_manual', models.IntegerField(default=0)),
('mortality_manual', models.IntegerField(default=0)),
('mortality_manual_total', models.IntegerField(default=0)),
('feed_manual', models.IntegerField(default=0)),
('feed_manual_total', models.IntegerField(default=0)),
('harvest_manual', models.IntegerField(default=0)),
('harvest_manual_total', models.IntegerField(default=0)),
('harvest_weight_manual', models.IntegerField(default=0)),
('harvest_weight_manual_total', models.IntegerField(default=0)),
('manual_weight_float', models.FloatField(default=0.0)),
('average_harvest_day_manual', models.FloatField(default=0.0)),
('average_harvest_weight_manual', models.FloatField(default=0.0)),
('fcr_manual', models.FloatField(default=0.0)),
('eef_manual', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='manual_inputs', to='farms.cycle')),
],
options={
'db_table': 'manual_input',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='KPI',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age', models.IntegerField(default=0)),
('mortality', models.IntegerField(default=0)),
('mortality_total', models.IntegerField(default=0)),
('feed', models.IntegerField(default=0)),
('feed_total', models.IntegerField(default=0)),
('harvest', models.IntegerField(default=0)),
('harvest_total', models.IntegerField(default=0)),
('harvest_weight', models.IntegerField(default=0)),
('harvest_weight_total', models.IntegerField(default=0)),
('chicken_life', models.IntegerField(default=0)),
('chicken_life_percentage', models.FloatField(default=0.0)),
('lot_weight', models.FloatField(default=0.0)),
('average_harvest_day', models.FloatField(default=0.0)),
('average_harvest_weight', models.FloatField(default=0.0)),
('fcr', models.FloatField(default=0.0)),
('eef', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cc', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.chickencounting')),
('cw', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.chickenweight')),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kpis', to='farms.cycle')),
('fs', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.feedsacks')),
('manual', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.manualinput')),
],
options={
'verbose_name_plural': 'KPIs',
'db_table': 'kpi',
'ordering': ['-date'],
},
),
migrations.AddConstraint(
model_name='chickencounting',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_chicken_counting_cycle_date'),
),
migrations.AddConstraint(
model_name='chickenweight',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_cw_cycle_date'),
),
migrations.AddConstraint(
model_name='feedsacks',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_feed_sacks_cycle_date'),
),
migrations.AddConstraint(
model_name='iotpanel',
constraint=models.UniqueConstraint(fields=('flock', 'date'), name='uniq_iot_panel_flock_date'),
),
migrations.AddConstraint(
model_name='manualinput',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_manual_input_cycle_date'),
),
migrations.AddConstraint(
model_name='kpi',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_kpi_cycle_date'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.17 on 2026-08-19 07:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('operations', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='kpi',
name='harvest_weight',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='kpi',
name='harvest_weight_total',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='manualinput',
name='harvest_weight_manual',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='manualinput',
name='harvest_weight_manual_total',
field=models.FloatField(default=0.0),
),
]
@@ -0,0 +1,18 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("farms", "0005_erd_named_primary_keys"),
("operations", "0002_harvest_weight_float"),
]
operations = [
migrations.RenameField(model_name="iotpanel", old_name="id", new_name="panel_id"),
migrations.RenameField(model_name="feedsacks", old_name="id", new_name="fs_id"),
migrations.RenameField(model_name="chickencounting", old_name="id", new_name="cc_id"),
migrations.RenameField(model_name="chickenweight", old_name="id", new_name="cw_id"),
migrations.RenameField(model_name="manualinput", old_name="id", new_name="manual_id"),
migrations.RenameField(model_name="kpi", old_name="id", new_name="kpi_id"),
]
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0003_erd_named_primary_keys"),
]
operations = [
migrations.RenameField(
model_name="manualinput",
old_name="manual_weight_float",
new_name="manual_weight",
),
migrations.RenameField(
model_name="kpi",
old_name="lot_weight",
new_name="iot_weight",
),
migrations.AlterField(
model_name="chickenweight",
name="doc_weight",
field=models.FloatField(),
),
]
@@ -0,0 +1,55 @@
from django.db import migrations, models
def backfill_feed_in_from_feed_sacks(apps, schema_editor):
ManualInput = apps.get_model("operations", "ManualInput")
FeedSacks = apps.get_model("operations", "FeedSacks")
feed_by_cycle_date = {
(fs.cycle_id, fs.date): fs
for fs in FeedSacks.objects.all().only("cycle_id", "date", "in_today", "in_total")
}
for manual in ManualInput.objects.all().only("cycle_id", "date"):
fs = feed_by_cycle_date.get((manual.cycle_id, manual.date))
if fs is None:
continue
manual.feed_in_manual = fs.in_today
manual.feed_in_manual_total = fs.in_total
manual.save(update_fields=["feed_in_manual", "feed_in_manual_total", "updated_at"])
class Migration(migrations.Migration):
dependencies = [
("operations", "0004_erd_column_updates"),
]
operations = [
migrations.RenameField(
model_name="manualinput",
old_name="feed_manual",
new_name="feed_use_manual",
),
migrations.RenameField(
model_name="manualinput",
old_name="feed_manual_total",
new_name="feed_use_manual_total",
),
migrations.AddField(
model_name="manualinput",
name="feed_in_manual",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="manualinput",
name="feed_in_manual_total",
field=models.IntegerField(default=0),
),
migrations.RunPython(backfill_feed_in_from_feed_sacks, migrations.RunPython.noop),
migrations.AlterField(
model_name="chickenweight",
name="doc_weight",
field=models.IntegerField(),
),
]
@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0007_cycle_doc_in_weight_int"),
("operations", "0005_erd_feed_and_doc_weight"),
]
operations = [
migrations.RemoveConstraint(
model_name="iotpanel",
name="uniq_iot_panel_flock_date",
),
migrations.AddField(
model_name="iotpanel",
name="timestamp",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddConstraint(
model_name="iotpanel",
constraint=models.UniqueConstraint(
fields=("flock", "timestamp"), name="uniq_iot_panel_flock_timestamp"
),
),
]
@@ -0,0 +1,29 @@
from datetime import datetime, time
from django.db import migrations
from django.utils import timezone
def backfill_timestamps(apps, schema_editor):
IotPanel = apps.get_model("operations", "IotPanel")
for row in IotPanel.objects.filter(timestamp__isnull=True):
row.timestamp = timezone.make_aware(
datetime.combine(row.date, time(0, 0)), timezone.get_current_timezone()
)
row.save(update_fields=["timestamp"])
def unset_timestamps(apps, schema_editor):
IotPanel = apps.get_model("operations", "IotPanel")
IotPanel.objects.update(timestamp=None)
class Migration(migrations.Migration):
dependencies = [
("operations", "0006_iotpanel_timestamp"),
]
operations = [
migrations.RunPython(backfill_timestamps, unset_timestamps),
]
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0007_backfill_iotpanel_timestamp"),
]
operations = [
migrations.AddField(
model_name="manualinput",
name="feed_out_manual",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="manualinput",
name="feed_out_manual_total",
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,61 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0008_cycle_feed_initial_balance"),
("operations", "0008_manualinput_feed_out_manual"),
]
operations = [
migrations.CreateModel(
name="ReportSnapshot",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
(
"report_type",
models.CharField(
choices=[("daily", "Daily"), ("end_cycle", "End Cycle")], max_length=20
),
),
("up_to_day", models.IntegerField(default=0)),
("payload", models.JSONField(default=dict)),
("generated_at", models.DateTimeField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"cycle",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="report_snapshots",
to="farms.cycle",
),
),
(
"kandang",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="report_snapshots",
to="farms.kandang",
),
),
],
options={
"verbose_name": "Report Snapshot",
"verbose_name_plural": "Report Snapshots",
"db_table": "report_snapshot",
"ordering": ["-updated_at"],
"constraints": [
models.UniqueConstraint(
fields=("cycle", "kandang", "report_type"),
name="uniq_report_snapshot_cycle_kandang_type",
)
],
},
),
]
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("operations", "0009_reportsnapshot"),
]
operations = [
migrations.DeleteModel(
name="ReportSnapshot",
),
]
Whitespace-only changes.
+198
View File
@@ -0,0 +1,198 @@
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()
timestamp = models.DateTimeField(null=True, blank=True)
wind_speed = models.FloatField(default=0.0)
humidity = models.FloatField(default=0.0)
water_total = models.FloatField(default=0.0)
average_temperature = models.FloatField(default=0.0)
experience_temperature = models.FloatField(default=0.0)
flock = models.ForeignKey(Flock, on_delete=models.CASCADE, related_name="iot_panels")
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"]
verbose_name = "IoT Panel"
verbose_name_plural = "IoT Panels"
constraints = [
models.UniqueConstraint(fields=["flock", "timestamp"], name="uniq_iot_panel_flock_timestamp")
]
@register_snippet
class AIInsight(models.Model):
date = models.DateField()
insight_text = models.TextField()
alert = models.CharField(max_length=100)
section = models.CharField(max_length=100)
session = models.CharField(max_length=100)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="ai_insights")
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()
in_today = models.IntegerField(default=0)
out_today = models.IntegerField(default=0)
in_total = models.IntegerField(default=0)
out_total = models.IntegerField(default=0)
feed_use_today = models.IntegerField(default=0)
feed_use_total = models.IntegerField(default=0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="feed_sacks")
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"]
verbose_name_plural = "Feed sacks"
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_feed_sacks_cycle_date")
]
@register_snippet
class ChickenCounting(models.Model):
cc_id = models.BigAutoField(primary_key=True)
date = models.DateField()
total_count = models.IntegerField(default=0)
mortality_count = models.IntegerField(default=0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="chicken_countings")
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"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_chicken_counting_cycle_date")
]
@register_snippet
class ChickenWeight(models.Model):
cw_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age = models.IntegerField()
doc_weight = models.IntegerField()
average_weight = models.FloatField()
chicken_count = models.IntegerField()
uniformity = models.FloatField()
average_daily_gain = models.FloatField()
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="chicken_weights")
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"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_cw_cycle_date")
]
@register_snippet
class ManualInput(models.Model):
manual_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age_manual = models.IntegerField(default=0)
mortality_manual = models.IntegerField(default=0)
mortality_manual_total = models.IntegerField(default=0)
feed_in_manual = models.IntegerField(default=0)
feed_use_manual = models.IntegerField(default=0)
feed_in_manual_total = models.IntegerField(default=0)
feed_use_manual_total = models.IntegerField(default=0)
feed_out_manual = models.IntegerField(default=0)
feed_out_manual_total = models.IntegerField(default=0)
harvest_manual = models.IntegerField(default=0)
harvest_manual_total = models.IntegerField(default=0)
harvest_weight_manual = models.FloatField(default=0.0)
harvest_weight_manual_total = models.FloatField(default=0.0)
manual_weight = models.FloatField(default=0.0)
average_harvest_day_manual = models.FloatField(default=0.0)
average_harvest_weight_manual = models.FloatField(default=0.0)
fcr_manual = models.FloatField(default=0.0)
eef_manual = models.FloatField(default=0.0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="manual_inputs")
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"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_manual_input_cycle_date")
]
@register_snippet
class KPI(models.Model):
kpi_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age = models.IntegerField(default=0)
mortality = models.IntegerField(default=0)
mortality_total = models.IntegerField(default=0)
feed = models.IntegerField(default=0)
feed_total = models.IntegerField(default=0)
harvest = models.IntegerField(default=0)
harvest_total = models.IntegerField(default=0)
harvest_weight = models.FloatField(default=0.0)
harvest_weight_total = models.FloatField(default=0.0)
chicken_life = models.IntegerField(default=0)
chicken_life_percentage = models.FloatField(default=0.0)
iot_weight = models.FloatField(default=0.0)
average_harvest_day = models.FloatField(default=0.0)
average_harvest_weight = models.FloatField(default=0.0)
fcr = models.FloatField(default=0.0)
eef = models.FloatField(default=0.0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="kpis")
cc = models.ForeignKey(ChickenCounting, on_delete=models.RESTRICT, related_name="kpis")
cw = models.ForeignKey(ChickenWeight, on_delete=models.RESTRICT, related_name="kpis")
fs = models.ForeignKey(FeedSacks, on_delete=models.RESTRICT, related_name="kpis")
manual = models.ForeignKey(ManualInput, on_delete=models.RESTRICT, related_name="kpis")
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"]
verbose_name_plural = "KPIs"
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_kpi_cycle_date")
]
+135
View File
@@ -0,0 +1,135 @@
from django.db import transaction
from rest_framework import serializers
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
from apps.accounts.serializers import PkAsIdMixin
from apps.operations.services.manual_input import recompute_manual_totals
class CycleContextMixin(serializers.Serializer):
kandang = serializers.IntegerField(source="cycle.kandang_id", read_only=True)
flock_id = serializers.SerializerMethodField()
def get_flock_id(self, obj):
flock = obj.cycle.flocks.order_by("flock_id").first()
return flock.pk if flock is not None else None
class KPISerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = KPI
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class ManualInputSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ManualInput
fields = "__all__"
read_only_fields = [
"id",
"created_at",
"updated_at",
"mortality_manual_total",
"feed_in_manual_total",
"feed_use_manual_total",
"harvest_manual_total",
"harvest_weight_manual_total",
"average_harvest_day_manual",
"average_harvest_weight_manual",
"fcr_manual",
"eef_manual",
]
validators = []
def create(self, validated_data):
return self._upsert(validated_data)
def update(self, instance, validated_data):
validated_data.setdefault("cycle", instance.cycle)
validated_data.setdefault("date", instance.date)
return self._upsert(validated_data, instance=instance)
def _upsert(self, validated_data, instance=None):
cycle = validated_data.get("cycle") or (instance.cycle if instance else None)
row_date = validated_data.get("date") or (instance.date if instance else None)
defaults = {
"age_manual": validated_data.get("age_manual", instance.age_manual if instance else 0),
"mortality_manual": validated_data.get(
"mortality_manual", instance.mortality_manual if instance else 0
),
"feed_in_manual": validated_data.get(
"feed_in_manual", instance.feed_in_manual if instance else 0
),
"feed_use_manual": validated_data.get(
"feed_use_manual", instance.feed_use_manual if instance else 0
),
"harvest_manual": validated_data.get(
"harvest_manual", instance.harvest_manual if instance else 0
),
"harvest_weight_manual": validated_data.get(
"harvest_weight_manual", instance.harvest_weight_manual if instance else 0
),
"manual_weight": validated_data.get(
"manual_weight", instance.manual_weight if instance else 0.0
),
}
with transaction.atomic():
row, _created = ManualInput.objects.update_or_create(
cycle=cycle,
date=row_date,
defaults=defaults,
)
recompute_manual_totals(cycle)
row.refresh_from_db()
return row
class ChickenCountingSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ChickenCounting
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class ChickenWeightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ChickenWeight
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class FeedSacksSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = FeedSacks
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class IotPanelSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.SerializerMethodField()
flock_id = serializers.IntegerField(read_only=True)
class Meta:
model = IotPanel
fields = "__all__"
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
class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = AIInsight
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
Whitespace-only changes.
@@ -0,0 +1,106 @@
from __future__ import annotations
from datetime import date
from typing import Any
from urllib.parse import urljoin
import httpx
from django.conf import settings
from django.core.exceptions import ValidationError
from apps.farms.models import Cycle
from apps.operations.models import FeedSacks
class KarungWebError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class KarungWebClient:
"""Outbound client for karung-web-admin — base URL from settings only."""
PATH = "/api/v1/cycles/active/sections"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.KARUNG_WEB_ADMIN_BASE_URL).rstrip("/")
self.timeout = timeout or settings.KARUNG_WEB_ADMIN_TIMEOUT_SECONDS
def fetch_active_sections(self) -> dict[str, Any]:
url = urljoin(self.base_url + "/", self.PATH.lstrip("/"))
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise KarungWebError(f"karung-web-admin request failed: {exc}") from exc
if response.status_code >= 400:
raise KarungWebError(
f"karung-web-admin HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
payload = response.json()
if not payload.get("success", True):
error = payload.get("error") or {}
raise KarungWebError(error.get("message") or "karung-web-admin returned failure")
return payload.get("data") or payload
def request_karung(
cycle: Cycle,
*,
section: str = "today",
) -> tuple[FeedSacks, dict[str, Any]]:
if section not in {"today", "yesterday_history"}:
raise ValidationError("section must be today or yesterday_history")
client = KarungWebClient()
data = client.fetch_active_sections()
sections = data.get("sections") or {}
block = sections.get(section) or {}
totals = block.get("totals") or {}
remote_cycle = data.get("cycle") or {}
in_today = int(totals.get("masuk") or 0)
out_today = int(totals.get("tuang") or 0)
feed_use_today = out_today
saldo_awal = int(remote_cycle.get("saldo_awal") or 0)
snapshot_raw = block.get("date")
snapshot_date = date.fromisoformat(str(snapshot_raw)) if snapshot_raw else date.today()
prior = (
FeedSacks.objects.filter(cycle=cycle)
.exclude(date=snapshot_date)
.order_by("-date", "-pk")
.first()
)
if prior:
in_total = prior.in_total + in_today
out_total = prior.out_total + out_today
feed_use_total = prior.feed_use_total + feed_use_today
else:
in_total = cycle.feed_initial_balance + in_today
out_total = out_today
feed_use_total = feed_use_today
feed_sack, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=snapshot_date,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": in_total,
"out_total": out_total,
"feed_use_today": feed_use_today,
"feed_use_total": feed_use_total,
},
)
summary = {
"section": section,
"snapshot_date": snapshot_date.isoformat(),
"totals": totals,
"saldo_awal": saldo_awal,
"remote_cycle": remote_cycle,
}
return feed_sack, summary
@@ -0,0 +1,37 @@
from __future__ import annotations
from apps.farms.models import Cycle
from apps.operations.models import ManualInput
DAILY_TO_TOTAL = (
("mortality_manual", "mortality_manual_total"),
("feed_in_manual", "feed_in_manual_total"),
("feed_use_manual", "feed_use_manual_total"),
("harvest_manual", "harvest_manual_total"),
("harvest_weight_manual", "harvest_weight_manual_total"),
)
SKIPPED_DERIVED_FIELDS = (
"average_harvest_day_manual",
"average_harvest_weight_manual",
"fcr_manual",
"eef_manual",
)
def recompute_manual_totals(cycle: Cycle) -> None:
running = {total: 0 for _, total in DAILY_TO_TOTAL}
rows = list(ManualInput.objects.filter(cycle=cycle).order_by("date", "pk"))
for row in rows:
changed: list[str] = []
for daily, total in DAILY_TO_TOTAL:
running[total] += getattr(row, daily) or 0
if getattr(row, total) != running[total]:
setattr(row, total, running[total])
changed.append(total)
for field in SKIPPED_DERIVED_FIELDS:
if getattr(row, field) != 0:
setattr(row, field, 0)
changed.append(field)
if changed:
row.save(update_fields=[*changed, "updated_at"])
+333
View File
@@ -0,0 +1,333 @@
from datetime import date, timedelta
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import ChickenWeight, FeedSacks, IotPanel
class OperationsAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
self.client = APIClient()
self.client.force_authenticate(self.user)
site = Site.objects.create(site_name="Sukawarna", user=self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today() - timedelta(days=2),
end_date=date.today() + timedelta(days=32),
doc_in_weight=40,
doc_in_count=10000,
status=Cycle.STATUS_ACTIVE,
)
self.flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
def test_karungs_alias_lists_feed_sacks_with_date(self):
FeedSacks.objects.create(
cycle=self.cycle,
date=date.today(),
in_today=10,
out_today=8,
feed_use_today=8,
feed_use_total=8,
)
response = self.client.get("/api/v1/karungs/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(results[0]["date"], date.today().isoformat())
self.assertEqual(results[0]["feed_use_total"], 8)
self.assertEqual(results[0]["kandang"], self.kandang.pk)
self.assertEqual(results[0]["flock_id"], self.flock.pk)
def test_karungs_latest_returns_newest_row(self):
FeedSacks.objects.create(cycle=self.cycle, date=date.today() - timedelta(days=1), out_today=1)
newest = FeedSacks.objects.create(cycle=self.cycle, date=date.today(), out_today=9)
response = self.client.get("/api/v1/karungs/latest/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["id"], newest.pk)
def test_panel_iot_accepts_cycle_id(self):
IotPanel.objects.create(
flock=self.flock,
date=date.today(),
average_temperature=28.5,
)
response = self.client.get("/api/v1/panel-iot/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
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)
row = IotPanel.objects.create(
flock=self.flock,
date=date.today(),
average_temperature=28.5,
)
IotPanel.objects.create(
flock=flock_b,
date=date.today(),
average_temperature=30.0,
)
response = self.client.get(
"/api/v1/panel-iot/",
{"cycle_id": self.cycle.pk, "flock_id": self.flock.pk},
)
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["id"], row.pk)
def test_chicken_counting_uses_date_not_cameras(self):
response = self.client.post(
"/api/v1/chicken-countings/",
{
"cycle": self.cycle.pk,
"date": date.today().isoformat(),
"total_count": 9800,
"mortality_count": 12,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["date"], date.today().isoformat())
self.assertNotIn("counting_date", response.data)
self.assertNotIn("cam1_count", response.data)
def test_weights_latest_avg_uses_date(self):
ChickenWeight.objects.create(
cycle=self.cycle,
date=date.today(),
age=10,
doc_weight=40,
average_weight=320.5,
chicken_count=9800,
uniformity=82.0,
average_daily_gain=28.0,
)
response = self.client.get(
"/api/v1/chicken-weights/latest-avg/", {"cycle_id": self.cycle.pk}
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["date"], date.today().isoformat())
self.assertEqual(response.data["average_weight"], 320.5)
self.assertNotIn("prediction_date", response.data)
def test_weights_request_endpoint_is_removed(self):
response = self.client.post(
"/api/v1/chicken-weights/request/",
{"cycle_id": self.cycle.pk, "mode": "fetch"},
format="json",
)
self.assertEqual(response.status_code, 405)
def test_health_does_not_probe_fatchicken(self):
response = self.client.get("/api/v1/health/")
self.assertEqual(response.status_code, 200)
self.assertNotIn("fatchicken", response.data)
self.assertNotIn("fatchicken_base_configured", response.data)
def test_manual_input_uses_erd_field_names(self):
response = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": date.today().isoformat(),
"age_manual": 10,
"mortality_manual": 5,
"mortality_manual_total": 5,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["mortality_manual_total"], 5)
self.assertNotIn("mortality_total_manual", response.data)
def test_manual_input_computes_totals_and_ignores_client_derived_fields(self):
day1 = date.today() - timedelta(days=1)
day2 = date.today()
first = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day1.isoformat(),
"age_manual": 9,
"mortality_manual": 5,
"feed_in_manual": 8,
"feed_use_manual": 10,
"harvest_manual": 2,
"harvest_weight_manual": 8,
"manual_weight": 320.5,
"mortality_manual_total": 999,
"feed_in_manual_total": 999,
"feed_use_manual_total": 999,
"harvest_manual_total": 999,
"harvest_weight_manual_total": 999,
"fcr_manual": 1.9,
"eef_manual": 400,
"average_harvest_day_manual": 12,
"average_harvest_weight_manual": 99,
},
format="json",
)
self.assertIn(first.status_code, (200, 201), first.data)
self.assertEqual(first.data["age_manual"], 9)
self.assertEqual(first.data["mortality_manual_total"], 5)
self.assertEqual(first.data["feed_in_manual_total"], 8)
self.assertEqual(first.data["feed_use_manual_total"], 10)
self.assertEqual(first.data["harvest_manual_total"], 2)
self.assertEqual(first.data["harvest_weight_manual_total"], 8)
self.assertEqual(first.data["fcr_manual"], 0)
self.assertEqual(first.data["eef_manual"], 0)
self.assertEqual(first.data["average_harvest_day_manual"], 0)
self.assertEqual(first.data["average_harvest_weight_manual"], 0)
second = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day2.isoformat(),
"age_manual": 10,
"mortality_manual": 3,
"feed_in_manual": 2,
"feed_use_manual": 4,
"harvest_manual": 1,
"harvest_weight_manual": 2,
"manual_weight": 330.0,
},
format="json",
)
self.assertIn(second.status_code, (200, 201), second.data)
self.assertEqual(second.data["mortality_manual_total"], 8)
self.assertEqual(second.data["feed_in_manual_total"], 10)
self.assertEqual(second.data["feed_use_manual_total"], 14)
self.assertEqual(second.data["harvest_manual_total"], 3)
self.assertEqual(second.data["harvest_weight_manual_total"], 10)
update_day1 = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day1.isoformat(),
"age_manual": 9,
"mortality_manual": 7,
"feed_in_manual": 8,
"feed_use_manual": 10,
"harvest_manual": 2,
"harvest_weight_manual": 8,
"manual_weight": 320.5,
},
format="json",
)
self.assertIn(update_day1.status_code, (200, 201), update_day1.data)
self.assertEqual(update_day1.data["id"], first.data["id"])
self.assertEqual(update_day1.data["mortality_manual_total"], 7)
refreshed_day2 = self.client.get(
"/api/v1/manual-inputs/", {"cycle_id": self.cycle.pk, "date": day2.isoformat()}
)
self.assertEqual(refreshed_day2.status_code, 200, refreshed_day2.data)
rows = (
refreshed_day2.data["results"]
if isinstance(refreshed_day2.data, dict)
else refreshed_day2.data
)
self.assertEqual(rows[0]["mortality_manual_total"], 10)
def test_manual_input_accepts_fractional_harvest_weight(self):
day1 = date.today() - timedelta(days=1)
day2 = date.today()
first = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day1.isoformat(),
"harvest_weight_manual": 8.5,
},
format="json",
)
self.assertIn(first.status_code, (200, 201), first.data)
self.assertEqual(first.data["harvest_weight_manual"], 8.5)
self.assertEqual(first.data["harvest_weight_manual_total"], 8.5)
second = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day2.isoformat(),
"harvest_weight_manual": 1.25,
},
format="json",
)
self.assertIn(second.status_code, (200, 201), second.data)
self.assertEqual(second.data["harvest_weight_manual"], 1.25)
self.assertEqual(second.data["harvest_weight_manual_total"], 9.75)
def test_kpi_accepts_fractional_harvest_weight(self):
from apps.operations.models import ChickenCounting, FeedSacks, KPI, ManualInput
cc = ChickenCounting.objects.create(cycle=self.cycle, date=date.today(), total_count=9800)
cw = ChickenWeight.objects.create(
cycle=self.cycle,
date=date.today(),
age=10,
doc_weight=40,
average_weight=320.5,
chicken_count=9800,
uniformity=82.0,
average_daily_gain=28.0,
)
fs = FeedSacks.objects.create(cycle=self.cycle, date=date.today())
manual = ManualInput.objects.create(cycle=self.cycle, date=date.today())
response = self.client.post(
"/api/v1/kpis/",
{
"cycle": self.cycle.pk,
"date": date.today().isoformat(),
"cc": cc.pk,
"cw": cw.pk,
"fs": fs.pk,
"manual": manual.pk,
"harvest_weight": 12.75,
"harvest_weight_total": 20.5,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["harvest_weight"], 12.75)
self.assertEqual(response.data["harvest_weight_total"], 20.5)
kpi = KPI.objects.get(pk=response.data["id"])
self.assertEqual(kpi.harvest_weight, 12.75)
self.assertEqual(kpi.harvest_weight_total, 20.5)
class ErdPrimaryKeyTests(TestCase):
ERD_PRIMARY_KEYS = {
"user_access": "user_id",
"sites": "site_id",
"kandang": "kandang_id",
"flock": "flock_id",
"cycles": "cycle_id",
"iot_panel": "panel_id",
"ai_insight": "id",
"feed_sacks": "fs_id",
"chicken_counting": "cc_id",
"chicken_weight": "cw_id",
"manual_input": "manual_id",
"kpi": "kpi_id",
}
def test_domain_primary_keys_match_erd_names(self):
from django.db import connection
with connection.cursor() as cursor:
for table, pk_name in self.ERD_PRIMARY_KEYS.items():
cursor.execute(f'PRAGMA table_info("{table}")')
live_pks = [row[1] for row in cursor.fetchall() if row[5]]
self.assertEqual(live_pks, [pk_name], table)
+29
View File
@@ -0,0 +1,29 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.operations.views import (
AIInsightViewSet,
ChickenCountingViewSet,
ChickenWeightViewSet,
FeedSacksViewSet,
HealthView,
IotPanelViewSet,
KPIViewSet,
ManualInputViewSet,
)
router = DefaultRouter()
router.register("kpis", KPIViewSet, basename="kpi")
router.register("manual-inputs", ManualInputViewSet, basename="manual-input")
router.register("chicken-countings", ChickenCountingViewSet, basename="chicken-counting")
router.register("chicken-weights", ChickenWeightViewSet, basename="chicken-weight")
router.register("feed-sacks", FeedSacksViewSet, basename="feed-sacks")
router.register("karungs", FeedSacksViewSet, basename="karung")
router.register("iot-panels", IotPanelViewSet, basename="iot-panel")
router.register("panel-iot", IotPanelViewSet, basename="panel-iot")
router.register("ai-insights", AIInsightViewSet, basename="ai-insight")
urlpatterns = [
path("health/", HealthView.as_view(), name="health"),
*router.urls,
]
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from django.conf import settings
from django.core.exceptions import ValidationError
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.farms.models import Cycle
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
from apps.operations.serializers import (
AIInsightSerializer,
ChickenCountingSerializer,
ChickenWeightSerializer,
FeedSacksSerializer,
IotPanelSerializer,
KPISerializer,
ManualInputSerializer,
)
from apps.operations.services.karung_web import KarungWebError, request_karung
class CycleScopedViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = super().get_queryset()
params = self.request.query_params
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()
if params.get("date"):
qs = qs.filter(date=params["date"])
return qs
class KPIViewSet(CycleScopedViewSet):
queryset = KPI.objects.select_related("cycle").all()
serializer_class = KPISerializer
class ManualInputViewSet(CycleScopedViewSet):
queryset = ManualInput.objects.select_related("cycle").all()
serializer_class = ManualInputSerializer
class ChickenCountingViewSet(CycleScopedViewSet):
queryset = ChickenCounting.objects.select_related("cycle").all()
serializer_class = ChickenCountingSerializer
class ChickenWeightViewSet(CycleScopedViewSet):
queryset = ChickenWeight.objects.select_related("cycle").all()
serializer_class = ChickenWeightSerializer
@action(detail=False, methods=["get"], url_path="latest-avg")
def latest_average(self, request):
cycle_id = request.query_params.get("cycle_id")
if not cycle_id:
return Response(
{"detail": "cycle_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
row = self.get_queryset().filter(cycle_id=cycle_id).order_by("-date", "-pk").first()
if row is None:
return Response(
{"detail": "No chicken weight exists for this cycle."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(self.get_serializer(row).data)
class FeedSacksViewSet(CycleScopedViewSet):
queryset = FeedSacks.objects.select_related("cycle").all()
serializer_class = FeedSacksSerializer
@action(detail=False, methods=["get"], url_path="latest")
def latest(self, request):
cycle_id = request.query_params.get("cycle_id")
if not cycle_id:
return Response(
{"detail": "cycle_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
row = self.get_queryset().filter(cycle_id=cycle_id).order_by("-date", "-pk").first()
if row is None:
return Response(
{"detail": "No feed sacks row exists for this cycle."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(self.get_serializer(row).data)
@action(detail=False, methods=["post"], url_path="request")
def request_sync(self, request):
cycle_id = request.data.get("cycle_id")
if not cycle_id:
return Response(
{"detail": "cycle_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
cycle = Cycle.objects.get(pk=cycle_id)
except Cycle.DoesNotExist:
return Response({"detail": "Cycle not found."}, status=status.HTTP_404_NOT_FOUND)
section = request.data.get("section") or "today"
try:
feed_sack, summary = request_karung(cycle, section=section)
except (KarungWebError, ValidationError) as exc:
code = getattr(exc, "status_code", None)
return Response(
{"detail": str(exc)},
status=code if code and code >= 400 else status.HTTP_502_BAD_GATEWAY,
)
return Response(
{"karung": self.get_serializer(feed_sack).data, "upstream": summary}
)
class IotPanelViewSet(viewsets.ModelViewSet):
queryset = IotPanel.objects.select_related("flock", "flock__cycle").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"])
if params.get("flock_id"):
qs = qs.filter(flock_id=params["flock_id"])
if params.get("date"):
qs = qs.filter(date=params["date"])
return qs
def paginate_queryset(self, queryset):
if self.request.query_params.get("date"):
return None
return super().paginate_queryset(queryset)
@action(detail=False, methods=["get"], url_path="dates")
def dates(self, request):
dates = list(
self.get_queryset()
.order_by("date")
.values_list("date", flat=True)
.distinct()
)
return Response(dates)
class AIInsightViewSet(CycleScopedViewSet):
queryset = AIInsight.objects.select_related("cycle").all()
serializer_class = AIInsightSerializer
class HealthView(APIView):
permission_classes = [AllowAny]
authentication_classes = []
def get(self, request):
payload = {
"status": "ok",
"service": "newdashboard-api",
"karung_web_admin_base_configured": bool(settings.KARUNG_WEB_ADMIN_BASE_URL),
}
return Response(payload)