models.py 13.5 KB
Newer Older
1
2
# -*- coding:utf-8 -*
#
Skia's avatar
Skia committed
3
# Copyright 2016,2017,2018
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#

Skia's avatar
Skia committed
25
26
27
28
from django.db import models
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError
29
from django.urls import reverse
Skia's avatar
Skia committed
30
from django.utils import timezone
Skia's avatar
Skia committed
31
from django.utils.functional import cached_property
Skia's avatar
Skia committed
32

Skia's avatar
Skia committed
33
from datetime import datetime
Sli's avatar
Sli committed
34
from itertools import chain
Skia's avatar
Skia committed
35
36
import pytz

Krophil's avatar
Krophil committed
37
from core.models import User, Group
38
from club.models import Club
Skia's avatar
Skia committed
39

Krophil's avatar
Krophil committed
40

Skia's avatar
Skia committed
41
42
43
class Forum(models.Model):
    """
    The Forum class, made as a tree to allow nice tidy organization
Skia's avatar
Skia committed
44
45
46
47

    owner_club allows club members to moderate there own topics
    edit_groups allows to put any group as a forum admin
    view_groups allows some groups to view a forum
Skia's avatar
Skia committed
48
    """
Sli's avatar
Sli committed
49

50
    # Those functions prevent generating migration upon settings changes
Sli's avatar
Sli committed
51
52
53
54
55
56
    def get_default_edit_group():
        return [settings.SITH_GROUP_OLD_SUBSCRIBERS_ID]

    def get_default_view_group():
        return [settings.SITH_GROUP_PUBLIC_ID]

57
    id = models.AutoField(primary_key=True, db_index=True)
Sli's avatar
Sli committed
58
59
60
    name = models.CharField(_("name"), max_length=64)
    description = models.CharField(_("description"), max_length=512, default="")
    is_category = models.BooleanField(_("is a category"), default=False)
61
62
63
64
65
66
67
    parent = models.ForeignKey(
        "Forum",
        related_name="children",
        null=True,
        blank=True,
        on_delete=models.CASCADE,
    )
Sli's avatar
Sli committed
68
69
70
71
72
    owner_club = models.ForeignKey(
        Club,
        related_name="owned_forums",
        verbose_name=_("owner club"),
        default=settings.SITH_MAIN_CLUB_ID,
73
        on_delete=models.CASCADE,
Sli's avatar
Sli committed
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
    )
    edit_groups = models.ManyToManyField(
        Group,
        related_name="editable_forums",
        blank=True,
        default=get_default_edit_group,
    )
    view_groups = models.ManyToManyField(
        Group,
        related_name="viewable_forums",
        blank=True,
        default=get_default_view_group,
    )
    number = models.IntegerField(
        _("number to choose a specific forum ordering"), default=1
    )
    _last_message = models.ForeignKey(
        "ForumMessage",
        related_name="forums_where_its_last",
        verbose_name=_("the last message"),
        null=True,
        on_delete=models.SET_NULL,
    )
97
    _topic_number = models.IntegerField(_("number of topics"), default=0)
Skia's avatar
Skia committed
98
99

    class Meta:
Sli's avatar
Sli committed
100
        ordering = ["number"]
101

102
103
104
105
106
107
108
109
110
111
112
    def clean(self):
        self.check_loop()

    def save(self, *args, **kwargs):
        copy_rights = False
        if self.id is None:
            copy_rights = True
        super(Forum, self).save(*args, **kwargs)
        if copy_rights:
            self.copy_rights()

113
114
115
116
117
118
119
    def set_topic_number(self):
        self._topic_number = self.get_topic_number()
        self.save()
        if self.parent:
            self.parent.set_topic_number()

    def set_last_message(self):
Sli's avatar
Sli committed
120
121
122
123
124
125
126
127
128
129
130
131
        topic = (
            ForumTopic.objects.filter(forum__id=self.id)
            .exclude(_last_message=None)
            .order_by("-_last_message__id")
            .first()
        )
        forum = (
            Forum.objects.filter(parent__id=self.id)
            .exclude(_last_message=None)
            .order_by("-_last_message__id")
            .first()
        )
132
133
134
135
136
137
138
139
140
141
142
143
144
        if topic and forum:
            if topic._last_message_id < forum._last_message_id:
                self._last_message_id = forum._last_message_id
            else:
                self._last_message_id = topic._last_message_id
        elif topic:
            self._last_message_id = topic._last_message_id
        elif forum:
            self._last_message_id = forum._last_message_id
        self.save()
        if self.parent:
            self.parent.set_last_message()

