536 lines
19 KiB
Python
536 lines
19 KiB
Python
#!/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
|
|
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
|