Files
dashboard-cpsp-executive/backend/apps/farms/models.py
T

339 lines
12 KiB
Python

from datetime import date
import secrets
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
from apps.accounts.models import User
def site_name_slug(site_name: str) -> str:
"""Slug portion of a site id (no numeric suffix)."""
code = slugify(str(site_name or "").strip())[:55]
if not code:
raise ValidationError({"site_name": "Nama site tidak valid untuk dijadikan id."})
return code
def site_id_from_name(site_name: str, *, numeric_suffix: int | None = None) -> str:
"""Site PK: `{name-slug}-{4-digit number}`, matching farm dashboard identity."""
base = site_name_slug(site_name)
if numeric_suffix is None:
numeric_suffix = secrets.randbelow(10_000)
return f"{base}-{numeric_suffix:04d}"
def allocate_site_id(site_name: str) -> str:
"""Pick an unused site_id for HQ-only creates (seeds / manual)."""
for _ in range(32):
candidate = site_id_from_name(site_name)
if not Site.objects.filter(pk=candidate).exists():
return candidate
raise ValidationError({"site_name": "Gagal membuat id site unik — coba lagi."})
class Site(models.Model):
# Same shape as farm dashboard Site.site_id; when linked, equals ActiveSite.source_site_id.
site_id = models.SlugField(primary_key=True, max_length=64)
site_name = models.CharField(max_length=30)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites")
# Linked when GM/GM Admin adds the site dashboard to their Pengaturan view list.
active_site = models.OneToOneField(
"sync.ActiveSite",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="hq_site",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "sites"
ordering = ["site_name"]
def __str__(self) -> str:
return self.site_name
def save(self, *args, **kwargs):
if not self.site_id:
self.site_id = allocate_site_id(self.site_name)
super().save(*args, **kwargs)
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
feed_in_button_urls = models.JSONField(default=list, blank=True)
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
source_kandang_id = models.BigIntegerField(
null=True,
blank=True,
help_text="kandang_id on the farm-location dashboard-cpsp deployment",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
constraints = [
models.UniqueConstraint(
fields=["site", "source_kandang_id"],
condition=models.Q(source_kandang_id__isnull=False),
name="uniq_kandang_site_source",
),
]
def __str__(self) -> str:
return self.kandang_name
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
kandang = models.ForeignKey(
Kandang,
on_delete=models.CASCADE,
related_name="flocks",
)
source_flock_id = models.BigIntegerField(
null=True,
blank=True,
help_text="flock_id on the farm-location dashboard-cpsp deployment",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "flock"
ordering = ["flock_name"]
constraints = [
models.UniqueConstraint(
fields=["kandang", "source_flock_id"],
condition=models.Q(source_flock_id__isnull=False),
name="uniq_flock_kandang_source",
),
]
def __str__(self) -> str:
return self.flock_name
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_PENDING_CLOSE = "pending_close"
STATUS_CLOSED = "closed"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_PENDING_CLOSE, "Pending close"),
(STATUS_CLOSED, "Closed"),
]
cycle_id = models.BigAutoField(primary_key=True)
total_days = models.IntegerField(default=1)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
feed_initial_balance_date = models.DateField(null=True, blank=True)
feed_initial_balance_iot = models.IntegerField(null=True, blank=True)
feed_initial_balance_accuracy = models.FloatField(null=True, blank=True)
feed_initial_balance_sync_error = models.TextField(null=True, blank=True)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
active_site = models.ForeignKey(
"sync.ActiveSite",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="mirrored_cycles",
)
source_cycle_id = models.BigIntegerField(
null=True,
blank=True,
help_text="cycle_id on the farm-location dashboard-cpsp deployment",
)
proposed_end_date = models.DateField(null=True, blank=True)
close_requested_at = models.DateTimeField(null=True, blank=True)
close_requested_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="requested_cycle_closes",
)
close_approved_by = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="approved_cycle_closes",
)
close_approved_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "cycles"
ordering = ["-start_date"]
constraints = [
models.CheckConstraint(
check=models.Q(end_date__isnull=True)
| models.Q(end_date__gte=models.F("start_date")),
name="chk_cycles_date_range",
),
models.UniqueConstraint(
fields=["active_site", "source_cycle_id"],
condition=models.Q(
active_site__isnull=False, source_cycle_id__isnull=False
),
name="uniq_cycle_active_source",
),
]
def __str__(self) -> str:
return f"Cycle #{self.pk} ({self.status})"
def clean(self) -> None:
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError({"end_date": "Must be on or after start_date"})
if (
self.proposed_end_date
and self.start_date
and self.proposed_end_date < self.start_date
):
raise ValidationError(
{"proposed_end_date": "Must be on or after start_date"}
)
def soft_end_date(self) -> date:
if self.end_date:
return self.end_date
from apps.operations.services.visibility import visible_through_date
return visible_through_date()
def resolved_status(self) -> str:
return self.status
def sync_status(self) -> bool:
return False
def save(self, *args, **kwargs):
self.sync_status()
if kwargs.get("update_fields") is None:
if self.end_date and self.start_date:
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
elif self.start_date and not self.end_date:
soft = self.soft_end_date()
self.total_days = max((soft - self.start_date).days + 1, 1)
super().save(*args, **kwargs)
def clear_close_request_fields(self) -> None:
self.proposed_end_date = None
self.close_requested_at = None
self.close_requested_by = None
def mark_pending_close(self, proposed_end_date: date, requested_at=None) -> None:
if proposed_end_date < self.start_date:
raise ValidationError(
{"proposed_end_date": "Must be on or after start_date"}
)
self.status = self.STATUS_PENDING_CLOSE
self.proposed_end_date = proposed_end_date
self.close_requested_at = requested_at or timezone.now()
self.close_approved_by = None
self.close_approved_at = None
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_approved_by",
"close_approved_at",
"updated_at",
]
)
def cancel_pending_close(self) -> None:
if self.status == self.STATUS_ACTIVE and self.proposed_end_date is None:
return
if self.status == self.STATUS_CLOSED:
raise ValidationError("Cycle is already closed.")
self.status = self.STATUS_ACTIVE
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def approve_close(self, approver: User) -> None:
if self.status != self.STATUS_PENDING_CLOSE:
raise ValidationError("Cycle is not pending close approval.")
if not self.proposed_end_date:
raise ValidationError("proposed_end_date is missing.")
self.status = self.STATUS_CLOSED
self.end_date = self.proposed_end_date
self.total_days = max((self.end_date - self.start_date).days + 1, 1)
self.close_approved_by = approver
self.close_approved_at = timezone.now()
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"end_date",
"total_days",
"close_approved_by",
"close_approved_at",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def reject_close(self, _actor: User) -> None:
if self.status != self.STATUS_PENDING_CLOSE:
raise ValidationError("Cycle is not pending close approval.")
self.status = self.STATUS_ACTIVE
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
@property
def current_day(self) -> int:
from apps.operations.services.visibility import visible_through_date
effective = visible_through_date()
if effective < self.start_date:
return 0
elapsed = (effective - self.start_date).days
if self.end_date:
max_day = max(self.total_days - 1, 0)
return min(max(elapsed, 0), max_day)
return max(elapsed, 0)
@property
def visible_through_date(self) -> date:
from apps.operations.services.visibility import visible_through_date as cutoff_date
return cutoff_date()