from datetime import date from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from apps.accounts.models import User class Site(models.Model): site_id = models.BigAutoField(primary_key=True) site_name = models.CharField(max_length=30) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites") 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 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()