update docker deployment and changing seed kandang name

This commit is contained in:
Alberto-Audrix committed 2026-08-31 11:10:32 +07:00
1 parent 76f4d0106a
commit 74dd3faf60
40 files changed
+1253 -122

No files matched your search

+13 -5
View File
@@ -1,3 +1,5 @@
from datetime import date
from django.core.exceptions import ValidationError
from django.db import models
from wagtail.admin.panels import FieldPanel
@@ -126,10 +128,16 @@ class Cycle(models.Model):
@property
def current_day(self) -> int:
from django.utils import timezone
from apps.operations.services.visibility import visible_through_date
today = timezone.localdate()
if today <= self.start_date:
return 1
elapsed = (today - self.start_date).days + 1
effective = visible_through_date()
if effective < self.start_date:
return 0
elapsed = (effective - self.start_date).days + 1
return min(max(elapsed, 1), self.total_days)
@property
def visible_through_date(self) -> date:
from apps.operations.services.visibility import visible_through_date as cutoff_date
return cutoff_date()
+23 -2
View File
@@ -27,18 +27,24 @@ class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
kandang_name = serializers.CharField(source="kandang.kandang_name", read_only=True)
flock = serializers.SerializerMethodField()
flock_name = serializers.SerializerMethodField()
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
visible_through_date = serializers.DateField(read_only=True)
class Meta:
model = Cycle
fields = [
"id",
"kandang",
"kandang_name",
"flock",
"flock_name",
"total_days",
"current_day",
"visible_through_date",
"start_date",
"end_date",
"chick_in_weight",
@@ -48,12 +54,27 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
"created_at",
"updated_at",
]
read_only_fields = ["id", "flock", "feed_initial_balance", "created_at", "updated_at"]
read_only_fields = [
"id",
"kandang_name",
"flock",
"flock_name",
"feed_initial_balance",
"created_at",
"updated_at",
]
def _first_flock(self, obj):
return obj.flocks.order_by("flock_id").first()
def get_flock(self, obj):
flock = obj.flocks.order_by("flock_id").first()
flock = self._first_flock(obj)
return flock.pk if flock is not None else None
def get_flock_name(self, obj):
flock = self._first_flock(obj)
return flock.flock_name if flock is not None else None
def create(self, validated_data):
kandang = validated_data["kandang"]
cycle = Cycle.objects.create(**validated_data)
+4
View File
@@ -32,6 +32,8 @@ class FarmAPITests(TestCase):
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["chick_in_weight"], 42)
self.assertEqual(response.data["kandang_name"], "Kandang 1")
self.assertEqual(response.data["flock_name"], "Kandang 1")
self.assertIsNotNone(response.data["flock"])
cycle = Cycle.objects.get(pk=response.data["id"])
flock = Flock.objects.get(pk=response.data["flock"])
@@ -54,6 +56,8 @@ class FarmAPITests(TestCase):
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["flock"], flock.pk)
self.assertEqual(results[0]["kandang_name"], "Kandang 1")
self.assertEqual(results[0]["flock_name"], "Flock A")
def test_list_flocks_by_cycle(self):
cycle = Cycle.objects.create(
@@ -0,0 +1,66 @@
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import ApiKey, User
class Command(BaseCommand):
help = (
"Create production superadmin login only (no demo sites/cycles/data). "
"Uses BOOTSTRAP_ADMIN_USER and BOOTSTRAP_ADMIN_PASSWORD from settings."
)
def add_arguments(self, parser):
parser.add_argument(
"--force-password",
action="store_true",
help="Reset password even when the admin user already has one",
)
@transaction.atomic
def handle(self, *args, **options):
user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not password:
raise CommandError(
"BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin."
)
user, created = User.objects.get_or_create(
user_name=user_name,
defaults={
"status": User.STATUS_SUPERADMIN,
"is_staff": True,
"is_superuser": True,
},
)
should_set_password = created or not user.has_usable_password() or options["force_password"]
if should_set_password:
user.set_password(password)
user.status = User.STATUS_SUPERADMIN
user.is_staff = True
user.is_superuser = True
user.save()
action = "Created" if created else "Updated password for"
self.stdout.write(f"{action} superadmin {user_name}")
else:
user.status = User.STATUS_SUPERADMIN
user.is_superuser = True
user.save(update_fields=["status", "is_superuser", "updated_at"])
self.stdout.write(f"Superadmin {user_name} already exists (password unchanged)")
bootstrap_key = settings.BOOTSTRAP_API_KEY
if bootstrap_key:
api_key, _ = ApiKey.generate(user, "bootstrap")
api_key.prefix = bootstrap_key[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap_key)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
self.stdout.write(
self.style.SUCCESS(
f"Bootstrap OK: login={user_name} (no demo farm data — use Pengaturan to add sites/kandang/cycles)"
)
)
@@ -3,28 +3,33 @@ from django.db.models import Sum
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting, FeedSacks, KPI
from apps.operations.services.visibility import visible_through_date
class Command(BaseCommand):
help = "Recompute simple KPI rollups from ops tables for active cycles"
def handle(self, *args, **options):
cutoff = visible_through_date()
count = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
counting = ChickenCounting.objects.filter(cycle=cycle).aggregate(
def visible(model):
return model.objects.filter(cycle=cycle, date__lte=cutoff)
counting = visible(ChickenCounting).aggregate(
mortality=Sum("mortality_count"),
life=Sum("total_count"),
)
feed_sack = FeedSacks.objects.filter(cycle=cycle).order_by("-date", "-pk").first()
feed_sack = visible(FeedSacks).order_by("-date", "-pk").first()
latest_cc = ChickenCounting.objects.filter(cycle=cycle).order_by("-date").first()
latest_cc = visible(ChickenCounting).order_by("-date").first()
latest_cw = None
latest_fs = feed_sack
latest_mi = None
from apps.operations.models import ChickenWeight, ManualInput
latest_cw = ChickenWeight.objects.filter(cycle=cycle).order_by("-date").first()
latest_mi = ManualInput.objects.filter(cycle=cycle).order_by("-date").first()
latest_cw = visible(ChickenWeight).order_by("-date").first()
latest_mi = visible(ManualInput).order_by("-date").first()
if not all([latest_cc, latest_cw, latest_fs, latest_mi]):
self.stdout.write(f"cycle={cycle.pk} skipped (missing source records)")
@@ -8,8 +8,8 @@ from apps.accounts.models import ApiKey, User
class Command(BaseCommand):
help = (
"Seed demo superadmin login + API key, then Sukawarna Kandang 4 "
"and Kandang 5 as the default closed cycles"
"Seed demo superadmin login + API key, then Sukawarna Kandang 1 "
"and Kandang 2 as the default closed cycles"
)
@transaction.atomic
@@ -43,6 +43,6 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS(
"Seed OK: Sukawarna Kandang 4 & Kandang 5 closed cycles + 10-minute IoT panels"
"Seed OK: Sukawarna Kandang 1 & Kandang 2 closed cycles + 10-minute IoT panels"
)
)
@@ -1,6 +1,6 @@
"""Seed 10-minute IoT panel readings (144/day) for every flock.
Kandang 5 Lantai 1 is seeded from real sensor snapshots pulled from
Kandang 2 Lantai 1 is seeded from real sensor snapshots pulled from
``dashboard.cpsp.id/api/iot/flocks/`` (cached in ``scripts/iot_panel_10min_extract.json``);
every other flock/floor is generated from that data with a deterministic per-house
jitter so each cycle day holds a full 00:00..23:50 run of 144 readings.
@@ -157,9 +157,9 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Seeded {seeded} IoT panel readings"))
def profile_for(self, kandang_name: str, flock_name: str) -> dict:
"""Per-house transform applied to the real Kandang 5 Lantai 1 readings."""
"""Per-house transform applied to the real Kandang 2 Lantai 1 readings."""
is_lantai1 = flock_name == "Lantai 1"
if kandang_name == "Kandang 5":
if kandang_name == "Kandang 2":
if is_lantai1:
return {"real": True}
return {
@@ -168,7 +168,7 @@ class Command(BaseCommand):
"temperature_delta": -0.3,
"water_factor": 0.96,
}
# Kandang 4: deterministic jitter (same approach as seed_sukawarna_kandang4).
# Kandang 1: deterministic jitter (same approach as seed_sukawarna_kandang4).
return {
"temperature_jitter": (-0.4, 0.4),
"humidity_jitter": (-3.0, 3.0),
@@ -1,8 +1,8 @@
"""Seed Sukawarna / Kandang 4 closed cycle.
"""Seed Sukawarna / Kandang 1 closed cycle.
Kandang 4 is a generated dataset modeled on the Kandang 5 seed
Kandang 1 is a generated dataset modeled on the Kandang 2 seed
(``seed_sukawarna_kandang5``). The two houses run the same 49-day window at
the same site, but Kandang 4 is stocked with 45,000 DOC and performs slightly
the same site, but Kandang 1 is stocked with 45,000 DOC and performs slightly
better (lower mortality, marginally better FCR / EEF).
Every daily series is hand-tuned so the numbers stay internally consistent:
@@ -17,11 +17,11 @@ Every daily series is hand-tuned so the numbers stay internally consistent:
57.47 t live weight, avg ~1,412 g/bird at ~33.1 days; tail harvests leave
fewer than 50 birds in the house at close-out.
* ``kpi`` / ``manual`` – FCR/EEF/lot-weight follow the same formulas as the
Kandang 5 preview:
Kandang 2 preview:
FCR = feed_total_kg / (harvest_weight_total_g + lot_weight_g)
EEF = life_pct * (avg_harvest_weight/1000) / (FCR * avg_harvest_day) * 100
lot = chicken_life * average_weight
* ``iot_panel`` – derived from the real Kandang 5 sensor extract with
* ``iot_panel`` – derived from the real Kandang 2 sensor extract with
deterministic per-house jitter (same site, same season).
"""
@@ -159,7 +159,7 @@ def _round(value: float, ndigits: int = 2) -> float:
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 4 closed cycle (generated, modeled on Kandang 5)"
help = "Seed Sukawarna / Kandang 1 closed cycle (generated, modeled on Kandang 2)"
def add_arguments(self, parser):
parser.add_argument("--password", default=DEFAULT_PASSWORD)
@@ -195,7 +195,7 @@ class Command(BaseCommand):
site.save(update_fields=["user", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 4",
kandang_name="Kandang 1",
site=site,
)
@@ -209,7 +209,7 @@ class Command(BaseCommand):
IotPanel.objects.filter(flock__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 4 cycle")
self.stdout.write("Reset previous Kandang 1 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
@@ -397,7 +397,7 @@ class Command(BaseCommand):
mort_total += MORTALITY[i]
# --- IoT panel: Kandang 5 sensors with per-house jitter -------------
# --- IoT panel: Kandang 2 sensors with per-house jitter -------------
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
@@ -35,7 +35,7 @@ OUT_TODAY = [0] * 42 + [4, 5, 4, 3, 2, 1, 1]
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 5 closed cycle from preview xlsx + IoT extract"
help = "Seed Sukawarna / Kandang 2 closed cycle from preview xlsx + IoT extract"
def add_arguments(self, parser):
parser.add_argument("--password", default=DEFAULT_PASSWORD)
@@ -82,7 +82,7 @@ class Command(BaseCommand):
site.save(update_fields=["user", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 5",
kandang_name="Kandang 2",
site=site,
)
@@ -99,7 +99,7 @@ class Command(BaseCommand):
IotPanel.objects.filter(flock__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 5 cycle")
self.stdout.write("Reset previous Kandang 2 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
@@ -0,0 +1,23 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.operations.services.iot_api import IotApiError, sync_iot_panels
class Command(BaseCommand):
help = "Sync IoT panel readings from the external IoT flocks API (10-minute snapshots)"
def handle(self, *args, **options):
if not settings.IOT_SYNC_ENABLED:
self.stdout.write("IOT_SYNC_ENABLED=false; skipping")
return
try:
summary = sync_iot_panels()
except IotApiError as exc:
self.stderr.write(str(exc))
return
self.stdout.write(
self.style.SUCCESS(
f"IoT sync OK: upserted={summary.upserted} skipped={summary.skipped} pages={summary.pages}"
)
)
+2 -2
View File
@@ -1,4 +1,4 @@
"""Load Sukawarna / Kandang 5 preview sheets into ORM-ready row dicts."""
"""Load Sukawarna / Kandang 2 preview sheets into ORM-ready row dicts."""
from __future__ import annotations
@@ -11,7 +11,7 @@ from openpyxl import load_workbook
REPO_ROOT = Path(__file__).resolve().parents[3]
PREVIEW_XLSX = REPO_ROOT / "backup" / "kandang5_sukawarna_preview.xlsx"
IOT_EXTRACT_JSON = REPO_ROOT / "scripts" / "iot_panel_cycle_extract.json"
# Final-cycle report (reference for manual feed use on Kandang 5).
# Final-cycle report (reference for manual feed use on Kandang 2).
FEED_REFERENCE_XLSX = (
REPO_ROOT
/ "backup"
+21 -28
View File
@@ -1,32 +1,25 @@
from datetime import date, datetime
from io import BytesIO
from django.core.management.base import CommandError
from django.test import TestCase, override_settings
from django.test import SimpleTestCase
from openpyxl import Workbook
from apps.jobs.sukawarna_preview import as_date, as_number, dated_rows
from apps.accounts.models import User
from apps.farms.models import Cycle, Site
from django.core.management import call_command
class SukawarnaPreviewHelpersTests(SimpleTestCase):
def test_as_date_accepts_iso_and_datetime(self):
self.assertEqual(as_date("2026-05-22"), date(2026, 5, 22))
self.assertEqual(as_date(datetime(2026, 7, 9, 12, 0)), date(2026, 7, 9))
self.assertIsNone(as_date("grams"))
self.assertIsNone(as_date("karung tuang Jul 4-8 updated"))
@override_settings(
BOOTSTRAP_ADMIN_USER="admin",
BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n",
)
class BootstrapAdminTests(TestCase):
def test_creates_superadmin_without_demo_data(self):
call_command("bootstrap_admin")
user = User.objects.get(user_name="admin")
self.assertEqual(user.status, User.STATUS_SUPERADMIN)
self.assertTrue(user.check_password("Pr04dm1n"))
self.assertFalse(Site.objects.exists())
self.assertFalse(Cycle.objects.exists())
def test_dated_rows_skips_note_rows(self):
wb = Workbook()
ws = wb.active
ws.append(["date", "total_count"])
ws.append(["grams", "grams"])
ws.append(["2026-05-22", 13980])
bio = BytesIO()
wb.save(bio)
bio.seek(0)
from openpyxl import load_workbook
loaded = load_workbook(bio, data_only=True).active
rows = dated_rows(loaded)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["date"], date(2026, 5, 22))
self.assertEqual(as_number(rows[0]["total_count"]), 13980)
def test_requires_password(self):
with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
+255
View File
@@ -0,0 +1,255 @@
"""Sync IoT panel readings from dashboard.cpsp.id/api/iot/flocks/ (10-minute snapshots)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone as dt_timezone
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from django.conf import settings
from django.utils import timezone
from apps.farms.models import Flock
from apps.operations.models import IotPanel
WIB = ZoneInfo("Asia/Jakarta")
CHILL_FACTOR_POINTS = (
(0, 8.0),
(1, 8.0),
(7, 7.0),
(14, 6.0),
(21, 4.5),
(28, 3.5),
(35, 3.5),
(42, 3.0),
)
class IotApiError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _sensor_celsius(sensor: dict | float | int | None) -> float | None:
if sensor is None:
return None
if isinstance(sensor, (int, float)):
return float(sensor)
raw = sensor.get("value")
if raw is None:
return None
cal = float(sensor.get("calibration") or 0)
return (float(raw) + cal) / 10.0
def _sensor_humidity_pct(sensor: dict | float | int | None, fallback: float | None) -> float:
if isinstance(sensor, dict):
raw = sensor.get("value")
if raw is not None:
cal = float(sensor.get("calibration") or 0)
return (float(raw) + cal) / 10.0
if isinstance(sensor, (int, float)):
return float(sensor)
if fallback is None:
return 70.0
value = float(fallback)
return value / 10.0 if value > 100 else value
def _wind_ms(payload_wind: float | int | None, sensors_wind: dict | float | int | None) -> float:
if isinstance(sensors_wind, dict) and sensors_wind.get("value") is not None:
return float(sensors_wind["value"]) / 10.0
if isinstance(sensors_wind, (int, float)):
return float(sensors_wind)
if payload_wind is None:
return 0.0
return float(payload_wind) / 10.0
def _average_inside_temp_c(sensors: dict | None, actual_temperature: float | int | None) -> float:
if sensors:
readings = [
v
for v in (
_sensor_celsius(sensors.get("temperature1")),
_sensor_celsius(sensors.get("temperature2")),
_sensor_celsius(sensors.get("temperature3")),
)
if v is not None
]
if readings:
return sum(readings) / len(readings)
if actual_temperature is None:
return 0.0
value = float(actual_temperature)
return value / 10.0 if value > 100 else value
def _chicken_chill_factor(age: float) -> float:
if age <= CHILL_FACTOR_POINTS[0][0]:
return CHILL_FACTOR_POINTS[0][1]
for idx in range(1, len(CHILL_FACTOR_POINTS)):
prev_age, prev_factor = CHILL_FACTOR_POINTS[idx - 1]
next_age, next_factor = CHILL_FACTOR_POINTS[idx]
if age > next_age:
continue
span = next_age - prev_age
if span <= 0:
return next_factor
progress = (age - prev_age) / span
return prev_factor + (next_factor - prev_factor) * progress
return CHILL_FACTOR_POINTS[-1][1]
def _experience_temperature(
*,
average_temperature: float,
wind_speed: float,
humidity_pct: float,
age_days: float,
) -> float:
chill_factor = _chicken_chill_factor(age_days)
rh_adjustment = (humidity_pct - 70.0) / 5.0
chill_effect = wind_speed * chill_factor - rh_adjustment
return average_temperature - chill_effect
def panel_fields_from_payload(data: dict, age_days: int) -> dict[str, float]:
sensors = data.get("sensors") or {}
humidity = _sensor_humidity_pct(sensors.get("humidity"), data.get("humidity"))
wind_speed = _wind_ms(data.get("wind"), sensors.get("wind"))
avg_temp = _average_inside_temp_c(sensors, data.get("actualTemperature"))
water = data.get("water")
if water is None and isinstance(sensors.get("water"), dict):
water = sensors["water"].get("value")
return {
"wind_speed": round(wind_speed, 4),
"humidity": round(humidity, 4),
"water_total": float(water or 0),
"average_temperature": round(avg_temp, 4),
"experience_temperature": round(
_experience_temperature(
average_temperature=avg_temp,
wind_speed=wind_speed,
humidity_pct=humidity,
age_days=age_days,
),
4,
),
}
def parse_fetched_at(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if timezone.is_naive(parsed):
return timezone.make_aware(parsed, dt_timezone.utc)
return parsed
def cycle_age_for_date(cycle_start: date, snapshot_date: date) -> int:
if snapshot_date < cycle_start:
return 1
return (snapshot_date - cycle_start).days + 1
@dataclass
class SyncSummary:
upserted: int
skipped: int
pages: int
class IotApiClient:
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.IOT_API_BASE_URL).rstrip("/") + "/"
self.timeout = timeout or settings.IOT_API_SYNC_TIMEOUT_SECONDS
def fetch_page(self, page: int) -> dict[str, Any]:
url = f"{self.base_url}?page={page}"
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise IotApiError(f"IoT API request failed: {exc}") from exc
if response.status_code >= 400:
raise IotApiError(
f"IoT API HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
return response.json()
def sync_iot_panels(
*,
lookback_minutes: int | None = None,
max_pages: int | None = None,
) -> SyncSummary:
flock_map: dict[str, int] = getattr(settings, "IOT_FLOCK_ID_MAP", {}) or {}
if not flock_map:
return SyncSummary(upserted=0, skipped=0, pages=0)
lookback = lookback_minutes if lookback_minutes is not None else settings.IOT_SYNC_LOOKBACK_MINUTES
page_limit = max_pages if max_pages is not None else settings.IOT_SYNC_MAX_PAGES
cutoff = timezone.now() - timedelta(minutes=lookback)
django_flocks: dict[str, Flock] = {}
for external_id, django_id in flock_map.items():
try:
django_flocks[str(external_id)] = Flock.objects.select_related("cycle").get(pk=int(django_id))
except Flock.DoesNotExist:
continue
client = IotApiClient()
upserted = 0
skipped = 0
pages_read = 0
for page in range(1, page_limit + 1):
payload = client.fetch_page(page)
pages_read += 1
results = payload.get("results") or []
if not results:
break
page_has_recent = False
for record in results:
external_flock_id = str(record.get("flock_id") or "")
flock = django_flocks.get(external_flock_id)
if flock is None:
skipped += 1
continue
fetched_raw = record.get("fetched_at")
payload_data = (record.get("payload") or {}).get("data")
if not fetched_raw or not payload_data:
skipped += 1
continue
fetched_at = parse_fetched_at(fetched_raw)
if fetched_at >= cutoff:
page_has_recent = True
elif page > 1:
continue
snapshot_date = fetched_at.astimezone(WIB).date()
age = cycle_age_for_date(flock.cycle.start_date, snapshot_date)
fields = panel_fields_from_payload(payload_data, age)
IotPanel.objects.update_or_create(
flock=flock,
timestamp=fetched_at,
defaults={
"date": snapshot_date,
**fields,
},
)
upserted += 1
if not page_has_recent and page > 1:
break
return SyncSummary(upserted=upserted, skipped=skipped, pages=pages_read)
@@ -0,0 +1,29 @@
"""Dashboard publish cutoff — day D data visible from 17:00 WIB on day D."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from django.conf import settings
from django.utils import timezone
def dashboard_publish_time() -> time:
hour = int(getattr(settings, "DASHBOARD_PUBLISH_HOUR", 17))
minute = int(getattr(settings, "DASHBOARD_PUBLISH_MINUTE", 0))
return time(hour, minute)
def visible_through_date(now: datetime | None = None) -> date:
"""Latest calendar date (WIB) allowed on dashboard reads."""
if now is None:
now = timezone.localtime()
elif timezone.is_naive(now):
now = timezone.make_aware(now, timezone.get_current_timezone())
else:
now = timezone.localtime(now)
today = now.date()
if now.time() < dashboard_publish_time():
return today - timedelta(days=1)
return today
+109 -1
View File
@@ -1,6 +1,7 @@
from datetime import date, timedelta
from unittest.mock import patch
from django.test import TestCase
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from apps.accounts.models import User
@@ -8,6 +9,7 @@ from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.operations.models import ChickenWeight, FeedSacks, IotPanel
@override_settings(DASHBOARD_PUBLISH_HOUR=0)
class OperationsAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
@@ -306,6 +308,112 @@ class OperationsAPITests(TestCase):
self.assertEqual(kpi.harvest_weight_total, 20.5)
@override_settings(DASHBOARD_PUBLISH_HOUR=17, TIME_ZONE="Asia/Jakarta")
class DashboardVisibilityTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
self.client = APIClient()
self.client.force_authenticate(self.user)
site = Site.objects.create(site_name="Sukawarna", user=self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=site)
self.cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date(2026, 8, 26),
end_date=date(2026, 9, 29),
doc_in_weight=40,
doc_in_count=10000,
status=Cycle.STATUS_ACTIVE,
)
def test_hides_today_before_publish_time(self):
FeedSacks.objects.create(
cycle=self.cycle,
date=date(2026, 8, 27),
in_today=5,
out_today=3,
feed_use_today=3,
feed_use_total=3,
)
FeedSacks.objects.create(
cycle=self.cycle,
date=date(2026, 8, 28),
in_today=9,
out_today=7,
feed_use_today=7,
feed_use_total=10,
)
with patch(
"apps.operations.services.visibility.timezone.localtime",
) as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 8, 28, 16, 30, tzinfo=wib)
response = self.client.get("/api/v1/karungs/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["date"], "2026-08-27")
def test_shows_today_after_publish_time(self):
FeedSacks.objects.create(
cycle=self.cycle,
date=date(2026, 8, 28),
in_today=9,
out_today=7,
feed_use_today=7,
feed_use_total=10,
)
with patch(
"apps.operations.services.visibility.timezone.localtime",
) as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 8, 28, 17, 0, tzinfo=wib)
response = self.client.get("/api/v1/karungs/latest/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["date"], "2026-08-28")
def test_panel_iot_not_hidden_before_publish_time(self):
flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
IotPanel.objects.create(
flock=flock,
date=date(2026, 8, 28),
average_temperature=28.5,
)
with patch(
"apps.operations.services.visibility.timezone.localtime",
) as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 8, 28, 10, 0, tzinfo=wib)
response = self.client.get("/api/v1/panel-iot/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["date"], "2026-08-28")
def test_health_includes_visible_through_date(self):
with patch(
"apps.operations.services.visibility.timezone.localtime",
) as mock_localtime:
from datetime import datetime
from zoneinfo import ZoneInfo
wib = ZoneInfo("Asia/Jakarta")
mock_localtime.return_value = datetime(2026, 8, 28, 10, 0, tzinfo=wib)
response = self.client.get("/api/v1/health/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["visible_through_date"], "2026-08-27")
self.assertEqual(response.data["dashboard_publish_time"], "17:00")
class ErdPrimaryKeyTests(TestCase):
ERD_PRIMARY_KEYS = {
"user_access": "user_id",
+24 -2
View File
@@ -28,9 +28,25 @@ from apps.operations.serializers import (
ManualInputSerializer,
)
from apps.operations.services.karung_web import KarungWebError, request_karung
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates"})
class CycleScopedViewSet(viewsets.ModelViewSet):
class VisibilityFilteredMixin:
visibility_date_field = "date"
def applies_visibility_filter(self) -> bool:
return getattr(self, "action", None) in READ_ACTIONS
def filter_visible_through(self, qs):
if not self.applies_visibility_filter():
return qs
cutoff = visible_through_date()
return qs.filter(**{f"{self.visibility_date_field}__lte": cutoff})
class CycleScopedViewSet(VisibilityFilteredMixin, viewsets.ModelViewSet):
def get_queryset(self):
qs = super().get_queryset()
params = self.request.query_params
@@ -40,7 +56,7 @@ class CycleScopedViewSet(viewsets.ModelViewSet):
qs = qs.filter(cycle__flocks__flock_id=params["flock_id"]).distinct()
if params.get("date"):
qs = qs.filter(date=params["date"])
return qs
return self.filter_visible_through(qs)
class KPIViewSet(CycleScopedViewSet):
@@ -126,6 +142,7 @@ class FeedSacksViewSet(CycleScopedViewSet):
class IotPanelViewSet(viewsets.ModelViewSet):
"""IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff."""
queryset = IotPanel.objects.select_related("flock", "flock__cycle").all()
serializer_class = IotPanelSerializer
@@ -166,9 +183,14 @@ class HealthView(APIView):
authentication_classes = []
def get(self, request):
cutoff = visible_through_date()
publish = dashboard_publish_time()
payload = {
"status": "ok",
"service": "newdashboard-api",
"karung_web_admin_base_configured": bool(settings.KARUNG_WEB_ADMIN_BASE_URL),
"visible_through_date": cutoff.isoformat(),
"dashboard_publish_time": publish.strftime("%H:%M"),
"dashboard_timezone": settings.TIME_ZONE,
}
return Response(payload)