import logging
from datetime import datetime, timedelta
from math import ceil

from django.conf import settings
from django.contrib.auth.models import Group, User, AnonymousUser
from django.urls import reverse
from django.db import models
from django.dispatch import receiver
from django.db.models.signals import pre_delete

from elasticsearch_dsl.field import Text, Keyword, Integer, Boolean, Float, Date

from zds.forum.managers import TopicManager, ForumManager, PostManager, TopicReadManager
from zds.forum import signals
from zds.searchv2.models import AbstractESDjangoIndexable, delete_document_in_elasticsearch, ESIndexManager
from zds.utils import get_current_user, old_slugify
from zds.utils.models import Comment, Tag

def sub_tag(tag):
    start = tag.group("start")
    end = tag.group("end")
    return "{}".format(start + end)

class ForumCategory(models.Model):
    """
    A ForumCategory is a simple container for Forums.
    There is no kind of logic in a ForumCategory. It simply here for Forum presentation in a predefined order.
    """

    class Meta:
        verbose_name = "Catégorie"
        verbose_name_plural = "Catégories"
        ordering = ["position", "title"]

    title = models.CharField("Titre", max_length=80)
    position = models.IntegerField("Position", default=0)
    # Some category slugs are forbidden due to path collisions: ForumCategory path is `/forums/<slug>` but some actions
    # on forums have path like `/forums/<action_name>`. Forbidden slugs are all top-level path in forum's `url.py`
    # module. As Categories can only be managed by superadmin, this is purely declarative and there is no control on
    slug = models.SlugField(
        max_length=80,
        unique=True,
        help_text="Ces slugs vont provoquer des conflits "
        "d'URL et sont donc interdits : notifications "
        "resolution_alerte sujet sujets message messages",
    )

    def __str__(self):
        """Textual form of a category."""
        return self.title

    def get_absolute_url(self):
        return reverse("cat-forums-list", kwargs={"slug": self.slug})

    def get_forums(self, user, with_count=False):
        """get all forums that user can access

        :param user: the related user
        :type user: User
        :param with_count: If true will preload thread and post number for each forum of this category
        :type with_count: bool
        :return: All forums in category, ordered by forum's position in category
        :rtype: list[Forum]
        """
        forums_pub = Forum.objects.get_public_forums_of_category(self, with_count=with_count)
        if user is not None and user.is_authenticated:
            forums_private = Forum.objects.get_private_forums_of_category(self, user)
            return list(forums_pub | forums_private)
        return forums_pub

class Forum(models.Model):
    """
    A Forum, containing Topics. It can be public or restricted to some groups.
    """

    class Meta:
        verbose_name = "Forum"
        verbose_name_plural = "Forums"
        ordering = ["position_in_category", "title"]

    title = models.CharField("Titre", max_length=80)
    subtitle = models.CharField("Sous-titre", max_length=200)

    # Groups authorized to read this forum. If no group is defined, the forum is public (and anyone can read it).
    groups = models.ManyToManyField(Group, verbose_name="Groupes autorisés (aucun = public)", blank=True)

    # better handling of on_delete with SET(value)?
    category = models.ForeignKey(ForumCategory, db_index=True, verbose_name="Catégorie", on_delete=models.CASCADE)
    position_in_category = models.IntegerField("Position dans la catégorie", null=True, blank=True, db_index=True)

    slug = models.SlugField(max_length=80, unique=True)
    _nb_group = None
    objects = ForumManager()

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("forum-topics-list", kwargs={"cat_slug": self.category.slug, "forum_slug": self.slug})

    def get_topic_count(self):
        """Retrieve or aggregate the number of threads in this forum. If this number already exists, it must be stored \
        in thread_count. Otherwise it will process a SQL query.

        :return: the number of threads in the forum.
        """
        try:
            return self.thread_count
        except AttributeError:
            return Topic.objects.filter(forum=self).count()

    def get_post_count(self):
        """Retrieve or aggregate the number of posts in this forum. If this number already exists, it must be stored \
        in post_count. Otherwise it will process a SQL query.

        :return: the number of posts for a forum.
        """
        try:
            return self.post_count
        except AttributeError:
            return Post.objects.filter(topic__forum=self).count()

    def get_last_message(self):
        """
        :return: the last message on the forum, if there are any.
        """
        try:
            last_post = Post.objects.select_related("topic").filter(topic__forum=self).order_by("-pubdate").all()[0]
            last_post.topic.forum = self
            return last_post
        except IndexError:
            return None

    def can_read(self, user):
        """
        Checks if a user can read current forum.
        The forum can be read if:
        - The forum has no access restriction (= no group), or
        - the user is in our database and is part of the restricted group which is needed to access this forum
        :param user: the user to check the rights
        :return: `True` if the user can read this forum, `False` otherwise.
        """

        if not self.has_group:
            return True
        else:
            # authentication is the best way to be sure groups are available in the user object
            if user is not None:
                groups = list(user.groups.all()) if not isinstance(user, AnonymousUser) else []
                return Forum.objects.filter(groups__in=groups, pk=self.pk).exists()
            else:
                return False

    @property
    def has_group(self):
        """
        Checks if this forum belongs to at least one group

        :return: ``True`` if it belongs to at least one group
        :rtype: bool
        """
        if self._nb_group is None:
            self._nb_group = self.groups.count()
        return self._nb_group > 0

class Topic(AbstractESDjangoIndexable):
    """
    A Topic is a thread of posts.
    A topic has several states, witch are all independent:
    - Solved: it was a question, and this question has been answered. The "solved" state is set at author's discretion.
    - Locked: none can write on a locked topic.
    - Sticky: sticky topics are displayed on top of topic lists (ex: on forum page).
    """

    objects_per_batch = 1000

    class Meta:
        verbose_name = "Sujet"
        verbose_name_plural = "Sujets"

    title = models.CharField("Titre", max_length=160)
    subtitle = models.CharField("Sous-titre", max_length=200, null=True, blank=True)

    forum = models.ForeignKey(Forum, verbose_name="Forum", db_index=True, on_delete=models.CASCADE)
    # on_delete anonymous?
    author = models.ForeignKey(
        User, verbose_name="Auteur", related_name="topics", db_index=True, on_delete=models.CASCADE
    )
    last_message = models.ForeignKey(
        "Post", null=True, related_name="last_message", verbose_name="Dernier message", on_delete=models.SET_NULL
    )
    pubdate = models.DateTimeField("Date de création", auto_now_add=True)
    update_index_date = models.DateTimeField(
        "Date de dernière modification pour la réindexation partielle", auto_now=True, db_index=True
    )
    solved_by = models.ForeignKey(
        User,
        verbose_name="Utilisateur ayant noté le sujet comme résolu",
        db_index=True,
        default=None,
        null=True,
        on_delete=models.SET_NULL,
    )
    is_locked = models.BooleanField("Est verrouillé", default=False, db_index=True)
    is_sticky = models.BooleanField("Est en post-it", default=False, db_index=True)
    github_issue = models.PositiveIntegerField("Ticket GitHub", null=True, blank=True)
    github_repository_name = models.CharField("Nom du dépôt GitHub", max_length=100, null=True, blank=True)

    tags = models.ManyToManyField(Tag, verbose_name="Tags du forum", blank=True, db_index=True)

    objects = TopicManager()
    _first_post = None

    def __str__(self):
        return self.title

    @property
    def is_solved(self):
        return self.solved_by is not None

    @property
    def meta_description(self):
        first_post = self.first_post()
        if len(first_post.text) < 120:
            return first_post.text
        return Topic.__remove_greetings(first_post)[: settings.ZDS_APP["forum"]["description_size"]]

    @staticmethod
    def __remove_greetings(post):
        greetings = settings.ZDS_APP["forum"]["greetings"]
        max_size = settings.ZDS_APP["forum"]["description_size"] + 1
        text = post.text
        for greeting in greetings:
            if text.strip().lower().startswith(greeting):
                index_of_dot = max(text.index("\n") if "\n" in text else -1, -1)
                index_of_dot = min(index_of_dot, text.index(".") if "." in text else max_size)
                index_of_dot = min(index_of_dot, text.index("!") if "!" in text else max_size)
                return text[index_of_dot + 1 :].strip()
        return text

    def get_absolute_url(self):
        return reverse("topic-posts-list", args=[self.pk, self.slug()])

    def slug(self):
        return old_slugify(self.title)

    def get_post_count(self):
        """
        :return: the number of posts in the topic.
        """
        return Post.objects.filter(topic__pk=self.pk).count()

    def get_last_post(self):
        """
        :return: the last post in the thread.
        """
        return self.last_mess

    def get_last_answer(self):
        """
        Gets the last answer in this tread, if any.
        Note the first post is not considered as an answer, therefore a topic with a single post (the 1st one) will
        return `None`.
        :return: the last answer in the thread, if any.
        """
        last_post = self.get_last_post()

        if last_post == self.first_post():
            return None
        else:
            return last_post

    def first_post(self):
        """
        :return: the first post of a topic, written by topic's author.
        """
        if not self._first_post:
            # we need the author prefetching as this method is widely used in templating directly or with
            # all the mess around last_answer and last_read message
            self._first_post = Post.objects.filter(topic=self).select_related("author").order_by("position").first()
            self._first_post.topic = self
        return self._first_post

    def add_tags(self, tag_collection):
        """
        Add all tags contained in `tag_collection` to this topic.
        If a tag is unknown, it is added to the system.
        :param tag_collection: A collection of tags.
        """
        for tag in filter(None, tag_collection):
            try:
                current_tag, created = Tag.objects.get_or_create(title=tag.lower().strip())
                self.tags.add(current_tag)
            except ValueError as e:
                logging.getLogger(__name__).warning(e)

        self.save()
        signals.topic_edited.send(sender=self.__class__, topic=self)

    def last_read_post(self):
        """
        Returns the last post the current user has read in this topic.
        If it has never read this topic, returns the first post.
        Used in "last read post" balloon (base.html line 91).
        :return: the last post the user has read.
        """
        try:
            return (
                TopicRead.objects.select_related()
                .filter(topic__pk=self.pk, user__pk=get_current_user().pk)
                .latest("post__position")
                .post
            )
        except TopicRead.DoesNotExist:
            return self.first_post()

    def resolve_first_post_absolute_url(self):
        return self.first_post().get_absolute_url()

    def resolve_last_read_post_absolute_url(self):
        """resolve the url that leads to the last post the current user has read. If current user is \
        anonymous, just lead to the thread start.

        :return: the url
        :rtype: str
        """
        user = get_current_user()
        try:
            pk, pos = self.resolve_last_post_pk_and_pos_read_by_user(user)
            page_nb = 1
            if pos > settings.ZDS_APP["forum"]["posts_per_page"]:
                page_nb += (pos - 1) // settings.ZDS_APP["forum"]["posts_per_page"]
            return f"{self.get_absolute_url()}?page={page_nb}#p{pk}"
        except TopicRead.DoesNotExist:
            return self.first_post().get_absolute_url()

    def resolve_last_post_pk_and_pos_read_by_user(self, user):
        """get the primary key and position of the last post the user read

        :param user: the current (authenticated) user. Please do not try with unauthenticated user, il would lead to a \
        useless request.
        :return: the primary key
        :rtype: int
        """
        t_read = (
            TopicRead.objects.select_related("post")
            .filter(topic__pk=self.pk, user__pk=user.pk)
            .latest("post__position")
        )
        if t_read:
            return t_read.post.pk, t_read.post.position
        return list(
            Post.objects.filter(topic__pk=self.pk).order_by("position").values("pk", "position").first().values()
        )

    def first_unread_post(self, user=None):
        """
        Returns the first post of this topics the current user has never read, or the first post if it has never read \
        this topic.\
        Used in notification menu.

        :return: The first unread post for this topic and this user.
        """
        try:
            if user is None:
                user = get_current_user()

            last_post = TopicRead.objects.filter(topic__pk=self.pk, user__pk=user.pk).latest("post__position").post

            next_post = (
                Post.objects.filter(topic__pk=self.pk, position__gt=last_post.position).select_related("author").first()
            )
            return next_post
        except (TopicRead.DoesNotExist, Post.DoesNotExist):
            return self.first_post()

    def antispam(self, user=None):
        """
        Check if the user is allowed to post in a topic according to the `ZDS_APP['forum']['spam_limit_seconds']` value.
        The user can always post if someone else has posted last.
        If the user is the last poster and there is less than `ZDS_APP['forum']['spam_limit_seconds']` since the last
        post, the anti-spam is active and the user cannot post.
        :param user: A user. If undefined, the current user is used.
        :return: `True` if the anti-spam is active (user can't post), `False` otherwise.
        """
        if user is None:
            user = get_current_user()

        last_user_post = Post.objects.filter(topic=self).filter(author=user.pk).order_by("position").last()

        if last_user_post and last_user_post == self.get_last_post():
            duration = datetime.now() - last_user_post.pubdate
            if duration.total_seconds() < settings.ZDS_APP["forum"]["spam_limit_seconds"]:
                return True

        return False

    def old_post_warning(self):
        """
        Check if the last message was written a long time ago according to `ZDS_APP['forum']['old_post_limit_days']`
        value.

        :return: `True` if the post is old (users are warned), `False` otherwise.
        """
        last_post = self.last_message

        if last_post is not None:
            t = last_post.pubdate + timedelta(days=settings.ZDS_APP["forum"]["old_post_limit_days"])
            if t < datetime.today():
                return True

        return False

    @property
    def is_read(self):
        return self.is_read_by_user(get_current_user())

    def is_read_by_user(self, user=None, check_auth=True):
        return TopicRead.objects.is_topic_last_message_read(self, user, check_auth)

    @classmethod
    def get_es_mapping(cls):
        es_mapping = super().get_es_mapping()

        es_mapping.field("title", Text(boost=1.5))
        es_mapping.field("tags", Text(boost=2.0))
        es_mapping.field("subtitle", Text())
        es_mapping.field("is_solved", Boolean())
        es_mapping.field("is_locked", Boolean())
        es_mapping.field("is_sticky", Boolean())
        es_mapping.field("pubdate", Date())
        es_mapping.field("forum_pk", Integer())

        es_mapping.field("get_absolute_url", Keyword(index=False))
        es_mapping.field("forum_title", Text(index=False))
        es_mapping.field("forum_get_absolute_url", Keyword(index=False))

        return es_mapping

    @classmethod
    def get_es_django_indexable(cls, force_reindexing=False):
        """Overridden to prefetch tags and forum"""

        query = super().get_es_django_indexable(force_reindexing)
        return query.prefetch_related("tags").select_related("forum")

    def get_es_document_source(self, excluded_fields=None):
        """Overridden to handle the case of tags (M2M field)"""

        excluded_fields = excluded_fields or []
        excluded_fields.extend(["tags", "forum_pk", "forum_title", "forum_get_absolute_url"])

        data = super().get_es_document_source(excluded_fields=excluded_fields)
        data["tags"] = [tag.title for tag in self.tags.all()]
        data["forum_pk"] = self.forum.pk
        data["forum_title"] = self.forum.title
        data["forum_get_absolute_url"] = self.forum.get_absolute_url()

        return data

    def save(self, *args, **kwargs):
        """Overridden to handle the displacement of the topic to another forum"""

        try:
            old_self = Topic.objects.get(pk=self.pk)
        except Topic.DoesNotExist:
            pass
        else:
            if old_self.forum.pk != self.forum.pk or old_self.title != self.title:
                Post.objects.filter(topic__pk=self.pk).update(es_flagged=True)
        return super().save(*args, **kwargs)

