            self.scene().cubicItem.reset()
            l = self.scene().layer
            l.applyToStack()
            l.parentImage.onImageChanged()

"""
This File is part of bLUe software.

"""
import pickle

import numpy as np
from PySide2.QtGui import QPainterPathStroker, QBrush, QPixmap
from PySide2.QtCore import QRect, QPointF, QPoint
from PySide2.QtWidgets import QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsPolygonItem, \
    QGraphicsSceneMouseEvent, QHBoxLayout
from PySide2.QtGui import QColor, QPen, QPainterPath, QPolygonF
from PySide2.QtCore import Qt, QRectF

from bLUeGui.graphicsForm import graphicsCurveForm
from bLUeTop.utils import optionsWidget, QbLUePushButton
from .baseSignal import baseSignal_No
from .spline import interpolationCubSpline, interpolationQuadSpline, displacementSpline
from .const import channelValues

# GUI for interactive construction of 1D LUTs and 2D LUTs.
# 1D LUT are calculated as interpolation splines defined by a set of control points.
# 2D LUTs are calculated as displacement (delta) splines defined by a set of bumps.
# Control points and splines are represented by QGraphicsPathItem instances in a QGraphicsScene.
# The origin of the scene is at the bottom left and the Y axis points downwards.
# Mouse event handlers are reimplemented to provide full control (move range, removing,...)

class activePoint(QGraphicsPathItem):
    """
    Interactive point
    """

    def __init__(self, x, y, color=Qt.white, fillColor=None, persistent=False, rect=None, parentItem=None):
        super().__init__(parent=parentItem)
        self.color = color
        self.setAcceptHoverEvents(True)
        self.persistent = persistent
        self.rect = rect
        if self.rect is not None:
            self.xmin, self.xmax, self.ymin, self.ymax = rect.left(), rect.right(), rect.top(), rect.bottom()
            x = min(max(x, self.xmin), self.xmax)
            y = min(max(y, self.ymin), self.ymax)
        self.setPos(QPointF(x, y))
        self.clicked = False
        self.setPen(QPen(color, 2))
        # filling brush
        if fillColor is not None:
            self.setBrush(QBrush(fillColor))
        qpp = QPainterPath()
        # coordinates are relative to activePoint
        qpp.addEllipse(-4, -4, 8, 8)
        self.setPath(qpp)

    def mousePressEvent(self, e):
        self.clicked = True

    def mouseMoveEvent(self, e):
        self.clicked = False
        x, y = e.scenePos().x(), e.scenePos().y()
        if self.rect is not None:
            x = min(max(x, self.xmin), self.xmax)
            y = min(max(y, self.ymin), self.ymax)
        self.setPos(x, y)

    def hoverEnterEvent(self, *args, **kwargs):
        self.setPen(QPen(QColor(0, 255, 0), 2))
        self.update()

    def hoverLeaveEvent(self, *args, **kwargs):
        self.setPen(QPen(self.color, 2))
        self.update()

class activeTriangle(QGraphicsPathItem):
    """
    interactive bump triangle
    """
    def __init__(self, x, y, bump, persistent=False, rect=None, parentItem=None):
        super().__init__(parent=parentItem)
        self.setAcceptHoverEvents(True)
        self.persistent = persistent
        self.rect = rect
        self.setPos(QPointF(x, y))
        self.clicked = False
        # coordinates are relative to activeTriangle
        self.B, self.C, self.A = QPointF(0, 0), QPointF(50, 0), QPointF(25, -bump)
        self.setPen(QPen(QColor(255, 255, 255), 2))
        self.update()

    def update(self):
        qpp = QPainterPath()
        trans = QPointF(-4, -4)
        # coordinates are relative to activeTriangle
        for p in [self.A, self.B, self.C]:
            qpp.addEllipse(p + trans, 4, 4)
        self.setPath(qpp)
        super().update()

    def mousePressEvent(self, e):
        self.clicked = True
        xt, yt = e.pos().x(), e.pos().y()
        p = QPointF(xt, yt)
        self.moving = None
        for p in [self.A, self.B, self.C]:
            if abs(xt - p.x()) + abs(yt - p.y()) < 15:
                self.moving = p
                break

    def mouseMoveEvent(self, e):
        if self.moving is None:
            return
        self.clicked = False
        xt, yt = e.pos().x(), e.pos().y()
        if self.moving is self.A:
            self.A.setY(yt)
        else:
            if self.moving is self.B:
                x1, x2 = xt, self.C.x()
            elif self.moving is self.C:
                x1, x2 = xt, self.B.x()
            self.A.setX((self.B.x() + self.C.x()) / 2)
            self.B.setX(min(x1, x2))
            self.C.setX(max(x1, x2))
        self.update()
        self.parentItem().updatePath()

    def mouseReleaseEvent(self, e):
        # get scene current spline
        sc = self.scene()
        # get parent spline
        activeSpline = self.parentItem()  # sc.cubicItem
        # click event : remove point
        if self.clicked:
            if self.persistent:
                return
            activeSpline.fixedPoints.remove(self)
            sc.removeItem(self)
            return
        activeSpline.updateLUTXY()
        activeSpline.curveChanged.sig.emit()

    def hoverEnterEvent(self, *args, **kwargs):
        self.setPen(QPen(QColor(0, 255, 0), 2))
        self.update()

    def hoverLeaveEvent(self, *args, **kwargs):
        self.setPen(QPen(QColor(255, 255, 255), 2))
        self.update()

