from django.conf import settings from django.db import models from django.urls import reverse from .uploads import project_attachment_storage, project_attachment_upload_to class ProjectBrief(models.Model): STATUS_SUBMITTED = "submitted" STATUS_UNDER_REVIEW = "under_review" STATUS_QUOTED = "quoted" STATUS_ACCEPTED = "accepted" STATUS_DECLINED = "declined" STATUS_IN_PROGRESS = "in_progress" STATUS_DELIVERED = "delivered" STATUS_CLOSED = "closed" STATUS_CHOICES = [ (STATUS_SUBMITTED, "Submitted"), (STATUS_UNDER_REVIEW, "Under review"), (STATUS_QUOTED, "Quoted"), (STATUS_ACCEPTED, "Accepted"), (STATUS_DECLINED, "Declined"), (STATUS_IN_PROGRESS, "In progress"), (STATUS_DELIVERED, "Delivered"), (STATUS_CLOSED, "Closed"), ] CATEGORY_WEB = "web" CATEGORY_DATA = "data" CATEGORY_RESEARCH = "research" CATEGORY_MEDICAL_IMAGING = "medical_imaging" CATEGORY_WORKFLOW = "workflow" CATEGORY_OTHER = "other" CATEGORY_CHOICES = [ (CATEGORY_WEB, "Web development"), (CATEGORY_DATA, "Data & analytics"), (CATEGORY_RESEARCH, "Research"), (CATEGORY_MEDICAL_IMAGING, "Medical imaging"), (CATEGORY_WORKFLOW, "Workflow automation"), (CATEGORY_OTHER, "Other"), ] BUDGET_UNDER_5K = "under_5k" BUDGET_5K_15K = "5k_15k" BUDGET_15K_50K = "15k_50k" BUDGET_50K_PLUS = "50k_plus" BUDGET_NOT_SURE = "not_sure" BUDGET_CHOICES = [ (BUDGET_UNDER_5K, "Under $5,000"), (BUDGET_5K_15K, "$5,000 – $15,000"), (BUDGET_15K_50K, "$15,000 – $50,000"), (BUDGET_50K_PLUS, "$50,000+"), (BUDGET_NOT_SURE, "Not sure yet"), ] LOCK_FIELD_MAP = { "title": "lock_title", "category": "lock_category", "description": "lock_description", "budget_range": "lock_budget_range", "desired_deadline": "lock_desired_deadline", "reference_links": "lock_reference_links", "attachments": "lock_attachments", } client = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="project_briefs", ) title = models.CharField(max_length=300) category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, blank=True, default="") description = models.TextField( blank=True, default="", help_text="Project scope, context, and goals combined.", ) budget_range = models.CharField(max_length=32, choices=BUDGET_CHOICES, blank=True, default="") desired_deadline = models.DateField(blank=True, null=True) reference_links = models.TextField(blank=True) status = models.CharField( max_length=32, choices=STATUS_CHOICES, default=STATUS_SUBMITTED, ) quote_text = models.TextField(blank=True) website_notes = models.TextField( blank=True, help_text="Notes from Tecvico shown to the client in the portal.", ) locked = models.BooleanField( default=False, help_text="Lock the whole project so the client cannot edit it at all.", ) lock_title = models.BooleanField(default=False, help_text="Lock title for the client.") lock_category = models.BooleanField(default=False, help_text="Lock category for the client.") lock_description = models.BooleanField( default=False, help_text="Lock description for the client." ) lock_budget_range = models.BooleanField( default=False, help_text="Lock budget range for the client." ) lock_desired_deadline = models.BooleanField( default=False, help_text="Lock desired deadline for the client." ) lock_reference_links = models.BooleanField( default=False, help_text="Lock reference links for the client." ) lock_attachments = models.BooleanField( default=False, help_text="Lock attachments for the client." ) show_on_website = models.BooleanField( default=False, help_text="Display this project in the public website showcase.", ) show_title_on_website = models.BooleanField( default=True, help_text="Show the title on the website card.", ) show_category_on_website = models.BooleanField( default=True, help_text="Show category as a tag on the website card.", ) show_description_on_website = models.BooleanField( default=True, help_text="Use the description as the website card summary when no summary is set.", ) show_budget_on_website = models.BooleanField( default=False, help_text="Show the budget range on the website card.", ) show_deadline_on_website = models.BooleanField( default=False, help_text="Show the desired deadline on the website card.", ) show_references_on_website = models.BooleanField( default=False, help_text="Show reference links on the website card.", ) show_attachments_on_website = models.BooleanField( default=False, help_text="Show attachment filenames on the website card.", ) public_title = models.CharField( max_length=300, blank=True, help_text="Optional showcase title. Defaults to the brief title.", ) public_summary = models.TextField( blank=True, help_text="Short summary for the website project card.", ) public_tags = models.CharField( max_length=300, blank=True, help_text="Comma-separated tags for the website card.", ) public_image = models.ImageField( upload_to="projects/showcase/", blank=True, null=True, help_text="Image for the website project card.", ) public_project_status = models.CharField( max_length=10, choices=[ ("", "—"), ("new", "New"), ("ongoing", "Ongoing"), ("done", "Done"), ], blank=True, help_text="Filter category for the website showcase.", ) public_url = models.CharField( max_length=300, blank=True, help_text="Optional link for the website project card.", ) submitted_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-submitted_at"] verbose_name = "Project Brief" verbose_name_plural = "Project Briefs" def __str__(self): return f"{self.title} ({self.get_status_display()})" @property def is_editable_by_client(self): return self.status == self.STATUS_SUBMITTED and not self.locked def is_field_locked(self, field_name): if self.locked: return True attr = self.LOCK_FIELD_MAP.get(field_name) if attr is None: return False return getattr(self, attr) @property def attachments_locked(self): return self.is_field_locked("attachments") @property def shows_quote_to_client(self): return self.status in { self.STATUS_QUOTED, self.STATUS_ACCEPTED, self.STATUS_DECLINED, self.STATUS_IN_PROGRESS, self.STATUS_DELIVERED, self.STATUS_CLOSED, } and bool(self.quote_text.strip()) @property def has_website_notes(self): return bool(self.website_notes.strip()) @property def public_display_title(self): if not self.show_title_on_website: return "" return self.public_title.strip() or self.title @property def public_tag_list(self): tags = [] if self.show_category_on_website and self.category: tags.append(self.get_category_display()) if self.public_tags.strip(): tags.extend( tag.strip() for tag in self.public_tags.split(",") if tag.strip() ) return tags @property def public_display_summary(self): if self.public_summary.strip(): return self.public_summary.strip() if self.show_description_on_website and self.description.strip(): return self.description.strip() return "" @property def public_budget_display(self): if not self.show_budget_on_website or not self.budget_range: return "" return self.get_budget_range_display() @property def public_deadline_display(self): if not self.show_deadline_on_website or not self.desired_deadline: return "" return self.desired_deadline.strftime("%b %-d, %Y") @property def public_reference_link_list(self): if not self.show_references_on_website or not self.reference_links.strip(): return [] return [ link.strip() for link in self.reference_links.splitlines() if link.strip() ] @property def public_attachment_filename_list(self): if not self.show_attachments_on_website: return [] return list(self.attachments.values_list("original_filename", flat=True)) def get_absolute_url(self): return reverse("projects:detail", kwargs={"pk": self.pk}) class ProjectBriefAttachment(models.Model): brief = models.ForeignKey( ProjectBrief, on_delete=models.CASCADE, related_name="attachments", ) file = models.FileField( upload_to=project_attachment_upload_to, storage=project_attachment_storage, ) original_filename = models.CharField(max_length=255) uploaded_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["uploaded_at"] verbose_name = "Project Attachment" verbose_name_plural = "Project Attachments" def __str__(self): return self.original_filename class ProjectInternalNote(models.Model): brief = models.ForeignKey( ProjectBrief, on_delete=models.CASCADE, related_name="internal_notes", ) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="project_internal_notes", ) note = models.TextField() created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] verbose_name = "Internal Note" verbose_name_plural = "Internal Notes" def __str__(self): preview = self.note[:60] return f"{self.brief.title} — {preview}"