models.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import datetime
  2. import uuid
  3. from django.contrib.auth.models import User
  4. from django.contrib.contenttypes.fields import GenericForeignKey, \
  5. GenericRelation
  6. from django.contrib.contenttypes.models import ContentType
  7. from django.core import serializers
  8. from django.db import models, connection
  9. from django.db.models.aggregates import Sum
  10. from martor.models import MartorField
  11. def norm(s):
  12. return s.lower().replace('é', 'e').replace('è', 'e').replace('ê', 'e').replace('à', 'a').replace('â', 'a').replace('ç', 'c').replace('ô', 'o');
  13. class Member(models.Model):
  14. user = models.OneToOneField(User, on_delete=models.CASCADE)
  15. trigram = models.CharField(max_length=5)
  16. def __str__(self):
  17. return self.trigram
  18. class BaseModel(models.Model):
  19. created = models.DateTimeField(auto_now_add=True)
  20. updated = models.DateTimeField(auto_now=True)
  21. objects = models.Manager()
  22. uuid = models.UUIDField(default=uuid.uuid4, editable=False)
  23. class Meta:
  24. abstract = True
  25. def get_content_type(self):
  26. return ContentType.objects.get_for_model(self)
  27. def model_name(self):
  28. try:
  29. return self._meta.verbose_name
  30. except AttributeError:
  31. return ""
  32. def to_json(self):
  33. return serializers.serialize('json', [ self, ])
  34. class Comment(BaseModel):
  35. class Meta:
  36. verbose_name = "commentaire"
  37. verbose_name_plural = "commentaires"
  38. ordering = ('-created', )
  39. def __str__(self):
  40. return "De {}, le {:%Y-%m-%d}: {}".format(self.author.username, self.created, self.content[:30])
  41. content_type = models.ForeignKey(ContentType,on_delete=models.CASCADE)
  42. object_id = models.PositiveIntegerField()
  43. content_object = GenericForeignKey('content_type', 'object_id')
  44. content = MartorField(blank=False, default="", verbose_name="Commentaire")
  45. author = models.ForeignKey(User,
  46. on_delete=models.PROTECT,
  47. verbose_name="Auteur")
  48. created = models.DateTimeField(auto_now_add=True)
  49. class Project(BaseModel):
  50. class Meta:
  51. verbose_name = "projet"
  52. verbose_name_plural = "projets"
  53. name = models.CharField(max_length=200)
  54. description = MartorField(blank=True, default="", verbose_name="Description")
  55. color = models.CharField(max_length=7, default="#f6755e")
  56. def __str__(self):
  57. return self.name
  58. class Epic(BaseModel):
  59. class Meta:
  60. verbose_name = "epic"
  61. verbose_name_plural = "epics"
  62. ordering = ('-value', )
  63. SIZES = (("XXS","XXS (< 1 sprints)"),
  64. ("XS", "XS (1 sprint)"),
  65. ("S", "S (1-2 sprints)"),
  66. ("M", "M (2-3 sprints)"),
  67. ("L", "L (3-4 sprints)"),
  68. ("XL", "XL (4-6 sprints)"),
  69. ("XXL", "XXL (> 6 sprints)"))
  70. name = models.CharField(max_length=200, default="", verbose_name="Nom")
  71. size = models.CharField(max_length=10, default="M", choices=SIZES, verbose_name="Taille")
  72. value = models.IntegerField(default=0, verbose_name="Valeur")
  73. description = MartorField(blank=True, default="", verbose_name="Description")
  74. project = models.ForeignKey(Project, on_delete=models.PROTECT, null=True, verbose_name="Projet")
  75. closed = models.BooleanField(default=False, verbose_name="Clôturée")
  76. comments = GenericRelation(Comment)
  77. def __str__(self):
  78. return self.name
  79. def nb_stories(self):
  80. return len(self.stories.all())
  81. def nb_closed_stories(self):
  82. return len(self.stories.filter(closed=True))
  83. def nb_active_stories(self):
  84. return len([story for story in self.stories.filter(closed=False) if story.running()])
  85. def contributors(self):
  86. qry = User.objects.raw("""SELECT DISTINCT auth_user.*
  87. FROM ((auth_user INNER JOIN main_story_assignees ON auth_user.id = main_story_assignees.user_id)
  88. INNER JOIN main_story ON main_story_assignees.story_id = main_story.id)
  89. WHERE main_story.epic_id = {};
  90. """.format(self.id))
  91. return list(qry)
  92. def contributors_str(self):
  93. return ", ".join([c.member.trigram for c in self.contributors()])
  94. def close(self):
  95. self.closed = True
  96. self.save()
  97. def reopen(self):
  98. self.closed = False
  99. self.save()
  100. @classmethod
  101. def search(cls, qstr):
  102. qstr = norm(qstr)
  103. dataset = cls.objects.order_by("-updated")
  104. return [item for item in dataset if qstr in norm(item.name)]
  105. class Sprint(BaseModel):
  106. class Meta:
  107. verbose_name = "sprint"
  108. verbose_name_plural = "sprints"
  109. ordering = ('-number', )
  110. number = models.IntegerField(default=0)
  111. date_start = models.DateField()
  112. date_end = models.DateField()
  113. closed = models.BooleanField(default=False, verbose_name="Terminé")
  114. retro = MartorField(blank=True, default="", verbose_name="Rétrospective")
  115. improvements = MartorField(blank=True, default="", verbose_name="Améliorations")
  116. def __str__(self):
  117. return "Sprint #{} ({:%d/%m/%Y} > {:%d/%m/%Y})".format(self.number, self.date_start, self.date_end)
  118. def running(self):
  119. return self.date_start <= datetime.date.today() <= self.date_end and not self.closed
  120. def nb_stories(self):
  121. return self.stories.count()
  122. def planned_velocity(self):
  123. total = self.stories.aggregate(Sum('weight'))['weight__sum']
  124. return total if total is not None else "NA"
  125. def real_velocity(self):
  126. if datetime.date.today() < self.date_start:
  127. return "NA"
  128. sql = """SELECT SUM(main_story.weight) as vel
  129. FROM main_story
  130. INNER JOIN (SELECT story_id, max(sprint_id) as sprint_id
  131. FROM main_story_sprints
  132. GROUP BY story_id) as last_sprints
  133. ON main_story.id = last_sprints.story_id
  134. WHERE last_sprints.sprint_id = {} AND closed = True;
  135. """.format(self.id)
  136. with connection.cursor() as cursor:
  137. cursor.execute(sql)
  138. row = cursor.fetchone()
  139. return row[0] if row[0] else 0
  140. def unplanned(self):
  141. total = self.stories.filter(story_type=2).aggregate(Sum('weight'))['weight__sum']
  142. return total if total is not None else 0
  143. @classmethod
  144. def current(cls):
  145. """ the current sprint is the first non-closed sprint """
  146. try:
  147. return Sprint.objects.filter(closed = False).order_by('number')[0]
  148. except IndexError:
  149. return None
  150. @classmethod
  151. def previous(cls):
  152. """ the previous sprint is the last closed sprint """
  153. try:
  154. return Sprint.objects.filter(closed = True).order_by('-number')[0]
  155. except IndexError:
  156. return None
  157. @classmethod
  158. def next(cls):
  159. """ the next sprint is the second non-closed sprint """
  160. try:
  161. return Sprint.objects.filter(closed = False).order_by('number')[1]
  162. except IndexError:
  163. return None
  164. class Story(BaseModel):
  165. class Meta:
  166. verbose_name = "story"
  167. verbose_name_plural = "stories"
  168. ordering = ('closed', '-updated')
  169. WEIGHTS = ((None, '------'), (1, 1),(2, 2),(3, 3),(5, 5),(8, 8),(13, 13),(21, 21))
  170. STORY_TYPE = ((0, 'Standard'), (1, 'NEW'), (2, 'Non Planifiée'))
  171. epic = models.ForeignKey(Epic,
  172. on_delete=models.PROTECT,
  173. null=True, blank=True,
  174. related_name="stories",
  175. verbose_name="Epic")
  176. name = models.CharField(max_length=200, default="", verbose_name="Nom")
  177. weight = models.IntegerField(null=True, choices=WEIGHTS, verbose_name="Poids")
  178. story_type = models.IntegerField(default=0, choices=STORY_TYPE, verbose_name="Type")
  179. description = MartorField(blank=True, default="", verbose_name="Description")
  180. closed = models.BooleanField(default=False, verbose_name="Clôturée")
  181. time_spent = models.FloatField(default=0, verbose_name="Temps passé (en jours)")
  182. author = models.ForeignKey(User,
  183. on_delete=models.PROTECT,
  184. null=True, blank=True,
  185. related_name="stories",
  186. related_query_name="story",
  187. verbose_name="Auteur")
  188. assignees = models.ManyToManyField(User,
  189. blank=True,
  190. related_name="assigned",
  191. verbose_name="Assignés")
  192. sprints = models.ManyToManyField(Sprint,
  193. blank=True,
  194. related_name="stories",
  195. related_query_name="story",
  196. verbose_name="Sprints")
  197. comments = GenericRelation(Comment)
  198. def __str__(self):
  199. return self.name
  200. @property
  201. def normedname(self):
  202. return self.name.lower().replace('é', 'e') \
  203. .replace('è', 'e') \
  204. .replace('ê', 'e') \
  205. .replace('à', 'a') \
  206. .replace('â', 'a') \
  207. .replace('ç', 'c') \
  208. .replace('ô', 'o');
  209. @classmethod
  210. def search(cls, qstr):
  211. qstr = norm(qstr)
  212. dataset = cls.objects.order_by("-updated")
  213. return [item for item in dataset if qstr in norm(item.name)]
  214. def running(self):
  215. for sprint in self.sprints.all():
  216. if sprint.running():
  217. return True
  218. return False
  219. def contributors(self):
  220. contributors = list(self.assignees.all())
  221. if not self.author in contributors:
  222. contributors += [self.author]
  223. return contributors
  224. def close(self):
  225. self.closed = True
  226. self.save()
  227. def reopen(self):
  228. self.closed = False
  229. self.save()