class activeMarker(QGraphicsPolygonItem):
    """
    Movable marker
    """

    size = 10
    triangle = QPolygonF()
    triangle.append(QPointF(-size, size))
    triangle.append(QPointF(0, 0))
    triangle.append(QPointF(size, size))

    cross = QPolygonF()
    cross.append(QPointF(-size/2, -size/2))
    cross.append(QPointF(0, 0))
    cross.append(QPointF(size / 2, size / 2))
    cross.append(QPointF(0, 0))
    cross.append(QPointF(-size / 2, size / 2))
    cross.append(QPointF(0, 0))
    cross.append(QPointF(size / 2, -size / 2))
    cross.append(QPointF(0, 0))

    @classmethod
    def fromTriangle(cls, *args, **kwargs):
        color = QColor(255, 255, 255)
        item = cls(*args, **kwargs)
        item.setPolygon(cls.triangle)
        item.setPen(QPen(color))
        item.setBrush(QBrush(color))
        # set move range to parent bounding rect
        item.moveRange = item.parentItem().boundingRect()
        return item

    @classmethod
    def fromCross(cls, *args, **kwargs):
        color = QColor(0, 0, 0)
        item = cls(*args, **kwargs)
        item.setPolygon(cls.cross)
        item.setPen(QPen(color))
        item.setBrush(QBrush(color))
        # set move range to parent bounding rect
        item.moveRange = item.parentItem().boundingRect()
        return item

    def __init__(self, parent=None):
        super().__init__(parent=parent)
        self.onMouseMove, self.onMouseRelease = lambda e, x, y: 0, lambda e, x, y: 0
        self.moveRange = QRectF(0.0, 0.0, 0.0, 0.0)

    @property  # read only
    def currentColor(self):
        return self.scene().slider2D.QImg.pixelColor((self.pos() - self.parentItem().offset()).toPoint())

    def setMoveRange(self, rect):
        self.moveRange = rect

    def mousePressEvent(self, e):
        pass

    def mouseMoveEvent(self, e):
        # event position relative to parent
        pos = e.pos() + self.pos() #  e.scenePos() - self.parentItem().scenePos()  # TODO modified 16/02/20 validate
        x, y = pos.x(), pos.y()
        # limit move to moveRange
        xmin, ymin = self.moveRange.left(), self.moveRange.top()
        xmax, ymax = self.moveRange.right(), self.moveRange.bottom()
        x, y = xmin if x < xmin else xmax if x > xmax else x, ymin if y < ymin else ymax if y > ymax else y
        self.setPos(x, y)
        self.onMouseMove(e, x, y)

    def mouseReleaseEvent(self, e):
        # event position relative to parent
        pos = e.pos() + self.pos() #  e.scenePos() - self.parentItem().scenePos()
        x, y = pos.x(), pos.y()
        # limit move to (0,0) and moveRange
        xmin, ymin = self.moveRange.left(), self.moveRange.top()
        xmax, ymax = self.moveRange.right(), self.moveRange.bottom()
        x, y = xmin if x < xmin else xmax if x > xmax else x, ymin if y < ymin else ymax if y > ymax else y
        self.onMouseRelease(e, x, y)

class activeRsMarker(activeMarker):
    """
    Marker for range slider. role is 'min' or 'max'
    """

    def __init__(self, parent=None, role=''):
        super().__init__(parent=parent)
        self.role = role

    def sceneEventFilter(self, target, e):
        """
        Filtering of mouse events for range slider markers :
        maintains marker order
        @param target:
        @type target:
        @param e:
        @type e:
        """
        if isinstance(e, QGraphicsSceneMouseEvent):
            if self.role == 'min':
                return e.scenePos().x() <= self.scenePos().x() + self.size
            else:
                return e.scenePos().x() >= self.scenePos().x() - self.size
        return False

class activeSplinePoint(activePoint):
    """
    Interactive (movable and removable) control point
    for a spline in a QGraphicsScene.
    """
    def __init__(self, x, y, persistent=False, rect=None, parentItem=None):
        """
        Interactive control point for the scene current spline.
        Persistent activePoints cannot be removed
        by mouse click (default is non persistent). If rect is not None,
        the moves of the point are restricted to rect.
        @param x: initial x-coordinate
        @type x: float
        @param y: initial y-coordinate
        @type y: float
        @param persistent: persistent flag
        @type persistent: boolean
        @param parentItem:
        @type parentItem: object
        """
        super().__init__(x, y, persistent=persistent, rect=rect, parentItem=parentItem)
        self.tangent = None  # link to tangent : used only by qudratic spline

    def mouseMoveEvent(self, e):
        self.clicked = False
        item = self.parentItem()
        if item is None:
            return
        p = e.pos() + self.pos()
        x, y = p.x(), p.y()  #  e.scenePos().x(), e.scenePos().y()  # TODO modified 20/02/20 validate
        if self.rect is not None:
            x = min(max(x, self.xmin), self.xmax)
            y = min(max(y, self.ymin), self.ymax)
        self.setPos(x, y)
        if self.tangent is not None:
            controlPoint, contactPoint = self.tangent.controlPoint, self.tangent.contactPoint
            v = controlPoint - contactPoint
            contactPoint = QPointF(x, y)
            self.tangent.contactPoint = contactPoint
            self.tangent.controlPoint = contactPoint + v
            self.tangent.setPos(contactPoint)
        item.fixedPoints.sort(key=lambda p: p.scenePos().x())
        item.updatePath()

    def mouseReleaseEvent(self, e):
        # get scene current spline
        item = self.parentItem()  # self.scene().cubicItem  # TODO modified 16/02/20 validate
        if item is None:
            return
        p = e.pos() + self.pos()
        x, y = p.x(), p.y()  #  e.scenePos().x(), e.scenePos().y()  # TODO modified 15/02/20 validate
        if self.rect is not None:
            x = min(max(x, self.xmin), self.xmax)
            y = min(max(y, self.ymin), self.ymax)
        self.setPos(x, y)
        sc = self.scene()
        # click event : remove point
        if self.clicked:
            if self.persistent:
                return
            item.fixedPoints.remove(self)
            # remove tangent if any
            fxdtg = getattr(item, 'fixedTangents', None)
            if fxdtg:
                fxdtg.remove(self.tangent)
                sc.removeItem(self.tangent)
            sc.removeItem(self)
            return
        item.updatePath()
        item.updateLUTXY()
        item.curveChanged.sig.emit()

