# HG changeset patch # User Goffi # Date 1439679654 -7200 # Node ID 9fce331ba0fdee9e6a0191279be816764e8ba807 # Parent c7fd121a61804fa348ace438890aa5ac62f1b365 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 diff -r c7fd121a6180 -r 9fce331ba0fd frontends/src/quick_frontend/constants.py --- 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' diff -r c7fd121a6180 -r 9fce331ba0fd frontends/src/quick_frontend/quick_app.py --- 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 diff -r c7fd121a6180 -r 9fce331ba0fd frontends/src/quick_frontend/quick_blog.py --- /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 + +# 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 . + +# 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