@receiver(pre_delete, sender=Topic)
def delete_topic_in_elasticsearch(sender, instance, **kwargs):
    """catch the pre_delete signal to ensure the deletion in ES"""
    return delete_document_in_elasticsearch(instance)

class Post(Comment, AbstractESDjangoIndexable):
    """
    A forum post written by a user.
    A post can be marked as useful: topic's author (or admin) can declare any topic as "useful", and this post is
    displayed as is on front.
    """

    objects_per_batch = 2000

    topic = models.ForeignKey(Topic, verbose_name="Sujet", db_index=True, on_delete=models.CASCADE)

    is_useful = models.BooleanField("Est utile", default=False)
    objects = PostManager()

    def __str__(self):
        return f"<Post pour '{self.topic}', #{self.pk}>"

    def get_absolute_url(self):
        """
        :return: the absolute URL for this post, including page in the topic.
        """
        page = int(ceil(float(self.position) / settings.ZDS_APP["forum"]["posts_per_page"]))

        return f"{self.topic.get_absolute_url()}?page={page}#p{self.pk}"

    def get_notification_title(self):
        return self.topic.title

    @classmethod
    def get_es_mapping(cls):
        es_mapping = super().get_es_mapping()

        es_mapping.field("text_html", Text())
        es_mapping.field("is_useful", Boolean())
        es_mapping.field("is_visible", Boolean())
        es_mapping.field("position", Integer())
        es_mapping.field("like_dislike_ratio", Float())
        es_mapping.field("pubdate", Date())
        es_mapping.field("forum_pk", Integer())
        es_mapping.field("topic_pk", Integer())

        es_mapping.field("get_absolute_url", Keyword(index=False))
        es_mapping.field("topic_title", Text(index=False))
        es_mapping.field("forum_title", Text(index=False))
        es_mapping.field("forum_get_absolute_url", Keyword(index=False))

        return es_mapping

    @classmethod
    def get_es_django_indexable(cls, force_reindexing=False):
        """Overridden to prefetch stuffs"""

        q = super().get_es_django_indexable(force_reindexing).prefetch_related("topic").prefetch_related("topic__forum")

        return q

    def get_es_document_source(self, excluded_fields=None):
        """Overridden to handle the information of the topic"""

        excluded_fields = excluded_fields or []
        excluded_fields.extend(
            ["like_dislike_ratio", "topic_title", "topic_pk", "forum_title", "forum_pk", "forum_get_absolute_url"]
        )

        data = super().get_es_document_source(excluded_fields=excluded_fields)

        data["like_dislike_ratio"] = (
            (self.like / self.dislike) if self.dislike != 0 else self.like if self.like != 0 else 1
        )

        data["topic_pk"] = self.topic.pk
        data["topic_title"] = self.topic.title

        data["forum_pk"] = self.topic.forum.pk
        data["forum_title"] = self.topic.forum.title
        data["forum_get_absolute_url"] = self.topic.forum.get_absolute_url()

        return data

    def hide_comment_by_user(self, user, text_hidden):
        """Overridden to directly hide the post in ES as well"""

        super().hide_comment_by_user(user, text_hidden)

        index_manager = ESIndexManager(**settings.ES_SEARCH_INDEX)
        index_manager.update_single_document(self, {"is_visible": False})

@receiver(pre_delete, sender=Post)
def delete_post_in_elasticsearch(sender, instance, **kwargs):
    """catch the pre_delete signal to ensure the deletion in ES"""
    return delete_document_in_elasticsearch(instance)

class TopicRead(models.Model):
    """
    This model tracks the last post read in a topic by a user.
    Technically it is a simple joint [user, topic, last read post].
    """

    class Meta:
        verbose_name = "Sujet lu"
        verbose_name_plural = "Sujets lus"
        unique_together = ("topic", "user")

    topic = models.ForeignKey(Topic, db_index=True, on_delete=models.CASCADE)
    post = models.ForeignKey(Post, db_index=True, on_delete=models.CASCADE)
    user = models.ForeignKey(User, related_name="topics_read", db_index=True, on_delete=models.CASCADE)
    objects = TopicReadManager()

    def __str__(self):
        return f"<Sujet '{self.topic}' lu par {self.user}, #{self.post.pk}>"

def mark_read(topic, user=None):
    """
    Mark the last message of a topic as read for the current user.
    :param topic: A topic.
    """
    if not user:
        user = get_current_user()

    if user and user.is_authenticated:
        current_topic_read = TopicRead.objects.filter(topic=topic, user=user).first()
        if current_topic_read is None:
            current_topic_read = TopicRead(post=topic.last_message, topic=topic, user=user)
        else:
            current_topic_read.post = topic.last_message
        current_topic_read.save()
        signals.topic_read.send(sender=topic.__class__, instance=topic, user=user)


        query = super().get_es_django_indexable(force_reindexing)
        return query.prefetch_related("tags").select_related("forum")

import logging
from datetime import datetime, timedelta
from math import ceil

from django.conf import settings
from django.contrib.auth.models import Group, User, AnonymousUser
from django.urls import reverse
from django.db import models
from django.dispatch import receiver
from django.db.models.signals import pre_delete

from elasticsearch_dsl.field import Text, Keyword, Integer, Boolean, Float, Date

from zds.forum.managers import TopicManager, ForumManager, PostManager, TopicReadManager
from zds.forum import signals
from zds.searchv2.models import AbstractESDjangoIndexable, delete_document_in_elasticsearch, ESIndexManager
from zds.utils import get_current_user, old_slugify
from zds.utils.models import Comment, Tag

def sub_tag(tag):
    start = tag.group("start")
    end = tag.group("end")
    return "{}".format(start + end)

