• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rafalp / Misago / 17496798327

05 Sep 2025 02:59PM UTC coverage: 96.509% (-0.3%) from 96.769%
17496798327

Pull #1995

github

web-flow
Merge 17284d7d7 into 6bb938c90
Pull Request #1995: Move `Post` model from `misago.threads` to `misago.posts`

1884 of 1974 new or added lines in 149 files covered. (95.44%)

305 existing lines in 16 files now uncovered.

66716 of 69129 relevant lines covered (96.51%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.74
/misago/threads/models/post.py
1
import copy
1✔
2
import hashlib
1✔
3
from typing import TYPE_CHECKING
1✔
4

5
from django.contrib.postgres.indexes import GinIndex
1✔
6
from django.contrib.postgres.search import SearchVector, SearchVectorField
1✔
7
from django.db import models
1✔
8
from django.db.models import Q
1✔
9
from django.urls import reverse
1✔
10
from django.utils import timezone
1✔
11

12
from ...conf import settings
1✔
13
from ...core.utils import parse_iso8601_string
1✔
14
from ...markup import finalize_markup
1✔
15
from ...plugins.models import PluginDataModel
1✔
16
from ..checksums import is_post_valid, update_post_checksum
1✔
17

18
if TYPE_CHECKING:
1✔
19
    from .thread import Thread
×
20

21

22
class Post(PluginDataModel):
1✔
23
    category = models.ForeignKey(
1✔
24
        "misago_categories.Category", related_name="+", on_delete=models.CASCADE
25
    )
26
    thread = models.ForeignKey(
1✔
27
        "misago_threads.Thread", related_name="+", on_delete=models.CASCADE
28
    )
29
    poster = models.ForeignKey(
1✔
30
        settings.AUTH_USER_MODEL,
31
        blank=True,
32
        null=True,
33
        related_name="+",
34
        on_delete=models.SET_NULL,
35
    )
36
    poster_name = models.CharField(max_length=255)
1✔
37
    original = models.TextField()
1✔
38
    parsed = models.TextField()
1✔
39
    checksum = models.CharField(max_length=64, default="-")
1✔
40
    metadata = models.JSONField(default=dict)
1✔
41

42
    attachments_cache = models.JSONField(null=True, blank=True)
1✔
43

44
    posted_on = models.DateTimeField(db_index=True)
1✔
45
    updated_on = models.DateTimeField()
1✔
46
    hidden_on = models.DateTimeField(default=timezone.now)
1✔
47

48
    edits = models.PositiveIntegerField(default=0)
1✔
49
    last_editor = models.ForeignKey(
1✔
50
        settings.AUTH_USER_MODEL,
51
        blank=True,
52
        null=True,
53
        on_delete=models.SET_NULL,
54
        related_name="+",
55
    )
56
    last_editor_name = models.CharField(max_length=255, null=True, blank=True)
1✔
57
    last_editor_slug = models.SlugField(max_length=255, null=True, blank=True)
1✔
58

59
    hidden_by = models.ForeignKey(
1✔
60
        settings.AUTH_USER_MODEL,
61
        blank=True,
62
        null=True,
63
        on_delete=models.SET_NULL,
64
        related_name="+",
65
    )
66
    hidden_by_name = models.CharField(max_length=255, null=True, blank=True)
1✔
67
    hidden_by_slug = models.SlugField(max_length=255, null=True, blank=True)
1✔
68

69
    has_reports = models.BooleanField(default=False)
1✔
70
    has_open_reports = models.BooleanField(default=False)
1✔
71
    is_unapproved = models.BooleanField(default=False, db_index=True)
1✔
72
    is_hidden = models.BooleanField(default=False)
1✔
73
    is_protected = models.BooleanField(default=False)
1✔
74

75
    is_event = models.BooleanField(default=False, db_index=True)
1✔
76
    event_type = models.CharField(max_length=255, null=True, blank=True)
1✔
77
    event_context = models.JSONField(null=True, blank=True)
1✔
78

79
    likes = models.PositiveIntegerField(default=0)
1✔
80
    last_likes = models.JSONField(null=True, blank=True)
1✔
81

82
    liked_by = models.ManyToManyField(
1✔
83
        settings.AUTH_USER_MODEL,
84
        related_name="liked_post_set",
85
        through="misago_threads.PostLike",
86
    )
87

88
    search_document = models.TextField(null=True, blank=True)
1✔
89
    search_vector = SearchVectorField()
1✔
90

91
    def __str__(self):
1✔
UNCOV
92
        return "%s..." % self.original[10:].strip()
×
93

94
    def delete(self, *args, **kwargs):
1✔
UNCOV
95
        from ..signals import delete_post
×
96

UNCOV
97
        delete_post.send(sender=self)
×
98

UNCOV
99
        super().delete(*args, **kwargs)
×
100

101
    def merge(self, other_post):
1✔
UNCOV
102
        if self.poster_id != other_post.poster_id:
×
UNCOV
103
            raise ValueError("post can't be merged with other user's post")
×
UNCOV
104
        elif (
×
105
            self.poster_id is None
106
            and other_post.poster_id is None
107
            and self.poster_name != other_post.poster_name
108
        ):
109
            raise ValueError("post can't be merged with other user's post")
×
110

UNCOV
111
        if self.thread_id != other_post.thread_id:
×
UNCOV
112
            raise ValueError("only posts belonging to same thread can be merged")
×
113

UNCOV
114
        if self.is_event or other_post.is_event:
×
UNCOV
115
            raise ValueError("can't merge events")
×
116

UNCOV
117
        if self.pk == other_post.pk:
×
UNCOV
118
            raise ValueError("post can't be merged with itself")
×
119

UNCOV
120
        other_post.original = str("\n\n").join((other_post.original, self.original))
×
UNCOV
121
        other_post.parsed = str("\n").join((other_post.parsed, self.parsed))
×
UNCOV
122
        update_post_checksum(other_post)
×
123

UNCOV
124
        if self.is_protected:
×
125
            other_post.is_protected = True
×
UNCOV
126
        if self.is_best_answer:
×
UNCOV
127
            self.thread.best_answer = other_post
×
UNCOV
128
        if other_post.is_best_answer:
×
UNCOV
129
            self.thread.best_answer_is_protected = other_post.is_protected
×
130

UNCOV
131
        from ..signals import merge_post
×
132

UNCOV
133
        merge_post.send(sender=self, other_post=other_post)
×
134

135
    def move(self, new_thread):
1✔
UNCOV
136
        from ..signals import move_post
×
137

UNCOV
138
        if self.is_best_answer:
×
139
            self.thread.clear_best_answer()
×
140

UNCOV
141
        self.category = new_thread.category
×
UNCOV
142
        self.thread = new_thread
×
UNCOV
143
        move_post.send(sender=self)
×
144

145
    @property
1✔
146
    def attachments(self):
1✔
147
        # pylint: disable=access-member-before-definition
148
        if hasattr(self, "_hydrated_attachments_cache"):
×
149
            return self._hydrated_attachments_cache
×
150

151
        self._hydrated_attachments_cache = []
×
152
        if self.attachments_cache:
×
153
            for attachment in copy.deepcopy(self.attachments_cache):
×
154
                attachment["uploaded_on"] = parse_iso8601_string(
×
155
                    attachment["uploaded_on"]
156
                )
157
                self._hydrated_attachments_cache.append(attachment)
×
158

159
        return self._hydrated_attachments_cache
×
160

161
    @property
1✔
162
    def sha256_checksum(self) -> str:
1✔
UNCOV
163
        return hashlib.sha256(
×
164
            f"{self.id}:{self.updated_on}:{self.parsed}".encode()
165
        ).hexdigest()
166

167
    @property
1✔
168
    def content(self):
1✔
169
        if not hasattr(self, "_finalised_parsed"):
×
170
            self._finalised_parsed = finalize_markup(self.parsed)
×
171
        return self._finalised_parsed
×
172

173
    @property
1✔
174
    def thread_type(self):
1✔
175
        return self.category.thread_type
×
176

177
    def get_absolute_url(self):
1✔
UNCOV
178
        return reverse("misago:post", kwargs={"id": self.id})
×
179

180
    def get_api_url(self):
1✔
181
        return self.thread_type.get_post_api_url(self)
×
182

183
    def get_likes_api_url(self):
1✔
184
        return self.thread_type.get_post_likes_api_url(self)
×
185

186
    def get_editor_api_url(self):
1✔
187
        return self.thread_type.get_post_editor_api_url(self)
×
188

189
    def get_edits_api_url(self):
1✔
190
        return self.thread_type.get_post_edits_api_url(self)
×
191

192
    def set_search_document(self, thread: "Thread", search_document: str):
1✔
UNCOV
193
        if self.id == thread.first_post_id:
×
UNCOV
194
            self.search_document = f"{thread.title}\n\n{search_document}"
×
195
        else:
UNCOV
196
            self.search_document = search_document
×
197

198
    def set_search_vector(self):
1✔
UNCOV
199
        self.search_vector = SearchVector(
×
200
            "search_document", config=settings.MISAGO_SEARCH_CONFIG
201
        )
202

203
    @property
1✔
204
    def short(self):
1✔
205
        if self.is_valid:
×
206
            if len(self.original) > 150:
×
207
                return str("%s...") % self.original[:150].strip()
×
208
            return self.original
×
209
        return ""
×
210

211
    @property
1✔
212
    def is_valid(self):
1✔
UNCOV
213
        return is_post_valid(self)
×
214

215
    @property
1✔
216
    def is_first_post(self):
1✔
UNCOV
217
        return self.id == self.thread.first_post_id
×
218

219
    @property
1✔
220
    def is_best_answer(self):
1✔
UNCOV
221
        return self.id == self.thread.best_answer_id
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc