changeset 1461:9fce331ba0fd

quick_frontend (constants, quick_app, quick_contact_list): blogging refactoring (not finished): - adaptation to backend modifications - moved common blogging parts from Libervia to quick frontend - QuickApp use a MB_HANDLE class variable to indicated if blogging is managed by the frontend (and avoid waste of resources if not) - comments are now managed inside parent entry, as a result a tree of comments is now possible - Entry.level indicate where in the tree we are (-1 mean parent QuickBlog, 0 mean main item, more means comment) - items + comments are requested in 1 shot, and RTDeferred are used to avoid blocking while waiting for them - in QuickBlog, id2entries allow to get Entry from it's item id, and node2entries allow to get Entry(ies) hosting a comments node - QuickBlog.new_message_target tell where a new message will be sent by default
author Goffi <goffi@goffi.org>
date Sun, 16 Aug 2015 01:00:54 +0200
parents c7fd121a6180
children c74015dc2785
files frontends/src/quick_frontend/constants.py frontends/src/quick_frontend/quick_app.py frontends/src/quick_frontend/quick_blog.py
diffstat 3 files changed, 494 insertions(+), 7 deletions(-) [+]
line wrap: on
line diff
--- a/frontends/src/quick_frontend/constants.py	Sun Aug 16 00:41:58 2015 +0200
+++ b/frontends/src/quick_frontend/constants.py	Sun Aug 16 01:00:54 2015 +0200
@@ -61,6 +61,11 @@
         "paused": u"⦷"
     }
 
+    # Blogs
+    ENTRY_MODE_TEXT = "text"
+    ENTRY_MODE_RICH = "rich"
+    ENTRY_MODE_XHTML = "xhtml"
+
     # Widgets management
     # FIXME: should be in quick_frontend.constant, but Libervia doesn't inherit from it
     WIDGET_NEW = 'NEW'
--- a/frontends/src/quick_frontend/quick_app.py	Sun Aug 16 00:41:58 2015 +0200
+++ b/frontends/src/quick_frontend/quick_app.py	Sun Aug 16 01:00:54 2015 +0200
@@ -27,6 +27,7 @@
 from sat_frontends.tools import jid
 from sat_frontends.quick_frontend import quick_widgets
 from sat_frontends.quick_frontend import quick_menus
+from sat_frontends.quick_frontend import quick_blog
 from sat_frontends.quick_frontend import quick_chat, quick_games
 from sat_frontends.quick_frontend.constants import Const as C
 
@@ -196,6 +197,7 @@
 
 class QuickApp(object):
     """This class contain the main methods needed for the frontend"""
+    MB_HANDLE = True # Set to false if the frontend doesn't manage microblog
 
     def __init__(self, create_bridge, check_options=None):
         """Create a frontend application
@@ -254,7 +256,7 @@
         self.registerSignal("roomUserChangedNick", iface="plugin")
         self.registerSignal("roomNewSubject", iface="plugin")
         self.registerSignal("chatStateReceived", iface="plugin")
-        self.registerSignal("personalEvent", iface="plugin")
+        self.registerSignal("psEvent", iface="plugin")
 
         # FIXME: do it dynamically
         quick_games.Tarot.registerSignals(self)
@@ -607,15 +609,42 @@
                 contact_list.setCache(from_jid, 'chat_state', to_display)
                 widget.update(from_jid)
 
-    def personalEventHandler(self, sender, event_type, data, profile):
-        """Called when a PEP event is received.
+    def psEventHandler(self, category, service_s, node, event_type, data, profile):
+        """Called when a PubSub event is received.
 
-        @param sender (jid.JID): event sender
-        @param event_type (unicode): event type, e.g. 'MICROBLOG' or 'MICROBLOG_DELETE'
+        @param category(unicode): event category (e.g. "PEP", "MICROBLOG")
+        @param service_s (unicode): pubsub service
+        @param node (unicode): pubsub node
+        @param event_type (unicode): event type (one of C.PUBLISH, C.RETRACT, C.DELETE)
         @param data (dict): event data
         """
