330 lines
10 KiB
Python
330 lines
10 KiB
Python
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}"
|