145
146
147
148
149
150
151
152
153
154
155
156
157
158
    def apply_rights_recursively(self):
        children = self.children.all()
        for c in children:
            c.copy_rights()
            c.apply_rights_recursively()

    def copy_rights(self):
        """Copy, if possible, the rights of the parent folder"""
        if self.parent is not None:
            self.owner_club = self.parent.owner_club
            self.edit_groups = self.parent.edit_groups.all()
            self.view_groups = self.parent.view_groups.all()
            self.save()

Krophil's avatar
Krophil committed
159
    _club_memberships = {}  # This cache is particularly efficient:
Sli's avatar
Sli committed
160
161
    # divided by 3 the number of requests on the main forum page
    # after the first load
162
    def is_owned_by(self, user):
163
164
        if user.is_in_group(settings.SITH_GROUP_FORUM_ADMIN_ID):
            return True
Krophil's avatar
Krophil committed
165
166
        try:
            m = Forum._club_memberships[self.id][user.id]
167
168
        except:
            m = self.owner_club.get_membership_for(user)
Krophil's avatar
Krophil committed
169
170
            try:
                Forum._club_memberships[self.id][user.id] = m
171
172
173
            except:
                Forum._club_memberships[self.id] = {}
                Forum._club_memberships[self.id][user.id] = m
174
175
176
        if m:
            return m.role > settings.SITH_MAXIMUM_FREE_ROLE
        return False
Skia's avatar
Skia committed
177
178
179
180
181
182
183

    def check_loop(self):
        """Raise a validation error when a loop is found within the parent list"""
        objs = []
        cur = self
        while cur.parent is not None:
            if cur in objs:
Sli's avatar
Sli committed
184
                raise ValidationError(_("You can not make loops in forums"))
Skia's avatar
Skia committed
185
186
187
188
189
190
            objs.append(cur)
            cur = cur.parent

    def __str__(self):
        return "%s" % (self.name)

Sli's avatar
Sli committed
191
    def get_full_name(self):
Sli's avatar
Sli committed
192
193
194
195
196
        return "/".join(
            chain.from_iterable(
                [[parent.name for parent in self.get_parent_list()], [self.name]]
            )
        )
Sli's avatar
Sli committed
197

Skia's avatar
Skia committed
198
    def get_absolute_url(self):
Sli's avatar
Sli committed
199
        return reverse("forum:view_forum", kwargs={"forum_id": self.id})
Skia's avatar
Skia committed
200

Skia's avatar
Skia committed
201
202
203
204
    @cached_property
    def parent_list(self):
        return self.get_parent_list()

Skia's avatar
Skia committed
205
206
207
208
209
210
211
212
    def get_parent_list(self):
        l = []
        p = self.parent
        while p is not None:
            l.append(p)
            p = p.parent
        return l

213
    @property
Skia's avatar
Skia committed
214
    def topic_number(self):
215
        return self._topic_number
Skia's avatar
Skia committed
216

Skia's avatar
Skia committed
217
218
219
    def get_topic_number(self):
        number = self.topics.all().count()
        for c in self.children.all():
Skia's avatar
Skia committed
220
            number += c.topic_number
Skia's avatar
Skia committed
221
222
        return number

Skia's avatar
Skia committed
223
224
    @cached_property
    def last_message(self):
225
226
227
228
229
230
231
232
        return self._last_message

    def get_children_list(self):
        l = [self.id]
        for c in self.children.all():
            l.append(c.id)
            l += c.get_children_list()
        return l
Skia's avatar
Skia committed
233

Krophil's avatar
Krophil committed
234

Skia's avatar
Skia committed
235
class ForumTopic(models.Model):
236
237
238
239
    forum = models.ForeignKey(Forum, related_name="topics", on_delete=models.CASCADE)
    author = models.ForeignKey(
        User, related_name="forum_topics", on_delete=models.CASCADE
    )
Sli's avatar
Sli committed
240
241
242
243
244
245
246
247
248
249
250
251
    description = models.CharField(_("description"), max_length=256, default="")
    subscribed_users = models.ManyToManyField(
        User, related_name="favorite_topics", verbose_name=_("subscribed users")
    )
    _last_message = models.ForeignKey(
        "ForumMessage",
        related_name="+",
        verbose_name=_("the last message"),
        null=True,
        on_delete=models.SET_NULL,
    )
    _title = models.CharField(_("title"), max_length=64, blank=True)
