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