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

rafalp / Misago / 26530724367

27 May 2026 06:29PM UTC coverage: 96.654% (-0.2%) from 96.89%
26530724367

Pull #2075

github

web-flow
Merge f265f3710 into 485618d52
Pull Request #2075: Add moderation framework to thread pages

1325 of 1600 new or added lines in 68 files covered. (82.81%)

4 existing lines in 4 files now uncovered.

86455 of 89448 relevant lines covered (96.65%)

0.97 hits per line

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

85.96
/misago/threads/models/thread.py
1
from functools import cached_property
1✔
2
from typing import TYPE_CHECKING, Optional
1✔
3

4
from django.db import models
1✔
5
from django.db.models import Q
1✔
6
from django.utils.translation import pgettext_lazy
1✔
7

8
from ...conf import settings
1✔
9
from ...core.utils import slugify
1✔
10
from ...plugins.models import PluginDataModel
1✔
11

12
if TYPE_CHECKING:
1✔
NEW
13
    from ...users.models import User
×
14

15

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

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

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

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

48
    require_replies_approval = models.BooleanField(default=False)
1✔
49

50
    started_at = models.DateTimeField(db_index=True)
1✔
51
    last_posted_at = models.DateTimeField(db_index=True)
1✔
52

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

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

84
    weight = models.PositiveIntegerField(default=WEIGHT_DEFAULT)
1✔
85

86
    is_unapproved = models.BooleanField(default=False, db_index=True)
1✔
87
    is_hidden = models.BooleanField(default=False)
1✔
88
    is_locked = models.BooleanField(default=False)
1✔
89

90
    solution = models.ForeignKey(
1✔
91
        "misago_threads.Post",
92
        related_name="+",
93
        null=True,
94
        blank=True,
95
        on_delete=models.SET_NULL,
96
    )
97
    solution_posted_at = models.DateTimeField(null=True, blank=True)
1✔
98
    solution_by = models.ForeignKey(
1✔
99
        settings.AUTH_USER_MODEL,
100
        related_name="+",
101
        null=True,
102
        blank=True,
103
        on_delete=models.SET_NULL,
104
    )
105
    solution_by_name = models.CharField(max_length=255, null=True, blank=True)
1✔
106
    solution_by_slug = models.CharField(max_length=255, null=True, blank=True)
1✔
107

108
    solution_selected_at = models.DateTimeField(null=True, blank=True)
1✔
109
    solution_selected_by = models.ForeignKey(
1✔
110
        settings.AUTH_USER_MODEL,
111
        related_name="+",
112
        null=True,
113
        blank=True,
114
        on_delete=models.SET_NULL,
115
    )
116
    solution_selected_by_name = models.CharField(max_length=255, null=True, blank=True)
1✔
117
    solution_selected_by_slug = models.CharField(max_length=255, null=True, blank=True)
1✔
118

119
    solution_is_locked = models.BooleanField(default=False)
1✔
120
    solution_locked_at = models.DateTimeField(null=True, blank=True)
1✔
121
    solution_locked_by = models.ForeignKey(
1✔
122
        settings.AUTH_USER_MODEL,
123
        related_name="+",
124
        null=True,
125
        blank=True,
126
        on_delete=models.SET_NULL,
127
    )
128
    solution_locked_by_name = models.CharField(max_length=255, null=True, blank=True)
1✔
129
    solution_locked_by_slug = models.CharField(max_length=255, null=True, blank=True)
1✔
130

131
    class Meta(PluginDataModel.Meta):
