from django.db import models from django_choices_field import TextChoicesField from core.models.base import BaseModel from core.models.customer import Customer from core.models.account import AccountAddress from core.models.enums import ServiceChoices from core.models.profile import TeamProfile class Project(BaseModel): """Project records for customers""" customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name='projects') # Optional: tie to a specific account address; if not provided, a freeform address is required account_address = models.ForeignKey( AccountAddress, on_delete=models.PROTECT, related_name='projects', blank=True, null=True, help_text="If set, the project uses this account address; otherwise, fill the address fields below", ) # Optional: if account_address is set, this is the scope of the project scope = models.ForeignKey( 'core.ProjectScope', on_delete=models.SET_NULL, related_name='projects', blank=True, null=True, ) # Freeform address used only when account_address is not provided street_address = models.CharField(max_length=255, blank=True, null=True) city = models.CharField(max_length=100, blank=True, null=True) state = models.CharField(max_length=100, blank=True, null=True) zip_code = models.CharField(max_length=20, blank=True, null=True) name = models.CharField(max_length=200, blank=True) date = models.DateField() status = TextChoicesField( choices_enum=ServiceChoices, default=ServiceChoices.SCHEDULED, help_text="Current status of the project", ) team_members = models.ManyToManyField(TeamProfile, related_name='projects') notes = models.TextField(blank=True, null=True) labor = models.DecimalField(max_digits=10, decimal_places=2) amount = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) calendar_event_id = models.CharField(max_length=255, blank=True, null=True, help_text="External calendar event ID") wave_service_id = models.CharField(max_length=255, blank=True, null=True, help_text="Wave service ID") class Meta: ordering = ['-date'] indexes = [ models.Index(fields=['customer', 'date']), models.Index(fields=['status', 'date']), models.Index(fields=['account_address', 'date']), ] constraints = [ # Enforce mutual exclusivity/requirement between account_address and freeform address models.CheckConstraint( name='project_addr_xor_check', condition=( # Case A: account_address is set, AND all freeform fields are NULL ( models.Q(account_address__isnull=False) & models.Q(street_address__isnull=True) & models.Q(city__isnull=True) & models.Q(state__isnull=True) & models.Q(zip_code__isnull=True) ) | # Case B: account_address is NULL, AND all freeform fields are non-NULL ( models.Q(account_address__isnull=True) & models.Q(street_address__isnull=False) & models.Q(city__isnull=False) & models.Q(state__isnull=False) & models.Q(zip_code__isnull=False) ) ), ), ] verbose_name = "Project" verbose_name_plural = "Projects" # python def clean(self): """Validate project data""" from django.core.exceptions import ValidationError # Normalize blanks to None so DB constraint and logic align def _blank_to_none(v): return None if isinstance(v, str) and not v.strip() else v self.street_address = _blank_to_none(self.street_address) self.city = _blank_to_none(self.city) self.state = _blank_to_none(self.state) self.zip_code = _blank_to_none(self.zip_code) has_account_address = self.account_address is not None has_freeform = all([self.street_address, self.city, self.state, self.zip_code]) # Enforce XOR between account_address and freeform address if has_account_address and has_freeform: raise ValidationError("Provide either an account address or a freeform address, not both.") if not has_account_address and not has_freeform: raise ValidationError("Provide a freeform address when no account address is selected.") # If an account_address is provided, ensure it belongs to the same customer if self.account_address and self.account_address.account.customer_id != self.customer_id: raise ValidationError("Selected account address must belong to the specified customer.") def __str__(self): if self.account_address: addr_info = f" ({self.account_address.account.name} - {self.account_address.street_address})" else: parts = [p for p in [self.street_address, self.city, self.state, self.zip_code] if p] addr_info = f" ({', '.join(parts)})" if parts else "" return f"Project for {self.customer.name}{addr_info} on {self.date}"