252
    _message_number = models.IntegerField(_("number of messages"), default=0)
Skia's avatar
Skia committed
253
254

    class Meta:
Sli's avatar
Sli committed
255
        ordering = ["-_last_message__date"]
256
257
258

    def save(self, *args, **kwargs):
        super(ForumTopic, self).save(*args, **kwargs)
Krophil's avatar
Krophil committed
259
        self.forum.set_topic_number()  # Recompute the cached value
260
        self.forum.set_last_message()
Skia's avatar
Skia committed
261

262
    def is_owned_by(self, user):
263
        return self.forum.is_owned_by(user)
264
265
266
267
268
269
270

    def can_be_edited_by(self, user):
        return user.can_edit(self.forum)

    def can_be_viewed_by(self, user):
        return user.can_view(self.forum)

Skia's avatar
Skia committed
271
272
    def __str__(self):
        return "%s" % (self.title)
Skia's avatar
Skia committed
273
274

    def get_absolute_url(self):
Sli's avatar
Sli committed
275
        return reverse("forum:view_topic", kwargs={"topic_id": self.id})
Skia's avatar
Skia committed
276

Skia's avatar
Skia committed
277
278
    def get_first_unread_message(self, user):
        try:
Sli's avatar
Sli committed
279
280
281
282
283
284
            msg = (
                self.messages.exclude(readers=user)
                .filter(date__gte=user.forum_infos.last_read_date)
                .order_by("id")
                .first()
            )
Skia's avatar
Skia committed
285
286
287
288
            return msg
        except:
            return None

289
290
    @cached_property
    def last_message(self):
291
        return self._last_message
292

293
    @cached_property
294
    def title(self):
295
        return self._title
296

Krophil's avatar
Krophil committed
297

Skia's avatar
Skia committed
298
299
class ForumMessage(models.Model):
    """
300
    "A ForumMessage object represents a message in the forum" -- Cpt. Obvious
Skia's avatar
Skia committed
301
    """
Sli's avatar
Sli committed
302

303
304
305
306
307
308
    topic = models.ForeignKey(
        ForumTopic, related_name="messages", on_delete=models.CASCADE
    )
    author = models.ForeignKey(
        User, related_name="forum_messages", on_delete=models.CASCADE
    )
Skia's avatar
Skia committed
309
310
    title = models.CharField(_("title"), default="", max_length=64, blank=True)
    message = models.TextField(_("message"), default="")
Sli's avatar
Sli committed
311
312
313
314
315
    date = models.DateTimeField(_("date"), default=timezone.now)
    readers = models.ManyToManyField(
        User, related_name="read_messages", verbose_name=_("readers")
    )
    _deleted = models.BooleanField(_("is deleted"), default=False)
Skia's avatar
Skia committed
316
317

    class Meta:
Sli's avatar
Sli committed
318
        ordering = ["-date"]
Skia's avatar
Skia committed
319

Skia's avatar
Skia committed
320
    def __str__(self):
321
322
323
        return "%s (%s) - %s" % (self.id, self.author, self.title)

    def save(self, *args, **kwargs):
Krophil's avatar
Krophil committed
324
        self._deleted = self.is_deleted()  # Recompute the cached value
325
326
327
        super(ForumMessage, self).save(*args, **kwargs)
        if self.is_last_in_topic():
            self.topic._last_message_id = self.id
Skia's avatar
Skia committed
328
        if self.is_first_in_topic() and self.title:
329
            self.topic._title = self.title
330
331
        self.topic._message_number = self.topic.messages.count()
        self.topic.save()
332
333

    def is_first_in_topic(self):
Sli's avatar
Sli committed
334
        return bool(self.id == self.topic.messages.order_by("date").first().id)
335
336

    def is_last_in_topic(self):
Sli's avatar
Sli committed
337
        return bool(self.id == self.topic.messages.order_by("date").last().id)
Skia's avatar
Skia committed
338

Krophil's avatar
Krophil committed
339
    def is_owned_by(self, user):  # Anyone can create a topic: it's better to
Sli's avatar
Sli committed
340
        # check the rights at the forum level, since it's more controlled