class ForumCategory(models.Model):
    """
    A ForumCategory is a simple container for Forums.
    There is no kind of logic in a ForumCategory. It simply here for Forum presentation in a predefined order.
    """

    class Meta:
        verbose_name = "Catégorie"
        verbose_name_plural = "Catégories"
        ordering = ["position", "title"]

    title = models.CharField("Titre", max_length=80)
    position = models.IntegerField("Position", default=0)
    # Some category slugs are forbidden due to path collisions: ForumCategory path is `/forums/<slug>` but some actions
    # on forums have path like `/forums/<action_name>`. Forbidden slugs are all top-level path in forum's `url.py`
    # module. As Categories can only be managed by superadmin, this is purely declarative and there is no control on
    slug = models.SlugField(
        max_length=80,
        unique=True,
        help_text="Ces slugs vont provoquer des conflits "
        "d'URL et sont donc interdits : notifications "
        "resolution_alerte sujet sujets message messages",
    )

    def __str__(self):
        """Textual form of a category."""
        return self.title

    def get_absolute_url(self):
        return reverse("cat-forums-list", kwargs={"slug": self.slug})

    def get_forums(self, user, with_count=False):
        """get all forums that user can access

        :param user: the related user
        :type user: User
        :param with_count: If true will preload thread and post number for each forum of this category
        :type with_count: bool
        :return: All forums in category, ordered by forum's position in category
        :rtype: list[Forum]
        """
        forums_pub = Forum.objects.get_public_forums_of_category(self, with_count=with_count)
        if user is not None and user.is_authenticated:
            forums_private = Forum.objects.get_private_forums_of_category(self, user)
            return list(forums_pub | forums_private)
        return forums_pub

class Forum(models.Model):
    """
    A Forum, containing Topics. It can be public or restricted to some groups.
    """

    class Meta:
        verbose_name = "Forum"
        verbose_name_plural = "Forums"
        ordering = ["position_in_category", "title"]

    title = models.CharField("Titre", max_length=80)
    subtitle = models.CharField("Sous-titre", max_length=200)

    # Groups authorized to read this forum. If no group is defined, the forum is public (and anyone can read it).
    groups = models.ManyToManyField(Group, verbose_name="Groupes autorisés (aucun = public)", blank=True)

    # better handling of on_delete with SET(value)?
    category = models.ForeignKey(ForumCategory, db_index=True, verbose_name="Catégorie", on_delete=models.CASCADE)
    position_in_category = models.IntegerField("Position dans la catégorie", null=True, blank=True, db_index=True)

    slug = models.SlugField(max_length=80, unique=True)
    _nb_group = None
    objects = ForumManager()

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("forum-topics-list", kwargs={"cat_slug": self.category.slug, "forum_slug": self.slug})

    def get_topic_count(self):
        """Retrieve or aggregate the number of threads in this forum. If this number already exists, it must be stored \
        in thread_count. Otherwise it will process a SQL query.

        :return: the number of threads in the forum.
        """
        try:
            return self.thread_count
        except AttributeError:
            return Topic.objects.filter(forum=self).count()

    def get_post_count(self):
        """Retrieve or aggregate the number of posts in this forum. If this number already exists, it must be stored \
        in post_count. Otherwise it will process a SQL query.

        :return: the number of posts for a forum.
        """
        try:
            return self.post_count
        except AttributeError:
            return Post.objects.filter(topic__forum=self).count()

    def get_last_message(self):
        """
        :return: the last message on the forum, if there are any.
        """
        try:
            last_post = Post.objects.select_related("topic").filter(topic__forum=self).order_by("-pubdate").all()[0]
            last_post.topic.forum = self
            return last_post
        except IndexError:
            return None

    def can_read(self, user):
        """
        Checks if a user can read current forum.
        The forum can be read if:
        - The forum has no access restriction (= no group), or
        - the user is in our database and is part of the restricted group which is needed to access this forum
        :param user: the user to check the rights
        :return: `True` if the user can read this forum, `False` otherwise.
        """

        if not self.has_group:
            return True
        else:
            # authentication is the best way to be sure groups are available in the user object
            if user is not None:
                groups = list(user.groups.all()) if not isinstance(user, AnonymousUser) else []
                return Forum.objects.filter(groups__in=groups, pk=self.pk).exists()
            else:
                return False

    @property
    def has_group(self):
        """
        Checks if this forum belongs to at least one group

        :return: ``True`` if it belongs to at least one group
        :rtype: bool
        """
        if self._nb_group is None:
            self._nb_group = self.groups.count()
        return self._nb_group > 0

class Topic(AbstractESDjangoIndexable):
    """
    A Topic is a thread of posts.
    A topic has several states, witch are all independent:
    - Solved: it was a question, and this question has been answered. The "solved" state is set at author's discretion.
    - Locked: none can write on a locked topic.
    - Sticky: sticky topics are displayed on top of topic lists (ex: on forum page).
    """

    objects_per_batch = 1000

    class Meta:
        verbose_name = "Sujet"
        verbose_name_plural = "Sujets"

    title = models.CharField("Titre", max_length=160)
    subtitle = models.CharField("Sous-titre", max_length=200, null=True, blank=True)

    forum = models.ForeignKey(Forum, verbose_name="Forum", db_index=True, on_delete=models.CASCADE)
    # on_delete anonymous?
    author = models.ForeignKey(
        User, verbose_name="Auteur", related_name="topics", db_index=True, on_delete=models.CASCADE
    )
    last_message = models.ForeignKey(
        "Post", null=True, related_name="last_message", verbose_name="Dernier message", on_delete=models.SET_NULL
    )
    pubdate = models.DateTimeField("Date de création", auto_now_add=True)
    update_index_date = models.DateTimeField(
        "Date de dernière modification pour la réindexation partielle", auto_now=True, db_index=True
    )
    solved_by = models.ForeignKey(
        User,
        verbose_name="Utilisateur ayant noté le sujet comme résolu",
        db_index=True,
        default=None,
        null=True,
        on_delete=models.SET_NULL,
    )
    is_locked = models.BooleanField("Est verrouillé", default=False, db_index=True)
    is_sticky = models.BooleanField("Est en post-it", default=False, db_index=True)
    github_issue = models.PositiveIntegerField("Ticket GitHub", null=True, blank=True)
    github_repository_name = models.CharField("Nom du dépôt GitHub", max_length=100, null=True, blank=True)

    tags = models.ManyToManyField(Tag, verbose_name="Tags du forum", blank=True, db_index=True)

    objects = TopicManager()
    _first_post = None

    def __str__(self):
        return self.title

    @property
    def is_solved(self):
        return self.solved_by is not None

    @property
    def meta_description(self):
        first_post = self.first_post()
        if len(first_post.text) < 120:
            return first_post.text
        return Topic.__remove_greetings(first_post)[: settings.ZDS_APP["forum"]["description_size"]]

    @staticmethod
    def __remove_greetings(post):
        greetings = settings.ZDS_APP["forum"]["greetings"]
        max_size = settings.ZDS_APP["forum"]["description_size"] + 1
        text = post.text
        for greeting in greetings:
            if text.strip().lower().startswith(greeting):
                index_of_dot = max(text.index("\n") if "\n" in text else -1, -1)
                index_of_dot = min(index_of_dot, text.index(".") if "." in text else max_size)
                index_of_dot = min(index_of_dot, text.index("!") if "!" in text else max_size)
                return text[index_of_dot + 1 :].strip()
        return text

    def get_absolute_url(self):
        return reverse("topic-posts-list", args=[self.pk, self.slug()])

    def slug(self):
        return old_slugify(self.title)

    def get_post_count(self):
        """
        :return: the number of posts in the topic.
        """
        return Post.objects.filter(topic__pk=self.pk).count()

    def get_last_post(self):
        """
        :return: the last post in the thread.
        """
        return self.last_message

    def get_last_answer(self):
        """
        Gets the last answer in this tread, if any.
        Note the first post is not considered as an answer, therefore a topic with a single post (the 1st one) will
        return `None`.
        :return: the last answer in the thread, if any.
        """
        last_post = self.get_last_post()

        if last_post == self.first_post():
            return None
        else:
            return last_post

    def first_post(self):
        """
        :return: the first post of a topic, written by topic's author.
        """
        if not self._first_post:
            # we need the author prefetching as this method is widely used in templating directly or with
            # all the mess around last_answer and last_read message
            self._first_post = Post.objects.filter(topic=self).select_related("author").order_by("position").first()
            self._first_post.topic = self
        return self._first_post

    def add_tags(self, tag_collection):
        """
        Add all tags contained in `tag_collection` to this topic.
        If a tag is unknown, it is added to the system.
        :param tag_collection: A collection of tags.
        """
        for tag in filter(None, tag_collection):
            try:
                current_tag, created = Tag.objects.get_or_create(title=tag.lower().strip())
                self.tags.add(current_tag)
            except ValueError as e:
                logging.getLogger(__name__).warning(e)

        self.save()
        signals.topic_edited.send(sender=self.__class__, topic=self)

    def last_read_post(self):
        """
        Returns the last post the current user has read in this topic.
        If it has never read this topic, returns the first post.
        Used in "last read post" balloon (base.html line 91).
        :return: the last post the user has read.
        """
        try:
            return (
                TopicRead.objects.select_related()
                .filter(topic__pk=self.pk, user__pk=get_current_user().pk)
                .latest("post__position")
                .post
            )
        except TopicRead.DoesNotExist:
            return self.first_post()

    def resolve_first_post_absolute_url(self):
        return self.first_post().get_absolute_url()

    def resolve_last_read_post_absolute_url(self):
        """resolve the url that leads to the last post the current user has read. If current user is \
        anonymous, just lead to the thread start.

        :return: the url
        :rtype: str
        """
        user = get_current_user()
        try:
            pk, pos = self.resolve_last_post_pk_and_pos_read_by_user(user)
            page_nb = 1
            if pos > settings.ZDS_APP["forum"]["posts_per_page"]:
                page_nb += (pos - 1) // settings.ZDS_APP["forum"]["posts_per_page"]
            return f"{self.get_absolute_url()}?page={page_nb}#p{pk}"
        except TopicRead.DoesNotExist:
            return self.first_post().get_absolute_url()

    def resolve_last_post_pk_and_pos_read_by_user(self, user):
        """get the primary key and position of the last post the user read

        :param user: the current (authenticated) user. Please do not try with unauthenticated user, il would lead to a \
        useless request.
        :return: the primary key
        :rtype: int
        """
        t_read = (
            TopicRead.objects.select_related("post")
            .filter(topic__pk=self.pk, user__pk=user.pk)
            .latest("post__position")
        )
        if t_read:
            return t_read.post.pk, t_read.post.position
        return list(
            Post.objects.filter(topic__pk=self.pk).order_by("position").values("pk", "position").first().values()
        )

    def first_unread_post(self, user=None):
        """
        Returns the first post of this topics the current user has never read, or the first post if it has never read \
        this topic.\
        Used in notification menu.

        :return: The first unread post for this topic and this user.
        """
        try:
            if user is None:
                user = get_current_user()

            last_post = TopicRead.objects.filter(topic__pk=self.pk, user__pk=user.pk).latest("post__position").post

            next_post = (
                Post.objects.filter(topic__pk=self.pk, position__gt=last_post.position).select_related("author").first()
            )
            return next_post
        except (TopicRead.DoesNotExist, Post.DoesNotExist):
            return self.first_post()

    def antispam(self, user=None):
        """
        Check if the user is allowed to post in a topic according to the `ZDS_APP['forum']['spam_limit_seconds']` value.
        The user can always post if someone else has posted last.
        If the user is the last poster and there is less than `ZDS_APP['forum']['spam_limit_seconds']` since the last
        post, the anti-spam is active and the user cannot post.
        :param user: A user. If undefined, the current user is used.
        :return: `True` if the anti-spam is active (user can't post), `False` otherwise.
        """
        if user is None:
            user = get_current_user()

        last_user_post = Post.objects.filter(topic=self).filter(author=user.pk).order_by("position").last()

        if last_user_post and last_user_post == self.get_last_post():
            duration = datetime.now() - last_user_post.pubdate
            if duration.total_seconds() < settings.ZDS_APP["forum"]["spam_limit_seconds"]:
                return True

        return False

    def old_post_warning(self):
        """
        Check if the last message was written a long time ago according to `ZDS_APP['forum']['old_post_limit_days']`
        value.

        :return: `True` if the post is old (users are warned), `False` otherwise.
        """
        last_post = self.last_message

        if last_post is not None:
            t = last_post.pubdate + timedelta(days=settings.ZDS_APP["forum"]["old_post_limit_days"])
            if t < datetime.today():
                return True

        return False

    @property
    def is_read(self):
        return self.is_read_by_user(get_current_user())

    def is_read_by_user(self, user=None, check_auth=True):
        return TopicRead.objects.is_topic_last_message_read(self, user, check_auth)

    @classmethod
    def get_es_mapping(cls):
        es_mapping = super().get_es_mapping()

        es_mapping.field("title", Text(boost=1.5))
        es_mapping.field("tags", Text(boost=2.0))
        es_mapping.field("subtitle", Text())
        es_mapping.field("is_solved", Boolean())
        es_mapping.field("is_locked", Boolean())
        es_mapping.field("is_sticky", Boolean())
        es_mapping.field("pubdate", Date())
        es_mapping.field("forum_pk", Integer())

        es_mapping.field("get_absolute_url", Keyword(index=False))
        es_mapping.field("forum_title", Text(index=False))
        es_mapping.field("forum_get_absolute_url", Keyword(index=False))

        return es_mapping

    @classmethod
    def get_es_django_indexable(cls, force_reindexing=False):
        """Overridden to prefetch tags and forum"""

    def get_es_document_source(self, excluded_fields=None):
        """Overridden to handle the case of tags (M2M field)"""

        excluded_fields = excluded_fields or []
        excluded_fields.extend(["tags", "forum_pk", "forum_title", "forum_get_absolute_url"])

        data = super().get_es_document_source(excluded_fields=excluded_fields)
        data["tags"] = [tag.title for tag in self.tags.all()]
        data["forum_pk"] = self.forum.pk
        data["forum_title"] = self.forum.title
        data["forum_get_absolute_url"] = self.forum.get_absolute_url()

        return data

    def save(self, *args, **kwargs):
        """Overridden to handle the displacement of the topic to another forum"""

        try:
            old_self = Topic.objects.get(pk=self.pk)
        except Topic.DoesNotExist:
            pass
        else:
            if old_self.forum.pk != self.forum.pk or old_self.title != self.title:
                Post.objects.filter(topic__pk=self.pk).update(es_flagged=True)
        return super().save(*args, **kwargs)

