Mercurial > libervia-backend
diff libervia/frontends/quick_frontend/quick_blog.py @ 4074:26b7ed2817da
refactoring: rename `sat_frontends` to `libervia.frontends`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 14:12:38 +0200 |
parents | sat_frontends/quick_frontend/quick_blog.py@4b842c1fb686 |
children | 0d7bb4df2343 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/frontends/quick_frontend/quick_blog.py Fri Jun 02 14:12:38 2023 +0200 @@ -0,0 +1,530 @@ +#!/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