update docker deployment and changing seed kandang name
This commit is contained in:
1 parent
76f4d0106a
commit
74dd3faf60
40 files changed
+1252
-121
No files matched your search
@@ -0,0 +1,25 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
.vite
|
||||
*.local
|
||||
.git
|
||||
.gitignore
|
||||
docker-compose*.yml
|
||||
DOCKER.md
|
||||
README.md
|
||||
|
||||
# Backend artifacts (api image copies backend/ explicitly)
|
||||
backend/.venv
|
||||
backend/venv
|
||||
backend/__pycache__
|
||||
backend/db.sqlite3
|
||||
backend/data
|
||||
backend/media
|
||||
backend/staticfiles
|
||||
backend/*.egg-info
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
# Empty uses the Vite proxy (/api → Django :8000).
|
||||
# Set a full origin only if you skip the proxy.
|
||||
# Local dev: Vite proxies /api → Django :8000
|
||||
VITE_API_BASE=/api/v1
|
||||
|
||||
# Docker Compose env: cp docker/.env.example .env
|
||||
@@ -7,3 +7,4 @@ coverage
|
||||
.vite
|
||||
backup
|
||||
__pycache__
|
||||
docker-compose.override.yml
|
||||
@@ -0,0 +1,217 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
Deploy **dashboard-new-v2** with Docker Compose. Layout mirrors the legacy `dashboard/` stack (Postgres + API + Nginx frontend), adapted for Django + Gunicorn and a separate cron container.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Host Server │
|
||||
│ │
|
||||
│ ┌─────────────┐ /api/* ┌──────────────┐ │
|
||||
│ │ frontend │────────────►│ api │ │
|
||||
│ │ (Nginx) │ │ (Django) │ │
|
||||
│ │ port 5002 │ │ port 8000 │ │
|
||||
│ └─────────────┘ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────┐ ▼ │
|
||||
│ │ cron │────────────► ┌──────────────┐ │
|
||||
│ │ (same image)│ │ database │ │
|
||||
│ └─────────────┘ │ (PostgreSQL) │ │
|
||||
│ │ port 15432* │ │
|
||||
│ └──────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
* 15432 on host → 5432 in container (SSH tunnel / DBeaver)
|
||||
```
|
||||
|
||||
| Container | Image / build | Host port | Role |
|
||||
| ---------- | ------------------ | ---------------- | ----------------------------- |
|
||||
| frontend | `docker/Dockerfile.web` | 5002 → 80 | React SPA + Nginx API proxy |
|
||||
| api | `docker/Dockerfile.api` | 18000 → 8000 | Gunicorn, migrations, static |
|
||||
| cron | same as api | — | Scheduled management commands |
|
||||
| database | postgres:16-alpine | 15432 → 5432 | PostgreSQL |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose v2+
|
||||
- Ports available: **5002** (UI), **18000** (direct API), **15432** (Postgres on localhost)
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd dashboard-new-v2
|
||||
cp docker/.env.example .env
|
||||
# Edit .env — set SECRET_KEY and DB_PASSWORD
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
**Production** (empty database, no demo data):
|
||||
|
||||
```bash
|
||||
docker compose exec api python manage.py bootstrap_admin
|
||||
```
|
||||
|
||||
Uses `BOOTSTRAP_ADMIN_USER` / `BOOTSTRAP_ADMIN_PASSWORD` from `.env` (default in `docker/.env.example`: `admin` / `Pr04dm1n`). Then log in and create sites, kandang, and cycles in Pengaturan.
|
||||
|
||||
**Dev / demo** (Sukawarna sample cycles):
|
||||
|
||||
```bash
|
||||
docker compose exec api python manage.py seed_demo
|
||||
```
|
||||
|
||||
Logins after seed: `admin` / `admin123`, `staff` / `staff123`. Do **not** run `seed_demo` on production.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
|
||||
curl http://localhost:5002/health
|
||||
curl http://localhost:5002/api/v1/health/
|
||||
```
|
||||
|
||||
Open **http://localhost:5002** in a browser.
|
||||
|
||||
## Configuration files (`docker/`)
|
||||
|
||||
| File | Purpose |
|
||||
| ---- | ------- |
|
||||
| `Dockerfile.web` | Frontend image (Vite + Nginx) |
|
||||
| `Dockerfile.api` | Backend image (Django + Gunicorn) |
|
||||
| `nginx.conf` | Nginx proxy config for frontend |
|
||||
| `entrypoint-api.sh` | API container startup |
|
||||
| `entrypoint-cron.sh` | Cron container startup |
|
||||
| `crontab` | Scheduled jobs (IoT 10 min, karung/KPI 17:00) |
|
||||
| `.env.example` | Compose env template → copy to project root `.env` |
|
||||
| `compose.override.local.example` | Optional local Postgres port override |
|
||||
|
||||
## Configuration
|
||||
|
||||
Compose reads variables from a root `.env` file. Template: `docker/.env.example`.
|
||||
|
||||
Important production values:
|
||||
|
||||
| Variable | Purpose |
|
||||
| -------- | ------- |
|
||||
| `SECRET_KEY` | Django secret — use a long random string |
|
||||
| `DB_PASSWORD` | PostgreSQL password |
|
||||
| `CSRF_TRUSTED_ORIGINS` | Must include your public UI origin (e.g. `https://dashboard.example.com`) |
|
||||
| `CORS_ALLOWED_ORIGINS` | Same as above if the SPA is on a different origin |
|
||||
| `KARUNG_WEB_ADMIN_BASE_URL` | External karung service; default `http://host.docker.internal:5000` reaches the host from containers |
|
||||
| `BOOTSTRAP_ADMIN_USER` | Superadmin username for `bootstrap_admin` (default `admin`) |
|
||||
| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) |
|
||||
| `BOOTSTRAP_API_KEY` | Optional fixed API key for scripts (hashed at rest) |
|
||||
|
||||
Wagtail admin is proxied at **http://localhost:5002/admin/** (through Nginx → Django).
|
||||
|
||||
## Management
|
||||
|
||||
```bash
|
||||
# Start / stop
|
||||
docker compose start
|
||||
docker compose stop
|
||||
docker compose restart
|
||||
|
||||
# Logs
|
||||
docker compose logs -f api
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f cron
|
||||
|
||||
# Django shell
|
||||
docker compose exec api python manage.py shell
|
||||
|
||||
# Database (psql)
|
||||
docker compose exec database psql -U newdashboard -d newdashboard
|
||||
|
||||
# From host (port 15432)
|
||||
psql -h localhost -p 15432 -U newdashboard -d newdashboard
|
||||
```
|
||||
|
||||
### Update after code changes
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Rolling update (less downtime):
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d --no-deps --build api
|
||||
docker compose up -d --no-deps --build cron
|
||||
docker compose up -d --no-deps --build frontend
|
||||
```
|
||||
|
||||
### Local DBeaver on Mac
|
||||
|
||||
```bash
|
||||
cp docker/compose.override.local.example docker-compose.override.yml
|
||||
```
|
||||
|
||||
Adds `127.0.0.1:5432:5432` while keeping the server’s `15432` mapping.
|
||||
|
||||
## Cron jobs
|
||||
|
||||
The `cron` service runs:
|
||||
|
||||
- **Every 10 min** — `sync_iot_from_api` (IoT panel, real-time — not gated by 17:00)
|
||||
- **17:00 daily** — `sync_karung_from_web_admin` then `recompute_kpi_rollups` (feed use counts until 16:59, so karung sync runs at publish time)
|
||||
|
||||
Dashboard reads hide today's KPI/counting/weight/karung data until 17:00 WIB. **IoT panel is excluded** and updates live every 10 minutes.
|
||||
|
||||
Logs: `docker compose exec cron tail -f /var/log/cron.log`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**API unhealthy**
|
||||
|
||||
```bash
|
||||
docker compose logs api
|
||||
docker compose exec api python manage.py migrate --plan
|
||||
```
|
||||
|
||||
**Frontend 502 on /api**
|
||||
|
||||
Ensure `api` is healthy and both services share `newdashboard-network`:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
curl http://127.0.0.1:18000/api/v1/health/
|
||||
```
|
||||
|
||||
**Database connection errors**
|
||||
|
||||
Check Postgres is healthy and credentials in `.env` match `docker-compose.yml` defaults.
|
||||
|
||||
**Port conflicts**
|
||||
|
||||
Change mappings in `docker-compose.yml`, e.g. `"8080:80"` for frontend.
|
||||
|
||||
**Reset database** (destructive)
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
docker compose up -d --build
|
||||
docker compose exec api python manage.py bootstrap_admin
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Do not commit `.env` with real secrets.
|
||||
- Use strong `SECRET_KEY` and `DB_PASSWORD` in production.
|
||||
- Restrict database port `15432` to localhost (already bound to `127.0.0.1`).
|
||||
- Put HTTPS in front of port 5002 (reverse proxy + Let's Encrypt) for public deployment.
|
||||
|
||||
---
|
||||
|
||||
Docker deployment guide — dashboard-new-v2
|
||||
@@ -46,7 +46,7 @@ Seed logins:
|
||||
- `admin` / `admin123` — superadmin (can create/manage sites and kandang)
|
||||
- `staff` / `staff123` — active staff (owns the seeded site; can read sites/kandang and create cycles, but cannot create/edit/delete sites or kandang)
|
||||
|
||||
`seed_demo` creates both logins plus an API key, then seeds the default Sukawarna demo data: **Kandang 4** (45,000 DOC) and **Kandang 5** (50,000 DOC), both 49-day closed cycles (22 May – 9 Jul 2026) with full daily data (chicken counting, chicken weight, feed sacks, manual input, KPI) and **10-minute IoT panel readings** (144 readings per day per flock). Feed-sack `out` (keluar) data only occurs in the final week of the cycle (~20 sacks total), separate from usage (`feed_use`).
|
||||
`seed_demo` creates both logins plus an API key, then seeds the default Sukawarna demo data: **Kandang 1** (45,000 DOC) and **Kandang 2** (50,000 DOC), both 49-day closed cycles (22 May – 9 Jul 2026) with full daily data (chicken counting, chicken weight, feed sacks, manual input, KPI) and **10-minute IoT panel readings** (144 readings per day per flock). Feed-sack `out` (keluar) data only occurs in the final week of the cycle (~20 sacks total), separate from usage (`feed_use`).
|
||||
|
||||
**Frontend** (terminal 2):
|
||||
|
||||
@@ -77,6 +77,28 @@ npm run dev
|
||||
|
||||
Open [http://localhost:3001](http://localhost:3001). Vite proxies `/api` to Django on `:8000`.
|
||||
|
||||
## Docker deployment
|
||||
|
||||
Full-stack deployment (Postgres + Django API + cron + Nginx frontend), modeled on the legacy `dashboard/` Docker setup:
|
||||
|
||||
```bash
|
||||
cd dashboard-new-v2
|
||||
cp docker/.env.example .env
|
||||
# edit SECRET_KEY and DB_PASSWORD
|
||||
docker compose up -d --build
|
||||
docker compose exec api python manage.py bootstrap_admin
|
||||
```
|
||||
|
||||
- UI: [http://localhost:5002](http://localhost:5002) — login `admin` / password from `BOOTSTRAP_ADMIN_PASSWORD` in `.env`
|
||||
- API (direct): [http://localhost:18000/api/v1/health/](http://localhost:18000/api/v1/health/)
|
||||
- Postgres (host): `localhost:15432`
|
||||
|
||||
For **dev demo data** only: `docker compose exec api python manage.py seed_demo` (not for production).
|
||||
|
||||
See [DOCKER.md](./DOCKER.md) for architecture, env vars, cron, and troubleshooting.
|
||||
|
||||
**Daily publish rule:** dashboard data for calendar day D is only visible from **17:00 WIB** on day D — **except IoT Panel**, which syncs every 10 minutes and shows live readings.
|
||||
|
||||
## Screens
|
||||
|
||||
Login, Dashboard (KPI + AI insight + Total Pakan section + in-memory Harian/Akhir Siklus report), Hitung Ayam, Berat Ayam, Standar Ross, Hitung Karung (Sync hari ini + Saldo Awal + daily in/use/out tables), Standar Performa, Panel IoT (per-day 10-minute readings with charts), FCR, EEF, Pengaturan.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
db.sqlite3
|
||||
data/
|
||||
media/
|
||||
staticfiles/
|
||||
*.egg-info
|
||||
.git
|
||||
+15
-3
@@ -12,12 +12,12 @@ DB_NAME=db.sqlite3
|
||||
# Docker/compose typically overrides to:
|
||||
# DB_NAME=/app/data/db.sqlite3
|
||||
|
||||
# PostgreSQL (production) — use with: docker compose --profile prod up
|
||||
# PostgreSQL (production / Docker Compose) — set in project root .env.example
|
||||
# DB_ENGINE=django.db.backends.postgresql
|
||||
# DB_NAME=newdashboard
|
||||
# DB_USER=newdashboard
|
||||
# DB_PASSWORD=change-me
|
||||
# DB_HOST=db
|
||||
# DB_HOST=database
|
||||
# DB_PORT=5432
|
||||
|
||||
WAGTAIL_SITE_NAME=New Dashboard
|
||||
@@ -30,9 +30,21 @@ CSRF_COOKIE_SECURE=False
|
||||
# Optional: create hashed ApiKey on seed/bootstrap (raw shown once in seed output)
|
||||
BOOTSTRAP_API_KEY=
|
||||
|
||||
# Production bootstrap (bootstrap_admin — no demo data)
|
||||
BOOTSTRAP_ADMIN_USER=admin
|
||||
BOOTSTRAP_ADMIN_PASSWORD=
|
||||
|
||||
# karung-web-admin (external)
|
||||
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
|
||||
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
|
||||
KARUNG_WEB_ADMIN_SYNC_ENABLED=false
|
||||
|
||||
CRON_ADVANCE_CYCLE_DAY=true
|
||||
DASHBOARD_PUBLISH_HOUR=17
|
||||
DASHBOARD_PUBLISH_MINUTE=0
|
||||
|
||||
# IoT panel — real-time sync every 10 min (excluded from publish cutoff)
|
||||
IOT_SYNC_ENABLED=true
|
||||
IOT_API_BASE_URL=https://dashboard.cpsp.id/api/iot/flocks
|
||||
IOT_SYNC_LOOKBACK_MINUTES=20
|
||||
IOT_SYNC_MAX_PAGES=10
|
||||
IOT_FLOCK_ID_MAP={}
|
||||
+9
-10
@@ -26,23 +26,22 @@ npm run dev
|
||||
# or: python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
Seed creates user `admin` / `admin123`, then seeds the default Sukawarna demo data — **Kandang 4** and **Kandang 5** closed cycles (49 days each, 22 May – 9 Jul 2026) with full daily records and IoT panels. A manager login `skw_manager` / `skw_manager123` is created too. It also prints a one-time API key.
|
||||
Seed creates user `admin` / `admin123`, then seeds the default Sukawarna demo data — **Kandang 1** and **Kandang 2** closed cycles (49 days each, 22 May – 9 Jul 2026) with full daily records and IoT panels. A manager login `skw_manager` / `skw_manager123` is created too. It also prints a one-time API key.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
cp dashboard-new-v2/backend/.env.example dashboard-new-v2/backend/.env
|
||||
# edit KARUNG_WEB_ADMIN_BASE_URL as needed
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Production Postgres:
|
||||
From the project root (`dashboard-new-v2/`):
|
||||
|
||||
```bash
|
||||
# set DB_ENGINE=django.db.backends.postgresql and DB_* in dashboard-new-v2/backend/.env
|
||||
docker compose --profile prod up --build
|
||||
cp docker/.env.example .env
|
||||
docker compose up -d --build
|
||||
docker compose exec api python manage.py bootstrap_admin
|
||||
```
|
||||
|
||||
Production bootstrap creates superadmin only (no demo cycles). Dev demo: `python manage.py seed_demo`.
|
||||
|
||||
See [../DOCKER.md](../DOCKER.md) for ports, cron, and troubleshooting.
|
||||
|
||||
## Auth
|
||||
|
||||
- **Session:** `POST /api/v1/auth/login/` then send session cookie + `X-CSRFToken` on writes
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
)
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,6 +28,14 @@ def env_list(key: str, default: str = "") -> list[str]:
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
def env_json_dict(key: str, default: str = "") -> dict[str, int]:
|
||||
raw = os.getenv(key, default)
|
||||
if not raw:
|
||||
return {}
|
||||
data = json.loads(raw)
|
||||
return {str(k): int(v) for k, v in data.items()}
|
||||
|
||||
|
||||
SECRET_KEY = env("SECRET_KEY", "insecure-dev-key")
|
||||
DEBUG = env_bool("DEBUG", True)
|
||||
ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", "localhost,127.0.0.1")
|
||||
@@ -166,6 +175,9 @@ SPECTACULAR_SETTINGS = {
|
||||
|
||||
BOOTSTRAP_API_KEY = env("BOOTSTRAP_API_KEY", "") or ""
|
||||
|
||||
BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin"
|
||||
BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or ""
|
||||
|
||||
KARUNG_WEB_ADMIN_BASE_URL = (
|
||||
env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or ""
|
||||
).rstrip("/")
|
||||
@@ -174,4 +186,15 @@ KARUNG_WEB_ADMIN_TIMEOUT_SECONDS = float(
|
||||
)
|
||||
KARUNG_WEB_ADMIN_SYNC_ENABLED = env_bool("KARUNG_WEB_ADMIN_SYNC_ENABLED", False)
|
||||
|
||||
CRON_ADVANCE_CYCLE_DAY = env_bool("CRON_ADVANCE_CYCLE_DAY", True)
|
||||
# Dashboard publish cutoff (WIB): day D visible from this time on day D.
|
||||
DASHBOARD_PUBLISH_HOUR = int(env("DASHBOARD_PUBLISH_HOUR", "17") or "17")
|
||||
DASHBOARD_PUBLISH_MINUTE = int(env("DASHBOARD_PUBLISH_MINUTE", "0") or "0")
|
||||
|
||||
# External IoT flocks API — panel sync every 10 minutes (real-time, not gated by publish cutoff).
|
||||
IOT_API_BASE_URL = (env("IOT_API_BASE_URL", "https://dashboard.cpsp.id/api/iot/flocks") or "").rstrip("/")
|
||||
IOT_API_SYNC_TIMEOUT_SECONDS = float(env("IOT_API_SYNC_TIMEOUT_SECONDS", "60") or "60")
|
||||
IOT_SYNC_ENABLED = env_bool("IOT_SYNC_ENABLED", True)
|
||||
IOT_SYNC_LOOKBACK_MINUTES = int(env("IOT_SYNC_LOOKBACK_MINUTES", "20") or "20")
|
||||
IOT_SYNC_MAX_PAGES = int(env("IOT_SYNC_MAX_PAGES", "10") or "10")
|
||||
# JSON map: external IoT flock_id -> Django flock.pk, e.g. {"686df69f407b21002da8750c": 1}
|
||||
IOT_FLOCK_ID_MAP = env_json_dict("IOT_FLOCK_ID_MAP")
|
||||
@@ -1,6 +0,0 @@
|
||||
SHELL=/bin/sh
|
||||
PATH=/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
0 * * * * root cd /app && /usr/local/bin/python manage.py advance_cycle_days >> /var/log/cron.log 2>&1
|
||||
15 * * * * root cd /app && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1
|
||||
*/15 * * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
cd /app
|
||||
python manage.py migrate --noinput
|
||||
# Ensure cron env can see Django settings
|
||||
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|CRON_|WAGTAIL_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
|
||||
> /etc/environment || true
|
||||
exec cron -f
|
||||
@@ -57,7 +57,8 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
() => [...manualRows].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
[manualRows]
|
||||
);
|
||||
const displayDateIso = latest?.date ?? todayIso();
|
||||
const visibleThrough = selectedCycle?.visible_through_date;
|
||||
const displayDateIso = latest?.date ?? visibleThrough ?? todayIso();
|
||||
const latestManual =
|
||||
sortedManualRows.find((row) => row.date === displayDateIso) ??
|
||||
sortedManualRows[sortedManualRows.length - 1] ??
|
||||
@@ -65,7 +66,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
|
||||
// Display date follows the latest snapshot; without any snapshot, fall back to today.
|
||||
// Cycle day is capped so data never extends past the cycle's planned duration.
|
||||
const displayDate = latest?.date ?? todayIso();
|
||||
const displayDate = latest?.date ?? visibleThrough ?? todayIso();
|
||||
const currentDay = useMemo(() => {
|
||||
if (!selectedCycle) return 0;
|
||||
const rawDay = cycleDayForDate(selectedCycle.start_date, displayDate);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
formatDateId,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
todayIso,
|
||||
} from '../../utils/format.ts';
|
||||
import PopulationTrendChart from './PopulationTrendChart.tsx';
|
||||
|
||||
@@ -102,7 +101,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
|
||||
const lastCycleDay = Math.max(0, (selectedCycle.total_days || 1) - 1);
|
||||
const maxDay = Math.min(selectedCycle.current_day ?? 0, lastCycleDay);
|
||||
const today = todayIso();
|
||||
const visibleThrough = selectedCycle.visible_through_date;
|
||||
|
||||
let previousStockAkhir = 0;
|
||||
let cumulativeMortality = 0;
|
||||
@@ -153,8 +152,8 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
? kpi.harvest_weight_total / 1000
|
||||
: cumulativeHarvestWeight,
|
||||
isEdited: !!manual,
|
||||
isToday: date === today,
|
||||
isFuture: date > today,
|
||||
isToday: date === visibleThrough,
|
||||
isFuture: date > visibleThrough,
|
||||
});
|
||||
|
||||
previousStockAkhir = stockAkhir;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
services:
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
container_name: newdashboard-database
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-newdashboard}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_this_password}
|
||||
POSTGRES_DB: ${DB_NAME:-newdashboard}
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- newdashboard-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${DB_USER:-newdashboard} -d ${DB_NAME:-newdashboard}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
# Server: SSH tunnel / DBeaver via localhost:15432 (avoids host Postgres on 5432).
|
||||
# Mac dev: add docker-compose.override.yml from docker/compose.override.local.example for :5432.
|
||||
ports:
|
||||
- "127.0.0.1:15432:5432"
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.api
|
||||
container_name: newdashboard-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:18000:8000"
|
||||
environment:
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend}
|
||||
CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost:5002,http://127.0.0.1:5002}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5002,http://127.0.0.1:5002}
|
||||
CORS_ALLOW_CREDENTIALS: "true"
|
||||
DB_ENGINE: django.db.backends.postgresql
|
||||
DB_NAME: ${DB_NAME:-newdashboard}
|
||||
DB_USER: ${DB_USER:-newdashboard}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-change_this_password}
|
||||
DB_HOST: database
|
||||
DB_PORT: "5432"
|
||||
WAGTAIL_SITE_NAME: ${WAGTAIL_SITE_NAME:-New Dashboard}
|
||||
WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://localhost:5002}
|
||||
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
|
||||
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30}
|
||||
KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}
|
||||
DASHBOARD_PUBLISH_HOUR: ${DASHBOARD_PUBLISH_HOUR:-17}
|
||||
DASHBOARD_PUBLISH_MINUTE: ${DASHBOARD_PUBLISH_MINUTE:-0}
|
||||
IOT_SYNC_ENABLED: ${IOT_SYNC_ENABLED:-true}
|
||||
IOT_API_BASE_URL: ${IOT_API_BASE_URL:-https://dashboard.cpsp.id/api/iot/flocks}
|
||||
IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}}
|
||||
IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20}
|
||||
IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10}
|
||||
BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-}
|
||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- api-media:/app/media
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- newdashboard-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; r=urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/'); exit(0 if r.status == 200 else 1)",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
cron:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.api
|
||||
container_name: newdashboard-cron
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/app/entrypoint-cron.sh"]
|
||||
environment:
|
||||
DEBUG: ${DEBUG:-false}
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend}
|
||||
DB_ENGINE: django.db.backends.postgresql
|
||||
DB_NAME: ${DB_NAME:-newdashboard}
|
||||
DB_USER: ${DB_USER:-newdashboard}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-change_this_password}
|
||||
DB_HOST: database
|
||||
DB_PORT: "5432"
|
||||
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
|
||||
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30}
|
||||
KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}
|
||||
DASHBOARD_PUBLISH_HOUR: ${DASHBOARD_PUBLISH_HOUR:-17}
|
||||
DASHBOARD_PUBLISH_MINUTE: ${DASHBOARD_PUBLISH_MINUTE:-0}
|
||||
IOT_SYNC_ENABLED: ${IOT_SYNC_ENABLED:-true}
|
||||
IOT_API_BASE_URL: ${IOT_API_BASE_URL:-https://dashboard.cpsp.id/api/iot/flocks}
|
||||
IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}}
|
||||
IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20}
|
||||
IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- api-media:/app/media
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- newdashboard-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.web
|
||||
container_name: newdashboard-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5002:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- newdashboard-network
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
newdashboard-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
driver: local
|
||||
api-media:
|
||||
driver: local
|
||||
@@ -0,0 +1,40 @@
|
||||
# Docker Compose environment — copy to project root:
|
||||
# cp docker/.env.example .env
|
||||
|
||||
# --- PostgreSQL ---
|
||||
DB_USER=newdashboard
|
||||
DB_PASSWORD=change_this_to_a_strong_password
|
||||
DB_NAME=newdashboard
|
||||
|
||||
# --- Django ---
|
||||
SECRET_KEY=change-me-in-production-use-a-long-random-string
|
||||
DEBUG=false
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,api,frontend
|
||||
CSRF_TRUSTED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002
|
||||
|
||||
WAGTAIL_SITE_NAME=New Dashboard
|
||||
WAGTAILADMIN_BASE_URL=http://localhost:5002
|
||||
|
||||
# Production admin (bootstrap_admin — no demo data)
|
||||
BOOTSTRAP_ADMIN_USER=admin
|
||||
BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n
|
||||
|
||||
# Optional: fixed API key for scripts (hashed at rest)
|
||||
BOOTSTRAP_API_KEY=
|
||||
|
||||
# karung-web-admin on the Docker host
|
||||
KARUNG_WEB_ADMIN_BASE_URL=http://host.docker.internal:5000
|
||||
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
|
||||
KARUNG_WEB_ADMIN_SYNC_ENABLED=false
|
||||
|
||||
# Dashboard publish cutoff (WIB): day D visible from 17:00 on day D
|
||||
DASHBOARD_PUBLISH_HOUR=17
|
||||
DASHBOARD_PUBLISH_MINUTE=0
|
||||
|
||||
# IoT panel — real-time sync every 10 min (excluded from publish cutoff)
|
||||
IOT_SYNC_ENABLED=true
|
||||
IOT_API_BASE_URL=https://dashboard.cpsp.id/api/iot/flocks
|
||||
IOT_SYNC_LOOKBACK_MINUTES=20
|
||||
IOT_SYNC_MAX_PAGES=10
|
||||
IOT_FLOCK_ID_MAP={}
|
||||
@@ -1,3 +1,4 @@
|
||||
# Backend — Django + Gunicorn (+ cron in sibling container)
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -14,16 +15,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
RUN chmod +x docker/entrypoint-api.sh docker/entrypoint-cron.sh \
|
||||
COPY backend/ .
|
||||
COPY docker/entrypoint-api.sh docker/entrypoint-cron.sh /app/
|
||||
COPY docker/crontab /etc/cron.d/newdashboard
|
||||
|
||||
RUN chmod +x /app/entrypoint-api.sh /app/entrypoint-cron.sh \
|
||||
&& mkdir -p /app/data /app/media /app/staticfiles \
|
||||
&& cp docker/crontab /etc/cron.d/newdashboard \
|
||||
&& chmod 0644 /etc/cron.d/newdashboard \
|
||||
&& crontab /etc/cron.d/newdashboard
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["./docker/entrypoint-api.sh"]
|
||||
ENTRYPOINT ["/app/entrypoint-api.sh"]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Frontend — multi-stage build (Vite + Nginx)
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV VITE_API_BASE=/api/v1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:80/health || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,10 @@
|
||||
# On your Mac only — copy to docker-compose.override.yml (gitignored, not pushed):
|
||||
# cp docker/compose.override.local.example docker-compose.override.yml
|
||||
#
|
||||
# Compose merges with docker-compose.yml: you keep the server’s 15432 mapping and add 5432
|
||||
# for local DBeaver, restore scripts, or host tools. If 5432 is busy, use 5433:5432.
|
||||
|
||||
services:
|
||||
database:
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
@@ -0,0 +1,8 @@
|
||||
SHELL=/bin/sh
|
||||
PATH=/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# IoT panel: real-time sync every 10 minutes (not gated by 17:00 publish cutoff).
|
||||
*/10 * * * * root cd /app && /usr/local/bin/python manage.py sync_iot_from_api >> /var/log/cron.log 2>&1
|
||||
|
||||
# Daily publish at 17:00 WIB — karung first (feed use counts until 16:59), then KPI rollups.
|
||||
0 17 * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1 && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1
|
||||
File renamed without changes.
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
cd /app
|
||||
python manage.py migrate --noinput
|
||||
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|DASHBOARD_|WAGTAIL_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
|
||||
> /etc/environment || true
|
||||
exec cron -f
|
||||
@@ -0,0 +1,72 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_connect_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /django-admin/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Extract real 10-minute IoT snapshots for the Sukawarna Kandang 5 cycle window.
|
||||
"""Extract real 10-minute IoT snapshots for the Sukawarna Kandang 2 cycle window.
|
||||
|
||||
The public IoT API (dashboard.cpsp.id/api/iot/flocks/) polls the coop every 10
|
||||
minutes, so a full cycle day holds up to 144 rows. This script scans the API's
|
||||
history for flock 686df69f407b21002da8750c (Kandang 5 Lantai 1) inside the
|
||||
history for flock 686df69f407b21002da8750c (Kandang 2 Lantai 1) inside the
|
||||
seeded cycle window 2026-05-22 .. 2026-07-09 and writes every snapshot to
|
||||
``iot_panel_10min_extract.json``. The Django seed command reads this cache so
|
||||
Kandang 5 Lantai 1 is seeded from real sensor readings; the other floors and
|
||||
Kandang 4 are generated from it.
|
||||
Kandang 2 Lantai 1 is seeded from real sensor readings; the other floors and
|
||||
Kandang 1 are generated from it.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -44,9 +44,12 @@ export type CycleStatus = 'active' | 'closed';
|
||||
export type Cycle = {
|
||||
id: number;
|
||||
kandang: number;
|
||||
kandang_name: string;
|
||||
flock: number;
|
||||
flock_name: string | null;
|
||||
total_days: number;
|
||||
current_day: number;
|
||||
visible_through_date: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
chick_in_weight: number;
|
||||
|
||||
Reference in new issue
Block a user