class activeTangent(QGraphicsPathItem):
    """
    Interactive tangent
    """
    strokeWidth = 2
    penWidth = 2
    brushColor = Qt.darkGray

    def __init__(self, controlPoint=QPointF(), contactPoint=QPointF(), parentItem=None):
        super().__init__(parent=parentItem)
        self.savedPen = self.pen()
        self.setAcceptHoverEvents(True)
        self.controlPoint = controlPoint
        self.contactPoint = contactPoint
        self.setPos(contactPoint)
        qpp = QPainterPath()
        # coordinates are relative to activeTangent object
        qpp.moveTo(0, 0)
        qpp.lineTo((controlPoint - contactPoint))
        qpp.addEllipse(controlPoint - contactPoint, 5.0, 5.0)
        self.setPath(qpp)
        self.setZValue(-1)
        # set item pen
        self.setPen(QPen(QBrush(self.brushColor), self.penWidth))

    def updatePath(self):
        qpp = QPainterPath()
        # coordinates are relative to activeTangent object
        qpp.moveTo(0, 0)
        qpp.lineTo((self.controlPoint - self.contactPoint))
        qpp.addEllipse(self.controlPoint - self.contactPoint, 5.0, 5.0)
        self.setPath(qpp)

    def mousePressEvent(self, e):
        pass

    def mouseMoveEvent(self, e):
        newPos = e.scenePos()
        if abs(self.contactPoint.x() - newPos.x()) < 5:
            return
        slope = - (self.contactPoint.y() - newPos.y()) / (self.contactPoint.x() - newPos.x())
        if slope > 20 or slope < 0:
            return
        self.controlPoint = newPos
        # update tangent path
        self.updatePath()
        # update spline
        self.scene().cubicItem.updatePath()

    def mouseReleaseEvent(self, e):
        cb = self.scene().cubicItem
        cb.updatePath()
        cb.updateLUTXY()
        cb.curveChanged.sig.emit()

    def hoverEnterEvent(self, *args, **kwargs):
        self.setPen(QPen(QColor(0, 255, 0), 2))
        self.update()

    def hoverLeaveEvent(self, *args, **kwargs):
        self.setPen(self.savedPen)
        self.update()

class activeSpline(QGraphicsPathItem):
    """
    Base class for interactive splines.
    The attribute self.spline holds the list of
    QPointF instances to display (scene coordinates).
    It defines the path to display; sync with the scene is
    achieved by updatePath().
    """
    strokeWidth = 2
    penWidth = 2
    brushColor = Qt.darkGray

    def __init__(self, size, fixedPoints=None, baseCurve=None, parentItem=None):
        """
        Init an interactive spline with an empty set of control points and
        an empty curve
        @param size: initial path size
        @type size: int
        @param fixedPoints:
        @type fixedPoints:
        @param baseCurve: starting (initial) curve
        @type baseCurve: 2-uple of QPoint
        @param parentItem:
        @type parentItem: object
        """
        self.curveChanged = baseSignal_No()
        super().__init__(parent=parentItem)
        if fixedPoints is None:
            fixedPoints = []
        self.size = size
        # default initial curve : diagonal
        if baseCurve is None:
            baseCurve = (QPoint(0, 0), QPoint(size, -size))
        qpp = QPainterPath()
        qpp.moveTo(baseCurve[0])
        qpp.lineTo(baseCurve[1])
        # stroke curve
        stroker = QPainterPathStroker()
        stroker.setWidth(self.strokeWidth)
        self.mboundingPath = stroker.createStroke(qpp)
        self.setPath(self.mboundingPath)
        self.clicked = False
        self.setVisible(False)
        self.fixedPoints = fixedPoints
        self.__spline = []
        self.LUTXY = np.arange(256)
        self.channel = channelValues.RGB
        self.histImg = None
        # set item pen
        self.setPen(QPen(QBrush(self.brushColor), self.penWidth))

    @property
    def spline(self):
        return self.__spline

    @spline.setter
    def spline(self, data):
        """

        @param data: curve data (scene coordinates)
        @type data: list of QPointF instances
        """
        self.__spline = data

    def mousePressEvent(self, e):
        self.clicked = True

    def mouseMoveEvent(self, e):
        self.clicked = False

    def mouseReleaseEvent(self, e):
        """
        if clicked, add a control point to the curve
        """
        # click event
        if self.clicked:
            # add point
            p = e.pos()
            a = activeSplinePoint(p.x(), p.y(), parentItem=self)
            self.fixedPoints.append(a)
            self.fixedPoints.sort(key=lambda z: z.scenePos().x())
            self.updatePath()

    def initFixedPoints(self):
        """
        Add 2 boundary control points and a central one.
        """
        axeSize = self.size
        rect = QRectF(0.0, -axeSize, axeSize, axeSize)
        self.fixedPoints = [activeSplinePoint(0, 0, persistent=True, rect=rect, parentItem=self),
                            activeSplinePoint(axeSize / 2, -axeSize / 2, rect=rect, parentItem=self),
                            activeSplinePoint(axeSize, -axeSize, persistent=True, rect=rect, parentItem=self)]

    def setFixedPoints(self, points):
        for p in self.fixedPoints:
            sc = p.scene()
            if sc is not None:
                sc.removeItem(p)
        for p in points:
            p.setParentItem(self)
        self.fixedPoints = points
        self.updatePath()
        self.updateLUTXY()

    def updatePath(self):
        """
        Update and display the spline. Derived classes should
        override and call it after each control
        point or tangent modification: see
        activePoint and activeTangent mouse event handlers
        """
        pass

    def updateLUTXY(self):
        """
        Sync the LUT with the spline
        """
        scale = 255.0 / self.size
        self.LUTXY = np.array([int((-p.y()) * scale) for p in self.spline])

    def __getstate__(self):
        s = self.size
        return {'fixedpoints' : [(p.x() / s, p.y() / s) for p in self.fixedPoints]}

    def __setstate__(self, state):
        s = self.size
        fixedPoints = [activeSplinePoint(item[0] * s, item[1] * s) for item in state['fixedpoints']]
        self.setFixedPoints(fixedPoints)

    def writeToStream(self, outStream):
        outStream.writeInt32(self.size)
        outStream.writeInt32(len(self.fixedPoints))
        for point in self.fixedPoints:
            outStream << point.pos()
        return outStream

    def readFromStream(self, inStream):
        size = inStream.readInt32()
        count = inStream.readInt32()
        for point in self.childItems():
            self.scene().removeItem(point)
        self.fixedPoints = []
        for i in range(count):
            point = QPointF()
            inStream >> point
            self.fixedPoints.append(activeSplinePoint(point.x(), point.y(), parentItem=self))
        self.updatePath()
        self.updateLUTXY()
        return self

