view sat_frontends/quick_frontend/quick_blog.py @ 3080:16925f494820

plugin XEP-0045: don't fail on `item-not-found` with MAM: Some servers don't store permanently the whole history of MUC with MAM. As a result, an "item-not-found" stanza error can be received when message id used as reference doesn't exist anymore on the server. When this case happens, the history is retrieved in the same way as for a newly joined room (i.e. last 50 messages are requested).
author Goffi <goffi@goffi.org>
date Thu, 05 Dec 2019 23:05:16 +0100
parents ab2696e34d29
children 9d0df638c8b4
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# helper class for making a SAT frontend
# Copyright (C) 2011-2019 Jérôme Poisson <goffi@goffi.org>

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

# from sat.core.i18n import _, D_
from sat.core.log import getLogger

log = getLogger(__name__)


from sat_frontends.quick_frontend.constants import Const as C
from sat_frontends.quick_frontend import quick_widgets
from sat_frontends.tools import jid
from sat.tools.common import data_format

try:
    # FIXME: to be removed when an acceptable solution is here
    str("")  # XXX: unicode doesn't exist in pyjamas
except (
    TypeError,
    AttributeError,
):  # Error raised is not the same depending on pyjsbuild options
    str = str

ENTRY_CLS = None
COMMENTS_CLS = None


class Item(object):
    """Manage all (meta)data of an item"""

    def __init__(self, data):
        """
        @param data(dict): microblog data as return by bridge methods
            if data values are not defined, set default values
        """
        self.id = data["id"]
        self.title = data.get("title")
        self.title_rich = None
        self.title_xhtml = data.get("title_xhtml")
        self.tags = data.get('tags', [])
        self.content = data.get("content")
        self.content_rich = None
        self.content_xhtml = data.get("content_xhtml")
        self.author = data["author"]
        try:
            author_jid = data["author_jid"]
            self.author_jid = jid.JID(author_jid) if author_jid else None
        except KeyError:
            self.author_jid = None

        self.author_verified = data.get("author_jid_verified", False)

        try:
            self.updated = float(
                data["updated"]
            )  # XXX: int doesn't work here (pyjamas bug)
        except KeyError:
            self.updated = None

        try:
            self.published = float(
                data["published"]
            )  # XXX: int doesn't work here (pyjamas bug)
        except KeyError:
            self.published = None

        self.comments = data.get("comments")
        try:
            self.comments_service = jid.JID(data["comments_service"])
        except KeyError:
            self.comments_service = None
        self.comments_node = data.get("comments_node")

    # def loadComments(self):
    #     """Load all the comments"""
    #     index = str(main_entry.comments_count - main_entry.hidden_count)
    #     rsm = {'max': str(main_entry.hidden_count), 'index': index}
    #     self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm)


class EntriesManager(object):
    """Class which manages list of (micro)blog entries"""

    def __init__(self, manager):
        """
        @param manager (EntriesManager, None): parent EntriesManager
            must be None for QuickBlog (and only for QuickBlog)
        """
        self.manager = manager
        if manager is None:
            self.blog = self
        else:
            self.blog = manager.blog
        self.entries = []
        self.edit_entry = None

    @property
    def level(self):
        """indicate how deep is this entry in the tree

        if level == -1, we have a QuickBlog
        if level == 0, we have a main item
        else we have a comment
        """
        level = -1
        manager = self.manager
        while manager is not None:
            level += 1
            manager = manager.manager
        return level

    def _addMBItems(self, items_tuple, service=None, node=None):
        """Add Microblog items to this panel
        update is NOT called after addition

        @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGet
        """
        items, metadata = items_tuple
        for item in items:
            self.addEntry(item, service=service, node=node, with_update=False)

    def _addMBItemsWithComments(self, items_tuple, service=None, node=None):
        """Add Microblog items to this panel
        update is NOT called after addition

        @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGet
        """
        items, metadata = items_tuple
        for item, comments in items:
            self.addEntry(item, comments, service=service, node=node, with_update=False)

    def addEntry(self, item=None, comments=None, service=None, node=None,
                 with_update=True, editable=False, edit_entry=False):
        """Add a microblog entry

        @param editable (bool): True if the entry can be modified
        @param item (dict, None): blog item data, or None for an empty entry
        @param comments (list, None): list of comments data if available
        @param service (jid.JID, None): service where the entry is coming from
        @param service (unicode, None): node hosting the entry
        @param with_update (bool): if True, udpate is called with the new entry
        @param edit_entry(bool): if True, will be in self.edit_entry instead of
            self.entries, so it can be managed separately (e.g. first or last
            entry regardless of sorting)
        """
        new_entry = ENTRY_CLS(self, item, comments, service=service, node=node)
        new_entry.setEditable(editable)
        if edit_entry:
            self.edit_entry = new_entry
        else:
            self.entries.append(new_entry)
        if with_update:
            self.update()
        return new_entry

    def update(self, entry=None):
        """Update the display with entries

        @param entry (Entry, None): if not None, must be the new entry.
            If None, all the items will be checked to update the display
        """
        # update is separated from addEntry to allow adding
        # several entries at once, and updating at the end
        raise NotImplementedError


