first commit

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

No files matched your search

View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.contrib import admin
from apps.farms.models import Cycle, Flock, Kandang, Site
admin.site.register(Site)
admin.site.register(Kandang)
admin.site.register(Flock)
admin.site.register(Cycle)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class FarmsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.farms"
label = "farms"
verbose_name = "Farms"
@@ -0,0 +1,91 @@
# Generated by Django 5.2.17 on 2026-08-18 08:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('total_days', models.IntegerField()),
('start_date', models.DateField()),
('end_date', models.DateField()),
('doc_in_weight', models.IntegerField()),
('doc_in_count', models.IntegerField()),
('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed')], default='active', max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'cycles',
'ordering': ['-start_date'],
},
),
migrations.CreateModel(
name='Flock',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('flock_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='flock_set', to='farms.cycle')),
],
options={
'db_table': 'flock',
'ordering': ['flock_name'],
},
),
migrations.AddField(
model_name='cycle',
name='flock',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cycles', to='farms.flock'),
),
migrations.CreateModel(
name='Site',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('site_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sites', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'sites',
'ordering': ['site_name'],
},
),
migrations.CreateModel(
name='Kandang',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kandang_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kandangs', to='farms.site')),
],
options={
'db_table': 'kandang',
'ordering': ['kandang_name'],
},
),
migrations.AddField(
model_name='cycle',
name='kandang',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycles', to='farms.kandang'),
),
migrations.AddConstraint(
model_name='cycle',
constraint=models.CheckConstraint(condition=models.Q(('end_date__gte', models.F('start_date'))), name='chk_cycles_date_range'),
),
]
@@ -0,0 +1,81 @@
# Generated by Django 5.2.17 on 2026-08-19
import django.db.models.deletion
from django.db import migrations, models
def _column_names(connection, table):
with connection.cursor() as cursor:
if connection.vendor == "sqlite":
cursor.execute(f"PRAGMA table_info({table})")
return {row[1] for row in cursor.fetchall()}
cursor.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s
""",
[table],
)
return {row[0] for row in cursor.fetchall()}
def ensure_cycle_kandang_id(apps, schema_editor):
connection = schema_editor.connection
if "kandang_id" in _column_names(connection, "cycles"):
return
with connection.cursor() as cursor:
cursor.execute(
"ALTER TABLE cycles ADD COLUMN kandang_id bigint NULL REFERENCES kandang(id)"
)
def backfill_cycle_flocks(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Flock = apps.get_model("farms", "Flock")
for cycle_id in Cycle.objects.filter(flock_id__isnull=True).values_list("pk", flat=True):
flock = Flock.objects.create(flock_name=f"Cycle {cycle_id}")
Cycle.objects.filter(pk=cycle_id).update(flock_id=flock.pk)
Flock.objects.filter(pk=flock.pk).update(cycle_id=cycle_id)
class Migration(migrations.Migration):
dependencies = [
("farms", "0001_initial"),
]
operations = [
migrations.RunPython(ensure_cycle_kandang_id, migrations.RunPython.noop),
migrations.RunPython(backfill_cycle_flocks, migrations.RunPython.noop),
migrations.AlterField(
model_name="cycle",
name="flock",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cycles",
to="farms.flock",
),
),
migrations.AlterField(
model_name="cycle",
name="status",
field=models.CharField(
choices=[("active", "Active"), ("closed", "Closed")],
db_index=True,
default="active",
max_length=30,
),
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flock_set",
to="farms.cycle",
),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 5.2.17 on 2026-08-19
from django.db import migrations
def _sqlite_flock_cycle_notnull(connection):
with connection.cursor() as cursor:
cursor.execute("PRAGMA table_info(flock)")
cols = {row[1]: row for row in cursor.fetchall()}
cycle_col = cols.get("cycle_id")
return bool(cycle_col and cycle_col[3] == 1)
def make_flock_cycle_nullable(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
if not _sqlite_flock_cycle_notnull(connection):
return
with connection.cursor() as cursor:
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("ALTER TABLE flock RENAME TO flock_old")
cursor.execute(
"""
CREATE TABLE flock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flock_name varchar(30) NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
cycle_id bigint NULL REFERENCES cycles(id)
)
"""
)
cursor.execute(
"""
INSERT INTO flock (id, flock_name, created_at, updated_at, cycle_id)
SELECT id, flock_name, created_at, updated_at, cycle_id FROM flock_old
"""
)
cursor.execute("DROP TABLE flock_old")
cursor.execute("PRAGMA foreign_keys=ON")
class Migration(migrations.Migration):
dependencies = [
("farms", "0002_cycle_requires_flock"),
]
operations = [
migrations.RunPython(make_flock_cycle_nullable, migrations.RunPython.noop),
]
@@ -0,0 +1,59 @@
# Generated by Django 5.2.17 on 2026-08-19
from django.db import migrations
def retarget_cycles_flock_fk(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
with connection.cursor() as cursor:
cursor.execute("SELECT sql FROM sqlite_master WHERE name='cycles'")
row = cursor.fetchone()
if not row or "flock_old" not in (row[0] or ""):
return
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("ALTER TABLE cycles RENAME TO cycles_old")
cursor.execute(
"""
CREATE TABLE cycles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
total_days integer NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
doc_in_weight integer NOT NULL,
doc_in_count integer NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
flock_id bigint NOT NULL REFERENCES flock(id) DEFERRABLE INITIALLY DEFERRED,
kandang_id bigint NOT NULL REFERENCES kandang(id) DEFERRABLE INITIALLY DEFERRED,
status varchar(30) NOT NULL,
CONSTRAINT chk_cycles_date_range CHECK (end_date >= start_date)
)
"""
)
cursor.execute(
"""
INSERT INTO cycles (
id, total_days, start_date, end_date, doc_in_weight, doc_in_count,
created_at, updated_at, flock_id, kandang_id, status
)
SELECT
id, total_days, start_date, end_date, doc_in_weight, doc_in_count,
created_at, updated_at, flock_id, kandang_id, status
FROM cycles_old
"""
)
cursor.execute("DROP TABLE cycles_old")
cursor.execute("PRAGMA foreign_keys=ON")
class Migration(migrations.Migration):
dependencies = [
("farms", "0003_flock_cycle_nullable"),
]
operations = [
migrations.RunPython(retarget_cycles_flock_fk, migrations.RunPython.noop),
]
@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_erd_named_primary_keys"),
("farms", "0004_retarget_cycles_flock_fk"),
]
operations = [
migrations.RenameField(model_name="site", old_name="id", new_name="site_id"),
migrations.RenameField(model_name="kandang", old_name="id", new_name="kandang_id"),
migrations.RenameField(model_name="flock", old_name="id", new_name="flock_id"),
migrations.RenameField(model_name="cycle", old_name="id", new_name="cycle_id"),
]
@@ -0,0 +1,40 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_cycle_from_cycles(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Flock = apps.get_model("farms", "Flock")
for cycle in Cycle.objects.exclude(flock_id__isnull=True).iterator():
Flock.objects.filter(pk=cycle.flock_id).update(cycle_id=cycle.pk)
Flock.objects.filter(cycle_id__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("farms", "0005_erd_named_primary_keys"),
]
operations = [
migrations.RunPython(backfill_flock_cycle_from_cycles, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.cycle",
),
),
migrations.RemoveField(
model_name="cycle",
name="flock",
),
migrations.AlterField(
model_name="cycle",
name="doc_in_weight",
field=models.FloatField(),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0006_erd_flock_belongs_to_cycle"),
]
operations = [
migrations.AlterField(
model_name="cycle",
name="doc_in_weight",
field=models.IntegerField(),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0007_cycle_doc_in_weight_int"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance",
field=models.IntegerField(default=0),
),
]
Whitespace-only changes.
+135
View File
@@ -0,0 +1,135 @@
from django.core.exceptions import ValidationError
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
from apps.accounts.models import User
@register_snippet
class Site(models.Model):
site_id = models.BigAutoField(primary_key=True)
site_name = models.CharField(max_length=30)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("site_name"),
FieldPanel("user"),
]
class Meta:
db_table = "sites"
ordering = ["site_name"]
def __str__(self) -> str:
return self.site_name
@register_snippet
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang_name"),
FieldPanel("site"),
]
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
def __str__(self) -> str:
return self.kandang_name
@register_snippet
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
cycle = models.ForeignKey(
"Cycle",
on_delete=models.CASCADE,
related_name="flocks",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("flock_name"),
FieldPanel("cycle"),
]
class Meta:
db_table = "flock"
ordering = ["flock_name"]
def __str__(self) -> str:
return self.flock_name
@register_snippet
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_CLOSED = "closed"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_CLOSED, "Closed"),
]
cycle_id = models.BigAutoField(primary_key=True)
total_days = models.IntegerField()
start_date = models.DateField()
end_date = models.DateField()
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang"),
FieldPanel("total_days"),
FieldPanel("start_date"),
FieldPanel("end_date"),
FieldPanel("doc_in_weight"),
FieldPanel("doc_in_count"),
FieldPanel("feed_initial_balance"),
FieldPanel("status"),
]
class Meta:
db_table = "cycles"
ordering = ["-start_date"]
constraints = [
models.CheckConstraint(
check=models.Q(end_date__gte=models.F("start_date")),
name="chk_cycles_date_range",
)
]
def __str__(self) -> str:
return f"Cycle #{self.pk} ({self.status})"
def clean(self) -> None:
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError({"end_date": "Must be on or after start_date"})
@property
def current_day(self) -> int:
from django.utils import timezone
today = timezone.localdate()
if today <= self.start_date:
return 1
elapsed = (today - self.start_date).days + 1
return min(max(elapsed, 1), self.total_days)
+61
View File
@@ -0,0 +1,61 @@
from rest_framework import serializers
from apps.accounts.serializers import PkAsIdMixin
from apps.farms.models import Cycle, Flock, Kandang, Site
class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Site
fields = ["id", "site_name", "user", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Kandang
fields = ["id", "kandang_name", "site", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "cycle", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
flock = serializers.SerializerMethodField()
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
class Meta:
model = Cycle
fields = [
"id",
"kandang",
"flock",
"total_days",
"current_day",
"start_date",
"end_date",
"chick_in_weight",
"doc_in_count",
"feed_initial_balance",
"status",
"created_at",
"updated_at",
]
read_only_fields = ["id", "flock", "feed_initial_balance", "created_at", "updated_at"]
def get_flock(self, obj):
flock = obj.flocks.order_by("flock_id").first()
return flock.pk if flock is not None else None
def create(self, validated_data):
kandang = validated_data["kandang"]
cycle = Cycle.objects.create(**validated_data)
Flock.objects.create(flock_name=kandang.kandang_name[:30], cycle=cycle)
return cycle
+82
View File
@@ -0,0 +1,82 @@
from datetime import date, timedelta
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
class FarmAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.site = Site.objects.create(site_name="Sukawarna", user=self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
def test_create_cycle_auto_creates_flock_and_returns_erd_fields(self):
start = date.today()
response = self.client.post(
"/api/v1/cycles/",
{
"kandang": self.kandang.pk,
"total_days": 35,
"start_date": start.isoformat(),
"end_date": (start + timedelta(days=34)).isoformat(),
"chick_in_weight": 42,
"doc_in_count": 10000,
"status": "active",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["chick_in_weight"], 42)
self.assertIsNotNone(response.data["flock"])
cycle = Cycle.objects.get(pk=response.data["id"])
flock = Flock.objects.get(pk=response.data["flock"])
self.assertEqual(cycle.doc_in_weight, 42)
self.assertEqual(flock.cycle_id, cycle.pk)
def test_list_cycles_by_kandang(self):
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
response = self.client.get("/api/v1/cycles/", {"kandang_id": self.kandang.pk})
self.assertEqual(response.status_code, 200)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["flock"], flock.pk)
def test_list_flocks_by_cycle(self):
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
other_cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
Flock.objects.create(flock_name="Flock B", cycle=other_cycle)
response = self.client.get("/api/v1/flocks/", {"cycle_id": cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual([r["id"] for r in results], [flock.pk])
+11
View File
@@ -0,0 +1,11 @@
from rest_framework.routers import DefaultRouter
from apps.farms.views import CycleViewSet, FlockViewSet, KandangViewSet, SiteViewSet
router = DefaultRouter()
router.register("sites", SiteViewSet, basename="site")
router.register("kandangs", KandangViewSet, basename="kandang")
router.register("flocks", FlockViewSet, basename="flock")
router.register("cycles", CycleViewSet, basename="cycle")
urlpatterns = router.urls
+172
View File
@@ -0,0 +1,172 @@
from datetime import date, timedelta
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.operations.models import FeedSacks, ManualInput
from apps.operations.services.karung_web import KarungWebError, request_karung
from apps.operations.services.manual_input import recompute_manual_totals
class IsSuperAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated user; mutations only for superadmin.
Staff accounts may read sites/kandangs (to pick one and create a cycle) but
cannot create, edit, or delete them — only superadmins manage structures.
"""
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
if request.method in permissions.SAFE_METHODS:
return True
return bool(request.user.is_superuser or request.user.status == User.STATUS_SUPERADMIN)
class SiteViewSet(viewsets.ModelViewSet):
queryset = Site.objects.select_related("user").all()
serializer_class = SiteSerializer
permission_classes = [IsSuperAdminForWrite]
def get_queryset(self):
qs = super().get_queryset()
user_id = self.request.query_params.get("user_id")
if user_id:
qs = qs.filter(user_id=user_id)
return qs
class KandangViewSet(viewsets.ModelViewSet):
queryset = Kandang.objects.select_related("site").all()
serializer_class = KandangSerializer
permission_classes = [IsSuperAdminForWrite]
def get_queryset(self):
qs = super().get_queryset()
site_id = self.request.query_params.get("site_id")
if site_id:
qs = qs.filter(site_id=site_id)
return qs
class FlockViewSet(viewsets.ModelViewSet):
queryset = Flock.objects.all()
serializer_class = FlockSerializer
def get_queryset(self):
qs = super().get_queryset()
cycle_id = self.request.query_params.get("cycle_id")
if cycle_id:
qs = qs.filter(cycle_id=cycle_id)
return qs
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related("kandang").prefetch_related("flocks").all()
serializer_class = CycleSerializer
def get_queryset(self):
qs = super().get_queryset()
params = self.request.query_params
if params.get("kandang_id"):
qs = qs.filter(kandang_id=params["kandang_id"])
if params.get("status"):
qs = qs.filter(status=params["status"])
return qs
@action(detail=True, methods=["post"], url_path="initial-balance")
def set_initial_balance(self, request, pk=None):
"""Save the feed-in balance for the cycle (Saldo Awal).
Sets ``feed_initial_balance`` on the cycle (the only cycle field this
feature writes — never during cycle creation), records the manual feed-in
for the balance date, then pulls the IoT-detected "in" for that date from
the karung-web-admin feed and returns a manual vs IoT accuracy figure.
"""
cycle = self.get_object()
date_str = request.data.get("date")
if not date_str:
return Response({"detail": "date is required."}, status=status.HTTP_400_BAD_REQUEST)
balance_date = parse_date(str(date_str))
if balance_date is None:
return Response(
{"detail": "date must be in YYYY-MM-DD format."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
feed_in_manual = int(request.data.get("feed_in_manual"))
except (TypeError, ValueError):
return Response(
{"detail": "feed_in_manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
if feed_in_manual < 0:
return Response(
{"detail": "feed_in_manual cannot be negative."},
status=status.HTTP_400_BAD_REQUEST,
)
cycle.feed_initial_balance = feed_in_manual
cycle.save(update_fields=["feed_initial_balance", "updated_at"])
# Pull IoT "in" at the balance date (karung-web-admin syncs today/yesterday).
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
sync_error = None
if feed_sack is None:
try:
today = date.today()
section = None
if balance_date == today:
section = "today"
elif balance_date == today - timedelta(days=1):
section = "yesterday_history"
if section:
request_karung(cycle, section=section)
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
except (KarungWebError, ValidationError) as exc:
sync_error = str(exc)
iot_in = feed_sack.in_today if feed_sack is not None else None
# Record the manual feed-in for the balance date.
ManualInput.objects.update_or_create(
cycle=cycle,
date=balance_date,
defaults={"feed_in_manual": feed_in_manual},
)
recompute_manual_totals(cycle)
# FeedSacks in_total always includes the initial balance as its base.
running = cycle.feed_initial_balance
for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"):
running += fs_row.in_today
if fs_row.in_total != running:
fs_row.in_total = running
fs_row.save(update_fields=["in_total", "updated_at"])
accuracy = None
if feed_in_manual > 0 and iot_in is not None and iot_in > 0:
accuracy = round(min(feed_in_manual, iot_in) / max(feed_in_manual, iot_in) * 1000) / 10
elif feed_in_manual == 0 and iot_in == 0:
accuracy = 100.0
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"manual": feed_in_manual,
"manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual,
"iot": iot_in,
"accuracy": accuracy,
"sync_error": sync_error,
}
)