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

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 

7 

8class Project(TenantScopedModel): 

9 """Project entity.""" 

10 STATUS_CHOICES = [ 

11 ('ACTIVE', 'Active'), 

12 ('ARCHIVED', 'Archived'), 

13 ('COMPLETED', 'Completed'), 

14 ] 

15 

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') 

26 

27 class Meta: 

28 unique_together = ('tenant_id', 'key') 

29 ordering = ['name'] 

30 

31 def __str__(self): 

32 return f"[{self.key}] {self.name}" 

33 

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') 

38 

39 def __str__(self): 

40 return f"{self.project.key} - {self.name}" 

41 

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 ] 

51 

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') 

57 

58 class Meta: 

59 ordering = ['order'] 

60 

61 def __str__(self): 

62 return f"{self.board.name} -> {self.name}" 

63 

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) 

72 

73 def __str__(self): 

74 return f"{self.project.key}: {self.name}" 

75 

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 ] 

91 

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') 

96 

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) 

103 

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) 

109 

110 class Meta: 

111 unique_together = ('project', 'task_number') 

112 ordering = ['order', '-created_at'] 

113 

114 @property 

115 def task_code(self) -> str: 

116 return f"{self.project.key}-{self.task_number}" 

117 

118 @property 

119 def logged_hours(self) -> Decimal: 

120 return self.time_logs.aggregate(models.Sum('hours'))['hours__sum'] or Decimal('0.00') 

121 

122 def __str__(self): 

123 return f"{self.task_code}: {self.title}" 

124 

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() 

130 

131 class Meta: 

132 ordering = ['created_at'] 

133 

134 def __str__(self): 

135 return f"Comment on {self.task.task_code} by {self.author.full_name}" 

136 

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) 

144 

145 class Meta: 

146 ordering = ['-logged_date', '-created_at']