first commit

This commit is contained in:
Alberto-Audrix committed 2026-09-08 15:21:23 +07:00
commit b54624be96
226 files changed
+108840

No files matched your search

View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class OperationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.operations"
label = "operations"
verbose_name = "Operations"
@@ -0,0 +1,199 @@
# Generated by Django 5.2.17 on 2026-08-18 08:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('farms', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AIInsight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('insight_text', models.TextField()),
('alert', models.CharField(max_length=100)),
('section', models.CharField(max_length=100)),
('session', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_insights', to='farms.cycle')),
],
options={
'db_table': 'ai_insight',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ChickenCounting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('total_count', models.IntegerField(default=0)),
('mortality_count', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chicken_countings', to='farms.cycle')),
],
options={
'db_table': 'chicken_counting',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ChickenWeight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age', models.IntegerField()),
('doc_weight', models.IntegerField()),
('average_weight', models.FloatField()),
('chicken_count', models.IntegerField()),
('uniformity', models.FloatField()),
('average_daily_gain', models.FloatField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chicken_weights', to='farms.cycle')),
],
options={
'db_table': 'chicken_weight',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='FeedSacks',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('in_today', models.IntegerField(default=0)),
('out_today', models.IntegerField(default=0)),
('in_total', models.IntegerField(default=0)),
('out_total', models.IntegerField(default=0)),
('feed_use_today', models.IntegerField(default=0)),
('feed_use_total', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_sacks', to='farms.cycle')),
],
options={
'verbose_name_plural': 'Feed sacks',
'db_table': 'feed_sacks',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='IotPanel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('wind_speed', models.FloatField(default=0.0)),
('humidity', models.FloatField(default=0.0)),
('water_total', models.FloatField(default=0.0)),
('average_temperature', models.FloatField(default=0.0)),
('experience_temperature', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('flock', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='iot_panels', to='farms.flock')),
],
options={
'verbose_name': 'IoT Panel',
'verbose_name_plural': 'IoT Panels',
'db_table': 'iot_panel',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ManualInput',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age_manual', models.IntegerField(default=0)),
('mortality_manual', models.IntegerField(default=0)),
('mortality_manual_total', models.IntegerField(default=0)),
('feed_manual', models.IntegerField(default=0)),
('feed_manual_total', models.IntegerField(default=0)),
('harvest_manual', models.IntegerField(default=0)),
('harvest_manual_total', models.IntegerField(default=0)),
('harvest_weight_manual', models.IntegerField(default=0)),
('harvest_weight_manual_total', models.IntegerField(default=0)),
('manual_weight_float', models.FloatField(default=0.0)),
('average_harvest_day_manual', models.FloatField(default=0.0)),
('average_harvest_weight_manual', models.FloatField(default=0.0)),
('fcr_manual', models.FloatField(default=0.0)),
('eef_manual', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='manual_inputs', to='farms.cycle')),
],
options={
'db_table': 'manual_input',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='KPI',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age', models.IntegerField(default=0)),
('mortality', models.IntegerField(default=0)),
('mortality_total', models.IntegerField(default=0)),
('feed', models.IntegerField(default=0)),
('feed_total', models.IntegerField(default=0)),
('harvest', models.IntegerField(default=0)),
('harvest_total', models.IntegerField(default=0)),
('harvest_weight', models.IntegerField(default=0)),
('harvest_weight_total', models.IntegerField(default=0)),
('chicken_life', models.IntegerField(default=0)),
('chicken_life_percentage', models.FloatField(default=0.0)),
('lot_weight', models.FloatField(default=0.0)),
('average_harvest_day', models.FloatField(default=0.0)),
('average_harvest_weight', models.FloatField(default=0.0)),
('fcr', models.FloatField(default=0.0)),
('eef', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cc', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.chickencounting')),
('cw', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.chickenweight')),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kpis', to='farms.cycle')),
('fs', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.feedsacks')),
('manual', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.manualinput')),
],
options={
'verbose_name_plural': 'KPIs',
'db_table': 'kpi',
'ordering': ['-date'],
},
),
migrations.AddConstraint(
model_name='chickencounting',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_chicken_counting_cycle_date'),
),
migrations.AddConstraint(
model_name='chickenweight',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_cw_cycle_date'),
),
migrations.AddConstraint(
model_name='feedsacks',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_feed_sacks_cycle_date'),
),
migrations.AddConstraint(
model_name='iotpanel',
constraint=models.UniqueConstraint(fields=('flock', 'date'), name='uniq_iot_panel_flock_date'),
),
migrations.AddConstraint(
model_name='manualinput',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_manual_input_cycle_date'),
),
migrations.AddConstraint(
model_name='kpi',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_kpi_cycle_date'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.17 on 2026-08-19 07:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('operations', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='kpi',
name='harvest_weight',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='kpi',
name='harvest_weight_total',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='manualinput',
name='harvest_weight_manual',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='manualinput',
name='harvest_weight_manual_total',
field=models.FloatField(default=0.0),
),
]
@@ -0,0 +1,18 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("farms", "0005_erd_named_primary_keys"),
("operations", "0002_harvest_weight_float"),
]
operations = [
migrations.RenameField(model_name="iotpanel", old_name="id", new_name="panel_id"),
migrations.RenameField(model_name="feedsacks", old_name="id", new_name="fs_id"),
migrations.RenameField(model_name="chickencounting", old_name="id", new_name="cc_id"),
migrations.RenameField(model_name="chickenweight", old_name="id", new_name="cw_id"),
migrations.RenameField(model_name="manualinput", old_name="id", new_name="manual_id"),
migrations.RenameField(model_name="kpi", old_name="id", new_name="kpi_id"),
]
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0003_erd_named_primary_keys"),
]
operations = [
migrations.RenameField(
model_name="manualinput",
old_name="manual_weight_float",
new_name="manual_weight",
),
migrations.RenameField(
model_name="kpi",
old_name="lot_weight",
new_name="iot_weight",
),
migrations.AlterField(
model_name="chickenweight",
name="doc_weight",
field=models.FloatField(),
),
]
@@ -0,0 +1,55 @@
from django.db import migrations, models
def backfill_feed_in_from_feed_sacks(apps, schema_editor):
ManualInput = apps.get_model("operations", "ManualInput")
FeedSacks = apps.get_model("operations", "FeedSacks")
feed_by_cycle_date = {
(fs.cycle_id, fs.date): fs
for fs in FeedSacks.objects.all().only("cycle_id", "date", "in_today", "in_total")
}
for manual in ManualInput.objects.all().only("cycle_id", "date"):
fs = feed_by_cycle_date.get((manual.cycle_id, manual.date))
if fs is None:
continue
manual.feed_in_manual = fs.in_today
manual.feed_in_manual_total = fs.in_total
manual.save(update_fields=["feed_in_manual", "feed_in_manual_total", "updated_at"])
class Migration(migrations.Migration):
dependencies = [
("operations", "0004_erd_column_updates"),
]
operations = [
migrations.RenameField(
model_name="manualinput",
old_name="feed_manual",
new_name="feed_use_manual",
),
migrations.RenameField(
model_name="manualinput",
old_name="feed_manual_total",
new_name="feed_use_manual_total",
),
migrations.AddField(
model_name="manualinput",
name="feed_in_manual",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="manualinput",
name="feed_in_manual_total",
field=models.IntegerField(default=0),
),
migrations.RunPython(backfill_feed_in_from_feed_sacks, migrations.RunPython.noop),
migrations.AlterField(
model_name="chickenweight",
name="doc_weight",
field=models.IntegerField(),
),
]
@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0007_cycle_doc_in_weight_int"),
("operations", "0005_erd_feed_and_doc_weight"),
]
operations = [
migrations.RemoveConstraint(
model_name="iotpanel",
name="uniq_iot_panel_flock_date",
),
migrations.AddField(
model_name="iotpanel",
name="timestamp",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddConstraint(
model_name="iotpanel",
constraint=models.UniqueConstraint(
fields=("flock", "timestamp"), name="uniq_iot_panel_flock_timestamp"
),
),
]
@@ -0,0 +1,29 @@
from datetime import datetime, time
from django.db import migrations
from django.utils import timezone
def backfill_timestamps(apps, schema_editor):
IotPanel = apps.get_model("operations", "IotPanel")
for row in IotPanel.objects.filter(timestamp__isnull=True):
row.timestamp = timezone.make_aware(
datetime.combine(row.date, time(0, 0)), timezone.get_current_timezone()
)
row.save(update_fields=["timestamp"])
def unset_timestamps(apps, schema_editor):
IotPanel = apps.get_model("operations", "IotPanel")
IotPanel.objects.update(timestamp=None)
class Migration(migrations.Migration):
dependencies = [
("operations", "0006_iotpanel_timestamp"),
]
operations = [
migrations.RunPython(backfill_timestamps, unset_timestamps),
]
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0007_backfill_iotpanel_timestamp"),
]
operations = [
migrations.AddField(
model_name="manualinput",
name="feed_out_manual",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="manualinput",
name="feed_out_manual_total",
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,61 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0008_cycle_feed_initial_balance"),
("operations", "0008_manualinput_feed_out_manual"),
]
operations = [
migrations.CreateModel(
name="ReportSnapshot",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
(
"report_type",
models.CharField(
choices=[("daily", "Daily"), ("end_cycle", "End Cycle")], max_length=20
),
),
("up_to_day", models.IntegerField(default=0)),
("payload", models.JSONField(default=dict)),
("generated_at", models.DateTimeField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"cycle",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="report_snapshots",
to="farms.cycle",
),
),
(
"kandang",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="report_snapshots",
to="farms.kandang",
),
),
],
options={
"verbose_name": "Report Snapshot",
"verbose_name_plural": "Report Snapshots",
"db_table": "report_snapshot",
"ordering": ["-updated_at"],
"constraints": [
models.UniqueConstraint(
fields=("cycle", "kandang", "report_type"),
name="uniq_report_snapshot_cycle_kandang_type",
)
],
},
),
]
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("operations", "0009_reportsnapshot"),
]
operations = [
migrations.DeleteModel(
name="ReportSnapshot",
),
]
Whitespace-only changes.
+170
View File
@@ -0,0 +1,170 @@
from django.db import models
from apps.farms.models import Cycle, Flock
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)
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")
]
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)
class Meta:
db_table = "ai_insight"
ordering = ["-date"]
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)
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")
]
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)
class Meta:
db_table = "chicken_counting"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_chicken_counting_cycle_date")
]
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)
class Meta:
db_table = "chicken_weight"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_cw_cycle_date")
]
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)
class Meta:
db_table = "manual_input"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_manual_input_cycle_date")
]
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)
class Meta:
db_table = "kpi"
ordering = ["-date"]
verbose_name_plural = "KPIs"
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_kpi_cycle_date")
]
+138
View File
@@ -0,0 +1,138 @@
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.kandang.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",
"feed_out_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
),
"feed_out_manual": validated_data.get(
"feed_out_manual", instance.feed_out_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):
return obj.flock.kandang_id
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,214 @@
from __future__ import annotations
import re
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 django.db import transaction
from django.utils import timezone
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting
class ChickenCountingEdgeError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def kandang_index(kandang_name: str) -> str:
match = re.search(r"(\d+)", kandang_name or "")
if not match:
raise ValidationError(
f"Cannot derive edge kandang index from kandang name: {kandang_name!r}"
)
return match.group(1)
def location_prefix_for_kandang(kandang_name: str) -> str:
"""``Kandang 1`` → ``K1`` (matches floor prefixes like ``K1-L1``)."""
return f"K{kandang_index(kandang_name)}"
def coop_for_kandang(kandang_name: str) -> str:
"""``Kandang 1`` → ``K1`` (mortality ``coop`` field)."""
return location_prefix_for_kandang(kandang_name)
def _parse_history_date(raw: Any) -> date | None:
if raw is None:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
def aggregate_counting_by_date(
rows: list[dict[str, Any]] | None,
*,
kandang_name: str,
) -> dict[date, int]:
"""Sum ``total`` for floors whose location starts with ``KN-`` / equals ``KN``."""
prefix = location_prefix_for_kandang(kandang_name)
by_date: dict[date, int] = {}
for row in rows or []:
day = _parse_history_date(row.get("date"))
if day is None:
continue
location = str(row.get("location") or "").strip()
if location != prefix and not location.startswith(f"{prefix}-"):
continue
by_date[day] = by_date.get(day, 0) + int(row.get("total") or 0)
return by_date
def aggregate_mortality_by_date(
rows: list[dict[str, Any]] | None,
*,
kandang_name: str,
) -> dict[date, int]:
"""Map date → ``total_mortality_count`` for the matching coop."""
expected = coop_for_kandang(kandang_name).casefold()
by_date: dict[date, int] = {}
for row in rows or []:
day = _parse_history_date(row.get("date"))
if day is None:
continue
coop = str(row.get("coop") or row.get("location") or "").strip()
if coop.casefold() != expected:
continue
by_date[day] = int(row.get("total_mortality_count") or 0)
return by_date
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
if today is None:
today = timezone.localdate()
end = min(today, cycle.end_date)
return cycle.start_date, end
class ChickenCountingEdgeClient:
"""Outbound client for the chicken-counting edge box."""
DB_HISTORY_PATH = "/api/db/history"
MORTALITY_HISTORY_PATH = "/api/mortality/history"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (
base_url or getattr(settings, "CHICKEN_COUNTING_EDGE_BASE_URL", "") or ""
).rstrip("/")
self.timeout = timeout or float(
getattr(settings, "CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS", 30) or 30
)
def _get_json(self, path: str) -> Any:
if not self.base_url:
raise ChickenCountingEdgeError("CHICKEN_COUNTING_EDGE_BASE_URL is not configured")
url = urljoin(self.base_url + "/", path.lstrip("/"))
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise ChickenCountingEdgeError(f"chicken counting edge request failed: {exc}") from exc
if response.status_code >= 400:
raise ChickenCountingEdgeError(
f"chicken counting edge HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
return response.json()
def fetch_db_history(self) -> list[dict[str, Any]]:
payload = self._get_json(self.DB_HISTORY_PATH)
if isinstance(payload, list):
return payload
return list(payload.get("results") or payload.get("history") or [])
def fetch_mortality_history(self) -> list[dict[str, Any]]:
payload = self._get_json(self.MORTALITY_HISTORY_PATH)
if isinstance(payload, list):
return payload
return list(payload.get("results") or payload.get("history") or [])
@transaction.atomic
def sync_chicken_counting_for_cycle(
cycle: Cycle,
*,
db_history: list[dict[str, Any]] | None = None,
mortality_history: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Upsert ChickenCounting from edge history within the cycle window."""
counting_enabled = bool(
getattr(settings, "CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED", True)
)
mortality_enabled = bool(
getattr(settings, "CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED", True)
)
if not counting_enabled and not mortality_enabled:
return {"dates": [], "upserted": 0, "skipped": True}
client = ChickenCountingEdgeClient()
if db_history is None and counting_enabled:
db_history = client.fetch_db_history()
if mortality_history is None and mortality_enabled:
mortality_history = client.fetch_mortality_history()
kandang_name = cycle.kandang.kandang_name
counting_by_date = (
aggregate_counting_by_date(db_history, kandang_name=kandang_name)
if counting_enabled
else {}
)
mortality_by_date = (
aggregate_mortality_by_date(mortality_history, kandang_name=kandang_name)
if mortality_enabled
else {}
)
start, end = cycle_history_window(cycle)
dates = sorted(set(counting_by_date) | set(mortality_by_date))
upserted: list[date] = []
for day in dates:
if day < start or day > end:
continue
defaults: dict[str, int] = {}
if counting_enabled and day in counting_by_date:
defaults["total_count"] = counting_by_date[day]
if mortality_enabled and day in mortality_by_date:
defaults["mortality_count"] = mortality_by_date[day]
if not defaults:
continue
obj, created = ChickenCounting.objects.get_or_create(
cycle=cycle,
date=day,
defaults={
"total_count": defaults.get("total_count", 0),
"mortality_count": defaults.get("mortality_count", 0),
},
)
if not created:
update_fields = []
if "total_count" in defaults and obj.total_count != defaults["total_count"]:
obj.total_count = defaults["total_count"]
update_fields.append("total_count")
if (
"mortality_count" in defaults
and obj.mortality_count != defaults["mortality_count"]
):
obj.mortality_count = defaults["mortality_count"]
update_fields.append("mortality_count")
if update_fields:
update_fields.append("updated_at")
obj.save(update_fields=update_fields)
upserted.append(day)
return {"dates": upserted, "upserted": len(upserted), "skipped": False}
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import date
from apps.farms.models import Cycle
from apps.operations.models import FeedSacks
def recompute_feed_sack_in_totals(cycle: Cycle) -> None:
"""Rebuild cumulative in_total from daily IoT feed-in only (excludes opening balance)."""
running = 0
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"])
def remove_opening_balance_feed_row(
cycle: Cycle,
*,
balance_date: date | None,
balance_amount: int,
) -> None:
"""Drop a FeedSacks row that only existed to carry opening balance."""
if balance_date is None or balance_amount <= 0:
return
FeedSacks.objects.filter(
cycle=cycle,
date=balance_date,
in_today=balance_amount,
out_today=0,
feed_use_today=0,
out_total=0,
feed_use_total=0,
).delete()
def cleanup_opening_balance_feed_rows(
cycle: Cycle,
*,
previous_date: date | None,
previous_balance: int,
balance_date: date,
balance_amount: int,
) -> None:
"""Remove opening-balance snapshot rows for the previous and current save."""
for row_date, amount in (
(previous_date, previous_balance),
(balance_date, balance_amount),
):
remove_opening_balance_feed_row(
cycle,
balance_date=row_date,
balance_amount=amount,
)
# Legacy cleanup: drop opening-only rows left from earlier saves on other dates.
opening_amounts = {
amount for amount in (previous_balance, balance_amount) if amount > 0
}
if not opening_amounts:
return
FeedSacks.objects.filter(
cycle=cycle,
in_today__in=opening_amounts,
out_today=0,
feed_use_today=0,
out_total=0,
feed_use_total=0,
).delete()
+264
View File
@@ -0,0 +1,264 @@
"""Sync IoT panel readings from dashboard.cpsp.id/api/iot/flocks/ (10-minute snapshots)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone as dt_timezone
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from django.conf import settings
from django.utils import timezone
from apps.farms.models import Cycle, Flock
from apps.operations.models import IotPanel
WIB = ZoneInfo("Asia/Jakarta")
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),
)
class IotApiError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _sensor_celsius(sensor: dict | float | int | None) -> float | None:
if sensor is None:
return None
if isinstance(sensor, (int, float)):
return float(sensor)
raw = sensor.get("value")
if raw is None:
return None
cal = float(sensor.get("calibration") or 0)
return (float(raw) + cal) / 10.0
def _sensor_humidity_pct(sensor: dict | float | int | None, fallback: float | None) -> float:
if isinstance(sensor, dict):
raw = sensor.get("value")
if raw is not None:
cal = float(sensor.get("calibration") or 0)
return (float(raw) + cal) / 10.0
if isinstance(sensor, (int, float)):
return float(sensor)
if fallback is None:
return 70.0
value = float(fallback)
return value / 10.0 if value > 100 else value
def _wind_ms(payload_wind: float | int | None, sensors_wind: dict | float | int | None) -> float:
if isinstance(sensors_wind, dict) and sensors_wind.get("value") is not None:
return float(sensors_wind["value"]) / 10.0
if isinstance(sensors_wind, (int, float)):
return float(sensors_wind)
if payload_wind is None:
return 0.0
return float(payload_wind) / 10.0
def _average_inside_temp_c(sensors: dict | None, actual_temperature: float | int | None) -> float:
if sensors:
readings = [
v
for v in (
_sensor_celsius(sensors.get("temperature1")),
_sensor_celsius(sensors.get("temperature2")),
_sensor_celsius(sensors.get("temperature3")),
)
if v is not None
]
if readings:
return sum(readings) / len(readings)
if actual_temperature is None:
return 0.0
value = float(actual_temperature)
return value / 10.0 if value > 100 else value
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 panel_fields_from_payload(data: dict, age_days: int) -> dict[str, float]:
sensors = data.get("sensors") or {}
humidity = _sensor_humidity_pct(sensors.get("humidity"), data.get("humidity"))
wind_speed = _wind_ms(data.get("wind"), sensors.get("wind"))
avg_temp = _average_inside_temp_c(sensors, data.get("actualTemperature"))
water = data.get("water")
if water is None and isinstance(sensors.get("water"), dict):
water = sensors["water"].get("value")
return {
"wind_speed": round(wind_speed, 4),
"humidity": round(humidity, 4),
"water_total": float(water or 0),
"average_temperature": round(avg_temp, 4),
"experience_temperature": round(
_experience_temperature(
average_temperature=avg_temp,
wind_speed=wind_speed,
humidity_pct=humidity,
age_days=age_days,
),
4,
),
}
def parse_fetched_at(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if timezone.is_naive(parsed):
return timezone.make_aware(parsed, dt_timezone.utc)
return parsed
def cycle_age_for_date(cycle_start: date, snapshot_date: date) -> int:
if snapshot_date < cycle_start:
return 1
return (snapshot_date - cycle_start).days + 1
@dataclass
class SyncSummary:
upserted: int
skipped: int
pages: int
class IotApiClient:
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.IOT_API_BASE_URL).rstrip("/") + "/"
self.timeout = timeout or settings.IOT_API_SYNC_TIMEOUT_SECONDS
def fetch_page(self, page: int) -> dict[str, Any]:
url = f"{self.base_url}?page={page}"
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise IotApiError(f"IoT API request failed: {exc}") from exc
if response.status_code >= 400:
raise IotApiError(
f"IoT API HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
return response.json()
def sync_iot_panels(
*,
lookback_minutes: int | None = None,
max_pages: int | None = None,
) -> SyncSummary:
flock_map: dict[str, int] = getattr(settings, "IOT_FLOCK_ID_MAP", {}) or {}
if not flock_map:
return SyncSummary(upserted=0, skipped=0, pages=0)
lookback = lookback_minutes if lookback_minutes is not None else settings.IOT_SYNC_LOOKBACK_MINUTES
page_limit = max_pages if max_pages is not None else settings.IOT_SYNC_MAX_PAGES
cutoff = timezone.now() - timedelta(minutes=lookback)
django_flocks: dict[str, Flock] = {}
for external_id, django_id in flock_map.items():
try:
django_flocks[str(external_id)] = Flock.objects.select_related("kandang").get(pk=int(django_id))
except Flock.DoesNotExist:
continue
client = IotApiClient()
upserted = 0
skipped = 0
pages_read = 0
for page in range(1, page_limit + 1):
payload = client.fetch_page(page)
pages_read += 1
results = payload.get("results") or []
if not results:
break
page_has_recent = False
for record in results:
external_flock_id = str(record.get("flock_id") or "")
flock = django_flocks.get(external_flock_id)
if flock is None:
skipped += 1
continue
fetched_raw = record.get("fetched_at")
payload_data = (record.get("payload") or {}).get("data")
if not fetched_raw or not payload_data:
skipped += 1
continue
fetched_at = parse_fetched_at(fetched_raw)
if fetched_at >= cutoff:
page_has_recent = True
elif page > 1:
continue
snapshot_date = fetched_at.astimezone(WIB).date()
cycle = (
Cycle.objects.filter(
kandang_id=flock.kandang_id,
start_date__lte=snapshot_date,
end_date__gte=snapshot_date,
)
.order_by("-start_date")
.first()
)
age = cycle_age_for_date(cycle.start_date, snapshot_date) if cycle else 1
fields = panel_fields_from_payload(payload_data, age)
IotPanel.objects.update_or_create(
flock=flock,
timestamp=fetched_at,
defaults={
"date": snapshot_date,
**fields,
},
)
upserted += 1
if not page_has_recent and page > 1:
break
return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read)
@@ -0,0 +1,334 @@
from __future__ import annotations
import re
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 django.utils import timezone
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
def kandang_index(kandang_name: str) -> str:
"""Extract the numeric kandang index from labels like ``Kandang 1``."""
match = re.search(r"(\d+)", kandang_name or "")
if not match:
raise ValidationError(
f"Cannot derive IoT kandang index from kandang name: {kandang_name!r}"
)
return match.group(1)
def source_for_kandang(kandang_name: str, *, kind: str) -> str:
"""Map a kandang label to a karung-web-admin source such as ``K1 - In``."""
return f"K{kandang_index(kandang_name)} - {kind}"
def masuk_source_for_kandang(kandang_name: str) -> str:
"""Map a kandang label like ``Kandang 1`` to the karung-web-admin source ``K1 - In``."""
return source_for_kandang(kandang_name, kind="In")
def _karung_for_source(rows: list[dict[str, Any]] | None, expected_source: str) -> int:
normalized = _normalize_source(expected_source)
for row in rows or []:
if _normalize_source(row.get("source")) == normalized:
return int(row.get("karung") or row.get("counter_value") or 0)
return 0
def _normalize_source(value: str | None) -> str:
return (value or "").strip().casefold()
def _parse_history_date(raw: Any) -> date | None:
if raw is None:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
def daily_totals_from_combined_history(
combined: dict[str, Any],
*,
kandang_name: str,
) -> dict[date, dict[str, int]]:
"""Group /api/combined history into per-date in/out/use for one kandang."""
in_source = _normalize_source(source_for_kandang(kandang_name, kind="In"))
out_source = _normalize_source(source_for_kandang(kandang_name, kind="Out"))
use_source = _normalize_source(source_for_kandang(kandang_name, kind="Use"))
by_date: dict[date, dict[str, int]] = {}
def ensure(day: date) -> dict[str, int]:
return by_date.setdefault(
day, {"in_today": 0, "out_today": 0, "feed_use_today": 0}
)
masuk_history = (combined.get("masuk") or {}).get("history") or []
for row in masuk_history:
day = _parse_history_date(row.get("date"))
if day is None:
continue
source = _normalize_source(row.get("source"))
value = int(row.get("counter_value") or row.get("karung") or 0)
if source == in_source:
ensure(day)["in_today"] = value
elif source == out_source:
ensure(day)["out_today"] = value
tuang_history = (combined.get("tuang") or {}).get("history") or []
for row in tuang_history:
day = _parse_history_date(row.get("date"))
if day is None:
continue
source = _normalize_source(row.get("source"))
value = int(row.get("counter_value") or row.get("karung") or 0)
if source == use_source:
ensure(day)["feed_use_today"] = value
return by_date
def cycle_history_window(cycle: Cycle, *, today: date | None = None) -> tuple[date, date]:
"""Inclusive date window for history upserts."""
if today is None:
today = timezone.localdate()
end = min(today, cycle.end_date)
return cycle.start_date, end
def backfill_karung_from_combined(
cycle: Cycle,
combined: dict[str, Any] | None = None,
) -> list[date]:
"""Upsert FeedSacks for cycle dates present in combined history. Returns upserted dates."""
if combined is None:
combined = KarungWebClient().fetch_combined()
kandang_name = cycle.kandang.kandang_name
by_date = daily_totals_from_combined_history(combined, kandang_name=kandang_name)
start, end = cycle_history_window(cycle)
upserted: list[date] = []
for day in sorted(by_date):
if day < start or day > end:
continue
totals = by_date[day]
FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": totals["in_today"],
"out_today": totals["out_today"],
"feed_use_today": totals["feed_use_today"],
},
)
upserted.append(day)
return upserted
def recompute_feed_sack_totals(cycle: Cycle) -> None:
"""Recompute running in/out/use totals for all FeedSacks of a cycle in date order."""
running_in = 0
running_out = 0
running_use = 0
rows = list(FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"))
for row in rows:
running_in += row.in_today or 0
running_out += row.out_today or 0
running_use += row.feed_use_today or 0
changed = (
row.in_total != running_in
or row.out_total != running_out
or row.feed_use_total != running_use
)
if changed:
row.in_total = running_in
row.out_total = running_out
row.feed_use_total = running_use
row.save(update_fields=["in_total", "out_total", "feed_use_total", "updated_at"])
class KarungWebClient:
"""Outbound client for karung-web-admin — base URL from settings only."""
SECTIONS_PATH = "/api/v1/cycles/active/sections"
COMBINED_PATH = "/api/combined"
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 _get_json(self, path: str) -> dict[str, Any]:
if not self.base_url:
raise KarungWebError("KARUNG_WEB_ADMIN_BASE_URL is not configured")
url = urljoin(self.base_url + "/", 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) and payload.get("error"):
error = payload.get("error") or {}
raise KarungWebError(error.get("message") or "karung-web-admin returned failure")
return payload.get("data") or payload
def fetch_active_sections(self) -> dict[str, Any]:
return self._get_json(self.SECTIONS_PATH)
def fetch_combined(self) -> dict[str, Any]:
return self._get_json(self.COMBINED_PATH)
def fetch_iot_masuk_for_date(target_date: date, *, kandang_name: str) -> int | None:
"""Return IoT karung-masuk for ``target_date`` and kandang from karung-web-admin /api/combined."""
expected_source = _normalize_source(masuk_source_for_kandang(kandang_name))
target = target_date.isoformat()
client = KarungWebClient()
data = client.fetch_combined()
masuk = data.get("masuk") or {}
for row in masuk.get("history") or []:
if row.get("date") == target and _normalize_source(row.get("source")) == expected_source:
return int(row.get("counter_value") or 0)
if target_date == date.today():
for row in masuk.get("today") or []:
if _normalize_source(row.get("source")) == expected_source:
return int(row.get("karung") or 0)
return None
def initial_balance_accuracy(manual: int, iot_in: int | None) -> float | None:
if manual > 0 and iot_in is not None and iot_in > 0:
return round(min(manual, iot_in) / max(manual, iot_in) * 1000) / 10
if manual == 0 and iot_in == 0:
return 100.0
return None
def pull_initial_balance_iot(cycle: Cycle, balance_date: date) -> tuple[int | None, str | None]:
sync_error = None
iot_in = None
try:
iot_in = fetch_iot_masuk_for_date(
balance_date,
kandang_name=cycle.kandang.kandang_name,
)
except KarungWebError as exc:
sync_error = str(exc)
return iot_in, sync_error
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 {}
kandang_name = cycle.kandang.kandang_name
k_index = kandang_index(kandang_name)
masuk_rows = block.get("masuk") or []
tuang_rows = block.get("tuang") or []
in_today = _karung_for_source(masuk_rows, f"K{k_index} - In")
feed_use_today = _karung_for_source(tuang_rows, f"K{k_index} - Use")
out_today = _karung_for_source(masuk_rows, f"K{k_index} - Out")
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 timezone.localdate()
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 = 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,
"kandang": kandang_name,
"kandang_sources": {
"in": source_for_kandang(kandang_name, kind="In"),
"use": source_for_kandang(kandang_name, kind="Use"),
"out": source_for_kandang(kandang_name, kind="Out"),
},
"kandang_totals": {
"in_today": in_today,
"feed_use_today": feed_use_today,
"out_today": out_today,
},
"saldo_awal": saldo_awal,
"remote_cycle": remote_cycle,
}
return feed_sack, summary
def sync_karung_for_cycle(
cycle: Cycle,
*,
section: str = "today",
) -> tuple[FeedSacks, dict[str, Any]]:
"""Sync today from sections, backfill cycle history from /api/combined, recompute totals."""
feed_sack, summary = request_karung(cycle, section=section)
history_dates = backfill_karung_from_combined(cycle)
recompute_feed_sack_totals(cycle)
feed_sack.refresh_from_db()
summary = {
**summary,
"history_dates": [d.isoformat() for d in history_dates],
}
return feed_sack, summary
@@ -0,0 +1,38 @@
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"),
("feed_out_manual", "feed_out_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,29 @@
"""Dashboard publish cutoff — day D data visible from 17:00 WIB on day D."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from django.conf import settings
from django.utils import timezone
def dashboard_publish_time() -> time:
hour = int(getattr(settings, "DASHBOARD_PUBLISH_HOUR", 17))
minute = int(getattr(settings, "DASHBOARD_PUBLISH_MINUTE", 0))
return time(hour, minute)
def visible_through_date(now: datetime | None = None) -> date:
"""Latest calendar date (WIB) allowed on dashboard reads."""
if now is None:
now = timezone.localtime()
elif timezone.is_naive(now):
now = timezone.make_aware(now, timezone.get_current_timezone())
else:
now = timezone.localtime(now)
today = now.date()
if now.time() < dashboard_publish_time():
return today - timedelta(days=1)
return today
+441
View File
@@ -0,0 +1,441 @@
from datetime import date, timedelta
from unittest.mock import patch
from django.test import TestCase, override_settings
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
@override_settings(DASHBOARD_PUBLISH_HOUR=0)
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", kandang=self.kandang)
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", kandang=self.kandang)
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)
@override_settings(DASHBOARD_PUBLISH_HOUR=17, TIME_ZONE="Asia/Jakarta")
class DashboardVisibilityTests(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(2026, 8, 26),
end_date=date(2026, 9, 29),
doc_in_weight=40,
doc_in_count=10000,
status=Cycle.STATUS_ACTIVE,
)
def test_hides_today_before_publish_time(self):
FeedSacks.objects.create(
cycle=self.cycle,
date=date(2026, 8, 27),
in_today=5,
out_today=3,
feed_use_today=3,
feed_use_total=3,
)
FeedSacks.objects.create(
cycle=self.cycle,
date=date(2026, 8, 28),
in_today=9,
out_today=7,
feed_use_today=7,
feed_use_total=10,
)
with patch(
"apps.operations.services.visibility.timezone.localtime",
) as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 8, 28, 16, 30, tzinfo=wib)
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(len(results), 1)
self.assertEqual(results[0]["date"], "2026-08-27")
def test_shows_today_after_publish_time(self):
FeedSacks.objects.create(
cycle=self.cycle,
date=date(2026, 8, 28),
in_today=9,
out_today=7,
feed_use_today=7,
feed_use_total=10,
)
with patch(
"apps.operations.services.visibility.timezone.localtime",
) as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 8, 28, 17, 0, tzinfo=wib)
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["date"], "2026-08-28")
def test_panel_iot_not_hidden_before_publish_time(self):
flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang)
IotPanel.objects.create(
flock=flock,
date=date(2026, 8, 28),
average_temperature=28.5,
)
with patch(
"apps.operations.services.visibility.timezone.localtime",
) as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 8, 28, 10, 0, tzinfo=wib)
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"], "2026-08-28")
def test_health_includes_visible_through_date(self):
with patch(
"apps.operations.services.visibility.timezone.localtime",
) as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 8, 28, 10, 0, tzinfo=wib)
response = self.client.get("/api/v1/health/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["visible_through_date"], "2026-08-27")
self.assertEqual(response.data["dashboard_publish_time"], "17:00")
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,213 @@
from datetime import date
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Kandang, Site
from apps.operations.models import ChickenCounting, ChickenWeight
from apps.operations.services.chicken_counting_edge import (
ChickenCountingEdgeError,
aggregate_counting_by_date,
aggregate_mortality_by_date,
coop_for_kandang,
location_prefix_for_kandang,
sync_chicken_counting_for_cycle,
)
DB_HISTORY_SAMPLE = [
{"date": "2026-09-02", "location": "K1-L1", "total": 14591},
{"date": "2026-09-02", "location": "K1-L2", "total": 14591},
{"date": "2026-09-02", "location": "K2-L1", "total": 100},
{"date": "2026-09-02", "location": "K2-L2", "total": 100},
{"date": "2026-09-03", "location": "K1-L1", "total": 18736},
{"date": "2026-09-03", "location": "K1-L2", "total": 18736},
{"date": "2026-08-20", "location": "K1-L1", "total": 50},
]
MORTALITY_HISTORY_SAMPLE = [
{"date": "2026-09-02", "coop": "K1", "total_mortality_count": 0},
{"date": "2026-09-02", "coop": "K2", "total_mortality_count": 10},
{"date": "2026-09-03", "coop": "K1", "total_mortality_count": 29},
{"date": "2026-09-03", "coop": "K2", "total_mortality_count": 29},
]
class LocationMappingTests(TestCase):
def test_maps_kandang_to_prefix_and_coop(self):
self.assertEqual(location_prefix_for_kandang("Kandang 1"), "K1")
self.assertEqual(coop_for_kandang("Kandang 2"), "K2")
class AggregationTests(TestCase):
def test_sums_matching_floors_per_date(self):
by_date = aggregate_counting_by_date(DB_HISTORY_SAMPLE, kandang_name="Kandang 1")
self.assertEqual(by_date[date(2026, 9, 2)], 29182)
self.assertEqual(by_date[date(2026, 9, 3)], 37472)
self.assertNotIn(date(2026, 9, 2), aggregate_counting_by_date([], kandang_name="Kandang 1"))
def test_mortality_uses_matching_coop(self):
by_date = aggregate_mortality_by_date(
MORTALITY_HISTORY_SAMPLE, kandang_name="Kandang 1"
)
self.assertEqual(by_date[date(2026, 9, 2)], 0)
self.assertEqual(by_date[date(2026, 9, 3)], 29)
@override_settings(
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=True,
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=True,
)
class SyncChickenCountingTests(TestCase):
def setUp(self):
user = User.objects.create_user(user_name="sync-user", password="secret")
site = Site.objects.create(site_name="Sukawarna", user=user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=33,
start_date=date(2026, 9, 2),
end_date=date(2026, 10, 4),
doc_in_weight=35,
doc_in_count=50000,
status=Cycle.STATUS_ACTIVE,
)
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
def test_upserts_within_cycle_window(self, mock_db, mock_mort, mock_today):
mock_today.return_value = date(2026, 9, 4)
mock_db.return_value = DB_HISTORY_SAMPLE
mock_mort.return_value = MORTALITY_HISTORY_SAMPLE
summary = sync_chicken_counting_for_cycle(self.cycle)
self.assertEqual(sorted(summary["dates"]), [date(2026, 9, 2), date(2026, 9, 3)])
row_sep2 = ChickenCounting.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
self.assertEqual(row_sep2.total_count, 29182)
self.assertEqual(row_sep2.mortality_count, 0)
row_sep3 = ChickenCounting.objects.get(cycle=self.cycle, date=date(2026, 9, 3))
self.assertEqual(row_sep3.total_count, 37472)
self.assertEqual(row_sep3.mortality_count, 29)
self.assertFalse(
ChickenCounting.objects.filter(cycle=self.cycle, date=date(2026, 8, 20)).exists()
)
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
def test_respects_partial_enable_flags(self, mock_db, mock_mort, mock_today):
mock_today.return_value = date(2026, 9, 4)
mock_db.return_value = DB_HISTORY_SAMPLE
mock_mort.return_value = MORTALITY_HISTORY_SAMPLE
ChickenCounting.objects.create(
cycle=self.cycle,
date=date(2026, 9, 2),
total_count=1,
mortality_count=5,
)
with override_settings(
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=False,
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=True,
):
sync_chicken_counting_for_cycle(self.cycle)
row = ChickenCounting.objects.get(cycle=self.cycle, date=date(2026, 9, 2))
self.assertEqual(row.total_count, 1)
self.assertEqual(row.mortality_count, 0)
@override_settings(
CHICKEN_COUNTING_EDGE_BASE_URL="http://edge.internal",
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=True,
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=True,
DASHBOARD_PUBLISH_HOUR=17,
TIME_ZONE="Asia/Jakarta",
)
class ChickenCountingSyncApiTests(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=33,
start_date=date(2026, 9, 2),
end_date=date(2026, 10, 4),
doc_in_weight=35,
doc_in_count=50000,
status=Cycle.STATUS_ACTIVE,
)
@patch("apps.operations.services.chicken_counting_edge.timezone.localdate")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_mortality_history")
@patch("apps.operations.services.chicken_counting_edge.ChickenCountingEdgeClient.fetch_db_history")
def test_sync_action_upserts(self, mock_db, mock_mort, mock_today):
mock_today.return_value = date(2026, 9, 4)
mock_db.return_value = DB_HISTORY_SAMPLE
mock_mort.return_value = MORTALITY_HISTORY_SAMPLE
response = self.client.post(
"/api/v1/chicken-countings/sync/",
{"cycle_id": self.cycle.pk},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(ChickenCounting.objects.filter(cycle=self.cycle).count(), 2)
@patch(
"apps.operations.views.sync_chicken_counting_for_cycle",
side_effect=ChickenCountingEdgeError("edge down"),
)
def test_sync_action_returns_502_on_edge_error(self, _mock_sync):
response = self.client.post(
"/api/v1/chicken-countings/sync/",
{"cycle_id": self.cycle.pk},
format="json",
)
self.assertEqual(response.status_code, 502)
self.assertIn("edge down", response.data["detail"])
def test_counting_and_weight_visible_before_publish_time(self):
ChickenCounting.objects.create(
cycle=self.cycle,
date=date(2026, 9, 4),
total_count=100,
mortality_count=2,
)
ChickenWeight.objects.create(
cycle=self.cycle,
date=date(2026, 9, 4),
age=2,
doc_weight=35,
average_weight=40,
chicken_count=10,
uniformity=90,
average_daily_gain=1,
)
with patch("apps.operations.services.visibility.timezone.localtime") as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 9, 4, 10, 0, tzinfo=wib)
counting = self.client.get(
"/api/v1/chicken-countings/", {"cycle_id": self.cycle.pk}
)
weight = self.client.get(
"/api/v1/chicken-weights/", {"cycle_id": self.cycle.pk}
)
self.assertEqual(counting.status_code, 200)
c_results = counting.data["results"] if isinstance(counting.data, dict) else counting.data
self.assertEqual(len(c_results), 1)
self.assertEqual(c_results[0]["date"], "2026-09-04")
self.assertEqual(weight.status_code, 200)
w_results = weight.data["results"] if isinstance(weight.data, dict) else weight.data
self.assertEqual(len(w_results), 1)
self.assertEqual(w_results[0]["date"], "2026-09-04")
+313
View File
@@ -0,0 +1,313 @@
from datetime import date
from unittest.mock import patch
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from apps.accounts.models import User
from apps.farms.models import Cycle, Kandang, Site
from apps.operations.models import FeedSacks
from apps.operations.services.karung_web import (
KarungWebError,
backfill_karung_from_combined,
daily_totals_from_combined_history,
fetch_iot_masuk_for_date,
initial_balance_accuracy,
masuk_source_for_kandang,
recompute_feed_sack_totals,
request_karung,
source_for_kandang,
sync_karung_for_cycle,
)
ACTIVE_SECTIONS_SAMPLE = {
"sections": {
"today": {
"date": "2026-09-02",
"masuk": [
{"karung": 192, "source": "K1 - In"},
{"karung": 2, "source": "K1 - Out"},
{"karung": 120, "source": "K2 - In"},
{"karung": 5, "source": "K2 - Out"},
],
"tuang": [
{"karung": 7, "source": "K1 - Use"},
{"karung": 9, "source": "K2 - Use"},
],
"totals": {"masuk": 312, "tuang": 16},
}
},
"cycle": {"saldo_awal": 240},
}
COMBINED_SAMPLE = {
"masuk": {
"history": [
{"counter_value": 160, "date": "2026-09-02", "id": 17, "source": "K1 - In"},
{"counter_value": 80, "date": "2026-09-02", "id": 19, "source": "K2 - In"},
{"counter_value": 112, "date": "2026-08-27", "id": 9, "source": "K1 - In"},
{"counter_value": 32, "date": "2026-08-27", "id": 11, "source": "K2 - In"},
],
"today": [
{"karung": 160, "source": "K1 - In"},
{"karung": 80, "source": "K2 - In"},
],
}
}
COMBINED_HISTORY_FULL = {
"masuk": {
"history": [
{"counter_value": 192, "date": "2026-09-02", "source": "K1 - In"},
{"counter_value": 1, "date": "2026-09-02", "source": "K1 - Out"},
{"counter_value": 50, "date": "2026-09-03", "source": "K1 - In"},
{"counter_value": 0, "date": "2026-09-03", "source": "K1 - Out"},
{"counter_value": 120, "date": "2026-09-02", "source": "K2 - In"},
{"counter_value": 2, "date": "2026-09-02", "source": "K2 - Out"},
{"counter_value": 112, "date": "2026-08-27", "source": "K1 - In"},
],
"today": [],
},
"tuang": {
"history": [
{"counter_value": 7, "date": "2026-09-02", "source": "K1 - Use"},
{"counter_value": 4, "date": "2026-09-03", "source": "K1 - Use"},
{"counter_value": 9, "date": "2026-09-02", "source": "K2 - Use"},
],
"today": [],
},
}
class MasukSourceForKandangTests(TestCase):
def test_maps_kandang_number_to_iot_source(self):
self.assertEqual(masuk_source_for_kandang("Kandang 1"), "K1 - In")
self.assertEqual(masuk_source_for_kandang("Kandang 2"), "K2 - In")
self.assertEqual(source_for_kandang("Kandang 2", kind="Use"), "K2 - Use")
self.assertEqual(source_for_kandang("Kandang 2", kind="Out"), "K2 - Out")
def test_rejects_kandang_name_without_number(self):
with self.assertRaises(ValidationError):
masuk_source_for_kandang("Utara")
class InitialBalanceAccuracyTests(TestCase):
def test_returns_ratio_when_both_values_positive(self):
self.assertEqual(initial_balance_accuracy(120, 115), 95.8)
def test_returns_100_when_both_zero(self):
self.assertEqual(initial_balance_accuracy(0, 0), 100.0)
def test_returns_none_when_iot_missing(self):
self.assertIsNone(initial_balance_accuracy(120, None))
@override_settings(KARUNG_WEB_ADMIN_BASE_URL="http://karung.internal")
class KarungWebCombinedTests(TestCase):
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_uses_kandang_specific_history_row(self, mock_fetch):
mock_fetch.return_value = COMBINED_SAMPLE
result = fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 2")
self.assertEqual(result, 32)
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_matches_source_case_insensitively(self, mock_fetch):
mock_fetch.return_value = {
"masuk": {
"history": [
{"counter_value": 99, "date": "2026-08-27", "source": "K1 - in"},
]
}
}
result = fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 1")
self.assertEqual(result, 99)
@patch("apps.operations.services.karung_web.date")
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_falls_back_to_today_live_rows(self, mock_fetch, mock_date):
mock_date.today.return_value = date(2026, 9, 2)
mock_fetch.return_value = {"masuk": {"history": [], "today": COMBINED_SAMPLE["masuk"]["today"]}}
result = fetch_iot_masuk_for_date(date(2026, 9, 2), kandang_name="Kandang 1")
self.assertEqual(result, 160)
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
def test_fetch_iot_masuk_for_date_returns_none_when_day_missing(self, mock_fetch):
mock_fetch.return_value = COMBINED_SAMPLE
result = fetch_iot_masuk_for_date(date(2026, 8, 26), kandang_name="Kandang 1")
self.assertIsNone(result)
@patch(
"apps.operations.services.karung_web.KarungWebClient.fetch_combined",
side_effect=KarungWebError("upstream down"),
)
def test_fetch_iot_masuk_for_date_propagates_upstream_errors(self, _mock_fetch):
with self.assertRaises(KarungWebError):
fetch_iot_masuk_for_date(date(2026, 8, 27), kandang_name="Kandang 1")
@override_settings(KARUNG_WEB_ADMIN_BASE_URL="http://karung.internal")
class RequestKarungTests(TestCase):
def setUp(self):
user = User.objects.create_user(user_name="tester", password="secret")
site = Site.objects.create(site_name="Sukawarna", user=user)
self.kandang1 = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.kandang2 = Kandang.objects.create(kandang_name="Kandang 2", site=site)
self.cycle1 = Cycle.objects.create(
kandang=self.kandang1,
total_days=35,
start_date=date(2026, 9, 2),
end_date=date(2026, 10, 5),
doc_in_weight=40,
doc_in_count=10000,
status=Cycle.STATUS_ACTIVE,
)
self.cycle2 = Cycle.objects.create(
kandang=self.kandang2,
total_days=35,
start_date=date(2026, 9, 2),
end_date=date(2026, 10, 5),
doc_in_weight=40,
doc_in_count=10000,
status=Cycle.STATUS_ACTIVE,
)
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_active_sections")
def test_request_karung_uses_per_kandang_sources_not_aggregate_totals(self, mock_fetch):
mock_fetch.return_value = ACTIVE_SECTIONS_SAMPLE
feed_sack1, summary1 = request_karung(self.cycle1, section="today")
feed_sack2, summary2 = request_karung(self.cycle2, section="today")
self.assertEqual(feed_sack1.in_today, 192)
self.assertEqual(feed_sack1.feed_use_today, 7)
self.assertEqual(feed_sack1.out_today, 2)
self.assertEqual(feed_sack2.in_today, 120)
self.assertEqual(feed_sack2.feed_use_today, 9)
self.assertEqual(feed_sack2.out_today, 5)
self.assertEqual(summary1["kandang_totals"]["in_today"], 192)
self.assertEqual(summary2["kandang_totals"]["in_today"], 120)
def test_daily_totals_from_combined_history_groups_by_date_for_kandang(self):
by_date = daily_totals_from_combined_history(
COMBINED_HISTORY_FULL, kandang_name="Kandang 1"
)
self.assertEqual(
by_date[date(2026, 9, 2)],
{"in_today": 192, "out_today": 1, "feed_use_today": 7},
)
self.assertEqual(
by_date[date(2026, 9, 3)],
{"in_today": 50, "out_today": 0, "feed_use_today": 4},
)
self.assertEqual(
by_date[date(2026, 8, 27)],
{"in_today": 112, "out_today": 0, "feed_use_today": 0},
)
self.assertNotIn(date(2026, 9, 1), by_date)
@patch("apps.operations.services.karung_web.timezone")
def test_backfill_upserts_in_cycle_range_and_skips_outside(self, mock_tz):
mock_tz.localdate.return_value = date(2026, 9, 3)
dates = backfill_karung_from_combined(self.cycle1, COMBINED_HISTORY_FULL)
self.assertEqual(dates, [date(2026, 9, 2), date(2026, 9, 3)])
row_sep2 = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 2))
self.assertEqual(row_sep2.in_today, 192)
self.assertEqual(row_sep2.feed_use_today, 7)
self.assertEqual(row_sep2.out_today, 1)
self.assertFalse(
FeedSacks.objects.filter(cycle=self.cycle1, date=date(2026, 8, 27)).exists()
)
@patch("apps.operations.services.karung_web.timezone")
def test_backfill_refreshes_existing_row_from_upstream(self, mock_tz):
mock_tz.localdate.return_value = date(2026, 9, 3)
FeedSacks.objects.create(
cycle=self.cycle1,
date=date(2026, 9, 2),
in_today=1,
out_today=0,
feed_use_today=0,
in_total=1,
out_total=0,
feed_use_total=0,
)
backfill_karung_from_combined(self.cycle1, COMBINED_HISTORY_FULL)
row = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 2))
self.assertEqual(row.in_today, 192)
self.assertEqual(row.feed_use_today, 7)
self.assertEqual(row.out_today, 1)
def test_recompute_feed_sack_totals_accumulates_in_date_order(self):
FeedSacks.objects.create(
cycle=self.cycle1,
date=date(2026, 9, 2),
in_today=192,
out_today=1,
feed_use_today=7,
in_total=0,
out_total=0,
feed_use_total=0,
)
FeedSacks.objects.create(
cycle=self.cycle1,
date=date(2026, 9, 3),
in_today=50,
out_today=0,
feed_use_today=4,
in_total=0,
out_total=0,
feed_use_total=0,
)
recompute_feed_sack_totals(self.cycle1)
sep2 = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 2))
sep3 = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 3))
self.assertEqual((sep2.in_total, sep2.feed_use_total, sep2.out_total), (192, 7, 1))
self.assertEqual((sep3.in_total, sep3.feed_use_total, sep3.out_total), (242, 11, 1))
@patch("apps.operations.services.karung_web.timezone")
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_combined")
@patch("apps.operations.services.karung_web.KarungWebClient.fetch_active_sections")
def test_sync_karung_for_cycle_runs_today_then_history_backfill(
self, mock_sections, mock_combined, mock_tz
):
mock_tz.localdate.return_value = date(2026, 9, 3)
mock_sections.return_value = {
"sections": {
"today": {
"date": "2026-09-03",
"masuk": [
{"karung": 50, "source": "K1 - In"},
{"karung": 0, "source": "K1 - Out"},
],
"tuang": [{"karung": 4, "source": "K1 - Use"}],
"totals": {"masuk": 50, "tuang": 4},
}
},
"cycle": {"saldo_awal": 0},
}
mock_combined.return_value = COMBINED_HISTORY_FULL
feed_sack, summary = sync_karung_for_cycle(self.cycle1, section="today")
self.assertEqual(feed_sack.date, date(2026, 9, 3))
self.assertEqual(feed_sack.in_today, 50)
self.assertEqual(feed_sack.in_total, 242)
sep2 = FeedSacks.objects.get(cycle=self.cycle1, date=date(2026, 9, 2))
self.assertEqual(sep2.in_today, 192)
self.assertEqual(sep2.in_total, 192)
self.assertEqual(summary["history_dates"], ["2026-09-02", "2026-09-03"])
+29
View File
@@ -0,0 +1,29 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.operations.views import (
AIInsightViewSet,
ChickenCountingViewSet,
ChickenWeightViewSet,
FeedSacksViewSet,
HealthView,
IotPanelViewSet,
KPIViewSet,
ManualInputViewSet,
)
router = DefaultRouter()
router.register("kpis", KPIViewSet, basename="kpi")
router.register("manual-inputs", ManualInputViewSet, basename="manual-input")
router.register("chicken-countings", ChickenCountingViewSet, basename="chicken-counting")
router.register("chicken-weights", ChickenWeightViewSet, basename="chicken-weight")
router.register("feed-sacks", FeedSacksViewSet, basename="feed-sacks")
router.register("karungs", FeedSacksViewSet, basename="karung")
router.register("iot-panels", IotPanelViewSet, basename="iot-panel")
router.register("panel-iot", IotPanelViewSet, basename="panel-iot")
router.register("ai-insights", AIInsightViewSet, basename="ai-insight")
urlpatterns = [
path("health/", HealthView.as_view(), name="health"),
*router.urls,
]
+256
View File
@@ -0,0 +1,256 @@
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.accounts.scoping import filter_cycles_queryset, filter_flocks_queryset
from apps.farms.models import Cycle, Flock
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, sync_karung_for_cycle
from apps.operations.services.chicken_counting_edge import (
ChickenCountingEdgeError,
sync_chicken_counting_for_cycle,
)
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates"})
class VisibilityFilteredMixin:
visibility_date_field = "date"
def applies_visibility_filter(self) -> bool:
return getattr(self, "action", None) in READ_ACTIONS
def filter_visible_through(self, qs):
if not self.applies_visibility_filter():
return qs
cutoff = visible_through_date()
return qs.filter(**{f"{self.visibility_date_field}__lte": cutoff})
class CycleScopedViewSet(VisibilityFilteredMixin, viewsets.ModelViewSet):
def get_queryset(self):
qs = super().get_queryset()
# Scope via cycle → kandang → site → owner
visible_cycles = filter_cycles_queryset(Cycle.objects.all(), self.request.user)
qs = qs.filter(cycle_id__in=visible_cycles.values_list("pk", flat=True))
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__kandang__flocks=params["flock_id"]).distinct()
if params.get("date"):
qs = qs.filter(date=params["date"])
return self.filter_visible_through(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):
"""Chicken counting + mortality IoT — not subject to dashboard publish cutoff."""
queryset = ChickenCounting.objects.select_related("cycle").all()
serializer_class = ChickenCountingSerializer
def applies_visibility_filter(self) -> bool:
return False
@action(detail=False, methods=["post"], url_path="sync")
def sync_from_edge(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 = filter_cycles_queryset(
Cycle.objects.select_related("kandang"),
request.user,
).get(pk=cycle_id)
except Cycle.DoesNotExist:
return Response({"detail": "Cycle not found."}, status=status.HTTP_404_NOT_FOUND)
try:
summary = sync_chicken_counting_for_cycle(cycle)
except (ChickenCountingEdgeError, 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(
{
"upserted": summary["upserted"],
"dates": [d.isoformat() for d in summary["dates"]],
}
)
class ChickenWeightViewSet(CycleScopedViewSet):
"""Chicken weight — not subject to dashboard publish cutoff."""
queryset = ChickenWeight.objects.select_related("cycle").all()
serializer_class = ChickenWeightSerializer
def applies_visibility_filter(self) -> bool:
return False
@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 = filter_cycles_queryset(Cycle.objects.all(), request.user).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 = sync_karung_for_cycle(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):
"""IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff."""
queryset = IotPanel.objects.select_related("flock", "flock__kandang").all()
serializer_class = IotPanelSerializer
def get_queryset(self):
qs = super().get_queryset()
visible_flocks = filter_flocks_queryset(Flock.objects.all(), self.request.user)
qs = qs.filter(flock_id__in=visible_flocks.values_list("pk", flat=True))
params = self.request.query_params
cycle_id = params.get("cycle_id")
if cycle_id:
visible_cycles = filter_cycles_queryset(Cycle.objects.all(), self.request.user)
try:
cycle = visible_cycles.get(pk=cycle_id)
except Cycle.DoesNotExist:
return qs.none()
qs = qs.filter(
flock__kandang_id=cycle.kandang_id,
date__gte=cycle.start_date,
date__lte=cycle.end_date,
)
if params.get("flock_id"):
qs = qs.filter(flock_id=params["flock_id"])
if params.get("date"):
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):
cutoff = visible_through_date()
publish = dashboard_publish_time()
payload = {
"status": "ok",
"service": "newdashboard-api",
"karung_web_admin_base_configured": bool(settings.KARUNG_WEB_ADMIN_BASE_URL),
"visible_through_date": cutoff.isoformat(),
"dashboard_publish_time": publish.strftime("%H:%M"),
"dashboard_timezone": settings.TIME_ZONE,
}
return Response(payload)