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

rafalp / Misago / 17497187987

05 Sep 2025 03:14PM UTC coverage: 96.509% (-0.3%) from 96.769%
17497187987

push

github

web-flow
Move `Post` model from `misago.threads` to `misago.posts` (#1995)

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

73.55
/misago/threads/models/thread.py
1
from functools import cached_property
1✔
2

3
from django.core.exceptions import ObjectDoesNotExist
1✔
4
from django.db import models
1✔
5
from django.db.models import Q
1✔
6
from django.utils import timezone
1✔
7
from django.utils.translation import pgettext_lazy
1✔
8

9
from ...conf import settings
1✔
10
from ...core.utils import slugify
1✔
11
from ...plugins.models import PluginDataModel
1✔
12
from ...polls.models import Poll
1✔
13

14

15
class Thread(PluginDataModel):
1✔
16
    WEIGHT_DEFAULT = 0
1✔
17
    WEIGHT_PINNED = 1
1✔
18
    WEIGHT_GLOBAL = 2
1✔
19

20
    WEIGHT_CHOICES = [
1✔
21
        (
22
            WEIGHT_DEFAULT,
23
            pgettext_lazy("thread weight choice", "Not pinned"),
24
        ),
25
        (
26
            WEIGHT_PINNED,
27
            pgettext_lazy("thread weight choice", "Pinned in category"),
28
        ),
29
        (
30
            WEIGHT_GLOBAL,
31
            pgettext_lazy("thread weight choice", "Pinned globally"),
32
        ),
33
    ]
34

35
    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
1✔
36
    title = models.CharField(max_length=255)
1✔
37
    slug = models.CharField(max_length=255)
1✔
38
    replies = models.PositiveIntegerField(default=0, db_index=True)
1✔
39

40
    has_events = models.BooleanField(default=False)
1✔
41
    has_poll = models.BooleanField(default=False)
1✔
42
    has_reported_posts = models.BooleanField(default=False)
1✔
43
    has_open_reports = models.BooleanField(default=False)
1✔
44
    has_unapproved_posts = models.BooleanField(default=False)
1✔
45
    has_hidden_posts = models.BooleanField(default=False)
1✔
46

47
    started_on = models.DateTimeField(db_index=True)
1✔
48
    last_post_on = models.DateTimeField(db_index=True)
1✔
49

50
    first_post = models.ForeignKey(
1✔
51
        "misago_posts.Post",
52
        related_name="+",
53
        null=True,
54
        blank=True,
55
        on_delete=models.SET_NULL,
56
    )
57
    starter = models.ForeignKey(
1✔
58
        settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
59
    )
60
    starter_name = models.CharField(max_length=255)
1✔
61
    starter_slug = models.CharField(max_length=255)
1✔
62

63
    last_post = models.ForeignKey(
1✔
64
        "misago_posts.Post",
65
        related_name="+",
66
        null=True,
67
        blank=True,
68
        on_delete=models.SET_NULL,
69
    )
70
    last_post_is_event = models.BooleanField(default=False)
1✔
71
    last_poster = models.ForeignKey(
1✔
72
        settings.AUTH_USER_MODEL,
73
        related_name="last_poster_set",
74
        null=True,
75
        blank=True,
76
        on_delete=models.SET_NULL,
77
    )
78
    last_poster_name = models.CharField(max_length=255, null=True, blank=True)
1✔
79
    last_poster_slug = models.CharField(max_length=255, null=True, blank=True)
1✔
80

81
    weight = models.PositiveIntegerField(default=WEIGHT_DEFAULT)
1✔
82

83
    is_unapproved = models.BooleanField(default=False, db_index=True)
1✔
84
    is_hidden = models.BooleanField(default=False)
1✔
85
    is_closed = models.BooleanField(default=False)
1✔
86

87
    best_answer = models.ForeignKey(
1✔
88
        "misago_posts.Post",
89
        related_name="+",
90
        null=True,
91
        blank=True,
92
        on_delete=models.SET_NULL,
93
    )
94
    best_answer_is_protected = models.BooleanField(default=False)
1✔
95
    best_answer_marked_on = models.DateTimeField(null=True, blank=True)
1✔
96
    best_answer_marked_by = models.ForeignKey(
1✔
97
        settings.AUTH_USER_MODEL,
98
        related_name="marked_best_answer_set",
99
        null=True,
100
        blank=True,
101
        on_delete=models.SET_NULL,
102
    )
103
    best_answer_marked_by_name = models.CharField(max_length=255, null=True, blank=True)
1✔
104
    best_answer_marked_by_slug = models.CharField(max_length=255, null=True, blank=True)
1✔
105

106
    class Meta:
1✔
107
        indexes = [
1✔
108
            *PluginDataModel.Meta.indexes,
109
            models.Index(
110
                name="misago_thread_pinned_glob_part",
111
                fields=["weight"],
112
                condition=Q(weight=2),
113
            ),
114
            models.Index(
115
                name="misago_thread_pinned_loca_part",
116
                fields=["weight"],
117
                condition=Q(weight=1),
118
            ),
119
            models.Index(
120
                name="misago_thread_not_pinned_part",
121
                fields=["weight"],
122
                condition=Q(weight=0),
123
            ),
124
            models.Index(
125
                name="misago_thread_not_global_part",
126
                fields=["weight"],
127
                condition=Q(weight__lt=2),
128
            ),
129
            models.Index(
130
                name="misago_thread_has_reporte_part",
131
                fields=["has_reported_posts"],
132
                condition=Q(has_reported_posts=True),
133
            ),
134
            models.Index(
135
                name="misago_thread_has_unappro_part",
136
                fields=["has_unapproved_posts"],
137
                condition=Q(has_unapproved_posts=True),
138
            ),
139
            models.Index(
140
                name="misago_thread_is_visible_part",
141
                fields=["is_hidden"],
142
                condition=Q(is_hidden=False),
143
            ),
144
            models.Index(fields=["category", "id"]),
145
            models.Index(fields=["category", "last_post_on"]),
146
            models.Index(fields=["category", "replies"]),
147
        ]
148

149
    def __str__(self):
1✔
150
        return self.title
1✔
151

152
    def delete(self, *args, **kwargs):
1✔
153
        from ..signals import delete_thread
1✔
154

155
        delete_thread.send(sender=self)
1✔
156

157
        super().delete(*args, **kwargs)
1✔
158

159
    def merge(self, other_thread):
1✔
UNCOV
160
        if self.pk == other_thread.pk:
×
UNCOV
161
            raise ValueError("thread can't be merged with itself")
×
162

UNCOV
163
        from ..signals import merge_thread
×
164

UNCOV
165
        merge_thread.send(sender=self, other_thread=other_thread)
×
166

167
    def move(self, new_category):
1✔
168
        from ..signals import move_thread
1✔
169

170
        self.category = new_category
1✔
171
        move_thread.send(sender=self)
1✔
172

173
    @property
1✔
174
    def has_best_answer(self):
1✔
175
        return bool(self.best_answer_id)
1✔
176

177
    @property
1✔
178
    def replies_in_ks(self):
1✔
179
        return "%sK" % round(self.replies / 1000, 0)
×
180

181
    @cached_property
1✔
182
    def private_thread_owner_id(self) -> int | None:
1✔
183
        return (
1✔
184
            self.privatethreadmember_set.filter(is_owner=True)
185
            .values_list("user_id", flat=True)
186
            .first()
187
        )
188

189
    @cached_property
1✔
190
    def private_thread_member_ids(self) -> list[int]:
1✔
191
        """Returns lists of private thread participating users ids.
192

193
        Cached property. Thread owner is guaranteed to be first item of the list.
194
        """
195
        return list(
1✔
196
            self.privatethreadmember_set.order_by("-is_owner", "id").values_list(
197
                "user_id", flat=True
198
            )
199
        )
200

201
    def set_title(self, title):
1✔
UNCOV
202
        self.title = title
×
UNCOV
203
        self.slug = slugify(title)
×
204

UNCOV
205
        if self.id:
×
UNCOV
206
            from ..signals import update_thread_title
×
207

UNCOV
208
            update_thread_title.send(sender=self)
×
209

210
    def set_first_post(self, post):
1✔
211
        self.started_on = post.posted_at
1✔
212
        self.first_post = post
1✔
213
        self.starter = post.poster
1✔
214
        self.starter_name = post.poster_name
1✔
215
        if post.poster:
1✔
216
            self.starter_slug = post.poster.slug
1✔
217
        else:
UNCOV
218
            self.starter_slug = slugify(post.poster_name)
×
219

220
        self.is_unapproved = post.is_unapproved
1✔
221
        self.is_hidden = post.is_hidden
1✔
222

223
    def set_last_post(self, post):
1✔
224
        self.last_post_on = post.posted_at
1✔
225
        self.last_post = post
1✔
226
        self.last_poster = post.poster
1✔
227
        self.last_poster_name = post.poster_name
1✔
228
        if post.poster:
1✔
229
            self.last_poster_slug = post.poster.slug
1✔
230
        else:
UNCOV
231
            self.last_poster_slug = slugify(post.poster_name)
×
232

233
    def set_best_answer(self, user, post):
1✔
UNCOV
234
        if post.thread_id != self.id:
×
UNCOV
235
            raise ValueError("post to set as best answer must be in same thread")
×
UNCOV
236
        if post.is_first_post:
×
UNCOV
237
            raise ValueError("post to set as best answer can't be first post")
×
UNCOV
238
        if post.is_hidden:
×
UNCOV
239
            raise ValueError("post to set as best answer can't be hidden")
×
UNCOV
240
        if post.is_unapproved:
×
UNCOV
241
            raise ValueError("post to set as best answer can't be unapproved")
×
242

UNCOV
243
        self.best_answer = post
×
UNCOV
244
        self.best_answer_is_protected = post.is_protected
×
UNCOV
245
        self.best_answer_marked_on = timezone.now()
×
UNCOV
246
        self.best_answer_marked_by = user
×
UNCOV
247
        self.best_answer_marked_by_name = user.username
×
UNCOV
248
        self.best_answer_marked_by_slug = user.slug
×
249

250
    def clear_best_answer(self):
1✔
UNCOV
251
        self.best_answer = None
×
UNCOV
252
        self.best_answer_is_protected = False
×
UNCOV
253
        self.best_answer_marked_on = None
×
UNCOV
254
        self.best_answer_marked_by = None
×
UNCOV
255
        self.best_answer_marked_by_name = None
×
UNCOV
256
        self.best_answer_marked_by_slug = None
×
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