first commit
This commit is contained in:
commit
367533c02b
171 files changed
+101175
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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.
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
Whitespace-only changes.
@@ -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)"))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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.
@@ -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")
|
||||
]
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user