class activeBSpline(activeSpline):
    """
    Interactive displacement spline.
    """
    # To display periodic splines, the interval [0, period] is
    # by periodViewing on both sides.
    periodViewing = 50

    def __init__(self, size, period=0, yZero=0):
        """
        @param size: Spline size (scene coords)
        @type size: int
        @param period: Spline period (scene coords.) or 0
        @type period: int
        @param yZero: curve origin is (0, yZero)
        @type yZero: int
        """
        super().__init__(size, fixedPoints=None, parentItem=None, baseCurve=(QPoint(0, yZero), QPoint(size, yZero)))
        self.period = period
        self.yZero = yZero
        # x-coordinates of the  curve
        self.xCoords = np.arange(size + 2 * self.periodViewing) - self.periodViewing
        self.spline = [QPointF(x, yZero) for x in self.xCoords]  # scene coord.

    def mouseReleaseEvent(self, e):
        """
        if clicked, add a bump triangle to the curve
        """
        # click event
        if self.clicked:
            # add point
            p = e.pos()
            a = activeTriangle(p.x(), p.y(), 50, parentItem=self)
            self.fixedPoints.append(a)
            self.fixedPoints.sort(key=lambda z: z.scenePos().x())
            self.updatePath()

    def initFixedPoints(self):
        """
        Add 2 boundary control points.
        Coordinates are relative to the scene.
        """
        axeSize = self.size
        rect = QRectF(0.0, -axeSize, axeSize, axeSize)
        self.fixedPoints = [activeTriangle(50, self.yZero, 25, persistent=True, rect=rect, parentItem=self)]

    def updatePath(self):
        axeSize = self.size
        yZero = self.yZero
        try:
            X = []
            for item in self.fixedPoints:
                X.extend([item.B.x() + item.x(), item.C.x() + item.x()])
            X = np.array(X)
            Y = np.array([-(item.A.y() + item.y()) for item in self.fixedPoints]) + yZero
            T = displacementSpline(X, Y, self.xCoords,
                                   clippingInterval=[-self.scene().axeSize, 0], period=self.period)
            self.spline = [QPointF(x, y + yZero) for x, y in zip(self.xCoords, -T)]  # scene coord.
            # build path
            polygon = QPolygonF(self.spline)
            qpp = QPainterPath()
            qpp.addPolygon(polygon)
            # stroke path
            stroker = QPainterPathStroker()
            stroker.setWidth(self.strokeWidth)
            mboundingPath = stroker.createStroke(qpp)
            self.setPath(mboundingPath)
        except ValueError:
            pass

class activeCubicSpline(activeSpline):
    """
    Interactive cubic spline. Control points can be :
        - added by a mouse click on the curve,
        - moved with the mouse (cf. activePoint.mouseMoveEvent())
        - removed by a mouse click on the point (cf. activePoint.mouseReleaseEvent())
    """
    def updatePath(self):
        """
        Update and display the spline. Should be called after
        each control point or tangent modification : see
        activePoint and activeTangent mouse event handlers
        """
        # add ending control points, if needed,
        # to get full range 0..self.size
        X = [item.x() for item in self.fixedPoints]
        Y = [item.y() for item in self.fixedPoints]
        X0, X1 = X[0], X[-1]
        Y0, Y1 = Y[0], Y[-1]
        Y2 = Y0 - X0 * (Y1-Y0)/(X1-X0)
        Y3 = Y0 + (self.size - X0) * (Y1-Y0)/(X1-X0)
        if X[0] > 0.0:
            X.insert(0, 0.0)
            Y.insert(0, Y2)
        if X[-1] < self.size:
            X.append(self.size)
            Y.append(Y3)
        try:
            # interpolationCubSpline raises an exception if two points have identical x-coordinates
            self.spline = interpolationCubSpline(np.array(X), np.array(Y), clippingInterval= [-self.size, 0])     #  [-self.scene().axeSize, 0])  # TODO modified 16/02/20 validate
            # set the curve constant outside ]X0..X1[
            for P in self.spline:
                if P.x() < X0:
                    P.setY(Y0)
                elif P.x() > X1:
                    P.setY(Y1)
            # build path
            polygon = QPolygonF(self.spline)
            qpp = QPainterPath()
            qpp.addPolygon(polygon)
            # stroke path
            stroker = QPainterPathStroker()
            stroker.setWidth(self.strokeWidth)
            mboundingPath = stroker.createStroke(qpp)
            self.setPath(mboundingPath)
        except ValueError:
            pass

    def getLUTXY(self):
        """
        Returns the LUT
        @return: LUT
        @rtype: ndarray
        """
        return self.LUTXY

    def getStackedLUTXY(self):
        """
        Returns the stacked LUT (A row for each channel)
        @return: LUT
        @rtype: ndarray, shape (3,n)
        """
        if self.channel == channelValues.RGB:
            return np.vstack((self.LUTXY, self.LUTXY, self.LUTXY))
        else:
            return np.vstack((self.scene().cubicR.LUTXY, self.scene().cubicG.LUTXY, self.scene().cubicB.LUTXY))

    def reset(self):
        for point in self.childItems():
            self.scene().removeItem(point)
        self.initFixedPoints()
        # calculate spline
        self.updatePath()
        self.LUTXY = np.arange(256)

