post.py 7.5 KB

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