-        # FIXME move some code from Libervia to here and put the magic strings to constants
-        pass
+        service_s = jid.JID(service_s)
+
+        if category == C.PS_MICROBLOG and self.MB_HANDLE:
+            if event_type == C.PS_PUBLISH:
+                if not 'content' in data:
+                    log.warning("No content found in microblog data")
+                    return
+                if 'groups' in data:
+                    _groups = set(data['groups'].split() if data['groups'] else [])
+                else:
+                    _groups = None
+
+                for wid in self.widgets.getWidgets(quick_blog.QuickBlog):
+                    wid.addEntryIfAccepted(service_s, node, data, _groups, profile)
+
+                try:
+                    comments_node, comments_service = data['comments_node'], data['comments_service']
+                except KeyError:
+                    pass
+                else:
+                    self.bridge.mbGetLast(comments_service, comments_node, C.NO_LIMIT, {"subscribe":C.BOOL_TRUE}, profile=profile)
+            elif event_type == C.PS_RETRACT:
+                for wid in self.widgets.getWidgets(quick_blog.QuickBlog):
+                    wid.deleteEntryIfPresent(service_s, node, data['id'], profile)
+                pass
+            else:
+                log.warning("Unmanaged PubSub event type {}".format(event_type))
 
     def _subscribe_cb(self, answer, data):
         entity, profile = data
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/frontends/src/quick_frontend/quick_blog.py	Sun Aug 16 01:00:54 2015 +0200
@@ -0,0 +1,453 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# helper class for making a SAT frontend
+# Copyright (C) 2011, 2012, 2013, 2014, 2015 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
+
+try:
+    # FIXME: to be removed when an acceptable solution is here
+    unicode('') # XXX: unicode doesn't exist in pyjamas
+except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options
+    unicode = str
+
+ENTRY_CLS = None
+COMMENTS_CLS = None
+
+
+class Item(object):
+    """Manage all (meta)data of an item"""
+
+    def __init__(self, data):
+        """
+        @param data(dict, None): microblog data as return by bridge methods
+            if data is None, set a default values
+        """
+        self.id = data['id']
+        self.title = data.get('title')
+        self.title_rich = None
+        self.title_xhtml = data.get('title_xhtml')
+        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
+
+        try:
+            self.author_verified = C.bool(data['author_jid_verified'])
+        except KeyError:
+            self.author_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._first_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 mbGetLast
+        """
+        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 mbGetLast
+        """
+        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, first=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 first(bool): if True, will be the first entry regardless of sorting
+        """
+        new_entry = ENTRY_CLS(self, item, comments, service=service, node=node)
+        new_entry.setEditable(editable)
+        if first:
+            self._first_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): 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(semf):
+        """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 other to keep other than content* and title*
+        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] = unicode(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
+
+        if self.level == 0:
+            if self.blog.new_message_target == C.PUBLIC:
+                if self.new:
+                    mb_data["allow_comments"] = C.BOOL_TRUE
+            else:
+                raise NotImplementedError
+
+        self.blog.host.bridge.mbSend(
+            unicode(self.service or ''),
+            self.node or '',
+            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(u"deleting entry {}".format('EDIT ENTRY' if self.new else self.item.id))
+        for child in self.entries:
+            child.delete()
+        self.manager.entries.remove(self)
+        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.psDeleteNode(unicode(self.item.comments_service) or "", self.item.comments_node, profile=self.blog.profile)
+        self.blog.host.bridge.mbRetract(unicode(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], basestring)
+            quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE)
+            for target in targets[1:]:
+                assert isinstance(target, basestring)
+                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 self.targets
+        else:
+            raise ValueError("Unkown targets type")
+
+    def __str__(self):
+        return u"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, metadata = result
+            if not failure:
+                self._addMBItemsWithComments((items, 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 == C.ALL:
+            self.host.bridge.mbGetFromManyWithComments(C.ALL, (), 10, 10, {}, {"subscribe":C.BOOL_TRUE}, profile=self.profile, callback=gotSession)
+            own_pep = self.host.whoami.bare
+            self.host.bridge.mbGetFromManyWithComments(C.JID, (unicode(own_pep),), 10, 10, {}, {}, profile=self.profile, callback=gotSession)
+
+    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