nexus-5/core/models/project.py
2026-01-26 11:09:40 -05:00

123 lines
5.5 KiB
Python

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}"