post.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import copy
  2. from django.contrib.postgres.indexes import GinIndex
  3. from django.contrib.postgres.search import SearchVector, SearchVectorField
  4. from django.db import models
  5. from django.db.models import Q
  6. from django.utils import timezone
  7. from ...conf import settings
  8. from ...core.utils import parse_iso8601_string
  9. from ...markup import finalize_markup
  10. from ..checksums import is_post_valid, update_post_checksum
  11. from ..filtersearch import filter_search
  12. class Post(models.Model):
  13. category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
  14. thread = models.ForeignKey("misago_threads.Thread", on_delete=models.CASCADE)
  15. poster = models.ForeignKey(
  16. settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
  17. )
  18. poster_name = models.CharField(max_length=255)
  19. original = models.TextField()
  20. parsed = models.TextField()
  21. checksum = models.CharField(max_length=64, default="-")
  22. mentions = models.ManyToManyField(
  23. settings.AUTH_USER_MODEL, related_name="mention_set"
  24. )
  25. attachments_cache = models.JSONField(null=True, blank=True)
  26. posted_on = models.DateTimeField(db_index=True)
  27. updated_on = models.DateTimeField()
  28. hidden_on = models.DateTimeField(default=timezone.now)
  29. edits = models.PositiveIntegerField(default=0)
  30. last_editor = models.ForeignKey(
  31. settings.AUTH_USER_MODEL,
  32. blank=True,
  33. null=True,
  34. on_delete=models.SET_NULL,
  35. related_name="+",
  36. )
  37. last_editor_name = models.CharField(max_length=255, null=True, blank=True)
  38. last_editor_slug = models.SlugField(max_length=255, null=True, blank=True)
  39. hidden_by = models.ForeignKey(
  40. settings.AUTH_USER_MODEL,
  41. blank=True,
  42. null=True,
  43. on_delete=models.SET_NULL,
  44. related_name="+",
  45. )
  46. hidden_by_name = models.CharField(max_length=255, null=True, blank=True)
  47. hidden_by_slug = models.SlugField(max_length=255, null=True, blank=True)
  48. has_reports = models.BooleanField(default=False)
  49. has_open_reports = models.BooleanField(default=False)
  50. is_unapproved = models.BooleanField(default=False, db_index=True)
  51. is_hidden = models.BooleanField(default=False)
  52. is_protected = models.BooleanField(default=False)
  53. is_event = models.BooleanField(default=False, db_index=True)
  54. event_type = models.CharField(max_length=255, null=True, blank=True)
  55. event_context = models.JSONField(null=True, blank=True)
  56. likes = models.PositiveIntegerField(default=0)
  57. last_likes = models.JSONField(null=True, blank=True)
  58. liked_by = models.ManyToManyField(
  59. settings.AUTH_USER_MODEL,
  60. related_name="liked_post_set",
  61. through="misago_threads.PostLike",
  62. )
  63. search_document = models.TextField(null=True, blank=True)
  64. search_vector = SearchVectorField()
  65. class Meta:
  66. indexes = [
  67. models.Index(
  68. name="misago_post_has_open_repo_part",
  69. fields=["has_open_reports"],
  70. condition=Q(has_open_reports=True),
  71. ),
  72. models.Index(
  73. name="misago_post_is_hidden_part",
  74. fields=["is_hidden"],
  75. condition=Q(is_hidden=False),
  76. ),
  77. models.Index(
  78. name="misago_post_is_event_part",
  79. fields=["is_event", "event_type"],
  80. condition=Q(is_event=True),
  81. ),
  82. GinIndex(fields=["search_vector"]),
  83. ]
  84. index_together = [
  85. ("thread", "id"), # speed up threadview for team members
  86. ("is_event", "is_hidden"),
  87. ("poster", "posted_on"),
  88. ]
  89. def __str__(self):
  90. return "%s..." % self.original[10:].strip()
  91. def delete(self, *args, **kwargs):
  92. from ..signals import delete_post
  93. delete_post.send(sender=self)
  94. super().delete(*args, **kwargs)
  95. def merge(self, other_post):
  96. if self.poster_id != other_post.poster_id:
  97. raise ValueError("post can't be merged with other user's post")
  98. elif (
  99. self.poster_id is None
  100. and other_post.poster_id is None
  101. and self.poster_name != other_post.poster_name
  102. ):
  103. raise ValueError("post can't be merged with other user's post")
  104. if self.thread_id != other_post.thread_id:
  105. raise ValueError("only posts belonging to same thread can be merged")
  106. if self.is_event or other_post.is_event:
  107. raise ValueError("can't merge events")
  108. if self.pk == other_post.pk:
  109. raise ValueError("post can't be merged with itself")
  110. other_post.original = str("\n\n").join((other_post.original, self.original))
  111. other_post.parsed = str("\n").join((other_post.parsed, self.parsed))
  112. update_post_checksum(other_post)
  113. if self.is_protected:
  114. other_post.is_protected = True
  115. if self.is_best_answer:
  116. self.thread.best_answer = other_post
  117. if other_post.is_best_answer:
  118. self.thread.best_answer_is_protected = other_post.is_protected
  119. from ..signals import merge_post
  120. merge_post.send(sender=self, other_post=other_post)
  121. def move(self, new_thread):
  122. from ..signals import move_post
  123. if self.is_best_answer:
  124. self.thread.clear_best_answer()
  125. self.category = new_thread.category
  126. self.thread = new_thread
  127. move_post.send(sender=self)
  128. @property
  129. def attachments(self):
  130. # pylint: disable=access-member-before-definition
  131. if hasattr(self, "_hydrated_attachments_cache"):
  132. return self._hydrated_attachments_cache
  133. self._hydrated_attachments_cache = []
  134. if self.attachments_cache:
  135. for attachment in copy.deepcopy(self.attachments_cache):
  136. attachment["uploaded_on"] = parse_iso8601_string(
  137. attachment["uploaded_on"]
  138. )
  139. self._hydrated_attachments_cache.append(attachment)
  140. return self._hydrated_attachments_cache
  141. @property
  142. def content(self):
  143. if not hasattr(self, "_finalised_parsed"):
  144. self._finalised_parsed = finalize_markup(self.parsed)
  145. return self._finalised_parsed
  146. @property
  147. def thread_type(self):
  148. return self.category.thread_type
  149. def get_api_url(self):
  150. return self.thread_type.get_post_api_url(self)
  151. def get_likes_api_url(self):
  152. return self.thread_type.get_post_likes_api_url(self)
  153. def get_editor_api_url(self):
  154. return self.thread_type.get_post_editor_api_url(self)
  155. def get_edits_api_url(self):
  156. return self.thread_type.get_post_edits_api_url(self)
  157. def get_read_api_url(self):
  158. return self.thread_type.get_post_read_api_url(self)
  159. def get_absolute_url(self):
  160. return self.thread_type.get_post_absolute_url(self)
  161. def set_search_document(self, thread_title=None):
  162. if thread_title:
  163. self.search_document = filter_search(
  164. "\n\n".join([thread_title, self.original])
  165. )
  166. else:
  167. self.search_document = filter_search(self.original)
  168. def update_search_vector(self):
  169. self.search_vector = SearchVector(
  170. "search_document", config=settings.MISAGO_SEARCH_CONFIG
  171. )
  172. @property
  173. def short(self):
  174. if self.is_valid:
  175. if len(self.original) > 150:
  176. return str("%s...") % self.original[:150].strip()
  177. return self.original
  178. return ""
  179. @property
  180. def is_valid(self):
  181. return is_post_valid(self)
  182. @property
  183. def is_first_post(self):
  184. return self.id == self.thread.first_post_id
  185. @property
  186. def is_best_answer(self):
  187. return self.id == self.thread.best_answer_id