remove wagtail and admin website, remove edit option, from hitung ayam, add create flock to settings
This commit is contained in:
1 parent
74dd3faf60
commit
30a736641f
39 files changed
+583
-768
No files matched your search
@@ -58,7 +58,7 @@ docker compose up -d --build
|
|||||||
docker compose exec api python manage.py bootstrap_admin
|
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.
|
Uses `BOOTSTRAP_ADMIN_USER` / `BOOTSTRAP_ADMIN_PASSWORD` from `.env` (default in `docker/.env.example`: `admin` / `Pr04dm1n`). Optionally set `BOOTSTRAP_STAFF_USER` / `BOOTSTRAP_STAFF_PASSWORD` to create a staff login at the same time. Then log in and create sites, kandang, and cycles in Pengaturan.
|
||||||
|
|
||||||
**Dev / demo** (Sukawarna sample cycles):
|
**Dev / demo** (Sukawarna sample cycles):
|
||||||
|
|
||||||
@@ -108,6 +108,8 @@ Important production values:
|
|||||||
| `KARUNG_WEB_ADMIN_BASE_URL` | External karung service; default `http://host.docker.internal:5000` reaches the host from containers |
|
| `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_USER` | Superadmin username for `bootstrap_admin` (default `admin`) |
|
||||||
| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) |
|
| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) |
|
||||||
|
| `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation |
|
||||||
|
| `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) |
|
||||||
| `BOOTSTRAP_API_KEY` | Optional fixed API key for scripts (hashed at rest) |
|
| `BOOTSTRAP_API_KEY` | Optional fixed API key for scripts (hashed at rest) |
|
||||||
|
|
||||||
Wagtail admin is proxied at **http://localhost:5002/admin/** (through Nginx → Django).
|
Wagtail admin is proxied at **http://localhost:5002/admin/** (through Nginx → Django).
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ DB_NAME=db.sqlite3
|
|||||||
# DB_HOST=database
|
# DB_HOST=database
|
||||||
# DB_PORT=5432
|
# DB_PORT=5432
|
||||||
|
|
||||||
WAGTAIL_SITE_NAME=New Dashboard
|
|
||||||
WAGTAILADMIN_BASE_URL=http://localhost:8000
|
|
||||||
|
|
||||||
API_PAGE_SIZE=50
|
API_PAGE_SIZE=50
|
||||||
SESSION_COOKIE_SECURE=False
|
SESSION_COOKIE_SECURE=False
|
||||||
CSRF_COOKIE_SECURE=False
|
CSRF_COOKIE_SECURE=False
|
||||||
@@ -34,6 +31,10 @@ BOOTSTRAP_API_KEY=
|
|||||||
BOOTSTRAP_ADMIN_USER=admin
|
BOOTSTRAP_ADMIN_USER=admin
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=
|
BOOTSTRAP_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# Optional staff account (leave BOOTSTRAP_STAFF_USER empty to skip)
|
||||||
|
BOOTSTRAP_STAFF_USER=
|
||||||
|
BOOTSTRAP_STAFF_PASSWORD=
|
||||||
|
|
||||||
# karung-web-admin (external)
|
# karung-web-admin (external)
|
||||||
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
|
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
|
||||||
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
|
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
|
||||||
|
|
||||||
from apps.accounts.models import ApiKey, User
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
|
||||||
class UserAdmin(DjangoUserAdmin):
|
|
||||||
ordering = ("user_name",)
|
|
||||||
list_display = ("user_name", "status", "created_at")
|
|
||||||
search_fields = ("user_name",)
|
|
||||||
fieldsets = (
|
|
||||||
(None, {"fields": ("user_name", "password")}),
|
|
||||||
("Permissions", {"fields": ("status", "groups", "user_permissions")}),
|
|
||||||
)
|
|
||||||
add_fieldsets = (
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
{
|
|
||||||
"classes": ("wide",),
|
|
||||||
"fields": ("user_name", "password1", "password2", "status"),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
filter_horizontal = ("groups", "user_permissions")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ApiKey)
|
|
||||||
class ApiKeyAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "prefix", "user", "is_active", "last_used_at", "created_at")
|
|
||||||
list_filter = ("is_active",)
|
|
||||||
search_fields = ("name", "prefix", "user__user_name")
|
|
||||||
readonly_fields = ("prefix", "key_hash", "last_used_at", "created_at", "updated_at")
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from apps.farms.models import Cycle, Flock, Kandang, Site
|
|
||||||
|
|
||||||
admin.site.register(Site)
|
|
||||||
admin.site.register(Kandang)
|
|
||||||
admin.site.register(Flock)
|
|
||||||
admin.site.register(Cycle)
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_flock_kandang(apps, schema_editor):
|
||||||
|
Flock = apps.get_model("farms", "Flock")
|
||||||
|
for flock in Flock.objects.select_related("cycle").iterator():
|
||||||
|
if flock.cycle_id and flock.kandang_id is None:
|
||||||
|
flock.kandang_id = flock.cycle.kandang_id
|
||||||
|
flock.save(update_fields=["kandang_id"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("farms", "0008_cycle_feed_initial_balance"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="flock",
|
||||||
|
name="kandang",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="flocks",
|
||||||
|
to="farms.kandang",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill_flock_kandang, migrations.RunPython.noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="flock",
|
||||||
|
name="kandang",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="flocks",
|
||||||
|
to="farms.kandang",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="flock",
|
||||||
|
name="cycle",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="flocks",
|
||||||
|
to="farms.cycle",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def remove_orphan_flocks(apps, schema_editor):
|
||||||
|
Flock = apps.get_model("farms", "Flock")
|
||||||
|
Flock.objects.filter(cycle_id__isnull=True).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
atomic = False
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("farms", "0009_flock_belongs_to_kandang"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(remove_orphan_flocks, migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="flock",
|
||||||
|
name="kandang",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="flock",
|
||||||
|
name="cycle",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="flocks",
|
||||||
|
to="farms.cycle",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_flock_kandang_from_cycle(apps, schema_editor):
|
||||||
|
Flock = apps.get_model("farms", "Flock")
|
||||||
|
for flock in Flock.objects.select_related("cycle").iterator():
|
||||||
|
if flock.cycle_id and flock.kandang_id is None:
|
||||||
|
flock.kandang_id = flock.cycle.kandang_id
|
||||||
|
flock.save(update_fields=["kandang_id"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
atomic = False
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("farms", "0010_restore_flock_cycle_erd"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="flock",
|
||||||
|
name="kandang",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="flocks",
|
||||||
|
to="farms.kandang",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill_flock_kandang_from_cycle, migrations.RunPython.noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="flock",
|
||||||
|
name="kandang",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="flocks",
|
||||||
|
to="farms.kandang",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="flock",
|
||||||
|
name="cycle",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,13 +2,10 @@ from datetime import date
|
|||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from wagtail.admin.panels import FieldPanel
|
|
||||||
from wagtail.snippets.models import register_snippet
|
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class Site(models.Model):
|
class Site(models.Model):
|
||||||
site_id = models.BigAutoField(primary_key=True)
|
site_id = models.BigAutoField(primary_key=True)
|
||||||
site_name = models.CharField(max_length=30)
|
site_name = models.CharField(max_length=30)
|
||||||
@@ -16,11 +13,6 @@ class Site(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [
|
|
||||||
FieldPanel("site_name"),
|
|
||||||
FieldPanel("user"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "sites"
|
db_table = "sites"
|
||||||
ordering = ["site_name"]
|
ordering = ["site_name"]
|
||||||
@@ -29,7 +21,6 @@ class Site(models.Model):
|
|||||||
return self.site_name
|
return self.site_name
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class Kandang(models.Model):
|
class Kandang(models.Model):
|
||||||
kandang_id = models.BigAutoField(primary_key=True)
|
kandang_id = models.BigAutoField(primary_key=True)
|
||||||
kandang_name = models.CharField(max_length=30)
|
kandang_name = models.CharField(max_length=30)
|
||||||
@@ -37,11 +28,6 @@ class Kandang(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [
|
|
||||||
FieldPanel("kandang_name"),
|
|
||||||
FieldPanel("site"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "kandang"
|
db_table = "kandang"
|
||||||
ordering = ["kandang_name"]
|
ordering = ["kandang_name"]
|
||||||
@@ -50,23 +36,17 @@ class Kandang(models.Model):
|
|||||||
return self.kandang_name
|
return self.kandang_name
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class Flock(models.Model):
|
class Flock(models.Model):
|
||||||
flock_id = models.BigAutoField(primary_key=True)
|
flock_id = models.BigAutoField(primary_key=True)
|
||||||
flock_name = models.CharField(max_length=30)
|
flock_name = models.CharField(max_length=30)
|
||||||
cycle = models.ForeignKey(
|
kandang = models.ForeignKey(
|
||||||
"Cycle",
|
Kandang,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="flocks",
|
related_name="flocks",
|
||||||
)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [
|
|
||||||
FieldPanel("flock_name"),
|
|
||||||
FieldPanel("cycle"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "flock"
|
db_table = "flock"
|
||||||
ordering = ["flock_name"]
|
ordering = ["flock_name"]
|
||||||
@@ -75,7 +55,6 @@ class Flock(models.Model):
|
|||||||
return self.flock_name
|
return self.flock_name
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class Cycle(models.Model):
|
class Cycle(models.Model):
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
STATUS_CLOSED = "closed"
|
STATUS_CLOSED = "closed"
|
||||||
@@ -98,17 +77,6 @@ class Cycle(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [
|
|
||||||
FieldPanel("kandang"),
|
|
||||||
FieldPanel("total_days"),
|
|
||||||
FieldPanel("start_date"),
|
|
||||||
FieldPanel("end_date"),
|
|
||||||
FieldPanel("doc_in_weight"),
|
|
||||||
FieldPanel("doc_in_count"),
|
|
||||||
FieldPanel("feed_initial_balance"),
|
|
||||||
FieldPanel("status"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "cycles"
|
db_table = "cycles"
|
||||||
ordering = ["-start_date"]
|
ordering = ["-start_date"]
|
||||||
|
|||||||
@@ -21,15 +21,13 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
|||||||
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Flock
|
model = Flock
|
||||||
fields = ["id", "flock_name", "cycle", "created_at", "updated_at"]
|
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
|
||||||
read_only_fields = ["id", "created_at", "updated_at"]
|
read_only_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||||
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
|
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
|
||||||
kandang_name = serializers.CharField(source="kandang.kandang_name", read_only=True)
|
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")
|
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
|
||||||
current_day = serializers.IntegerField(read_only=True)
|
current_day = serializers.IntegerField(read_only=True)
|
||||||
visible_through_date = serializers.DateField(read_only=True)
|
visible_through_date = serializers.DateField(read_only=True)
|
||||||
@@ -40,8 +38,6 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"kandang",
|
"kandang",
|
||||||
"kandang_name",
|
"kandang_name",
|
||||||
"flock",
|
|
||||||
"flock_name",
|
|
||||||
"total_days",
|
"total_days",
|
||||||
"current_day",
|
"current_day",
|
||||||
"visible_through_date",
|
"visible_through_date",
|
||||||
@@ -57,26 +53,7 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
|||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"id",
|
"id",
|
||||||
"kandang_name",
|
"kandang_name",
|
||||||
"flock",
|
|
||||||
"flock_name",
|
|
||||||
"feed_initial_balance",
|
"feed_initial_balance",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
def _first_flock(self, obj):
|
|
||||||
return obj.flocks.order_by("flock_id").first()
|
|
||||||
|
|
||||||
def get_flock(self, obj):
|
|
||||||
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)
|
|
||||||
Flock.objects.create(flock_name=kandang.kandang_name[:30], cycle=cycle)
|
|
||||||
return cycle
|
|
||||||
+46
-45
@@ -10,12 +10,37 @@ from apps.farms.models import Cycle, Flock, Kandang, Site
|
|||||||
class FarmAPITests(TestCase):
|
class FarmAPITests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(user_name="tester", password="secret")
|
self.user = User.objects.create_user(user_name="tester", password="secret")
|
||||||
|
self.admin = User.objects.create_user(
|
||||||
|
user_name="admin",
|
||||||
|
password="secret",
|
||||||
|
status=User.STATUS_SUPERADMIN,
|
||||||
|
)
|
||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
self.client.force_authenticate(self.user)
|
|
||||||
self.site = Site.objects.create(site_name="Sukawarna", user=self.user)
|
self.site = Site.objects.create(site_name="Sukawarna", user=self.user)
|
||||||
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
|
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
|
||||||
|
|
||||||
def test_create_cycle_auto_creates_flock_and_returns_erd_fields(self):
|
def test_create_flock_under_kandang_requires_superadmin(self):
|
||||||
|
self.client.force_authenticate(self.admin)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/flocks/",
|
||||||
|
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201, response.data)
|
||||||
|
self.assertEqual(response.data["flock_name"], "Lantai 1")
|
||||||
|
self.assertEqual(response.data["kandang"], self.kandang.pk)
|
||||||
|
|
||||||
|
def test_staff_cannot_create_flock(self):
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/flocks/",
|
||||||
|
{"flock_name": "Lantai 1", "kandang": self.kandang.pk},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403, response.data)
|
||||||
|
|
||||||
|
def test_create_cycle_without_flock(self):
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
start = date.today()
|
start = date.today()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/v1/cycles/",
|
"/api/v1/cycles/",
|
||||||
@@ -31,56 +56,32 @@ class FarmAPITests(TestCase):
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 201, response.data)
|
self.assertEqual(response.status_code, 201, response.data)
|
||||||
self.assertEqual(response.data["chick_in_weight"], 42)
|
self.assertNotIn("flock", response.data)
|
||||||
self.assertEqual(response.data["kandang_name"], "Kandang 1")
|
self.assertNotIn("flock_name", response.data)
|
||||||
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"])
|
|
||||||
self.assertEqual(cycle.doc_in_weight, 42)
|
|
||||||
self.assertEqual(flock.cycle_id, cycle.pk)
|
|
||||||
|
|
||||||
def test_list_cycles_by_kandang(self):
|
def test_flock_persists_across_cycles(self):
|
||||||
cycle = Cycle.objects.create(
|
flock = Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
|
||||||
|
start = date.today()
|
||||||
|
for offset in (0, 40):
|
||||||
|
Cycle.objects.create(
|
||||||
kandang=self.kandang,
|
kandang=self.kandang,
|
||||||
total_days=35,
|
total_days=35,
|
||||||
start_date=date.today(),
|
start_date=start + timedelta(days=offset),
|
||||||
end_date=date.today() + timedelta(days=34),
|
end_date=start + timedelta(days=offset + 34),
|
||||||
doc_in_weight=40,
|
doc_in_weight=40,
|
||||||
doc_in_count=9000,
|
doc_in_count=9000,
|
||||||
status=Cycle.STATUS_ACTIVE,
|
status=Cycle.STATUS_ACTIVE,
|
||||||
)
|
)
|
||||||
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
|
self.assertEqual(Flock.objects.filter(kandang=self.kandang).count(), 1)
|
||||||
response = self.client.get("/api/v1/cycles/", {"kandang_id": self.kandang.pk})
|
self.assertEqual(flock.kandang_id, self.kandang.pk)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
results = response.data["results"] if isinstance(response.data, dict) else response.data
|
|
||||||
self.assertEqual(len(results), 1)
|
|
||||||
self.assertEqual(results[0]["flock"], flock.pk)
|
|
||||||
self.assertEqual(results[0]["kandang_name"], "Kandang 1")
|
|
||||||
self.assertEqual(results[0]["flock_name"], "Flock A")
|
|
||||||
|
|
||||||
def test_list_flocks_by_cycle(self):
|
def test_list_flocks_by_kandang(self):
|
||||||
cycle = Cycle.objects.create(
|
self.client.force_authenticate(self.user)
|
||||||
kandang=self.kandang,
|
Flock.objects.create(flock_name="Lantai 1", kandang=self.kandang)
|
||||||
total_days=35,
|
Flock.objects.create(flock_name="Lantai 2", kandang=self.kandang)
|
||||||
start_date=date.today(),
|
other_kandang = Kandang.objects.create(kandang_name="Kandang 2", site=self.site)
|
||||||
end_date=date.today() + timedelta(days=34),
|
Flock.objects.create(flock_name="Other", kandang=other_kandang)
|
||||||
doc_in_weight=40,
|
response = self.client.get("/api/v1/flocks/", {"kandang_id": self.kandang.pk})
|
||||||
doc_in_count=9000,
|
|
||||||
status=Cycle.STATUS_ACTIVE,
|
|
||||||
)
|
|
||||||
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
|
|
||||||
other_cycle = Cycle.objects.create(
|
|
||||||
kandang=self.kandang,
|
|
||||||
total_days=35,
|
|
||||||
start_date=date.today(),
|
|
||||||
end_date=date.today() + timedelta(days=34),
|
|
||||||
doc_in_weight=40,
|
|
||||||
doc_in_count=9000,
|
|
||||||
status=Cycle.STATUS_ACTIVE,
|
|
||||||
)
|
|
||||||
Flock.objects.create(flock_name="Flock B", cycle=other_cycle)
|
|
||||||
response = self.client.get("/api/v1/flocks/", {"cycle_id": cycle.pk})
|
|
||||||
self.assertEqual(response.status_code, 200, response.data)
|
self.assertEqual(response.status_code, 200, response.data)
|
||||||
results = response.data["results"] if isinstance(response.data, dict) else response.data
|
results = response.data["results"] if isinstance(response.data, dict) else response.data
|
||||||
self.assertEqual([r["id"] for r in results], [flock.pk])
|
self.assertEqual(len(results), 2)
|
||||||
@@ -56,19 +56,20 @@ class KandangViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class FlockViewSet(viewsets.ModelViewSet):
|
class FlockViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Flock.objects.all()
|
queryset = Flock.objects.select_related("kandang").all()
|
||||||
serializer_class = FlockSerializer
|
serializer_class = FlockSerializer
|
||||||
|
permission_classes = [IsSuperAdminForWrite]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
cycle_id = self.request.query_params.get("cycle_id")
|
kandang_id = self.request.query_params.get("kandang_id")
|
||||||
if cycle_id:
|
if kandang_id:
|
||||||
qs = qs.filter(cycle_id=cycle_id)
|
qs = qs.filter(kandang_id=kandang_id)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class CycleViewSet(viewsets.ModelViewSet):
|
class CycleViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Cycle.objects.select_related("kandang").prefetch_related("flocks").all()
|
queryset = Cycle.objects.select_related("kandang").all()
|
||||||
serializer_class = CycleSerializer
|
serializer_class = CycleSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|||||||
@@ -5,62 +5,119 @@ from django.db import transaction
|
|||||||
from apps.accounts.models import ApiKey, User
|
from apps.accounts.models import ApiKey, User
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_bootstrap_user(
|
||||||
|
*,
|
||||||
|
user_name: str,
|
||||||
|
password: str,
|
||||||
|
status: str,
|
||||||
|
is_staff: bool,
|
||||||
|
is_superuser: bool,
|
||||||
|
force_password: bool,
|
||||||
|
stdout,
|
||||||
|
) -> User:
|
||||||
|
user, created = User.objects.get_or_create(
|
||||||
|
user_name=user_name,
|
||||||
|
defaults={
|
||||||
|
"status": status,
|
||||||
|
"is_staff": is_staff,
|
||||||
|
"is_superuser": is_superuser,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created and user.status != status:
|
||||||
|
raise CommandError(
|
||||||
|
f"User {user_name!r} already exists with status {user.status!r}; "
|
||||||
|
f"cannot bootstrap as {status!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
should_set_password = created or not user.has_usable_password() or force_password
|
||||||
|
if should_set_password:
|
||||||
|
user.set_password(password)
|
||||||
|
user.status = status
|
||||||
|
user.is_staff = is_staff
|
||||||
|
user.is_superuser = is_superuser
|
||||||
|
user.save()
|
||||||
|
action = "Created" if created else "Updated password for"
|
||||||
|
stdout.write(f"{action} {status} user {user_name}")
|
||||||
|
else:
|
||||||
|
user.status = status
|
||||||
|
user.is_staff = is_staff
|
||||||
|
user.is_superuser = is_superuser
|
||||||
|
user.save(update_fields=["status", "is_staff", "is_superuser", "updated_at"])
|
||||||
|
stdout.write(f"User {user_name} already exists (password unchanged)")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = (
|
help = (
|
||||||
"Create production superadmin login only (no demo sites/cycles/data). "
|
"Create production login accounts only (no demo sites/cycles/data). "
|
||||||
"Uses BOOTSTRAP_ADMIN_USER and BOOTSTRAP_ADMIN_PASSWORD from settings."
|
"Uses BOOTSTRAP_ADMIN_* and optional BOOTSTRAP_STAFF_* from settings."
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--force-password",
|
"--force-password",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Reset password even when the admin user already has one",
|
help="Reset password even when the user already has one",
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
|
force_password = options["force_password"]
|
||||||
password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
|
|
||||||
if not password:
|
admin_user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
|
||||||
|
admin_password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
|
||||||
|
if not admin_password:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
"BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin."
|
"BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin."
|
||||||
)
|
)
|
||||||
|
|
||||||
user, created = User.objects.get_or_create(
|
admin = ensure_bootstrap_user(
|
||||||
user_name=user_name,
|
user_name=admin_user_name,
|
||||||
defaults={
|
password=admin_password,
|
||||||
"status": User.STATUS_SUPERADMIN,
|
status=User.STATUS_SUPERADMIN,
|
||||||
"is_staff": True,
|
is_staff=True,
|
||||||
"is_superuser": True,
|
is_superuser=True,
|
||||||
},
|
force_password=force_password,
|
||||||
|
stdout=self.stdout,
|
||||||
)
|
)
|
||||||
|
|
||||||
should_set_password = created or not user.has_usable_password() or options["force_password"]
|
staff_user_name = (settings.BOOTSTRAP_STAFF_USER or "").strip()
|
||||||
if should_set_password:
|
staff_password = settings.BOOTSTRAP_STAFF_PASSWORD or ""
|
||||||
user.set_password(password)
|
if staff_user_name:
|
||||||
user.status = User.STATUS_SUPERADMIN
|
if staff_user_name == admin_user_name:
|
||||||
user.is_staff = True
|
raise CommandError(
|
||||||
user.is_superuser = True
|
"BOOTSTRAP_STAFF_USER must differ from BOOTSTRAP_ADMIN_USER."
|
||||||
user.save()
|
)
|
||||||
action = "Created" if created else "Updated password for"
|
if not staff_password:
|
||||||
self.stdout.write(f"{action} superadmin {user_name}")
|
raise CommandError(
|
||||||
else:
|
"BOOTSTRAP_STAFF_PASSWORD is required when BOOTSTRAP_STAFF_USER is set."
|
||||||
user.status = User.STATUS_SUPERADMIN
|
)
|
||||||
user.is_superuser = True
|
ensure_bootstrap_user(
|
||||||
user.save(update_fields=["status", "is_superuser", "updated_at"])
|
user_name=staff_user_name,
|
||||||
self.stdout.write(f"Superadmin {user_name} already exists (password unchanged)")
|
password=staff_password,
|
||||||
|
status=User.STATUS_ACTIVE,
|
||||||
|
is_staff=True,
|
||||||
|
is_superuser=False,
|
||||||
|
force_password=force_password,
|
||||||
|
stdout=self.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
bootstrap_key = settings.BOOTSTRAP_API_KEY
|
bootstrap_key = settings.BOOTSTRAP_API_KEY
|
||||||
if bootstrap_key:
|
if bootstrap_key:
|
||||||
api_key, _ = ApiKey.generate(user, "bootstrap")
|
api_key, _ = ApiKey.generate(admin, "bootstrap")
|
||||||
api_key.prefix = bootstrap_key[:8]
|
api_key.prefix = bootstrap_key[:8]
|
||||||
api_key.key_hash = ApiKey.hash_key(bootstrap_key)
|
api_key.key_hash = ApiKey.hash_key(bootstrap_key)
|
||||||
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
|
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(f"Bootstrap API key installed (prefix={api_key.prefix})")
|
||||||
|
|
||||||
|
accounts = admin_user_name
|
||||||
|
if staff_user_name:
|
||||||
|
accounts = f"{admin_user_name}, {staff_user_name}"
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(
|
||||||
f"Bootstrap OK: login={user_name} (no demo farm data — use Pengaturan to add sites/kandang/cycles)"
|
f"Bootstrap OK: login={accounts} "
|
||||||
|
"(no demo farm data — use Pengaturan to add sites/kandang/cycles)"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -133,7 +133,7 @@ class Command(BaseCommand):
|
|||||||
real_by_date = self.load_real_rows(Path(iot_json))
|
real_by_date = self.load_real_rows(Path(iot_json))
|
||||||
|
|
||||||
cycles = Cycle.objects.filter(
|
cycles = Cycle.objects.filter(
|
||||||
start_date=cycle_start, flocks__isnull=False
|
start_date=cycle_start, kandang__flocks__isnull=False
|
||||||
).distinct()
|
).distinct()
|
||||||
if not cycles:
|
if not cycles:
|
||||||
raise CommandError(f"No cycles starting {cycle_start} with flocks")
|
raise CommandError(f"No cycles starting {cycle_start} with flocks")
|
||||||
@@ -141,7 +141,7 @@ class Command(BaseCommand):
|
|||||||
seeded = 0
|
seeded = 0
|
||||||
for cycle in cycles:
|
for cycle in cycles:
|
||||||
kandang_name = cycle.kandang.kandang_name
|
kandang_name = cycle.kandang.kandang_name
|
||||||
for flock in cycle.flocks.order_by("flock_id"):
|
for flock in cycle.kandang.flocks.order_by("flock_id"):
|
||||||
profile = self.profile_for(kandang_name, flock.flock_name)
|
profile = self.profile_for(kandang_name, flock.flock_name)
|
||||||
rng = random.Random(options["jitter_seed"] + flock.pk)
|
rng = random.Random(options["jitter_seed"] + flock.pk)
|
||||||
rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng)
|
rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng)
|
||||||
|
|||||||
@@ -206,8 +206,11 @@ class Command(BaseCommand):
|
|||||||
ChickenWeight.objects.filter(cycle=existing).delete()
|
ChickenWeight.objects.filter(cycle=existing).delete()
|
||||||
FeedSacks.objects.filter(cycle=existing).delete()
|
FeedSacks.objects.filter(cycle=existing).delete()
|
||||||
ManualInput.objects.filter(cycle=existing).delete()
|
ManualInput.objects.filter(cycle=existing).delete()
|
||||||
IotPanel.objects.filter(flock__cycle=existing).delete()
|
IotPanel.objects.filter(
|
||||||
Flock.objects.filter(cycle=existing).delete()
|
flock__kandang=kandang,
|
||||||
|
date__gte=existing.start_date,
|
||||||
|
date__lte=existing.end_date,
|
||||||
|
).delete()
|
||||||
existing.delete()
|
existing.delete()
|
||||||
self.stdout.write("Reset previous Kandang 1 cycle")
|
self.stdout.write("Reset previous Kandang 1 cycle")
|
||||||
|
|
||||||
@@ -232,7 +235,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
flocks_by_name: dict[str, Flock] = {}
|
flocks_by_name: dict[str, Flock] = {}
|
||||||
for name in FLOCK_NAMES:
|
for name in FLOCK_NAMES:
|
||||||
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
|
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
|
||||||
flocks_by_name[name] = flock
|
flocks_by_name[name] = flock
|
||||||
|
|
||||||
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
|
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
|
||||||
@@ -426,7 +429,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(
|
||||||
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
|
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
|
||||||
f"flocks={Flock.objects.filter(cycle=cycle).count()} "
|
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
|
||||||
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
|
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
|
||||||
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
|
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
|
||||||
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} "
|
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} "
|
||||||
|
|||||||
@@ -96,8 +96,11 @@ class Command(BaseCommand):
|
|||||||
ChickenWeight.objects.filter(cycle=existing).delete()
|
ChickenWeight.objects.filter(cycle=existing).delete()
|
||||||
FeedSacks.objects.filter(cycle=existing).delete()
|
FeedSacks.objects.filter(cycle=existing).delete()
|
||||||
ManualInput.objects.filter(cycle=existing).delete()
|
ManualInput.objects.filter(cycle=existing).delete()
|
||||||
IotPanel.objects.filter(flock__cycle=existing).delete()
|
IotPanel.objects.filter(
|
||||||
Flock.objects.filter(cycle=existing).delete()
|
flock__kandang=kandang,
|
||||||
|
date__gte=existing.start_date,
|
||||||
|
date__lte=existing.end_date,
|
||||||
|
).delete()
|
||||||
existing.delete()
|
existing.delete()
|
||||||
self.stdout.write("Reset previous Kandang 2 cycle")
|
self.stdout.write("Reset previous Kandang 2 cycle")
|
||||||
|
|
||||||
@@ -122,7 +125,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
flocks_by_name: dict[str, Flock] = {}
|
flocks_by_name: dict[str, Flock] = {}
|
||||||
for name in ("Lantai 1", "Lantai 2"):
|
for name in ("Lantai 1", "Lantai 2"):
|
||||||
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
|
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
|
||||||
flocks_by_name[name] = flock
|
flocks_by_name[name] = flock
|
||||||
|
|
||||||
cc_by_date = {}
|
cc_by_date = {}
|
||||||
@@ -288,7 +291,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(
|
||||||
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
|
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
|
||||||
f"flocks={Flock.objects.filter(cycle=cycle).count()} "
|
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
|
||||||
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
|
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
|
||||||
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
|
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
|
||||||
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}"
|
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from django.core.management import call_command
|
|||||||
@override_settings(
|
@override_settings(
|
||||||
BOOTSTRAP_ADMIN_USER="admin",
|
BOOTSTRAP_ADMIN_USER="admin",
|
||||||
BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n",
|
BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n",
|
||||||
|
BOOTSTRAP_STAFF_USER="",
|
||||||
|
BOOTSTRAP_STAFF_PASSWORD="",
|
||||||
)
|
)
|
||||||
class BootstrapAdminTests(TestCase):
|
class BootstrapAdminTests(TestCase):
|
||||||
def test_creates_superadmin_without_demo_data(self):
|
def test_creates_superadmin_without_demo_data(self):
|
||||||
@@ -23,3 +25,31 @@ class BootstrapAdminTests(TestCase):
|
|||||||
with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""):
|
with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""):
|
||||||
with self.assertRaises(CommandError):
|
with self.assertRaises(CommandError):
|
||||||
call_command("bootstrap_admin")
|
call_command("bootstrap_admin")
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
BOOTSTRAP_STAFF_USER="staff",
|
||||||
|
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
|
||||||
|
)
|
||||||
|
def test_creates_staff_when_configured(self):
|
||||||
|
call_command("bootstrap_admin")
|
||||||
|
staff = User.objects.get(user_name="staff")
|
||||||
|
self.assertEqual(staff.status, User.STATUS_ACTIVE)
|
||||||
|
self.assertTrue(staff.is_staff)
|
||||||
|
self.assertFalse(staff.is_superuser)
|
||||||
|
self.assertTrue(staff.check_password("St4ff!"))
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
BOOTSTRAP_STAFF_USER="staff",
|
||||||
|
BOOTSTRAP_STAFF_PASSWORD="",
|
||||||
|
)
|
||||||
|
def test_requires_staff_password_when_user_set(self):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command("bootstrap_admin")
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
BOOTSTRAP_STAFF_USER="admin",
|
||||||
|
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
|
||||||
|
)
|
||||||
|
def test_rejects_staff_same_as_admin(self):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command("bootstrap_admin")
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from apps.operations.models import (
|
|
||||||
AIInsight,
|
|
||||||
ChickenCounting,
|
|
||||||
ChickenWeight,
|
|
||||||
FeedSacks,
|
|
||||||
IotPanel,
|
|
||||||
KPI,
|
|
||||||
ManualInput,
|
|
||||||
)
|
|
||||||
|
|
||||||
admin.site.register(KPI)
|
|
||||||
admin.site.register(ManualInput)
|
|
||||||
admin.site.register(ChickenCounting)
|
|
||||||
admin.site.register(ChickenWeight)
|
|
||||||
admin.site.register(FeedSacks)
|
|
||||||
admin.site.register(IotPanel)
|
|
||||||
admin.site.register(AIInsight)
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from wagtail.admin.panels import FieldPanel
|
|
||||||
from wagtail.snippets.models import register_snippet
|
|
||||||
|
|
||||||
from apps.farms.models import Cycle, Flock
|
from apps.farms.models import Cycle, Flock
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class IotPanel(models.Model):
|
class IotPanel(models.Model):
|
||||||
panel_id = models.BigAutoField(primary_key=True)
|
panel_id = models.BigAutoField(primary_key=True)
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
@@ -19,8 +16,6 @@ class IotPanel(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [FieldPanel("flock"), FieldPanel("date"), FieldPanel("average_temperature")]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "iot_panel"
|
db_table = "iot_panel"
|
||||||
ordering = ["-date"]
|
ordering = ["-date"]
|
||||||
@@ -31,7 +26,6 @@ class IotPanel(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class AIInsight(models.Model):
|
class AIInsight(models.Model):
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
insight_text = models.TextField()
|
insight_text = models.TextField()
|
||||||
@@ -42,14 +36,11 @@ class AIInsight(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("insight_text"), FieldPanel("alert")]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "ai_insight"
|
db_table = "ai_insight"
|
||||||
ordering = ["-date"]
|
ordering = ["-date"]
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class FeedSacks(models.Model):
|
class FeedSacks(models.Model):
|
||||||
fs_id = models.BigAutoField(primary_key=True)
|
fs_id = models.BigAutoField(primary_key=True)
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
@@ -63,8 +54,6 @@ class FeedSacks(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("in_today"), FieldPanel("out_today")]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "feed_sacks"
|
db_table = "feed_sacks"
|
||||||
ordering = ["-date"]
|
ordering = ["-date"]
|
||||||
@@ -74,7 +63,6 @@ class FeedSacks(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class ChickenCounting(models.Model):
|
class ChickenCounting(models.Model):
|
||||||
cc_id = models.BigAutoField(primary_key=True)
|
cc_id = models.BigAutoField(primary_key=True)
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
@@ -84,8 +72,6 @@ class ChickenCounting(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("total_count")]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "chicken_counting"
|
db_table = "chicken_counting"
|
||||||
ordering = ["-date"]
|
ordering = ["-date"]
|
||||||
@@ -94,7 +80,6 @@ class ChickenCounting(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class ChickenWeight(models.Model):
|
class ChickenWeight(models.Model):
|
||||||
cw_id = models.BigAutoField(primary_key=True)
|
cw_id = models.BigAutoField(primary_key=True)
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
@@ -108,13 +93,6 @@ class ChickenWeight(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [
|
|
||||||
FieldPanel("cycle"),
|
|
||||||
FieldPanel("date"),
|
|
||||||
FieldPanel("average_weight"),
|
|
||||||
FieldPanel("uniformity"),
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "chicken_weight"
|
db_table = "chicken_weight"
|
||||||
ordering = ["-date"]
|
ordering = ["-date"]
|
||||||
@@ -123,7 +101,6 @@ class ChickenWeight(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class ManualInput(models.Model):
|
class ManualInput(models.Model):
|
||||||
manual_id = models.BigAutoField(primary_key=True)
|
manual_id = models.BigAutoField(primary_key=True)
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
@@ -149,8 +126,6 @@ class ManualInput(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age_manual"), FieldPanel("fcr_manual")]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "manual_input"
|
db_table = "manual_input"
|
||||||
ordering = ["-date"]
|
ordering = ["-date"]
|
||||||
@@ -159,7 +134,6 @@ class ManualInput(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@register_snippet
|
|
||||||
class KPI(models.Model):
|
class KPI(models.Model):
|
||||||
kpi_id = models.BigAutoField(primary_key=True)
|
kpi_id = models.BigAutoField(primary_key=True)
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
@@ -187,8 +161,6 @@ class KPI(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age"), FieldPanel("fcr"), FieldPanel("eef")]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "kpi"
|
db_table = "kpi"
|
||||||
ordering = ["-date"]
|
ordering = ["-date"]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class CycleContextMixin(serializers.Serializer):
|
|||||||
flock_id = serializers.SerializerMethodField()
|
flock_id = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_flock_id(self, obj):
|
def get_flock_id(self, obj):
|
||||||
flock = obj.cycle.flocks.order_by("flock_id").first()
|
flock = obj.cycle.kandang.flocks.order_by("flock_id").first()
|
||||||
return flock.pk if flock is not None else None
|
return flock.pk if flock is not None else None
|
||||||
|
|
||||||
|
|
||||||
@@ -124,8 +124,7 @@ class IotPanelSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
|||||||
read_only_fields = ["id", "created_at", "updated_at"]
|
read_only_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
def get_kandang(self, obj):
|
def get_kandang(self, obj):
|
||||||
cycle = getattr(obj.flock, "cycle", None)
|
return obj.flock.kandang_id
|
||||||
return cycle.kandang_id if cycle is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
|
class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import httpx
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.farms.models import Flock
|
from apps.farms.models import Cycle, Flock
|
||||||
from apps.operations.models import IotPanel
|
from apps.operations.models import IotPanel
|
||||||
|
|
||||||
WIB = ZoneInfo("Asia/Jakarta")
|
WIB = ZoneInfo("Asia/Jakarta")
|
||||||
@@ -199,7 +199,7 @@ def sync_iot_panels(
|
|||||||
django_flocks: dict[str, Flock] = {}
|
django_flocks: dict[str, Flock] = {}
|
||||||
for external_id, django_id in flock_map.items():
|
for external_id, django_id in flock_map.items():
|
||||||
try:
|
try:
|
||||||
django_flocks[str(external_id)] = Flock.objects.select_related("cycle").get(pk=int(django_id))
|
django_flocks[str(external_id)] = Flock.objects.select_related("kandang").get(pk=int(django_id))
|
||||||
except Flock.DoesNotExist:
|
except Flock.DoesNotExist:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -236,7 +236,16 @@ def sync_iot_panels(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
snapshot_date = fetched_at.astimezone(WIB).date()
|
snapshot_date = fetched_at.astimezone(WIB).date()
|
||||||
age = cycle_age_for_date(flock.cycle.start_date, snapshot_date)
|
cycle = (
|
||||||
|
Cycle.objects.filter(
|
||||||
|
kandang_id=flock.kandang_id,
|
||||||
|
start_date__lte=snapshot_date,
|
||||||
|
end_date__gte=snapshot_date,
|
||||||
|
)
|
||||||
|
.order_by("-start_date")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
age = cycle_age_for_date(cycle.start_date, snapshot_date) if cycle else 1
|
||||||
fields = panel_fields_from_payload(payload_data, age)
|
fields = panel_fields_from_payload(payload_data, age)
|
||||||
|
|
||||||
IotPanel.objects.update_or_create(
|
IotPanel.objects.update_or_create(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class OperationsAPITests(TestCase):
|
|||||||
doc_in_count=10000,
|
doc_in_count=10000,
|
||||||
status=Cycle.STATUS_ACTIVE,
|
status=Cycle.STATUS_ACTIVE,
|
||||||
)
|
)
|
||||||
self.flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
|
self.flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang)
|
||||||
|
|
||||||
def test_karungs_alias_lists_feed_sacks_with_date(self):
|
def test_karungs_alias_lists_feed_sacks_with_date(self):
|
||||||
FeedSacks.objects.create(
|
FeedSacks.objects.create(
|
||||||
@@ -65,7 +65,7 @@ class OperationsAPITests(TestCase):
|
|||||||
self.assertEqual(results[0]["date"], date.today().isoformat())
|
self.assertEqual(results[0]["date"], date.today().isoformat())
|
||||||
|
|
||||||
def test_panel_iot_filters_by_flock(self):
|
def test_panel_iot_filters_by_flock(self):
|
||||||
flock_b = Flock.objects.create(flock_name="Flock B", cycle=self.cycle)
|
flock_b = Flock.objects.create(flock_name="Flock B", kandang=self.kandang)
|
||||||
row = IotPanel.objects.create(
|
row = IotPanel.objects.create(
|
||||||
flock=self.flock,
|
flock=self.flock,
|
||||||
date=date.today(),
|
date=date.today(),
|
||||||
@@ -379,7 +379,7 @@ class DashboardVisibilityTests(TestCase):
|
|||||||
self.assertEqual(response.data["date"], "2026-08-28")
|
self.assertEqual(response.data["date"], "2026-08-28")
|
||||||
|
|
||||||
def test_panel_iot_not_hidden_before_publish_time(self):
|
def test_panel_iot_not_hidden_before_publish_time(self):
|
||||||
flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
|
flock = Flock.objects.create(flock_name="Flock A", kandang=self.kandang)
|
||||||
IotPanel.objects.create(
|
IotPanel.objects.create(
|
||||||
flock=flock,
|
flock=flock,
|
||||||
date=date(2026, 8, 28),
|
date=date(2026, 8, 28),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class CycleScopedViewSet(VisibilityFilteredMixin, viewsets.ModelViewSet):
|
|||||||
if params.get("cycle_id"):
|
if params.get("cycle_id"):
|
||||||
qs = qs.filter(cycle_id=params["cycle_id"])
|
qs = qs.filter(cycle_id=params["cycle_id"])
|
||||||
if params.get("flock_id"):
|
if params.get("flock_id"):
|
||||||
qs = qs.filter(cycle__flocks__flock_id=params["flock_id"]).distinct()
|
qs = qs.filter(cycle__kandang__flocks=params["flock_id"]).distinct()
|
||||||
if params.get("date"):
|
if params.get("date"):
|
||||||
qs = qs.filter(date=params["date"])
|
qs = qs.filter(date=params["date"])
|
||||||
return self.filter_visible_through(qs)
|
return self.filter_visible_through(qs)
|
||||||
@@ -143,14 +143,23 @@ class FeedSacksViewSet(CycleScopedViewSet):
|
|||||||
|
|
||||||
class IotPanelViewSet(viewsets.ModelViewSet):
|
class IotPanelViewSet(viewsets.ModelViewSet):
|
||||||
"""IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff."""
|
"""IoT panel is real-time — not subject to the 17:00 dashboard publish cutoff."""
|
||||||
queryset = IotPanel.objects.select_related("flock", "flock__cycle").all()
|
queryset = IotPanel.objects.select_related("flock", "flock__kandang").all()
|
||||||
serializer_class = IotPanelSerializer
|
serializer_class = IotPanelSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
if params.get("cycle_id"):
|
cycle_id = params.get("cycle_id")
|
||||||
qs = qs.filter(flock__cycle_id=params["cycle_id"])
|
if cycle_id:
|
||||||
|
try:
|
||||||
|
cycle = Cycle.objects.get(pk=cycle_id)
|
||||||
|
except Cycle.DoesNotExist:
|
||||||
|
return qs.none()
|
||||||
|
qs = qs.filter(
|
||||||
|
flock__kandang_id=cycle.kandang_id,
|
||||||
|
date__gte=cycle.start_date,
|
||||||
|
date__lte=cycle.end_date,
|
||||||
|
)
|
||||||
if params.get("flock_id"):
|
if params.get("flock_id"):
|
||||||
qs = qs.filter(flock_id=params["flock_id"])
|
qs = qs.filter(flock_id=params["flock_id"])
|
||||||
if params.get("date"):
|
if params.get("date"):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Django settings for New Dashboard (ERD-aligned Wagtail + DRF)."""
|
"""Django settings for New Dashboard (ERD-aligned DRF API)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -42,24 +42,6 @@ ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", "localhost,127.0.0.1")
|
|||||||
CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS", "http://localhost:8000")
|
CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS", "http://localhost:8000")
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"apps.accounts",
|
|
||||||
"apps.farms",
|
|
||||||
"apps.operations",
|
|
||||||
"apps.jobs",
|
|
||||||
"wagtail.contrib.forms",
|
|
||||||
"wagtail.contrib.redirects",
|
|
||||||
"wagtail.embeds",
|
|
||||||
"wagtail.sites",
|
|
||||||
"wagtail.users",
|
|
||||||
"wagtail.snippets",
|
|
||||||
"wagtail.documents",
|
|
||||||
"wagtail.images",
|
|
||||||
"wagtail.search",
|
|
||||||
"wagtail.admin",
|
|
||||||
"wagtail",
|
|
||||||
"modelcluster",
|
|
||||||
"taggit",
|
|
||||||
"django.contrib.admin",
|
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
@@ -68,6 +50,10 @@ INSTALLED_APPS = [
|
|||||||
"rest_framework",
|
"rest_framework",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
|
"apps.accounts",
|
||||||
|
"apps.farms",
|
||||||
|
"apps.operations",
|
||||||
|
"apps.jobs",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -79,7 +65,6 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
@@ -142,10 +127,6 @@ MEDIA_ROOT = BASE_DIR / "media"
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
WAGTAIL_SITE_NAME = env("WAGTAIL_SITE_NAME", "New Dashboard")
|
|
||||||
WAGTAILADMIN_BASE_URL = env("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
|
|
||||||
WAGTAIL_APPEND_SLASH = False
|
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS", "http://localhost:3000")
|
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS", "http://localhost:3000")
|
||||||
CORS_ALLOW_CREDENTIALS = env_bool("CORS_ALLOW_CREDENTIALS", True)
|
CORS_ALLOW_CREDENTIALS = env_bool("CORS_ALLOW_CREDENTIALS", True)
|
||||||
|
|
||||||
@@ -177,6 +158,8 @@ BOOTSTRAP_API_KEY = env("BOOTSTRAP_API_KEY", "") or ""
|
|||||||
|
|
||||||
BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin"
|
BOOTSTRAP_ADMIN_USER = env("BOOTSTRAP_ADMIN_USER", "admin") or "admin"
|
||||||
BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or ""
|
BOOTSTRAP_ADMIN_PASSWORD = env("BOOTSTRAP_ADMIN_PASSWORD", "") or ""
|
||||||
|
BOOTSTRAP_STAFF_USER = env("BOOTSTRAP_STAFF_USER", "") or ""
|
||||||
|
BOOTSTRAP_STAFF_PASSWORD = env("BOOTSTRAP_STAFF_PASSWORD", "") or ""
|
||||||
|
|
||||||
KARUNG_WEB_ADMIN_BASE_URL = (
|
KARUNG_WEB_ADMIN_BASE_URL = (
|
||||||
env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or ""
|
env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or ""
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||||
from wagtail import urls as wagtail_urls
|
|
||||||
from wagtail.admin import urls as wagtailadmin_urls
|
|
||||||
from wagtail.documents import urls as wagtaildocs_urls
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("django-admin/", admin.site.urls),
|
|
||||||
path("admin/", include(wagtailadmin_urls)),
|
|
||||||
path("documents/", include(wagtaildocs_urls)),
|
|
||||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||||
path(
|
path(
|
||||||
"api/docs/",
|
"api/docs/",
|
||||||
@@ -20,7 +13,6 @@ urlpatterns = [
|
|||||||
path("api/v1/", include("apps.accounts.urls")),
|
path("api/v1/", include("apps.accounts.urls")),
|
||||||
path("api/v1/", include("apps.farms.urls")),
|
path("api/v1/", include("apps.farms.urls")),
|
||||||
path("api/v1/", include("apps.operations.urls")),
|
path("api/v1/", include("apps.operations.urls")),
|
||||||
path("", include(wagtail_urls)),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
Django>=5.1,<5.3
|
Django>=5.1,<5.3
|
||||||
wagtail>=6.3,<7.0
|
|
||||||
djangorestframework>=3.15,<4.0
|
djangorestframework>=3.15,<4.0
|
||||||
drf-spectacular>=0.27,<1.0
|
drf-spectacular>=0.27,<1.0
|
||||||
django-cors-headers>=4.4,<5.0
|
django-cors-headers>=4.4,<5.0
|
||||||
@@ -7,5 +6,4 @@ python-dotenv>=1.0,<2.0
|
|||||||
gunicorn>=23.0,<24.0
|
gunicorn>=23.0,<24.0
|
||||||
psycopg[binary]>=3.2,<4.0
|
psycopg[binary]>=3.2,<4.0
|
||||||
httpx>=0.27,<1.0
|
httpx>=0.27,<1.0
|
||||||
Pillow>=10.0,<12.0
|
|
||||||
openpyxl>=3.1,<4.0
|
openpyxl>=3.1,<4.0
|
||||||
@@ -76,7 +76,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
}, [selectedChartKey]);
|
}, [selectedChartKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCycle) {
|
if (!selectedKandang) {
|
||||||
setFlocks([]);
|
setFlocks([]);
|
||||||
setSelectedFlockId(null);
|
setSelectedFlockId(null);
|
||||||
setDates([]);
|
setDates([]);
|
||||||
@@ -89,7 +89,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.flocks.list({ cycle_id: selectedCycle.id });
|
const data = await api.flocks.list({ kandang_id: selectedKandang.id });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setFlocks(data);
|
setFlocks(data);
|
||||||
setSelectedFlockId((previous) =>
|
setSelectedFlockId((previous) =>
|
||||||
@@ -102,7 +102,7 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [selectedCycle, refreshToken]);
|
}, [selectedKandang, refreshToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCycle || selectedFlockId == null) {
|
if (!selectedCycle || selectedFlockId == null) {
|
||||||
|
|||||||
+78
-15
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { api, errorMessage } from '../services/apiClient.ts';
|
import { api, errorMessage } from '../services/apiClient.ts';
|
||||||
import type { CycleStatus } from '../types/api.ts';
|
|
||||||
import { useAuth } from '../context/AuthContext.tsx';
|
import { useAuth } from '../context/AuthContext.tsx';
|
||||||
import { useFarm } from '../context/FarmContext.tsx';
|
import { useFarm } from '../context/FarmContext.tsx';
|
||||||
import { Banner, PageHeader } from './ui/Feedback.tsx';
|
import { Banner, PageHeader } from './ui/Feedback.tsx';
|
||||||
@@ -14,6 +13,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
const {
|
const {
|
||||||
sites,
|
sites,
|
||||||
kandangs,
|
kandangs,
|
||||||
|
flocks,
|
||||||
cycles,
|
cycles,
|
||||||
selectedSiteId,
|
selectedSiteId,
|
||||||
selectedKandangId,
|
selectedKandangId,
|
||||||
@@ -27,11 +27,11 @@ const SettingsPage: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [siteName, setSiteName] = useState('');
|
const [siteName, setSiteName] = useState('');
|
||||||
const [kandangName, setKandangName] = useState('');
|
const [kandangName, setKandangName] = useState('');
|
||||||
|
const [flockName, setFlockName] = useState('');
|
||||||
const [startDate, setStartDate] = useState(todayIso());
|
const [startDate, setStartDate] = useState(todayIso());
|
||||||
const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34));
|
const [endDate, setEndDate] = useState(() => addDaysIso(todayIso(), 34));
|
||||||
const [docInCount, setDocInCount] = useState(20000);
|
const [docInCount, setDocInCount] = useState(20000);
|
||||||
const [chickInWeight, setChickInWeight] = useState(45);
|
const [chickInWeight, setChickInWeight] = useState(35);
|
||||||
const [cycleStatus, setCycleStatus] = useState<CycleStatus>('active');
|
|
||||||
|
|
||||||
const flash = (msg: string) => {
|
const flash = (msg: string) => {
|
||||||
setNotice(msg);
|
setNotice(msg);
|
||||||
@@ -55,7 +55,10 @@ const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Pengaturan" subtitle="Site, kandang, dan siklus produksi" />
|
<PageHeader
|
||||||
|
title="Pengaturan"
|
||||||
|
subtitle="Site, kandang, flock (IoT), dan siklus produksi"
|
||||||
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Banner kind="error">{error}</Banner>
|
<Banner kind="error">{error}</Banner>
|
||||||
@@ -66,7 +69,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
<Banner kind="success">{notice}</Banner>
|
<Banner kind="success">{notice}</Banner>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4 gap-6">
|
||||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||||
<h2 className="font-semibold text-gray-800 mb-3">Sites</h2>
|
<h2 className="font-semibold text-gray-800 mb-3">Sites</h2>
|
||||||
{isSuperAdmin ? (
|
{isSuperAdmin ? (
|
||||||
@@ -188,6 +191,69 @@ const SettingsPage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||||
|
<h2 className="font-semibold text-gray-800 mb-3">Flock</h2>
|
||||||
|
<p className="mb-3 text-xs text-gray-500">
|
||||||
|
Flock tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus.
|
||||||
|
</p>
|
||||||
|
{isSuperAdmin ? (
|
||||||
|
<form
|
||||||
|
className="space-y-2 mb-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedKandangId) return;
|
||||||
|
void wrap(async () => {
|
||||||
|
const created = await api.flocks.create({
|
||||||
|
flock_name: flockName,
|
||||||
|
kandang: selectedKandangId,
|
||||||
|
});
|
||||||
|
setFlockName('');
|
||||||
|
flash(`Flock ${created.flock_name} dibuat.`);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Nama flock"
|
||||||
|
value={flockName}
|
||||||
|
onChange={(e) => setFlockName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
|
||||||
|
disabled={saving || !selectedKandangId}
|
||||||
|
>
|
||||||
|
Tambah flock
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat flock.</p>
|
||||||
|
)}
|
||||||
|
{!selectedKandangId ? (
|
||||||
|
<p className="text-xs text-gray-500">Pilih kandang terlebih dahulu.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{flocks.map((flock) => (
|
||||||
|
<li key={flock.id} className="py-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm text-gray-700">{flock.flock_name}</span>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-red-500 hover:underline"
|
||||||
|
onClick={() =>
|
||||||
|
void wrap(async () => {
|
||||||
|
await api.flocks.remove(flock.id);
|
||||||
|
flash('Flock dihapus.');
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||||
<h2 className="font-semibold text-gray-800 mb-3">Siklus</h2>
|
<h2 className="font-semibold text-gray-800 mb-3">Siklus</h2>
|
||||||
<form
|
<form
|
||||||
@@ -204,7 +270,6 @@ const SettingsPage: React.FC = () => {
|
|||||||
end_date: endDate,
|
end_date: endDate,
|
||||||
chick_in_weight: chickInWeight,
|
chick_in_weight: chickInWeight,
|
||||||
doc_in_count: docInCount,
|
doc_in_count: docInCount,
|
||||||
status: cycleStatus,
|
|
||||||
});
|
});
|
||||||
flash('Siklus dibuat.');
|
flash('Siklus dibuat.');
|
||||||
});
|
});
|
||||||
@@ -247,18 +312,16 @@ const SettingsPage: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className={`${inputClass} mt-1`}
|
className={`${inputClass} mt-1`}
|
||||||
|
min={35}
|
||||||
|
max={51}
|
||||||
value={chickInWeight}
|
value={chickInWeight}
|
||||||
onChange={(e) => setChickInWeight(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
if (Number.isNaN(value)) return;
|
||||||
|
setChickInWeight(Math.min(51, Math.max(35, value)));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<select
|
|
||||||
className={inputClass}
|
|
||||||
value={cycleStatus}
|
|
||||||
onChange={(e) => setCycleStatus(e.target.value as CycleStatus)}
|
|
||||||
>
|
|
||||||
<option value="active">active</option>
|
|
||||||
<option value="closed">closed</option>
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
|
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import SettingsPage from '../SettingsPage.tsx';
|
import SettingsPage from '../SettingsPage.tsx';
|
||||||
|
|
||||||
const { createKandangMock, createCycleMock, reloadMock, farmState } = vi.hoisted(() => ({
|
const { createKandangMock, createFlockMock, createCycleMock, reloadMock, farmState } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
createKandangMock: vi.fn(),
|
createKandangMock: vi.fn(),
|
||||||
|
createFlockMock: vi.fn(),
|
||||||
createCycleMock: vi.fn(),
|
createCycleMock: vi.fn(),
|
||||||
reloadMock: vi.fn().mockResolvedValue(undefined),
|
reloadMock: vi.fn().mockResolvedValue(undefined),
|
||||||
farmState: {
|
farmState: {
|
||||||
@@ -15,12 +17,21 @@ const { createKandangMock, createCycleMock, reloadMock, farmState } = vi.hoisted
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}>,
|
}>,
|
||||||
|
flocks: [] as Array<{
|
||||||
|
id: number;
|
||||||
|
flock_name: string;
|
||||||
|
kandang: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>,
|
||||||
cycles: [] as unknown[],
|
cycles: [] as unknown[],
|
||||||
selectedSiteId: 7,
|
selectedSiteId: 7,
|
||||||
selectedKandangId: null as number | null,
|
selectedKandangId: null as number | null,
|
||||||
|
selectedCycleId: null as number | null,
|
||||||
selectedCycle: null as unknown,
|
selectedCycle: null as unknown,
|
||||||
},
|
},
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock('../../services/apiClient.ts', () => ({
|
vi.mock('../../services/apiClient.ts', () => ({
|
||||||
api: {
|
api: {
|
||||||
@@ -32,6 +43,10 @@ vi.mock('../../services/apiClient.ts', () => ({
|
|||||||
create: createKandangMock,
|
create: createKandangMock,
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
},
|
},
|
||||||
|
flocks: {
|
||||||
|
create: createFlockMock,
|
||||||
|
remove: vi.fn(),
|
||||||
|
},
|
||||||
cycles: {
|
cycles: {
|
||||||
create: createCycleMock,
|
create: createCycleMock,
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
@@ -56,9 +71,11 @@ vi.mock('../../context/FarmContext.tsx', () => ({
|
|||||||
useFarm: () => ({
|
useFarm: () => ({
|
||||||
sites: farmState.sites,
|
sites: farmState.sites,
|
||||||
kandangs: farmState.kandangs,
|
kandangs: farmState.kandangs,
|
||||||
|
flocks: farmState.flocks,
|
||||||
cycles: farmState.cycles,
|
cycles: farmState.cycles,
|
||||||
selectedSiteId: farmState.selectedSiteId,
|
selectedSiteId: farmState.selectedSiteId,
|
||||||
selectedKandangId: farmState.selectedKandangId,
|
selectedKandangId: farmState.selectedKandangId,
|
||||||
|
selectedCycleId: farmState.selectedCycleId,
|
||||||
selectedCycle: farmState.selectedCycle,
|
selectedCycle: farmState.selectedCycle,
|
||||||
reload: reloadMock,
|
reload: reloadMock,
|
||||||
setSelectedSiteId: vi.fn(),
|
setSelectedSiteId: vi.fn(),
|
||||||
@@ -70,12 +87,15 @@ vi.mock('../../context/FarmContext.tsx', () => ({
|
|||||||
describe('SettingsPage settings forms', () => {
|
describe('SettingsPage settings forms', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createKandangMock.mockReset();
|
createKandangMock.mockReset();
|
||||||
|
createFlockMock.mockReset();
|
||||||
createCycleMock.mockReset();
|
createCycleMock.mockReset();
|
||||||
reloadMock.mockClear();
|
reloadMock.mockClear();
|
||||||
farmState.kandangs = [];
|
farmState.kandangs = [];
|
||||||
|
farmState.flocks = [];
|
||||||
farmState.cycles = [];
|
farmState.cycles = [];
|
||||||
farmState.selectedSiteId = 7;
|
farmState.selectedSiteId = 7;
|
||||||
farmState.selectedKandangId = null;
|
farmState.selectedKandangId = null;
|
||||||
|
farmState.selectedCycleId = null;
|
||||||
farmState.selectedCycle = null;
|
farmState.selectedCycle = null;
|
||||||
createKandangMock.mockResolvedValue({
|
createKandangMock.mockResolvedValue({
|
||||||
id: 11,
|
id: 11,
|
||||||
@@ -84,15 +104,21 @@ describe('SettingsPage settings forms', () => {
|
|||||||
created_at: '',
|
created_at: '',
|
||||||
updated_at: '',
|
updated_at: '',
|
||||||
});
|
});
|
||||||
|
createFlockMock.mockResolvedValue({
|
||||||
|
id: 3,
|
||||||
|
flock_name: 'Lantai 1',
|
||||||
|
kandang: 11,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
});
|
||||||
createCycleMock.mockResolvedValue({
|
createCycleMock.mockResolvedValue({
|
||||||
id: 21,
|
id: 21,
|
||||||
kandang: 11,
|
kandang: 11,
|
||||||
flock: 3,
|
|
||||||
total_days: 35,
|
total_days: 35,
|
||||||
current_day: 1,
|
current_day: 1,
|
||||||
start_date: '2026-08-18',
|
start_date: '2026-08-18',
|
||||||
end_date: '2026-09-21',
|
end_date: '2026-09-21',
|
||||||
chick_in_weight: 45,
|
chick_in_weight: 35,
|
||||||
doc_in_count: 20000,
|
doc_in_count: 20000,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
created_at: '',
|
created_at: '',
|
||||||
@@ -116,7 +142,7 @@ describe('SettingsPage settings forms', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits cycle fields expected by the backend contract', async () => {
|
it('submits flock fields for the selected kandang', async () => {
|
||||||
farmState.kandangs = [
|
farmState.kandangs = [
|
||||||
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
|
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
|
||||||
];
|
];
|
||||||
@@ -124,6 +150,27 @@ describe('SettingsPage settings forms', () => {
|
|||||||
|
|
||||||
render(<SettingsPage />);
|
render(<SettingsPage />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Nama flock'), {
|
||||||
|
target: { value: 'Lantai 1' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Tambah flock' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(createFlockMock).toHaveBeenCalledTimes(1));
|
||||||
|
expect(createFlockMock).toHaveBeenCalledWith({
|
||||||
|
flock_name: 'Lantai 1',
|
||||||
|
kandang: 11,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits cycle fields without flock assignment', async () => {
|
||||||
|
farmState.kandangs = [
|
||||||
|
{ id: 11, kandang_name: 'Kandang 1', site: 7, created_at: '', updated_at: '' },
|
||||||
|
];
|
||||||
|
farmState.selectedKandangId = 11;
|
||||||
|
|
||||||
|
render(<SettingsPage />);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(/^Siklus$/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/Total hari/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/Total hari/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Mulai/i), { target: { value: '2026-08-01' } });
|
fireEvent.change(screen.getByLabelText(/Mulai/i), { target: { value: '2026-08-01' } });
|
||||||
@@ -137,9 +184,8 @@ describe('SettingsPage settings forms', () => {
|
|||||||
current_day: 1,
|
current_day: 1,
|
||||||
start_date: '2026-08-01',
|
start_date: '2026-08-01',
|
||||||
end_date: '2026-09-04',
|
end_date: '2026-09-04',
|
||||||
chick_in_weight: 45,
|
chick_in_weight: 35,
|
||||||
doc_in_count: 20000,
|
doc_in_count: 20000,
|
||||||
status: 'active',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -30,13 +30,6 @@ type Row = {
|
|||||||
isFuture: boolean;
|
isFuture: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditValues = {
|
|
||||||
mati: number;
|
|
||||||
panen: number;
|
|
||||||
beratPanen: string;
|
|
||||||
chickenCount?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||||
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
|
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
|
||||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||||
@@ -44,19 +37,11 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editingDay, setEditingDay] = useState<number | null>(null);
|
|
||||||
const [editValues, setEditValues] = useState<EditValues>({
|
|
||||||
mati: 0,
|
|
||||||
panen: 0,
|
|
||||||
beratPanen: '',
|
|
||||||
});
|
|
||||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
|
||||||
const [searchDay, setSearchDay] = useState<string>('');
|
const [searchDay, setSearchDay] = useState<string>('');
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
const isReadOnly = !selectedCycle || selectedCycle.status !== 'active';
|
const isClosedCycle = !selectedCycle || selectedCycle.status !== 'active';
|
||||||
|
|
||||||
const loadData = async (cycleId: number) => {
|
const loadData = async (cycleId: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -85,7 +70,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
setCountings([]);
|
setCountings([]);
|
||||||
setManualRows([]);
|
setManualRows([]);
|
||||||
setInsights([]);
|
setInsights([]);
|
||||||
setEditingDay(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void loadData(selectedCycle.id);
|
void loadData(selectedCycle.id);
|
||||||
@@ -222,104 +206,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (record: Row) => {
|
|
||||||
setEditingDay(record.day);
|
|
||||||
setEditValues({
|
|
||||||
mati: record.mati,
|
|
||||||
panen: record.panen,
|
|
||||||
beratPanen: record.beratPanen ? record.beratPanen.toString() : '',
|
|
||||||
chickenCount: record.chickenCount,
|
|
||||||
});
|
|
||||||
setValidationErrors({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateEditValues = (record: Row): boolean => {
|
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
const beratPanenValue = editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen);
|
|
||||||
|
|
||||||
if (editValues.mati < 0) {
|
|
||||||
errors.mati = 'Mati tidak boleh negatif';
|
|
||||||
} else if (editValues.mati > record.stockAwal) {
|
|
||||||
errors.mati = `Mati tidak boleh lebih dari stock awal (${record.stockAwal})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editValues.panen < 0) {
|
|
||||||
errors.panen = 'Panen tidak boleh negatif';
|
|
||||||
} else if (editValues.panen > record.stockAwal - editValues.mati) {
|
|
||||||
errors.panen = `Panen tidak boleh lebih dari sisa (${record.stockAwal - editValues.mati})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editValues.chickenCount !== undefined && editValues.chickenCount < 0) {
|
|
||||||
errors.chickenCount = 'Chicken counting tidak boleh negatif';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number.isNaN(beratPanenValue) || beratPanenValue < 0) {
|
|
||||||
errors.beratPanen = 'Berat panen tidak boleh negatif';
|
|
||||||
}
|
|
||||||
|
|
||||||
setValidationErrors(errors);
|
|
||||||
return Object.keys(errors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (editingDay === null || !selectedCycle) return;
|
|
||||||
|
|
||||||
const record = rows.find((r) => r.day === editingDay);
|
|
||||||
if (!record) return;
|
|
||||||
|
|
||||||
if (!validateEditValues(record)) return;
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const beratPanenValue = editValues.beratPanen === '' ? 0 : parseFloat(editValues.beratPanen);
|
|
||||||
const manualChanged =
|
|
||||||
editValues.mati !== record.mati ||
|
|
||||||
editValues.panen !== record.panen ||
|
|
||||||
beratPanenValue !== record.beratPanen;
|
|
||||||
|
|
||||||
if (manualChanged) {
|
|
||||||
await api.manualInputs.create({
|
|
||||||
cycle: selectedCycle.id,
|
|
||||||
date: record.date,
|
|
||||||
age_manual: record.day,
|
|
||||||
mortality_manual: editValues.mati,
|
|
||||||
harvest_manual: editValues.panen,
|
|
||||||
// Input pengguna dalam kg, disimpan ke database dalam gram.
|
|
||||||
harvest_weight_manual: beratPanenValue * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
editValues.chickenCount !== undefined &&
|
|
||||||
editValues.chickenCount !== record.chickenCount
|
|
||||||
) {
|
|
||||||
const existing = countings.find((c) => c.date === record.date);
|
|
||||||
if (existing) {
|
|
||||||
await api.countings.update(existing.id, { total_count: editValues.chickenCount });
|
|
||||||
} else {
|
|
||||||
await api.countings.create({
|
|
||||||
cycle: selectedCycle.id,
|
|
||||||
date: record.date,
|
|
||||||
total_count: editValues.chickenCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingDay(null);
|
|
||||||
setValidationErrors({});
|
|
||||||
await loadData(selectedCycle.id);
|
|
||||||
} catch (err) {
|
|
||||||
setError(errorMessage(err));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setEditingDay(null);
|
|
||||||
setValidationErrors({});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -328,7 +214,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Hitung Ayam"
|
title="Hitung Ayam"
|
||||||
icon={<i className="fa-solid fa-calculator" />}
|
icon={<i className="fa-solid fa-calculator" />}
|
||||||
subtitle={`Kelola dan edit data deplesi harian untuk siklus produksi aktif · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
|
subtitle={`Lihat data deplesi harian untuk siklus produksi · ${selectedSite?.site_name ?? ''} · ${selectedKandang?.kandang_name ?? ''} · Siklus ${formatDateRange(selectedCycle.start_date, selectedCycle.end_date)}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI Insight */}
|
{/* AI Insight */}
|
||||||
@@ -488,8 +374,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Read-only banner for closed cycles */}
|
{isClosedCycle && (
|
||||||
{isReadOnly && (
|
|
||||||
<div className="mb-6 bg-amber-50 border-l-4 border-amber-400 rounded-lg p-4">
|
<div className="mb-6 bg-amber-50 border-l-4 border-amber-400 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<i className="fa-solid fa-eye text-amber-500 text-xl mr-4"></i>
|
<i className="fa-solid fa-eye text-amber-500 text-xl mr-4"></i>
|
||||||
@@ -497,9 +382,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
<p className="text-sm text-amber-800 font-semibold">
|
<p className="text-sm text-amber-800 font-semibold">
|
||||||
Mode Lihat Saja (Siklus Selesai)
|
Mode Lihat Saja (Siklus Selesai)
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-amber-700 mt-1">
|
|
||||||
Untuk mengedit data, pilih siklus yang masih aktif pada dropdown di atas.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -515,7 +397,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredRecords.map((record) => {
|
filteredRecords.map((record) => {
|
||||||
const isEditing = !isReadOnly && editingDay === record.day;
|
|
||||||
const cardTone = record.isToday
|
const cardTone = record.isToday
|
||||||
? 'ring-2 ring-blue-200 bg-blue-50/40'
|
? 'ring-2 ring-blue-200 bg-blue-50/40'
|
||||||
: record.panen > 0
|
: record.panen > 0
|
||||||
@@ -527,11 +408,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
key={record.day}
|
key={record.day}
|
||||||
className={`rounded-2xl border border-gray-200 shadow-sm overflow-hidden ${cardTone}`}
|
className={`rounded-2xl border border-gray-200 shadow-sm overflow-hidden ${cardTone}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-start justify-between gap-3 px-4 py-4 bg-gray-50/80">
|
||||||
className={`flex items-start justify-between gap-3 px-4 py-4 ${
|
|
||||||
isEditing ? 'bg-blue-50' : 'bg-gray-50/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
|
||||||
@@ -557,38 +434,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
{record.dateLabel}
|
{record.dateLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isReadOnly && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-green-500 text-white shadow-sm transition-colors hover:bg-green-600 disabled:opacity-60"
|
|
||||||
aria-label="Simpan"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-check"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl bg-gray-500 text-white shadow-sm transition-colors hover:bg-gray-600"
|
|
||||||
aria-label="Batal"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(record)}
|
|
||||||
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-edit"></i>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@@ -606,92 +451,27 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-red-500">
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-red-500">
|
||||||
Mati
|
Mati
|
||||||
</div>
|
</div>
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editValues.mati}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditValues({
|
|
||||||
...editValues,
|
|
||||||
mati: parseInt(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-1 w-full rounded-lg border border-red-200 bg-white px-2 py-2 text-right text-[11px] focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-200 md:px-3 md:text-sm"
|
|
||||||
/>
|
|
||||||
{validationErrors.mati && (
|
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
|
||||||
{validationErrors.mati}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-[11px] font-semibold text-red-600 md:text-sm">
|
<div className="text-[11px] font-semibold text-red-600 md:text-sm">
|
||||||
{formatNumber(record.mati, 0)}
|
{formatNumber(record.mati, 0)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl bg-purple-50 p-3">
|
<div className="rounded-xl bg-purple-50 p-3">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
|
||||||
Panen
|
Panen
|
||||||
</div>
|
</div>
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editValues.panen}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditValues({
|
|
||||||
...editValues,
|
|
||||||
panen: parseInt(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-1 w-full rounded-lg border border-purple-200 bg-white px-2 py-2 text-right text-[11px] focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 md:px-3 md:text-sm"
|
|
||||||
/>
|
|
||||||
{validationErrors.panen && (
|
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
|
||||||
{validationErrors.panen}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||||
{formatNumber(record.panen, 0)}
|
{formatNumber(record.panen, 0)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl bg-purple-50 p-3">
|
<div className="rounded-xl bg-purple-50 p-3">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-purple-500">
|
||||||
Berat panen (kg)
|
Berat panen (kg)
|
||||||
</div>
|
</div>
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editValues.beratPanen}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditValues({
|
|
||||||
...editValues,
|
|
||||||
beratPanen: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-1 w-full rounded-lg border border-purple-200 bg-white px-2 py-2 text-right text-[11px] focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 md:px-3 md:text-sm"
|
|
||||||
/>
|
|
||||||
{validationErrors.beratPanen && (
|
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
|
||||||
{validationErrors.beratPanen}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
<div className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||||
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
|
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'} kg
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl bg-slate-50 p-3">
|
<div className="rounded-xl bg-slate-50 p-3">
|
||||||
@@ -699,12 +479,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
Stock akhir
|
Stock akhir
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] font-semibold text-slate-900 md:text-sm">
|
<div className="text-[11px] font-semibold text-slate-900 md:text-sm">
|
||||||
{isEditing
|
{formatNumber(record.stockAkhir, 0)}
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
record.stockAwal - editValues.mati - editValues.panen
|
|
||||||
).toLocaleString('id-ID')
|
|
||||||
: formatNumber(record.stockAkhir, 0)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -712,35 +487,11 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-blue-500">
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-blue-500">
|
||||||
Chicken Counting
|
Chicken Counting
|
||||||
</div>
|
</div>
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editValues.chickenCount || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditValues({
|
|
||||||
...editValues,
|
|
||||||
chickenCount: e.target.value
|
|
||||||
? parseInt(e.target.value)
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="0"
|
|
||||||
className="mt-1 w-full rounded-lg border border-blue-200 bg-white px-2 py-2 text-right text-[11px] focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:px-3 md:text-sm"
|
|
||||||
/>
|
|
||||||
{validationErrors.chickenCount && (
|
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
|
||||||
{validationErrors.chickenCount}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-[11px] font-semibold text-blue-600 md:text-sm">
|
<div className="text-[11px] font-semibold text-blue-600 md:text-sm">
|
||||||
{record.chickenCount !== undefined
|
{record.chickenCount !== undefined
|
||||||
? formatNumber(record.chickenCount, 0)
|
? formatNumber(record.chickenCount, 0)
|
||||||
: '-'}
|
: '-'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl bg-emerald-50 p-3">
|
<div className="rounded-xl bg-emerald-50 p-3">
|
||||||
@@ -801,18 +552,10 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
rowSpan={2}
|
rowSpan={2}
|
||||||
className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700 border-r border-gray-300"
|
className="w-20 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
|
||||||
>
|
>
|
||||||
Persentase
|
Persentase
|
||||||
</th>
|
</th>
|
||||||
{!isReadOnly && (
|
|
||||||
<th
|
|
||||||
rowSpan={2}
|
|
||||||
className="w-24 px-2 py-2 text-center text-[11px] font-semibold uppercase text-gray-700"
|
|
||||||
>
|
|
||||||
Aksi
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
{/* Second header row with individual column names */}
|
{/* Second header row with individual column names */}
|
||||||
<tr className="border-b border-gray-200">
|
<tr className="border-b border-gray-200">
|
||||||
@@ -834,7 +577,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
{filteredRecords.length === 0 ? (
|
{filteredRecords.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={isReadOnly ? 9 : 10}
|
colSpan={9}
|
||||||
className="px-6 py-12 text-center text-gray-500"
|
className="px-6 py-12 text-center text-gray-500"
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
|
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
|
||||||
@@ -843,7 +586,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredRecords.map((record, index) => {
|
filteredRecords.map((record, index) => {
|
||||||
const isEditing = !isReadOnly && editingDay === record.day;
|
|
||||||
const rowClass = `hover:bg-gray-100 transition-colors ${
|
const rowClass = `hover:bg-gray-100 transition-colors ${
|
||||||
index % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
index % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||||
} ${record.panen > 0 ? 'border-l-4 border-purple-400' : ''}`;
|
} ${record.panen > 0 ? 'border-l-4 border-purple-400' : ''}`;
|
||||||
@@ -862,175 +604,50 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
{record.dateLabel}
|
{record.dateLabel}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Stock awal (calculated, read-only) */}
|
{/* Stock awal */}
|
||||||
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-500 md:text-sm">
|
<td className="px-2 py-2 whitespace-nowrap border-r border-gray-200 text-center text-[11px] text-gray-500 md:text-sm">
|
||||||
{formatNumber(record.stockAwal, 0)}
|
{formatNumber(record.stockAwal, 0)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Mati (editable) */}
|
{/* Mati */}
|
||||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editValues.mati}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditValues({
|
|
||||||
...editValues,
|
|
||||||
mati: parseInt(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-20 px-2 py-1 border border-blue-500 rounded text-right text-[11px] focus:ring-2 focus:ring-blue-500 md:w-24 md:text-sm"
|
|
||||||
/>
|
|
||||||
{validationErrors.mati && (
|
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
|
||||||
{validationErrors.mati}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] font-semibold text-red-600 md:text-sm">
|
<span className="text-[11px] font-semibold text-red-600 md:text-sm">
|
||||||
{formatNumber(record.mati, 0)}
|
{formatNumber(record.mati, 0)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* JUMLAH PANEN (editable) */}
|
{/* JUMLAH PANEN */}
|
||||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editValues.panen}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditValues({
|
|
||||||
...editValues,
|
|
||||||
panen: parseInt(e.target.value) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-20 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-24 md:text-sm"
|
|
||||||
/>
|
|
||||||
{validationErrors.panen && (
|
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
|
||||||
{validationErrors.panen}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||||
{formatNumber(record.panen, 0)}
|
{formatNumber(record.panen, 0)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* BERAT PANEN (editable) */}
|
{/* BERAT PANEN */}
|
||||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editValues.beratPanen}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditValues({
|
|
||||||
...editValues,
|
|
||||||
beratPanen: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-24 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-28 md:text-sm"
|
|
||||||
/>
|
|
||||||
{validationErrors.beratPanen && (
|
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
|
||||||
{validationErrors.beratPanen}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
<span className="text-[11px] font-semibold text-purple-600 md:text-sm">
|
||||||
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
|
{record.beratPanen ? formatNumber(record.beratPanen, 2) : '0.00'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Stock akhir (calculated, read-only) */}
|
{/* Stock akhir */}
|
||||||
<td className="px-2 py-2 whitespace-nowrap text-center text-[11px] font-bold text-gray-800 border-r border-gray-200 md:text-sm">
|
<td className="px-2 py-2 whitespace-nowrap text-center text-[11px] font-bold text-gray-800 border-r border-gray-200 md:text-sm">
|
||||||
{isEditing
|
{formatNumber(record.stockAkhir, 0)}
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
record.stockAwal - editValues.mati - editValues.panen
|
|
||||||
).toLocaleString('id-ID')
|
|
||||||
: formatNumber(record.stockAkhir, 0)}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Chicken Counting (editable) */}
|
{/* Chicken Counting */}
|
||||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editValues.chickenCount || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditValues({
|
|
||||||
...editValues,
|
|
||||||
chickenCount: e.target.value
|
|
||||||
? parseInt(e.target.value)
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="0"
|
|
||||||
className="w-24 px-2 py-1 border border-blue-500 rounded text-center text-[11px] focus:ring-2 focus:ring-blue-500 md:w-28 md:text-sm"
|
|
||||||
/>
|
|
||||||
{validationErrors.chickenCount && (
|
|
||||||
<div className="mt-1 text-[11px] text-red-600">
|
|
||||||
{validationErrors.chickenCount}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] font-semibold text-blue-600 md:text-sm">
|
<span className="text-[11px] font-semibold text-blue-600 md:text-sm">
|
||||||
{record.chickenCount !== undefined
|
{record.chickenCount !== undefined
|
||||||
? formatNumber(record.chickenCount, 0)
|
? formatNumber(record.chickenCount, 0)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Persentase (calculated, badge) */}
|
{/* Persentase */}
|
||||||
<td className="px-2 py-2 whitespace-nowrap text-center border-r border-gray-200">
|
<td className="px-2 py-2 whitespace-nowrap text-center">
|
||||||
{getPersentaseBadge(record.persentase)}
|
{getPersentaseBadge(record.persentase)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Aksi */}
|
|
||||||
{!isReadOnly && (
|
|
||||||
<td className="px-2 py-2 whitespace-nowrap text-center">
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="flex justify-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-2 py-1 bg-green-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-green-600 disabled:opacity-60 md:px-3 md:text-sm"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-check"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="px-2 py-1 bg-gray-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-gray-600 md:px-3 md:text-sm"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(record)}
|
|
||||||
className="px-2 py-1 bg-blue-500 text-[11px] font-medium text-white rounded-lg transition-colors hover:bg-blue-600 md:px-3 md:text-sm"
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-edit mr-1"></i>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -1079,10 +696,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
|||||||
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
<i className="fa-solid fa-circle text-xs mr-2"></i>
|
||||||
<strong>Chicken Counting (IOT)</strong> = hasil hitung otomatis dari IOT
|
<strong>Chicken Counting (IOT)</strong> = hasil hitung otomatis dari IOT
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<i className="fa-solid fa-circle text-xs mr-2"></i>Data yang diedit akan
|
|
||||||
tersimpan sebagai input manual dan ditandai dengan highlight kuning
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<i className="fa-solid fa-circle text-xs mr-2"></i>Baris dengan border ungu
|
<i className="fa-solid fa-circle text-xs mr-2"></i>Baris dengan border ungu
|
||||||
menandakan ada panen pada hari tersebut
|
menandakan ada panen pada hari tersebut
|
||||||
|
|||||||
@@ -68,5 +68,6 @@ describe('DataManagementPage', () => {
|
|||||||
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: 'Hitung Ayam' })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('heading', { name: /Input manual/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('heading', { name: /Input manual/i })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /Simpan hitungan/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /Simpan hitungan/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /Edit/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+26
-1
@@ -1,11 +1,12 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { api, errorMessage } from '../services/apiClient.ts';
|
import { api, errorMessage } from '../services/apiClient.ts';
|
||||||
import type { Cycle, Kandang, Site } from '../types/api.ts';
|
import type { Cycle, Flock, Kandang, Site } from '../types/api.ts';
|
||||||
import { useAuth } from './AuthContext.tsx';
|
import { useAuth } from './AuthContext.tsx';
|
||||||
|
|
||||||
type FarmContextValue = {
|
type FarmContextValue = {
|
||||||
sites: Site[];
|
sites: Site[];
|
||||||
kandangs: Kandang[];
|
kandangs: Kandang[];
|
||||||
|
flocks: Flock[];
|
||||||
cycles: Cycle[];
|
cycles: Cycle[];
|
||||||
selectedSiteId: number | null;
|
selectedSiteId: number | null;
|
||||||
selectedKandangId: number | null;
|
selectedKandangId: number | null;
|
||||||
@@ -27,6 +28,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [sites, setSites] = useState<Site[]>([]);
|
const [sites, setSites] = useState<Site[]>([]);
|
||||||
const [kandangs, setKandangs] = useState<Kandang[]>([]);
|
const [kandangs, setKandangs] = useState<Kandang[]>([]);
|
||||||
|
const [flocks, setFlocks] = useState<Flock[]>([]);
|
||||||
const [cycles, setCycles] = useState<Cycle[]>([]);
|
const [cycles, setCycles] = useState<Cycle[]>([]);
|
||||||
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
|
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
|
||||||
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
|
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
|
||||||
@@ -43,6 +45,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
setSites([]);
|
setSites([]);
|
||||||
setKandangs([]);
|
setKandangs([]);
|
||||||
|
setFlocks([]);
|
||||||
setCycles([]);
|
setCycles([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,26 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
};
|
};
|
||||||
}, [user, selectedSiteId, reloadToken]);
|
}, [user, selectedSiteId, reloadToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || selectedKandangId == null) {
|
||||||
|
setFlocks([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const next = await api.flocks.list({ kandang_id: selectedKandangId });
|
||||||
|
if (cancelled) return;
|
||||||
|
setFlocks(next);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(errorMessage(err));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user, selectedKandangId, reloadToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || selectedKandangId == null) {
|
if (!user || selectedKandangId == null) {
|
||||||
setCycles([]);
|
setCycles([]);
|
||||||
@@ -138,6 +161,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
() => ({
|
() => ({
|
||||||
sites,
|
sites,
|
||||||
kandangs,
|
kandangs,
|
||||||
|
flocks,
|
||||||
cycles,
|
cycles,
|
||||||
selectedSiteId,
|
selectedSiteId,
|
||||||
selectedKandangId,
|
selectedKandangId,
|
||||||
@@ -155,6 +179,7 @@ export const FarmProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
[
|
[
|
||||||
sites,
|
sites,
|
||||||
kandangs,
|
kandangs,
|
||||||
|
flocks,
|
||||||
cycles,
|
cycles,
|
||||||
selectedSiteId,
|
selectedSiteId,
|
||||||
selectedKandangId,
|
selectedKandangId,
|
||||||
|
|||||||
+30
-25
@@ -1,22 +1,24 @@
|
|||||||
|
name: dashboard-cpsp
|
||||||
|
|
||||||
services:
|
services:
|
||||||
database:
|
database:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: newdashboard-database
|
container_name: dashboard-cpsp-database
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${DB_USER:-newdashboard}
|
POSTGRES_USER: ${DB_USER:-dashboard}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-change_this_password}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
|
||||||
POSTGRES_DB: ${DB_NAME:-newdashboard}
|
POSTGRES_DB: ${DB_NAME:-dashboard}
|
||||||
PGDATA: /var/lib/postgresql/data/pgdata
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- newdashboard-network
|
- dashboard-cpsp-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
"CMD-SHELL",
|
"CMD-SHELL",
|
||||||
"pg_isready -U ${DB_USER:-newdashboard} -d ${DB_NAME:-newdashboard}",
|
"pg_isready -U ${DB_USER:-dashboard} -d ${DB_NAME:-dashboard}",
|
||||||
]
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
@@ -28,28 +30,27 @@ services:
|
|||||||
- "127.0.0.1:15432:5432"
|
- "127.0.0.1:15432:5432"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
image: dashboard-cpsp-api
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile.api
|
dockerfile: docker/Dockerfile.api
|
||||||
container_name: newdashboard-api
|
container_name: dashboard-cpsp-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:18000:8000"
|
- "127.0.0.1:18000:8000"
|
||||||
environment:
|
environment:
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-test-secret-key}
|
||||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend}
|
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}
|
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_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5002,http://127.0.0.1:5002}
|
||||||
CORS_ALLOW_CREDENTIALS: "true"
|
CORS_ALLOW_CREDENTIALS: "true"
|
||||||
DB_ENGINE: django.db.backends.postgresql
|
DB_ENGINE: django.db.backends.postgresql
|
||||||
DB_NAME: ${DB_NAME:-newdashboard}
|
DB_NAME: ${DB_NAME:-dashboard}
|
||||||
DB_USER: ${DB_USER:-newdashboard}
|
DB_USER: ${DB_USER:-dashboard}
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-change_this_password}
|
DB_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
|
||||||
DB_HOST: database
|
DB_HOST: database
|
||||||
DB_PORT: "5432"
|
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_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_TIMEOUT_SECONDS: ${KARUNG_WEB_ADMIN_TIMEOUT_SECONDS:-30}
|
||||||
KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}
|
KARUNG_WEB_ADMIN_SYNC_ENABLED: ${KARUNG_WEB_ADMIN_SYNC_ENABLED:-false}
|
||||||
@@ -60,9 +61,11 @@ services:
|
|||||||
IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}}
|
IOT_FLOCK_ID_MAP: ${IOT_FLOCK_ID_MAP:-{}}
|
||||||
IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20}
|
IOT_SYNC_LOOKBACK_MINUTES: ${IOT_SYNC_LOOKBACK_MINUTES:-20}
|
||||||
IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10}
|
IOT_SYNC_MAX_PAGES: ${IOT_SYNC_MAX_PAGES:-10}
|
||||||
BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-}
|
BOOTSTRAP_API_KEY: ${BOOTSTRAP_API_KEY:-test-api-key}
|
||||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin}
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-Pr04dm1n}
|
||||||
|
BOOTSTRAP_STAFF_USER: ${BOOTSTRAP_STAFF_USER:-skw}
|
||||||
|
BOOTSTRAP_STAFF_PASSWORD: ${BOOTSTRAP_STAFF_PASSWORD:-skw123}
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -71,7 +74,7 @@ services:
|
|||||||
database:
|
database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- newdashboard-network
|
- dashboard-cpsp-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -86,20 +89,21 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
cron:
|
cron:
|
||||||
|
image: dashboard-cpsp-cron
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile.api
|
dockerfile: docker/Dockerfile.api
|
||||||
container_name: newdashboard-cron
|
container_name: dashboard-cpsp-cron
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
entrypoint: ["/app/entrypoint-cron.sh"]
|
entrypoint: ["/app/entrypoint-cron.sh"]
|
||||||
environment:
|
environment:
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-test-secret-key}
|
||||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend}
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,api,frontend}
|
||||||
DB_ENGINE: django.db.backends.postgresql
|
DB_ENGINE: django.db.backends.postgresql
|
||||||
DB_NAME: ${DB_NAME:-newdashboard}
|
DB_NAME: ${DB_NAME:-dashboard}
|
||||||
DB_USER: ${DB_USER:-newdashboard}
|
DB_USER: ${DB_USER:-dashboard}
|
||||||
DB_PASSWORD: ${DB_PASSWORD:-change_this_password}
|
DB_PASSWORD: ${DB_PASSWORD:-Ket0pr4k}
|
||||||
DB_HOST: database
|
DB_HOST: database
|
||||||
DB_PORT: "5432"
|
DB_PORT: "5432"
|
||||||
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
|
KARUNG_WEB_ADMIN_BASE_URL: ${KARUNG_WEB_ADMIN_BASE_URL:-http://host.docker.internal:5000}
|
||||||
@@ -120,13 +124,14 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- newdashboard-network
|
- dashboard-cpsp-network
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: dashboard-cpsp-frontend
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile.web
|
dockerfile: docker/Dockerfile.web
|
||||||
container_name: newdashboard-frontend
|
container_name: dashboard-cpsp-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5002:80"
|
- "5002:80"
|
||||||
@@ -134,7 +139,7 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- newdashboard-network
|
- dashboard-cpsp-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
|
["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"]
|
||||||
@@ -144,7 +149,7 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
newdashboard-network:
|
dashboard-cpsp-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+4
-3
@@ -13,13 +13,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1,api,frontend
|
|||||||
CSRF_TRUSTED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002
|
CSRF_TRUSTED_ORIGINS=http://localhost:5002,http://127.0.0.1:5002
|
||||||
CORS_ALLOWED_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)
|
# Production admin (bootstrap_admin — no demo data)
|
||||||
BOOTSTRAP_ADMIN_USER=admin
|
BOOTSTRAP_ADMIN_USER=admin
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n
|
BOOTSTRAP_ADMIN_PASSWORD=Pr04dm1n
|
||||||
|
|
||||||
|
# Optional production staff (leave BOOTSTRAP_STAFF_USER empty to skip)
|
||||||
|
BOOTSTRAP_STAFF_USER=staff
|
||||||
|
BOOTSTRAP_STAFF_PASSWORD=change_me
|
||||||
|
|
||||||
# Optional: fixed API key for scripts (hashed at rest)
|
# Optional: fixed API key for scripts (hashed at rest)
|
||||||
BOOTSTRAP_API_KEY=
|
BOOTSTRAP_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
set -e
|
set -e
|
||||||
cd /app
|
cd /app
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
python manage.py bootstrap_admin
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
exec gunicorn config.wsgi:application -c config/gunicorn.py
|
exec gunicorn config.wsgi:application -c config/gunicorn.py
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
set -e
|
set -e
|
||||||
cd /app
|
cd /app
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|DASHBOARD_|WAGTAIL_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
|
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|IOT_|DASHBOARD_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
|
||||||
> /etc/environment || true
|
> /etc/environment || true
|
||||||
exec cron -f
|
exec cron -f
|
||||||
@@ -37,24 +37,6 @@ server {
|
|||||||
proxy_send_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/ {
|
location /media/ {
|
||||||
proxy_pass http://api:8000;
|
proxy_pass http://api:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CycleFilter,
|
CycleFilter,
|
||||||
CycleWrite,
|
CycleWrite,
|
||||||
Flock,
|
Flock,
|
||||||
|
FlockWrite,
|
||||||
InitialBalanceResult,
|
InitialBalanceResult,
|
||||||
Karung,
|
Karung,
|
||||||
KarungRequestResult,
|
KarungRequestResult,
|
||||||
@@ -163,8 +164,11 @@ export const api = {
|
|||||||
remove: (id: number) => del(`/kandangs/${id}/`),
|
remove: (id: number) => del(`/kandangs/${id}/`),
|
||||||
},
|
},
|
||||||
flocks: {
|
flocks: {
|
||||||
list: async (query?: { cycle_id?: number }) =>
|
list: async (query?: { kandang_id?: number }) =>
|
||||||
unwrapList(await get<Paginated<Flock> | Flock[]>('/flocks/', query)),
|
unwrapList(await get<Paginated<Flock> | Flock[]>('/flocks/', query)),
|
||||||
|
create: (body: FlockWrite) => post<Flock>('/flocks/', body),
|
||||||
|
update: (id: number, body: Partial<FlockWrite>) => patch<Flock>(`/flocks/${id}/`, body),
|
||||||
|
remove: (id: number) => del(`/flocks/${id}/`),
|
||||||
},
|
},
|
||||||
cycles: {
|
cycles: {
|
||||||
list: async (query?: CycleFilter) =>
|
list: async (query?: CycleFilter) =>
|
||||||
|
|||||||
+5
-4
@@ -34,7 +34,7 @@ export type Kandang = {
|
|||||||
export type Flock = {
|
export type Flock = {
|
||||||
id: number;
|
id: number;
|
||||||
flock_name: string;
|
flock_name: string;
|
||||||
cycle: number;
|
kandang: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
@@ -45,8 +45,6 @@ export type Cycle = {
|
|||||||
id: number;
|
id: number;
|
||||||
kandang: number;
|
kandang: number;
|
||||||
kandang_name: string;
|
kandang_name: string;
|
||||||
flock: number;
|
|
||||||
flock_name: string | null;
|
|
||||||
total_days: number;
|
total_days: number;
|
||||||
current_day: number;
|
current_day: number;
|
||||||
visible_through_date: string;
|
visible_through_date: string;
|
||||||
@@ -221,9 +219,12 @@ export type KandangWrite = {
|
|||||||
kandang_name: string;
|
kandang_name: string;
|
||||||
site: number;
|
site: number;
|
||||||
};
|
};
|
||||||
|
export type FlockWrite = {
|
||||||
|
flock_name: string;
|
||||||
|
kandang: number;
|
||||||
|
};
|
||||||
export type CycleWrite = {
|
export type CycleWrite = {
|
||||||
kandang: number;
|
kandang: number;
|
||||||
flock?: number;
|
|
||||||
total_days: number;
|
total_days: number;
|
||||||
current_day?: number;
|
current_day?: number;
|
||||||
start_date: string;
|
start_date: string;
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import type { ChickenCounting, Cycle, Karung, KPI } from '../../types/api.ts';
|
|||||||
const cycle: Cycle = {
|
const cycle: Cycle = {
|
||||||
id: 1,
|
id: 1,
|
||||||
kandang: 9,
|
kandang: 9,
|
||||||
flock: 1,
|
kandang_name: 'Kandang 1',
|
||||||
total_days: 35,
|
total_days: 35,
|
||||||
current_day: 2,
|
current_day: 2,
|
||||||
|
visible_through_date: '2026-05-02',
|
||||||
start_date: '2026-05-01',
|
start_date: '2026-05-01',
|
||||||
end_date: '2026-06-04',
|
end_date: '2026-06-04',
|
||||||
chick_in_weight: 45,
|
chick_in_weight: 45,
|
||||||
|
|||||||
Reference in new issue
Block a user