class activeQuadricSpline(activeSpline):
    """
    Interactive quadratic spline. Control points can be :
        - added by a mouse click on the curve,
        - moved with the mouse (cf. activePoint.mouseMoveEvent())
        - removed by a mouse click on the point (cf. activePoint.mouseReleaseEvent())
    """
    halfTgLen = 50.0

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fixedTangents = []
        self.hasVisibleTangents = False  # default: tangents not shown

    def setTangentsVisible(self, flag):
        """
        Toggles tangents visibility

        @param flag:
        @type flag: boolean
        """
        self.hasVisibleTangents = flag
        for t in self.fixedTangents:
            t.setVisible(flag)

    def updatePath(self, calculate=True):
        """
        Calculates (if calculate=true) and displays the spline.
        Called by activePoint and activeTangent mouse event
        @param calculate:
        @type calculate: bool
        """
        # calculate the array of slopes
        d = [item.controlPoint - item.contactPoint for item in self.fixedTangents]
        d1 = - np.array(list(map(lambda a: a.y(), d)))
        d2 = np.array(list(map(lambda a: a.x(), d)))
        d = d1 / d2
        # add boundary points if needed
        X = [item.x() for item in self.fixedPoints]
        Y = [item.y() for item in self.fixedPoints]
        X0, X1 = X[0], X[-1]
        Y0, Y1 = Y[0], Y[-1]
        t = (Y1-Y0)/(X1-X0)
        Y2 = Y0 - X0 * t
        Y3 = Y0 + (self.size - X0) * t
        d = d.tolist()
        if X[0] > 0.0:
            X.insert(0, 0.0)
            Y.insert(0, Y2)
            d.insert(0, t)
        if X[-1] < self.size:
            X.append(self.size)
            Y.append(Y3)
            d.append(t)
        d = np.array(d)
        try:
            if calculate:
                T = interpolationQuadSpline(np.array(X)/self.size, -np.array(Y)/self.size, d) * self.size
                self.spline = [QPointF(x, y) for x, y in zip(np.arange(256) * (self.size / 255.0), -T)]
            for P in self.spline:
                if P.x() < X0:
                    P.setY(Y0)
                elif P.x() > X1:
                    P.setY(Y1)
            polygon = QPolygonF(self.spline)
            qpp = QPainterPath()
            qpp.addPolygon(polygon)
            # stroke path
            stroker = QPainterPathStroker()
            stroker.setWidth(self.strokeWidth)
            mboundingPath = stroker.createStroke(qpp)
            self.setPath(mboundingPath)
        except Exception as e:
            print(str(e))

    def setCurve(self, a, b, d, T):
        """
        Initialises the spline and the LUT. See also setFixed().
        Parameters a, b, d, T  correspond to values
        returned by warpHistogram(), scaled by the size of curve axes.
        Class instance attributes self.a, self.b, self.d, self.T store
        the method parameters used in the last call,
        to enable auto curve reset. They should not be used for
        other purposes.
        a, b, d must have identical sizes.
        a, b, T values are in range 0.. self.size
        @param a: x-ccoordinates of control points
        @type a: ndarray
        @param b: y-coordinates of control points
        @type b: ndarray
        @param d: tangent slopes
        @type d: ndarray
        @param T: spline array
        @type T: ndarray, size 256
        """
        self.a, self.b, self.d, self.T = a, b, d, T
        rect = QRectF(0.0, -self.size, self.size, self.size)
        # half tangent length and orientation
        alpha = [self.halfTgLen] * len(d)
        # choose backward orientation for the last tangent
        alpha[-1] = - alpha[-1]
        for item in self.fixedPoints:
            self.scene().removeItem(item)
        for item in self.fixedTangents:
            self.scene().removeItem(item)
        self.fixedPoints = [activeSplinePoint(x, -y, rect=rect, parentItem=self) for x, y in zip(a, b)]
        # tangent normalization
        n = np.sqrt(1 + d * d)
        alpha = alpha / n  # converts the list to a ndarray
        self.fixedTangents = [activeTangent(controlPoint=QPointF(x+alpha[i], -y-alpha[i]*p),
                                            contactPoint=QPointF(x, -y), parentItem=self)
                              for i, (x, y, p) in enumerate(zip(a, b, d))]
        # link contact point to tangent
        for i1, i2 in zip(self.fixedPoints, self.fixedTangents):
            i1.tangent = i2
            i1.tangent.setVisible(self.hasVisibleTangents)
        self.spline = [QPointF(x, y) for x, y in zip(np.arange(256)*(self.size/255), -T)]
        self.updatePath(calculate=False)  # don't recalculate the spline!
        self.updateLUTXY()

    def mouseReleaseEvent(self, e):
        """
        Adds a control point to the curve
        @param e:
        """
        # click event
        if self.clicked:
            # add point
            p = e.pos()
            a = activeSplinePoint(p.x(), p.y(), parentItem=self)
            self.fixedPoints.append(a)
            self.fixedPoints.sort(key=lambda z: z.scenePos().x())
            t = activeTangent(controlPoint=p+QPointF(0.7, -0.7) * self.halfTgLen, contactPoint=p, parentItem=self)
            t.setVisible(self.hasVisibleTangents)
            a.tangent = t
            self.fixedTangents.insert(self.fixedPoints.index(a), t)
            self.updatePath()

    def getStackedLUTXY(self):
        """
        Returns the 3-channel LUT (A 1-line LUT for each channel)
        @return: LUT
        @rtype: ndarray, shape (3,n)
        """
        if self.channel == channelValues.RGB:
            return np.vstack((self.LUTXY, self.LUTXY, self.LUTXY))
        else:
            return np.vstack((self.scene().cubicR.LUTXY, self.scene().cubicG.LUTXY, self.scene().cubicB.LUTXY))

    def reset(self):
        self.setCurve(self.a, self.b, self.d, self.T)
        """
        for point in self.childItems():
            self.scene().removeItem(point)
        self.initFixedPoints()
        #calculate spline
        self.updatePath()
        LUT = range(256)
        self.LUTXY = np.array(LUT)  # buildLUT(LUT)
        """

    def setFixed(self, points, tangents):
        """
        Initialises the spline and the LUT.
        See also setCurve()
        @param points:
        @type points: list of activeSplinePoint
        @param tangents:
        @type tangents: list of activeTangent
        """
        for p in self.fixedPoints:
            sc = p.scene()
            if sc is not None:
                sc.removeItem(p)
        for p in points:
            p.setParentItem(self)
        self.fixedPoints = points
        for t in self.fixedTangents:
            sc = t.scene()
            if sc is not None:
                sc.removeItem(t)
        for t in tangents:
            t.setParentItem(self)
        for p, t in zip(points, tangents):
            p.tangent = t
        self.fixedTangents = tangents
        self.updatePath()
        self.updateLUTXY()
        # enable resetting to this curve (see method setCurve())
        self.a = np.array([p.x() for p in self.fixedPoints])
        self.b = np.array([- p.y() for p in self.fixedPoints])
        self.d = np.array([ - (t.controlPoint.y() - t.contactPoint.y()) / (t.controlPoint.x() - t.contactPoint.x())
                  for t in self.fixedTangents])
        self.T = self.LUTXY / 256 * self.size

    def __getstate__(self):
        d = {}
        s = self.size
        d['fixedpoints'] = [(p.x() / s, p.y() / s) for p in self.fixedPoints]
        d['fixedtangentcontact'] = [(p.x() / s, p.y() / s) for p in [tg.contactPoint for tg in self.fixedTangents]]
        d['fixedtangentcontrol'] = [(p.x() / s, p.y() / s) for p in [tg.controlPoint for tg in self.fixedTangents]]
        return d

    def __setstate__(self, state):
        s = self.size
        fixedPoints = [activeSplinePoint(item[0] * s, item[1] * s) for item in state['fixedpoints']]
        fixedTangents = [activeTangent(contactPoint=QPointF(item[0][0] * s, item[0][1] * s), controlPoint=QPointF(item[1][0] * s, item[1][1] * s))
                         for item in zip(state['fixedtangentcontact'], state['fixedtangentcontrol'])]
        self.setFixed(fixedPoints, fixedTangents)

