Coverage for modules\planner\models.py: 91%
93 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-05 09:19 +0800
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-05 09:19 +0800
1from decimal import Decimal
2from django.db import models
3from django.utils import timezone
4from core.models import TenantScopedModel, UUIDModel, TimeStampedModel
5from modules.authentication.models import User
6from modules.team.models import Department
8class Project(TenantScopedModel):
9 """Project entity."""
10 STATUS_CHOICES = [
11 ('ACTIVE', 'Active'),
12 ('ARCHIVED', 'Archived'),
13 ('COMPLETED', 'Completed'),
14 ]
16 name = models.CharField(max_length=128)
17 key = models.CharField(max_length=16, db_index=True) # e.g., "FUDO", "CRM"
18 description = models.TextField(blank=True, null=True)
19 goal = models.TextField(blank=True, null=True)
20 roadmap_start = models.DateField(null=True, blank=True)
21 roadmap_end = models.DateField(null=True, blank=True)
22 department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='planner_projects')
23 color = models.CharField(max_length=32, default='#6366F1')
24 status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='ACTIVE')
25 lead = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='led_projects')
27 class Meta:
28 unique_together = ('tenant_id', 'key')
29 ordering = ['name']
31 def __str__(self):
32 return f"[{self.key}] {self.name}"
34class Board(TenantScopedModel):
35 """Kanban board within a project."""
36 project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='boards')
37 name = models.CharField(max_length=128, default='Main Board')
39 def __str__(self):
40 return f"{self.project.key} - {self.name}"
42class BoardColumn(TenantScopedModel):
43 """Column in a Kanban board."""
44 STATUS_CATEGORY_CHOICES = [
45 ('BACKLOG', 'Backlog'),
46 ('TODO', 'To Do'),
47 ('IN_PROGRESS', 'In Progress'),
48 ('IN_REVIEW', 'In Review'),
49 ('DONE', 'Done'),
50 ]
52 board = models.ForeignKey(Board, on_delete=models.CASCADE, related_name='columns')
53 name = models.CharField(max_length=64)
54 order = models.IntegerField(default=0)
55 limit = models.IntegerField(default=0) # WIP limit (0 = unlimited)
56 status_category = models.CharField(max_length=32, choices=STATUS_CATEGORY_CHOICES, default='TODO')
58 class Meta:
59 ordering = ['order']
61 def __str__(self):
62 return f"{self.board.name} -> {self.name}"
64class Sprint(TenantScopedModel):
65 """Agile Sprint."""
66 project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='sprints')
67 name = models.CharField(max_length=128)
68 goal = models.TextField(blank=True, null=True)
69 start_date = models.DateField(null=True, blank=True)
70 end_date = models.DateField(null=True, blank=True)
71 is_active = models.BooleanField(default=False)
73 def __str__(self):
74 return f"{self.project.key}: {self.name}"
76class Task(TenantScopedModel):
77 """Agile Task / Story / Bug."""
78 PRIORITY_CHOICES = [
79 ('LOW', 'Low'),
80 ('MEDIUM', 'Medium'),
81 ('HIGH', 'High'),
82 ('URGENT', 'Urgent'),
83 ]
84 STATUS_CHOICES = [
85 ('BACKLOG', 'Backlog'),
86 ('TODO', 'To Do'),
87 ('IN_PROGRESS', 'In Progress'),
88 ('IN_REVIEW', 'In Review'),
89 ('DONE', 'Done'),
90 ]
92 project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='tasks')
93 board = models.ForeignKey(Board, on_delete=models.SET_NULL, null=True, blank=True, related_name='tasks')
94 column = models.ForeignKey(BoardColumn, on_delete=models.SET_NULL, null=True, blank=True, related_name='tasks')
95 sprint = models.ForeignKey(Sprint, on_delete=models.SET_NULL, null=True, blank=True, related_name='tasks')
97 title = models.CharField(max_length=255)
98 description = models.TextField(blank=True, null=True)
99 task_number = models.IntegerField()
100 priority = models.CharField(max_length=16, choices=PRIORITY_CHOICES, default='MEDIUM')
101 status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='TODO')
102 order = models.IntegerField(default=0)
104 assignee = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_tasks')
105 reporter = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reported_tasks')
106 due_date = models.DateField(null=True, blank=True)
107 estimated_hours = models.DecimalField(max_digits=6, decimal_places=2, default=Decimal('0.00'))
108 tags = models.JSONField(default=list, blank=True)
110 class Meta:
111 unique_together = ('project', 'task_number')
112 ordering = ['order', '-created_at']
114 @property
115 def task_code(self) -> str:
116 return f"{self.project.key}-{self.task_number}"
118 @property
119 def logged_hours(self) -> Decimal:
120 return self.time_logs.aggregate(models.Sum('hours'))['hours__sum'] or Decimal('0.00')
122 def __str__(self):
123 return f"{self.task_code}: {self.title}"
125class TaskComment(TenantScopedModel):
126 """Task discussion comment."""
127 task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='comments')
128 author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='task_comments')
129 content = models.TextField()
131 class Meta:
132 ordering = ['created_at']
134 def __str__(self):
135 return f"Comment on {self.task.task_code} by {self.author.full_name}"
137class TimeLog(TenantScopedModel):
138 """Logged work time on a task."""
139 task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='time_logs')
140 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='time_logs')
141 hours = models.DecimalField(max_digits=6, decimal_places=2)
142 description = models.CharField(max_length=255, blank=True, null=True)
143 logged_date = models.DateField(default=timezone.now)
145 class Meta:
146 ordering = ['-logged_date', '-created_at']