mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 11:03:04 +00:00 
			
		
		
		
	* ruff: apply rule F * ruff: apply rule E * ruff: apply rule SIM * ruff: apply rule TCH * ruff: apply rule ERA * ruff: apply rule PLW * ruff: apply rule FLY * ruff: apply rule PERF * ruff: apply rules FURB & RUF
		
			
				
	
	
		
			445 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			445 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
#
 | 
						|
# Copyright 2016,2017,2018
 | 
						|
# - 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.
 | 
						|
#
 | 
						|
#
 | 
						|
from __future__ import annotations
 | 
						|
 | 
						|
from datetime import datetime
 | 
						|
from datetime import timezone as tz
 | 
						|
from itertools import chain
 | 
						|
from typing import Self
 | 
						|
 | 
						|
from django.conf import settings
 | 
						|
from django.core.exceptions import ValidationError
 | 
						|
from django.db import models
 | 
						|
from django.urls import reverse
 | 
						|
from django.utils import timezone
 | 
						|
from django.utils.functional import cached_property
 | 
						|
from django.utils.translation import gettext_lazy as _
 | 
						|
 | 
						|
from club.models import Club
 | 
						|
from core.models import Group, User
 | 
						|
 | 
						|
 | 
						|
# Those functions prevent generating migration upon settings changes
 | 
						|
def get_default_edit_group():
 | 
						|
    return [settings.SITH_GROUP_OLD_SUBSCRIBERS_ID]
 | 
						|
 | 
						|
 | 
						|
def get_default_view_group():
 | 
						|
    return [settings.SITH_GROUP_PUBLIC_ID]
 | 
						|
 | 
						|
 | 
						|
class Forum(models.Model):
 | 
						|
    """The Forum class, made as a tree to allow nice tidy organization.
 | 
						|
 | 
						|
    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
 | 
						|
    """
 | 
						|
 | 
						|
    id = models.AutoField(primary_key=True, db_index=True)
 | 
						|
    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,
 | 
						|
        on_delete=models.CASCADE,
 | 
						|
    )
 | 
						|
    owner_club = models.ForeignKey(
 | 
						|
        Club,
 | 
						|
        related_name="owned_forums",
 | 
						|
        verbose_name=_("owner club"),
 | 
						|
        default=settings.SITH_MAIN_CLUB_ID,
 | 
						|
        on_delete=models.CASCADE,
 | 
						|
    )
 | 
						|
    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,
 | 
						|
    )
 | 
						|
    _topic_number = models.IntegerField(_("number of topics"), default=0)
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        ordering = ["number"]
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return self.name
 | 
						|
 | 
						|
    def save(self, *args, **kwargs):
 | 
						|
        copy_rights = False
 | 
						|
        if self.id is None:
 | 
						|
            copy_rights = True
 | 
						|
        super().save(*args, **kwargs)
 | 
						|
        if copy_rights:
 | 
						|
            self.copy_rights()
 | 
						|
 | 
						|
    def get_absolute_url(self):
 | 
						|
        return reverse("forum:view_forum", kwargs={"forum_id": self.id})
 | 
						|
 | 
						|
    def clean(self):
 | 
						|
        self.check_loop()
 | 
						|
 | 
						|
    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):
 | 
						|
        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()
 | 
						|
        )
 | 
						|
        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()
 | 
						|
 | 
						|
    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.set(self.parent.edit_groups.all())
 | 
						|
            self.view_groups.set(self.parent.view_groups.all())
 | 
						|
            self.save()
 | 
						|
 | 
						|
    _club_memberships = {}  # This cache is particularly efficient:
 | 
						|
 | 
						|
    # divided by 3 the number of requests on the main forum page
 | 
						|
    # after the first load
 | 
						|
    def is_owned_by(self, user):
 | 
						|
        if user.is_anonymous:
 | 
						|
            return False
 | 
						|
        if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_FORUM_ADMIN_ID):
 | 
						|
            return True
 | 
						|
        try:
 | 
						|
            m = Forum._club_memberships[self.id][user.id]
 | 
						|
        except KeyError:
 | 
						|
            m = self.owner_club.get_membership_for(user)
 | 
						|
            try:
 | 
						|
                Forum._club_memberships[self.id][user.id] = m
 | 
						|
            except KeyError:
 | 
						|
                Forum._club_memberships[self.id] = {}
 | 
						|
                Forum._club_memberships[self.id][user.id] = m
 | 
						|
        if m:
 | 
						|
            return m.role > settings.SITH_MAXIMUM_FREE_ROLE
 | 
						|
        return False
 | 
						|
 | 
						|
    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:
 | 
						|
                raise ValidationError(_("You can not make loops in forums"))
 | 
						|
            objs.append(cur)
 | 
						|
            cur = cur.parent
 | 
						|
 | 
						|
    def get_full_name(self):
 | 
						|
        return "/".join(
 | 
						|
            chain.from_iterable(
 | 
						|
                [[parent.name for parent in self.get_parent_list()], [self.name]]
 | 
						|
            )
 | 
						|
        )
 | 
						|
 | 
						|
    @cached_property
 | 
						|
    def parent_list(self):
 | 
						|
        return self.get_parent_list()
 | 
						|
 | 
						|
    def get_parent_list(self):
 | 
						|
        parents = []
 | 
						|
        current = self.parent
 | 
						|
        while current is not None:
 | 
						|
            parents.append(current)
 | 
						|
            current = current.parent
 | 
						|
        return parents
 | 
						|
 | 
						|
    @property
 | 
						|
    def topic_number(self):
 | 
						|
        return self._topic_number
 | 
						|
 | 
						|
    def get_topic_number(self):
 | 
						|
        number = self.topics.all().count()
 | 
						|
        for c in self.children.all():
 | 
						|
            number += c.topic_number
 | 
						|
        return number
 | 
						|
 | 
						|
    @cached_property
 | 
						|
    def last_message(self):
 | 
						|
        return self._last_message
 | 
						|
 | 
						|
    def get_children_list(self) -> list[Self]:
 | 
						|
        children = [self.id]
 | 
						|
        for c in self.children.all():
 | 
						|
            children.append(c.id)
 | 
						|
            children.extend(c.get_children_list())
 | 
						|
        return children
 | 
						|
 | 
						|
 | 
						|
