# # Copyright 2016,2017,2018 # - Skia # # 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)