@receiver(pre_delete, sender=Topic)
def delete_topic_in_elasticsearch(sender, instance, **kwargs):
    """catch the pre_delete signal to ensure the deletion in ES"""
    return delete_document_in_elasticsearch(instance)

class Post(Comment, AbstractESDjangoIndexable):
    """
    A forum post written by a user.
    A post can be marked as useful: topic's author (or admin) can declare any topic as "useful", and this post is
    displayed as is on front.
    """

    objects_per_batch = 2000

    topic = models.ForeignKey(Topic, verbose_name="Sujet", db_index=True, on_delete=models.CASCADE)

    is_useful = models.BooleanField("Est utile", default=False)
    objects = PostManager()

    def __str__(self):
        return f"<Post pour '{self.topic}', #{self.pk}>"

    def get_absolute_url(self):
        """
        :return: the absolute URL for this post, including page in the topic.
        """
        page = int(ceil(float(self.position) / settings.ZDS_APP["forum"]["posts_per_page"]))

        return f"{self.topic.get_absolute_url()}?page={page}#p{self.pk}"

    def get_notification_title(self):
        return self.topic.title

    @classmethod
    def get_es_mapping(cls):
        es_mapping = super().get_es_mapping()

        es_mapping.field("text_html", Text())
        es_mapping.field("is_useful", Boolean())
        es_mapping.field("is_visible", Boolean())
        es_mapping.field("position", Integer())
        es_mapping.field("like_dislike_ratio", Float())
        es_mapping.field("pubdate", Date())
        es_mapping.field("forum_pk", Integer())
        es_mapping.field("topic_pk", Integer())

        es_mapping.field("get_absolute_url", Keyword(index=False))
        es_mapping.field("topic_title", Text(index=False))
        es_mapping.field("forum_title", Text(index=False))
        es_mapping.field("forum_get_absolute_url", Keyword(index=False))

        return es_mapping

    @classmethod
    def get_es_django_indexable(cls, force_reindexing=False):
        """Overridden to prefetch stuffs"""

        q = super().get_es_django_indexable(force_reindexing).prefetch_related("topic").prefetch_related("topic__forum")

        return q

    def get_es_document_source(self, excluded_fields=None):
        """Overridden to handle the information of the topic"""

        excluded_fields = excluded_fields or []
        excluded_fields.extend(
            ["like_dislike_ratio", "topic_title", "topic_pk", "forum_title", "forum_pk", "forum_get_absolute_url"]
        )

        data = super().get_es_document_source(excluded_fields=excluded_fields)

        data["like_dislike_ratio"] = (
            (self.like / self.dislike) if self.dislike != 0 else self.like if self.like != 0 else 1
        )

        data["topic_pk"] = self.topic.pk
        data["topic_title"] = self.topic.title

        data["forum_pk"] = self.topic.forum.pk
        data["forum_title"] = self.topic.forum.title
        data["forum_get_absolute_url"] = self.topic.forum.get_absolute_url()

        return data

    def hide_comment_by_user(self, user, text_hidden):
        """Overridden to directly hide the post in ES as well"""

        super().hide_comment_by_user(user, text_hidden)

        index_manager = ESIndexManager(**settings.ES_SEARCH_INDEX)
        index_manager.update_single_document(self, {"is_visible": False})

@receiver(pre_delete, sender=Post)
def delete_post_in_elasticsearch(sender, instance, **kwargs):
    """catch the pre_delete signal to ensure the deletion in ES"""
    return delete_document_in_elasticsearch(instance)

class TopicRead(models.Model):
    """
    This model tracks the last post read in a topic by a user.
    Technically it is a simple joint [user, topic, last read post].
    """

    class Meta:
        verbose_name = "Sujet lu"
        verbose_name_plural = "Sujets lus"
        unique_together = ("topic", "user")

    topic = models.ForeignKey(Topic, db_index=True, on_delete=models.CASCADE)
    post = models.ForeignKey(Post, db_index=True, on_delete=models.CASCADE)
    user = models.ForeignKey(User, related_name="topics_read", db_index=True, on_delete=models.CASCADE)
    objects = TopicReadManager()

    def __str__(self):
        return f"<Sujet '{self.topic}' lu par {self.user}, #{self.post.pk}>"

def mark_read(topic, user=None):
    """
    Mark the last message of a topic as read for the current user.
    :param topic: A topic.
    """
    if not user:
        user = get_current_user()

    if user and user.is_authenticated:
        current_topic_read = TopicRead.objects.filter(topic=topic, user=user).first()
        if current_topic_read is None:
            current_topic_read = TopicRead(post=topic.last_message, topic=topic, user=user)
        else:
            current_topic_read.post = topic.last_message
        current_topic_read.save()
        signals.topic_read.send(sender=topic.__class__, instance=topic, user=user)


try:
                current_tag, created = Tag.objects.get_or_create(title=tag.lower().strip())
                self.tags.add(current_tag)
            except ValueError as e:
                logging.getLogger(__name__).warning(e)

import logging
from datetime import datetime, timedelta
from math import ceil

from django.conf import settings
from django.contrib.auth.models import Group, User, AnonymousUser
from django.urls import reverse
from django.db import models
from django.dispatch import receiver
from django.db.models.signals import pre_delete

from elasticsearch_dsl.field import Text, Keyword, Integer, Boolean, Float, Date

from zds.forum.managers import TopicManager, ForumManager, PostManager, TopicReadManager
from zds.forum import signals
from zds.searchv2.models import AbstractESDjangoIndexable, delete_document_in_elasticsearch, ESIndexManager
from zds.utils import get_current_user, old_slugify
from zds.utils.models import Comment, Tag

def sub_tag(tag):
    start = tag.group("start")
    end = tag.group("end")
    return "{}".format(start + end)