class Entry(EntriesManager):
    """Graphical representation of an Item
    This class must be overriden by frontends"""

    def __init__(
        self, manager, item_data=None, comments_data=None, service=None, node=None
    ):
        """
        @param blog(QuickBlog): the parent QuickBlog
        @param manager(EntriesManager): the parent EntriesManager
        @param item_data(dict, None): dict containing the blog item data, or None for an empty entry
        @param comments_data(list, None): list of comments data
        """
        assert manager is not None
        EntriesManager.__init__(self, manager)
        self.service = service
        self.node = node
        self.editable = False
        self.reset(item_data)
        self.blog.id2entries[self.item.id] = self
        if self.item.comments:
            node_tuple = (self.item.comments_service, self.item.comments_node)
            self.blog.node2entries.setdefault(node_tuple, []).append(self)

    def reset(self, item_data):
        """Reset the entry with given data

        used during init (it's a set and not a reset then)
        or later (e.g. message sent, or cancellation of an edition
        @param idem_data(dict, None): data as in __init__
        """
        if item_data is None:
            self.new = True
            item_data = {
                "id": None,
                # TODO: find a best author value
                "author": self.blog.host.whoami.node,
            }
        else:
            self.new = False
        self.item = Item(item_data)
        self.author_jid = self.blog.host.whoami.bare if self.new else self.item.author_jid
        if self.author_jid is None and self.service and self.service.node:
            self.author_jid = self.service
        self.mode = (
            C.ENTRY_MODE_TEXT if self.item.content_xhtml is None else C.ENTRY_MODE_XHTML
        )

    def refresh(self):
        """Refresh the display when data have been modified"""
        pass

    def setEditable(self, editable=True):
        """tell if the entry can be edited or not

        @param editable(bool): True if the entry can be edited
        """
        # XXX: we don't use @property as property setter doesn't play well with pyjamas
        raise NotImplementedError

    def addComments(self, comments_data):
        """Add comments to this entry by calling addEntry repeatidly

        @param comments_data(tuple): data as returned by mbGetFromMany*RTResults
        """
        # TODO: manage seperator between comments of coming from different services/nodes
        for data in comments_data:
            service, node, failure, comments, metadata = data
            for comment in comments:
                if not failure:
                    self.addEntry(comment, service=jid.JID(service), node=node)
                else:
                    log.warning("getting comment failed: {}".format(failure))
        self.update()

    def send(self):
        """Send entry according to parent QuickBlog configuration and current level"""

        # keys to keep other than content*, title* and tag*
        # FIXME: see how to avoid comments node hijacking (someone could bind his post to another post's comments node)
        keys_to_keep = ("id", "comments", "author", "author_jid", "published")

        mb_data = {}
        for key in keys_to_keep:
            value = getattr(self.item, key)
            if value is not None:
                mb_data[key] = str(value)

        for prefix in ("content", "title"):
            for suffix in ("", "_rich", "_xhtml"):
                name = "{}{}".format(prefix, suffix)
                value = getattr(self.item, name)
                if value is not None:
                    mb_data[name] = value

        mb_data['tags'] = self.item.tags

        if self.blog.new_message_target not in (C.PUBLIC, C.GROUP):
            raise NotImplementedError

        if self.level == 0:
            mb_data["allow_comments"] = True

        if self.blog.new_message_target == C.GROUP:
            mb_data['groups'] = list(self.blog.targets)

        self.blog.host.bridge.mbSend(
            str(self.service or ""),
            self.node or "",
            data_format.serialise(mb_data),
            profile=self.blog.profile,
        )

    def delete(self):
        """Remove this Entry from parent manager

        This doesn't delete any entry in PubSub, just locally
        all children entries will be recursively removed too
        """
        # XXX: named delete and not remove to avoid conflict with pyjamas
        log.debug("deleting entry {}".format("EDIT ENTRY" if self.new else self.item.id))
        for child in self.entries:
            child.delete()
        try:
            self.manager.entries.remove(self)
        except ValueError:
            if self != self.manager.edit_entry:
                log.error("Internal Error: entry not found in manager")
            else:
                self.manager.edit_entry = None
        if not self.new:
            # we must remove references to self
            # in QuickBlog's dictionary
            del self.blog.id2entries[self.item.id]
            if self.item.comments:
                comments_tuple = (self.item.comments_service, self.item.comments_node)
                other_entries = self.blog.node2entries[comments_tuple].remove(self)
                if not other_entries:
                    del self.blog.node2entries[comments_tuple]

    def retract(self):
        """Retract this item from microblog node

        if there is a comments node, it will be purged too
        """
        # TODO: manage several comments nodes case.
        if self.item.comments:
            self.blog.host.bridge.psNodeDelete(
                str(self.item.comments_service) or "",
                self.item.comments_node,
                profile=self.blog.profile,
            )
        self.blog.host.bridge.mbRetract(
            str(self.service or ""),
            self.node or "",
            self.item.id,
            profile=self.blog.profile,
        )


