first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
Whitespace-only changes.
@@ -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.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user