class ForumCategory(models.Model):
    """
    A ForumCategory is a simple container for Forums.
    There is no kind of logic in a ForumCategory. It simply here for Forum presentation in a predefined order.
    """

    class Meta:
        verbose_name = "Catégorie"
        verbose_name_plural = "Catégories"
        ordering = ["position", "title"]

    title = models.CharField("Titre", max_length=80)
    position = models.IntegerField("Position", default=0)
    # Some category slugs are forbidden due to path collisions: ForumCategory path is `/forums/<slug>` but some actions
    # on forums have path like `/forums/<action_name>`. Forbidden slugs are all top-level path in forum's `url.py`
    # module. As Categories can only be managed by superadmin, this is purely declarative and there is no control on
    slug = models.SlugField(
        max_length=80,
        unique=True,
        help_text="Ces slugs vont provoquer des conflits "
        "d'URL et sont donc interdits : notifications "
        "resolution_alerte sujet sujets message messages",
    )

    def __str__(self):
        """Textual form of a category."""
        return self.title

    def get_absolute_url(self):
        return reverse("cat-forums-list", kwargs={"slug": self.slug})

    def get_forums(self, user, with_count=False):
        """get all forums that user can access

        :param user: the related user
        :type user: User
        :param with_count: If true will preload thread and post number for each forum of this category
        :type with_count: bool
        :return: All forums in category, ordered by forum's position in category
        :rtype: list[Forum]
        """
        forums_pub = Forum.objects.get_public_forums_of_category(self, with_count=with_count)
        if user is not None and user.is_authenticated:
            forums_private = Forum.objects.get_private_forums_of_category(self, user)
            return list(forums_pub | forums_private)
        return forums_pub

class Forum(models.Model):
    """
    A Forum, containing Topics. It can be public or restricted to some groups.
    """

    class Meta:
        verbose_name = "Forum"
        verbose_name_plural = "Forums"
        ordering = ["position_in_category", "title"]

    title = models.CharField("Titre", max_length=80)
    subtitle = models.CharField("Sous-titre", max_length=200)

    # Groups authorized to read this forum. If no group is defined, the forum is public (and anyone can read it).
    groups = models.ManyToManyField(Group, verbose_name="Groupes autorisés (aucun = public)", blank=True)

    # better handling of on_delete with SET(value)?
    category = models.ForeignKey(ForumCategory, db_index=True, verbose_name="Catégorie", on_delete=models.CASCADE)
    position_in_category = models.IntegerField("Position dans la catégorie", null=True, blank=True, db_index=True)

    slug = models.SlugField(max_length=80, unique=True)
    _nb_group = None
    objects = ForumManager()

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("forum-topics-list", kwargs={"cat_slug": self.category.slug, "forum_slug": self.slug})

    def get_topic_count(self):
        """Retrieve or aggregate the number of threads in this forum. If this number already exists, it must be stored \
        in thread_count. Otherwise it will process a SQL query.

        :return: the number of threads in the forum.
        """
        try:
            return self.thread_count
        except AttributeError:
            return Topic.objects.filter(forum=self).count()

    def get_post_count(self):
        """Retrieve or aggregate the number of posts in this forum. If this number already exists, it must be stored \
        in post_count. Otherwise it will process a SQL query.

        :return: the number of posts for a forum.
        """
        try:
            return self.post_count
        except AttributeError:
            return Post.objects.filter(topic__forum=self).count()

    def get_last_message(self):
        """
        :return: the last message on the forum, if there are any.
        """
        try:
            last_post = Post.objects.select_related("topic").filter(topic__forum=self).order_by("-pubdate").all()[0]
            last_post.topic.forum = self
            return last_post
        except IndexError:
            return None

    def can_read(self, user):
        """
        Checks if a user can read current forum.
        The forum can be read if:
        - The forum has no access restriction (= no group), or
        - the user is in our database and is part of the restricted group which is needed to access this forum
        :param user: the user to check the rights
        :return: `True` if the user can read this forum, `False` otherwise.
        """

        if not self.has_group:
            return True
        else:
            # authentication is the best way to be sure groups are available in the user object
            if user is not None:
                groups = list(user.groups.all()) if not isinstance(user, AnonymousUser) else []
                return Forum.objects.filter(groups__in=groups, pk=self.pk).exists()
            else:
                return False

    @property
    def has_group(self):
        """
        Checks if this forum belongs to at least one group

        :return: ``True`` if it belongs to at least one group
        :rtype: bool
        """
        if self._nb_group is None:
            self._nb_group = self.groups.count()
        return self._nb_group > 0

class Topic(AbstractESDjangoIndexable):
    """
    A Topic is a thread of posts.
    A topic has several states, witch are all independent:
    - Solved: it was a question, and this question has been answered. The "solved" state is set at author's discretion.
    - Locked: none can write on a locked topic.
    - Sticky: sticky topics are displayed on top of topic lists (ex: on forum page).
    """

    objects_per_batch = 1000

    class Meta:
        verbose_name = "Sujet"
        verbose_name_plural = "Sujets"

    title = models.CharField("Titre", max_length=160)
    subtitle = models.CharField("Sous-titre", max_length=200, null=True, blank=True)

    forum = models.ForeignKey(Forum, verbose_name="Forum", db_index=True, on_delete=models.CASCADE)
    # on_delete anonymous?
    author = models.ForeignKey(
        User, verbose_name="Auteur", related_name="topics", db_index=True, on_delete=models.CASCADE
    )
    last_message = models.ForeignKey(
        "Post", null=True, related_name="last_message", verbose_name="Dernier message", on_delete=models.SET_NULL
    )
    pubdate = models.DateTimeField("Date de création", auto_now_add=True)
    update_index_date = models.DateTimeField(
        "Date de dernière modification pour la réindexation partielle", auto_now=True, db_index=True
    )
    solved_by = models.ForeignKey(
        User,
        verbose_name="Utilisateur ayant noté le sujet comme résolu",
        db_index=True,
        default=None,
        null=True,
        on_delete=models.SET_NULL,
    )
    is_locked = models.BooleanField("Est verrouillé", default=False, db_index=True)
    is_sticky = models.BooleanField("Est en post-it", default=False, db_index=True)
    github_issue = models.PositiveIntegerField("Ticket GitHub", null=True, blank=True)
    github_repository_name = models.CharField("Nom du dépôt GitHub", max_length=100, null=True, blank=True)

    tags = models.ManyToManyField(Tag, verbose_name="Tags du forum", blank=True, db_index=True)

    objects = TopicManager()
    _first_post = None

    def __str__(self):
        return self.title

    @property
    def is_solved(self):
        return self.solved_by is not None

    @property
    def meta_description(self):
        first_post = self.first_post()
        if len(first_post.text) < 120:
            return first_post.text
        return Topic.__remove_greetings(first_post)[: settings.ZDS_APP["forum"]["description_size"]]

    @staticmethod
    def __remove_greetings(post):
        greetings = settings.ZDS_APP["forum"]["greetings"]
        max_size = settings.ZDS_APP["forum"]["description_size"] + 1
        text = post.text
        for greeting in greetings:
            if text.strip().lower().startswith(greeting):
                index_of_dot = max(text.index("\n") if "\n" in text else -1, -1)
                index_of_dot = min(index_of_dot, text.index(".") if "." in text else max_size)
                index_of_dot = min(index_of_dot, text.index("!") if "!" in text else max_size)
                return text[index_of_dot + 1 :].strip()
        return text

    def get_absolute_url(self):
        return reverse("topic-posts-list", args=[self.pk, self.slug()])

    def slug(self):
        return old_slugify(self.title)

    def get_post_count(self):
        """
        :return: the number of posts in the topic.
        """
        return Post.objects.filter(topic__pk=self.pk).count()

    def get_last_post(self):
        """
        :return: the last post in the thread.
        """
        return self.last_message

    def get_last_answer(self):
        """
        Gets the last answer in this tread, if any.
        Note the first post is not considered as an answer, therefore a topic with a single post (the 1st one) will
        return `None`.
        :return: the last answer in the thread, if any.
        """
        last_post = self.get_last_post()

        if last_post == self.first_post():
            return None
        else:
            return last_post

    def first_post(self):
        """
        :return: the first post of a topic, written by topic's author.
        """
        if not self._first_post:
            # we need the author prefetching as this method is widely used in templating directly or with
            # all the mess around last_answer and last_read message
            self._first_post = Post.objects.filter(topic=self).select_related("author").order_by("position").first()
            self._first_post.topic = self
        return self._first_post

    def add_tags(self, tag_collection):
        """
        Add all tags contained in `tag_collection` to this topic.
        If a tag is unknown, it is added to the system.
        :param tag_collection: A collection of tags.
        """
        for tag in filter(None, tag_collection):

        self.save()
        signals.topic_edited.send(sender=self.__class__, topic=self)

    def last_read_post(self):
        """
        Returns the last post the current user has read in this topic.
        If it has never read this topic, returns the first post.
        Used in "last read post" balloon (base.html line 91).
        :return: the last post the user has read.
        """
        try:
            return (
                TopicRead.objects.select_related()
                .filter(topic__pk=self.pk, user__pk=get_current_user().pk)
                .latest("post__position")
                .post
            )
        except TopicRead.DoesNotExist:
            return self.first_post()

    def resolve_first_post_absolute_url(self):
        return self.first_post().get_absolute_url()

    def resolve_last_read_post_absolute_url(self):
        """resolve the url that leads to the last post the current user has read. If current user is \
        anonymous, just lead to the thread start.

        :return: the url
        :rtype: str
        """
        user = get_current_user()
        try:
            pk, pos = self.resolve_last_post_pk_and_pos_read_by_user(user)
            page_nb = 1
            if pos > settings.ZDS_APP["forum"]["posts_per_page"]:
                page_nb += (pos - 1) // settings.ZDS_APP["forum"]["posts_per_page"]
            return f"{self.get_absolute_url()}?page={page_nb}#p{pk}"
        except TopicRead.DoesNotExist:
            return self.first_post().get_absolute_url()

    def resolve_last_post_pk_and_pos_read_by_user(self, user):
        """get the primary key and position of the last post the user read

        :param user: the current (authenticated) user. Please do not try with unauthenticated user, il would lead to a \
        useless request.
        :return: the primary key
        :rtype: int
        """
        t_read = (
            TopicRead.objects.select_related("post")
            .filter(topic__pk=self.pk, user__pk=user.pk)
            .latest("post__position")
        )
        if t_read:
            return t_read.post.pk, t_read.post.position
        return list(
            Post.objects.filter(topic__pk=self.pk).order_by("position").values("pk", "position").first().values()
        )

    def first_unread_post(self, user=None):
        """
        Returns the first post of this topics the current user has never read, or the first post if it has never read \
        this topic.\
        Used in notification menu.

        :return: The first unread post for this topic and this user.
        """
        try:
            if user is None:
                user = get_current_user()

            last_post = TopicRead.objects.filter(topic__pk=self.pk, user__pk=user.pk).latest("post__position").post

            next_post = (
                Post.objects.filter(topic__pk=self.pk, position__gt=last_post.position).select_related("author").first()
            )
            return next_post
        except (TopicRead.DoesNotExist, Post.DoesNotExist):
            return self.first_post()

    def antispam(self, user=None):
        """
        Check if the user is allowed to post in a topic according to the `ZDS_APP['forum']['spam_limit_seconds']` value.
        The user can always post if someone else has posted last.
        If the user is the last poster and there is less than `ZDS_APP['forum']['spam_limit_seconds']` since the last
        post, the anti-spam is active and the user cannot post.
        :param user: A user. If undefined, the current user is used.
        :return: `True` if the anti-spam is active (user can't post), `False` otherwise.
        """
        if user is None:
            user = get_current_user()

        last_user_post = Post.objects.filter(topic=self).filter(author=user.pk).order_by("position").last()

        if last_user_post and last_user_post == self.get_last_post():
            duration = datetime.now() - last_user_post.pubdate
            if duration.total_seconds() < settings.ZDS_APP["forum"]["spam_limit_seconds"]:
                return True

        return False

    def old_post_warning(self):
        """
        Check if the last message was written a long time ago according to `ZDS_APP['forum']['old_post_limit_days']`
        value.

        :return: `True` if the post is old (users are warned), `False` otherwise.
        """
        last_post = self.last_message

        if last_post is not None:
            t = last_post.pubdate + timedelta(days=settings.ZDS_APP["forum"]["old_post_limit_days"])
            if t < datetime.today():
                return True

        return False

    @property
    def is_read(self):
        return self.is_read_by_user(get_current_user())

    def is_read_by_user(self, user=None, check_auth=True):
        return TopicRead.objects.is_topic_last_message_read(self, user, check_auth)

    @classmethod
    def get_es_mapping(cls):
        es_mapping = super().get_es_mapping()

        es_mapping.field("title", Text(boost=1.5))
        es_mapping.field("tags", Text(boost=2.0))
        es_mapping.field("subtitle", Text())
        es_mapping.field("is_solved", Boolean())
        es_mapping.field("is_locked", Boolean())
        es_mapping.field("is_sticky", Boolean())
        es_mapping.field("pubdate", Date())
        es_mapping.field("forum_pk", Integer())

        es_mapping.field("get_absolute_url", Keyword(index=False))
        es_mapping.field("forum_title", Text(index=False))
        es_mapping.field("forum_get_absolute_url", Keyword(index=False))

        return es_mapping

    @classmethod
    def get_es_django_indexable(cls, force_reindexing=False):
        """Overridden to prefetch tags and forum"""

        query = super().get_es_django_indexable(force_reindexing)
        return query.prefetch_related("tags").select_related("forum")

    def get_es_document_source(self, excluded_fields=None):
        """Overridden to handle the case of tags (M2M field)"""

        excluded_fields = excluded_fields or []
        excluded_fields.extend(["tags", "forum_pk", "forum_title", "forum_get_absolute_url"])

        data = super().get_es_document_source(excluded_fields=excluded_fields)
        data["tags"] = [tag.title for tag in self.tags.all()]
        data["forum_pk"] = self.forum.pk
        data["forum_title"] = self.forum.title
        data["forum_get_absolute_url"] = self.forum.get_absolute_url()

        return data

    def save(self, *args, **kwargs):
        """Overridden to handle the displacement of the topic to another forum"""

        try:
            old_self = Topic.objects.get(pk=self.pk)
        except Topic.DoesNotExist:
            pass
        else:
            if old_self.forum.pk != self.forum.pk or old_self.title != self.title:
                Post.objects.filter(topic__pk=self.pk).update(es_flagged=True)
        return super().save(*args, **kwargs)