class graphicsSplineItem(QGraphicsPixmapItem):
    """
    graphic spline component
    """

    def __init__(self, size=100, parentItem=None):
        super().__init__()
        self.setParentItem(parentItem)
        self.targetObject = None
        self.axeSize = size
        # background
        pxmp = QPixmap(size, size)
        pxmp.fill(Qt.lightGray)
        self.setPixmap(pxmp)
        # curve
        cubic = activeCubicSpline(size)
        cubic.setVisible(True)
        cubic.setParentItem(self)
        cubic.setPos(0, size)
        self.defaultAxes = graphicsCurveForm.drawPlotGrid(size)
        cubic.axes = self.defaultAxes
        cubic.initFixedPoints()
        self.cubic = cubic
        # graphicsScene.cubicR = cubic
        # cubic.channel = channelValues.L
        # get histogram as a Qimage

class graphicsThrSplineItem(graphicsSplineItem):
    """
     graphic spline + range slider component
    """

    def __init__(self, size=100, border=20,  parentItem=None):
        super().__init__(size=size, parentItem=parentItem)
        # brightness sliders
        self.brightnessSliderHeight = 20
        self.brightnessSliderWidth = size #+ 2 * border
        px = QPixmap(self.brightnessSliderWidth, self.brightnessSliderHeight)
        px.fill(Qt.gray)
        self.brightnessSlider = QGraphicsPixmapItem(px, parent=self)
        self.brightnessSlider.setPos(0, size + 20)
        # brightnessSlider handles
        self.brightnessThr0 = activeRsMarker.fromTriangle(parent=self.brightnessSlider, role='min')
        self.brightnessThr0.setMoveRange(
            QRectF(0.0, self.brightnessThr0.size, self.brightnessSlider.pixmap().width(), 0.0))
        self.brightnessThr0.setPos(0, self.brightnessSlider.pixmap().height() - self.brightnessThr0.size)
        self.brightnessThr0.val = 0.0
        self.brightnessThr1 = activeRsMarker.fromTriangle(parent=self.brightnessSlider, role='max')
        self.brightnessThr1.setMoveRange(
            QRectF(0.0, self.brightnessThr0.size, self.brightnessSlider.pixmap().width(), 0.0))
        self.brightnessThr1.setPos(self.brightnessSlider.pixmap().width(), self.brightnessSlider.pixmap().height() - self.brightnessThr0.size)
        self.brightnessThr1.val = 1.0

    def mousePressEvent(self, e):
        pass  # don't select on click!

class graphicsSplineForm(graphicsCurveForm):
    """
    Form for interactive cubic or quadratic spline.
    """
    @classmethod
    def getNewWindow(cls, targetImage=None, axeSize=500, layer=None, parent=None, curveType='quadric'):
        newWindow = graphicsSplineForm(targetImage=targetImage, axeSize=axeSize, layer=layer,
                                       parent=parent, curveType=curveType)
        newWindow.setWindowTitle(layer.name)
        return newWindow

    def __init__(self, targetImage=None, axeSize=500, layer=None, parent=None, curveType='quadric'):
        super().__init__(targetImage=targetImage, axeSize=axeSize, layer=layer, parent=parent)
        graphicsScene = self.scene()
        # init the curve
        if curveType == 'quadric':
            curve = activeQuadricSpline(graphicsScene.axeSize)
        else:
            curve = activeCubicSpline(graphicsScene.axeSize)
        graphicsScene.addItem(curve)
        graphicsScene.quadricB = curve
        curve.channel = channelValues.Br
        curve.histImg = graphicsScene.layer.inputImg().histogram(size=graphicsScene.axeSize, bgColor=graphicsScene.bgColor,
                                                       range=(0, 255), chans=channelValues.Br)  # , mode='Luminosity')
        curve.initFixedPoints()
        # set current curve
        graphicsScene.cubicItem = graphicsScene.quadricB
        graphicsScene.cubicItem.setVisible(True)
        self.setWhatsThis(
                        """<b>Contrast Curve</b><br>
                        Drag <b>control points</b> and <b>tangents</b> with the mouse.<br>
                        <b>Add</b> a control point by clicking on the curve.<br>
                        <b>Remove</b> a control point by clicking it.<br>
                        <b>Zoom</b> with the mouse wheel.<br>
                        """
                           )

        def onResetCurve():
            """
            Reset the selected curve
            """

        # buttons
        pushButton1 = QbLUePushButton("Reset to Auto Curve")
        pushButton1.setGeometry(10, 20, 100, 30)  # x,y,w,h
        pushButton1.clicked.connect(onResetCurve)
        if curveType == 'quadric':
            optionList1, optionNames1 = ['Show Tangents'], ['Show Tangents']
            self.listWidget1 = optionsWidget(options=optionList1, optionNames=optionNames1, exclusive=False)

            def f():
                curve.setTangentsVisible(self.listWidget1.options['Show Tangents'])

            self.listWidget1.itemClicked.connect(f)

        # layout
        gl = QHBoxLayout()
        container = self.addCommandLayout(gl)
        if curveType == 'quadric':
            gl.addWidget(self.listWidget1)
        gl.addWidget(pushButton1)
        self.adjustSize()
        self.setViewportMargins(0, 0, 0, container.height() + 15)

        graphicsScene.addWidget(pushButton1)
        self.pushButton = pushButton1

    def setButtonText(self, text):
        self.pushButton.setText(text)

    def drawBackground(self, qp, qrF):
        graphicsScene = self.scene()
        s = graphicsScene.axeSize
        if graphicsScene.cubicItem.histImg is not None:
            qp.drawImage(QRect(0, -s, s, s), graphicsScene.cubicItem.histImg)
        qp.save()
        qp.setPen(Qt.red)
        if self.baseCurve is not None:
            qp.drawPolyline(self.baseCurve)
        qp.restore()

    def updateHist(self, curve):
        """
        Updates the channel histogram displayed under the curve

        @param curve:
        @type curve:

        """
        sc = self.scene()
        curve.histImg = sc.layer.inputImg().histogram(size=sc.axeSize, bgColor=sc.bgColor, chans=[], mode='Luminosity')

    def updateHists(self):
        """
        Updates all histograms
        """
        sc = self.scene()
        self.updateHist(sc.cubicItem)
        # Force to redraw histogram
        sc.invalidate(QRectF(0.0, -sc.axeSize, sc.axeSize, sc.axeSize),
                             sc.BackgroundLayer)

    def __getstate__(self):
        return self.scene().quadricB.__getstate__()

    def __setstate__(self, state):
        self.scene().quadricB.__setstate__(state)

    def writeToStream(self, outStream):
        graphicsScene = self.scene()
        layer = graphicsScene.layer
        outStream.writeQString(layer.actionName)
        outStream.writeQString(layer.name)
        if layer.actionName in ['actionBrightness_Contrast', 'actionCurves_HSpB', 'actionCurves_Lab']:
            outStream.writeQString(self.listWidget1.selectedItems()[0].text())
            graphicsScene.quadricR.writeToStream(outStream)
            graphicsScene.quadricG.writeToStream(outStream)
            graphicsScene.quadricB.writeToStream(outStream)
        return outStream

    def readFromStream(self, inStream):
        actionName = inStream.readQString()
        name = inStream.readQString()
        sel = inStream.readQString()
        cubics = []
        graphicsScene = self.scene()
        graphicsScene.quadricR.readFromStream(inStream)
        graphicsScene.quadricG.readFromStream(inStream)
        graphicsScene.quadricB.readFromStream(inStream)
        return inStream