class ForumTopic(models.Model):
 | 
						|
    forum = models.ForeignKey(Forum, related_name="topics", on_delete=models.CASCADE)
 | 
						|
    author = models.ForeignKey(
 | 
						|
        User, related_name="forum_topics", on_delete=models.CASCADE
 | 
						|
    )
 | 
						|
    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)
 | 
						|
    _message_number = models.IntegerField(_("number of messages"), default=0)
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        ordering = ["-_last_message__date"]
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return self.title
 | 
						|
 | 
						|
    def save(self, *args, **kwargs):
 | 
						|
        super().save(*args, **kwargs)
 | 
						|
        self.forum.set_topic_number()  # Recompute the cached value
 | 
						|
        self.forum.set_last_message()
 | 
						|
 | 
						|
    def get_absolute_url(self):
 | 
						|
        return reverse("forum:view_topic", kwargs={"topic_id": self.id})
 | 
						|
 | 
						|
    def is_owned_by(self, user):
 | 
						|
        return self.forum.is_owned_by(user)
 | 
						|
 | 
						|
    def can_be_edited_by(self, user):
 | 
						|
        return user.is_root or user.can_edit(self.forum)
 | 
						|
 | 
						|
    def can_be_viewed_by(self, user):
 | 
						|
        return user.is_root or user.can_view(self.forum)
 | 
						|
 | 
						|
    def get_first_unread_message(self, user: User) -> ForumMessage | None:
 | 
						|
        if not hasattr(user, "forum_infos"):
 | 
						|
            return None
 | 
						|
        return (
 | 
						|
            self.messages.exclude(readers=user)
 | 
						|
            .filter(date__gte=user.forum_infos.last_read_date)
 | 
						|
            .order_by("id")
 | 
						|
            .first()
 | 
						|
        )
 | 
						|
 | 
						|
    @cached_property
 | 
						|
    def last_message(self):
 | 
						|
        return self._last_message
 | 
						|
 | 
						|
    @cached_property
 | 
						|
    def title(self):
 | 
						|
        return self._title
 | 
						|
 | 
						|
 | 
						|
