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

300 lines
11 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}`, e.g. sukawarna-4821."""
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 this name."""
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):
site_id = models.SlugField(primary_key=True, max_length=64)
site_name = models.CharField(max_length=30, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites")
pusat_code = models.SlugField(max_length=64, blank=True, default="")
pusat_active_site_id = models.BigIntegerField(null=True, blank=True)
pusat_registered_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 = "sites"
ordering = ["site_name"]
def __str__(self) -> str:
return self.site_name
@property
def is_registered_to_pusat(self) -> bool:
return bool(self.pusat_active_site_id and self.pusat_code)
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")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
def __str__(self) -> str:
return self.kandang_name
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
kandang = models.ForeignKey(
Kandang,
on_delete=models.CASCADE,
related_name="flocks",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "flock"
ordering = ["flock_name"]
def __str__(self) -> str:
return self.flock_name
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_PENDING_CLOSE = "pending_close"
STATUS_CLOSED = "closed"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_PENDING_CLOSE, "Pending close"),
(STATUS_CLOSED, "Closed"),
]
cycle_id = models.BigAutoField(primary_key=True)
total_days = models.IntegerField(default=1)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
feed_initial_balance_date = models.DateField(null=True, blank=True)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
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",
)
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",
)
]
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:
"""Dashboard-facing upper bound: approved end_date, else publish visible-through."""
if self.end_date:
return self.end_date
from apps.operations.services.visibility import visible_through_date
return visible_through_date()
def operational_end_date(self, *, today: date | None = None) -> date:
"""Inclusive upper date for edge/IoT history sync — not publish-gated.
Open cycles sync through calendar today so morning edge data is not
skipped until DASHBOARD_PUBLISH_HOUR. Closed cycles stop at end_date.
"""
if today is None:
today = timezone.localdate()
if self.end_date:
return min(today, self.end_date)
return today
def resolved_status(self) -> str:
"""Status is approval-driven — never auto-closed by publish cutoff."""
return self.status
def sync_status(self) -> bool:
return False
def save(self, *args, **kwargs):
self.sync_status()
# Only refresh provisional total_days on full saves (not partial close-field updates).
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 request_close(self, requester: User, proposed_end_date: date) -> None:
if requester.status != User.STATUS_ACTIVE:
raise ValidationError("Only active users may request cycle close.")
if self.status != self.STATUS_ACTIVE:
raise ValidationError("Only an active cycle can request close.")
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 = timezone.now()
self.close_requested_by = requester
self.save(
update_fields=[
"status",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
def cancel_close_request(self, actor: User) -> None:
if actor.status != User.STATUS_ACTIVE:
raise ValidationError("Only active users may cancel a close request.")
if self.status == self.STATUS_ACTIVE and self.proposed_end_date is None:
return
if self.status != self.STATUS_PENDING_CLOSE:
raise ValidationError("Cycle has no pending close request.")
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 apply_close_decision(self, *, approved: bool, end_date: date | None = None) -> None:
if approved:
if end_date is None:
raise ValidationError("end_date is required when approving close.")
if end_date < self.start_date:
raise ValidationError({"end_date": "Must be on or after start_date"})
self.status = self.STATUS_CLOSED
self.end_date = end_date
self.total_days = max((end_date - self.start_date).days + 1, 1)
self.clear_close_request_fields()
self.save(
update_fields=[
"status",
"end_date",
"total_days",
"proposed_end_date",
"close_requested_at",
"close_requested_by",
"updated_at",
]
)
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",
]
)
@property
def current_day(self) -> int:
"""0-based cycle age: start_date is day 0."""
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()