models.py 13.1 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
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
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)

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]]
            )
        )
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

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
207
    def topic_number(self):
208
        return self._topic_number
209

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

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
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

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()
            )
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
328
        return self.topic.forum.is_owned_by(user) or user.id == self.author.id
329 330

    def can_be_edited_by(self, user):
331
        return user.can_edit(self.topic.forum)
332 333

    def can_be_viewed_by(self, user):
Sli's avatar
Sli committed
334 335 336
        return (
            not self._deleted
        )  # No need to check the real rights since it's already done by the Topic view
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)
        )
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

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()
        )
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):
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)