class ForumMessage(models.Model):
 | 
						|
    """A message in the forum (thx Cpt. Obvious.)."""
 | 
						|
 | 
						|
    topic = models.ForeignKey(
 | 
						|
        ForumTopic, related_name="messages", on_delete=models.CASCADE
 | 
						|
    )
 | 
						|
    author = models.ForeignKey(
 | 
						|
        User, related_name="forum_messages", on_delete=models.CASCADE
 | 
						|
    )
 | 
						|
    title = models.CharField(_("title"), default="", max_length=64, blank=True)
 | 
						|
    message = models.TextField(_("message"), default="")
 | 
						|
    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)
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        ordering = ["-date"]
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return "%s (%s) - %s" % (self.id, self.author, self.title)
 | 
						|
 | 
						|
    def save(self, *args, **kwargs):
 | 
						|
        self._deleted = self.is_deleted()  # Recompute the cached value
 | 
						|
        super().save(*args, **kwargs)
 | 
						|
        if self.is_last_in_topic():
 | 
						|
            self.topic._last_message_id = self.id
 | 
						|
        if self.is_first_in_topic() and self.title:
 | 
						|
            self.topic._title = self.title
 | 
						|
        self.topic._message_number = self.topic.messages.count()
 | 
						|
        self.topic.save()
 | 
						|
 | 
						|
    def get_absolute_url(self):
 | 
						|
        return reverse("forum:view_message", kwargs={"message_id": self.id})
 | 
						|
 | 
						|
    def is_first_in_topic(self):
 | 
						|
        return bool(self.id == self.topic.messages.order_by("date").first().id)
 | 
						|
 | 
						|
    def is_last_in_topic(self):
 | 
						|
        return bool(self.id == self.topic.messages.order_by("date").last().id)
 | 
						|
 | 
						|
    def is_owned_by(self, user):
 | 
						|
        if user.is_anonymous:
 | 
						|
            return False
 | 
						|
        # Anyone can create a topic: it's better to
 | 
						|
        # check the rights at the forum level, since it's more controlled
 | 
						|
        return self.topic.forum.is_owned_by(user) or user.id == self.author.id
 | 
						|
 | 
						|
    def can_be_edited_by(self, user):
 | 
						|
        return user.can_edit(self.topic.forum)
 | 
						|
 | 
						|
    def can_be_viewed_by(self, user):
 | 
						|
        # 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
 | 
						|
 | 
						|
    def can_be_moderated_by(self, user):
 | 
						|
        return self.topic.forum.is_owned_by(user) or user.id == self.author_id
 | 
						|
 | 
						|
    def get_url(self):
 | 
						|
        return (
 | 
						|
            self.topic.get_absolute_url()
 | 
						|
            + "?page="
 | 
						|
            + str(self.get_page())
 | 
						|
            + "#msg_"
 | 
						|
            + str(self.id)
 | 
						|
        )
 | 
						|
 | 
						|
    def get_page(self):
 | 
						|
        return (
 | 
						|
            int(
 | 
						|
                self.topic.messages.filter(id__lt=self.id).count()
 | 
						|
                / settings.SITH_FORUM_PAGE_LENGTH
 | 
						|
            )
 | 
						|
            + 1
 | 
						|
        )
 | 
						|
 | 
						|
    def mark_as_read(self, user):
 | 
						|
        if user.is_anonymous:
 | 
						|
            return
 | 
						|
        if not self.is_read(user):
 | 
						|
            self.readers.add(user)
 | 
						|
 | 
						|
    def is_read(self, user):
 | 
						|
        return (self.date < user.forum_infos.last_read_date) or (
 | 
						|
            user in self.readers.all()
 | 
						|
        )
 | 
						|
 | 
						|
    def is_deleted(self):
 | 
						|
        if self._state.adding:
 | 
						|
            return False
 | 
						|
        meta = self.metas.exclude(action="EDIT").order_by("-date").first()
 | 
						|
        if meta:
 | 
						|
            return meta.action == "DELETE"
 | 
						|
        return False
 | 
						|
 | 
						|
 | 
						|
MESSAGE_META_ACTIONS = [
 | 
						|
    ("EDIT", _("Message edited by")),
 | 
						|
    ("DELETE", _("Message deleted by")),
 | 
						|
    ("UNDELETE", _("Message undeleted by")),
 | 
						|
]
 | 
						|
 | 
						|
 | 
						|
class ForumMessageMeta(models.Model):
 | 
						|
    user = models.ForeignKey(
 | 
						|
        User, related_name="forum_message_metas", on_delete=models.CASCADE
 | 
						|
    )
 | 
						|
    message = models.ForeignKey(
 | 
						|
        ForumMessage, related_name="metas", on_delete=models.CASCADE
 | 
						|
    )
 | 
						|
    date = models.DateTimeField(_("date"), default=timezone.now)
 | 
						|
    action = models.CharField(_("action"), choices=MESSAGE_META_ACTIONS, max_length=16)
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return f"{self.user.nick_name} ({self.date})"
 | 
						|
 | 
						|
    def save(self, *args, **kwargs):
 | 
						|
        super().save(*args, **kwargs)
 | 
						|
        self.message._deleted = self.message.is_deleted()
 | 
						|
        self.message.save()
 | 
						|
 | 
						|
 | 
						|
class ForumUserInfo(models.Model):
 | 
						|
    """The forum infos of a user.
 | 
						|
 | 
						|
    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...
 | 
						|
    """
 | 
						|
 | 
						|
    user = models.OneToOneField(
 | 
						|
        User, related_name="_forum_infos", on_delete=models.CASCADE
 | 
						|
    )
 | 
						|
    last_read_date = models.DateTimeField(
 | 
						|
        _("last read date"),
 | 
						|
        default=datetime(
 | 
						|
            year=settings.SITH_SCHOOL_START_YEAR, month=1, day=1, tzinfo=tz.utc
 | 
						|
        ),
 | 
						|
    )
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return str(self.user)
 |