1✔
132
        indexes = PluginDataModel.Meta.indexes + [
1✔
133
            models.Index(
134
                name="misago_thread_pinned_glob_part",
135
                fields=["weight"],
136
                condition=Q(weight=2),
137
            ),
138
            models.Index(
139
                name="misago_thread_pinned_loca_part",
140
                fields=["weight"],
141
                condition=Q(weight=1),
142
            ),
143
            models.Index(
144
                name="misago_thread_not_pinned_part",
145
                fields=["weight"],
146
                condition=Q(weight=0),
147
            ),
148
            models.Index(
149
                name="misago_thread_not_global_part",
150
                fields=["weight"],
151
                condition=Q(weight__lt=2),
152
            ),
153
            models.Index(
154
                name="misago_thread_has_reporte_part",
155
                fields=["has_reported_posts"],
156
                condition=Q(has_reported_posts=True),
157
            ),
158
            models.Index(
159
                name="misago_thread_has_unappro_part",
160
                fields=["has_unapproved_posts"],
161
                condition=Q(has_unapproved_posts=True),
162
            ),
163
            models.Index(
164
                name="misago_thread_is_visible_part",
165
                fields=["is_hidden"],
166
                condition=Q(is_hidden=False),
167
            ),
168
            models.Index(fields=["category", "id"]),
169
            models.Index(fields=["category", "last_posted_at"]),
170
            models.Index(fields=["category", "replies"]),
171
        ]
172

173
    def __str__(self):
1✔
174
        return self.title
1✔
175

176
    def delete(self, *args, **kwargs):
1✔
177
        from ..signals import delete_thread
1✔
178

179
        delete_thread.send(sender=self)
1✔
180

181
        super().delete(*args, **kwargs)
1✔
182

183
    def merge(self, other_thread):
1✔
184
        if self.pk == other_thread.pk:
×
185
            raise ValueError("thread can't be merged with itself")
×
186

187
        from ..signals import merge_thread
×
188

189
        merge_thread.send(sender=self, other_thread=other_thread)
×
190

191
    def move(self, new_category):
1✔
192
        from ..signals import move_thread
×
193

194
        self.category = new_category
×
195
        move_thread.send(sender=self)
×
196

197
    @property
1✔
198
    def has_solution(self) -> bool:
1✔
199
        return bool(self.solution_id)
1✔
200

201
    @property
1✔
202
    def replies_in_ks(self):
1✔
203
        return "%sK" % round(self.replies / 1000, 0)
×
204

205
    @cached_property
1✔
206
    def private_thread_owner(self) -> Optional["User"]:
1✔
207
        return None
1✔
208

209
    @cached_property
1✔
210
    def private_thread_owner_id(self) -> int | None:
1✔
211
        return (
1✔
212
            self.privatethreadmember_set.filter(is_owner=True)
213
            .values_list("user_id", flat=True)
214
            .first()
215
        )
216

217
    @cached_property
1✔
218
    def private_thread_members(self) -> list["User"]:
1✔
219
        return []
1✔
220

221
    @cached_property
1✔
222
    def private_thread_member_ids(self) -> list[int]:
1✔
223
        """Returns lists of private thread participating users ids.
224

225
        Cached property. Thread owner is guaranteed to be first item of the list.
226
        """
227
        return list(
1✔
228
            self.privatethreadmember_set.order_by("-is_owner", "id").values_list(
229
                "user_id", flat=True
230
            )
231
        )
232

233
    def set_title(self, title):
1✔
234
        self.title = title
×
235
        self.slug = slugify(title)
×
236

237
        if self.id:
×
238
            from ..signals import update_thread_title
×
239

240
            update_thread_title.send(sender=self)
×
241

242
    def set_first_post(self, post):
1✔
243
        self.started_at = post.posted_at
1✔
244
        self.first_post = post
1✔
245
        self.starter = post.poster
1✔
246
        self.starter_name = post.poster_name
1✔
247
        if post.poster:
1✔
248
            self.starter_slug = post.poster.slug
1✔
249
        else:
250
            self.starter_slug = slugify(post.poster_name)
×
251

252
        self.is_unapproved = post.is_unapproved
1✔
253
        self.is_hidden = post.is_hidden
1✔
254

255
    def set_last_post(self, post):
1✔
256
        self.last_posted_at = post.posted_at
1✔
257
        self.last_post = post
1✔
258
        self.last_poster = post.poster
1✔
259
        self.last_poster_name = post.poster_name
1✔
260
        if post.poster:
1✔
261
            self.last_poster_slug = post.poster.slug
1✔
262
        else:
263
            self.last_poster_slug = slugify(post.poster_name)
×
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