"""
This example introduces the use of TFLearn variables to easily implement
Tensorflow variables with custom initialization and regularization.

Note: If you are using TFLearn layers, inititalization and regularization
are directly defined at the layer definition level and applied to inner
variables.
"""

import tensorflow as tf
import tflearn
import tflearn.variables as va

# Loading MNIST dataset
import tflearn.datasets.mnist as mnist
trainX, trainY, testX, testY = mnist.load_data(one_hot=True)

# Define a dnn using Tensorflow
with tf.Graph().as_default():

    # Model variables
    X = tf.placeholder("float", [None, 784])
    Y = tf.placeholder("float", [None, 10])

    # Multilayer perceptron

with tf.variable_scope('Layer1'):
            # Creating variable using TFLearn
            W1 = va.variable(name='W', shape=[784, 256],
                             initializer='uniform_scaling',
                             regularizer='L2')
            b1 = va.variable(name='b', shape=[256])
            x = tf.nn.tanh(tf.add(tf.matmul(x, W1), b1))

        with tf.variable_scope('Layer2'):
            W2 = va.variable(name='W', shape=[256, 256],
                             initializer='uniform_scaling',
                             regularizer='L2')
            b2 = va.variable(name='b', shape=[256])
            x = tf.nn.tanh(tf.add(tf.matmul(x, W2), b2))

        with tf.variable_scope('Layer3'):
            W3 = va.variable(name='W', shape=[256, 10],
                             initializer='uniform_scaling')
            b3 = va.variable(name='b', shape=[10])
            x = tf.add(tf.matmul(x, W3), b3)

        return x

    net = dnn(X)
    loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(net, Y))
    optimizer = tf.train.GradientDescentOptimizer(learning_rate=0.1)
    accuracy = tf.reduce_mean(
        tf.cast(tf.equal(tf.argmax(net, 1), tf.argmax(Y, 1)), tf.float32),
        name='acc')

    # Define a train op
    trainop = tflearn.TrainOp(loss=loss, optimizer=optimizer,
                              metric=accuracy, batch_size=128)

    trainer = tflearn.Trainer(train_ops=trainop, tensorboard_verbose=3,
                              tensorboard_dir='/tmp/tflearn_logs/')
    trainer.fit({X: trainX, Y: trainY}, val_feed_dicts={X: testX, Y: testY},
                n_epoch=10, show_metric=True, run_id='Variables_example')


"""
This example introduces the use of TFLearn variables to easily implement
Tensorflow variables with custom initialization and regularization.

Note: If you are using TFLearn layers, inititalization and regularization
are directly defined at the layer definition level and applied to inner
variables.
"""

import tensorflow as tf
import tflearn
import tflearn.variables as va

# Loading MNIST dataset
import tflearn.datasets.mnist as mnist
trainX, trainY, testX, testY = mnist.load_data(one_hot=True)

# Define a dnn using Tensorflow
with tf.Graph().as_default():

    # Model variables
    X = tf.placeholder("float", [None, 784])
    Y = tf.placeholder("float", [None, 10])

    # Multilayer perceptron
    def dnn(x):
        with tf.variable_scope('Layer1'):
            # Creating variable using TFLearn
            W1 = va.variable(name='W', shape=[784, 256],
                             initializer='uniform_scaling',
                             regularizer='L2')
            b1 = va.variable(name='b', shape=[256])
            x = tf.nn.tanh(tf.add(tf.matmul(x, W1), b1))

        with tf.variable_scope('Layer2'):
            W2 = va.variable(name='W', shape=[256, 256],
                             initializer='uniform_scaling',
                             regularizer='L2')
            b2 = va.variable(name='b', shape=[256])
            x = tf.nn.tanh(tf.add(tf.matmul(x, W2), b2))

        with tf.variable_scope('Layer3'):
            W3 = va.variable(name='W', shape=[256, 10],
                             initializer='uniform_scaling')
            b3 = va.variable(name='b', shape=[10])
            x = tf.add(tf.matmul(x, W3), b3)

        return

    net = dnn(X)
    loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(net, Y))
    optimizer = tf.train.GradientDescentOptimizer(learning_rate=0.1)
    accuracy = tf.reduce_mean(
        tf.cast(tf.equal(tf.argmax(net, 1), tf.argmax(Y, 1)), tf.float32),
        name='acc')

    # Define a train op
    trainop = tflearn.TrainOp(loss=loss, optimizer=optimizer,
                              metric=accuracy, batch_size=128)

    trainer = tflearn.Trainer(train_ops=trainop, tensorboard_verbose=3,
                              tensorboard_dir='/tmp/tflearn_logs/')
    trainer.fit({X: trainX, Y: trainY}, val_feed_dicts={X: testX, Y: testY},
                n_epoch=10, show_metric=True, run_id='Variables_example')


