models.py 13.2 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
29
30
from django.db import models
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
    name = models.CharField(_("name"), max_length=64)
    description = models.CharField(_("description"), max_length=512, default="")
    is_category = models.BooleanField(_("is a category"), default=False)
    parent = models.ForeignKey("Forum", related_name="children", null=True, blank=True)
    owner_club = models.ForeignKey(
        Club,
        related_name="owned_forums",
        verbose_name=_("owner club"),
        default=settings.SITH_MAIN_CLUB_ID,
    )
    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,
    )
90
    _topic_number = models.IntegerField(_("number of topics"), default=0)
Skia's avatar
Skia committed
91
92

    class Meta:
Sli's avatar
Sli committed
93
        ordering = ["number"]
94

95
96
97
98
99
100
101
102
103
104
105
    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()

106
107
108
109
110
111
112
    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
113
114
115
116
117
118
119
120
121
122
123
124
        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()
        )
125
126
127
128
129
130
131
132
133
134
135
136
137
        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()

138
139
140
141
142
143
144
145
146
147
148
149
150
151
    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
152
    _club_memberships = {}  # This cache is particularly efficient:
Sli's avatar
Sli committed
153
154
    # divided by 3 the number of requests on the main forum page
    # after the first load
155
    def is_owned_by(self, user):
156
157
        if user.is_in_group(settings.SITH_GROUP_FORUM_ADMIN_ID):
            return True
Krophil's avatar
Krophil committed
158
159
        try:
            m = Forum._club_memberships[self.id][user.id]
160
161
        except:
            m = self.owner_club.get_membership_for(user)
Krophil's avatar
Krophil committed
162
163
            try:
                Forum._club_memberships[self.id][user.id] = m
164
165
166
            except:
                Forum._club_memberships[self.id] = {}
                Forum._club_memberships[self.id][user.id] = m
167
168
169
        if m:
            return m.role > settings.SITH_MAXIMUM_FREE_ROLE
        return False
Skia's avatar
Skia committed
170
171
172
173
174
175
176

    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
177
                raise ValidationError(_("You can not make loops in forums"))
Skia's avatar
Skia committed
178
179
180
181
182
183
            objs.append(cur)
            cur = cur.parent

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

Sli's avatar
Sli committed
184
    def get_full_name(self):
Sli's avatar
Sli committed
185
186
187
188
189
        return "/".join(
            chain.from_iterable(
                [[parent.name for parent in self.get_parent_list()], [self.name]]
            )
        )
Sli's avatar
Sli committed
190

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

Skia's avatar
Skia committed
194
195
196
197
    @cached_property
    def parent_list(self):
        return self.get_parent_list()

Skia's avatar
Skia committed
198
199
200
201
202
203
204
205
    def get_parent_list(self):
        l = []
        p = self.parent
        while p is not None:
            l.append(p)
            p = p.parent
        return l

206
    @property
Skia's avatar
Skia committed
207
    def topic_number(self):
208
        return self._topic_number
Skia's avatar
Skia committed
209

Skia's avatar
Skia committed
210
211
212
    def get_topic_number(self):
        number = self.topics.all().count()
        for c in self.children.all():
Skia's avatar
Skia committed
213
            number += c.topic_number
Skia's avatar
Skia committed
214
215
        return number

Skia's avatar
Skia committed
216
217
    @cached_property
    def last_message(self):
218
219
220
221
222
223
224
225
        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
226

Krophil's avatar
Krophil committed
227

Skia's avatar
Skia committed
228
class ForumTopic(models.Model):
Sli's avatar
Sli committed
229
230
231
232
233
234
235
236
237
238
239
240
241
242
    forum = models.ForeignKey(Forum, related_name="topics")
    author = models.ForeignKey(User, related_name="forum_topics")
    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)
243
    _message_number = models.IntegerField(_("number of messages"), default=0)
Skia's avatar
Skia committed
244
245

    class Meta:
Sli's avatar
Sli committed
246
        ordering = ["-_last_message__date"]
247
248
249

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

253
    def is_owned_by(self, user):
254
        return self.forum.is_owned_by(user)
255
256
257
258
259
260
261

    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
262
263
    def __str__(self):
        return "%s" % (self.title)
Skia's avatar
Skia committed
264
265

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

Skia's avatar
Skia committed
268
269
    def get_first_unread_message(self, user):
        try:
Sli's avatar
Sli committed
270
271
272
273
274
275
            msg = (
                self.messages.exclude(readers=user)
                .filter(date__gte=user.forum_infos.last_read_date)
                .order_by("id")
                .first()
            )
Skia's avatar
Skia committed
276
277
278
279
            return msg
        except:
            return None

280
281
    @cached_property
    def last_message(self):
282
        return self._last_message
283

284
    @cached_property
285
    def title(self):
286
        return self._title
287

Krophil's avatar
Krophil committed
288

Skia's avatar
Skia committed
289
290
class ForumMessage(models.Model):
    """
291
    "A ForumMessage object represents a message in the forum" -- Cpt. Obvious
Skia's avatar
Skia committed
292
    """