@receiver(pre_delete, sender=Topic)
def delete_topic_in_elasticsearch(sender, instance, **kwargs):
    """catch the pre_delete signal to ensure the deletion in ES"""
    return delete_document_in_elasticsearch(instance)

class Post(Comment, AbstractESDjangoIndexable):
    """
    A forum post written by a user.
    A post can be marked as useful: topic's author (or admin) can declare any topic as "useful", and this post is
    displayed as is on front.
    """

    objects_per_batch = 2000

    topic = models.ForeignKey(Topic, verbose_name="Sujet", db_index=True, on_delete=models.CASCADE)

    is_useful = models.BooleanField("Est utile", default=False)
    objects = PostManager()

    def __str__(self):
        return f"<Post pour '{self.topic}', #{self.pk}>"

    def get_absolute_url(self):
        """
        :return: the absolute URL for this post, including page in the topic.
        """
        page = int(ceil(float(self.position) / settings.ZDS_APP["forum"]["posts_per_page"]))

        return f"{self.topic.get_absolute_url()}?page={page}#p{self.pk}"

    def get_notification_title(self):
        return self.topic.title

    @classmethod
    def get_es_mapping(cls):
        es_mapping = super().get_es_mapping()

        es_mapping.field("text_html", Text())
        es_mapping.field("is_useful", Boolean())
        es_mapping.field("is_visible", Boolean())
        es_mapping.field("position", Integer())
        es_mapping.field("like_dislike_ratio", Float())
        es_mapping.field("pubdate", Date())
        es_mapping.field("forum_pk", Integer())
        es_mapping.field("topic_pk", Integer())

        es_mapping.field("get_absolute_url", Keyword(index=False))
        es_mapping.field("topic_title", Text(index=False))
        es_mapping.field("forum_title", Text(index=False))
        es_mapping.field("forum_get_absolute_url", Keyword(index=False))

        return es_mapping

    @classmethod
    def get_es_django_indexable(cls, force_reindexing=False):
        """Overridden to prefetch stuffs"""

        q = super().get_es_django_indexable(force_reindexing).prefetch_related("topic").prefetch_related("topic__forum")

        return q

    def get_es_document_source(self, excluded_fields=None):
        """Overridden to handle the information of the topic"""

        excluded_fields = excluded_fields or []
        excluded_fields.extend(
            ["like_dislike_ratio", "topic_title", "topic_pk", "forum_title", "forum_pk", "forum_get_absolute_url"]
        )

        data = super().get_es_document_source(excluded_fields=excluded_fields)

        data["like_dislike_ratio"] = (
            (self.like / self.dislike) if self.dislike != 0 else self.like if self.like != 0 else 1
        )

        data["topic_pk"] = self.topic.pk
        data["topic_title"] = self.topic.title

        data["forum_pk"] = self.topic.forum.pk
        data["forum_title"] = self.topic.forum.title
        data["forum_get_absolute_url"] = self.topic.forum.get_absolute_url()

        return data

    def hide_comment_by_user(self, user, text_hidden):
        """Overridden to directly hide the post in ES as well"""

        super().hide_comment_by_user(user, text_hidden)

        index_manager = ESIndexManager(**settings.ES_SEARCH_INDEX)
        index_manager.update_single_document(self, {"is_visible": False})

@receiver(pre_delete, sender=Post)
def delete_post_in_elasticsearch(sender, instance, **kwargs):
    """catch the pre_delete signal to ensure the deletion in ES"""
    return delete_document_in_elasticsearch(instance)

class TopicRead(models.Model):
    """
    This model tracks the last post read in a topic by a user.
    Technically it is a simple joint [user, topic, last read post].
    """

    class Meta:
        verbose_name = "Sujet lu"
        verbose_name_plural = "Sujets lus"
        unique_together = ("topic", "user")

    topic = models.ForeignKey(Topic, db_index=True, on_delete=models.CASCADE)
    post = models.ForeignKey(Post, db_index=True, on_delete=models.CASCADE)
    user = models.ForeignKey(User, related_name="topics_read", db_index=True, on_delete=models.CASCADE)
    objects = TopicReadManager()

    def __str__(self):
        return f"<Sujet '{self.topic}' lu par {self.user}, #{self.post.pk}>"

def mark_read(topic, user=None):
    """
    Mark the last message of a topic as read for the current user.
    :param topic: A topic.
    """
    if not user:
        user = get_current_user()

    if user and user.is_authenticated:
        current_topic_read = TopicRead.objects.filter(topic=topic, user=user).first()
        if current_topic_read is None:
            current_topic_read = TopicRead(post=topic.last_message, topic=topic, user=user)
        else:
            current_topic_read.post = topic.last_message
        current_topic_read.save()
        signals.topic_read.send(sender=topic.__class__, instance=topic, user=user)


def test_db_output_filter(self, init_models, init_buildings):

"""Tests the interface Modules/DB"""

from uuid import uuid4 as gen_uuid
import pytest

from bemserver.database import ServiceDB, ModelDB, OutputDB
from bemserver.database.exceptions import ItemNotFoundError
from bemserver.models import (
    Service, Model, OutputEvent, OutputTimeSeries, ValuesDescription,
    Parameter)

from tests import TestCoreDatabaseOntology

@pytest.mark.usefixtures('init_onto_mgr_fact')
class TestServiceDB(TestCoreDatabaseOntology):
    """Tests on the interface to handle services in the ontology."""

    def test_db_service_get_empty(self):

        service_db = ServiceDB()

        # get all items
        result = service_db.get_all()
        assert list(result) == []

        # try to get an inexistant item
        with pytest.raises(ItemNotFoundError):
            service_db.get_by_id('not_existing')

    def test_db_service_create(self, init_sites):

        service_db = ServiceDB()

        # check that database is empty
        result = service_db.get_all()
        assert list(result) == []

        # create an item

        service = Service('Service#0', url='http://example.com',
                          description='New sample service',
                          has_frontend=True, site_ids=init_sites)
        new_service_id = service_db.create(service)
        assert new_service_id is not None
        assert new_service_id == service.id

        # check that database is not empty now
        result = service_db.get_all()
        services = list(result)
        assert len(services) == 1
        assert services[0].id == service.id
        assert services[0].name == service.name
        assert services[0].description == service.description
        assert services[0].url == service.url
        assert services[0].has_frontend
        assert set(services[0].site_ids) == set(init_sites)

    def test_db_service_filter(self, init_sites):
        service_db = ServiceDB()

        # check that database is empty
        result = service_db.get_all(name='Service#0')
        assert list(result) == []
        result = service_db.get_all(
            url="http://example.com")
        assert list(result) == []

        # create an item
        service_db.create(
            Service('Service#0', url='http://example.com',
                    description='New sample FDD service',
                    site_ids=[str(init_sites[0])]))
        service_db.create(
            Service('Service#1', url='http://hit2gap.eu/services/forecasting',
                    description='New sample forecasting service',
                    has_frontend=True))

        services = list(service_db.get_all())
        assert len(services) == 2
        result = service_db.get_all(name="Service#1")
        assert len(list(result)) == 1

        result = service_db.get_all(
            url="http://hit2gap.eu/services/forecasting")
        assert len(list(result)) == 1

        result = service_db.get_all(site=str(init_sites[0]))
        assert len(list(result)) == 1

        result = service_db.get_all(has_frontend=True)
        assert len(list(result)) == 1

    def test_db_service_update(self, init_services):

        service_ids = init_services
        service_db = ServiceDB()

        # get all items
        result = service_db.get_all()
        services = list(result)
        assert len(services) == 2
        for cur_service in services:
            assert cur_service.id in service_ids

        # get an item by its ID!
        # for a service, the ID to be used is the URL
        service = service_db.get_by_id(services[0].id)

        # update item data
        new_description = 'updated by patator'
        new_name = 'New Name'
        new_url = 'http://example.com'

        service.description = new_description
        service.name = new_name
        service.url = new_url
        service_db.update(service.id, service)

        # check that item has really been updated in database
        updated_service = service_db.get_by_id(service.id)
        assert updated_service.id == service.id
        assert updated_service.name == new_name
        assert updated_service.description == new_description
        assert updated_service.url == new_url
        assert updated_service.kind == service.kind == 'Service'
        assert updated_service.model_ids == service.model_ids == []

        # delete an item by its ID
        service_db.remove(service.id)

        # get an item by its ID
        with pytest.raises(ItemNotFoundError):
            # it has been removed...
            service_db.get_by_id(service.id)