Skia's avatar
Skia committed
341
        return self.topic.forum.is_owned_by(user) or user.id == self.author.id
342
343

    def can_be_edited_by(self, user):
Skia's avatar
Skia committed
344
        return user.can_edit(self.topic.forum)
345
346

    def can_be_viewed_by(self, user):
347
348
349
        # No need to check the real rights since it's already done by the Topic view
        # and it impacts performances too much
        return not self._deleted
350

Skia's avatar
Skia committed
351
    def can_be_moderated_by(self, user):
352
        return self.topic.forum.is_owned_by(user) or user.id == self.author.id
Skia's avatar
Skia committed
353

Skia's avatar
Skia committed
354
    def get_absolute_url(self):
Sli's avatar
Sli committed
355
        return reverse("forum:view_message", kwargs={"message_id": self.id})
356
357

    def get_url(self):
Sli's avatar
Sli committed
358
359
360
361
362
363
364
        return (
            self.topic.get_absolute_url()
            + "?page="
            + str(self.get_page())
            + "#msg_"
            + str(self.id)
        )
Skia's avatar
Skia committed
365
366

    def get_page(self):
Sli's avatar
Sli committed
367
368
369
370
371
372
373
        return (
            int(
                self.topic.messages.filter(id__lt=self.id).count()
                / settings.SITH_FORUM_PAGE_LENGTH
            )
            + 1
        )
Skia's avatar
Skia committed
374
375

    def mark_as_read(self, user):
Krophil's avatar
Krophil committed
376
        try:  # Need the try/except because of AnonymousUser
377
378
            if not self.is_read(user):
                self.readers.add(user)
Krophil's avatar
Krophil committed
379
380
        except:
            pass
Skia's avatar
Skia committed
381

Skia's avatar
Skia committed
382
    def is_read(self, user):
Sli's avatar
Sli committed
383
384
385
        return (self.date < user.forum_infos.last_read_date) or (
            user in self.readers.all()
        )
Skia's avatar
Skia committed
386

Skia's avatar
Skia committed
387
    def is_deleted(self):
Sli's avatar
Sli committed
388
        meta = self.metas.exclude(action="EDIT").order_by("-date").first()
Skia's avatar
Skia committed
389
390
391
392
        if meta:
            return meta.action == "DELETE"
        return False

Krophil's avatar
Krophil committed
393

Skia's avatar
Skia committed
394
MESSAGE_META_ACTIONS = [
Sli's avatar
Sli committed
395
396
397
    ("EDIT", _("Message edited by")),
    ("DELETE", _("Message deleted by")),
    ("UNDELETE", _("Message undeleted by")),
Krophil's avatar
Krophil committed
398
399
]

Skia's avatar
Skia committed
400
401

class ForumMessageMeta(models.Model):
402
403
404
405
406
407
    user = models.ForeignKey(
        User, related_name="forum_message_metas", on_delete=models.CASCADE
    )
    message = models.ForeignKey(
        ForumMessage, related_name="metas", on_delete=models.CASCADE
    )
Sli's avatar
Sli committed
408
    date = models.DateTimeField(_("date"), default=timezone.now)
Skia's avatar
Skia committed
409
410
    action = models.CharField(_("action"), choices=MESSAGE_META_ACTIONS, max_length=16)

411
412
413
414
415
416
    def save(self, *args, **kwargs):
        super(ForumMessageMeta, self).save(*args, **kwargs)
        self.message._deleted = self.message.is_deleted()
        self.message.save()


Skia's avatar
Skia committed
417
class ForumUserInfo(models.Model):
Skia's avatar
Skia committed
418
419
420
421
422
    """
    This currently stores only the last date a user clicked "Mark all as read".
    However, this can be extended with lot of user preferences dedicated to a
    user, such as the favourite topics, the signature, and so on...
    """
Sli's avatar
Sli committed
423

424
425
426
    user = models.OneToOneField(
        User, related_name="_forum_infos", on_delete=models.CASCADE
    )
Sli's avatar
Sli committed
427
428
429
430
431
432
    last_read_date = models.DateTimeField(
        _("last read date"),
        default=datetime(
            year=settings.SITH_SCHOOL_START_YEAR, month=1, day=1, tzinfo=pytz.UTC
        ),
    )
Skia's avatar
Skia committed
433

434
435
    def __str__(self):
        return str(self.user)