123 lines
5.5 KiB
Python
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}"
|