diff sat_frontends/quick_frontend/quick_blog.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents frontends/src/quick_frontend/quick_blog.py@0046283a285d
children 56f94936df1e
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat_frontends/quick_frontend/quick_blog.py	Mon Apr 02 19:44:50 2018 +0200
@@ -0,0 +1,471 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8 -*-
+
+# helper class for making a SAT frontend
+# Copyright (C) 2011-2018 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
+    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): 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 = list(data_format.dict2iter('tag', data))
+        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.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] = 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
+
+        data_format.iter2dict('tag', self.item.tags, mb_data)
+
+        if self.blog.new_message_target not in (C.PUBLIC, C.GROUP):
+            raise NotImplementedError
+
+        if self.level == 0:
+            mb_data["allow_comments"] = C.BOOL_TRUE
+
+        if self.blog.new_message_target == C.GROUP:
+            data_format.iter2dict('group', self.blog.targets, mb_data)
+
+        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()
+        try:
+            self.manager.entries.remove(self)
+        except ValueError:
+            if self != self.manager.edit_entry:
+                log.error(u"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(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 C.GROUP
+        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 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, (unicode(own_pep),), 10, 10, {}, {}, profile=self.profile, callback=gotSession)
+        else:
+            raise NotImplementedError(u'{} 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