@pytest.mark.usefixtures('init_onto_mgr_fact')
class TestModelDB(TestCoreDatabaseOntology):
    """Tests on the interface to handle services in the ontology."""

    def test_db_model_get_empty(self):

        model_db = ModelDB()

        # get all items
        result = model_db.get_all()
        assert list(result) == []

        # try to get an inexistant item
        with pytest.raises(ItemNotFoundError):
            model_db.get_by_id('not_existing')

    def test_db_model_create(self, init_services):

        model_db = ModelDB()

        # check that database is empty
        result = model_db.get_all()
        assert list(result) == []

        # create an item
        model = Model('ARIMA#0', init_services[0],
                      description='A sample model',
                      parameters=[Parameter('ar', 5)])
        new_model_id = model_db.create(model)
        assert new_model_id is not None
        assert new_model_id == model    .id

        # check that database is not empty now
        result = model_db.get_all()
        models = list(result)
        assert len(models) == 1
        assert models[0].id == model.id
        assert models[0].name == model.name
        assert models[0].service_id == model.service_id
        assert models[0].description == model.description
        assert models[0].get_set_of_parameters() == (
            model.get_set_of_parameters())

        # test relation between service and model
        service = ServiceDB().get_by_id(model.service_id)
        assert service.model_ids == [model.id]

    def test_db_model_filter(self, init_services):
        model_db = ModelDB()
        services = init_services

        # check that database is empty
        result = model_db.get_all(name='Model #0')
        assert list(result) == []
        result = model_db.get_all(service_id=str(services[0]))
        assert list(result) == []

        # create an item
        model_db.create(
            Model('Model #0', services[0], description='New sample FDD model',
                  parameters=[Parameter('param1', 1)]))
        model_db.create(
            Model('Model #1', services[0],
                  description='RENew sample FDD model',
                  parameters=[Parameter('param1', 1), Parameter('param2', 2)]))

        models = list(model_db.get_all())
        assert len(models) == 2
        result = model_db.get_all(name="Model #1")
        assert len(list(result)) == 1

        result = model_db.get_all(service_id=services[0])
        assert len(list(result)) == 2

        result = model_db.get_all(service_id=services[1])
        assert len(list(result)) == 0

    def test_db_model_update(self, init_models):
        model_ids, _ = init_models
        model_db = ModelDB()

        # get all items
        result = model_db.get_all()
        models = list(result)
        assert len(models) == 2
        for cur_model in models:
            assert cur_model.id in model_ids

        # get an item by its ID!
        # for a model, the ID to be used is the URL
        model = model_db.get_by_id(models[0].id)

        # update item data
        new_description = 'updated by patator'
        new_name = 'New Name'
        new_parameters = [Parameter('NewParam', 0)]
        model.description = new_description
        model.name = new_name
        model.parameters = new_parameters
        model_db.update(model.id, model)

        # check that item has really been updated in database
        updated_model = model_db.get_by_id(model.id)
        assert updated_model.id == model.id
        assert updated_model.name == new_name
        assert updated_model.description == new_description
        assert updated_model.service_id == model.service_id
        assert set(updated_model.event_output_ids) ==\
            set(model.event_output_ids)
        assert set(updated_model.timeseries_output_ids) ==\
            set(model.timeseries_output_ids)

        assert updated_model.get_set_of_parameters() ==\
            model.get_set_of_parameters() == set([('NewParam', 0)])

        # delete an item by its ID
        model_db.remove(model.id)

        # get an item by its ID
        with pytest.raises(ItemNotFoundError):
            # it has been removed...
            model_db.get_by_id(model.id)

@pytest.mark.usefixtures('init_onto_mgr_fact')
class TestOutputDB(TestCoreDatabaseOntology):
    """Tests on the interface to handle services in the ontology."""

    def test_db_output_get_empty(self):
        output_db = OutputDB()
        # get all items
        result = output_db.get_all()
        assert list(result) == []
        # try to get an inexistant item
        with pytest.raises(ItemNotFoundError):
            output_db.get_by_id('not_existing')

    def test_db_output_create(self, init_models, init_buildings):
        building_ids, _ = init_buildings
        output_db = OutputDB()
        # check that database is empty
        result = output_db.get_all()
        assert list(result) == []

        # get service and model id
        models = list(ModelDB().get_all())
        model_id, service_id = models[0].id, models[0].service_id

        # create an item
        output = OutputEvent(service_id, model_id, id=str(gen_uuid()))
        new_output_id_evt = output_db.create(output)
        assert new_output_id_evt is not None

        # check that database is not empty now
        result = output_db.get_all()
        outputs = list(result)
        assert len(outputs) == 1
        assert outputs[0].module_id == service_id
        assert outputs[0].model_id == model_id

        # test with time series
        output = OutputTimeSeries(
            service_id, model_id, building_ids[0],
            ValuesDescription('Temperature', 'DegreeCelsius', 20),
            id=str(gen_uuid()))
        new_output_id_ts = output_db.create(output)
        assert new_output_id_ts is not None

        result = output_db.get_all()
        outputs = list(result)
        assert len(outputs) == 2
        output_event = (
            outputs[0]
            if isinstance(outputs[0], OutputTimeSeries) else outputs[1])
        assert output_event.module_id == service_id
        assert output_event.model_id == model_id
        assert output_event.localization == output.localization
        assert output_event.values_desc.unit == output.values_desc.unit
        assert output_event.values_desc.kind == output.values_desc.kind
        assert output_event.values_desc.sampling == output.values_desc.sampling

        # test relation between service and output
        model = ModelDB().get_by_id(model_id)
        assert set(model.event_output_ids) == {new_output_id_evt}
        assert set(model.timeseries_output_ids) == {new_output_id_ts}

building_ids, _ = init_buildings
        output_db = OutputDB()

        # get service and model id
        models = list(ModelDB().get_all())
        model_id, service_id = models[0].id, models[0].service_id

        # check that database is empty
        result = output_db.get_all(module_id=service_id)
        assert list(result) == []
        result = output_db.get_all(kind='Temperature')
        assert list(result) == []

        # create an item
        output_db.create(OutputEvent(service_id, model_id))
        output_db.create(
            OutputTimeSeries(
                service_id, model_id, building_ids[0],
                ValuesDescription('Temperature', 'DegreeCelsius', 20)))

        outputs = list(output_db.get_all())
        assert len(outputs) == 2
        result = output_db.get_all(module_id=service_id)
        assert len(list(result)) == 2

        result = output_db.get_all(localization=building_ids[0])
        assert len(list(result)) == 1

        result = output_db.get_all(value_type='Temperature')
        assert len(list(result)) == 1

    def test_db_output_update(self, init_models, init_buildings):
        building_ids, _ = init_buildings

        models = list(ModelDB().get_all())
        model_id, service_id = models[0].id, models[0].service_id

        output_db = OutputDB()
        output = OutputTimeSeries(
            service_id, model_id, building_ids[0],
            ValuesDescription('Temperature', 'DegreeCelsius', 20),
            id=str(gen_uuid()))
        new_output_id_ts = output_db.create(output)

        # get all items
        result = output_db.get_all()
        outputs = list(result)
        assert len(outputs) == 1
        assert [item.id for item in outputs] == [new_output_id_ts]

        # get an item by its ID!
        # for a output, the ID to be used is the URL
        output = output_db.get_by_id(outputs[0].id)

        # update item data
        new_sampling = 400
        new_model_id = models[1].id
        new_kind = 'Energy'
        new_unit = 'Joule'
        output.sampling = new_sampling
        output.model_id = new_model_id
        output.values_desc.kind = new_kind
        output.values_desc.unit = new_unit
        output_db.update(output.id, output)

        # check that item has really been updated in database
        updated_output = output_db.get_by_id(output.id)
        assert updated_output.id == output.id
        assert updated_output.model_id == new_model_id
        assert updated_output.module_id == output.module_id
        assert updated_output.localization == output.localization
        assert updated_output.values_desc.unit == new_unit
        assert updated_output.values_desc.kind == new_kind
        assert updated_output.values_desc.sampling == (
            output.values_desc.sampling)

        # delete an item by its ID
        output_db.remove(output.id)

        # get an item by its ID
        with pytest.raises(ItemNotFoundError):
            # it has been removed...
            output_db.get_by_id(output.id)


model_db = ModelDB()

        # get all items
        result = model_db.get_all()
        assert list(result) == []

        # try to get an inexistant item
        with pytest.raises(ItemNotFoundError):
            model_db.get_by_id('not_existing')

"""Tests the interface Modules/DB"""

from uuid import uuid4 as gen_uuid
import pytest

from bemserver.database import ServiceDB, ModelDB, OutputDB
from bemserver.database.exceptions import ItemNotFoundError
from bemserver.models import (
    Service, Model, OutputEvent, OutputTimeSeries, ValuesDescription,
    Parameter)

from tests import TestCoreDatabaseOntology