# Event (cond var) is simply to complex for our dumb case
# <function Event at 0x7fc42a3681b8> 14.4261770248

__all__ = ['Ack']

from threading import Lock
from time import time, sleep

class Ack(object):
    """ Dumb (and fast, and unsafe) event replacement """

    __slots__ = ('_lock', '_is_set', '_wait_lock')

    def __init__(self):
        self._lock = Lock()
        self._is_set = None
        self._wait_lock = None

    def is_set(self):
        with self._lock:
            return self._is_set is True

with self._lock:
            if self._is_set is False and self._wait_lock:
                self._wait_lock.release()

            self._is_set = True

    def wait(self, timeout=None, probe=0.5):
        if not timeout:
            with self._lock:
                if self._is_set:
                    return True

                elif self._is_set is None:
                    self._is_set = False
                    self._wait_lock = Lock()
                    self._wait_lock.acquire()

                else:
                    raise ValueError('Already in wait state!')

            self._wait_lock.acquire()

            with self._lock:
                self._wait_lock = None
                return self._is_set is True

        else:
            with self._lock:
                if self._is_set:
                    return True

            delay = 0.00005
            prev = time()
            while timeout > 0:
                sleep(delay)
                now = time()
                timeout -= now - prev
                prev = now
                delay = min(timeout, probe, delay*2)

                with self._lock:
                    if self._is_set:
                        return True

            return False


with self._lock:
            if self._is_set is False and self._wait_lock:
                self._wait_lock.release()

            self._is_set = True

# Event (cond var) is simply to complex for our dumb case
# <function Event at 0x7fc42a3681b8> 14.4261770248

__all__ = ['Ack']

from threading import Lock
from time import time, sleep

class Ack(object):
    """ Dumb (and fast, and unsafe) event replacement """

    __slots__ = ('_lock', '_is_set', '_wait_lock')

    def __init__(self):
        self._lock = Lock()
        self._is_set = None
        self._wait_lock = None

    def is_set(self):
        with self._lock:
            return self._is_set is True

    def set(self):

    def wait(self, timeout=None, probe=0.5):
        if not timeout:
            with self._lock:
                if self._is_set:
                    return True

                elif self._is_set is None:
                    self._is_set = False
                    self._wait_lock = Lock()
                    self._wait_lock.acquire()

                else:
                    raise ValueError('Already in wait state!')

            self._wait_lock.acquire()

            with self._lock:
                self._wait_lock = None
                return self._is_set is True

        else:
            with self._lock:
                if self._is_set:
                    return True

            delay = 0.00005
            prev = time()
            while timeout > 0:
                sleep(delay)
                now = time()
                timeout -= now - prev
                prev = now
                delay = min(timeout, probe, delay*2)

                with self._lock:
                    if self._is_set:
                        return True

            return False


# Event (cond var) is simply to complex for our dumb case
# <function Event at 0x7fc42a3681b8> 14.4261770248

__all__ = ['Ack']

from threading import Lock
from time import time, sleep

class Ack(object):
    """ Dumb (and fast, and unsafe) event replacement """

    __slots__ = ('_lock', '_is_set', '_wait_lock')

    def __init__(self):
        self._lock = Loc

        self._is_set = None
        self._wait_lock = None

    def is_set(self):
        with self._lock:
            return self._is_set is True

    def set(self):
        with self._lock:
            if self._is_set is False and self._wait_lock:
                self._wait_lock.release()

            self._is_set = True

    def wait(self, timeout=None, probe=0.5):
        if not timeout:
            with self._lock:
                if self._is_set:
                    return True

                elif self._is_set is None:
                    self._is_set = False
                    self._wait_lock = Lock()
                    self._wait_lock.acquire()

                else:
                    raise ValueError('Already in wait state!')

            self._wait_lock.acquire()

            with self._lock:
                self._wait_lock = None
                return self._is_set is True

        else:
            with self._lock:
                if self._is_set:
                    return True

            delay = 0.00005
            prev = time()
            while timeout > 0:
                sleep(delay)
                now = time()
                timeout -= now - prev
                prev = now
                delay = min(timeout, probe, delay*2)

                with self._lock:
                    if self._is_set:
                        return True

            return False


while timeout > 0:
                sleep(delay)
                now = time()
                timeout -= now - prev
                prev = now
                delay = min(timeout, probe, delay*2)

                with self._lock:
                    if self._is_set:
                        return True

# Event (cond var) is simply to complex for our dumb case
# <function Event at 0x7fc42a3681b8> 14.4261770248

__all__ = ['Ack']

from threading import Lock
from time import time, sleep

class Ack(object):
    """ Dumb (and fast, and unsafe) event replacement """

    __slots__ = ('_lock', '_is_set', '_wait_lock')

    def __init__(self):
        self._lock = Lock()
        self._is_set = None
        self._wait_lock = None

    def is_set(self):
        with self._lock:
            return self._is_set is True

    def set(self):
        with self._lock:
            if self._is_set is False and self._wait_lock:
                self._wait_lock.release()

            self._is_set = True

    def wait(self, timeout=None, probe=0.5):
        if not timeout:
            with self._lock:
                if self._is_set:
                    return True

                elif self._is_set is None:
                    self._is_set = False
                    self._wait_lock = Lock()
                    self._wait_lock.acquire()

                else:
                    raise ValueError('Already in wait state!')

            self._wait_lock.acquire()

            with self._lock:
                self._wait_lock = None
                return self._is_set is True

        else:
            with self._lock:
                if self._is_set:
                    return True

            delay = 0.00005
            prev = time()


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

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


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

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

        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)