class QuickBlog(EntriesManager, quick_widgets.QuickWidget):
    def __init__(self, host, targets, profiles=None):
        """Panel used to show microblog

        @param targets (tuple(unicode)): contact groups displayed in this panel.
            If empty, show all microblogs from all contacts. targets is also used
            to know where to send new messages.
        """
        EntriesManager.__init__(self, None)
        self.id2entries = {}  # used to find an entry with it's item id
        # must be kept up-to-date by Entry
        self.node2entries = {}  # same as above, values are lists in case of
        # two entries link to the same comments node
        if not targets:
            targets = ()  # XXX: we use empty tuple instead of None to workaround a pyjamas bug
            quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE)
            self._targets_type = C.ALL
        else:
            assert isinstance(targets[0], str)
            quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE)
            for target in targets[1:]:
                assert isinstance(target, str)
                self.addTarget(target)
            self._targets_type = C.GROUP

    @property
    def new_message_target(self):
        if self._targets_type == C.ALL:
            return C.PUBLIC
        elif self._targets_type == C.GROUP:
            return C.GROUP
        else:
            raise ValueError("Unkown targets type")

    def __str__(self):
        return "Blog Widget [target: {}, profile: {}]".format(
            ", ".join(self.targets), self.profile
        )

    def _getResultsCb(self, data, rt_session):
        remaining, results = data
        log.debug(
            "Got {got_len} results, {rem_len} remaining".format(
                got_len=len(results), rem_len=remaining
            )
        )
        for result in results:
            service, node, failure, items_data, metadata = result
            for item_data in items_data:
                item_data[0] = data_format.deserialise(item_data[0])
                for item_metadata in item_data[1]:
                    item_metadata[3] = [data_format.deserialise(i) for i in item_metadata[3]]
            if not failure:
                self._addMBItemsWithComments((items_data, metadata),
                                             service=jid.JID(service))

        self.update()
        if remaining:
            self._getResults(rt_session)

    def _getResultsEb(self, failure):
        log.warning("microblog getFromMany error: {}".format(failure))

    def _getResults(self, rt_session):
        """Manage results from mbGetFromMany RT Session

        @param rt_session(str): session id as returned by mbGetFromMany
        """
        self.host.bridge.mbGetFromManyWithCommentsRTResult(
            rt_session,
            profile=self.profile,
            callback=lambda data: self._getResultsCb(data, rt_session),
            errback=self._getResultsEb,
        )

    def getAll(self):
        """Get all (micro)blogs from self.targets"""

        def gotSession(rt_session):
            self._getResults(rt_session)

        if self._targets_type in (C.ALL, C.GROUP):
            targets = tuple(self.targets) if self._targets_type is C.GROUP else ()
            self.host.bridge.mbGetFromManyWithComments(
                self._targets_type,
                targets,
                10,
                10,
                {},
                {"subscribe": C.BOOL_TRUE},
                profile=self.profile,
                callback=gotSession,
            )
            own_pep = self.host.whoami.bare
            self.host.bridge.mbGetFromManyWithComments(
                C.JID,
                (str(own_pep),),
                10,
                10,
                {},
                {},
                profile=self.profile,
                callback=gotSession,
            )
        else:
            raise NotImplementedError(
                "{} target type is not managed".format(self._targets_type)
            )

    def isJidAccepted(self, jid_):
        """Tell if a jid is actepted and must be shown in this panel

        @param jid_(jid.JID): jid to check
        @return: True if the jid is accepted
        """
        if self._targets_type == C.ALL:
            return True
        assert self._targets_type is C.GROUP  # we don't manage other types for now
        for group in self.targets:
            if self.host.contact_lists[self.profile].isEntityInGroup(jid_, group):
                return True
        return False

    def addEntryIfAccepted(self, service, node, mb_data, groups, profile):
        """add entry to this panel if it's acceptable

        This method check if the entry is new or an update,
        if it below to a know node, or if it acceptable anyway
        @param service(jid.JID): jid of the emitting pubsub service
        @param node(unicode): node identifier
        @param mb_data: microblog data
        @param groups(list[unicode], None): groups which can receive this entry
            None to accept everything
        @param profile: %(doc_profile)s
        """
        try:
            entry = self.id2entries[mb_data["id"]]
        except KeyError:
            # The entry is new
            try:
                parent_entries = self.node2entries[(service, node)]
            except:
                # The node is unknown,
                # we need to check that we can accept the entry
                if (
                    self.isJidAccepted(service)
                    or (
                        groups is None
                        and service == self.host.profiles[self.profile].whoami.bare
                    )
                    or (groups and groups.intersection(self.targets))
                ):
                    self.addEntry(mb_data, service=service, node=node)
            else:
                # the entry is a comment in a known node
                for parent_entry in parent_entries:
                    parent_entry.addEntry(mb_data, service=service, node=node)
        else:
            # The entry exist, it's an update
            entry.reset(mb_data)
            entry.refresh()

    def deleteEntryIfPresent(self, service, node, item_id, profile):
        """Delete and entry if present in this QuickBlog

        @param sender(jid.JID): jid of the entry sender
        @param mb_data: microblog data
        @param service(jid.JID): sending service
        @param node(unicode): hosting node
        """
        try:
            entry = self.id2entries[item_id]
        except KeyError:
            pass
        else:
            entry.delete()


def registerClass(type_, cls):
    global ENTRY_CLS, COMMENTS_CLS
    if type_ == "ENTRY":
        ENTRY_CLS = cls
    elif type == "COMMENT":
        COMMENTS_CLS = cls
    else:
        raise ValueError("type_ should be ENTRY or COMMENT")
    if COMMENTS_CLS is None:
        COMMENTS_CLS = ENTRY_CLS