models.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import datetime
  2. import os
  3. import textwrap
  4. from django.utils import timezone
  5. from django.conf import settings
  6. from django.contrib.auth.models import Group
  7. from django.db import DEFAULT_DB_ALIAS, models
  8. from django.db.transaction import Atomic, get_connection
  9. from django.urls import reverse
  10. def get_attachment_upload_dir(instance, filename):
  11. """Determine upload dir for task attachment files.
  12. """
  13. return "/".join(["tasks", "attachments", str(instance.task.id), filename])
  14. class LockedAtomicTransaction(Atomic):
  15. """
  16. modified from https://stackoverflow.com/a/41831049
  17. this is needed for safely merging
  18. Does a atomic transaction, but also locks the entire table for any transactions, for the duration of this
  19. transaction. Although this is the only way to avoid concurrency issues in certain situations, it should be used with
  20. caution, since it has impacts on performance, for obvious reasons...
  21. """
  22. def __init__(self, *models, using=None, savepoint=None):
  23. if using is None:
  24. using = DEFAULT_DB_ALIAS
  25. super().__init__(using, savepoint)
  26. self.models = models
  27. def __enter__(self):
  28. super(LockedAtomicTransaction, self).__enter__()
  29. # Make sure not to lock, when sqlite is used, or you'll run into problems while running tests!!!
  30. if settings.DATABASES[self.using]["ENGINE"] != "django.db.backends.sqlite3":
  31. cursor = None
  32. try:
  33. cursor = get_connection(self.using).cursor()
  34. for model in self.models:
  35. cursor.execute(
  36. "LOCK TABLE {table_name}".format(table_name=model._meta.db_table)
  37. )
  38. finally:
  39. if cursor and not cursor.closed:
  40. cursor.close()
  41. class TaskList(models.Model):
  42. name = models.CharField(max_length=48, verbose_name='Name')
  43. slug = models.SlugField(default="", verbose_name='Slug')
  44. group = models.ForeignKey(Group, on_delete=models.CASCADE, verbose_name='Group')
  45. def __str__(self):
  46. return self.name
  47. class Meta:
  48. ordering = ["name"]
  49. verbose_name_plural = "Ticket Lists"
  50. unique_together = ("group", "slug")
  51. class TicketType(models.Model):
  52. name = models.CharField(max_length=32, unique=True, editable=True, verbose_name='Name')
  53. life_cycle = models.CharField(max_length=192, unique=True, editable=True, verbose_name='Life cycle')
  54. def __str__(self):
  55. return self.name
  56. class Task(models.Model):
  57. task_list = models.ForeignKey(TaskList, blank=True, on_delete=models.CASCADE, verbose_name="Task list")
  58. status = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name="Status")
  59. created_date = models.DateField(verbose_name="Created Date")
  60. status_changed_date = models.DateTimeField(blank=True, verbose_name="Status changed date")
  61. created_by = models.ForeignKey(
  62. settings.AUTH_USER_MODEL,
  63. blank=True,
  64. null=True,
  65. on_delete=models.SET_NULL,
  66. verbose_name="Created by"
  67. )
  68. priority = models.PositiveSmallIntegerField(default=0, verbose_name="Priority")
  69. type = models.ForeignKey(
  70. TicketType,
  71. on_delete=models.SET_NULL,
  72. default=0,
  73. null=True,
  74. related_name="tickets_tickettype",
  75. verbose_name="Type")
  76. title = models.CharField(max_length=64, verbose_name="Title")
  77. note = models.TextField(blank=True, null=True, verbose_name="Note")
  78. due_date = models.DateField(blank=True, null=True, verbose_name="Due date")
  79. assigned_to = models.ForeignKey(
  80. settings.AUTH_USER_MODEL,
  81. blank=True,
  82. null=True,
  83. related_name="tickets_assigned_to",
  84. on_delete=models.SET_NULL,
  85. verbose_name="Assigned to"
  86. )
  87. def get_states_list(self):
  88. data = self.type.life_cycle
  89. data = data.split(",")
  90. for i, item in enumerate(data):
  91. data[i] = item.split("-")
  92. for inner_index, inner_item in enumerate(data[i]):
  93. data[i][inner_index] = int(inner_item)
  94. return data
  95. def now_state_list(self):
  96. states_list = self.get_states_list()
  97. if self.status == None:
  98. return states_list[0]
  99. else:
  100. for i in range(len(states_list)):
  101. if states_list[i][0] == self.status:
  102. return states_list[i]
  103. def next_state(self):
  104. states_list = self.get_states_list()
  105. try:
  106. return self.now_state_list(self, states_list)[1]
  107. except IndexError:
  108. return 1
  109. def first_state(self):
  110. return self.get_states_list()[0][0]
  111. # Has due date for an instance of this object passed?
  112. def overdue_status(self):
  113. "Returns whether the Tasks's due date has passed or not."
  114. if self.due_date and datetime.date.today() > self.due_date:
  115. return True
  116. def __str__(self):
  117. return self.title
  118. def get_absolute_url(self):
  119. return reverse("tickets:task_detail", kwargs={"task_id": self.id})
  120. def save(self, **kwargs):
  121. if not self.id:
  122. self.created_date = timezone.now()
  123. self.status_changed_date = timezone.now()
  124. super(Task, self).save()
  125. # def merge_into(self, merge_target):
  126. # if merge_target.pk == self.pk:
  127. # raise ValueError("can't merge a task with self")
  128. # # lock the comments to avoid concurrent additions of comments after the
  129. # # update request. these comments would be irremediably lost because of
  130. # # the cascade clause
  131. # with LockedAtomicTransaction(Comment):
  132. # Comment.objects.filter(task=self).update(task=merge_target)
  133. # self.delete()
  134. class Meta:
  135. verbose_name = "Task"
  136. ordering = ["priority", "created_date"]
  137. class Comment(models.Model):
  138. author = models.ForeignKey(
  139. settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True,
  140. related_name="tickets_comments",
  141. verbose_name='Author'
  142. )
  143. task = models.ForeignKey(Task, on_delete=models.CASCADE, verbose_name='Task')
  144. date = models.DateTimeField(default=timezone.now, verbose_name='Date')
  145. email_from = models.CharField(max_length=320, blank=True, null=True, verbose_name='Eamil from')
  146. email_message_id = models.CharField(max_length=255, blank=True, null=True, verbose_name='Eamil message id')
  147. body = models.TextField(blank=True, verbose_name='Body')
  148. class Meta:
  149. # an email should only appear once per task
  150. verbose_name='Comment'
  151. unique_together = ("task", "email_message_id")
  152. @property
  153. def author_text(self):
  154. if self.author is not None:
  155. return str(self.author)
  156. assert self.email_message_id is not None
  157. return str(self.email_from)
  158. @property
  159. def snippet(self):
  160. body_snippet = textwrap.shorten(self.body, width=35, placeholder="...")
  161. # Define here rather than in __str__ so we can use it in the admin list_display
  162. return "{author} - {snippet}...".format(author=self.author_text, snippet=body_snippet)
  163. def __str__(self):
  164. return self.snippet
  165. class Attachment(models.Model):
  166. """
  167. Defines a generic file attachment for use in M2M relation with Task.
  168. """
  169. task = models.ForeignKey(Task, on_delete=models.CASCADE, verbose_name='Task')
  170. added_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name='Added by')
  171. timestamp = models.DateTimeField(default=datetime.datetime.now, verbose_name='Timestamp')
  172. file = models.FileField(upload_to=get_attachment_upload_dir, max_length=255, verbose_name='File')
  173. def filename(self):
  174. return os.path.basename(self.file.name)
  175. def extension(self):
  176. name, extension = os.path.splitext(self.file.name)
  177. return extension
  178. def __str__(self):
  179. return f"{self.task.id} - {self.file.name}"