@pytest.mark.usefixtures('init_onto_mgr_fact')
class TestServiceDB(TestCoreDatabaseOntology):
    """Tests on the interface to handle services in the ontology."""

    def test_db_service_get_empty(self):

        service_db = ServiceDB()

        # get all items
        result = service_db.get_all()
        assert list(result) == []

        # try to get an inexistant item
        with pytest.raises(ItemNotFoundError):
            service_db.get_by_id('not_existing')

    def test_db_service_create(self, init_sites):

        service_db = ServiceDB()

        # check that database is empty
        result = service_db.get_all()
        assert list(result) == []

        # create an item

        service = Service('Service#0', url='http://example.com',
                          description='New sample service',
                          has_frontend=True, site_ids=init_sites)
        new_service_id = service_db.create(service)
        assert new_service_id is not None
        assert new_service_id == service.id

        # check that database is not empty now
        result = service_db.get_all()
        services = list(result)
        assert len(services) == 1
        assert services[0].id == service.id
        assert services[0].name == service.name
        assert services[0].description == service.description
        assert services[0].url == service.url
        assert services[0].has_frontend
        assert set(services[0].site_ids) == set(init_sites)

    def test_db_service_filter(self, init_sites):
        service_db = ServiceDB()

        # check that database is empty
        result = service_db.get_all(name='Service#0')
        assert list(result) == []
        result = service_db.get_all(
            url="http://example.com")
        assert list(result) == []

        # create an item
        service_db.create(
            Service('Service#0', url='http://example.com',
                    description='New sample FDD service',
                    site_ids=[str(init_sites[0])]))
        service_db.create(
            Service('Service#1', url='http://hit2gap.eu/services/forecasting',
                    description='New sample forecasting service',
                    has_frontend=True))

        services = list(service_db.get_all())
        assert len(services) == 2
        result = service_db.get_all(name="Service#1")
        assert len(list(result)) == 1

        result = service_db.get_all(
            url="http://hit2gap.eu/services/forecasting")
        assert len(list(result)) == 1

        result = service_db.get_all(site=str(init_sites[0]))
        assert len(list(result)) == 1

        result = service_db.get_all(has_frontend=True)
        assert len(list(result)) == 1

    def test_db_service_update(self, init_services):

        service_ids = init_services
        service_db = ServiceDB()

        # get all items
        result = service_db.get_all()
        services = list(result)
        assert len(services) == 2
        for cur_service in services:
            assert cur_service.id in service_ids

        # get an item by its ID!
        # for a service, the ID to be used is the URL
        service = service_db.get_by_id(services[0].id)

        # update item data
        new_description = 'updated by patator'
        new_name = 'New Name'
        new_url = 'http://example.com'

        service.description = new_description
        service.name = new_name
        service.url = new_url
        service_db.update(service.id, service)

        # check that item has really been updated in database
        updated_service = service_db.get_by_id(service.id)
        assert updated_service.id == service.id
        assert updated_service.name == new_name
        assert updated_service.description == new_description
        assert updated_service.url == new_url
        assert updated_service.kind == service.kind == 'Service'
        assert updated_service.model_ids == service.model_ids == []

        # delete an item by its ID
        service_db.remove(service.id)

        # get an item by its ID
        with pytest.raises(ItemNotFoundError):
            # it has been removed...
            service_db.get_by_id(service.id)

@pytest.mark.usefixtures('init_onto_mgr_fact')
class TestModelDB(TestCoreDatabaseOntology):
    """Tests on the interface to handle services in the ontology."""

    def test_db_model_get_empty(self):

    def test_db_model_create(self, init_services):

        model_db = ModelDB()

        # check that database is empty
        result = model_db.get_all()
        assert list(result) == []

        # create an item
        model = Model('ARIMA#0', init_services[0],
                      description='A sample model',
                      parameters=[Parameter('ar', 5)])
        new_model_id = model_db.create(model)
        assert new_model_id is not None
        assert new_model_id == model    .id

        # check that database is not empty now
        result = model_db.get_all()
        models = list(result)
        assert len(models) == 1
        assert models[0].id == model.id
        assert models[0].name == model.name
        assert models[0].service_id == model.service_id
        assert models[0].description == model.description
        assert models[0].get_set_of_parameters() == (
            model.get_set_of_parameters())

        # test relation between service and model
        service = ServiceDB().get_by_id(model.service_id)
        assert service.model_ids == [model.id]

    def test_db_model_filter(self, init_services):
        model_db = ModelDB()
        services = init_services

        # check that database is empty
        result = model_db.get_all(name='Model #0')
        assert list(result) == []
        result = model_db.get_all(service_id=str(services[0]))
        assert list(result) == []

        # create an item
        model_db.create(
            Model('Model #0', services[0], description='New sample FDD model',
                  parameters=[Parameter('param1', 1)]))
        model_db.create(
            Model('Model #1', services[0],
                  description='RENew sample FDD model',
                  parameters=[Parameter('param1', 1), Parameter('param2', 2)]))

        models = list(model_db.get_all())
        assert len(models) == 2
        result = model_db.get_all(name="Model #1")
        assert len(list(result)) == 1

        result = model_db.get_all(service_id=services[0])
        assert len(list(result)) == 2

        result = model_db.get_all(service_id=services[1])
        assert len(list(result)) == 0

    def test_db_model_update(self, init_models):
        model_ids, _ = init_models
        model_db = ModelDB()

        # get all items
        result = model_db.get_all()
        models = list(result)
        assert len(models) == 2
        for cur_model in models:
            assert cur_model.id in model_ids

        # get an item by its ID!
        # for a model, the ID to be used is the URL
        model = model_db.get_by_id(models[0].id)

        # update item data
        new_description = 'updated by patator'
        new_name = 'New Name'
        new_parameters = [Parameter('NewParam', 0)]
        model.description = new_description
        model.name = new_name
        model.parameters = new_parameters
        model_db.update(model.id, model)

        # check that item has really been updated in database
        updated_model = model_db.get_by_id(model.id)
        assert updated_model.id == model.id
        assert updated_model.name == new_name
        assert updated_model.description == new_description
        assert updated_model.service_id == model.service_id
        assert set(updated_model.event_output_ids) ==\
            set(model.event_output_ids)
        assert set(updated_model.timeseries_output_ids) ==\
            set(model.timeseries_output_ids)

        assert updated_model.get_set_of_parameters() ==\
            model.get_set_of_parameters() == set([('NewParam', 0)])

        # delete an item by its ID
        model_db.remove(model.id)

        # get an item by its ID
        with pytest.raises(ItemNotFoundError):
            # it has been removed...
            model_db.get_by_id(model.id)

@pytest.mark.usefixtures('init_onto_mgr_fact')
class TestOutputDB(TestCoreDatabaseOntology):
    """Tests on the interface to handle services in the ontology."""

    def test_db_output_get_empty(self):
        output_db = OutputDB()
        # get all items
        result = output_db.get_all()
        assert list(result) == []
        # try to get an inexistant item
        with pytest.raises(ItemNotFoundError):
            output_db.get_by_id('not_existing')

    def test_db_output_create(self, init_models, init_buildings):
        building_ids, _ = init_buildings
        output_db = OutputDB()
        # check that database is empty
        result = output_db.get_all()
        assert list(result) == []

        # get service and model id
        models = list(ModelDB().get_all())
        model_id, service_id = models[0].id, models[0].service_id

        # create an item
        output = OutputEvent(service_id, model_id, id=str(gen_uuid()))
        new_output_id_evt = output_db.create(output)
        assert new_output_id_evt is not None

        # check that database is not empty now
        result = output_db.get_all()
        outputs = list(result)
        assert len(outputs) == 1
        assert outputs[0].module_id == service_id
        assert outputs[0].model_id == model_id

        # test with time series
        output = OutputTimeSeries(
            service_id, model_id, building_ids[0],
            ValuesDescription('Temperature', 'DegreeCelsius', 20),
            id=str(gen_uuid()))
        new_output_id_ts = output_db.create(output)
        assert new_output_id_ts is not None

        result = output_db.get_all()
        outputs = list(result)
        assert len(outputs) == 2
        output_event = (
            outputs[0]
            if isinstance(outputs[0], OutputTimeSeries) else outputs[1])
        assert output_event.module_id == service_id
        assert output_event.model_id == model_id
        assert output_event.localization == output.localization
        assert output_event.values_desc.unit == output.values_desc.unit
        assert output_event.values_desc.kind == output.values_desc.kind
        assert output_event.values_desc.sampling == output.values_desc.sampling

        # test relation between service and output
        model = ModelDB().get_by_id(model_id)
        assert set(model.event_output_ids) == {new_output_id_evt}
        assert set(model.timeseries_output_ids) == {new_output_id_ts}

    def test_db_output_filter(self, init_models, init_buildings):
        building_ids, _ = init_buildings
        output_db = OutputDB()

        # get service and model id
        models = list(ModelDB().get_all())
        model_id, service_id = models[0].id, models[0].service_id

        # check that database is empty
        result = output_db.get_all(module_id=service_id)
        assert list(result) == []
        result = output_db.get_all(kind='Temperature')
        assert list(result) == []

        # create an item
        output_db.create(OutputEvent(service_id, model_id))
        output_db.create(
            OutputTimeSeries(
                service_id, model_id, building_ids[0],
                ValuesDescription('Temperature', 'DegreeCelsius', 20)))

        outputs = list(output_db.get_all())
        assert len(outputs) == 2
        result = output_db.get_all(module_id=service_id)
        assert len(list(result)) == 2

        result = output_db.get_all(localization=building_ids[0])
        assert len(list(result)) == 1

        result = output_db.get_all(value_type='Temperature')
        assert len(list(result)) == 1

    def test_db_output_update(self, init_models, init_buildings):
        building_ids, _ = init_buildings

        models = list(ModelDB().get_all())
        model_id, service_id = models[0].id, models[0].service_id

        output_db = OutputDB()
        output = OutputTimeSeries(
            service_id, model_id, building_ids[0],
            ValuesDescription('Temperature', 'DegreeCelsius', 20),
            id=str(gen_uuid()))
        new_output_id_ts = output_db.create(output)

        # get all items
        result = output_db.get_all()
        outputs = list(result)
        assert len(outputs) == 1
        assert [item.id for item in outputs] == [new_output_id_ts]

        # get an item by its ID!
        # for a output, the ID to be used is the URL
        output = output_db.get_by_id(outputs[0].id)

        # update item data
        new_sampling = 400
        new_model_id = models[1].id
        new_kind = 'Energy'
        new_unit = 'Joule'
        output.sampling = new_sampling
        output.model_id = new_model_id
        output.values_desc.kind = new_kind
        output.values_desc.unit = new_unit
        output_db.update(output.id, output)

        # check that item has really been updated in database
        updated_output = output_db.get_by_id(output.id)
        assert updated_output.id == output.id
        assert updated_output.model_id == new_model_id
        assert updated_output.module_id == output.module_id
        assert updated_output.localization == output.localization
        assert updated_output.values_desc.unit == new_unit
        assert updated_output.values_desc.kind == new_kind
        assert updated_output.values_desc.sampling == (
            output.values_desc.sampling)

        # delete an item by its ID
        output_db.remove(output.id)

        # get an item by its ID
        with pytest.raises(ItemNotFoundError):
            # it has been removed...
            output_db.get_by_id(output.id)