- Cleaned up admin.py by removing unnecessary dictionary wrapping for fields. - Adjusted migration files to maintain consistent formatting and removed redundant lines. - Updated models.py and tests to remove unnecessary blank lines and improve readability. - Enhanced error handling in the analytics API to properly forward upstream status and messages. - Added comprehensive tests for the analytics proxy to ensure correct status and body forwarding. - Updated contributing guidelines to clarify the use of ruff for code style enforcement.
424 lines
13 KiB
Python
424 lines
13 KiB
Python
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from accounts.models import Account
|
|
from common.base import SAMPLE_DATA_HELP_TEXT, AssignableMixin, BaseModel
|
|
from common.models import Org, Profile, Tags, Teams
|
|
from contacts.models import Contact
|
|
|
|
# Cleanup notes:
|
|
# - Removed 'created_on_arrow' property from Task model (frontend computes its own timestamps)
|
|
|
|
|
|
class Board(BaseModel):
|
|
"""Kanban Board"""
|
|
|
|
name = models.CharField(max_length=255)
|
|
description = models.TextField(blank=True, null=True)
|
|
owner = models.ForeignKey(
|
|
Profile, on_delete=models.CASCADE, related_name="owned_boards"
|
|
)
|
|
members = models.ManyToManyField(
|
|
Profile, through="BoardMember", related_name="boards"
|
|
)
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="boards")
|
|
is_archived = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
verbose_name = "Board"
|
|
verbose_name_plural = "Boards"
|
|
db_table = "board"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["org", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class BoardMember(BaseModel):
|
|
"""Board membership with roles"""
|
|
|
|
ROLE_CHOICES = (
|
|
("owner", "Owner"),
|
|
("admin", "Admin"),
|
|
("member", "Member"),
|
|
)
|
|
|
|
board = models.ForeignKey(
|
|
Board, on_delete=models.CASCADE, related_name="memberships"
|
|
)
|
|
profile = models.ForeignKey(
|
|
Profile, on_delete=models.CASCADE, related_name="board_memberships"
|
|
)
|
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default="member")
|
|
org = models.ForeignKey(
|
|
Org,
|
|
on_delete=models.CASCADE,
|
|
related_name="board_members",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Board Member"
|
|
verbose_name_plural = "Board Members"
|
|
db_table = "board_member"
|
|
unique_together = ("board", "profile")
|
|
indexes = [
|
|
models.Index(fields=["org", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.profile} - {self.board} ({self.role})"
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.org_id and self.board_id:
|
|
self.org_id = self.board.org_id
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class BoardColumn(BaseModel):
|
|
"""Column in a board (e.g., To Do, In Progress, Done)"""
|
|
|
|
board = models.ForeignKey(Board, on_delete=models.CASCADE, related_name="columns")
|
|
name = models.CharField(max_length=100)
|
|
order = models.PositiveIntegerField(default=0)
|
|
color = models.CharField(max_length=7, default="#6B7280") # Hex color
|
|
limit = models.PositiveIntegerField(null=True, blank=True) # WIP limit
|
|
org = models.ForeignKey(
|
|
Org,
|
|
on_delete=models.CASCADE,
|
|
related_name="board_columns",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Board Column"
|
|
verbose_name_plural = "Board Columns"
|
|
db_table = "board_column"
|
|
ordering = ("order",)
|
|
unique_together = ("board", "name")
|
|
indexes = [
|
|
models.Index(fields=["org", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.board.name} - {self.name}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.org_id and self.board_id:
|
|
self.org_id = self.board.org_id
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class BoardTask(BaseModel):
|
|
"""Task/Card in a board column"""
|
|
|
|
PRIORITY_CHOICES = (
|
|
("low", "Low"),
|
|
("medium", "Medium"),
|
|
("high", "High"),
|
|
("urgent", "Urgent"),
|
|
)
|
|
|
|
column = models.ForeignKey(
|
|
BoardColumn, on_delete=models.CASCADE, related_name="tasks"
|
|
)
|
|
title = models.CharField(max_length=255)
|
|
description = models.TextField(blank=True, null=True)
|
|
order = models.PositiveIntegerField(default=0)
|
|
priority = models.CharField(
|
|
max_length=10, choices=PRIORITY_CHOICES, default="medium"
|
|
)
|
|
assigned_to = models.ManyToManyField(
|
|
Profile, related_name="assigned_board_tasks", blank=True
|
|
)
|
|
due_date = models.DateTimeField(null=True, blank=True)
|
|
completed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Link to CRM entities (optional)
|
|
account = models.ForeignKey(
|
|
Account,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="board_tasks",
|
|
)
|
|
contact = models.ForeignKey(
|
|
Contact,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="board_tasks",
|
|
)
|
|
opportunity = models.ForeignKey(
|
|
"opportunity.Opportunity",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="board_tasks",
|
|
)
|
|
org = models.ForeignKey(
|
|
Org,
|
|
on_delete=models.CASCADE,
|
|
related_name="board_tasks",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Board Task"
|
|
verbose_name_plural = "Board Tasks"
|
|
db_table = "board_task"
|
|
ordering = ("order",)
|
|
indexes = [
|
|
models.Index(fields=["org", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.org_id and self.column_id:
|
|
self.org_id = self.column.board.org_id
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_completed(self):
|
|
return self.completed_at is not None
|
|
|
|
@property
|
|
def is_overdue(self):
|
|
if self.due_date and not self.is_completed:
|
|
return timezone.now() > self.due_date
|
|
return False
|
|
|
|
|
|
class TaskPipeline(BaseModel):
|
|
"""
|
|
Custom pipeline for organizing tasks into stages (Kanban columns).
|
|
Each organization can have multiple pipelines (e.g., Development, Support, Marketing).
|
|
"""
|
|
|
|
name = models.CharField(_("Pipeline Name"), max_length=255)
|
|
description = models.TextField(_("Description"), blank=True, null=True)
|
|
org = models.ForeignKey(
|
|
Org, on_delete=models.CASCADE, related_name="task_pipelines"
|
|
)
|
|
is_default = models.BooleanField(
|
|
default=False,
|
|
help_text=(
|
|
"Sorts this pipeline first in listings (see Meta.ordering) and is "
|
|
"limited to one per org. It does NOT route new records: a task "
|
|
"created without a stage keeps stage=NULL, which is the supported "
|
|
"status-based kanban mode."
|
|
),
|
|
)
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Task Pipeline"
|
|
verbose_name_plural = "Task Pipelines"
|
|
db_table = "task_pipeline"
|
|
ordering = ("-is_default", "name")
|
|
indexes = [
|
|
models.Index(fields=["org", "-created_at"]),
|
|
]
|
|
constraints = [
|
|
# Only one default pipeline per org
|
|
models.UniqueConstraint(
|
|
fields=["org"],
|
|
condition=models.Q(is_default=True),
|
|
name="unique_default_task_pipeline_per_org",
|
|
)
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({self.org.name})"
|
|
|
|
|
|
class TaskStage(BaseModel):
|
|
"""
|
|
Stage within a Task Pipeline (Kanban column).
|
|
"""
|
|
|
|
STAGE_TYPE_CHOICES = [
|
|
("open", "Open"), # New tasks
|
|
("in_progress", "In Progress"), # Active work
|
|
("completed", "Completed"), # Done
|
|
]
|
|
|
|
pipeline = models.ForeignKey(
|
|
TaskPipeline, on_delete=models.CASCADE, related_name="stages"
|
|
)
|
|
name = models.CharField(_("Stage Name"), max_length=100)
|
|
order = models.PositiveIntegerField(default=0)
|
|
color = models.CharField(max_length=7, default="#6B7280") # Hex color
|
|
|
|
# Business logic fields
|
|
stage_type = models.CharField(
|
|
max_length=20, choices=STAGE_TYPE_CHOICES, default="open"
|
|
)
|
|
maps_to_status = models.CharField(
|
|
_("Maps to Status"),
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text="When task enters this stage, also update Task.status",
|
|
)
|
|
|
|
# Kanban features
|
|
wip_limit = models.PositiveIntegerField(
|
|
_("WIP Limit"),
|
|
null=True,
|
|
blank=True,
|
|
help_text="Maximum tasks allowed in this stage (null = unlimited)",
|
|
)
|
|
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="task_stages")
|
|
|
|
class Meta:
|
|
verbose_name = "Task Stage"
|
|
verbose_name_plural = "Task Stages"
|
|
db_table = "task_stage"
|
|
ordering = ("order",)
|
|
unique_together = ("pipeline", "name")
|
|
indexes = [
|
|
models.Index(fields=["org", "order"]),
|
|
models.Index(fields=["pipeline", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.pipeline.name} - {self.name}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.org_id and self.pipeline_id:
|
|
self.org_id = self.pipeline.org_id
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class Task(AssignableMixin, BaseModel):
|
|
STATUS_CHOICES = (
|
|
("New", "New"),
|
|
("In Progress", "In Progress"),
|
|
("Completed", "Completed"),
|
|
)
|
|
|
|
PRIORITY_CHOICES = (("Low", "Low"), ("Medium", "Medium"), ("High", "High"))
|
|
|
|
title = models.CharField(_("title"), max_length=200)
|
|
status = models.CharField(_("status"), max_length=50, choices=STATUS_CHOICES)
|
|
priority = models.CharField(_("priority"), max_length=50, choices=PRIORITY_CHOICES)
|
|
due_date = models.DateField(blank=True, null=True)
|
|
description = models.TextField(_("Notes"), blank=True, null=True)
|
|
custom_fields = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Per-org schema extension; values are validated against common.CustomFieldDefinition.",
|
|
)
|
|
account = models.ForeignKey(
|
|
Account,
|
|
related_name="accounts_tasks",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
)
|
|
opportunity = models.ForeignKey(
|
|
"opportunity.Opportunity",
|
|
related_name="opportunity_tasks",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
)
|
|
case = models.ForeignKey(
|
|
"cases.Case",
|
|
related_name="case_tasks",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
)
|
|
lead = models.ForeignKey(
|
|
"leads.Lead",
|
|
related_name="lead_tasks",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
)
|
|
|
|
contacts = models.ManyToManyField(Contact, related_name="task_contacts")
|
|
|
|
assigned_to = models.ManyToManyField(Profile, related_name="task_assigned_users")
|
|
teams = models.ManyToManyField(Teams, related_name="tasks_teams")
|
|
tags = models.ManyToManyField(Tags, related_name="task_tags", blank=True)
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="tasks")
|
|
is_sample = models.BooleanField(default=False, help_text=SAMPLE_DATA_HELP_TEXT)
|
|
|
|
# Kanban fields
|
|
kanban_order = models.DecimalField(
|
|
_("Kanban Order"),
|
|
max_digits=15,
|
|
decimal_places=6,
|
|
default=0,
|
|
help_text="Order within the kanban column for drag-drop positioning",
|
|
)
|
|
stage = models.ForeignKey(
|
|
TaskStage,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="tasks",
|
|
help_text="Custom pipeline stage (if using pipeline mode)",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Task"
|
|
verbose_name_plural = "Tasks"
|
|
db_table = "task"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["status"]),
|
|
models.Index(fields=["due_date"]),
|
|
models.Index(fields=["org", "-created_at"]),
|
|
models.Index(fields=["status", "kanban_order"]),
|
|
models.Index(fields=["stage", "kanban_order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.title}"
|
|
|
|
def clean(self):
|
|
"""Validate that task has at most one parent entity."""
|
|
super().clean()
|
|
parent_fields = ["account", "opportunity", "case", "lead"]
|
|
set_parents = [
|
|
field for field in parent_fields if getattr(self, f"{field}_id", None)
|
|
]
|
|
if len(set_parents) > 1:
|
|
raise ValidationError(
|
|
{
|
|
"account": _(
|
|
"A task can only be linked to one parent entity "
|
|
"(Account, Opportunity, Case, or Lead). "
|
|
f"Currently linked to: {', '.join(set_parents)}"
|
|
)
|
|
}
|
|
)
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_overdue(self) -> bool:
|
|
"""Check if task is overdue (past due date and not completed)."""
|
|
if self.status == "Completed":
|
|
return False
|
|
if self.due_date:
|
|
return timezone.localdate() > self.due_date
|
|
return False
|
|
|
|
@property
|
|
def days_until_due(self) -> int | None:
|
|
"""Return days until due (negative if overdue). None if no due date."""
|
|
if not self.due_date:
|
|
return None
|
|
return (self.due_date - timezone.localdate()).days
|