Sli's avatar
Sli committed
293
294
295

    topic = models.ForeignKey(ForumTopic, related_name="messages")
    author = models.ForeignKey(User, related_name="forum_messages")
Skia's avatar
Skia committed
296
297
    title = models.CharField(_("title"), default="", max_length=64, blank=True)
    message = models.TextField(_("message"), default="")
Sli's avatar
Sli committed
298
299
300
301
302
    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
303
304

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

Skia's avatar
Skia committed
307
    def __str__(self):
308
309
310
        return "%s (%s) - %s" % (self.id, self.author, self.title)

    def save(self, *args, **kwargs):
Krophil's avatar
Krophil committed
311
        self._deleted = self.is_deleted()  # Recompute the cached value
312
313
314
        super(ForumMessage, self).save(*args, **kwargs)
        if self.is_last_in_topic():
            self.topic._last_message_id = self.id
Skia's avatar
Skia committed
315
        if self.is_first_in_topic() and self.title:
316
            self.topic._title = self.title
317
318
        self.topic._message_number = self.topic.messages.count()
        self.topic.save()
319
320

    def is_first_in_topic(self):
Sli's avatar
Sli committed
321
        return bool(self.id == self.topic.messages.order_by("date").first().id)
322
323

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

Krophil's avatar
Krophil committed
326
    def is_owned_by(self, user):  # Anyone can create a topic: it's better to
Sli's avatar
Sli committed
327
        # check the rights at the forum level, since it's more controlled
Skia's avatar
Skia committed
328
        return self.topic.forum.is_owned_by(user) or user.id == self.author.id
329
330

    def can_be_edited_by(self, user):
Skia's avatar
Skia committed
331
        return user.can_edit(self.topic.forum)
332
333

    def can_be_viewed_by(self, user):
334
335
336
        # 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
337

Skia's avatar
Skia committed
338
    def can_be_moderated_by(self, user):
339
        return self.topic.forum.is_owned_by(user) or user.id == self.author.id
Skia's avatar
Skia committed
340

Skia's avatar
Skia committed
341
    def get_absolute_url(self):
Sli's avatar
Sli committed
342
        return reverse("forum:view_message", kwargs={"message_id": self.id})
343
344

    def get_url(self):
Sli's avatar
Sli committed
345
346
347
348
349
350
351
        return (
            self.topic.get_absolute_url()
            + "?page="
            + str(self.get_page())
            + "#msg_"
            + str(self.id)
        )
Skia's avatar
Skia committed
352
353

    def get_page(self):
Sli's avatar
Sli committed
354
355
356
357
358
359
360
        return (
            int(
                self.topic.messages.filter(id__lt=self.id).count()
                / settings.SITH_FORUM_PAGE_LENGTH
            )
            + 1
        )
Skia's avatar
Skia committed
361
362

    def mark_as_read(self, user):
Krophil's avatar
Krophil committed
363
        try:  # Need the try/except because of AnonymousUser
364
365
            if not self.is_read(user):
                self.readers.add(user)
Krophil's avatar
Krophil committed
366
367
        except:
            pass
Skia's avatar
Skia committed
368

Skia's avatar
Skia committed
369
    def is_read(self, user):
Sli's avatar
Sli committed
370
371
372
        return (self.date < user.forum_infos.last_read_date) or (
            user in self.readers.all()
        )
Skia's avatar
Skia committed
373

Skia's avatar
Skia committed
374
    def is_deleted(self):
Sli's avatar
Sli committed
375
        meta = self.metas.exclude(action="EDIT").order_by("-date").first()
Skia's avatar
Skia committed
376
377
378
379
        if meta:
            return meta.action == "DELETE"
        return False

Krophil's avatar
Krophil committed
380

Skia's avatar
Skia committed
381
MESSAGE_META_ACTIONS = [
Sli's avatar
Sli committed
382
383
384
    ("EDIT", _("Message edited by")),
    ("DELETE", _("Message deleted by")),
    ("UNDELETE", _("Message undeleted by")),
Krophil's avatar
Krophil committed
385
386
]

Skia's avatar
Skia committed
387
388
389
390

class ForumMessageMeta(models.Model):
    user = models.ForeignKey(User, related_name="forum_message_metas")
    message = models.ForeignKey(ForumMessage, related_name="metas")
Sli's avatar
Sli committed
391
    date = models.DateTimeField(_("date"), default=timezone.now)
Skia's avatar
Skia committed
392
393
    action = models.CharField(_("action"), choices=MESSAGE_META_ACTIONS, max_length=16)

394
395
396
397
398
399
    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
400
class ForumUserInfo(models.Model):
Skia's avatar
Skia committed
401
402
403
404
405
    """
    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
406

407
    user = models.OneToOneField(User, related_name="_forum_infos")
Sli's avatar
Sli committed
408
409
410
411
412
413
    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
414

415
416
    def __str__(self):
        return str(self.user)