from __future__ import annotations import uuid from django.conf import settings from django.db import models from django.utils import timezone class Business(models.Model): name = models.CharField(max_length=160) slug = models.SlugField(unique=True) industry = models.CharField(max_length=120, default='Home Services') primary_city = models.CharField(max_length=120) primary_state = models.CharField(max_length=2) google_review_url = models.URLField(blank=True) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['name'] verbose_name_plural = 'businesses' def __str__(self) -> str: return self.name @property def initials(self) -> str: words = [chunk for chunk in self.name.split() if chunk] if not words: return 'TF' return ''.join(word[0] for word in words[:2]).upper() class BusinessMembership(models.Model): class Role(models.TextChoices): OWNER = 'owner', 'Owner' ADMIN = 'admin', 'Admin' MANAGER = 'manager', 'Manager' TECHNICIAN = 'technician', 'Technician' business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='memberships') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='business_memberships') role = models.CharField(max_length=24, choices=Role.choices, default=Role.OWNER) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['business__name', 'user__email'] unique_together = ('business', 'user') def __str__(self) -> str: identity = self.user.get_full_name() or self.user.email or self.user.username return f'{identity} · {self.business.name} ({self.get_role_display()})' @property def can_manage_workspace(self) -> bool: return self.role in {self.Role.OWNER, self.Role.ADMIN} @property def can_manage_proof(self) -> bool: return self.role in {self.Role.OWNER, self.Role.ADMIN, self.Role.MANAGER} class Customer(models.Model): business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='customers') full_name = models.CharField(max_length=160) email = models.EmailField(blank=True) phone = models.CharField(max_length=40, blank=True) city = models.CharField(max_length=120) state = models.CharField(max_length=2) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['full_name'] def __str__(self) -> str: return f'{self.full_name} · {self.city}, {self.state}' class Job(models.Model): class Status(models.TextChoices): COMPLETED = 'completed', 'Completed' REVIEW_REQUESTED = 'review_requested', 'Review requested' PROOF_READY = 'proof_ready', 'Proof ready' business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='jobs') customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='jobs') service_type = models.CharField(max_length=120) description = models.TextField(blank=True) technician_name = models.CharField(max_length=120, blank=True) city = models.CharField(max_length=120) state = models.CharField(max_length=2) completed_at = models.DateField(default=timezone.localdate) project_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) status = models.CharField(max_length=32, choices=Status.choices, default=Status.COMPLETED) is_verified = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-completed_at', '-created_at'] def __str__(self) -> str: return f'{self.service_type} for {self.customer.full_name}' def _get_media_by_type(self, media_type: str): prefetched_media = getattr(self, '_prefetched_objects_cache', {}).get('media') if prefetched_media is not None: return next((media for media in prefetched_media if media.media_type == media_type), None) return self.media.filter(media_type=media_type).first() @property def before_media(self): return self._get_media_by_type(JobMedia.MediaType.BEFORE) @property def after_media(self): return self._get_media_by_type(JobMedia.MediaType.AFTER) def job_media_upload_path(instance: 'JobMedia', filename: str) -> str: return f'jobs/job_{instance.job_id}/{instance.media_type}_{filename}' class JobMedia(models.Model): class MediaType(models.TextChoices): BEFORE = 'before', 'Before' AFTER = 'after', 'After' job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='media') media_type = models.CharField(max_length=16, choices=MediaType.choices) file = models.FileField(upload_to=job_media_upload_path, blank=True) caption = models.CharField(max_length=140, blank=True) display_order = models.PositiveSmallIntegerField(default=0) class Meta: ordering = ['display_order', 'id'] verbose_name_plural = 'job media' def __str__(self) -> str: return f'{self.job} · {self.get_media_type_display()}' class ReviewRequest(models.Model): class Status(models.TextChoices): SENT = 'sent', 'Sent' VIEWED = 'viewed', 'Viewed' RESPONDED = 'responded', 'Responded' class Channel(models.TextChoices): EMAIL = 'email', 'Email' SMS = 'sms', 'SMS' MANUAL = 'manual', 'Manual share' job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='review_request') token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) status = models.CharField(max_length=20, choices=Status.choices, default=Status.SENT) channel = models.CharField(max_length=16, choices=Channel.choices, default=Channel.EMAIL) sent_at = models.DateTimeField(default=timezone.now) last_opened_at = models.DateTimeField(null=True, blank=True) reviewed_at = models.DateTimeField(null=True, blank=True) delivery_note = models.CharField(max_length=200, blank=True) class Meta: ordering = ['-sent_at'] def __str__(self) -> str: return f'Review request for {self.job}' class Feedback(models.Model): class Experience(models.TextChoices): GREAT = 'great', 'Great' GOOD = 'good', 'Good' OKAY = 'okay', 'Okay' BAD = 'bad', 'Bad' review_request = models.OneToOneField(ReviewRequest, on_delete=models.CASCADE, related_name='feedback') experience = models.CharField(max_length=12, choices=Experience.choices) rating = models.PositiveSmallIntegerField(null=True, blank=True) testimonial = models.TextField(blank=True) follow_up_required = models.BooleanField(default=False) is_public_approved = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] def __str__(self) -> str: return f'{self.get_experience_display()} feedback for {self.review_request.job}' class ProofCard(models.Model): class Status(models.TextChoices): DRAFT = 'draft', 'Draft' PUBLISHED = 'published', 'Published' HIDDEN = 'hidden', 'Hidden' job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='proof_card') customer_display_name = models.CharField(max_length=160) is_anonymized = models.BooleanField(default=False) testimonial_quote = models.TextField(blank=True) rating = models.PositiveSmallIntegerField(null=True, blank=True) status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) is_featured = models.BooleanField(default=False) attached_widget_label = models.CharField(max_length=120, blank=True) attached_pages = models.CharField(max_length=200, blank=True) published_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-is_featured', '-updated_at'] def __str__(self) -> str: return f'Proof card · {self.job.service_type} · {self.customer_display_name}' @property def verified_label(self) -> str: return 'Verified job' if self.job.is_verified else 'Pending verification'