300 lines
11 KiB
Python
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()
|