view libervia/frontends/quick_frontend/quick_blog.py @ 4195:22cd3094cd1e

docker (demo): fix starting + better UX: - Use the new `wait_for_service` option to be sure that Prosody is launched. - Use the new `init_script_path` option to initiate forums and bookmark a demo room. - Activate MUC service. - Change log formats to make them more readable.
author Goffi <goffi@goffi.org>
date Wed, 13 Dec 2023 22:00:25 +0100
parents 26b7ed2817da
children 0d7bb4df2343
line wrap: on
line source

#!/usr/bin/env python3


# helper class for making a SAT frontend
# Copyright (C) 2011-2021 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 libervia.backend.core.log import getLogger

log = getLogger(__name__)


from libervia.frontends.quick_frontend.constants import Const as C
from libervia.frontends.quick_frontend import quick_widgets
from libervia.frontends.tools import jid
from libervia.backend.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 _add_mb_items(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 mb_get
        """
        items, metadata = items_tuple
        for item in items:
            self.add_entry(item, service=service, node=node, with_update=False)

    def _add_mb_items_with_comments(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 mb_get
        """
        items, metadata = items_tuple
        for item, comments in items:
            self.add_entry(item, comments, service=service, node=node, with_update=False)

    def add_entry(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.set_editable(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 add_entry 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 set_editable(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 add_comments(self, comments_data):
        """Add comments to this entry by calling add_entry repeatidly

        @param comments_data(tuple): data as returned by mb_get_from_many*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.add_entry(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.mb_send(
            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.ps_node_delete(
                str(self.item.comments_service) or "",
                self.item.comments_node,
                profile=self.blog.profile,
            )
        self.blog.host.bridge.mb_retract(
            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.add_target(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 _get_results_cb(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._add_mb_items_with_comments((items_data, metadata),
                                             service=jid.JID(service))

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

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

    def _get_results(self, rt_session):
        """Manage results from mb_get_from_many RT Session

        @param rt_session(str): session id as returned by mb_get_from_many
        """
        self.host.bridge.mb_get_from_many_with_comments_rt_result(
            rt_session,
            profile=self.profile,
            callback=lambda data: self._get_results_cb(data, rt_session),
            errback=self._get_results_eb,
        )

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

        def got_session(rt_session):
            self._get_results(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.mb_get_from_many_with_comments(
                self._targets_type,
                targets,
                10,
                10,
                {},
                {"subscribe": C.BOOL_TRUE},
                profile=self.profile,
                callback=got_session,
            )
            own_pep = self.host.whoami.bare
            self.host.bridge.mb_get_from_many_with_comments(
                C.JID,
                (str(own_pep),),
                10,
                10,
                {},
                {},
                profile=self.profile,
                callback=got_session,
            )
        else:
            raise NotImplementedError(
                "{} target type is not managed".format(self._targets_type)
            )

    def is_jid_accepted(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].is_entity_in_group(jid_, group):
                return True
        return False

    def add_entry_if_accepted(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.is_jid_accepted(service)
                    or (
                        groups is None
                        and service == self.host.profiles[self.profile].whoami.bare
                    )
                    or (groups and groups.intersection(self.targets))
                ):
                    self.add_entry(mb_data, service=service, node=node)
            else:
                # the entry is a comment in a known node
                for parent_entry in parent_entries:
                    parent_entry.add_entry(mb_data, service=service, node=node)
        else:
            # The entry exist, it's an update
            entry.reset(mb_data)
            entry.refresh()

    def delete_entry_if_present(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 register_class(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