/!\ this is a huge commit /!\ starting from this commit, SàT is needs Python 3.6+ /!\ SàT maybe be instable or some feature may not work anymore, this will improve with time This patch port backend, bridge and frontends to Python 3. Roughly this has been done this way: - 2to3 tools has been applied (with python 3.7) - all references to python2 have been replaced with python3 (notably shebangs) - fixed files not handled by 2to3 (notably the shell script) - several manual fixes - fixed issues reported by Python 3 that where not handled in Python 2 - replaced "async" with "async_" when needed (it's a reserved word from Python 3.7) - replaced zope's "implements" with @implementer decorator - temporary hack to handle data pickled in database, as str or bytes may be returned, to be checked later - fixed hash comparison for password - removed some code which is not needed anymore with Python 3 - deactivated some code which needs to be checked (notably certificate validation) - tested with jp, fixed reported issues until some basic commands worked - ported Primitivus (after porting dependencies like urwid satext) - more manual fixes
530 lines
19 KiB
Python
530 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
|
|
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 _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] = 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.mbSend(
|
|
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.psNodeDelete(
|
|
str(self.item.comments_service) or "",
|
|
self.item.comments_node,
|
|
profile=self.blog.profile,
|
|
)
|
|
self.blog.host.bridge.mbRetract(
|
|
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.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 "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_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._addMBItemsWithComments((items_data, 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,
|
|
(str(own_pep),),
|
|
10,
|
|
10,
|
|
{},
|
|
{},
|
|
profile=self.profile,
|
|
callback=gotSession,
|
|
)
|
|
else:
|
|
raise NotImplementedError(
|
|
"{} 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
|