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") # 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 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") 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()