Mercurial > libervia-backend
view libervia/frontends/quick_frontend/quick_blog.py @ 4306:94e0968987cd
plugin XEP-0033: code modernisation, improve delivery, data validation:
- Code has been rewritten using Pydantic models and `async` coroutines for data validation
and cleaner element parsing/generation.
- Delivery has been completely rewritten. It now works even if server doesn't support
multicast, and send to local multicast service first. Delivering to local multicast
service first is due to bad support of XEP-0033 in server (notably Prosody which has an
incomplete implementation), and the current impossibility to detect if a sub-domain
service handles fully multicast or only for local domains. This is a workaround to have
a good balance between backward compatilibity and use of bandwith, and to make it work
with the incoming email gateway implementation (the gateway will only deliver to
entities of its own domain).
- disco feature checking now uses `async` corountines. `host` implementation still use
Deferred return values for compatibility with legacy code.
rel 450
author | Goffi <goffi@goffi.org> |
---|---|
date | Thu, 26 Sep 2024 16:12:01 +0200 |
parents | 0d7bb4df2343 |
children |
line wrap: on
line source
#!/usr/bin/env python3 # helper class for making a SAT frontend # Copyright (C) 2011-2021 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 libervia.backend.core.log import getLogger log = getLogger(__name__) from libervia.frontends.quick_frontend.constants import Const as C from libervia.frontends.quick_frontend import quick_widgets from libervia.frontends.tools import jid from libervia.backend.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 _add_mb_items(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 mb_get """ items, metadata = items_tuple for item in items: self.add_entry(item, service=service, node=node, with_update=False) def _add_mb_items_with_comments(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 mb_get """ items, metadata = items_tuple for item, comments in items: self.add_entry(item, comments, service=service, node=node, with_update=False) def add_entry( 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.set_editable(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 add_entry 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 set_editable(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 add_comments(self, comments_data): """Add comments to this entry by calling add_entry repeatidly @param comments_data(tuple): data as returned by mb_get_from_many*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.add_entry(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.mb_send( 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.ps_node_delete( str(self.item.comments_service) or "", self.item.comments_node, profile=self.blog.profile, ) self.blog.host.bridge.mb_retract( 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.add_target(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 _get_results_cb(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._add_mb_items_with_comments( (items_data, metadata), service=jid.JID(service) ) self.update() if remaining: self._get_results(rt_session) def _get_results_eb(self, failure): log.warning("microblog get_from_many error: {}".format(failure)) def _get_results(self, rt_session): """Manage results from mb_get_from_many RT Session @param rt_session(str): session id as returned by mb_get_from_many """ self.host.bridge.mb_get_from_many_with_comments_rt_result( rt_session, profile=self.profile, callback=lambda data: self._get_results_cb(data, rt_session), errback=self._get_results_eb, ) def get_all(self): """Get all (micro)blogs from self.targets""" def got_session(rt_session): self._get_results(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.mb_get_from_many_with_comments( self._targets_type, targets, 10, 10, {}, {"subscribe": C.BOOL_TRUE}, profile=self.profile, callback=got_session, ) own_pep = self.host.whoami.bare self.host.bridge.mb_get_from_many_with_comments( C.JID, (str(own_pep),), 10, 10, {}, {}, profile=self.profile, callback=got_session, ) else: raise NotImplementedError( "{} target type is not managed".format(self._targets_type) ) def is_jid_accepted(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].is_entity_in_group(jid_, group): return True return False def add_entry_if_accepted(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.is_jid_accepted(service) or ( groups is None and service == self.host.profiles[self.profile].whoami.bare ) or (groups and groups.intersection(self.targets)) ): self.add_entry(mb_data, service=service, node=node) else: # the entry is a comment in a known node for parent_entry in parent_entries: parent_entry.add_entry(mb_data, service=service, node=node) else: # The entry exist, it's an update entry.reset(mb_data) entry.refresh() def delete_entry_if_present(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 register_class(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