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 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),
),
]
@@ -0,0 +1,50 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_kandang(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
for flock in Flock.objects.select_related("cycle").iterator():
if flock.cycle_id and flock.kandang_id is None:
flock.kandang_id = flock.cycle.kandang_id
flock.save(update_fields=["kandang_id"])
class Migration(migrations.Migration):
dependencies = [
("farms", "0008_cycle_feed_initial_balance"),
]
operations = [
migrations.AddField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RunPython(backfill_flock_kandang, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flocks",
to="farms.cycle",
),
),
]
@@ -0,0 +1,32 @@
from django.db import migrations, models
import django.db.models.deletion
def remove_orphan_flocks(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
Flock.objects.filter(cycle_id__isnull=True).delete()
class Migration(migrations.Migration):
atomic = False
dependencies = [
("farms", "0009_flock_belongs_to_kandang"),
]
operations = [
migrations.RunPython(remove_orphan_flocks, migrations.RunPython.noop),
migrations.RemoveField(
model_name="flock",
name="kandang",
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.cycle",
),
),
]
@@ -0,0 +1,45 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_kandang_from_cycle(apps, schema_editor):
Flock = apps.get_model("farms", "Flock")
for flock in Flock.objects.select_related("cycle").iterator():
if flock.cycle_id and flock.kandang_id is None:
flock.kandang_id = flock.cycle.kandang_id
flock.save(update_fields=["kandang_id"])
class Migration(migrations.Migration):
atomic = False
dependencies = [
("farms", "0010_restore_flock_cycle_erd"),
]
operations = [
migrations.AddField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RunPython(backfill_flock_kandang_from_cycle, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="kandang",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.kandang",
),
),
migrations.RemoveField(
model_name="flock",
name="cycle",
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0011_flock_belongs_to_kandang"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance_date",
field=models.DateField(blank=True, null=True),
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0012_cycle_feed_initial_balance_date"),
]
operations = [
migrations.AddField(
model_name="kandang",
name="feed_in_button_url",
field=models.URLField(blank=True, default="", max_length=500),
),
]
@@ -0,0 +1,29 @@
from django.db import migrations, models
def migrate_single_url_to_list(apps, schema_editor):
Kandang = apps.get_model("farms", "Kandang")
for kandang in Kandang.objects.exclude(feed_in_button_url="").exclude(feed_in_button_url__isnull=True):
url = (kandang.feed_in_button_url or "").strip()
if url:
kandang.feed_in_button_urls = [{"label": "", "url": url}]
kandang.save(update_fields=["feed_in_button_urls"])
class Migration(migrations.Migration):
dependencies = [
("farms", "0013_kandang_feed_in_button_url"),
]
operations = [
migrations.AddField(
model_name="kandang",
name="feed_in_button_urls",
field=models.JSONField(blank=True, default=list),
),
migrations.RunPython(migrate_single_url_to_list, migrations.RunPython.noop),
migrations.RemoveField(
model_name="kandang",
name="feed_in_button_url",
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.1 for HQ GM cycle-close approval
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("accounts", "0005_executive_roles"),
("farms", "0014_kandang_feed_in_button_urls"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="close_approved_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="close_approved_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="approved_cycle_closes",
to=settings.AUTH_USER_MODEL,
),
),
]
@@ -0,0 +1,109 @@
# Generated manually for cycle close request channel
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def null_end_dates_for_open_cycles(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Cycle.objects.exclude(status="closed").update(end_date=None)
class Migration(migrations.Migration):
dependencies = [
("farms", "0015_cycle_close_approval"),
("sync", "0002_citysite_registered_by"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveConstraint(
model_name="cycle",
name="chk_cycles_date_range",
),
migrations.AlterField(
model_name="cycle",
name="end_date",
field=models.DateField(blank=True, null=True),
),
migrations.AlterField(
model_name="cycle",
name="total_days",
field=models.IntegerField(default=1),
),
migrations.AlterField(
model_name="cycle",
name="status",
field=models.CharField(
choices=[
("active", "Active"),
("pending_close", "Pending close"),
("closed", "Closed"),
],
db_index=True,
default="active",
max_length=30,
),
),
migrations.AddField(
model_name="cycle",
name="city_site",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="mirrored_cycles",
to="sync.citysite",
),
),
migrations.AddField(
model_name="cycle",
name="source_cycle_id",
field=models.BigIntegerField(
blank=True,
help_text="cycle_id on the city dashboard-cpsp deployment",
null=True,
),
),
migrations.AddField(
model_name="cycle",
name="proposed_end_date",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="close_requested_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="cycle",
name="close_requested_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="requested_cycle_closes",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddConstraint(
model_name="cycle",
constraint=models.CheckConstraint(
condition=models.Q(("end_date__isnull", True))
| models.Q(("end_date__gte", models.F("start_date"))),
name="chk_cycles_date_range",
),
),
migrations.AddConstraint(
model_name="cycle",
constraint=models.UniqueConstraint(
condition=models.Q(
("city_site__isnull", False), ("source_cycle_id__isnull", False)
),
fields=("city_site", "source_cycle_id"),
name="uniq_cycle_city_source",
),
),
migrations.RunPython(null_end_dates_for_open_cycles, migrations.RunPython.noop),
]
@@ -0,0 +1,23 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("farms", "0016_cycle_close_request_channel"),
("sync", "0002_citysite_registered_by"),
]
operations = [
migrations.AddField(
model_name="site",
name="city_site",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="hq_site",
to="sync.citysite",
),
),
]
Whitespace-only changes.
+276
View File
@@ -0,0 +1,276 @@
from datetime import date
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from apps.accounts.models import User
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")
# Linked when GM/GM Admin adds the city dashboard to their Pengaturan view list.
city_site = models.OneToOneField(
"sync.CitySite",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="hq_site",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "sites"
ordering = ["site_name"]
def __str__(self) -> str:
return self.site_name
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
feed_in_button_urls = models.JSONField(default=list, blank=True)
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)
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
def __str__(self) -> str:
return self.kandang_name
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
kandang = models.ForeignKey(
Kandang,
on_delete=models.CASCADE,
related_name="flocks",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "flock"
ordering = ["flock_name"]
def __str__(self) -> str:
return self.flock_name
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_PENDING_CLOSE = "pending_close"
STATUS_CLOSED = "closed"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_PENDING_CLOSE, "Pending close"),
(STATUS_CLOSED, "Closed"),
]
cycle_id = models.BigAutoField(primary_key=True)
total_days = models.IntegerField(default=1)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
feed_initial_balance_date = models.DateField(null=True, blank=True)
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")
city_site = models.ForeignKey(
"sync.CitySite",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="mirrored_cycles",
)
source_cycle_id = models.BigIntegerField(
null=True,
blank=True,
help_text="cycle_id on the city dashboard-cpsp deployment",
)
proposed_end_date = models.DateField(null=True, blank=True)
close_requested_at = models.DateTimeField(null=True, blank=True)
close_requested_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="requested_cycle_closes",
)
close_approved_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="approved_cycle_closes",
)
close_approved_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "cycles"
ordering = ["-start_date"]
constraints = [
models.CheckConstraint(
check=models.Q(end_date__isnull=True)
| models.Q(end_date__gte=models.F("start_date")),
name="chk_cycles_date_range",
),
models.UniqueConstraint(
fields=["city_site", "source_cycle_id"],
condition=models.Q(city_site__isnull=False, source_cycle_id__isnull=False),
name="uniq_cycle_city_source",
),
]
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"})
if (
self.proposed_end_date
and self.start_date
and self.proposed_end_date < self.start_date
):
raise ValidationError(
{"proposed_end_date": "Must be on or after start_date"}
)
def soft_end_date(self) -> date:
if self.end_date:
return self.end_date
from apps.operations.services.visibility import visible_through_date
return visible_through_date()
def resolved_status(self) -> str:
return self.status
def sync_status(self) -> bool:
return False
def save(self, *args, **kwargs):
self.sync_status()
if kwargs.get("update_fields") is None:
if self.end_date and self.start_date:
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
elif self.start_date and not self.end_date:
soft = self.soft_end_date()
self.total_days = max((soft - self.start_date).days + 1, 1)
super().save(*args, **kwargs)
def clear_close_request_fields(self) -> None:
self.proposed_end_date = None
self.close_requested_at = None
self.close_requested_by = None
def mark_pending_close(self, proposed_end_date: date, requested_at=None) -> None:
if proposed_end_date < self.start_date:
raise ValidationError(
{"proposed_end_date": "Must be on or after start_date"}
)
self.status = self.STATUS_PENDING_CLOSE
self.proposed_end_date = proposed_end_date
self.close_requested_at = requested_at or timezone.now()
self.close_approved_by = None
self.close_approved_at = None
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_approved_by",
"close_approved_at",
"updated_at",
]
)
def cancel_pending_close(self) -> None:
if self.status == self.STATUS_ACTIVE and self.proposed_end_date is None:
return
if self.status == self.STATUS_CLOSED:
raise ValidationError("Cycle is already closed.")
self.status = self.STATUS_ACTIVE
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def approve_close(self, approver: User) -> None:
if self.status != self.STATUS_PENDING_CLOSE:
raise ValidationError("Cycle is not pending close approval.")
if not self.proposed_end_date:
raise ValidationError("proposed_end_date is missing.")
self.status = self.STATUS_CLOSED
self.end_date = self.proposed_end_date
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
self.close_approved_by = approver
self.close_approved_at = timezone.now()
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"end_date",
"total_days",
"close_approved_by",
"close_approved_at",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def reject_close(self, _actor: User) -> None:
if self.status != self.STATUS_PENDING_CLOSE:
raise ValidationError("Cycle is not pending close approval.")
self.status = self.STATUS_ACTIVE
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
@property
def current_day(self) -> int:
from apps.operations.services.visibility import visible_through_date
effective = visible_through_date()
if effective < self.start_date:
return 0
elapsed = (effective - self.start_date).days
if self.end_date:
max_day = max(self.total_days - 1, 0)
return min(max(elapsed, 0), max_day)
return max(elapsed, 0)
@property
def visible_through_date(self) -> date:
from apps.operations.services.visibility import visible_through_date as cutoff_date
return cutoff_date()
+152
View File
@@ -0,0 +1,152 @@
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):
owner_name = serializers.CharField(source="user.user_name", read_only=True)
class Meta:
model = Site
fields = [
"id",
"site_name",
"user",
"owner_name",
"city_site",
"created_at",
"updated_at",
]
read_only_fields = ["id", "owner_name", "city_site", "created_at", "updated_at"]
class FeedInButtonUrlSerializer(serializers.Serializer):
label = serializers.CharField(max_length=50, required=False, allow_blank=True, default="")
url = serializers.URLField(max_length=500)
class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
feed_in_button_urls = FeedInButtonUrlSerializer(many=True, required=False)
class Meta:
model = Kandang
fields = [
"id",
"kandang_name",
"feed_in_button_urls",
"site",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
def validate_feed_in_button_urls(self, value):
cleaned = []
for item in value or []:
url = str(item.get("url", "")).strip()
if not url:
continue
label = str(item.get("label", "") or "").strip()[:50]
cleaned.append({"label": label, "url": url})
return cleaned
def update(self, instance, validated_data):
urls = validated_data.pop("feed_in_button_urls", None)
instance = super().update(instance, validated_data)
if urls is not None:
instance.feed_in_button_urls = urls
instance.save(update_fields=["feed_in_button_urls", "updated_at"])
return instance
def to_representation(self, instance):
data = super().to_representation(instance)
urls = data.get("feed_in_button_urls") or []
if not isinstance(urls, list):
urls = []
data["feed_in_button_urls"] = [
{"label": str(item.get("label", "") or ""), "url": str(item.get("url", "") or "")}
for item in urls
if isinstance(item, dict) and str(item.get("url", "") or "").strip()
]
return data
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
kandang_name = serializers.CharField(source="kandang.kandang_name", read_only=True)
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
visible_through_date = serializers.DateField(read_only=True)
soft_end_date = serializers.SerializerMethodField()
class Meta:
model = Cycle
fields = [
"id",
"kandang",
"kandang_name",
"total_days",
"current_day",
"visible_through_date",
"soft_end_date",
"start_date",
"end_date",
"chick_in_weight",
"doc_in_count",
"feed_initial_balance",
"feed_initial_balance_date",
"status",
"city_site",
"source_cycle_id",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"close_approved_by",
"close_approved_at",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"kandang_name",
"feed_initial_balance",
"feed_initial_balance_date",
"status",
"city_site",
"source_cycle_id",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"close_approved_by",
"close_approved_at",
"created_at",
"updated_at",
"soft_end_date",
]
def get_soft_end_date(self, obj: Cycle):
return obj.soft_end_date()
def validate(self, attrs):
attrs.pop("status", None)
if self.instance is None:
attrs.pop("end_date", None)
attrs.setdefault("total_days", 1)
else:
attrs.pop("end_date", None)
start = attrs.get("start_date", getattr(self.instance, "start_date", None))
end = attrs.get("end_date", getattr(self.instance, "end_date", None))
if start and end and end < start:
raise serializers.ValidationError({"end_date": "Must be on or after start_date."})
return attrs
def to_representation(self, instance):
return super().to_representation(instance)
Whitespace-only changes.
@@ -0,0 +1,43 @@
"""Link HQ Site rows to CitySite registry entries (Pengaturan view list)."""
from __future__ import annotations
from apps.farms.models import Site
from apps.sync.models import CitySite
def ensure_hq_site_for_city(city: CitySite) -> Site:
"""
Ensure the city registry entry has an HQ Site for farm visibility.
Prefers an existing linked site, then an unlinked site owned by the
registrar with a matching name (so seeded Sukawarna attaches when
registered), otherwise creates a new Site.
"""
existing = Site.objects.filter(city_site=city).first()
if existing:
return existing
owner = city.registered_by
if owner is None:
raise ValueError("CitySite.registered_by is required to create an HQ site.")
site_name = (city.name or city.code or "Site")[:30]
orphan = (
Site.objects.filter(user=owner, city_site__isnull=True, site_name__iexact=site_name)
.order_by("pk")
.first()
)
if orphan is None and city.code:
# e.g. code "sukawarna" ↔ site_name "Sukawarna"
orphan = (
Site.objects.filter(user=owner, city_site__isnull=True, site_name__iexact=city.code)
.order_by("pk")
.first()
)
if orphan is not None:
orphan.city_site = city
orphan.save(update_fields=["city_site", "updated_at"])
return orphan
return Site.objects.create(site_name=site_name, user=owner, city_site=city)
+49
View File
@@ -0,0 +1,49 @@
"""Push close decisions from pusat to city dashboard-cpsp."""
from __future__ import annotations
import logging
import requests
from apps.farms.models import Cycle
logger = logging.getLogger(__name__)
class SiteClosePushError(Exception):
def __init__(self, message: str):
self.message = message
super().__init__(message)
def push_close_decision(cycle: Cycle, *, approved: bool) -> None:
city = cycle.city_site
if not city or not city.api_base_url or not city.api_key or not cycle.source_cycle_id:
logger.info("No city channel configured for cycle %s; skip push", cycle.pk)
return
base = city.api_base_url.rstrip("/")
url = f"{base}/cycles/{cycle.source_cycle_id}/apply-close-decision/"
payload = {"decision": "approve" if approved else "reject"}
if approved:
if not cycle.end_date:
raise SiteClosePushError("Approved cycle is missing end_date.")
payload["end_date"] = cycle.end_date.isoformat()
try:
response = requests.post(
url,
json=payload,
headers={"X-API-Key": city.api_key, "Content-Type": "application/json"},
timeout=30,
)
if response.status_code >= 400:
raise SiteClosePushError(
f"Gagal kirim keputusan ke situs ({response.status_code}: {response.text[:200]})"
)
except SiteClosePushError:
raise
except requests.RequestException as exc:
logger.exception("Push close decision failed for cycle %s", cycle.pk)
raise SiteClosePushError("Gagal kirim keputusan ke situs — coba lagi") from exc
+394
View File
@@ -0,0 +1,394 @@
from datetime import date, timedelta
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import FeedSacks, ManualInput
class FarmAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
self.admin = User.objects.create_user(
user_name="admin",
password="secret",
status=User.STATUS_DIRECTOR_ADMIN,
)
self.client = APIClient()
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_flock_under_kandang_requires_director_admin(self):
self.client.force_authenticate(self.admin)
response = self.client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["flock_name"], "Lantai 1")
self.assertEqual(response.data["kandang"], self.kandang.pk)
def test_staff_cannot_create_flock(self):
self.client.force_authenticate(self.user)
response = self.client.post(
"/api/v1/flocks/",
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
format="json",
)
self.assertEqual(response.status_code, 403, response.data)
def test_create_cycle_without_flock(self):
self.client.force_authenticate(self.user)
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.assertNotIn("flock", response.data)
self.assertNotIn("flock_name", response.data)
def test_update_cycle(self):
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
new_end = start + timedelta(days=39)
response = self.client.patch(
f"/api/v1/cycles/{cycle.pk}/",
{
"end_date": new_end.isoformat(),
"total_days": 40,
"doc_in_count": 9500,
"chick_in_weight": 42,
},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
cycle.refresh_from_db()
self.assertEqual(cycle.end_date, new_end)
self.assertEqual(cycle.total_days, 40)
self.assertEqual(cycle.doc_in_count, 9500)
self.assertEqual(cycle.doc_in_weight, 42)
def test_cycle_stays_active_without_gm_approval(self):
"""HQ does not auto-close by publish cutoff — GM approve-close is required."""
self.client.force_authenticate(self.user)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
response = self.client.get(f"/api/v1/cycles/{cycle.pk}/")
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["status"], Cycle.STATUS_ACTIVE)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
def test_gm_can_approve_cycle_close(self):
gm = User.objects.create_user(user_name="gm_approver", password="x", status=User.STATUS_GM)
site = Site.objects.create(site_name="GM Site", user=gm)
kandang = Kandang.objects.create(kandang_name="Kandang GM", site=site)
self.client.force_authenticate(gm)
start = date(2026, 8, 1)
proposed = start + timedelta(days=34)
cycle = Cycle.objects.create(
kandang=kandang,
total_days=1,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_PENDING_CLOSE,
proposed_end_date=proposed,
)
response = self.client.post(f"/api/v1/cycles/{cycle.pk}/approve-close/")
self.assertEqual(response.status_code, 200, response.data)
body = response.data.get("cycle", response.data)
self.assertEqual(body["status"], Cycle.STATUS_CLOSED)
self.assertEqual(body["end_date"], proposed.isoformat())
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_CLOSED)
self.assertEqual(cycle.close_approved_by_id, gm.pk)
self.assertIsNotNone(cycle.close_approved_at)
self.assertEqual(cycle.end_date, proposed)
def test_gm_can_reject_cycle_close(self):
gm = User.objects.create_user(user_name="gm_rejector", password="x", status=User.STATUS_GM)
site = Site.objects.create(site_name="GM Reject Site", user=gm)
kandang = Kandang.objects.create(kandang_name="Kandang R", site=site)
self.client.force_authenticate(gm)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=kandang,
total_days=1,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_PENDING_CLOSE,
proposed_end_date=start + timedelta(days=20),
)
response = self.client.post(f"/api/v1/cycles/{cycle.pk}/reject-close/")
self.assertEqual(response.status_code, 200, response.data)
body = response.data.get("cycle", response.data)
self.assertEqual(body["status"], Cycle.STATUS_ACTIVE)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_ACTIVE)
self.assertIsNone(cycle.proposed_end_date)
def test_gm_admin_cannot_approve_cycle_close(self):
gm_admin = User.objects.create_user(
user_name="gm_admin_user", password="x", status=User.STATUS_GM_ADMIN
)
site = Site.objects.create(site_name="GM Admin Site", user=gm_admin)
kandang = Kandang.objects.create(kandang_name="Kandang GMA", site=site)
self.client.force_authenticate(gm_admin)
start = date(2026, 8, 1)
cycle = Cycle.objects.create(
kandang=kandang,
total_days=1,
start_date=start,
end_date=None,
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_PENDING_CLOSE,
proposed_end_date=start + timedelta(days=34),
)
response = self.client.post(f"/api/v1/cycles/{cycle.pk}/approve-close/")
self.assertEqual(response.status_code, 403)
cycle.refresh_from_db()
self.assertEqual(cycle.status, Cycle.STATUS_PENDING_CLOSE)
def test_flock_persists_across_cycles(self):
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
start = date.today()
for offset in (0, 40):
Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start + timedelta(days=offset),
end_date=start + timedelta(days=offset + 34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1)
self.assertEqual(flock.kandang_id, self.kandang.pk)
def test_current_day_is_zero_based_from_start_date(self):
start = date(2026, 9, 2)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
with patch(
"apps.operations.services.visibility.visible_through_date",
return_value=start,
):
self.assertEqual(cycle.current_day, 0)
with patch(
"apps.operations.services.visibility.visible_through_date",
return_value=start + timedelta(days=1),
):
self.assertEqual(cycle.current_day, 1)
def test_list_flocks_by_kandang(self):
self.client.force_authenticate(self.user)
Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang)
other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site)
Flock.objects.create(flock_name="Other", kandang=other_kandang)
response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.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), 2)
def test_set_initial_balance_does_not_create_feed_sack_row(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 120)
self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists())
self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists())
def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self):
start = date.today()
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=1),
in_today=4,
in_total=4,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 4)
def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(self):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
feed_row = FeedSacks.objects.create(
cycle=cycle,
date=start + timedelta(days=2),
in_today=6,
in_total=6,
out_today=0,
out_total=0,
feed_use_today=0,
feed_use_total=0,
)
self.client.force_authenticate(self.user)
first = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(first.status_code, 200, first.data)
second = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80},
format="json",
)
self.assertEqual(second.status_code, 200, second.data)
cycle.refresh_from_db()
self.assertEqual(cycle.feed_initial_balance, 80)
self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1))
self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1)
feed_row.refresh_from_db()
self.assertEqual(feed_row.in_total, 6)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(115, None))
def test_set_initial_balance_compares_manual_against_combined_api_iot(self, mock_pull):
start = date(2026, 8, 26)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.post(
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
{"date": start.isoformat(), "feed_in_manual": 120},
format="json",
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 115)
self.assertEqual(response.data["accuracy"], 95.8)
mock_pull.assert_called_once_with(cycle, start)
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(112, None))
def test_compare_initial_balance_returns_iot_and_accuracy(self, mock_pull):
start = date(2026, 8, 27)
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=start,
end_date=start + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
feed_initial_balance=120,
status=Cycle.STATUS_ACTIVE,
)
self.client.force_authenticate(self.user)
response = self.client.get(
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
{"date": start.isoformat(), "manual": 120},
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["iot"], 112)
self.assertEqual(response.data["accuracy"], 93.3)
mock_pull.assert_called_once_with(cycle, start)
@@ -0,0 +1,75 @@
from django.test import Client, TestCase
from apps.accounts.models import User
from apps.farms.models import Kandang, Site
class KandangFeedInButtonUrlTests(TestCase):
def setUp(self):
self.owner = User.objects.create_user(user_name="owner", password="secret")
self.admin = User.objects.create_user(
user_name="admin",
password="admin123",
status=User.STATUS_DIRECTOR_ADMIN,
)
self.staff = User.objects.create_user(
user_name="staff",
password="staff123",
status=User.STATUS_GM,
)
self.site = Site.objects.create(site_name="Site A", user=self.owner)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
self.payload = {
"feed_in_button_urls": [{"label": "Gudang A", "url": "http://192.168.1.10:8080/"}]
}
def _csrf_headers(self, client: Client) -> dict[str, str]:
token = client.cookies["csrftoken"].value
return {"HTTP_X_CSRFTOKEN": token}
def _login(self, client: Client, user_name: str, password: str) -> None:
client.get("/api/v1/auth/csrf/")
response = client.post(
"/api/v1/auth/login/",
{"user_name": user_name, "password": password},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
def test_director_admin_can_save_feed_in_button_urls(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "admin", "admin123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
self.payload,
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
self.kandang.refresh_from_db()
self.assertEqual(self.kandang.feed_in_button_urls, self.payload["feed_in_button_urls"])
def test_staff_cannot_save_feed_in_button_urls(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "staff", "staff123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
self.payload,
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 403, response.content)
self.kandang.refresh_from_db()
self.assertEqual(self.kandang.feed_in_button_urls, [])
def test_staff_cannot_rename_kandang(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "staff", "staff123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
{"kandang_name": "Renamed"},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 403, response.content)
+22
View File
@@ -0,0 +1,22 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.farms.views import (
CycleViewSet,
FlockViewSet,
KandangViewSet,
SiteViewSet,
cycle_close_cancel,
cycle_close_ingest,
)
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 = [
path("cycle-close/ingest/", cycle_close_ingest, name="cycle-close-ingest"),
path("cycle-close/cancel/", cycle_close_cancel, name="cycle-close-cancel"),
] + router.urls
+382
View File
@@ -0,0 +1,382 @@
from django.core.exceptions import ValidationError
from django.utils.dateparse import parse_date, parse_datetime
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from apps.accounts.models import User
from apps.accounts.permissions import CanApproveCycleClose, can_approve_cycle_close
from apps.accounts.scoping import (
filter_cycles_queryset,
filter_flocks_queryset,
filter_kandangs_queryset,
filter_sites_queryset,
)
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.farms.serializers import CycleSerializer, FlockSerializer, KandangSerializer, SiteSerializer
from apps.farms.services.site_close import SiteClosePushError, push_close_decision
from apps.operations.services.feed_sacks import (
cleanup_opening_balance_feed_rows,
recompute_feed_sack_in_totals,
)
from apps.operations.services.karung_web import (
initial_balance_accuracy,
pull_initial_balance_iot,
)
from apps.farms.services.city_registry import ensure_hq_site_for_city
from apps.sync.models import CitySite
def _validation_detail(exc: ValidationError):
if hasattr(exc, "message_dict"):
return exc.message_dict
if hasattr(exc, "messages"):
return "; ".join(str(m) for m in exc.messages)
return str(getattr(exc, "message", exc))
def _upsert_mirror_cycle_from_ingest(city: CitySite, payload: dict) -> Cycle:
source_cycle_id = payload.get("source_cycle_id")
if not source_cycle_id:
raise ValidationError("source_cycle_id is required.")
proposed = parse_date(str(payload.get("proposed_end_date") or ""))
if proposed is None:
raise ValidationError("proposed_end_date is required.")
start = parse_date(str(payload.get("start_date") or ""))
if start is None:
raise ValidationError("start_date is required.")
site_name = str(payload.get("site_name") or city.name)[:30]
kandang_name = str(payload.get("kandang_name") or "Kandang")[:30]
owner = city.registered_by
if owner is None:
owner = User.objects.filter(status=User.STATUS_GM).first()
if owner is None:
raise ValidationError("No GM available to own mirrored site.")
site = ensure_hq_site_for_city(city)
if site.site_name != site_name and not Site.objects.filter(
user=owner, site_name=site_name
).exclude(pk=site.pk).exists():
# Prefer ingest site_name when it does not collide.
site.site_name = site_name
site.save(update_fields=["site_name", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
site=site,
kandang_name=kandang_name,
defaults={},
)
cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first()
if cycle is None:
cycle = Cycle(
kandang=kandang,
city_site=city,
source_cycle_id=source_cycle_id,
start_date=start,
end_date=None,
doc_in_weight=int(payload.get("doc_in_weight") or 0),
doc_in_count=int(payload.get("doc_in_count") or 0),
total_days=int(payload.get("total_days") or 1),
status=Cycle.STATUS_ACTIVE,
)
cycle.save()
else:
cycle.kandang = kandang
cycle.start_date = start
cycle.doc_in_weight = int(payload.get("doc_in_weight") or cycle.doc_in_weight)
cycle.doc_in_count = int(payload.get("doc_in_count") or cycle.doc_in_count)
cycle.save()
requested_dt = parse_datetime(str(payload.get("requested_at") or "")) or None
cycle.mark_pending_close(proposed, requested_at=requested_dt)
return cycle
@api_view(["POST"])
@permission_classes([permissions.IsAuthenticated])
def cycle_close_ingest(request):
"""Site → pusat: register/update a pending close request."""
city_site_id = request.data.get("city_site_id")
try:
city = CitySite.objects.get(pk=city_site_id, is_active=True)
except (CitySite.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST)
try:
cycle = _upsert_mirror_cycle_from_ingest(city, request.data)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data)
@api_view(["POST"])
@permission_classes([permissions.IsAuthenticated])
def cycle_close_cancel(request):
"""Site → pusat: cancel a pending close request."""
city_site_id = request.data.get("city_site_id")
source_cycle_id = request.data.get("source_cycle_id")
try:
city = CitySite.objects.get(pk=city_site_id, is_active=True)
except (CitySite.DoesNotExist, TypeError, ValueError):
return Response({"detail": "Invalid city_site_id."}, status=status.HTTP_400_BAD_REQUEST)
cycle = Cycle.objects.filter(city_site=city, source_cycle_id=source_cycle_id).first()
if cycle is None:
return Response({"detail": "Mirror cycle not found."}, status=status.HTTP_404_NOT_FOUND)
try:
cycle.cancel_pending_close()
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(CycleSerializer(cycle).data)
class IsDirectorAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated executive; structure writes for director_admin.
Mirrored farm structures should normally arrive via city sync. Manual
create/edit/delete is reserved for director_admin (bootstrap / repair).
"""
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 getattr(request.user, "status", None)
in {
User.STATUS_DIRECTOR_ADMIN,
User.STATUS_SUPERUSER,
}
)
class SiteViewSet(viewsets.ModelViewSet):
queryset = Site.objects.select_related("user").all()
serializer_class = SiteSerializer
permission_classes = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_sites_queryset(super().get_queryset(), self.request.user)
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 = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_kandangs_queryset(super().get_queryset(), self.request.user)
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.select_related("kandang").all()
serializer_class = FlockSerializer
permission_classes = [IsDirectorAdminForWrite]
def get_queryset(self):
qs = filter_flocks_queryset(super().get_queryset(), self.request.user)
kandang_id = self.request.query_params.get("kandang_id")
if kandang_id:
qs = qs.filter(kandang_id=kandang_id)
return qs
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related(
"kandang", "close_approved_by", "city_site", "kandang__site"
).all()
serializer_class = CycleSerializer
def get_queryset(self):
qs = filter_cycles_queryset(super().get_queryset(), self.request.user)
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="approve-close",
permission_classes=[CanApproveCycleClose],
)
def approve_close(self, request, pk=None):
"""GM-only: approve closing a cycle on the pusat mirror."""
if not can_approve_cycle_close(request.user):
return Response({"detail": "Only GM may approve cycle close."}, status=status.HTTP_403_FORBIDDEN)
cycle = self.get_object()
try:
cycle.approve_close(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
site_error = None
try:
push_close_decision(cycle, approved=True)
except SiteClosePushError as exc:
site_error = exc.message
data = CycleSerializer(cycle).data
if site_error:
return Response({"cycle": data, "site_error": site_error})
return Response(data)
@action(
detail=True,
methods=["post"],
url_path="reject-close",
permission_classes=[CanApproveCycleClose],
)
def reject_close(self, request, pk=None):
"""GM-only: reject a pending close request."""
if not can_approve_cycle_close(request.user):
return Response({"detail": "Only GM may reject cycle close."}, status=status.HTTP_403_FORBIDDEN)
cycle = self.get_object()
try:
cycle.reject_close(request.user)
except ValidationError as exc:
return Response({"detail": _validation_detail(exc)}, status=status.HTTP_400_BAD_REQUEST)
site_error = None
try:
push_close_decision(cycle, approved=False)
except SiteClosePushError as exc:
site_error = exc.message
data = CycleSerializer(cycle).data
if site_error:
return Response({"cycle": data, "site_error": site_error})
return Response(data)
@action(detail=True, methods=["get"], url_path="initial-balance-compare")
def compare_initial_balance(self, request, pk=None):
"""Pull IoT masuk for a date and compare against the cycle saldo awal."""
cycle = self.get_object()
date_str = request.query_params.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,
)
manual_raw = request.query_params.get("manual")
if manual_raw is None:
feed_in_manual = cycle.feed_initial_balance
else:
try:
feed_in_manual = int(manual_raw)
except (TypeError, ValueError):
return Response(
{"detail": "manual must be an integer."},
status=status.HTTP_400_BAD_REQUEST,
)
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": initial_balance_accuracy(feed_in_manual, iot_in),
"sync_error": sync_error,
}
)
@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). Opening balance is kept on
the cycle for saldo only and is not written into daily FeedSacks rows.
Pulls the IoT-detected "in" for that date from karung-web-admin
``/api/combined`` 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,
)
previous_date = cycle.feed_initial_balance_date
previous_balance = cycle.feed_initial_balance
cycle.feed_initial_balance = feed_in_manual
cycle.feed_initial_balance_date = balance_date
cycle.save(
update_fields=["feed_initial_balance", "feed_initial_balance_date", "updated_at"]
)
# Pull IoT "in" at the balance date from karung-web-admin /api/combined.
iot_in, sync_error = pull_initial_balance_iot(cycle, balance_date)
cleanup_opening_balance_feed_rows(
cycle,
previous_date=previous_date,
previous_balance=previous_balance,
balance_date=balance_date,
balance_amount=feed_in_manual,
)
recompute_feed_sack_in_totals(cycle)
accuracy = initial_balance_accuracy(feed_in_manual, iot_in)
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"feed_initial_balance_date": (
cycle.feed_initial_balance_date.isoformat()
if cycle.feed_initial_balance_date
else None
),
"manual": feed_in_manual,
"manual_total": feed_in_manual,
"iot": iot_in,
"accuracy": accuracy,
"sync_error": sync_error,
}
)